@ifc-lite/viewer 1.25.1 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.turbo/turbo-build.log +83 -85
  2. package/CHANGELOG.md +104 -0
  3. package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-DP2AK1-_.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/exporters-DSq76AVM.js +4687 -0
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-By06vdeL.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-DDkkb4mo.js → ids-DiLcGTer.js} +4 -4
  10. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-CqBdDOAZ.js → index-BAH8IJVR.js} +39550 -36936
  13. package/dist/assets/{jpeg-B4IBTphL.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-CtdH775t.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-DG3zn49C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/parser.worker-8md211IW.js +182 -0
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-CsRXlgCO.js} +4715 -3081
  21. package/dist/assets/{server-client-D9xO_8yX.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-_-qCDjkn.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-DlfgC8gA.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +23 -21
  26. package/src/App.tsx +4 -0
  27. package/src/components/extensions/FlavorDialog.tsx +18 -2
  28. package/src/components/extensions/FlavorListView.tsx +12 -3
  29. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  30. package/src/components/viewer/ClashPanel.tsx +370 -0
  31. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  32. package/src/components/viewer/CommandPalette.tsx +19 -16
  33. package/src/components/viewer/MainToolbar.tsx +155 -153
  34. package/src/components/viewer/ViewerLayout.tsx +5 -0
  35. package/src/components/viewer/Viewport.tsx +97 -12
  36. package/src/components/viewer/ViewportContainer.tsx +45 -3
  37. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  38. package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
  39. package/src/components/viewer/useGeometryStreaming.ts +134 -19
  40. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
  41. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
  42. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  43. package/src/hooks/ingest/streamCleanup.ts +45 -0
  44. package/src/hooks/ingest/viewerModelIngest.ts +118 -52
  45. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  46. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  47. package/src/hooks/useAlignmentLines3D.ts +164 -0
  48. package/src/hooks/useClash.ts +420 -0
  49. package/src/hooks/useIfcCache.ts +44 -18
  50. package/src/hooks/useIfcFederation.ts +16 -2
  51. package/src/hooks/useIfcLoader.ts +6 -30
  52. package/src/hooks/useSymbolicAnnotations.ts +170 -35
  53. package/src/lib/clash/persistence.ts +308 -0
  54. package/src/lib/geo/effective-georef.test.ts +66 -0
  55. package/src/services/extensions/host.ts +13 -0
  56. package/src/store/constants.ts +38 -14
  57. package/src/store/index.ts +29 -7
  58. package/src/store/slices/clashSlice.ts +251 -0
  59. package/src/store/slices/visibilitySlice.test.ts +23 -5
  60. package/src/store/slices/visibilitySlice.ts +19 -8
  61. package/src/store/types.ts +9 -0
  62. package/src/utils/serverDataModel.test.ts +51 -1
  63. package/src/utils/serverDataModel.ts +2 -26
  64. package/vite.config.ts +0 -5
  65. package/dist/assets/exporters-CZe0D8N-.js +0 -5957
  66. package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
  67. package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
  68. package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
  69. package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
  70. package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
  71. package/dist/assets/index-Bws3UAkj.css +0 -1
  72. package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
  73. package/dist/assets/raw-DY7Y_acr.js +0 -1
  74. package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
  75. package/dist/assets/workerHelpers-Crstj4Oa.js +0 -36
@@ -39,6 +39,26 @@ const DEFAULT_COORDINATE_INFO: CoordinateInfo = {
39
39
  hasLargeCoordinates: false,
40
40
  };
41
41
 
42
+ type Vec3Bounds = { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } };
43
+
44
+ /** True for a real (non-placeholder, non-degenerate) bounds box. */
45
+ function isUsableBounds(b: Vec3Bounds | undefined): b is Vec3Bounds {
46
+ if (!b) return false;
47
+ return (
48
+ b.max.x > b.min.x || b.max.y > b.min.y || b.max.z > b.min.z
49
+ );
50
+ }
51
+
52
+ /** Axis-aligned union of two bounds boxes (either may be undefined). */
53
+ function unionBounds(acc: Vec3Bounds | undefined, b: Vec3Bounds | undefined): Vec3Bounds | undefined {
54
+ if (!isUsableBounds(b)) return acc;
55
+ if (!acc) return { min: { ...b.min }, max: { ...b.max } };
56
+ return {
57
+ min: { x: Math.min(acc.min.x, b.min.x), y: Math.min(acc.min.y, b.min.y), z: Math.min(acc.min.z, b.min.z) },
58
+ max: { x: Math.max(acc.max.x, b.max.x), y: Math.max(acc.max.y, b.max.y), z: Math.max(acc.max.z, b.max.z) },
59
+ };
60
+ }
61
+
42
62
  export function ViewportContainer() {
43
63
  // Drive Stacked / Solo / Exploded level display from the slice.
44
64
  // Mount-once hook — it self-gates on mode + gap + model changes.
@@ -121,7 +141,16 @@ export function ViewportContainer() {
121
141
  if (storeModels.size > 1) {
122
142
  let totalVertices = 0;
123
143
  let totalTriangles = 0;
124
- let mergedCoordinateInfo: CoordinateInfo | undefined;
144
+ // The merged coordinateInfo must cover ALL visible models, not just the
145
+ // first one — the renderer fits the camera to `shiftedBounds`, so a
146
+ // first-wins box left every model after the first off-screen (it only
147
+ // showed its 2D grid overlay). Union the bounds across visible models;
148
+ // keep the first model's frame metadata (originShift / RTC) since
149
+ // federated models share a coordinate frame.
150
+ let baseCoordInfo: CoordinateInfo | undefined;
151
+ let unionedShifted: Vec3Bounds | undefined;
152
+ let unionedOriginal: Vec3Bounds | undefined;
153
+ let anyLargeCoords = false;
125
154
  let shouldRebuild = false;
126
155
 
127
156
  if (mergedLengthsRef.current.size !== storeModels.size) {
@@ -142,8 +171,12 @@ export function ViewportContainer() {
142
171
  const meshCount = model.visible ? (modelGeometry?.meshes.length ?? 0) : 0;
143
172
  totalVertices += model.visible ? (modelGeometry?.totalVertices ?? 0) : 0;
144
173
  totalTriangles += model.visible ? (modelGeometry?.totalTriangles ?? 0) : 0;
145
- if (!mergedCoordinateInfo && model.visible && modelGeometry?.coordinateInfo) {
146
- mergedCoordinateInfo = modelGeometry.coordinateInfo;
174
+ if (model.visible && modelGeometry?.coordinateInfo) {
175
+ const ci = modelGeometry.coordinateInfo;
176
+ if (!baseCoordInfo) baseCoordInfo = ci;
177
+ anyLargeCoords = anyLargeCoords || !!ci.hasLargeCoordinates;
178
+ unionedShifted = unionBounds(unionedShifted, ci.shiftedBounds);
179
+ unionedOriginal = unionBounds(unionedOriginal, ci.originalBounds);
147
180
  }
148
181
 
149
182
  if (
@@ -187,6 +220,15 @@ export function ViewportContainer() {
187
220
  }
188
221
  }
189
222
 
223
+ const mergedCoordinateInfo: CoordinateInfo | undefined = baseCoordInfo
224
+ ? {
225
+ ...baseCoordInfo,
226
+ originalBounds: unionedOriginal ?? baseCoordInfo.originalBounds,
227
+ shiftedBounds: unionedShifted ?? baseCoordInfo.shiftedBounds,
228
+ hasLargeCoordinates: anyLargeCoords,
229
+ }
230
+ : undefined;
231
+
190
232
  return {
191
233
  meshes: mergedCacheRef.current,
192
234
  totalVertices,
@@ -128,7 +128,7 @@ export function BCFOverlay() {
128
128
  const bcfProject = useViewerStore((s) => s.bcfProject);
129
129
  const activeTopicId = useViewerStore((s) => s.activeTopicId);
130
130
  const setActiveTopic = useViewerStore((s) => s.setActiveTopic);
131
- const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
131
+ const openWorkspacePanel = useViewerStore((s) => s.openWorkspacePanel);
132
132
  const models = useViewerStore((s) => s.models);
133
133
  const loading = useViewerStore((s) => s.loading);
134
134
  const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
@@ -239,10 +239,11 @@ export function BCFOverlay() {
239
239
  if (!overlay) return;
240
240
  return overlay.onMarkerClick((topicGuid) => {
241
241
  setActiveTopic(topicGuid);
242
- const panelVisible = useViewerStore.getState().bcfPanelVisible;
243
- if (!panelVisible) setBcfPanelVisible(true);
242
+ // Open BCF exclusively so clicking a marker brings it to the front over any
243
+ // other right panel (e.g. clash), instead of leaving it behind.
244
+ openWorkspacePanel('bcf');
244
245
  });
245
- }, [overlayReady, setActiveTopic, setBcfPanelVisible]);
246
+ }, [overlayReady, setActiveTopic, openWorkspacePanel]);
246
247
 
247
248
  return (
248
249
  <div
@@ -17,6 +17,20 @@ export const IFC_ICON_CODEPOINTS: Record<string, string> = {
17
17
  IfcBuilding: '\uea40',
18
18
  IfcBuildingStorey: '\ue8fe',
19
19
  IfcSpace: '\ueff4',
20
+ // IFC4.3 facility containers \u2014 same family as IfcBuilding (multi-storey
21
+ // spatial root) but `domain` carries the "campus / infrastructure
22
+ // facility" reading; IfcFacilityPart follows the storey-line icon for
23
+ // consistency. (Issue #860 \u2014 user reported no icon on IfcFacility.)
24
+ IfcFacility: '\ue7ee', // "domain"
25
+ IfcFacilityPart: '\ue8fe', // "layers" \u2014 mirrors IfcBuildingStorey
26
+ IfcBridge: '\uebbf', // "directions_railway" \u2014 civil bridge icon
27
+ IfcBridgePart: '\ue8fe',
28
+ IfcRoad: '\uebbe', // "route"
29
+ IfcRoadPart: '\ue8fe',
30
+ IfcRailway: '\ue570', // "train"
31
+ IfcRailwayPart: '\ue8fe',
32
+ IfcMarineFacility: '\ue532', // "directions_boat"
33
+ IfcMarineFacilityPart: '\ue8fe',
20
34
 
21
35
  // Structural
22
36
  IfcBeam: '\uf108',
@@ -80,6 +94,52 @@ export const IFC_ICON_CODEPOINTS: Record<string, string> = {
80
94
  IfcGeographicElement: '\uea99',
81
95
  IfcLinearElement: '\uebaa',
82
96
 
97
+ // IFC4.3 alignment / positioning. IfcAlignment shares the linear-element
98
+ // glyph because that's exactly what it is at the geometry level (a
99
+ // parameterised curve). IfcReferent is a station marker along that
100
+ // alignment (mileposts, kilometre posts) \u2014 pin glyph. IfcPositioningElement
101
+ // is the abstract base.
102
+ IfcAlignment: '\uebaa', // "polyline" / linear scale
103
+ IfcPositioningElement: '\ue55f', // "place"
104
+ IfcReferent: '\ue55f', // "place" \u2014 station marker
105
+
106
+ // IFC4.3 transportation signage & signals (rail/road). Same traffic-light
107
+ // glyph for both since the spec treats signals as the trackside subtype of
108
+ // signs.
109
+ IfcSign: '\ue9b2', // "traffic"
110
+ IfcSignal: '\ue9b2',
111
+
112
+ // IFC4.3 road / rail wearing surface. Pavement is the assembly, courses
113
+ // are its layers, kerbs sit at the edge.
114
+ IfcPavement: '\ue4f4', // "texture"
115
+ IfcCourse: '\ue8fe', // "layers"
116
+ IfcKerb: '\uf108', // "horizontal_rule"
117
+
118
+ // IFC4.3 earthworks. Cut/Fill share the geotechnical "terrain" glyph
119
+ // since they're shape-of-ground operations on the same domain.
120
+ IfcEarthworksElement: '\ue564',
121
+ IfcEarthworksFill: '\ue564',
122
+ IfcEarthworksCut: '\ue564',
123
+
124
+ // Geotechnical strata (IFC4.3) \u2014 issue #860. The abstract base plus the
125
+ // three concrete leaves (IfcSolidStratum / IfcVoidStratum / IfcWaterStratum)
126
+ // all share the `terrain` glyph. The geometry pipeline routes the leaves
127
+ // through IfcGeotechnicalStratum via legacy_entities.rs, so the icon map
128
+ // covers both the leaf names (when entries land in the spatial tree with
129
+ // their original type string) and the base.
130
+ IfcGeotechnicalAssembly: '\ue564',
131
+ IfcGeotechnicalElement: '\ue564',
132
+ IfcGeotechnicalStratum: '\ue564',
133
+ IfcSolidStratum: '\ue564',
134
+ IfcVoidStratum: '\ue564',
135
+ IfcWaterStratum: '\ue564',
136
+
137
+ // IFC4.3 marine / navigation / track / vehicle leaves.
138
+ IfcMooringDevice: '\uf1cd', // "anchor"
139
+ IfcNavigationElement: '\ue55d', // "navigation"
140
+ IfcTrackElement: '\ue260', // "linear_scale"
141
+ IfcVehicle: '\ue531', // "directions_car"
142
+
83
143
  // Proxy / generic fallback
84
144
  IfcProduct: '\ue047',
85
145
  IfcBuildingElementProxy: '\ue047',
@@ -22,6 +22,13 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
22
22
  import type { Renderer } from '@ifc-lite/renderer';
23
23
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
24
24
  import { logToDesktopTerminal } from '@/services/desktop-logger';
25
+ import { toast } from '../ui/toast.js';
26
+
27
+ // Session-scoped flag so the linear-infrastructure hint fires at most once
28
+ // per page load (model swaps included). Stored at module scope rather than
29
+ // in component state because federation re-mounts the streaming hook on
30
+ // every model load — a useRef wouldn't survive.
31
+ let linearFitHintShown = false;
25
32
 
26
33
  export interface UseGeometryStreamingParams {
27
34
  rendererRef: MutableRefObject<Renderer | null>;
@@ -39,6 +46,10 @@ export interface UseGeometryStreamingParams {
39
46
  geometryContentVersion?: number;
40
47
  coordinateInfo?: CoordinateInfo;
41
48
  isStreaming: boolean;
49
+ /** Number of loaded models. When this increases (a model was added to the
50
+ * federation) the camera must refit to the new combined bounds — otherwise
51
+ * it stays framed on the first model and the newly-added one is off-screen. */
52
+ modelCount?: number;
42
53
  geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
43
54
  pendingMeshColorUpdates: Map<number, [number, number, number, number]> | null;
44
55
  pendingColorUpdates: Map<number, [number, number, number, number]> | null;
@@ -88,6 +99,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
88
99
  geometryContentVersion,
89
100
  coordinateInfo,
90
101
  isStreaming,
102
+ modelCount = 0,
91
103
  geometryBoundsRef,
92
104
  pendingMeshColorUpdates,
93
105
  pendingColorUpdates,
@@ -109,8 +121,13 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
109
121
  const cameraFittedRef = useRef(false);
110
122
  const finalBoundsRefittedRef = useRef(false);
111
123
  const cameraSnapshotRef = useRef<{ px: number; py: number; pz: number; tx: number; ty: number; tz: number } | null>(null);
124
+ // Tracks which fit branch the post-load auto-fit took. Linear models get a
125
+ // one-time status-line hint via the viewer store; the home button can also
126
+ // mirror the same policy on re-press without re-deriving the bbox shape.
127
+ const lastFitPolicyKindRef = useRef<'compact' | 'linear' | null>(null);
112
128
  const prevIsStreamingRef = useRef(isStreaming);
113
129
  const lastContentVersionRef = useRef(geometryContentVersion ?? 0);
130
+ const prevModelCountRef = useRef(modelCount);
114
131
  const queuePumpTimerRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(null);
115
132
 
116
133
  // Only activate the timer-based queue pump when the tab is background-throttled
@@ -189,6 +206,20 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
189
206
  }
190
207
  }
191
208
 
209
+ // A model was added to the federation — refit the camera to the new
210
+ // combined bounds. Without this, `cameraFittedRef` stays true from the
211
+ // first model's fit, so the newly-added model renders off-screen and only
212
+ // its 2D grid overlay shows. Refit only on an INCREASE (a model added),
213
+ // and never mid-stream (the streaming first-fit + finalize refit handle
214
+ // the active model). The combined bounds come from the merged
215
+ // coordinateInfo (union of all visible models).
216
+ if (modelCount > prevModelCountRef.current && !isStreaming) {
217
+ traceGeometrySync(`model added (${prevModelCountRef.current}→${modelCount}) — refitting camera to combined bounds`);
218
+ cameraFittedRef.current = false;
219
+ finalBoundsRefittedRef.current = false;
220
+ }
221
+ prevModelCountRef.current = modelCount;
222
+
192
223
  // Read AFTER the optional reset above so the classification below reflects
193
224
  // the post-reset state (otherwise an in-place update gets misclassified as
194
225
  // "no change" and returns early at currentLength === lastLength).
@@ -244,7 +275,24 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
244
275
  geometryBoundsRef.current = { ...DEFAULT_BOUNDS };
245
276
  }
246
277
  } else if (currentLength === lastLength) {
247
- return; // No change
278
+ // No mesh-count change, so the queueMeshes / appendToBatches block
279
+ // below would be a no-op. But we MUST still reach the camera-fit
280
+ // block — the streaming-complete re-render (isStreaming flips
281
+ // false, geometry array length stays at the final mesh count)
282
+ // arrives here, and that's the FIRST render where path 2
283
+ // (`computeBounds(geometry)` fallback when shiftedBounds is empty)
284
+ // is allowed to fire. Pre-fix the early return short-circuited
285
+ // the camera fit entirely; the user reported 33 meshes streamed
286
+ // with the viewport stuck at the default ±100 m bounds (issue
287
+ // #859 / PR #871 deploy preview, `linear-placement-of-signal.ifc`).
288
+ //
289
+ // Skip only when the camera is already fitted or there's nothing
290
+ // to fit to.
291
+ if (cameraFittedRef.current || currentLength === 0) {
292
+ return;
293
+ }
294
+ // Otherwise fall through so the camera-fit block at the bottom of
295
+ // the effect gets a chance to run.
248
296
  }
249
297
 
250
298
  // Visibility toggle while NOT streaming — array rebuilt from scratch
@@ -302,31 +350,77 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
302
350
  lastGeometryLengthRef.current = currentLength;
303
351
 
304
352
  // ── Fit camera ──
305
- if (!cameraFittedRef.current && coordinateInfo?.shiftedBounds) {
306
- const sb = coordinateInfo.shiftedBounds;
307
- const maxSize = Math.max(sb.max.x - sb.min.x, sb.max.y - sb.min.y, sb.max.z - sb.min.z);
308
- if (maxSize > 0 && Number.isFinite(maxSize)) {
309
- renderer.getCamera().fitToBounds(sb.min, sb.max);
310
- geometryBoundsRef.current = { min: { ...sb.min }, max: { ...sb.max } };
311
- cameraFittedRef.current = true;
312
- const pos = renderer.getCamera().getPosition();
313
- const tgt = renderer.getCamera().getTarget();
314
- cameraSnapshotRef.current = { px: pos.x, py: pos.y, pz: pos.z, tx: tgt.x, ty: tgt.y, tz: tgt.z };
353
+ //
354
+ // Pre-#871 the branching here was structured as
355
+ // if (coordinateInfo?.shiftedBounds) { try to fit }
356
+ // else if (geometry.length > 0) { fall back }
357
+ // but `coordinateInfo.shiftedBounds` is ALWAYS truthy — the wasm
358
+ // bridge ships a default `{ min: 0, max: 0 }` placeholder before
359
+ // any real bounds get computed. The outer `if` therefore won
360
+ // every time, the inner `maxSize > 0` failed, and the `else if`
361
+ // fallback NEVER fired. Result: the camera stayed at the default
362
+ // (0, 0, 0) framing while linearly-placed railway geometry sat at
363
+ // its MGA-territory world coords (~330, 123 after RTC), invisible
364
+ // to the user. Compute the size first so the branch reflects
365
+ // whether the data is actually usable, not just whether the
366
+ // property exists.
367
+ if (!cameraFittedRef.current) {
368
+ // The adaptive fit picks an SE-isometric pose for compact models
369
+ // (today's behaviour) but switches to a side-on-along-the-alignment
370
+ // pose for high-aspect-ratio bboxes (railway / road corridors).
371
+ // Without the switch, a 932 × 0.75 × 428 m alignment auto-fits to a
372
+ // ~1864 m distance where every 1 m signal projects to a sub-pixel
373
+ // dot — the user sees a blank viewport even though geometry is in
374
+ // the scene. See packages/renderer/src/camera-fit-policy.ts.
375
+ let fitted = false;
376
+ const sb = coordinateInfo?.shiftedBounds;
377
+ if (sb) {
378
+ const maxSize = Math.max(sb.max.x - sb.min.x, sb.max.y - sb.min.y, sb.max.z - sb.min.z);
379
+ if (maxSize > 0 && Number.isFinite(maxSize)) {
380
+ const canvas = renderer.getCanvas();
381
+ const canvasShort = Math.min(canvas?.height ?? 0, canvas?.width ?? 0);
382
+ const policy = renderer.getCamera().fitBoundsAdaptive(
383
+ { min: sb.min, max: sb.max },
384
+ { viewportShortPx: canvasShort > 0 ? canvasShort : undefined },
385
+ );
386
+ geometryBoundsRef.current = { min: { ...sb.min }, max: { ...sb.max } };
387
+ lastFitPolicyKindRef.current = policy.kind;
388
+ fitted = true;
389
+ }
315
390
  }
316
- } else if (!cameraFittedRef.current && geometry.length > 0 && !isStreaming) {
317
- const bounds = computeBounds(geometry);
318
- if (bounds) {
319
- renderer.getCamera().fitToBounds(bounds.min, bounds.max);
320
- geometryBoundsRef.current = bounds;
391
+ if (!fitted && geometry.length > 0 && !isStreaming) {
392
+ const bounds = computeBounds(geometry);
393
+ if (bounds) {
394
+ const canvas = renderer.getCanvas();
395
+ const canvasShort = Math.min(canvas?.height ?? 0, canvas?.width ?? 0);
396
+ const policy = renderer.getCamera().fitBoundsAdaptive(
397
+ bounds,
398
+ { viewportShortPx: canvasShort > 0 ? canvasShort : undefined },
399
+ );
400
+ geometryBoundsRef.current = bounds;
401
+ lastFitPolicyKindRef.current = policy.kind;
402
+ fitted = true;
403
+ }
404
+ }
405
+ if (fitted) {
321
406
  cameraFittedRef.current = true;
322
407
  const pos = renderer.getCamera().getPosition();
323
408
  const tgt = renderer.getCamera().getTarget();
324
409
  cameraSnapshotRef.current = { px: pos.x, py: pos.y, pz: pos.z, tx: tgt.x, ty: tgt.y, tz: tgt.z };
410
+ // One-time hint for linear-infrastructure models. The side-on auto-fit
411
+ // shows a slice of the alignment at a useful zoom — but the FULL
412
+ // alignment is much longer than what fits on screen, so users need
413
+ // to know to pan / use Frame Selection to inspect remote stations.
414
+ // Hint is module-scoped so model swaps within one session don't spam.
415
+ if (lastFitPolicyKindRef.current === 'linear' && !linearFitHintShown) {
416
+ linearFitHintShown = true;
417
+ toast.info('Linear infrastructure — pan along the alignment, or select an element and press F to zoom in');
418
+ }
325
419
  }
326
420
  }
327
421
 
328
422
  renderer.requestRender();
329
- }, [geometry, geometryVersion, geometryContentVersion, coordinateInfo, isInitialized, isStreaming]);
423
+ }, [geometry, geometryVersion, geometryContentVersion, coordinateInfo, isInitialized, isStreaming, modelCount]);
330
424
 
331
425
  useEffect(() => {
332
426
  return () => {
@@ -363,14 +457,35 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
363
457
  `finalize start geometryLength=${capturedGeometry?.length ?? 0} releaseAfterFinalize=${releaseGeometryAfterFinalize}`
364
458
  );
365
459
 
366
- // Compute exact bounds and refit camera (fast ~15ms scan)
460
+ // Compute exact bounds and refit camera (fast ~15ms scan). Use
461
+ // the adaptive policy so linear-infrastructure models keep the
462
+ // side-on pose chosen by the early-fit branch — without this,
463
+ // the streaming-complete refit reverts to the legacy
464
+ // `fitToBounds` (SE isometric at `maxSize * 2`), undoing the
465
+ // useful close-in framing and putting the camera back at the
466
+ // sub-pixel distance for railway / road corridors.
367
467
  if (cameraFittedRef.current && !finalBoundsRefittedRef.current && capturedGeometry && capturedGeometry.length > 0) {
368
468
  const t0 = performance.now();
369
469
  const exactBounds = computeBounds(capturedGeometry);
370
470
  console.log(`[GeomStream] computeBounds: ${(performance.now() - t0).toFixed(0)}ms`);
371
471
  if (exactBounds) {
372
472
  if (!userMovedCamera(r, cameraSnapshotRef.current)) {
373
- r.getCamera().fitToBounds(exactBounds.min, exactBounds.max);
473
+ const canvas = r.getCanvas();
474
+ const canvasShort = Math.min(canvas?.height ?? 0, canvas?.width ?? 0);
475
+ const policy = r.getCamera().fitBoundsAdaptive(
476
+ exactBounds,
477
+ { viewportShortPx: canvasShort > 0 ? canvasShort : undefined },
478
+ );
479
+ lastFitPolicyKindRef.current = policy.kind;
480
+ // Update the snapshot so a subsequent userMovedCamera check
481
+ // doesn't fire against the new pose's own delta.
482
+ const pos = r.getCamera().getPosition();
483
+ const tgt = r.getCamera().getTarget();
484
+ cameraSnapshotRef.current = { px: pos.x, py: pos.y, pz: pos.z, tx: tgt.x, ty: tgt.y, tz: tgt.z };
485
+ if (policy.kind === 'linear' && !linearFitHintShown) {
486
+ linearFitHintShown = true;
487
+ toast.info('Linear infrastructure — pan along the alignment, or select an element and press F to zoom in');
488
+ }
374
489
  }
375
490
  geometryBoundsRef.current = exactBounds;
376
491
  finalBoundsRefittedRef.current = true;
@@ -0,0 +1,61 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+
8
+ import { resolveDataStoreOrAbort } from './resolveDataStoreOrAbort.js';
9
+
10
+ const isAbortError = (err: unknown): boolean =>
11
+ err instanceof DOMException && err.name === 'AbortError';
12
+
13
+ describe('resolveDataStoreOrAbort', () => {
14
+ it('returns the parse result when not aborted', async () => {
15
+ const store = { id: 'store' };
16
+ const result = await resolveDataStoreOrAbort(Promise.resolve(store), { aborted: false });
17
+ assert.equal(result, store);
18
+ });
19
+
20
+ it('throws AbortError and terminates without awaiting a blocked parse', async () => {
21
+ let terminated = false;
22
+ // A promise that never settles — mirrors a worker parse blocked on
23
+ // waitForEntityIndex after the geometry loop was cancelled. The previous
24
+ // code awaited this directly and hung forever.
25
+ const neverSettles = new Promise<unknown>(() => {});
26
+
27
+ await assert.rejects(
28
+ resolveDataStoreOrAbort(neverSettles, {
29
+ aborted: true,
30
+ terminate: () => {
31
+ terminated = true;
32
+ },
33
+ }),
34
+ isAbortError,
35
+ );
36
+
37
+ assert.equal(terminated, true, 'the worker parser should be terminated on abort');
38
+ });
39
+
40
+ it('swallows the abandoned parse rejection on abort', async () => {
41
+ // A parse that rejects after we bail must not surface as an unhandled
42
+ // rejection (this test would fail the process if the .catch guard were
43
+ // removed from resolveDataStoreOrAbort).
44
+ const rejecting = Promise.reject(new Error('worker died after abort'));
45
+
46
+ await assert.rejects(
47
+ resolveDataStoreOrAbort(rejecting, { aborted: true }),
48
+ isAbortError,
49
+ );
50
+
51
+ // Give the swallowed rejection a tick to settle.
52
+ await new Promise((resolve) => setTimeout(resolve, 10));
53
+ });
54
+
55
+ it('works without a terminate callback', async () => {
56
+ await assert.rejects(
57
+ resolveDataStoreOrAbort(new Promise<unknown>(() => {}), { aborted: true }),
58
+ isAbortError,
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,28 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Resolve a parse promise, unless the load was cancelled.
7
+ *
8
+ * A worker parse started with `waitForEntityIndex` blocks until the streaming
9
+ * geometry pre-pass hands over the entity index. If the geometry loop is
10
+ * cancelled before that handoff, the index never arrives and the parse promise
11
+ * never settles — awaiting it would hang the whole ingest. On abort we instead
12
+ * terminate the worker, abandon (and swallow) the parse promise, and throw an
13
+ * `AbortError` so callers treat it as a clean cancellation (matching the
14
+ * federated loader's `err.name === 'AbortError'` convention).
15
+ */
16
+ export async function resolveDataStoreOrAbort<T>(
17
+ parsePromise: Promise<T>,
18
+ opts: { aborted: boolean; terminate?: () => void },
19
+ ): Promise<T> {
20
+ if (opts.aborted) {
21
+ opts.terminate?.();
22
+ // Swallow the abandoned parse's eventual rejection so it doesn't surface
23
+ // as an unhandled rejection after we've already bailed out.
24
+ void parsePromise.catch(() => {});
25
+ throw new DOMException('Model load aborted', 'AbortError');
26
+ }
27
+ return parsePromise;
28
+ }
@@ -0,0 +1,41 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+
8
+ import { boundedIteratorReturn } from './streamCleanup.js';
9
+
10
+ describe('boundedIteratorReturn', () => {
11
+ it('resolves promptly even when return() never settles (the stalled-worker case)', async () => {
12
+ // Mirrors a geometry generator parked on an unresolved await: its return()
13
+ // can never settle, so an unbounded await would re-wedge the caller.
14
+ const iterator = { return: () => new Promise<never>(() => { /* never settles */ }) };
15
+ const start = Date.now();
16
+ await boundedIteratorReturn(iterator, 50);
17
+ const elapsed = Date.now() - start;
18
+ assert.ok(elapsed < 1000, `expected bounded (<1000ms), took ${elapsed}ms`);
19
+ });
20
+
21
+ it('awaits a fast return() to completion (lets the generator finally run)', async () => {
22
+ let returned = false;
23
+ const iterator = {
24
+ return: async () => {
25
+ returned = true;
26
+ return { done: true, value: undefined };
27
+ },
28
+ };
29
+ await boundedIteratorReturn(iterator, 1000);
30
+ assert.strictEqual(returned, true);
31
+ });
32
+
33
+ it('swallows a rejecting return() without throwing', async () => {
34
+ const iterator = { return: () => Promise.reject(new Error('teardown blew up')) };
35
+ await assert.doesNotReject(() => boundedIteratorReturn(iterator, 1000));
36
+ });
37
+
38
+ it('is a no-op when the iterator has no return()', async () => {
39
+ await assert.doesNotReject(() => boundedIteratorReturn({}, 1000));
40
+ });
41
+ });
@@ -0,0 +1,45 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /** How long to wait for an abandoned geometry iterator to shut down before
6
+ * giving up on it. Generous enough for a healthy generator to run its
7
+ * `finally` (freeing WASM handles, terminating workers), short enough that a
8
+ * wedged one never holds the caller hostage. */
9
+ export const GEOMETRY_ITERATOR_CLEANUP_MS = 2000;
10
+
11
+ interface ClosableAsyncIterator {
12
+ return?: (value?: unknown) => Promise<unknown> | unknown;
13
+ }
14
+
15
+ /**
16
+ * Abandon an async iterator without letting its shutdown wedge the caller.
17
+ *
18
+ * `AsyncIterator.return()` cannot interrupt a generator parked on an unresolved
19
+ * `await` — e.g. the geometry drain loop suspended waiting on a worker that
20
+ * failed to instantiate ("Worker from an empty source") and therefore never
21
+ * resolves the promise. Awaiting `return()` unbounded would re-block on the
22
+ * exact stall the stream watchdog just escaped, swallowing the timeout error so
23
+ * the load hangs in cleanup instead of surfacing a recoverable failure. Racing
24
+ * it against a deadline guarantees the caller always proceeds; a healthy
25
+ * generator still resolves well within the deadline so its `finally` runs.
26
+ */
27
+ export async function boundedIteratorReturn(
28
+ iterator: ClosableAsyncIterator,
29
+ cleanupMs: number = GEOMETRY_ITERATOR_CLEANUP_MS,
30
+ ): Promise<void> {
31
+ if (typeof iterator.return !== 'function') return;
32
+ let timer: ReturnType<typeof setTimeout> | null = null;
33
+ try {
34
+ await Promise.race([
35
+ Promise.resolve(iterator.return(undefined)).catch(() => {
36
+ /* cleanup — safe to ignore */
37
+ }),
38
+ new Promise<void>((resolve) => {
39
+ timer = setTimeout(resolve, cleanupMs);
40
+ }),
41
+ ]);
42
+ } finally {
43
+ if (timer !== null) clearTimeout(timer);
44
+ }
45
+ }