mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
train 3d visualizations
This commit is contained in:
@@ -13,14 +13,22 @@ interface Train3DVisualizationProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CONTAINER_PALETTE = [0xc0392b, 0x2471a3, 0x1e8449, 0xd4ac0d, 0x884ea0, 0xca6f1e, 0x117a65, 0x6e2c00];
|
||||
// shipping-line style colors — no purple
|
||||
const CONTAINER_PALETTE = [0xb63a2b, 0x1f6fb2, 0x1e8449, 0xd9a213, 0xd35f1e, 0x11707f, 0x7a3b1e, 0x35506e];
|
||||
|
||||
const hashColor = (key: string) => {
|
||||
// wagon body color keyed by wagon type — same type = same color across the train
|
||||
const WAGON_TYPE_PALETTE = [0x2e6f8e, 0xb0722d, 0x5c7f3b, 0x8e3b3b, 0x3b6e63, 0x8a6d2f, 0x505a7d, 0x6d5540];
|
||||
const wagonTypeColor = (w: Wagon, fallback: number) =>
|
||||
w.wagonType?.code ? WAGON_TYPE_PALETTE[Math.abs(hash32(w.wagonType.code)) % WAGON_TYPE_PALETTE.length] : fallback;
|
||||
|
||||
const hash32 = (key: string) => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
|
||||
return CONTAINER_PALETTE[Math.abs(h) % CONTAINER_PALETTE.length];
|
||||
return h;
|
||||
};
|
||||
|
||||
const hashColor = (key: string) => CONTAINER_PALETTE[Math.abs(hash32(key)) % CONTAINER_PALETTE.length];
|
||||
|
||||
type WagonKind = "container" | "bulk" | "tank" | "flat";
|
||||
|
||||
const wagonKind = (w: Wagon): WagonKind => {
|
||||
@@ -74,7 +82,7 @@ function makeLabelSprite(text: string, bg: string): THREE.Sprite {
|
||||
|
||||
function buildClouds(scene: THREE.Scene, rng: () => number) {
|
||||
const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, transparent: true, opacity: 0.92 });
|
||||
for (let i = 0; i < 14; i++) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const cloud = new THREE.Group();
|
||||
const puffs = 3 + Math.floor(rng() * 4);
|
||||
for (let p = 0; p < puffs; p++) {
|
||||
@@ -83,66 +91,103 @@ function buildClouds(scene: THREE.Scene, rng: () => number) {
|
||||
puff.scale.y = 0.55;
|
||||
cloud.add(puff);
|
||||
}
|
||||
cloud.position.set((rng() - 0.5) * 1400, 90 + rng() * 60, (rng() - 0.5) * 800);
|
||||
// high and pushed to the sides so they never sit between camera and train
|
||||
cloud.position.set((rng() - 0.5) * 1800, 220 + rng() * 80, (rng() > 0.5 ? 1 : -1) * (350 + rng() * 400));
|
||||
scene.add(cloud);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(rng: () => number): THREE.Group {
|
||||
function buildAcacia(rng: () => number): THREE.Group {
|
||||
// flat-topped acacia
|
||||
const tree = new THREE.Group();
|
||||
const trunkH = 2 + rng() * 2;
|
||||
const trunk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.25, 0.4, trunkH, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x6e4a2f, roughness: 1 }),
|
||||
);
|
||||
const trunkH = 2.6 + rng() * 2;
|
||||
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x7a5230, roughness: 1 });
|
||||
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.35, trunkH, 7), trunkMat);
|
||||
trunk.position.y = trunkH / 2;
|
||||
trunk.rotation.z = (rng() - 0.5) * 0.25;
|
||||
tree.add(trunk);
|
||||
const greens = [0x2f7d32, 0x3c8d40, 0x4f9e3f, 0x2e6b28];
|
||||
const foliageMat = new THREE.MeshStandardMaterial({ color: greens[Math.floor(rng() * greens.length)], roughness: 1 });
|
||||
if (rng() > 0.5) {
|
||||
// conifer: stacked cones
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const cone = new THREE.Mesh(new THREE.ConeGeometry(2.4 - i * 0.6, 2.6, 10), foliageMat);
|
||||
cone.position.y = trunkH + i * 1.5 + 0.8;
|
||||
tree.add(cone);
|
||||
}
|
||||
} else {
|
||||
const crown = new THREE.Mesh(new THREE.SphereGeometry(2 + rng() * 1.4, 10, 8), foliageMat);
|
||||
crown.position.y = trunkH + 1.6;
|
||||
crown.scale.y = 0.9;
|
||||
tree.add(crown);
|
||||
for (const tilt of [-0.6, 0.6]) {
|
||||
const branch = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.14, trunkH * 0.6, 6), trunkMat);
|
||||
branch.position.set(tilt * 0.8, trunkH * 0.85, (rng() - 0.5) * 0.6);
|
||||
branch.rotation.z = tilt;
|
||||
tree.add(branch);
|
||||
}
|
||||
const canopyMat = new THREE.MeshStandardMaterial({
|
||||
color: rng() > 0.5 ? 0x5d7a35 : 0x6b8342,
|
||||
roughness: 1,
|
||||
});
|
||||
const canopy = new THREE.Mesh(new THREE.SphereGeometry(2.6 + rng() * 1.6, 10, 8), canopyMat);
|
||||
canopy.position.y = trunkH + 0.9;
|
||||
canopy.scale.set(1.2, 0.32, 1.2); // flat umbrella top
|
||||
tree.add(canopy);
|
||||
return tree;
|
||||
}
|
||||
|
||||
function buildAnimal(rng: () => number): THREE.Group {
|
||||
// low-poly cow / sheep
|
||||
const isCow = rng() > 0.45;
|
||||
const bodyColor = isCow ? (rng() > 0.5 ? 0x8b5a3c : 0x54432f) : 0xe8e4da;
|
||||
function buildShrub(rng: () => number): THREE.Mesh {
|
||||
const dry = [0x9c8a4d, 0x8f7a3e, 0xa89a5e, 0x7d8a4a];
|
||||
const shrub = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.5 + rng() * 0.8, 7, 6),
|
||||
new THREE.MeshStandardMaterial({ color: dry[Math.floor(rng() * dry.length)], roughness: 1 }),
|
||||
);
|
||||
shrub.scale.y = 0.55;
|
||||
shrub.position.y = 0.25;
|
||||
return shrub;
|
||||
}
|
||||
|
||||
function buildRock(rng: () => number): THREE.Mesh {
|
||||
const rock = new THREE.Mesh(
|
||||
new THREE.DodecahedronGeometry(0.5 + rng() * 1.6, 0),
|
||||
new THREE.MeshStandardMaterial({ color: rng() > 0.5 ? 0xa08d76 : 0x8d7f6d, roughness: 1 }),
|
||||
);
|
||||
rock.scale.set(1 + rng(), 0.55 + rng() * 0.4, 1 + rng());
|
||||
rock.position.y = 0.3;
|
||||
rock.rotation.y = rng() * Math.PI;
|
||||
return rock;
|
||||
}
|
||||
|
||||
function buildCamel(rng: () => number): THREE.Group {
|
||||
const g = new THREE.Group();
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyColor, roughness: 1 });
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.9, 0.8), bodyMat);
|
||||
body.position.y = 0.95;
|
||||
const mat = new THREE.MeshStandardMaterial({ color: rng() > 0.5 ? 0xc49a5f : 0xb28850, roughness: 1 });
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.95, 0.85), mat);
|
||||
body.position.y = 1.55;
|
||||
g.add(body);
|
||||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.5, 0.45), bodyMat);
|
||||
head.position.set(0.95, 1.25, 0);
|
||||
const hump = new THREE.Mesh(new THREE.SphereGeometry(0.55, 10, 8), mat);
|
||||
hump.position.set(-0.15, 2.25, 0);
|
||||
hump.scale.set(1.15, 0.9, 0.85);
|
||||
g.add(hump);
|
||||
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.3, 0.3), mat);
|
||||
neck.position.set(1.05, 2.35, 0);
|
||||
neck.rotation.z = -0.25;
|
||||
g.add(neck);
|
||||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.65, 0.32, 0.3), mat);
|
||||
head.position.set(1.45, 2.95, 0);
|
||||
g.add(head);
|
||||
const legGeo = new THREE.BoxGeometry(0.16, 0.6, 0.16);
|
||||
for (const [x, z] of [[-0.6, -0.25], [-0.6, 0.25], [0.6, -0.25], [0.6, 0.25]]) {
|
||||
const leg = new THREE.Mesh(legGeo, bodyMat);
|
||||
leg.position.set(x, 0.3, z);
|
||||
const legGeo = new THREE.BoxGeometry(0.17, 1.15, 0.17);
|
||||
for (const [x, z] of [[-0.8, -0.28], [-0.8, 0.28], [0.8, -0.28], [0.8, 0.28]]) {
|
||||
const leg = new THREE.Mesh(legGeo, mat);
|
||||
leg.position.set(x, 0.58, z);
|
||||
g.add(leg);
|
||||
}
|
||||
if (isCow) {
|
||||
const patch = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.7, 0.5, 0.82),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1 }),
|
||||
);
|
||||
patch.position.set(-0.3, 1.05, 0);
|
||||
g.add(patch);
|
||||
g.rotation.y = rng() * Math.PI * 2;
|
||||
return g;
|
||||
}
|
||||
|
||||
function buildGoat(rng: () => number): THREE.Group {
|
||||
const g = new THREE.Group();
|
||||
const colors = [0xe8e4da, 0x4a3b2d, 0x9c8a72];
|
||||
const mat = new THREE.MeshStandardMaterial({ color: colors[Math.floor(rng() * colors.length)], roughness: 1 });
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.5, 0.4), mat);
|
||||
body.position.y = 0.62;
|
||||
g.add(body);
|
||||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.3, 0.26), mat);
|
||||
head.position.set(0.55, 0.85, 0);
|
||||
g.add(head);
|
||||
const legGeo = new THREE.BoxGeometry(0.09, 0.4, 0.09);
|
||||
for (const [x, z] of [[-0.32, -0.13], [-0.32, 0.13], [0.32, -0.13], [0.32, 0.13]]) {
|
||||
const leg = new THREE.Mesh(legGeo, mat);
|
||||
leg.position.set(x, 0.2, z);
|
||||
g.add(leg);
|
||||
}
|
||||
const scale = isCow ? 1 : 0.6;
|
||||
g.scale.setScalar(scale);
|
||||
g.rotation.y = rng() * Math.PI * 2;
|
||||
return g;
|
||||
}
|
||||
@@ -151,38 +196,67 @@ function buildScenery(scene: THREE.Scene, trackLen: number) {
|
||||
const rng = makeRng(1234567);
|
||||
buildClouds(scene, rng);
|
||||
const spread = Math.max(trackLen, 600);
|
||||
// grass tufts: darker green patches on the field
|
||||
const patchMat = new THREE.MeshStandardMaterial({ color: 0x4f8f3a, roughness: 1 });
|
||||
const patchGeo = new THREE.CircleGeometry(3, 8);
|
||||
const patches = new THREE.InstancedMesh(patchGeo, patchMat, 220);
|
||||
// sand variation: lighter wind-blown patches
|
||||
const patchMat = new THREE.MeshStandardMaterial({ color: 0xe6cfa3, roughness: 1 });
|
||||
const patchGeo = new THREE.CircleGeometry(4, 8);
|
||||
const patches = new THREE.InstancedMesh(patchGeo, patchMat, 160);
|
||||
const m4 = new THREE.Matrix4();
|
||||
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0));
|
||||
const v = new THREE.Vector3();
|
||||
const s = new THREE.Vector3();
|
||||
for (let i = 0; i < 220; i++) {
|
||||
const z = (rng() > 0.5 ? 1 : -1) * (6 + rng() * 180);
|
||||
v.set((rng() - 0.5) * spread, 0.02, z);
|
||||
s.setScalar(0.5 + rng() * 2);
|
||||
for (let i = 0; i < 160; i++) {
|
||||
const z = (rng() > 0.5 ? 1 : -1) * (6 + rng() * 220);
|
||||
v.set((rng() - 0.5) * spread * 1.4, 0.02, z);
|
||||
s.setScalar(0.6 + rng() * 2.4);
|
||||
m4.compose(v, q, s);
|
||||
patches.setMatrixAt(i, m4);
|
||||
}
|
||||
scene.add(patches);
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const tree = buildTree(rng);
|
||||
const z = (rng() > 0.5 ? 1 : -1) * (14 + rng() * 170);
|
||||
tree.position.set((rng() - 0.5) * spread, 0, z);
|
||||
tree.scale.setScalar(0.8 + rng() * 1.2);
|
||||
// low dunes on the horizon sides
|
||||
const duneMat = new THREE.MeshStandardMaterial({ color: 0xcaa878, roughness: 1 });
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const dune = new THREE.Mesh(new THREE.SphereGeometry(30 + rng() * 50, 12, 8), duneMat);
|
||||
dune.scale.set(1.6 + rng(), 0.12 + rng() * 0.08, 1 + rng());
|
||||
dune.position.set((rng() - 0.5) * spread * 1.6, 0, (rng() > 0.5 ? 1 : -1) * (180 + rng() * 200));
|
||||
scene.add(dune);
|
||||
}
|
||||
// sparse acacias, dry shrubs, rocks
|
||||
for (let i = 0; i < 34; i++) {
|
||||
const tree = buildAcacia(rng);
|
||||
tree.position.set((rng() - 0.5) * spread * 1.2, 0, (rng() > 0.5 ? 1 : -1) * (18 + rng() * 170));
|
||||
tree.scale.setScalar(0.9 + rng() * 1.1);
|
||||
scene.add(tree);
|
||||
}
|
||||
// grazing herds
|
||||
for (let h = 0; h < 6; h++) {
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const shrub = buildShrub(rng);
|
||||
shrub.position.set((rng() - 0.5) * spread * 1.3, 0.15, (rng() > 0.5 ? 1 : -1) * (8 + rng() * 200));
|
||||
scene.add(shrub);
|
||||
}
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const rock = buildRock(rng);
|
||||
rock.position.x = (rng() - 0.5) * spread * 1.3;
|
||||
rock.position.z = (rng() > 0.5 ? 1 : -1) * (10 + rng() * 190);
|
||||
scene.add(rock);
|
||||
}
|
||||
// camel caravans + goat herds
|
||||
for (let h = 0; h < 5; h++) {
|
||||
const cx = (rng() - 0.5) * spread;
|
||||
const cz = (rng() > 0.5 ? 1 : -1) * (25 + rng() * 120);
|
||||
const herd = 2 + Math.floor(rng() * 4);
|
||||
for (let a = 0; a < herd; a++) {
|
||||
const animal = buildAnimal(rng);
|
||||
animal.position.set(cx + (rng() - 0.5) * 14, 0, cz + (rng() - 0.5) * 12);
|
||||
scene.add(animal);
|
||||
const cz = (rng() > 0.5 ? 1 : -1) * (30 + rng() * 130);
|
||||
const count = 2 + Math.floor(rng() * 3);
|
||||
for (let a = 0; a < count; a++) {
|
||||
const camel = buildCamel(rng);
|
||||
camel.position.set(cx + a * 4 + (rng() - 0.5) * 2, 0, cz + (rng() - 0.5) * 6);
|
||||
scene.add(camel);
|
||||
}
|
||||
}
|
||||
for (let h = 0; h < 4; h++) {
|
||||
const cx = (rng() - 0.5) * spread;
|
||||
const cz = (rng() > 0.5 ? 1 : -1) * (20 + rng() * 120);
|
||||
const count = 3 + Math.floor(rng() * 4);
|
||||
for (let a = 0; a < count; a++) {
|
||||
const goat = buildGoat(rng);
|
||||
goat.position.set(cx + (rng() - 0.5) * 10, 0, cz + (rng() - 0.5) * 8);
|
||||
scene.add(goat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,8 +288,8 @@ function buildTrack(scene: THREE.Scene, length: number) {
|
||||
scene.add(ballast);
|
||||
// ground
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2200, 800),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5da043, roughness: 1 }),
|
||||
new THREE.PlaneGeometry(4000, 1600),
|
||||
new THREE.MeshStandardMaterial({ color: 0xd8b98a, roughness: 1 }),
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -0.02;
|
||||
@@ -279,7 +353,11 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3
|
||||
const g = new THREE.Group();
|
||||
const len = Math.max(8, wagon.lengthMeters || 14) * M;
|
||||
const kind = wagonKind(wagon);
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: WAGON_BODY_COLOR[kind], metalness: 0.35, roughness: 0.6 });
|
||||
const bodyMat = new THREE.MeshStandardMaterial({
|
||||
color: wagonTypeColor(wagon, WAGON_BODY_COLOR[kind]),
|
||||
metalness: 0.35,
|
||||
roughness: 0.6,
|
||||
});
|
||||
|
||||
// frame/deck common to all
|
||||
const deck = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 3), bodyMat);
|
||||
@@ -309,7 +387,11 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3
|
||||
});
|
||||
} else if (kind === "bulk") {
|
||||
// open hopper walls
|
||||
const wallMat = new THREE.MeshStandardMaterial({ color: 0x8a5a34, metalness: 0.25, roughness: 0.7 });
|
||||
const wallMat = new THREE.MeshStandardMaterial({
|
||||
color: wagonTypeColor(wagon, 0x8a5a34),
|
||||
metalness: 0.25,
|
||||
roughness: 0.7,
|
||||
});
|
||||
const wallH = 2.2;
|
||||
const side = new THREE.BoxGeometry(len, wallH, 0.12);
|
||||
for (const z of [-1.45, 1.45]) {
|
||||
@@ -499,10 +581,10 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
if (!host) return;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9ed3f5);
|
||||
scene.fog = new THREE.Fog(0xbfe3f7, 350, 1100);
|
||||
scene.background = new THREE.Color(0xa7d8f0);
|
||||
scene.fog = new THREE.Fog(0xd9e8f0, 900, 3200); // far desert haze — never clouds the train
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 2000);
|
||||
const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 5000);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.15;
|
||||
@@ -511,7 +593,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
host.appendChild(renderer.domElement);
|
||||
|
||||
// lights: bright sunny day
|
||||
scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x6f9c4e, 1.0));
|
||||
scene.add(new THREE.HemisphereLight(0xcfe8ff, 0xcaa878, 1.0));
|
||||
const sun = new THREE.DirectionalLight(0xfff4d6, 2.4);
|
||||
sun.position.set(-120, 180, 90);
|
||||
scene.add(sun);
|
||||
@@ -567,6 +649,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
controls.maxPolarAngle = Math.PI / 2 - 0.03;
|
||||
controls.maxDistance = Math.max(600, trainLen * 1.6);
|
||||
controls.enableDamping = true;
|
||||
controls.zoomToCursor = true; // wheel zooms toward the point under the pointer
|
||||
|
||||
// camera fly-to: goal recomputed each frame so it tracks a moving train
|
||||
let cameraGoal: (() => { pos: THREE.Vector3; target: THREE.Vector3 }) | null = null;
|
||||
@@ -584,14 +667,17 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
const rearGroup = wagonGroups[wagonGroups.length - 1]?.group ?? frontGroup;
|
||||
cameraApiRef.current = {
|
||||
overview: () => {
|
||||
// side view far enough to fit the whole train in the 55° fov
|
||||
const dist = Math.max(40, (trainLen / 2 / Math.tan((55 * Math.PI) / 360)) * 1.15);
|
||||
// side view fitted with the HORIZONTAL fov — much closer than the naive
|
||||
// vertical-fov fit, and low to the ground so the train fills the frame
|
||||
const vFov = (camera.fov * Math.PI) / 180;
|
||||
const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
|
||||
const dist = Math.max(40, (trainLen / 2 / Math.tan(hFov / 2)) * 1.08);
|
||||
cameraGoal = () => {
|
||||
train.getWorldPosition(worldPos);
|
||||
const cx = worldPos.x - trainLen / 2; // train extends in -x from its origin
|
||||
return {
|
||||
pos: new THREE.Vector3(cx, Math.max(20, dist * 0.25), dist),
|
||||
target: new THREE.Vector3(cx, 2, 0),
|
||||
pos: new THREE.Vector3(cx, Math.max(10, dist * 0.1), dist),
|
||||
target: new THREE.Vector3(cx, 2.5, 0),
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user