Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e81ae4b
fix(pointcloud): near-term batch — correctness + code-org
claude May 1, 2026
11698d3
feat(pointcloud): near-term UX — hover XYZ, solid picker, legend, cancel
claude May 1, 2026
abcf25a
fix(pointcloud): map AbortError to "Cancelled" in federated loader
claude May 1, 2026
c2b7308
feat(pointcloud): PTS / XYZ ASCII reader
claude May 1, 2026
cdc4d0b
feat(pointcloud): E57 ScaledInteger codec — bit-packed cartesian / in…
claude May 1, 2026
3541bd9
feat(pointcloud): E57 multi-scan pose merging
claude May 1, 2026
4ed61f1
feat(pointcloud): GPU rectangle pick — marquee select for meshes + po…
claude May 1, 2026
5cdfe03
feat(pointcloud): per-class visibility toggles for ASPRS scans
claude May 1, 2026
5269c53
feat(pointcloud): section-plane drag preview — 1/4 density during sli…
claude May 1, 2026
26b3d0b
feat(pointcloud): BIM ↔ scan deviation heatmap — GPU compute, all IFC
claude May 1, 2026
cbb8806
merge: PR-B PTS / XYZ ASCII reader
claude May 1, 2026
0b30f7e
merge: PR-C E57 ScaledInteger codec
claude May 1, 2026
3fc36a4
merge: PR-D E57 multi-scan pose merging
claude May 1, 2026
e7d2479
merge: PR-E GPU rectangle pick
claude May 1, 2026
bec68bc
merge: PR-F per-class visibility toggles
claude May 1, 2026
9685f74
merge: PR-G section-plane drag preview
claude May 1, 2026
4fe8f56
merge: PR-H BIM↔scan deviation heatmap (GPU compute)
claude May 1, 2026
0dbb465
fix(pointcloud): address PR #614 review — correctness round
claude May 1, 2026
7cdf6b3
fix(pointcloud): E57 packet bounds — no trailing CRC at packet level
claude May 2, 2026
2b09650
chore(pointcloud): debug logging for classification colour mode
claude May 2, 2026
21fb96d
feat(pointcloud): E57 classification field decode + clearer diagnostic
claude May 2, 2026
6013ac6
fix(pointcloud): bypass laz-perf wasm fetch via Vite ?url asset
claude May 3, 2026
5d0d26d
fix(pointcloud): address PR #614 review round 2
claude May 3, 2026
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
32 changes: 32 additions & 0 deletions .changeset/pointcloud-class-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@ifc-lite/renderer": minor
"@ifc-lite/viewer": minor
---

Per-class visibility toggles for ASPRS-classified point clouds.

A new "Classes" section in the point cloud panel exposes a checkbox
list of every LAS 1.4 standard class (Ground, Vegetation, Building,
Water, Wires, Bridge deck, ...). Toggling a class hides every point
with that classification. Works in any colour mode; the swatch
colours mirror the splat shader's classification palette so the UI
matches what's on screen.

Implementation:
- New `pointCloudClassMask: number` (u32 bitmask, default
`0xFFFFFFFF`) on the point cloud slice. `togglePointCloudClass(id)`
flips a single bit; `setPointCloudClassMask(mask)` replaces all 32.
- `PointCloudRenderOptions.classMask` plumbed through the renderer.
Stored in uniform slot `flags.w` (was unused).
- Splat shader checks `(flags.w >> classId) & 1` per vertex; hidden
classes get a degenerate `clipPos = vec4(0, 0, -2, 1)` so they're
culled before rasterisation rather than wasted on a fragment-stage
discard.
- New `PointCloudClasses` component in the panel renders a
`<details>` collapsible with "Show all" + per-class toggles. A
badge surfaces "N of 32 visible" when not all are on.
- `usePointCloudSync` forwards the mask to
`setPointCloudOptions({ classMask })`.

Class ids ≥32 always show — the mask only covers the standard
range. Custom-labelled scans need a richer UI (deferred).
72 changes: 72 additions & 0 deletions .changeset/pointcloud-deviation-heatmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
"@ifc-lite/renderer": minor
"@ifc-lite/viewer": minor
---

BIM ↔ scan deviation heatmap — GPU compute pipeline that colours each
scan point by signed distance to the nearest mesh surface. Works with
every IFC ingest path (STEP / IFCx / GLB / federated) and with every
point cloud format (inline IFCx + streamed LAS / LAZ / PLY / PCD / E57
/ PTS / XYZ — anywhere `Scene.forEachMeshData` reaches and any node
the splat pipeline already renders).

Pipeline:
1. **Per-triangle BVH** built from `Scene.forEachMeshData()` —
reaches every CPU-side `MeshData` regardless of source. Median
split along longest axis, max 16 tris per leaf, flattened to a
`Float32Array` of 32-byte nodes during the build (no second
pass).
2. **Two GPU storage buffers** — nodes + triangles — uploaded once
per mesh-set change. Cached by a `(meshCount, totalPositions)`
fingerprint so re-running deviation against the same model is a
pure dispatch.
3. **Compute shader** with stack-based BVH descent (workgroup-size
64). Per point: descend BVH pruning by squared point-to-AABB
distance, run Ericson §5.1.5 closest-point-on-triangle on every
leaf candidate, output signed distance via the closest face's
precomputed normal.
4. **Per-chunk deviation buffer** allocated alongside the splat
vertex buffer (`STORAGE | VERTEX | COPY_DST`, 4 bytes per point,
zero-initialised). Compute reads the vertex buffer's positions
directly — no CPU copy of streamed clouds needed.
5. **Splat shader** gains a 2nd vertex buffer (location 4 = `f32`
deviation), a new `deviation` color mode, and a diverging
blue → white → red `deviation_ramp`. Uniform block grows by 16
bytes (new `deviationRange: vec4<f32>` slot for centre + half-
range), `POINT_UNIFORM_SIZE` 208 → 224.
6. **Public API** — `Renderer.computeDeviations({ maxRange?,
forceRebuild? })` returns `{ bvhTriangles, bvhNodes,
chunksProcessed, pointsProcessed, bounds, suggestedHalfRange }`.
Awaits `queue.onSubmittedWorkDone` so callers see populated
buffers when the promise resolves.
7. **UI** — new `DeviationPanel` inside `PointCloudPanel`. Compute
button (gated on `triangleCount > 0`), live progress + duration
readout, range slider in millimetres (1 mm to 1 m), inline
blue-white-red legend. Auto-suggests a half-range from the BVH
bbox (±max-extent / 1000) and auto-switches the colour mode to
`deviation` on success.
8. **Slice** — `pointCloudColorMode` gains `'deviation'`, plus
`pointCloudDeviationCenterOffset`, `pointCloudDeviationHalfRange`
(default ±5 cm), and `pointCloudDeviationComputed`. Sync hook
forwards the range to the renderer uniform.

Sign convention: positive = scan point is on the outward-normal
side of the closest triangle (typical "scan overshoots wall by
5 mm"). Negative = inside / behind. Non-watertight BIM (typical
IFC) means "inside the building" isn't globally defined, but
per-surface front/back is always meaningful.

Limitations / future work:
- The dispatch processes every uploaded point against every
triangle in the scene; isolated / hidden meshes still contribute
to the BVH. A `meshFilter` predicate is a natural follow-up.
- Histogram + auto-range from p5/p95 not yet implemented — the
default half-range suggestion is a coarse bbox/1000 heuristic.
Phase B will add a 2nd compute pass with atomic histogram.
- The BVH walk uses a 64-deep per-thread stack. Pathologically
unbalanced trees (>64 deep) silently drop the deepest branch.
Real BIMs don't get there; SAH or surface-area cost would help
if we ever hit it.

Verified: full repo typecheck (24/24), 655 viewer tests, viewer
Vite build green.
34 changes: 34 additions & 0 deletions .changeset/pointcloud-e57-multi-scan-pose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@ifc-lite/pointcloud": minor
---

E57 multi-scan pose merging — registered files now load.

Previously a multi-scan E57 with `<pose>` elements threw a clear
"re-export as merged" error. This change parses each Data3D's pose
(unit quaternion + translation) and applies it before merging, so
registered scans line up in the file's global frame.

Implementation:
- `Data3DEntry.hasPose: boolean` → `Data3DEntry.pose?: E57Pose`
carrying `{ rotation: {w,x,y,z}, translation: {x,y,z} }`.
- New `parsePoseElement` walks the `<pose><rotation/><translation/></pose>`
structure; non-finite values fall through to identity rather than
rejecting the whole file.
- New exported `applyPoseInPlace(positions, count, pose)` derives the
3×3 rotation matrix from the quaternion (Hamilton convention,
`w + xi + yj + zk`) and computes `out = R · in + T` per point.
- `decodeE57` applies the pose after `decodeE57Scan` returns and
recomputes bbox; identity / absent poses are no-ops.
- The "Multi-scan pose merging is not yet supported" rejection is
removed.

3 new tests:
- Pose extraction from XML (90°-around-Z quaternion + finite
translation, plus a no-pose sibling).
- `applyPoseInPlace` with a 90°-around-Z + translation, asserting
per-axis transforms.
- Identity pose round-trips positions unchanged.

Verified: 64 pointcloud unit tests pass, full repo typecheck (24/24),
viewer Vite build green.
44 changes: 44 additions & 0 deletions .changeset/pointcloud-e57-scaled-integer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@ifc-lite/pointcloud": minor
"@ifc-lite/renderer": patch
---
Comment thread
coderabbitai[bot] marked this conversation as resolved.

E57 ScaledInteger codec — bit-packed cartesian / intensity / colour.

ScaledInteger is the more compact encoding most real-world Faro,
Trimble, and Leica E57 exports use; previously we threw a clear
error on these files. This change implements the decoder so they
load directly.

Per spec ASTM E2807-11 §6.3.4:
- `bitsPerRecord = ceil(log2(maximum - minimum + 1))`
- Bytestream stores `raw_int = original − minimum` packed LSB-first
within each byte; decoded float = `(raw_int + minimum) * scale + offset`

Implementation:
- New `readBitsLE(bytes, bitOffset, bitsPerRecord)` walks a byte
buffer and reconstructs each value into a JS number using
`Math.pow(2, n)` instead of `<< n`, so precision holds up to 53
bits (covers every real exporter — LiDAR + survey kit tops out
around 32 bits). Wider fields throw a clear error.
- `readCartesianStream` and `readIntensityStream` now branch on
field kind: Float / Integer paths unchanged, ScaledInteger path
bit-walks per record.
- `writeColorChannel` extended with a ScaledInteger branch that
remaps `raw → [0, 1]` via the declared min/max range.
- Per-axis packet capacity computation now varies by field kind
(Float = `length / byteSize`, ScaledInteger = `length * 8 / bitsPerRecord`)
via `floatOrSiPointCapacity`.

The "ScaledInteger throws clearly" error is removed for cartesian,
intensity, and colour — all three now decode. The earlier multi-scan
pose rejection stays in place; that's a separate piece of work.

2 new tests:
- 8-bit ScaledInteger across all three cartesian axes (round-trip
through known raw values).
- 12-bit ScaledInteger that crosses byte boundaries (proves the
bit-pack walk is correct for non-multiples-of-8).

Verified: 63 pointcloud unit tests pass, full repo typecheck (24/24),
viewer Vite build green.
21 changes: 21 additions & 0 deletions .changeset/pointcloud-laz-wasm-mime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@ifc-lite/pointcloud": patch
"@ifc-lite/viewer": patch
---

Fix LAZ load failing with `WebAssembly: Response has unsupported MIME
type 'text/plain'` on real-world files (e.g. autzen-classified.laz).

`laz-perf`'s emscripten shim resolves the wasm via `locateFile()` and
calls `fetch("laz-perf.wasm")` relative to its own script directory.
In a Vite-bundled module worker that path becomes `/assets/<chunk>/…`
or just `/laz-perf.wasm` — both 404, and the SPA fallback returns
`index.html` as `text/plain`, which `instantiateStreaming` rightly
rejects. The async fallback then 404s the same way and aborts.

`loadLazPerf` now resolves the wasm asset URL through Vite's
`?url` import (`laz-perf/lib/web/laz-perf.wasm?url`), pre-fetches the
bytes itself, and hands them to emscripten as `Module.wasmBinary` so
the shim's own fetch is bypassed entirely. Failure modes (asset
resolution, fetch HTTP error) now produce a precise error message
naming the URL and status instead of the opaque emscripten "Aborted".
40 changes: 40 additions & 0 deletions .changeset/pointcloud-near-term-correctness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@ifc-lite/pointcloud": patch
"@ifc-lite/renderer": patch
"@ifc-lite/viewer": patch
Comment thread
coderabbitai[bot] marked this conversation as resolved.
---

Near-term batch — correctness + robustness items from #611.

**`computeBBox` empty / non-finite guards.** Both `e57.ts` and
`ifcx-points.ts` now return `{0,0,0}/{0,0,0}` for empty arrays and
skip non-finite triplets. Previously a zero-point or NaN-poisoned
chunk produced ±Infinity bounds that broke camera fit-to-view and
section-plane sliders.

**Magic-byte-first format detection.** `detectPointCloudFormat` now
probes the buffer (E57 magic, LASF magic, "ply" / "#" / ".PCD"
ASCII tokens) before falling back to extension. A LAS file
mistakenly named `*.ply` no longer goes down the wrong decoder. LAS
vs LAZ still uses the extension to disambiguate (they share the
LASF magic).

**E57 packet-bounds + per-stream guards.** Validate that the
DataPacket header, bytestream-length table, and each individual
bytestream stay inside `payloadEnd = packetEnd - 4` before reading.
Corrupt files now fail with a precise "bytestream X runs past
packet payload" error instead of silently reading into the next
packet.

**`e57.ts` split (631 → 4 files).** `e57-page.ts` (header / page CRC
/ section-header resolver), `e57-xml.ts` (prototype + Data3D
parser), `e57-decode.ts` (per-scan binary decoder), `e57.ts`
(orchestrator + re-exports). All four under the AGENTS ~400-line
guideline.

**`point-cloud-renderer.ts` extract.** Pulled the uniform-block
writer into `point-cloud-uniforms.ts` (`writePointCloudUniforms` +
mode index maps). Renderer drops below 400 lines.

Verified: 62 pointcloud unit tests pass, full repo typecheck
(24/24).
36 changes: 36 additions & 0 deletions .changeset/pointcloud-near-term-ux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@ifc-lite/renderer": minor
"@ifc-lite/viewer": minor
---

Near-term UX features from #611.

**Hover XYZ readback.** GPU pick now also samples the depth texel at
the click position and unprojects it through the inverse view-
projection. `PickResult` carries an optional `worldXYZ`. Reverse-Z is
honoured (depth=1 = near, 0 = far / miss). The hover tooltip shows
`x, y, z` (2 decimals) under the entity id. Useful for measurement
hooks and point-cloud picks where the synthetic entity has no
surface property to display.

**Solid-color picker.** When the point-cloud panel's colour mode is
set to `fixed`, a native `<input type="color">` swatch appears.
Hex round-trips through the existing `[r,g,b,a]` store tuple.

**Colour-mode legend.** A new `PointCloudLegend` component renders
inline beneath the colour-mode buttons:
- Classification → list of ASPRS LAS 1.4 class id / colour swatch /
label (Ground, Vegetation, Building, ...). Palette mirrors
`point-shader.wgsl.ts` exactly.
- Intensity → black-to-white gradient bar with low/high labels.
- Height → cool-warm gradient bar (blue → cyan → green → yellow →
red), matching the shader's `height_ramp`.
RGB and Solid don't render a legend.

**Cancel button for in-flight streams.** New
`activeStreamCanceller` field on the loading slice. Both ingest
sites (`useIfcLoader`, `useIfcFederation`) register
`() => streamHandle.cancel()` after starting and clear on success /
error. `StatusBar` shows a Cancel button while the canceller is
non-null. AbortError on cancel is reported as "Cancelled" rather
than a scary error string.
36 changes: 36 additions & 0 deletions .changeset/pointcloud-pr614-review-r2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@ifc-lite/pointcloud": patch
"@ifc-lite/viewer": patch
---

Round 2 of CodeRabbit feedback on PR #614:

- **E57 stride downsampling drops classifications.** `applyStride` rebuilt
positions / colors / intensities into new arrays but never copied the
per-point class IDs, so any non-default stride (`{ stride: 2 }` and up)
silently lost them and `hasClassification` flipped to false.
- **Federation abort can stomp a newer load.** The AbortError handler in
`useIfcFederation.addModel()` wrote `progress`, `error`, and `loading`
unconditionally — if a second `addModel()` started after the first was
cancelled, it lost its spinner and progress to the cancelled load's
cleanup. Added a `loadSessionRef` token (mirrors `useIfcLoader`) and
gate state writes on `loadSessionRef.current === currentSession`.
- **E57 Integer classification subtracts `minimum`.** Class IDs are
absolute labels (ASPRS LAS 1.4 0..31), not range-normalised offsets.
`raw - minimum` was corrupting class IDs whenever a producer declared
a non-zero `minimum` on the Integer-encoded classification field. The
Integer branch now matches the ScaledInteger branch's intent: keep
the raw byte, clamp to 0..255.
- **PCD probe missed `VERSION` / `FIELDS` headers.** The magic-byte
detector only recognised `# .PCD …` comment-style headers. Real PCDs
emitted by PCL's `pcl_io` and a few third-party tools start directly
with `VERSION 0.7\n…` or `FIELDS x y z\n…` — these now route through
the PCD decoder instead of falling through to extension-based
detection (which would mis-route a renamed PCD).
- **Catch-block logging.** Per repo convention, log point-cloud ingest
failures in `useIfcLoader.ts` before the early return so abort vs.
real-failure vs. stale-session paths are distinguishable in console
triage.

Test cleanup: drop the shadowed (and unused) ScaledInteger packet
buffer in `e57.test.ts` so only the live `fullBuf` setup remains.
39 changes: 39 additions & 0 deletions .changeset/pointcloud-pts-xyz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@ifc-lite/pointcloud": minor
"@ifc-lite/viewer": minor
---

PTS / XYZ ASCII point cloud reader.

Both formats are line-oriented plain-text scans common in legacy
survey workflows. They share the same syntax — they differ only in
the optional first-line point count (PTS may have one; XYZ never
does). One shared decoder + streaming source handles both.

Auto-detected per-line layouts (by column count of the first data
line):
- 3 cols → `X Y Z`
- 4 cols → `X Y Z I` (intensity)
- 6 cols → `X Y Z R G B`
- 7 cols → `X Y Z I R G B` (canonical PTS)
- 9 cols → `X Y Z R G B Nx Ny Nz` (XYZ-with-normals; normals dropped)
- 10 cols → `X Y Z I R G B Nx Ny Nz` (PTS-with-normals; normals dropped)
- For XYZ with unknown column counts ≥3 we still emit positions and
skip the rest, so weird custom exports load instead of erroring.

Other behaviour:
- Comment lines (`#`, `//`) and blank lines are skipped.
- Intensity normalisation: 0..1 vs 0..255 vs raw sensor detected from
the observed maximum, then mapped to u16.
- RGB normalisation: same heuristic (>1.0 → 0..255 source).
- Whole-file decode wrapped in `AsciiPointsStreamingSource`; the
streaming host's 25M-point cap stride-downsamples on the way out.

Wired into the decode worker, format detection
(`detectPointCloudFormat` returns `'pts'` / `'xyz'`), the file
picker accept lists, drop handlers, and both `useIfcLoader` /
`useIfcFederation` ingest branches. The "PTS / XYZ ASCII points —
not yet supported" toast is removed from `describeUnsupportedFormat`.

10 new unit tests cover layout probing, decoder round-trips for the
common shapes, and the comment / header-count edge cases.
Loading
Loading