@ifc-lite/viewer 1.25.0 → 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.
- package/.turbo/turbo-build.log +79 -84
- package/CHANGELOG.md +60 -0
- package/dist/assets/{basketViewActivator-CU8_toGq.js → basketViewActivator-CTgyKI3U.js} +6 -6
- package/dist/assets/{bcf-DXGDhw56.js → bcf-7jQby1qi.js} +1 -1
- package/dist/assets/{deflate-Bb1_H2Yf.js → deflate-Cfp9t1Df.js} +1 -1
- package/dist/assets/exporters-DfSvJPi4.js +4660 -0
- package/dist/assets/geometry.worker-Cyn5BybV.js +1 -0
- package/dist/assets/{geotiff-y0ZxbRJd.js → geotiff-xZoE8BkO.js} +10 -10
- package/dist/assets/{ids-DruUNtfD.js → ids-Cu73hD0Y.js} +21 -21
- package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
- package/dist/assets/{index-Dr88ZlSY.js → index-WSbA5iy6.js} +31959 -31608
- package/dist/assets/{jpeg-B3_loqFe.js → jpeg-DhwFEbqb.js} +1 -1
- package/dist/assets/{lerc-nkwS8ZUe.js → lerc-Dz6BXOVb.js} +1 -1
- package/dist/assets/{lzw-D3cW5Wpg.js → lzw-C9z0fG2o.js} +1 -1
- package/dist/assets/{native-bridge-BcYJooq8.js → native-bridge-RvDmzO-2.js} +1 -1
- package/dist/assets/{packbits-DDN4xzB5.js → packbits-jfwifz7C.js} +1 -1
- package/dist/assets/parser.worker-C594dWxH.js +182 -0
- package/dist/assets/raw-R2QfzPAR.js +1 -0
- package/dist/assets/{sandbox-DETNEyQb.js → sandbox-DDSZ7rek.js} +2450 -2260
- package/dist/assets/{server-client-CmzJOeS7.js → server-client-Ctk8_Bof.js} +1 -1
- package/dist/assets/{webimage-CBjgg4up.js → webimage-XFHVyVtC.js} +1 -1
- package/dist/assets/{zstd-C8oQ6qdS.js → zstd-3q5qcl5V.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +22 -21
- package/src/App.tsx +4 -0
- package/src/components/viewer/BCFPanel.tsx +8 -1
- package/src/components/viewer/CommandPalette.tsx +5 -1
- package/src/components/viewer/MainToolbar.tsx +41 -19
- package/src/components/viewer/Section2DPanel.tsx +6 -2
- package/src/components/viewer/Viewport.tsx +48 -3
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +24 -0
- package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
- package/src/components/viewer/useGeometryStreaming.ts +113 -18
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
- package/src/hooks/ingest/viewerModelIngest.ts +55 -11
- package/src/hooks/useBCF.ts +98 -16
- package/src/hooks/useDrawingGeneration.ts +149 -3
- package/src/hooks/useIfcCache.ts +44 -18
- package/src/hooks/useIfcLoader.ts +1 -23
- package/src/hooks/useSymbolicAnnotations.ts +240 -61
- package/src/store/constants.ts +19 -3
- package/src/store/index.ts +1 -0
- package/src/store/slices/visibilitySlice.ts +2 -1
- package/src/store/types.ts +9 -0
- package/src/utils/serverDataModel.test.ts +51 -1
- package/src/utils/serverDataModel.ts +2 -26
- package/vite.config.ts +0 -5
- package/dist/assets/exporters-DZhLN0ux.js +0 -5957
- package/dist/assets/geometry-controller.worker-DQOSYqtw.js +0 -7
- package/dist/assets/geometry.worker-B62e03Ao.js +0 -1
- package/dist/assets/ifc-lite-Ch2T9pP9.js +0 -7
- package/dist/assets/ifc-lite_bg-D7O1WHgP.wasm +0 -0
- package/dist/assets/ifc-lite_bg-iH_07wf8.wasm +0 -0
- package/dist/assets/parser.worker-BW1IMUed.js +0 -182
- package/dist/assets/raw-CoIXstQ-.js +0 -1
- package/dist/assets/wasm-bridge-CT7mK9W0.js +0 -1
- package/dist/assets/workerHelpers-IEQDo8r3.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
|
|
631
|
-
// Look up the entity table directly. byType keys are
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
|
@@ -31,7 +31,7 @@ import { SheetSetupPanel } from './SheetSetupPanel';
|
|
|
31
31
|
import { TitleBlockEditor } from './TitleBlockEditor';
|
|
32
32
|
import { TextAnnotationEditor } from './TextAnnotationEditor';
|
|
33
33
|
import { Drawing2DCanvas } from './Drawing2DCanvas';
|
|
34
|
-
import { useDrawingGeneration, AXIS_MAP } from '@/hooks/useDrawingGeneration';
|
|
34
|
+
import { useDrawingGeneration, AXIS_MAP, ANNOTATION_VIEW_DEPTH } from '@/hooks/useDrawingGeneration';
|
|
35
35
|
import { useMeasure2D } from '@/hooks/useMeasure2D';
|
|
36
36
|
import { useAnnotation2D } from '@/hooks/useAnnotation2D';
|
|
37
37
|
import { useViewControls } from '@/hooks/useViewControls';
|
|
@@ -294,7 +294,11 @@ export function Section2DPanel({
|
|
|
294
294
|
const axisMin = bounds.min[axis];
|
|
295
295
|
const axisMax = bounds.max[axis];
|
|
296
296
|
const sectionPosWorld = axisMin + (sectionPlane.position / 100) * (axisMax - axisMin);
|
|
297
|
-
|
|
297
|
+
// IFC annotations get a tight 1.2 m view-depth slab — typical plan-view
|
|
298
|
+
// convention so dimension chains from the next storey don't stack onto
|
|
299
|
+
// the cut floor. The body cutter still uses half-extent for its own
|
|
300
|
+
// projection edges; the slab is annotation-specific.
|
|
301
|
+
const viewDepth = ANNOTATION_VIEW_DEPTH;
|
|
298
302
|
// For loose annotations (no resolvable storey), fall back to mid-Y like
|
|
299
303
|
// the 3D viewport does. This lets storeyless models still surface their
|
|
300
304
|
// annotations on the relevant section.
|
|
@@ -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 {
|
|
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
|
-
//
|
|
626
|
-
|
|
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(() => {
|
|
@@ -18,8 +18,14 @@ import {
|
|
|
18
18
|
MousePointer2,
|
|
19
19
|
Focus,
|
|
20
20
|
EyeOff,
|
|
21
|
+
Crosshair,
|
|
21
22
|
} from 'lucide-react';
|
|
22
23
|
import { Button } from '@/components/ui/button';
|
|
24
|
+
import {
|
|
25
|
+
Tooltip,
|
|
26
|
+
TooltipContent,
|
|
27
|
+
TooltipTrigger,
|
|
28
|
+
} from '@/components/ui/tooltip';
|
|
23
29
|
import { Input } from '@/components/ui/input';
|
|
24
30
|
import { Badge } from '@/components/ui/badge';
|
|
25
31
|
import {
|
|
@@ -46,6 +52,8 @@ export interface BCFTopicDetailProps {
|
|
|
46
52
|
onActivateViewpoint: (viewpoint: BCFViewpoint) => void;
|
|
47
53
|
onDeleteViewpoint: (viewpointGuid: string) => void;
|
|
48
54
|
onUpdateStatus: (status: string) => void;
|
|
55
|
+
onZoomToTopic: () => void;
|
|
56
|
+
canZoomToTopic: boolean;
|
|
49
57
|
onDeleteTopic: () => void;
|
|
50
58
|
// Viewer state info for capture feedback
|
|
51
59
|
selectionCount: number;
|
|
@@ -65,6 +73,8 @@ export function BCFTopicDetail({
|
|
|
65
73
|
onActivateViewpoint,
|
|
66
74
|
onDeleteViewpoint,
|
|
67
75
|
onUpdateStatus,
|
|
76
|
+
onZoomToTopic,
|
|
77
|
+
canZoomToTopic,
|
|
68
78
|
onDeleteTopic,
|
|
69
79
|
selectionCount,
|
|
70
80
|
hasIsolation,
|
|
@@ -107,6 +117,20 @@ export function BCFTopicDetail({
|
|
|
107
117
|
<ChevronLeft className="h-4 w-4" />
|
|
108
118
|
</Button>
|
|
109
119
|
<h3 className="font-medium text-sm flex-1 truncate">{topic.title}</h3>
|
|
120
|
+
<Tooltip>
|
|
121
|
+
<TooltipTrigger asChild>
|
|
122
|
+
<Button
|
|
123
|
+
variant="ghost"
|
|
124
|
+
size="sm"
|
|
125
|
+
onClick={onZoomToTopic}
|
|
126
|
+
disabled={!canZoomToTopic}
|
|
127
|
+
aria-label="Zoom to topic"
|
|
128
|
+
>
|
|
129
|
+
<Crosshair className="h-4 w-4" aria-hidden />
|
|
130
|
+
</Button>
|
|
131
|
+
</TooltipTrigger>
|
|
132
|
+
<TooltipContent>Zoom to</TooltipContent>
|
|
133
|
+
</Tooltip>
|
|
110
134
|
<Button
|
|
111
135
|
variant="ghost"
|
|
112
136
|
size="sm"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|