@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.
- package/.turbo/turbo-build.log +83 -85
- package/CHANGELOG.md +104 -0
- package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-ZpTYWE3K.js} +6 -6
- package/dist/assets/{bcf-DP2AK1-_.js → bcf-Ctcu_Sc2.js} +5 -5
- package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cnx0il6E.js} +1 -1
- package/dist/assets/exporters-DSq76AVM.js +4687 -0
- package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
- package/dist/assets/{geotiff-By06vdeL.js → geotiff-A5UjhI6L.js} +10 -10
- package/dist/assets/{ids-DDkkb4mo.js → ids-DiLcGTer.js} +4 -4
- package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
- package/dist/assets/index-B9Ug2EqU.css +1 -0
- package/dist/assets/{index-CqBdDOAZ.js → index-BAH8IJVR.js} +39550 -36936
- package/dist/assets/{jpeg-B4IBTphL.js → jpeg-BzSkwo5D.js} +1 -1
- package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Cg2Rz-D5.js} +1 -1
- package/dist/assets/{lzw-CtdH775t.js → lzw-BBPPLW-0.js} +1 -1
- package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-CPojOeGE.js} +1 -1
- package/dist/assets/{packbits-DG3zn49C.js → packbits-yLSpjW-V.js} +1 -1
- package/dist/assets/parser.worker-8md211IW.js +182 -0
- package/dist/assets/raw-BQrAgxwT.js +1 -0
- package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-CsRXlgCO.js} +4715 -3081
- package/dist/assets/{server-client-D9xO_8yX.js → server-client-Bk4c1CPO.js} +1 -1
- package/dist/assets/{webimage-_-qCDjkn.js → webimage-YafxjjGr.js} +1 -1
- package/dist/assets/{zstd-DlfgC8gA.js → zstd-CkSLOiuu.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +23 -21
- package/src/App.tsx +4 -0
- package/src/components/extensions/FlavorDialog.tsx +18 -2
- package/src/components/extensions/FlavorListView.tsx +12 -3
- package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
- package/src/components/viewer/ClashPanel.tsx +370 -0
- package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
- package/src/components/viewer/CommandPalette.tsx +19 -16
- package/src/components/viewer/MainToolbar.tsx +155 -153
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +97 -12
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
- package/src/components/viewer/useGeometryStreaming.ts +134 -19
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +118 -52
- package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
- package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
- package/src/hooks/useAlignmentLines3D.ts +164 -0
- package/src/hooks/useClash.ts +420 -0
- package/src/hooks/useIfcCache.ts +44 -18
- package/src/hooks/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +6 -30
- package/src/hooks/useSymbolicAnnotations.ts +170 -35
- package/src/lib/clash/persistence.ts +308 -0
- package/src/lib/geo/effective-georef.test.ts +66 -0
- package/src/services/extensions/host.ts +13 -0
- package/src/store/constants.ts +38 -14
- package/src/store/index.ts +29 -7
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +19 -8
- 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-CZe0D8N-.js +0 -5957
- package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
- package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
- package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
- package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
- package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
- package/dist/assets/index-Bws3UAkj.css +0 -1
- package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
- package/dist/assets/raw-DY7Y_acr.js +0 -1
- package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
- 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
|
-
|
|
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 (
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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.
|
|
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
|
+
}
|