diff --git a/README.md b/README.md
index 3c0dc8c..0091184 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ feature, not a limitation. The app deploys as a pure static site.
| **1+** | Preset plan/elevation views, orthographic toggle, scale bar, colour modes (elevation / distance-from-entrance / gradient / survey / date / single), leg-type visibility toggles, PNG export _(ideas adopted from [CaveView.js](https://github.com/aardgoose/CaveView.js))_ | ✅ done |
| **2** | **Compass `.plt`** (processed coordinates, LRUD, splays, multi-survey) | ✅ done |
| **3** | **Therion `.lox`** + lit triangle-mesh passage walls (the modelled scrap surfaces); **LRUD passage tubes** reconstructed for `.3d`/`.plt` | ✅ done |
-| **4** | **Interaction & UX**: ViewCube navigation (drag to orbit, click a face to snap); click a station for its details; hover labels + station finder; **measure tool** (straight-line / horizontal / vertical / bearing); **survey-tree show/hide**; **vertical exaggeration**; entrance & fixed-point markers; light/dark theme; metric/imperial units; render-on-demand (idle GPU) | ✅ done |
+| **4** | **Interaction & UX**: ViewCube navigation (drag to orbit, click a face to snap); click a station for its details; hover labels + station finder; **measure tool** (straight-line / horizontal / vertical / bearing / shortest route along the cave); **survey-tree show/hide**; **vertical exaggeration**; entrance & fixed-point markers; light/dark theme; metric/imperial units; render-on-demand (idle GPU) | ✅ done |
| **5** | **PocketTopo `.top`** (raw DistoX shots: declination, repeated-shot averaging, splays, reference anchoring, trip dates) | ✅ done |
| next | Cross-sections from splays; clipping plane / depth cursor; depth fog | planned |
@@ -201,7 +201,7 @@ with the compass directions.
**Selecting & measuring:**
- **Hover** a station to see its name; **click** it for a panel with name, position, elevation, and distance from the entrance. Entrances (green) and fixed points (amber) are marked.
-- **Measure**: toolbar toggle; click two stations for the straight-line / horizontal / vertical distance and compass bearing.
+- **Measure**: toolbar toggle; click two stations for the straight-line / horizontal / vertical distance and compass bearing, plus the shortest **route distance along the cave** (the route is highlighted in the view; “no route” if the stations aren't connected).
**Toolbar** (bottom):
diff --git a/src/main.ts b/src/main.ts
index fc869ba..df90c60 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -112,8 +112,8 @@ viewer.onPick = (id) => {
// it so a tap on a station actually shows something on a phone.
if (id !== null) openInfoOnMobile();
};
-viewer.onMeasure = (a, b) => {
- measurePanel.show(a, b);
+viewer.onMeasure = (a, b, route) => {
+ measurePanel.show(a, b, route);
if (a !== null && b !== null) openInfoOnMobile();
};
viewer.onHover = (id, x, y) => {
diff --git a/src/parser/therionLox.ts b/src/parser/therionLox.ts
index fcec754..deb1786 100644
--- a/src/parser/therionLox.ts
+++ b/src/parser/therionLox.ts
@@ -230,15 +230,18 @@ function assemble(
for (const s of loxStations) {
const index = stations.length;
idToIndex.set(s.id, index);
+ // Therion names anonymous splay/wall endpoints "." or "-"; treat them like
+ // the other parsers' anonymous stations (unlabelled, not pickable).
+ const anonymous = s.name === "" || s.name === "." || s.name === "-";
const path = surveyPath(surveys, s.surveyId);
- const label = path ? (s.name ? `${path}.${s.name}` : path) : s.name;
+ const label = anonymous ? "" : path ? `${path}.${s.name}` : s.name;
const flags = emptyStationFlags();
flags.surface = (s.flags & ST_FLAG_SURFACE) !== 0;
flags.underground = !flags.surface;
flags.entrance = (s.flags & ST_FLAG_ENTRANCE) !== 0;
flags.fixed = (s.flags & ST_FLAG_FIXED) !== 0;
flags.wall = (s.flags & ST_FLAG_HAS_WALLS) !== 0;
- flags.anonymous = s.name === "";
+ flags.anonymous = anonymous;
stations.push({ id: index, label, x: s.x, y: s.y, z: s.z, flags });
}
diff --git a/src/ui/measurePanel.ts b/src/ui/measurePanel.ts
index 06d30a6..1eae9b4 100644
--- a/src/ui/measurePanel.ts
+++ b/src/ui/measurePanel.ts
@@ -1,8 +1,10 @@
/**
* Measure-tool readout: straight-line + horizontal distance, vertical change,
- * and compass bearing between two picked stations. Respects the unit system.
+ * compass bearing, and the shortest along-the-cave route distance between two
+ * picked stations. Respects the unit system.
*/
import type { CaveModel } from "../parser/index";
+import type { Route } from "../viewer/route";
import { escapeHtml } from "./escapeHtml";
import { formatLength, toDisplayLength, unitLabel, type UnitSystem } from "./units";
@@ -15,6 +17,7 @@ export class MeasurePanel {
private model: CaveModel | null = null;
private a: number | null = null;
private b: number | null = null;
+ private route: Route | null = null;
constructor() {
this.el = document.createElement("div");
@@ -32,9 +35,10 @@ export class MeasurePanel {
}
/** Show the measurement: one endpoint (prompt for second) or both (results). */
- show(a: number | null, b: number | null): void {
+ show(a: number | null, b: number | null, route: Route | null = null): void {
this.a = a;
this.b = b;
+ this.route = route;
this.render();
}
@@ -65,6 +69,9 @@ export class MeasurePanel {
let bearing = (Math.atan2(dEast, dNorth) * 180) / Math.PI; // 0 = N, 90 = E
if (bearing < 0) bearing += 360;
const vert = `${dz >= 0 ? "+" : "−"}${toDisplayLength(Math.abs(dz), u).toFixed(1)} ${lbl}`;
+ const route = this.route
+ ? formatLength(this.route.lengthM, u)
+ : "no route"; // endpoints not connected through the centreline
this.el.innerHTML = `${head}
${escapeHtml(a.label || "(anon)")} → ${escapeHtml(b.label || "(anon)")}
@@ -72,6 +79,7 @@ export class MeasurePanel {
- Horizontal
- ${formatLength(plan, u)}
- Vertical
- ${vert}
- Bearing
- ${bearing.toFixed(1)}°
+ - Along cave
- ${route}
`;
}
this.el.style.display = "";
diff --git a/src/viewer/Viewer.ts b/src/viewer/Viewer.ts
index f3b5e4f..7753857 100644
--- a/src/viewer/Viewer.ts
+++ b/src/viewer/Viewer.ts
@@ -7,10 +7,13 @@ import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+import { Line2 } from "three/examples/jsm/lines/Line2.js";
+import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
import type { CaveModel } from "../parser/index";
import { buildCenterline, type LegVisibility } from "./buildCenterline";
import type { ColorMode, LegendSpec } from "./coloring";
import { surveyToThree } from "./coords";
+import { findRoute, type Route } from "./route";
import { buildLrudTubes } from "./buildLrudTubes";
/** What a plain left-drag does. See {@link Viewer.setLeftDragMode}. */
@@ -104,6 +107,11 @@ export class Viewer {
private measurePts: number[] = [];
private measureMarkers: (THREE.Mesh | null)[] = [null, null];
private measureLine: THREE.Line | null = null;
+ // Shortest along-the-cave route between the two measure endpoints. Drawn as
+ // a fat line so it reads on top of the (also fat) centreline.
+ private measureRoute: Route | null = null;
+ private routeLine: Line2 | null = null;
+ private readonly routeMaterial: LineMaterial;
/** Fires whenever the camera moves (north indicator, scale bar). */
onCameraChange?: () => void;
@@ -116,7 +124,7 @@ export class Viewer {
/** Fires on hover (not dragging) with the station under the cursor, or null. */
onHover?: (stationId: number | null, clientX: number, clientY: number) => void;
/** Fires when the measure tool has two endpoints (or null,null when cleared). */
- onMeasure?: (aId: number | null, bId: number | null) => void;
+ onMeasure?: (aId: number | null, bId: number | null, route: Route | null) => void;
constructor(private readonly container: HTMLElement) {
this.scene.background = new THREE.Color(0x10131a);
@@ -153,6 +161,16 @@ export class Viewer {
});
this.material.resolution.set(w, h);
+ this.routeMaterial = new LineMaterial({
+ color: 0x5cc8fa,
+ linewidth: 5, // pixels — wider than the centreline so the highlight reads
+ worldUnits: false,
+ transparent: true,
+ opacity: 0.85,
+ depthTest: false,
+ });
+ this.routeMaterial.resolution.set(w, h);
+
this.resizeObserver = new ResizeObserver(() => this.handleResize());
this.resizeObserver.observe(container);
@@ -361,7 +379,10 @@ export class Viewer {
for (let i = 0; i < this.measurePts.length; i++) {
this.measureMarkers[i]?.position.copy(this.stationPoint(this.measurePts[i]));
}
- if (this.measurePts.length === 2) this.drawMeasureLine();
+ if (this.measurePts.length === 2) {
+ this.drawMeasureLine();
+ this.drawRouteLine();
+ }
for (let i = 0; i < this.flagMarkers.length; i++) {
this.flagMarkers[i].position.copy(this.stationPoint(this.flagStationIds[i]));
}
@@ -824,10 +845,12 @@ export class Viewer {
m.position.copy(this.stationPoint(id));
this.measureMarkers[slot] = m;
if (this.measurePts.length === 2) {
+ this.measureRoute = findRoute(this.model!, this.measurePts[0], this.measurePts[1]);
this.drawMeasureLine();
- this.onMeasure?.(this.measurePts[0], this.measurePts[1]);
+ this.drawRouteLine();
+ this.onMeasure?.(this.measurePts[0], this.measurePts[1], this.measureRoute);
} else {
- this.onMeasure?.(id, null);
+ this.onMeasure?.(id, null, null);
}
}
@@ -847,6 +870,29 @@ export class Viewer {
this.requestRender();
}
+ /** Highlight the along-the-cave route between the two measure endpoints. */
+ private drawRouteLine(): void {
+ if (this.routeLine) {
+ this.scene.remove(this.routeLine);
+ this.routeLine.geometry.dispose(); // material is shared; keep it
+ this.routeLine = null;
+ }
+ const route = this.measureRoute;
+ if (!route || route.stations.length < 2) return;
+ const positions: number[] = [];
+ for (const id of route.stations) {
+ const p = this.stationPoint(id);
+ positions.push(p.x, p.y, p.z);
+ }
+ const geom = new LineGeometry();
+ geom.setPositions(positions);
+ this.routeLine = new Line2(geom, this.routeMaterial);
+ this.routeLine.computeLineDistances();
+ this.routeLine.renderOrder = 997; // beneath the straight measure line
+ this.scene.add(this.routeLine);
+ this.requestRender();
+ }
+
private clearMeasure(): void {
this.measurePts = [];
for (let i = 0; i < this.measureMarkers.length; i++) {
@@ -864,6 +910,8 @@ export class Viewer {
(this.measureLine.material as THREE.Material).dispose();
this.measureLine = null;
}
+ this.measureRoute = null;
+ this.drawRouteLine(); // disposes the highlight (no route set)
this.requestRender();
}
@@ -958,6 +1006,7 @@ export class Viewer {
this.orthoCam.updateProjectionMatrix();
this.renderer.setSize(w, h);
this.material.resolution.set(w, h);
+ this.routeMaterial.resolution.set(w, h);
this.onCameraChange?.();
this.requestRender();
}
diff --git a/src/viewer/coloring.ts b/src/viewer/coloring.ts
index cfb50cd..d8f96e8 100644
--- a/src/viewer/coloring.ts
+++ b/src/viewer/coloring.ts
@@ -189,6 +189,11 @@ export function surveyColor(survey: string): RGB {
* Multi-source Dijkstra: geodesic distance along centreline legs from the
* nearest entrance. Falls back to fixed stations, then to station 0, if no
* entrances are flagged. Splay shots are excluded from the graph.
+ *
+ * Stations at identical coordinates are treated as one graph node: Therion
+ * .lox writes an equated station once per survey (different ids and names,
+ * same point, no joining shot), so without this the distance flood stops at
+ * every survey boundary and most of a .lox model colours grey.
*/
export function entranceDistances(model: CaveModel): {
distance: Float64Array;
@@ -198,22 +203,36 @@ export function entranceDistances(model: CaveModel): {
const distance = new Float64Array(n).fill(Infinity);
if (n === 0) return { distance, max: 0 };
- // Build adjacency from non-splay legs.
+ // Canonicalize coordinate-coincident stations to one node (0.1 mm key).
+ const canon = new Map();
+ const node = new Int32Array(n);
+ for (const s of model.stations) {
+ const key = `${s.x.toFixed(4)},${s.y.toFixed(4)},${s.z.toFixed(4)}`;
+ const existing = canon.get(key);
+ if (existing === undefined) {
+ canon.set(key, s.id);
+ node[s.id] = s.id;
+ } else {
+ node[s.id] = existing;
+ }
+ }
+
+ // Build adjacency from non-splay legs, over canonical nodes.
const adj: Array> = Array.from({ length: n }, () => []);
for (const leg of model.legs) {
if (leg.flags.splay) continue;
const a = model.stations[leg.from];
const b = model.stations[leg.to];
const w = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
- adj[leg.from].push({ to: leg.to, w });
- adj[leg.to].push({ to: leg.from, w });
+ adj[node[leg.from]].push({ to: node[leg.to], w });
+ adj[node[leg.to]].push({ to: node[leg.from], w });
}
const sources = chooseSources(model);
const heap = new MinHeap();
for (const s of sources) {
- distance[s] = 0;
- heap.push(s, 0);
+ distance[node[s]] = 0;
+ heap.push(node[s], 0);
}
let max = 0;
@@ -229,6 +248,9 @@ export function entranceDistances(model: CaveModel): {
}
}
}
+
+ // Coincident stations share their canonical node's distance.
+ for (let i = 0; i < n; i++) distance[i] = distance[node[i]];
return { distance, max };
}
diff --git a/src/viewer/route.ts b/src/viewer/route.ts
new file mode 100644
index 0000000..037f75a
--- /dev/null
+++ b/src/viewer/route.ts
@@ -0,0 +1,137 @@
+/**
+ * Route finder: the shortest path ALONG the centreline between two stations —
+ * the "how far through the cave" distance, as opposed to the measure tool's
+ * straight-line readout. Pure logic (no Three.js, no DOM) so it is
+ * unit-testable: single-source Dijkstra with predecessor tracking over the
+ * non-splay leg graph, early-exiting at the target.
+ *
+ * Stations at identical coordinates are treated as one graph node: Therion
+ * .lox writes an equated station once per survey (different ids and names,
+ * same point, no joining shot), so without this the centreline graph
+ * fragments at every survey boundary — Migovec splits into ~600 pieces.
+ */
+import type { CaveModel } from "../parser/index";
+
+export interface Route {
+ /** Station ids along the route, start to end inclusive. */
+ stations: number[];
+ /** Total length (m) along the route's legs (true, unexaggerated metres). */
+ lengthM: number;
+}
+
+/** Shortest centreline route between two stations, or null if unreachable. */
+export function findRoute(model: CaveModel, from: number, to: number): Route | null {
+ const n = model.stations.length;
+ if (from < 0 || from >= n || to < 0 || to >= n) return null;
+
+ // Canonicalize coordinate-coincident stations to one node (0.1 mm key).
+ const canon = new Map();
+ const node = new Int32Array(n);
+ for (const s of model.stations) {
+ const key = `${s.x.toFixed(4)},${s.y.toFixed(4)},${s.z.toFixed(4)}`;
+ const existing = canon.get(key);
+ if (existing === undefined) {
+ canon.set(key, s.id);
+ node[s.id] = s.id;
+ } else {
+ node[s.id] = existing;
+ }
+ }
+ const src = node[from];
+ const dst = node[to];
+ if (src === dst) return { stations: [from], lengthM: 0 };
+
+ // Adjacency over non-splay legs (splays are wall shots, not passage).
+ const adj: Array> = Array.from({ length: n }, () => []);
+ for (const leg of model.legs) {
+ if (leg.flags.splay) continue;
+ const a = model.stations[leg.from];
+ const b = model.stations[leg.to];
+ const w = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
+ adj[node[leg.from]].push({ to: node[leg.to], w });
+ adj[node[leg.to]].push({ to: node[leg.from], w });
+ }
+
+ const distance = new Float64Array(n).fill(Infinity);
+ const prev = new Int32Array(n).fill(-1);
+ distance[src] = 0;
+ const heap = new MinHeap();
+ heap.push(src, 0);
+
+ while (heap.size > 0) {
+ const { id, dist } = heap.pop();
+ if (dist > distance[id]) continue; // stale entry
+ if (id === dst) break; // target settled — its shortest distance is final
+ for (const edge of adj[id]) {
+ const nd = dist + edge.w;
+ if (nd < distance[edge.to]) {
+ distance[edge.to] = nd;
+ prev[edge.to] = id;
+ heap.push(edge.to, nd);
+ }
+ }
+ }
+
+ if (!Number.isFinite(distance[dst])) return null;
+ const stations: number[] = [];
+ for (let id = dst; id !== -1; id = prev[id]) stations.push(id);
+ stations.reverse();
+ return { stations, lengthM: distance[dst] };
+}
+
+/**
+ * A small binary min-heap keyed by distance. (Deliberately mirrors the private
+ * heap in coloring.ts rather than sharing it — keeps both modules self-contained.)
+ */
+class MinHeap {
+ private ids: number[] = [];
+ private keys: number[] = [];
+
+ get size(): number {
+ return this.ids.length;
+ }
+
+ push(id: number, key: number): void {
+ this.ids.push(id);
+ this.keys.push(key);
+ let i = this.ids.length - 1;
+ while (i > 0) {
+ const p = (i - 1) >> 1;
+ if (this.keys[p] <= this.keys[i]) break;
+ this.swap(i, p);
+ i = p;
+ }
+ }
+
+ pop(): { id: number; dist: number } {
+ const id = this.ids[0];
+ const dist = this.keys[0];
+ const lastId = this.ids.pop()!;
+ const lastKey = this.keys.pop()!;
+ if (this.ids.length > 0) {
+ this.ids[0] = lastId;
+ this.keys[0] = lastKey;
+ this.siftDown(0);
+ }
+ return { id, dist };
+ }
+
+ private siftDown(i: number): void {
+ const n = this.ids.length;
+ for (;;) {
+ const l = 2 * i + 1;
+ const r = 2 * i + 2;
+ let m = i;
+ if (l < n && this.keys[l] < this.keys[m]) m = l;
+ if (r < n && this.keys[r] < this.keys[m]) m = r;
+ if (m === i) break;
+ this.swap(i, m);
+ i = m;
+ }
+ }
+
+ private swap(a: number, b: number): void {
+ [this.ids[a], this.ids[b]] = [this.ids[b], this.ids[a]];
+ [this.keys[a], this.keys[b]] = [this.keys[b], this.keys[a]];
+ }
+}
diff --git a/test/coloring.test.ts b/test/coloring.test.ts
index 1040877..3195084 100644
--- a/test/coloring.test.ts
+++ b/test/coloring.test.ts
@@ -68,6 +68,41 @@ describe("entranceDistances", () => {
expect(distance[splayEnd.id]).toBe(Infinity);
});
+ it("crosses coordinate-coincident stations (cross-survey equates in .lox)", () => {
+ // Therion .lox writes an equated station once per survey: stations 1 and 2
+ // here share a point but no leg joins them. Distance must flow through.
+ const mkStation = (id: number, x: number, y: number, entrance = false) => {
+ const flags = emptyStationFlags();
+ flags.underground = true;
+ flags.entrance = entrance;
+ return { id, label: `s${id}`, x, y, z: 0, flags };
+ };
+ const model: CaveModel = {
+ metadata: {
+ title: "t",
+ format: "test",
+ separator: ".",
+ bounds: { min: [0, 0, 0], max: [3, 0, 0] },
+ isExtendedElevation: false,
+ },
+ stations: [
+ mkStation(0, 0, 0, true), // entrance
+ mkStation(1, 1, 0),
+ mkStation(2, 1, 0), // coincides with station 1
+ mkStation(3, 3, 0),
+ ],
+ legs: [
+ { from: 0, to: 1, flags: emptyLegFlags() }, // 1 m
+ { from: 2, to: 3, flags: emptyLegFlags() }, // 2 m
+ ],
+ };
+ const { distance, max } = entranceDistances(model);
+ expect(distance[1]).toBeCloseTo(1, 6);
+ expect(distance[2]).toBeCloseTo(1, 6); // the equate twin shares the distance
+ expect(distance[3]).toBeCloseTo(3, 6);
+ expect(max).toBeCloseTo(3, 6);
+ });
+
it("falls back to station 0 when no entrance or fixed station exists", () => {
const model = parse(
encode3d({
diff --git a/test/route.test.ts b/test/route.test.ts
new file mode 100644
index 0000000..bf1fec0
--- /dev/null
+++ b/test/route.test.ts
@@ -0,0 +1,136 @@
+/**
+ * Tests for the route finder: shortest path along the centreline between two
+ * stations (single-source Dijkstra with predecessor tracking).
+ */
+import { describe, it, expect } from "vitest";
+import {
+ emptyLegFlags,
+ emptyStationFlags,
+ type CaveModel,
+} from "../src/parser/index";
+import { findRoute } from "../src/viewer/route";
+
+/** Hand-built model: stations at coords, legs by station index. */
+function model(
+ coords: Array<[number, number, number]>,
+ legs: Array<{ from: number; to: number; splay?: boolean }>,
+): CaveModel {
+ const stations: CaveModel["stations"] = coords.map(([x, y, z], id) => {
+ const flags = emptyStationFlags();
+ flags.underground = true;
+ return { id, label: `s${id}`, x, y, z, flags };
+ });
+ return {
+ metadata: {
+ title: "t",
+ format: "test",
+ separator: ".",
+ bounds: { min: [0, 0, 0], max: [1, 1, 1] },
+ isExtendedElevation: false,
+ },
+ stations,
+ legs: legs.map(({ from, to, splay }) => {
+ const flags = emptyLegFlags();
+ if (splay) flags.splay = true;
+ return { from, to, flags };
+ }),
+ };
+}
+
+describe("findRoute", () => {
+ it("follows a chain of legs and sums their true lengths", () => {
+ const m = model(
+ [
+ [0, 0, 0],
+ [1, 0, 0], // 1 m from station 0
+ [1, 2, 0], // 2 m from station 1
+ ],
+ [
+ { from: 0, to: 1 },
+ { from: 1, to: 2 },
+ ],
+ );
+ expect(findRoute(m, 0, 2)).toEqual({ stations: [0, 1, 2], lengthM: 3 });
+ });
+
+ it("picks the shorter way around a loop, traversing legs in either direction", () => {
+ // Two ways from 0 to 1: via 2 (1 + 9 = 10 m) or via 3 (20 + ~22.4 m).
+ // The 1->3 leg is oriented against the route, so both directions are used.
+ const m = model(
+ [
+ [0, 0, 0],
+ [10, 0, 0],
+ [1, 0, 0],
+ [0, 20, 0], // a long way round: 0 -> 3 -> 1
+ ],
+ [
+ { from: 1, to: 3 }, // ~22.4 m (also: reversed orientation vs the route)
+ { from: 3, to: 0 }, // 20 m
+ { from: 0, to: 2 }, // 1 m
+ { from: 2, to: 1 }, // 9 m
+ ],
+ );
+ const route = findRoute(m, 0, 1)!;
+ expect(route.stations).toEqual([0, 2, 1]);
+ expect(route.lengthM).toBeCloseTo(10, 9);
+ });
+
+ it("ignores splay legs entirely", () => {
+ const m = model(
+ [
+ [0, 0, 0],
+ [1, 0, 0],
+ ],
+ [{ from: 0, to: 1, splay: true }],
+ );
+ expect(findRoute(m, 0, 1)).toBeNull();
+ });
+
+ it("returns null for disconnected stations", () => {
+ const m = model(
+ [
+ [0, 0, 0],
+ [1, 0, 0],
+ [5, 0, 0],
+ [6, 0, 0],
+ ],
+ [
+ { from: 0, to: 1 },
+ { from: 2, to: 3 },
+ ],
+ );
+ expect(findRoute(m, 0, 3)).toBeNull();
+ });
+
+ it("treats coordinate-coincident stations (cross-survey equates) as connected", () => {
+ // Therion .lox writes an equated station once per survey: different ids and
+ // names, identical coordinates, and no shot joining them. Stations 1 and 2
+ // here are such a pair; the only way from 0 to 3 is through the equate.
+ const m = model(
+ [
+ [0, 0, 0],
+ [1, 0, 0],
+ [1, 0, 0], // coincides with station 1
+ [3, 0, 0],
+ ],
+ [
+ { from: 0, to: 1 }, // 1 m
+ { from: 2, to: 3 }, // 2 m
+ ],
+ );
+ const route = findRoute(m, 0, 3)!;
+ expect(route).not.toBeNull();
+ expect(route.lengthM).toBeCloseTo(3, 9);
+ });
+
+ it("returns a zero-length route from a station to itself", () => {
+ const m = model([[0, 0, 0]], []);
+ expect(findRoute(m, 0, 0)).toEqual({ stations: [0], lengthM: 0 });
+ });
+
+ it("returns null for out-of-range ids", () => {
+ const m = model([[0, 0, 0]], []);
+ expect(findRoute(m, 0, 5)).toBeNull();
+ expect(findRoute(m, -1, 0)).toBeNull();
+ });
+});
diff --git a/test/therionLox.golden.test.ts b/test/therionLox.golden.test.ts
index 7a3f3e9..2cfc0f3 100644
--- a/test/therionLox.golden.test.ts
+++ b/test/therionLox.golden.test.ts
@@ -46,6 +46,14 @@ describe("Therion .lox — cross-format golden test vs the same cave's .3d", ()
it("decodes the same coordinates (identical bounds) as the trusted .3d parser", () => {
expect(stationBounds(lox)).toEqual(stationBounds(threeD));
});
+
+ it('keeps stations whose given name merely ends in "." (e.g. "9.") named', () => {
+ // "S1.9." is a real named station in this dataset — the .3d agrees — and
+ // must not be confused with Therion's "." anonymous-point convention.
+ const odd = lox.stations.find((s) => s.label === "ResurgenceDeLAvenir.S1.9.");
+ expect(odd).toBeDefined();
+ expect(odd!.flags.anonymous).toBe(false);
+ });
});
// --- Minimal hand-built .lox (little-endian), field order per lxFile.cxx Load() ---
@@ -80,7 +88,9 @@ function buildSyntheticLox(): ArrayBuffer {
// STATION: id, surveyId, namePtr{pos,size}, commentPtr{pos,size}, flags, x,y,z; data="ab"
const stA = [...u32(0), ...u32(0), ...u32(0), ...u32(1), ...u32(0), ...u32(0), ...u32(2), ...f64(0), ...f64(0), ...f64(0)];
const stB = [...u32(1), ...u32(0), ...u32(1), ...u32(1), ...u32(0), ...u32(0), ...u32(16), ...f64(10), ...f64(0), ...f64(-2)];
- const station = chunk(2, [...stA, ...stB], 2, ascii("ab"));
+ // stC is named "." — Therion's anonymous splay/wall-point convention.
+ const stC = [...u32(2), ...u32(0), ...u32(2), ...u32(1), ...u32(0), ...u32(0), ...u32(0), ...f64(1), ...f64(1), ...f64(1)];
+ const station = chunk(2, [...stA, ...stB, ...stC], 3, ascii("ab."));
// SHOT: from,to, fLRUD[4], tLRUD[4], flags, sectionType, surveyId, threshold
const shot = chunk(
3,
@@ -105,13 +115,19 @@ describe("Therion .lox — synthetic: walls, flags, labels", () => {
it("builds survey-path labels and station flags", () => {
expect(m.metadata.title).toBe("Test Cave");
- expect(m.stations.map((s) => s.label).sort()).toEqual(["cave.a", "cave.b"]);
+ expect(m.stations.map((s) => s.label).sort()).toEqual(["", "cave.a", "cave.b"]);
const byLabel = new Map(m.stations.map((s) => [s.label, s]));
expect(byLabel.get("cave.a")!.flags.entrance).toBe(true);
expect(byLabel.get("cave.b")!.flags.wall).toBe(true);
expect(byLabel.get("cave.b")).toMatchObject({ x: 10, y: 0, z: -2 });
});
+ it('marks the station named "." anonymous and unlabelled', () => {
+ const anon = m.stations.filter((s) => s.flags.anonymous);
+ expect(anon).toHaveLength(1);
+ expect(anon[0]).toMatchObject({ label: "", x: 1, y: 1, z: 1 });
+ });
+
it("decodes the centreline leg", () => {
expect(m.legs).toHaveLength(1);
expect(m.legs[0].flags.splay).toBe(false);