Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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):

Expand Down
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
7 changes: 5 additions & 2 deletions src/parser/therionLox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
12 changes: 10 additions & 2 deletions src/ui/measurePanel.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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");
Expand All @@ -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();
}

Expand Down Expand Up @@ -65,13 +69,17 @@ 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}
<p class="measure-ends">${escapeHtml(a.label || "(anon)")} → ${escapeHtml(b.label || "(anon)")}</p>
<dl class="hud-stats">
<dt>Straight line</dt><dd>${formatLength(slope, u)}</dd>
<dt>Horizontal</dt><dd>${formatLength(plan, u)}</dd>
<dt>Vertical</dt><dd>${vert}</dd>
<dt>Bearing</dt><dd>${bearing.toFixed(1)}°</dd>
<dt>Along cave</dt><dd>${route}</dd>
</dl>`;
}
this.el.style.display = "";
Expand Down
57 changes: 53 additions & 4 deletions src/viewer/Viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}. */
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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]));
}
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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++) {
Expand All @@ -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();
}

Expand Down Expand Up @@ -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();
}
Expand Down
32 changes: 27 additions & 5 deletions src/viewer/coloring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, number>();
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<{ to: number; w: number }>> = 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;
Expand All @@ -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 };
}

Expand Down
Loading
Loading