Skip to content

Commit 7fe0962

Browse files
committed
fix: display vehicle paths and hypertubes as splines
1 parent 36c34ae commit 7fe0962

10 files changed

Lines changed: 203 additions & 87 deletions

src/map/MapFiltersPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const INFRA_SPLINE_ICONS: Record<SplineKind, ReactNode> = {
119119
hyper: <IconRoute size={14} />,
120120
rail: <IconTrain size={14} />,
121121
power: <IconBolt size={14} />,
122+
vehicle: <IconTruck size={14} />,
122123
};
123124

124125
export interface MapFiltersPanelProps {

src/map/infrastructure/InfrastructureCanvasLayer.tsx

Lines changed: 82 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useStore } from '@/core/zustand';
77
import {
88
INFRASTRUCTURE_CATEGORIES,
99
type InfrastructureCategory,
10+
type InfrastructureSplinesBlock,
1011
type ParsedInfrastructure,
1112
SPLINE_KINDS,
1213
type SplineKind,
@@ -336,6 +337,81 @@ function paintTileBuildings(
336337
ctx.globalAlpha = 1;
337338
}
338339

340+
/**
341+
* Appends a single polyline to `path`. When `step === 1` and the block
342+
* has Hermite tangents (`block.tangentsXY`) the segments are emitted
343+
* as `bezierCurveTo` so curved track / belt sections render as actual
344+
* curves. Otherwise a straight `lineTo` chain is used, with sub-pixel
345+
* points skipped to keep the tile-paint fast at low zoom.
346+
*
347+
* Shared between `paintTileSplines` (tile-batched main render) and
348+
* `drawHighlight` (single-polyline hover stroke) so both paths use the
349+
* same curve geometry — keeping the hover accent on top of the curve
350+
* rather than collapsing it to chords.
351+
*/
352+
function appendPolylineToPath(
353+
path: Path2D,
354+
block: InfrastructureSplinesBlock,
355+
polylineIndex: number,
356+
a: AffineGameToContainer,
357+
step: number,
358+
): void {
359+
const start = block.offsets[polylineIndex];
360+
const end = block.offsets[polylineIndex + 1];
361+
if (end - start < 2) return;
362+
363+
const { pointsXY, tangentsXY } = block;
364+
const sx = a.ox + pointsXY[start * 2] * a.ax;
365+
const sy = a.oy + pointsXY[start * 2 + 1] * a.by;
366+
path.moveTo(sx, sy);
367+
368+
if (step === 1 && tangentsXY != null) {
369+
const t = tangentsXY;
370+
let p0x = sx;
371+
let p0y = sy;
372+
for (let j = start + 1; j < end; j++) {
373+
const p1x = a.ox + pointsXY[j * 2] * a.ax;
374+
const p1y = a.oy + pointsXY[j * 2 + 1] * a.by;
375+
const leaveX = (t[(j - 1) * 4 + 2] * a.ax) / 3;
376+
const leaveY = (t[(j - 1) * 4 + 3] * a.by) / 3;
377+
const arriveX = (t[j * 4] * a.ax) / 3;
378+
const arriveY = (t[j * 4 + 1] * a.by) / 3;
379+
path.bezierCurveTo(
380+
p0x + leaveX,
381+
p0y + leaveY,
382+
p1x - arriveX,
383+
p1y - arriveY,
384+
p1x,
385+
p1y,
386+
);
387+
p0x = p1x;
388+
p0y = p1y;
389+
}
390+
return;
391+
}
392+
393+
let lastDX = sx;
394+
let lastDY = sy;
395+
const lastIdx = end - 1;
396+
for (let j = start + step; j < lastIdx; j += step) {
397+
const px = a.ox + pointsXY[j * 2] * a.ax;
398+
const py = a.oy + pointsXY[j * 2 + 1] * a.by;
399+
const dx = px - lastDX;
400+
const dy = py - lastDY;
401+
if (dx * dx + dy * dy < 1) continue;
402+
path.lineTo(px, py);
403+
lastDX = px;
404+
lastDY = py;
405+
}
406+
const lx = a.ox + pointsXY[lastIdx * 2] * a.ax;
407+
const ly = a.oy + pointsXY[lastIdx * 2 + 1] * a.by;
408+
const ldx = lx - lastDX;
409+
const ldy = ly - lastDY;
410+
if (ldx * ldx + ldy * ldy >= 1 || end - start === 2) {
411+
path.lineTo(lx, ly);
412+
}
413+
}
414+
339415
function paintTileSplines(
340416
ctx: CanvasRenderingContext2D,
341417
infra: ParsedInfrastructure,
@@ -353,6 +429,7 @@ function paintTileSplines(
353429
hyper: 1,
354430
rail: 1.8,
355431
power: 0.75,
432+
vehicle: 1,
356433
};
357434

358435
const step = splineStepForZoom(zoom);
@@ -369,67 +446,13 @@ function paintTileSplines(
369446
if (!SPLINE_KINDS.includes(block.kind)) continue;
370447
if ((state.splineVisibility?.[block.kind] ?? true) === false) continue;
371448

372-
const start = block.offsets[polyIdx];
373-
const end = block.offsets[polyIdx + 1];
374-
if (end - start < 2) continue;
375-
376449
let path = pathsByBlock.get(blockIdx);
377450
if (!path) {
378451
path = new Path2D();
379452
pathsByBlock.set(blockIdx, path);
380453
}
381454

382-
const { pointsXY, tangentsXY } = block;
383-
const useBezier = step === 1 && tangentsXY != null;
384-
const sx = a.ox + pointsXY[start * 2] * a.ax;
385-
const sy = a.oy + pointsXY[start * 2 + 1] * a.by;
386-
path.moveTo(sx, sy);
387-
388-
if (useBezier) {
389-
const t = tangentsXY;
390-
let p0x = sx;
391-
let p0y = sy;
392-
for (let j = start + 1; j < end; j++) {
393-
const p1x = a.ox + pointsXY[j * 2] * a.ax;
394-
const p1y = a.oy + pointsXY[j * 2 + 1] * a.by;
395-
const leaveX = (t[(j - 1) * 4 + 2] * a.ax) / 3;
396-
const leaveY = (t[(j - 1) * 4 + 3] * a.by) / 3;
397-
const arriveX = (t[j * 4] * a.ax) / 3;
398-
const arriveY = (t[j * 4 + 1] * a.by) / 3;
399-
path.bezierCurveTo(
400-
p0x + leaveX,
401-
p0y + leaveY,
402-
p1x - arriveX,
403-
p1y - arriveY,
404-
p1x,
405-
p1y,
406-
);
407-
p0x = p1x;
408-
p0y = p1y;
409-
}
410-
continue;
411-
}
412-
413-
let lastDX = sx;
414-
let lastDY = sy;
415-
const lastIdx = end - 1;
416-
for (let j = start + step; j < lastIdx; j += step) {
417-
const px = a.ox + pointsXY[j * 2] * a.ax;
418-
const py = a.oy + pointsXY[j * 2 + 1] * a.by;
419-
const dx = px - lastDX;
420-
const dy = py - lastDY;
421-
if (dx * dx + dy * dy < 1) continue;
422-
path.lineTo(px, py);
423-
lastDX = px;
424-
lastDY = py;
425-
}
426-
const lx = a.ox + pointsXY[lastIdx * 2] * a.ax;
427-
const ly = a.oy + pointsXY[lastIdx * 2 + 1] * a.by;
428-
const ldx = lx - lastDX;
429-
const ldy = ly - lastDY;
430-
if (ldx * ldx + ldy * ldy >= 1 || end - start === 2) {
431-
path.lineTo(lx, ly);
432-
}
455+
appendPolylineToPath(path, block, polyIdx, a, step);
433456
}
434457

435458
ctx.lineCap = 'round';
@@ -489,21 +512,11 @@ function drawHighlight(
489512
ctx.restore();
490513
return;
491514
}
492-
const start = block.offsets[hit.polylineIndex];
493-
const end = block.offsets[hit.polylineIndex + 1];
494-
if (end - start < 2) {
495-
ctx.restore();
496-
return;
497-
}
498515
const path = new Path2D();
499-
const sx = a.ox + block.pointsXY[start * 2] * a.ax;
500-
const sy = a.oy + block.pointsXY[start * 2 + 1] * a.by;
501-
path.moveTo(sx, sy);
502-
for (let j = start + 1; j < end; j++) {
503-
const px = a.ox + block.pointsXY[j * 2] * a.ax;
504-
const py = a.oy + block.pointsXY[j * 2 + 1] * a.by;
505-
path.lineTo(px, py);
506-
}
516+
// step=1 forces the full point sequence; combined with tangentsXY it
517+
// selects the bezier branch so the hover accent traces the curve
518+
// instead of collapsing to straight chords on top of it.
519+
appendPolylineToPath(path, block, hit.polylineIndex, a, 1);
507520

508521
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
509522
ctx.lineWidth = 6;

src/map/infrastructure/infrastructureCategories.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ export const SplineColor: Record<SplineKind, Record<number, string>> = {
143143
power: {
144144
0: '#fde68a',
145145
},
146+
vehicle: {
147+
0: '#06b6d4',
148+
},
146149
};
147150

148151
export const SplineLabel: Record<SplineKind, string> = {
@@ -151,6 +154,7 @@ export const SplineLabel: Record<SplineKind, string> = {
151154
hyper: 'Hyper tubes',
152155
rail: 'Railroads',
153156
power: 'Power lines',
157+
vehicle: 'Vehicle paths',
154158
};
155159

156160
export function splineColor(kind: SplineKind, tier: number): string {

src/recipes/savegame/ParseSavegameMessages.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,21 @@ export const INFRASTRUCTURE_CATEGORIES: InfrastructureCategory[] = [
2525
'other',
2626
];
2727

28-
export type SplineKind = 'belt' | 'pipe' | 'hyper' | 'rail' | 'power';
28+
export type SplineKind =
29+
| 'belt'
30+
| 'pipe'
31+
| 'hyper'
32+
| 'rail'
33+
| 'power'
34+
| 'vehicle';
2935

3036
export const SPLINE_KINDS: SplineKind[] = [
3137
'belt',
3238
'pipe',
3339
'hyper',
3440
'rail',
3541
'power',
42+
'vehicle',
3643
];
3744

3845
/**

src/recipes/savegame/infrastructure/classifyTypePath.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,32 @@ describe('classifyTypePath', () => {
3232
).toEqual({ mode: 'spline', kind: 'pipe', tier: 1 });
3333
});
3434

35-
it('classifies hyper tubes before falling through to the generic pipe regex', () => {
35+
it('classifies the hyper tube spline segment', () => {
3636
expect(
37-
classifyTypePath('/Game/.../Build_PipelineHyper.Build_PipelineHyper_C'),
37+
classifyTypePath(
38+
'/Game/FactoryGame/Buildable/Factory/PipeHyper/Build_PipeHyper.Build_PipeHyper_C',
39+
),
3840
).toEqual({ mode: 'spline', kind: 'hyper', tier: 0 });
3941
});
4042

43+
it('leaves hyper tube fittings (start, support, t-junction) classified as buildings', () => {
44+
expect(
45+
classifyTypePath(
46+
'/Game/FactoryGame/Buildable/Factory/PipeHyperStart/Build_PipeHyperStart.Build_PipeHyperStart_C',
47+
),
48+
).toEqual({ mode: 'building' });
49+
expect(
50+
classifyTypePath(
51+
'/Game/FactoryGame/Buildable/Factory/PipeHyperSupport/Build_PipeHyperSupport.Build_PipeHyperSupport_C',
52+
),
53+
).toEqual({ mode: 'building' });
54+
expect(
55+
classifyTypePath(
56+
'/Game/FactoryGame/Buildable/Factory/PipeHyperTJunction/Build_HypertubeTJunction.Build_HypertubeTJunction_C',
57+
),
58+
).toEqual({ mode: 'building' });
59+
});
60+
4161
it('classifies railroad tracks (regular and integrated)', () => {
4262
expect(
4363
classifyTypePath('/Game/.../Build_RailroadTrack.Build_RailroadTrack_C'),
@@ -49,6 +69,32 @@ describe('classifyTypePath', () => {
4969
).toEqual({ mode: 'spline', kind: 'rail', tier: 0 });
5070
});
5171

72+
it('classifies vehicle path segments and reads the mSplinePoints property', () => {
73+
expect(
74+
classifyTypePath(
75+
'/Game/FactoryGame/Buildable/Vehicle/VehiclePath/Build_VehiclePath_Universal.Build_VehiclePath_Universal_C',
76+
),
77+
).toEqual({
78+
mode: 'spline',
79+
kind: 'vehicle',
80+
tier: 0,
81+
splineProperty: 'mSplinePoints',
82+
});
83+
});
84+
85+
it('leaves vehicle path nodes classified as buildings', () => {
86+
expect(
87+
classifyTypePath(
88+
'/Game/FactoryGame/Buildable/Vehicle/VehiclePath/Build_VehiclePathNode_Default.Build_VehiclePathNode_Default_C',
89+
),
90+
).toEqual({ mode: 'building' });
91+
expect(
92+
classifyTypePath(
93+
'/Game/FactoryGame/Buildable/Vehicle/VehiclePath/Build_VehiclePathNode_DockingStation.Build_VehiclePathNode_DockingStation_C',
94+
),
95+
).toEqual({ mode: 'building' });
96+
});
97+
5298
it('classifies power lines', () => {
5399
expect(
54100
classifyTypePath('/Game/.../Build_PowerLine.Build_PowerLine_C'),

src/recipes/savegame/infrastructure/classifyTypePath.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import type { Classification } from './types';
22

33
const RE_BELT = /Build_ConveyorBelt(?:Mk(\d+))?_C$/;
4-
const RE_HYPER = /Build_PipelineHyper.*_C$/;
4+
const RE_HYPER = /Build_PipeHyper(?:Mk\d+)?_C$/;
55
const RE_PIPE = /Build_Pipeline(?:MK(\d+))?(?:_NoIndicator)?_C$/;
66
const RE_RAIL = /Build_RailroadTrack(?:Integrated)?_C$/;
77
const RE_POWER_LINE = /Build_PowerLine.*_C$/;
8+
const RE_VEHICLE_PATH = /Build_VehiclePath_Universal(?:_.*)?_C$/;
89

910
/**
1011
* Decides how the worker should treat an entity given its `typePath`.
11-
* The order of checks matters: hyper tubes have to be checked before
12-
* the generic pipe regex (`Build_PipelineHyper*` would otherwise
13-
* match `Build_Pipeline*`), and rails before "anything else", because
14-
* the network branches each have their own data layout downstream.
12+
* Each branch downstream has its own data layout — belts, pipes,
13+
* hypertubes, rails, vehicle paths, and power lines all carry their
14+
* spline / wire data in slightly different shapes.
1515
*/
1616
export function classifyTypePath(typePath: string): Classification {
1717
const belt = typePath.match(RE_BELT);
@@ -30,6 +30,14 @@ export function classifyTypePath(typePath: string): Classification {
3030
if (RE_RAIL.test(typePath)) {
3131
return { mode: 'spline', kind: 'rail', tier: 0 };
3232
}
33+
if (RE_VEHICLE_PATH.test(typePath)) {
34+
return {
35+
mode: 'spline',
36+
kind: 'vehicle',
37+
tier: 0,
38+
splineProperty: 'mSplinePoints',
39+
};
40+
}
3341
if (RE_POWER_LINE.test(typePath)) {
3442
return { mode: 'powerline' };
3543
}

src/recipes/savegame/infrastructure/extractInfrastructure.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ export function ingestEntity(
289289
const cls = classifyTypePath(typePath);
290290

291291
if (cls.mode === 'spline') {
292-
const points = readSplineLocations(obj.properties);
292+
const points = readSplineLocations(obj.properties, cls.splineProperty);
293293
if (points) {
294294
const bucket = getOrCreateBucket(acc, cls.kind, cls.tier);
295295
const built = buildRotatedPolyline(tx, ty, yaw, points);

src/recipes/savegame/infrastructure/getClearance.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,33 @@ const HARDCODED_CLEARANCE_CM: Record<string, ClearanceCm> = {
2626
Build_ConveyorPoleStackable_C: { width: 100, length: 100, height: 400 },
2727
Build_ConveyorPoleWall_C: { width: 100, length: 100, height: 100 },
2828
Build_ConveyorCeilingAttachment_C: { width: 100, length: 100, height: 100 },
29+
// Conveyor lifts are 2x2m in-game but ship with `null` clearance in
30+
// the catalog, so without an override they fall back to the 8x8m
31+
// factory default. Height varies by tier (Mk1 ~4m, taller tiers
32+
// longer), but the visible footprint is identical: a 2x2m vertical
33+
// shaft. Pick 4m as a shared height since the map only uses it for
34+
// topmost-under-cursor stacking.
35+
Build_ConveyorLiftMk1_C: { width: 200, length: 200, height: 400 },
36+
Build_ConveyorLiftMk2_C: { width: 200, length: 200, height: 400 },
37+
Build_ConveyorLiftMk3_C: { width: 200, length: 200, height: 400 },
38+
Build_ConveyorLiftMk4_C: { width: 200, length: 200, height: 400 },
39+
Build_ConveyorLiftMk5_C: { width: 200, length: 200, height: 400 },
40+
Build_ConveyorLiftMk6_C: { width: 200, length: 200, height: 400 },
2941
Build_PipelineSupport_C: { width: 100, length: 100, height: 200 },
3042
Build_PipelineSupportWall_C: { width: 100, length: 100, height: 100 },
3143
Build_PipelineSupportWallHole_C: { width: 100, length: 100, height: 100 },
3244
Build_PipelineFlowIndicator_C: { width: 100, length: 100, height: 100 },
45+
// Vehicle path waypoints: invisible markers in-game that just anchor
46+
// the recorded route. The 8x8m fallback paints them as big squares
47+
// sitting on top of the path spline. Render them as 1x1m so they
48+
// read as discreet dots at path joints instead of competing with the
49+
// spline itself.
50+
Build_VehiclePathNode_Default_C: { width: 100, length: 100, height: 100 },
51+
Build_VehiclePathNode_DockingStation_C: {
52+
width: 400,
53+
length: 400,
54+
height: 200,
55+
},
3356
};
3457

3558
const SMALL_CLEARANCE_PATTERNS = [/Pole/, /Support/, /FlowIndicator/];

0 commit comments

Comments
 (0)