diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx index 975df9603..edb3a6b2b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx @@ -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), }; }; },