train 3d visualizations

This commit is contained in:
Marshal
2026-08-02 21:46:00 +00:00
parent 163c501f8f
commit 6d8e5551ad

View File

@@ -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),
};
};
},