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);