@ifc-lite/viewer 1.25.1 → 1.25.2

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 (53) hide show
  1. package/.turbo/turbo-build.log +79 -84
  2. package/CHANGELOG.md +23 -0
  3. package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-CTgyKI3U.js} +6 -6
  4. package/dist/assets/{bcf-DP2AK1-_.js → bcf-7jQby1qi.js} +1 -1
  5. package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cfp9t1Df.js} +1 -1
  6. package/dist/assets/exporters-DfSvJPi4.js +4660 -0
  7. package/dist/assets/geometry.worker-Cyn5BybV.js +1 -0
  8. package/dist/assets/{geotiff-By06vdeL.js → geotiff-xZoE8BkO.js} +10 -10
  9. package/dist/assets/{ids-DDkkb4mo.js → ids-Cu73hD0Y.js} +21 -21
  10. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  11. package/dist/assets/{index-CqBdDOAZ.js → index-WSbA5iy6.js} +34803 -34679
  12. package/dist/assets/{jpeg-B4IBTphL.js → jpeg-DhwFEbqb.js} +1 -1
  13. package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Dz6BXOVb.js} +1 -1
  14. package/dist/assets/{lzw-CtdH775t.js → lzw-C9z0fG2o.js} +1 -1
  15. package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-RvDmzO-2.js} +1 -1
  16. package/dist/assets/{packbits-DG3zn49C.js → packbits-jfwifz7C.js} +1 -1
  17. package/dist/assets/parser.worker-C594dWxH.js +182 -0
  18. package/dist/assets/raw-R2QfzPAR.js +1 -0
  19. package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-DDSZ7rek.js} +2450 -2260
  20. package/dist/assets/{server-client-D9xO_8yX.js → server-client-Ctk8_Bof.js} +1 -1
  21. package/dist/assets/{webimage-_-qCDjkn.js → webimage-XFHVyVtC.js} +1 -1
  22. package/dist/assets/{zstd-DlfgC8gA.js → zstd-3q5qcl5V.js} +1 -1
  23. package/dist/index.html +6 -6
  24. package/package.json +22 -21
  25. package/src/App.tsx +4 -0
  26. package/src/components/viewer/CommandPalette.tsx +5 -1
  27. package/src/components/viewer/MainToolbar.tsx +41 -19
  28. package/src/components/viewer/Viewport.tsx +48 -3
  29. package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
  30. package/src/components/viewer/useGeometryStreaming.ts +113 -18
  31. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
  32. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
  33. package/src/hooks/ingest/viewerModelIngest.ts +55 -11
  34. package/src/hooks/useIfcCache.ts +44 -18
  35. package/src/hooks/useIfcLoader.ts +1 -23
  36. package/src/hooks/useSymbolicAnnotations.ts +170 -35
  37. package/src/store/constants.ts +19 -3
  38. package/src/store/index.ts +1 -0
  39. package/src/store/slices/visibilitySlice.ts +2 -1
  40. package/src/store/types.ts +9 -0
  41. package/src/utils/serverDataModel.test.ts +51 -1
  42. package/src/utils/serverDataModel.ts +2 -26
  43. package/vite.config.ts +0 -5
  44. package/dist/assets/exporters-CZe0D8N-.js +0 -5957
  45. package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
  46. package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
  47. package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
  48. package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
  49. package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
  50. package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
  51. package/dist/assets/raw-DY7Y_acr.js +0 -1
  52. package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
  53. package/dist/assets/workerHelpers-Crstj4Oa.js +0 -36
@@ -627,29 +627,42 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
627
627
  // eslint-disable-next-line react-hooks/exhaustive-deps -- meshLen is a stable proxy for geometryResult
628
628
  }, [models, meshLen]);
629
629
 
630
- // IfcAnnotation has no body mesh, so it can't be detected via the mesh scan.
631
- // Look up the entity table directly. byType keys are uppercase STEP names
632
- // ('IFCANNOTATION') but cache loads sometimes preserve PascalCase too.
633
- // Symbolic 2D overlays cover BOTH IfcAnnotation (text, dimensions, leader
634
- // lines, filled regions) AND IfcGrid (axis lines + synthesized bubble +
635
- // tag). Some files ship only grids (Snowdon Towers Structural is the
636
- // canonical example — no IfcAnnotation at all), so the toggle must
637
- // surface for either entity type or grid-only models get no way to hide
638
- // the overlay.
639
- const hasIfcAnnotations = useMemo(() => {
640
- const has = (store: typeof ifcDataStore | undefined) => {
630
+ // IfcAnnotation / IfcGrid have no body mesh, so they can't be detected via
631
+ // the mesh scan. Look up the entity table directly. byType keys are
632
+ // uppercase STEP names but cache loads sometimes preserve PascalCase.
633
+ //
634
+ // Issue #862 split these into separate visibility toggles files that
635
+ // ship only one of the two need only that menu entry. Some files ship
636
+ // only grids (Snowdon Towers Structural — no IfcAnnotation) so probing
637
+ // each independently is required.
638
+ const hasIfcEntities = useMemo(() => {
639
+ const probe = (store: typeof ifcDataStore | undefined) => {
641
640
  const byType = store?.entityIndex?.byType;
642
- if (!byType) return false;
643
- return (byType.get('IFCANNOTATION')?.length ?? 0) > 0
644
- || (byType.get('IfcAnnotation')?.length ?? 0) > 0
645
- || (byType.get('IFCGRID')?.length ?? 0) > 0
646
- || (byType.get('IfcGrid')?.length ?? 0) > 0;
641
+ if (!byType) return { annotations: false, grid: false };
642
+ return {
643
+ annotations: (byType.get('IFCANNOTATION')?.length ?? 0) > 0
644
+ || (byType.get('IfcAnnotation')?.length ?? 0) > 0,
645
+ grid: (byType.get('IFCGRID')?.length ?? 0) > 0
646
+ || (byType.get('IfcGrid')?.length ?? 0) > 0,
647
+ };
647
648
  };
649
+ let annotations = false;
650
+ let grid = false;
648
651
  if (models.size > 0) {
649
- for (const [, m] of models) if (has(m.ifcDataStore)) return true;
652
+ for (const [, m] of models) {
653
+ const p = probe(m.ifcDataStore);
654
+ annotations ||= p.annotations;
655
+ grid ||= p.grid;
656
+ }
657
+ } else {
658
+ const p = probe(ifcDataStore);
659
+ annotations = p.annotations;
660
+ grid = p.grid;
650
661
  }
651
- return has(ifcDataStore);
662
+ return { annotations, grid };
652
663
  }, [models, ifcDataStore]);
664
+ const hasIfcAnnotations = hasIfcEntities.annotations;
665
+ const hasIfcGrid = hasIfcEntities.grid;
653
666
 
654
667
  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
655
668
  const files = e.target.files;
@@ -1577,7 +1590,16 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1577
1590
  onCheckedChange={() => toggleTypeVisibility('ifcAnnotations')}
1578
1591
  >
1579
1592
  <Pencil className="h-4 w-4 mr-2" style={{ color: '#e4b400' }} />
1580
- Show Annotations & Grids
1593
+ Show Annotations
1594
+ </DropdownMenuCheckboxItem>
1595
+ )}
1596
+ {hasIfcGrid && (
1597
+ <DropdownMenuCheckboxItem
1598
+ checked={typeVisibility.ifcGrid}
1599
+ onCheckedChange={() => toggleTypeVisibility('ifcGrid')}
1600
+ >
1601
+ <Pencil className="h-4 w-4 mr-2" style={{ color: '#e4b400' }} />
1602
+ Show Grids
1581
1603
  </DropdownMenuCheckboxItem>
1582
1604
  )}
1583
1605
 
@@ -40,7 +40,11 @@ import { useGeometryStreaming } from './useGeometryStreaming.js';
40
40
  import { usePointCloudSync } from './usePointCloudSync.js';
41
41
  import { usePointCloudLifecycle } from './usePointCloudLifecycle.js';
42
42
  import { useRenderUpdates } from './useRenderUpdates.js';
43
- import { useSymbolicAnnotations, useSymbolicAnnotationsRichData } from '../../hooks/useSymbolicAnnotations.js';
43
+ import {
44
+ useSymbolicAnnotations,
45
+ useSymbolicAnnotationsRichData,
46
+ type SectionClipForGrid,
47
+ } from '../../hooks/useSymbolicAnnotations.js';
44
48
 
45
49
  interface ViewportProps {
46
50
  geometry: MeshData[] | null;
@@ -622,8 +626,19 @@ export function Viewport({
622
626
  calculateScale();
623
627
  },
624
628
  home: () => {
625
- // Reset to isometric view
626
- camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
629
+ // Adaptive home: compact buildings get the historical SE isometric
630
+ // pose (1:1 with the old behaviour), linear infrastructure gets a
631
+ // side-on view at a distance where signals / referents are visible
632
+ // instead of receding to sub-pixel. The policy is computed from
633
+ // the current bbox shape so a federation that swaps from one
634
+ // building to a railway picks the right pose on Home press.
635
+ // See packages/renderer/src/camera-fit-policy.ts.
636
+ const canvas = rendererRef.current?.getCanvas();
637
+ const canvasShort = Math.min(canvas?.height ?? 0, canvas?.width ?? 0);
638
+ camera.fitBoundsAdaptive(
639
+ { min: geometryBoundsRef.current.min, max: geometryBoundsRef.current.max },
640
+ { animate: true, duration: 500, viewportShortPx: canvasShort > 0 ? canvasShort : undefined },
641
+ );
627
642
  calculateScale();
628
643
  },
629
644
  zoomIn: () => {
@@ -787,6 +802,11 @@ export function Viewport({
787
802
  // storey model shows all storeys' annotations layered correctly in 3D
788
803
  // (issue #653). Parsing is lazy and only runs while the toggle is on.
789
804
  const ifcAnnotationsVisible = useViewerStore((s) => s.typeVisibility.ifcAnnotations);
805
+ // Issue #862: IfcGrid is a separate toggle from IfcAnnotation. Default
806
+ // is on so existing users see no change; when the user disables it the
807
+ // grid axes + bubble tags drop out without affecting dimension/leader
808
+ // annotation rendering.
809
+ const ifcGridVisible = useViewerStore((s) => s.typeVisibility.ifcGrid);
790
810
  // For annotations whose storey can't be resolved (or whose authored
791
811
  // elevation is 0 because the storey Z lives on the placement instead),
792
812
  // lift to the middle of the model's vertical span so they don't end up
@@ -799,12 +819,37 @@ export function Viewport({
799
819
  if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return 0;
800
820
  return (min + max) * 0.5;
801
821
  }, [coordinateInfo]);
822
+
823
+ // Issue #862: section-clip grid lines so dense-grid models stay
824
+ // readable when a horizontal cut is active. Use a 1.5 m band on each
825
+ // side of the cut so the cut storey's grids are visible but storeys
826
+ // 1.5 m+ away are hidden (matches typical residential floor heights).
827
+ // Only applies to the floor-plan axis (`'down'`) — vertical cuts
828
+ // don't clip grids since grid lines are inherently vertical.
829
+ const gridSectionClip = useMemo<SectionClipForGrid | undefined>(() => {
830
+ if (!sectionPlane.enabled || sectionPlane.axis !== 'down' || !sectionRange) {
831
+ return undefined;
832
+ }
833
+ const posWorld = sectionRange.min + (sectionPlane.position / 100) * (sectionRange.max - sectionRange.min);
834
+ const GRID_CLIP_HALF_BAND_M = 1.5;
835
+ return {
836
+ enabled: true,
837
+ posWorld,
838
+ viewDepth: GRID_CLIP_HALF_BAND_M,
839
+ axis: sectionPlane.axis,
840
+ };
841
+ }, [sectionPlane.enabled, sectionPlane.axis, sectionPlane.position, sectionRange]);
842
+
802
843
  const annotationVertices3D = useSymbolicAnnotations({
803
844
  enabled: ifcAnnotationsVisible,
845
+ gridEnabled: ifcGridVisible,
846
+ gridSectionClip,
804
847
  fallbackY: annotationFallbackY,
805
848
  });
806
849
  const { texts: annotationTexts3D, fills: annotationFills3D } = useSymbolicAnnotationsRichData({
807
850
  enabled: ifcAnnotationsVisible,
851
+ gridEnabled: ifcGridVisible,
852
+ gridSectionClip,
808
853
  fallbackY: annotationFallbackY,
809
854
  });
810
855
  useEffect(() => {
@@ -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>;
@@ -109,6 +116,10 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
109
116
  const cameraFittedRef = useRef(false);
110
117
  const finalBoundsRefittedRef = useRef(false);
111
118
  const cameraSnapshotRef = useRef<{ px: number; py: number; pz: number; tx: number; ty: number; tz: number } | null>(null);
119
+ // Tracks which fit branch the post-load auto-fit took. Linear models get a
120
+ // one-time status-line hint via the viewer store; the home button can also
121
+ // mirror the same policy on re-press without re-deriving the bbox shape.
122
+ const lastFitPolicyKindRef = useRef<'compact' | 'linear' | null>(null);
112
123
  const prevIsStreamingRef = useRef(isStreaming);
113
124
  const lastContentVersionRef = useRef(geometryContentVersion ?? 0);
114
125
  const queuePumpTimerRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(null);
@@ -244,7 +255,24 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
244
255
  geometryBoundsRef.current = { ...DEFAULT_BOUNDS };
245
256
  }
246
257
  } else if (currentLength === lastLength) {
247
- return; // No change
258
+ // No mesh-count change, so the queueMeshes / appendToBatches block
259
+ // below would be a no-op. But we MUST still reach the camera-fit
260
+ // block — the streaming-complete re-render (isStreaming flips
261
+ // false, geometry array length stays at the final mesh count)
262
+ // arrives here, and that's the FIRST render where path 2
263
+ // (`computeBounds(geometry)` fallback when shiftedBounds is empty)
264
+ // is allowed to fire. Pre-fix the early return short-circuited
265
+ // the camera fit entirely; the user reported 33 meshes streamed
266
+ // with the viewport stuck at the default ±100 m bounds (issue
267
+ // #859 / PR #871 deploy preview, `linear-placement-of-signal.ifc`).
268
+ //
269
+ // Skip only when the camera is already fitted or there's nothing
270
+ // to fit to.
271
+ if (cameraFittedRef.current || currentLength === 0) {
272
+ return;
273
+ }
274
+ // Otherwise fall through so the camera-fit block at the bottom of
275
+ // the effect gets a chance to run.
248
276
  }
249
277
 
250
278
  // Visibility toggle while NOT streaming — array rebuilt from scratch
@@ -302,26 +330,72 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
302
330
  lastGeometryLengthRef.current = currentLength;
303
331
 
304
332
  // ── 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 };
333
+ //
334
+ // Pre-#871 the branching here was structured as
335
+ // if (coordinateInfo?.shiftedBounds) { try to fit }
336
+ // else if (geometry.length > 0) { fall back }
337
+ // but `coordinateInfo.shiftedBounds` is ALWAYS truthy — the wasm
338
+ // bridge ships a default `{ min: 0, max: 0 }` placeholder before
339
+ // any real bounds get computed. The outer `if` therefore won
340
+ // every time, the inner `maxSize > 0` failed, and the `else if`
341
+ // fallback NEVER fired. Result: the camera stayed at the default
342
+ // (0, 0, 0) framing while linearly-placed railway geometry sat at
343
+ // its MGA-territory world coords (~330, 123 after RTC), invisible
344
+ // to the user. Compute the size first so the branch reflects
345
+ // whether the data is actually usable, not just whether the
346
+ // property exists.
347
+ if (!cameraFittedRef.current) {
348
+ // The adaptive fit picks an SE-isometric pose for compact models
349
+ // (today's behaviour) but switches to a side-on-along-the-alignment
350
+ // pose for high-aspect-ratio bboxes (railway / road corridors).
351
+ // Without the switch, a 932 × 0.75 × 428 m alignment auto-fits to a
352
+ // ~1864 m distance where every 1 m signal projects to a sub-pixel
353
+ // dot — the user sees a blank viewport even though geometry is in
354
+ // the scene. See packages/renderer/src/camera-fit-policy.ts.
355
+ let fitted = false;
356
+ const sb = coordinateInfo?.shiftedBounds;
357
+ if (sb) {
358
+ const maxSize = Math.max(sb.max.x - sb.min.x, sb.max.y - sb.min.y, sb.max.z - sb.min.z);
359
+ if (maxSize > 0 && Number.isFinite(maxSize)) {
360
+ const canvas = renderer.getCanvas();
361
+ const canvasShort = Math.min(canvas?.height ?? 0, canvas?.width ?? 0);
362
+ const policy = renderer.getCamera().fitBoundsAdaptive(
363
+ { min: sb.min, max: sb.max },
364
+ { viewportShortPx: canvasShort > 0 ? canvasShort : undefined },
365
+ );
366
+ geometryBoundsRef.current = { min: { ...sb.min }, max: { ...sb.max } };
367
+ lastFitPolicyKindRef.current = policy.kind;
368
+ fitted = true;
369
+ }
370
+ }
371
+ if (!fitted && geometry.length > 0 && !isStreaming) {
372
+ const bounds = computeBounds(geometry);
373
+ if (bounds) {
374
+ const canvas = renderer.getCanvas();
375
+ const canvasShort = Math.min(canvas?.height ?? 0, canvas?.width ?? 0);
376
+ const policy = renderer.getCamera().fitBoundsAdaptive(
377
+ bounds,
378
+ { viewportShortPx: canvasShort > 0 ? canvasShort : undefined },
379
+ );
380
+ geometryBoundsRef.current = bounds;
381
+ lastFitPolicyKindRef.current = policy.kind;
382
+ fitted = true;
383
+ }
315
384
  }
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;
385
+ if (fitted) {
321
386
  cameraFittedRef.current = true;
322
387
  const pos = renderer.getCamera().getPosition();
323
388
  const tgt = renderer.getCamera().getTarget();
324
389
  cameraSnapshotRef.current = { px: pos.x, py: pos.y, pz: pos.z, tx: tgt.x, ty: tgt.y, tz: tgt.z };
390
+ // One-time hint for linear-infrastructure models. The side-on auto-fit
391
+ // shows a slice of the alignment at a useful zoom — but the FULL
392
+ // alignment is much longer than what fits on screen, so users need
393
+ // to know to pan / use Frame Selection to inspect remote stations.
394
+ // Hint is module-scoped so model swaps within one session don't spam.
395
+ if (lastFitPolicyKindRef.current === 'linear' && !linearFitHintShown) {
396
+ linearFitHintShown = true;
397
+ toast.info('Linear infrastructure — pan along the alignment, or select an element and press F to zoom in');
398
+ }
325
399
  }
326
400
  }
327
401
 
@@ -363,14 +437,35 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
363
437
  `finalize start geometryLength=${capturedGeometry?.length ?? 0} releaseAfterFinalize=${releaseGeometryAfterFinalize}`
364
438
  );
365
439
 
366
- // Compute exact bounds and refit camera (fast ~15ms scan)
440
+ // Compute exact bounds and refit camera (fast ~15ms scan). Use
441
+ // the adaptive policy so linear-infrastructure models keep the
442
+ // side-on pose chosen by the early-fit branch — without this,
443
+ // the streaming-complete refit reverts to the legacy
444
+ // `fitToBounds` (SE isometric at `maxSize * 2`), undoing the
445
+ // useful close-in framing and putting the camera back at the
446
+ // sub-pixel distance for railway / road corridors.
367
447
  if (cameraFittedRef.current && !finalBoundsRefittedRef.current && capturedGeometry && capturedGeometry.length > 0) {
368
448
  const t0 = performance.now();
369
449
  const exactBounds = computeBounds(capturedGeometry);
370
450
  console.log(`[GeomStream] computeBounds: ${(performance.now() - t0).toFixed(0)}ms`);
371
451
  if (exactBounds) {
372
452
  if (!userMovedCamera(r, cameraSnapshotRef.current)) {
373
- r.getCamera().fitToBounds(exactBounds.min, exactBounds.max);
453
+ const canvas = r.getCanvas();
454
+ const canvasShort = Math.min(canvas?.height ?? 0, canvas?.width ?? 0);
455
+ const policy = r.getCamera().fitBoundsAdaptive(
456
+ exactBounds,
457
+ { viewportShortPx: canvasShort > 0 ? canvasShort : undefined },
458
+ );
459
+ lastFitPolicyKindRef.current = policy.kind;
460
+ // Update the snapshot so a subsequent userMovedCamera check
461
+ // doesn't fire against the new pose's own delta.
462
+ const pos = r.getCamera().getPosition();
463
+ const tgt = r.getCamera().getTarget();
464
+ cameraSnapshotRef.current = { px: pos.x, py: pos.y, pz: pos.z, tx: tgt.x, ty: tgt.y, tz: tgt.z };
465
+ if (policy.kind === 'linear' && !linearFitHintShown) {
466
+ linearFitHintShown = true;
467
+ toast.info('Linear infrastructure — pan along the alignment, or select an element and press F to zoom in');
468
+ }
374
469
  }
375
470
  geometryBoundsRef.current = exactBounds;
376
471
  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
+ }
@@ -3,10 +3,12 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  import { IfcParser, parseIfcx, type IfcDataStore, type PointCloudExtraction } from '@ifc-lite/parser';
6
+ import { WorkerParser } from '@ifc-lite/parser/browser';
6
7
  import { GeometryProcessor, GeometryQuality, type CoordinateInfo, type DynamicBatchConfig, type GeometryResult, type MeshData, type PointCloudAsset } from '@ifc-lite/geometry';
7
8
  import { loadGLBToMeshData } from '@ifc-lite/cache';
8
9
  import type { SchemaVersion } from '../../store/types.js';
9
10
  import { calculateMeshBounds, calculateStoreyHeights, createCoordinateInfo, normalizeColor } from '../../utils/localParsingUtils.js';
11
+ import { resolveDataStoreOrAbort } from './resolveDataStoreOrAbort.js';
10
12
 
11
13
  type RgbaColor = [number, number, number, number];
12
14
 
@@ -217,6 +219,18 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
217
219
 
218
220
  const parser = new IfcParser();
219
221
  const wasmApi = geometryProcessor.getApi();
222
+ const canShareSource = WorkerParser.isSupported();
223
+ const sharedSource = canShareSource ? new SharedArrayBuffer(options.buffer.byteLength) : null;
224
+ if (sharedSource) {
225
+ new Uint8Array(sharedSource).set(new Uint8Array(options.buffer));
226
+ }
227
+ const geometryWillEmitEntityIndex =
228
+ sharedSource !== null
229
+ && options.fileSizeMB >= 2
230
+ && typeof Worker !== 'undefined'
231
+ && typeof navigator !== 'undefined'
232
+ && (navigator.hardwareConcurrency ?? 1) > 1;
233
+ let workerParser: WorkerParser | null = null;
220
234
  const allMeshes: MeshData[] = [];
221
235
  const cumulativeColorUpdates = new Map<number, RgbaColor>();
222
236
  let finalCoordinateInfo: CoordinateInfo | null = null;
@@ -224,20 +238,40 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
224
238
  let estimatedTotal = 0;
225
239
  let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
226
240
 
227
- const dataStorePromise = parser.parseColumnar(options.buffer, {
228
- wasmApi,
229
- onSpatialReady: (partialStore) => {
230
- if (options.shouldAbort?.()) {
231
- return;
232
- }
233
- options.onSpatialReady?.(normalizeDataStoreStoreys(partialStore));
234
- },
235
- });
241
+ const handleSpatialReady = (partialStore: IfcDataStore) => {
242
+ if (options.shouldAbort?.()) {
243
+ return;
244
+ }
245
+ options.onSpatialReady?.(normalizeDataStoreStoreys(partialStore));
246
+ };
247
+ const dataStorePromise = sharedSource
248
+ ? (() => {
249
+ workerParser = new WorkerParser();
250
+ return workerParser.parseColumnar(sharedSource, {
251
+ waitForEntityIndex: geometryWillEmitEntityIndex,
252
+ onSpatialReady: handleSpatialReady,
253
+ }).catch((error) => {
254
+ console.warn('[viewerModelIngest] Parser worker failed, falling back to main-thread parse:', error);
255
+ return parser.parseColumnar(options.buffer, {
256
+ wasmApi: wasmApi ?? undefined,
257
+ onSpatialReady: handleSpatialReady,
258
+ });
259
+ });
260
+ })()
261
+ : parser.parseColumnar(options.buffer, {
262
+ wasmApi: wasmApi ?? undefined,
263
+ onSpatialReady: handleSpatialReady,
264
+ });
236
265
 
237
- for await (const event of geometryProcessor.processAdaptive(new Uint8Array(options.buffer), {
266
+ const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(options.buffer);
267
+ for await (const event of geometryProcessor.processAdaptive(geometryView, {
238
268
  sizeThreshold: 2 * 1024 * 1024,
239
269
  batchSize: options.getDynamicBatchSize(options.fileSizeMB),
240
270
  sharedRtcOffset: options.sharedRtcOffset,
271
+ existingSab: sharedSource ?? undefined,
272
+ onEntityIndex: (ids, starts, lengths) => {
273
+ workerParser?.setEntityIndex(ids, starts, lengths);
274
+ },
241
275
  })) {
242
276
  if (options.shouldAbort?.()) {
243
277
  break;
@@ -282,7 +316,17 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
282
316
  }
283
317
  }
284
318
 
285
- const dataStore = normalizeDataStoreStoreys(await dataStorePromise);
319
+ // If the load was cancelled, don't await dataStorePromise: a worker parse
320
+ // started with waitForEntityIndex blocks until the geometry pre-pass hands
321
+ // over the entity index, which never happens once the geometry loop has been
322
+ // aborted above. resolveDataStoreOrAbort terminates the worker and throws an
323
+ // AbortError instead of hanging here.
324
+ const dataStore = normalizeDataStoreStoreys(
325
+ await resolveDataStoreOrAbort(dataStorePromise, {
326
+ aborted: options.shouldAbort?.() ?? false,
327
+ terminate: () => workerParser?.terminate(),
328
+ }),
329
+ );
286
330
  if (!finalCoordinateInfo) {
287
331
  finalCoordinateInfo = createCoordinateInfo(calculateMeshBounds(allMeshes).bounds);
288
332
  }