@ifc-lite/viewer 1.23.0 → 1.25.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 +34 -31
- package/CHANGELOG.md +96 -0
- package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
- package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
- package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
- package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
- package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
- package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
- package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
- package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
- package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
- package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
- package/dist/assets/index-Bws3UAkj.css +1 -0
- package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
- package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
- package/dist/assets/lens-PYsLu_MA.js +1 -0
- package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
- package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
- package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
- package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
- package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
- package/dist/assets/raw-CoIXstQ-.js +1 -0
- package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
- package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
- package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
- package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
- package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
- package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +11 -9
- package/src/App.tsx +5 -2
- package/src/components/extensions/AuditLogPanel.tsx +259 -0
- package/src/components/extensions/BundlePreview.tsx +102 -0
- package/src/components/extensions/CapabilityReview.tsx +333 -0
- package/src/components/extensions/ExtensionDockHost.tsx +192 -0
- package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
- package/src/components/extensions/ExtensionsPanel.tsx +481 -0
- package/src/components/extensions/FlavorDialog.tsx +398 -0
- package/src/components/extensions/FlavorImportPreview.tsx +79 -0
- package/src/components/extensions/FlavorIndicator.tsx +81 -0
- package/src/components/extensions/FlavorListView.tsx +318 -0
- package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
- package/src/components/extensions/HelpHint.tsx +182 -0
- package/src/components/extensions/IdeasPanel.tsx +344 -0
- package/src/components/extensions/PlanCard.tsx +227 -0
- package/src/components/extensions/PrivacyPanel.tsx +312 -0
- package/src/components/extensions/PromoteToolDialog.tsx +313 -0
- package/src/components/extensions/RepairQueuePanel.tsx +222 -0
- package/src/components/extensions/icon-registry.ts +92 -0
- package/src/components/extensions/toast-helpers.ts +49 -0
- package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
- package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
- package/src/components/viewer/ChatPanel.tsx +251 -3
- package/src/components/viewer/CommandPalette.tsx +74 -4
- package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
- package/src/components/viewer/EntityContextMenu.tsx +70 -0
- package/src/components/viewer/ExportDialog.tsx +9 -1
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
- package/src/components/viewer/LensPanel.tsx +50 -0
- package/src/components/viewer/MainToolbar.tsx +170 -87
- package/src/components/viewer/ScriptPanel.tsx +105 -1
- package/src/components/viewer/Section2DPanel.tsx +58 -2
- package/src/components/viewer/StatusBar.tsx +18 -0
- package/src/components/viewer/ViewerLayout.tsx +53 -4
- package/src/components/viewer/Viewport.tsx +72 -0
- package/src/hooks/useActionLogger.test.ts +161 -0
- package/src/hooks/useActionLogger.ts +141 -0
- package/src/hooks/useForkExtension.ts +51 -0
- package/src/hooks/useIfcFederation.ts +7 -1
- package/src/hooks/useInstalledExtensions.ts +43 -0
- package/src/hooks/usePrivacyDisclosure.ts +48 -0
- package/src/hooks/useRunExtensionTests.ts +67 -0
- package/src/hooks/useSlotContributions.ts +38 -0
- package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
- package/src/hooks/useSymbolicAnnotations.ts +776 -0
- package/src/lib/desktop-product.ts +7 -1
- package/src/lib/lens/adapter.ts +14 -0
- package/src/lib/llm/prompt-cache.ts +77 -0
- package/src/lib/llm/stream-client.ts +20 -2
- package/src/lib/llm/stream-direct.ts +11 -1
- package/src/lib/llm/system-prompt.ts +42 -0
- package/src/lib/safe-mode.ts +30 -0
- package/src/sdk/ExtensionHostProvider.tsx +103 -0
- package/src/services/extensions/flavor-service.ts +183 -0
- package/src/services/extensions/host-commands.ts +112 -0
- package/src/services/extensions/host-installer.ts +289 -0
- package/src/services/extensions/host.ts +514 -0
- package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
- package/src/services/extensions/idb-flavor-storage.ts +241 -0
- package/src/services/extensions/idb-log-storage.test.ts +110 -0
- package/src/services/extensions/idb-log-storage.ts +171 -0
- package/src/services/extensions/idb-storage.ts +228 -0
- package/src/services/extensions/runtime-errors.ts +26 -0
- package/src/services/extensions/sandbox-factory.ts +217 -0
- package/src/store/constants.ts +48 -6
- package/src/store/index.ts +6 -1
- package/src/store/slices/drawing2DSlice.ts +8 -0
- package/src/store/slices/extensionsSlice.ts +90 -0
- package/src/store/slices/lensSlice.ts +28 -0
- package/src/store/slices/visibilitySlice.test.ts +6 -0
- package/src/store/slices/visibilitySlice.ts +17 -8
- package/src/store/types.ts +2 -0
- package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
- package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
- package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
- package/dist/assets/index-DS_xJQfP.css +0 -1
- package/dist/assets/lens-CpjUdqpw.js +0 -1
- package/dist/assets/raw-DzTtEZIY.js +0 -1
|
@@ -0,0 +1,776 @@
|
|
|
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
|
+
* Lazy extraction of IfcAnnotation 2D curves for the section-plane overlay.
|
|
7
|
+
*
|
|
8
|
+
* The WASM `parseSymbolicRepresentations` already emits polylines and arcs in
|
|
9
|
+
* the same 2D coordinate space the Section2DPanel feeds to
|
|
10
|
+
* `Section2DOverlayRenderer`. We only ever need the data when the IFC
|
|
11
|
+
* Annotation toggle is on AND a section plane is active, so the parse runs
|
|
12
|
+
* lazily and is cached per model source.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import { GeometryProcessor } from '@ifc-lite/geometry';
|
|
17
|
+
import type { DrawingLine2D } from '@ifc-lite/renderer';
|
|
18
|
+
import { decodeIfcString } from '@ifc-lite/encoding';
|
|
19
|
+
import { useViewerStore } from '@/store';
|
|
20
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
21
|
+
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
22
|
+
|
|
23
|
+
/** Lines belonging to a single storey, ready to feed into the section overlay. */
|
|
24
|
+
export interface AnnotationsForStorey {
|
|
25
|
+
storeyId: number;
|
|
26
|
+
/** Authored `IfcBuildingStorey.Elevation`. `null` means the storey carried
|
|
27
|
+
* no elevation in the parsed metadata — distinguishing that from a real
|
|
28
|
+
* ground-floor at 0.0 matters because `resolveBucketY` only wants to swap
|
|
29
|
+
* in the fallback in the missing case, not for legitimate ground floors. */
|
|
30
|
+
storeyElevation: number | null;
|
|
31
|
+
lines: DrawingLine2D[];
|
|
32
|
+
texts: AnnotationText2D[];
|
|
33
|
+
fills: AnnotationFill2D[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A single text label in renderer 2D space (XZ on the section plane).
|
|
38
|
+
*
|
|
39
|
+
* `dirX / dirY` encodes the baseline direction (already mirrored to match the
|
|
40
|
+
* Y-negated 2D coord system that lines and circles use). `height` is in world
|
|
41
|
+
* units. `alignment` is the raw IFC `BoxAlignment` string ("bottom-left",
|
|
42
|
+
* "center", …) — the renderer interprets it.
|
|
43
|
+
*/
|
|
44
|
+
export interface AnnotationText2D {
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
dirX: number;
|
|
48
|
+
dirY: number;
|
|
49
|
+
height: number;
|
|
50
|
+
content: string;
|
|
51
|
+
alignment: string;
|
|
52
|
+
/**
|
|
53
|
+
* For multi-line text literals (e.g. CJK descriptions with `\X\0A`
|
|
54
|
+
* newlines), one IfcTextLiteralWithExtent expands into one AnnotationText2D
|
|
55
|
+
* per line. `lineYOffset` is added to the storey-elevation world-Y at 3D
|
|
56
|
+
* conversion so successive lines stack downward (negative Y) below the
|
|
57
|
+
* shared anchor. Optional — single-line literals leave it undefined.
|
|
58
|
+
*/
|
|
59
|
+
lineYOffset?: number;
|
|
60
|
+
/**
|
|
61
|
+
* When true, the renderer rebuilds the glyph quad in screen-aligned
|
|
62
|
+
* (cameraRight, cameraUp) basis so the text always faces the camera.
|
|
63
|
+
* Set for IfcGridAxis tags — they must stay readable in top-down/ground
|
|
64
|
+
* views where the authored world-Y up axis collapses to zero on-screen.
|
|
65
|
+
* Defaults to false (authored, in-plane text — matches BIMvision for
|
|
66
|
+
* dimension/leader annotations that lie flat on the floor).
|
|
67
|
+
*/
|
|
68
|
+
billboard?: boolean;
|
|
69
|
+
/** sRGB straight-alpha tint (0..1). Defaults to renderer near-black. */
|
|
70
|
+
color?: [number, number, number, number];
|
|
71
|
+
/** Per-instance target cap height in screen pixels. 0/undef = renderer default. */
|
|
72
|
+
targetPx?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A single filled region in renderer 2D space. Outer ring + holes flattened
|
|
77
|
+
* into one `points` array; `holesOffsets` marks where each hole starts (in
|
|
78
|
+
* vertex indices, not floats). Empty `holesOffsets` = simple polygon.
|
|
79
|
+
*
|
|
80
|
+
* `hatching` is present when the IFC style chain resolved to an
|
|
81
|
+
* IfcFillAreaStyleHatching. When absent the fill is solid (color only).
|
|
82
|
+
*/
|
|
83
|
+
export interface AnnotationFill2D {
|
|
84
|
+
points: Float32Array;
|
|
85
|
+
holesOffsets: Uint32Array;
|
|
86
|
+
color: [number, number, number, number];
|
|
87
|
+
hatching?: {
|
|
88
|
+
spacing: number;
|
|
89
|
+
angle: number;
|
|
90
|
+
angleSecondary: number | null;
|
|
91
|
+
lineWidth: number;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Cached parse result keyed by source identity. */
|
|
96
|
+
interface ParseResult {
|
|
97
|
+
byStorey: Map<number, AnnotationsForStorey>;
|
|
98
|
+
/** Annotations with no resolvable storey — shown on every floor as a fallback. */
|
|
99
|
+
loose: DrawingLine2D[];
|
|
100
|
+
looseTexts: AnnotationText2D[];
|
|
101
|
+
looseFills: AnnotationFill2D[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const CIRCLE_SEGMENTS_FULL = 32;
|
|
105
|
+
const CIRCLE_SEGMENTS_ARC = 16;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convert a polyline (Float32Array of [x,y,x,y,…]) into start/end segments.
|
|
109
|
+
* Exported for unit testing.
|
|
110
|
+
*/
|
|
111
|
+
export function polylineToSegments(
|
|
112
|
+
points: Float32Array,
|
|
113
|
+
pointCount: number,
|
|
114
|
+
isClosed: boolean,
|
|
115
|
+
out: DrawingLine2D[],
|
|
116
|
+
): void {
|
|
117
|
+
for (let j = 0; j < pointCount - 1; j++) {
|
|
118
|
+
out.push({
|
|
119
|
+
line: {
|
|
120
|
+
start: { x: points[j * 2], y: points[j * 2 + 1] },
|
|
121
|
+
end: { x: points[(j + 1) * 2], y: points[(j + 1) * 2 + 1] },
|
|
122
|
+
},
|
|
123
|
+
category: 'annotation',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (isClosed && pointCount > 2) {
|
|
127
|
+
out.push({
|
|
128
|
+
line: {
|
|
129
|
+
start: { x: points[(pointCount - 1) * 2], y: points[(pointCount - 1) * 2 + 1] },
|
|
130
|
+
end: { x: points[0], y: points[1] },
|
|
131
|
+
},
|
|
132
|
+
category: 'annotation',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Tessellate a circle/arc into chord segments.
|
|
139
|
+
* Exported for unit testing.
|
|
140
|
+
*/
|
|
141
|
+
export function circleToSegments(
|
|
142
|
+
centerX: number,
|
|
143
|
+
centerY: number,
|
|
144
|
+
radius: number,
|
|
145
|
+
startAngle: number,
|
|
146
|
+
endAngle: number,
|
|
147
|
+
isFullCircle: boolean,
|
|
148
|
+
out: DrawingLine2D[],
|
|
149
|
+
): void {
|
|
150
|
+
const numSegments = isFullCircle ? CIRCLE_SEGMENTS_FULL : CIRCLE_SEGMENTS_ARC;
|
|
151
|
+
for (let j = 0; j < numSegments; j++) {
|
|
152
|
+
const t1 = j / numSegments;
|
|
153
|
+
const t2 = (j + 1) / numSegments;
|
|
154
|
+
const a1 = startAngle + t1 * (endAngle - startAngle);
|
|
155
|
+
const a2 = startAngle + t2 * (endAngle - startAngle);
|
|
156
|
+
out.push({
|
|
157
|
+
line: {
|
|
158
|
+
start: { x: centerX + radius * Math.cos(a1), y: centerY + radius * Math.sin(a1) },
|
|
159
|
+
end: { x: centerX + radius * Math.cos(a2), y: centerY + radius * Math.sin(a2) },
|
|
160
|
+
},
|
|
161
|
+
category: 'annotation',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Make a stable cache key for one parsed source.
|
|
167
|
+
*
|
|
168
|
+
* Uses byteLength + a sample of the actual bytes (head, middle, tail) so two
|
|
169
|
+
* different IFC sources can't alias even when they happen to share an exact
|
|
170
|
+
* size — a real risk in federated views with multiple loaded models, and the
|
|
171
|
+
* symptom is that the second model's annotations get hidden because the parse
|
|
172
|
+
* effect skips it as "already cached". Sampling 96 bytes is cheap, doesn't
|
|
173
|
+
* read the whole file, and is collision-resistant in practice. The buffer
|
|
174
|
+
* identity is also folded in so the same content loaded twice from two
|
|
175
|
+
* different ArrayBuffers (rare but possible) keeps distinct entries.
|
|
176
|
+
*/
|
|
177
|
+
function sourceKey(store: IfcDataStore | null | undefined): string | null {
|
|
178
|
+
const source = store?.source;
|
|
179
|
+
if (!source || source.byteLength === 0) return null;
|
|
180
|
+
const len = source.byteLength;
|
|
181
|
+
const sampleLen = Math.min(32, len);
|
|
182
|
+
const head = source.subarray(0, sampleLen);
|
|
183
|
+
const tail = source.subarray(len - sampleLen, len);
|
|
184
|
+
const midOffset = Math.max(0, Math.floor(len / 2) - Math.floor(sampleLen / 2));
|
|
185
|
+
const mid = source.subarray(midOffset, Math.min(midOffset + sampleLen, len));
|
|
186
|
+
// Fold each window into a 32-bit FNV-1a; cheap and collision-resistant for
|
|
187
|
+
// 96 bytes of structurally distinct IFC headers/body/footer.
|
|
188
|
+
const hashOne = (bytes: Uint8Array): string => {
|
|
189
|
+
let h = 0x811c9dc5;
|
|
190
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
191
|
+
h ^= bytes[i];
|
|
192
|
+
h = Math.imul(h, 0x01000193);
|
|
193
|
+
}
|
|
194
|
+
return (h >>> 0).toString(16);
|
|
195
|
+
};
|
|
196
|
+
return `b${len}-${hashOne(head)}-${hashOne(mid)}-${hashOne(tail)}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Set `localStorage.IFC_ANNOTATIONS_DEBUG = '1'` in the browser to log
|
|
200
|
+
* per-store parse counts + lift vertex counts to the console. Off by
|
|
201
|
+
* default; useful when triaging "no annotations visible" reports. */
|
|
202
|
+
const debugEnabled = (): boolean => {
|
|
203
|
+
if (typeof window === 'undefined') return false;
|
|
204
|
+
try { return window.localStorage?.getItem('IFC_ANNOTATIONS_DEBUG') === '1'; }
|
|
205
|
+
catch { return false; }
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
async function parseAnnotations(
|
|
209
|
+
store: IfcDataStore,
|
|
210
|
+
): Promise<ParseResult> {
|
|
211
|
+
const result: ParseResult = {
|
|
212
|
+
byStorey: new Map(),
|
|
213
|
+
loose: [],
|
|
214
|
+
looseTexts: [],
|
|
215
|
+
looseFills: [],
|
|
216
|
+
};
|
|
217
|
+
const source = store.source;
|
|
218
|
+
if (!source || source.byteLength === 0) {
|
|
219
|
+
if (debugEnabled()) console.log('[annotations] skip: missing/empty source');
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const hierarchy = store.spatialHierarchy;
|
|
224
|
+
const elementToStorey = hierarchy?.elementToStorey;
|
|
225
|
+
const storeyElevations = hierarchy?.storeyElevations;
|
|
226
|
+
|
|
227
|
+
const processor = new GeometryProcessor();
|
|
228
|
+
try {
|
|
229
|
+
await processor.init();
|
|
230
|
+
const collection = processor.parseSymbolicRepresentations(source);
|
|
231
|
+
if (debugEnabled()) {
|
|
232
|
+
console.log(
|
|
233
|
+
`[annotations] parsed ${source.byteLength} bytes →`,
|
|
234
|
+
collection
|
|
235
|
+
? `${collection.polylineCount} polylines, ${collection.circleCount} circles, ${collection.textCount} texts, ${collection.fillCount} fills`
|
|
236
|
+
: 'null',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (!collection || collection.isEmpty) return result;
|
|
240
|
+
|
|
241
|
+
// Resolve a bucket by elevation rather than by storey id.
|
|
242
|
+
//
|
|
243
|
+
// The legacy path used `elementToStorey` exclusively — which breaks for
|
|
244
|
+
// 3DEXPERIENCE / IfcPlusPlus exports whose `IfcRelAggregates` leaves
|
|
245
|
+
// storeys orphaned so `SpatialHierarchyBuilder` reports "No storeys
|
|
246
|
+
// found". Those files still encode the elevation on each item's
|
|
247
|
+
// geometry (the IfcCartesianPoint.Z), which the WASM extractor now
|
|
248
|
+
// surfaces as `primitive.worldY`. Bucketing by Y means every annotation
|
|
249
|
+
// lands at the right floor regardless of whether the spatial hierarchy
|
|
250
|
+
// could be built.
|
|
251
|
+
//
|
|
252
|
+
// Priority: explicit primitive worldY → fall back to storey-table
|
|
253
|
+
// elevation → null (loose bucket, renders at fallbackY).
|
|
254
|
+
//
|
|
255
|
+
// Bucket keys are millimetre-rounded Y so two storeys 1mm apart still
|
|
256
|
+
// collapse to one bucket — that's the precision Revit etc. round to.
|
|
257
|
+
const ensureBucket = (
|
|
258
|
+
expressId: number,
|
|
259
|
+
primitiveWorldY: number,
|
|
260
|
+
): AnnotationsForStorey | null => {
|
|
261
|
+
let effectiveY: number | null = null;
|
|
262
|
+
if (Number.isFinite(primitiveWorldY) && primitiveWorldY !== 0) {
|
|
263
|
+
effectiveY = primitiveWorldY;
|
|
264
|
+
} else {
|
|
265
|
+
const storeyId = elementToStorey?.get(expressId);
|
|
266
|
+
if (storeyId !== undefined) {
|
|
267
|
+
const elev = storeyElevations?.get(storeyId);
|
|
268
|
+
if (typeof elev === 'number' && Number.isFinite(elev)) effectiveY = elev;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (effectiveY === null) return null;
|
|
272
|
+
const key = Math.round(effectiveY * 1000);
|
|
273
|
+
let bucket = result.byStorey.get(key);
|
|
274
|
+
if (!bucket) {
|
|
275
|
+
bucket = {
|
|
276
|
+
storeyId: key,
|
|
277
|
+
storeyElevation: effectiveY,
|
|
278
|
+
lines: [],
|
|
279
|
+
texts: [],
|
|
280
|
+
fills: [],
|
|
281
|
+
};
|
|
282
|
+
result.byStorey.set(key, bucket);
|
|
283
|
+
}
|
|
284
|
+
return bucket;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < collection.polylineCount; i++) {
|
|
288
|
+
const poly = collection.getPolyline(i);
|
|
289
|
+
if (!poly) continue;
|
|
290
|
+
if (poly.ifcType !== 'IfcAnnotation' && poly.ifcType !== 'IfcGridAxis') continue;
|
|
291
|
+
const bucket = ensureBucket(poly.expressId, poly.worldY);
|
|
292
|
+
const out = bucket ? bucket.lines : result.loose;
|
|
293
|
+
polylineToSegments(poly.points, poly.pointCount, poly.isClosed, out);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (let i = 0; i < collection.circleCount; i++) {
|
|
297
|
+
const circle = collection.getCircle(i);
|
|
298
|
+
if (!circle) continue;
|
|
299
|
+
if (circle.ifcType !== 'IfcAnnotation' && circle.ifcType !== 'IfcGridAxis') continue;
|
|
300
|
+
const bucket = ensureBucket(circle.expressId, circle.worldY);
|
|
301
|
+
const out = bucket ? bucket.lines : result.loose;
|
|
302
|
+
circleToSegments(
|
|
303
|
+
circle.centerX,
|
|
304
|
+
circle.centerY,
|
|
305
|
+
circle.radius,
|
|
306
|
+
circle.startAngle,
|
|
307
|
+
circle.endAngle,
|
|
308
|
+
circle.isFullCircle,
|
|
309
|
+
out,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (let i = 0; i < collection.textCount; i++) {
|
|
314
|
+
const text = collection.getText(i);
|
|
315
|
+
if (!text) continue;
|
|
316
|
+
if (text.ifcType !== 'IfcAnnotation' && text.ifcType !== 'IfcGridAxis') continue;
|
|
317
|
+
// Skip empty literals so the renderer doesn't waste an instance slot.
|
|
318
|
+
// Decode STEP escapes — `\X2\NNNN\X0\` (UTF-16 hex code units) and
|
|
319
|
+
// `\X\NN` (Latin-1 hex byte). The Rust parser intentionally passes
|
|
320
|
+
// the literal through verbatim; this is where the JS encoding
|
|
321
|
+
// package gets applied. Without it, non-ASCII annotation labels
|
|
322
|
+
// (e.g. CJK content) render as raw escape sequences in the atlas.
|
|
323
|
+
const decoded = decodeIfcString(text.content);
|
|
324
|
+
if (decoded.length === 0) continue;
|
|
325
|
+
|
|
326
|
+
// Multi-line split: IfcTextLiteralWithExtent.SizeInY is the LAYOUT BOX
|
|
327
|
+
// height, not the glyph cap height. The Rust extractor multiplies
|
|
328
|
+
// SizeInY × 0.7 to recover a single-line cap; for multi-line literals
|
|
329
|
+
// we further divide by line count and stack lines downward in world-Y.
|
|
330
|
+
// Source: IFC4 spec — IfcPlanarExtent describes the bounding box of
|
|
331
|
+
// the typeset string; one literal per line is the conventional
|
|
332
|
+
// rendering model (matches BIMvision / Solibri / Revit).
|
|
333
|
+
const lines = decoded.split(/\r?\n/).filter((l) => l.length > 0);
|
|
334
|
+
if (lines.length === 0) continue;
|
|
335
|
+
const perLineHeight = lines.length > 1 ? text.height / lines.length : text.height;
|
|
336
|
+
// Industry-standard line-spacing (CSS line-height ≈ 1.2). Picks up
|
|
337
|
+
// a little air between rows so descenders don't kiss the next cap.
|
|
338
|
+
const lineSpacing = perLineHeight * 1.2;
|
|
339
|
+
const bucket = ensureBucket(text.expressId, text.worldY);
|
|
340
|
+
// IfcGridAxis bubble tags must stay readable in any view orientation
|
|
341
|
+
// (top-down, eye-level, oblique). Tag them as billboard so the text
|
|
342
|
+
// shader rebuilds the quad in screen-aligned basis at render time.
|
|
343
|
+
// Other annotation text (dimensions, leader labels) keeps authored
|
|
344
|
+
// orientation — those are meant to lie flat in the floor plane.
|
|
345
|
+
const isGridTag = text.ifcType === 'IfcGridAxis';
|
|
346
|
+
// Read per-instance style metadata. WASM emits these for grid
|
|
347
|
+
// bubble parts (● fill / ○ outline / tag) and reserves them for
|
|
348
|
+
// future IfcTextStyle resolution on regular annotation text.
|
|
349
|
+
const colorA = text.colorA;
|
|
350
|
+
const hasColor = colorA > 0;
|
|
351
|
+
const textColor: [number, number, number, number] | undefined = hasColor
|
|
352
|
+
? [text.colorR, text.colorG, text.colorB, colorA]
|
|
353
|
+
: undefined;
|
|
354
|
+
const targetPx = text.targetPx > 0 ? text.targetPx : undefined;
|
|
355
|
+
for (let li = 0; li < lines.length; li++) {
|
|
356
|
+
const t2d: AnnotationText2D = {
|
|
357
|
+
x: text.x,
|
|
358
|
+
y: text.y,
|
|
359
|
+
dirX: text.dirX,
|
|
360
|
+
dirY: text.dirY,
|
|
361
|
+
height: perLineHeight,
|
|
362
|
+
content: lines[li],
|
|
363
|
+
alignment: text.alignment,
|
|
364
|
+
lineYOffset: -li * lineSpacing,
|
|
365
|
+
billboard: isGridTag,
|
|
366
|
+
color: textColor,
|
|
367
|
+
targetPx,
|
|
368
|
+
};
|
|
369
|
+
(bucket ? bucket.texts : result.looseTexts).push(t2d);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (let i = 0; i < collection.fillCount; i++) {
|
|
374
|
+
const fill = collection.getFill(i);
|
|
375
|
+
if (!fill) continue;
|
|
376
|
+
if (fill.ifcType !== 'IfcAnnotation' && fill.ifcType !== 'IfcGridAxis') continue;
|
|
377
|
+
const points = fill.points;
|
|
378
|
+
if (points.length < 6) continue; // <3 vertices = no polygon
|
|
379
|
+
const f2d: AnnotationFill2D = {
|
|
380
|
+
points,
|
|
381
|
+
holesOffsets: fill.holesOffsets,
|
|
382
|
+
color: [fill.fillR, fill.fillG, fill.fillB, fill.fillA],
|
|
383
|
+
hatching: fill.hasHatching
|
|
384
|
+
? {
|
|
385
|
+
spacing: fill.hatchSpacing,
|
|
386
|
+
angle: fill.hatchAngle,
|
|
387
|
+
angleSecondary: Number.isNaN(fill.hatchAngleSecondary) ? null : fill.hatchAngleSecondary,
|
|
388
|
+
lineWidth: fill.hatchLineWidth,
|
|
389
|
+
}
|
|
390
|
+
: undefined,
|
|
391
|
+
};
|
|
392
|
+
const bucket = ensureBucket(fill.expressId, fill.worldY);
|
|
393
|
+
(bucket ? bucket.fills : result.looseFills).push(f2d);
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
processor.dispose();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Lift 2D annotation lines (renderer XZ space) to a flat Float32Array of
|
|
404
|
+
* 3D line-list vertices `[x1, y, z1, x2, y, z2, …]`. The Y coordinate is
|
|
405
|
+
* the annotation's storey elevation in world space, so the resulting
|
|
406
|
+
* lines render at the right floor when drawn through the renderer's
|
|
407
|
+
* world-space line pipeline.
|
|
408
|
+
*
|
|
409
|
+
* Exported for unit testing.
|
|
410
|
+
*/
|
|
411
|
+
export function liftTo3DLineList(
|
|
412
|
+
lines: DrawingLine2D[],
|
|
413
|
+
y: number,
|
|
414
|
+
out: number[],
|
|
415
|
+
): void {
|
|
416
|
+
for (const line of lines) {
|
|
417
|
+
out.push(line.line.start.x, y, line.line.start.y);
|
|
418
|
+
out.push(line.line.end.x, y, line.line.end.y);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Returns IFC annotation segments as a single Float32Array of pre-lifted 3D
|
|
424
|
+
* line-list vertices in world space, ready to feed
|
|
425
|
+
* `renderer.uploadAnnotationLines3D`.
|
|
426
|
+
*
|
|
427
|
+
* Each annotation is lifted to its containing storey's elevation. Annotations
|
|
428
|
+
* with no resolvable storey fall back to `fallbackY` (typically the mid-Y of
|
|
429
|
+
* the scene bounds) so the overlay stays visible even when the IFC file's
|
|
430
|
+
* spatial hierarchy doesn't link annotations to a storey — common when the
|
|
431
|
+
* authoring tool encodes the storey Z directly on the placement point
|
|
432
|
+
* instead of on `IfcBuildingStorey.Elevation`.
|
|
433
|
+
*
|
|
434
|
+
* When `enabled` is false (toggle off, no models, etc.) the hook does no
|
|
435
|
+
* parse work and returns a stable empty Float32Array. Parsing is lazy —
|
|
436
|
+
* the WASM `parseSymbolicRepresentations` call only runs after the toggle
|
|
437
|
+
* is turned on, and the result is cached per model source.
|
|
438
|
+
*/
|
|
439
|
+
const EMPTY_F32 = new Float32Array(0);
|
|
440
|
+
|
|
441
|
+
// ─── Shared parse cache ─────────────────────────────────────────────────────
|
|
442
|
+
// Parsing the whole file's symbolic representations is not cheap (full WASM
|
|
443
|
+
// walk over every product's representations). Cache results module-globally
|
|
444
|
+
// so the line / text / fill hooks share one parse per model source instead
|
|
445
|
+
// of triggering it once per hook.
|
|
446
|
+
const PARSE_CACHE = new Map<string, ParseResult>();
|
|
447
|
+
const PARSE_INFLIGHT = new Map<string, Promise<void>>();
|
|
448
|
+
|
|
449
|
+
/** Subscribers that want to re-render when a new parse result lands. */
|
|
450
|
+
type CacheListener = () => void;
|
|
451
|
+
const CACHE_LISTENERS = new Set<CacheListener>();
|
|
452
|
+
function notifyCacheChange(): void {
|
|
453
|
+
for (const fn of CACHE_LISTENERS) fn();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function ensureParseFor(stores: IfcDataStore[]): void {
|
|
457
|
+
for (const store of stores) {
|
|
458
|
+
const key = sourceKey(store);
|
|
459
|
+
if (!key) continue;
|
|
460
|
+
if (PARSE_CACHE.has(key)) continue;
|
|
461
|
+
if (PARSE_INFLIGHT.has(key)) continue;
|
|
462
|
+
|
|
463
|
+
const promise = (async () => {
|
|
464
|
+
try {
|
|
465
|
+
const result = await parseAnnotations(store);
|
|
466
|
+
PARSE_CACHE.set(key, result);
|
|
467
|
+
notifyCacheChange();
|
|
468
|
+
} catch (error) {
|
|
469
|
+
// eslint-disable-next-line no-console
|
|
470
|
+
console.warn('[useSymbolicAnnotations] parse failed:', error);
|
|
471
|
+
} finally {
|
|
472
|
+
PARSE_INFLIGHT.delete(key);
|
|
473
|
+
}
|
|
474
|
+
})();
|
|
475
|
+
PARSE_INFLIGHT.set(key, promise);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Read the active store set from the viewer store. Federation-aware. */
|
|
480
|
+
function useActiveStores(): IfcDataStore[] {
|
|
481
|
+
const { models, ifcDataStore } = useViewerStore(
|
|
482
|
+
useShallow((s) => ({ models: s.models, ifcDataStore: s.ifcDataStore })),
|
|
483
|
+
);
|
|
484
|
+
return useMemo(() => {
|
|
485
|
+
const out: IfcDataStore[] = [];
|
|
486
|
+
if (models.size > 0) {
|
|
487
|
+
for (const [, m] of models) if (m.ifcDataStore) out.push(m.ifcDataStore);
|
|
488
|
+
} else if (ifcDataStore) {
|
|
489
|
+
out.push(ifcDataStore);
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}, [models, ifcDataStore]);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Trigger parse for the active stores when `enabled`, tick on completion. */
|
|
496
|
+
function useAnnotationParseTrigger(enabled: boolean, stores: IfcDataStore[]): number {
|
|
497
|
+
const [version, setVersion] = useState(0);
|
|
498
|
+
|
|
499
|
+
useEffect(() => {
|
|
500
|
+
if (!enabled) return undefined;
|
|
501
|
+
ensureParseFor(stores);
|
|
502
|
+
const listener: CacheListener = () => setVersion((v) => v + 1);
|
|
503
|
+
CACHE_LISTENERS.add(listener);
|
|
504
|
+
return () => {
|
|
505
|
+
CACHE_LISTENERS.delete(listener);
|
|
506
|
+
};
|
|
507
|
+
}, [enabled, stores]);
|
|
508
|
+
|
|
509
|
+
return version;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Resolve the world-space Y for a storey bucket.
|
|
513
|
+
*
|
|
514
|
+
* `null` elevation means the storey carried no value in the parsed metadata
|
|
515
|
+
* (rare but happens in older authoring tools that leave
|
|
516
|
+
* `IfcBuildingStorey.Elevation` blank and bake the Z into the placements);
|
|
517
|
+
* fall back to the caller's `fallbackY` (typically the model's mid-Y). A
|
|
518
|
+
* real ground floor at 0.0 keeps its authored 0 instead of being remapped.
|
|
519
|
+
*/
|
|
520
|
+
function resolveBucketY(elevation: number | null, fallbackY: number): number {
|
|
521
|
+
return elevation === null ? fallbackY : elevation;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function useSymbolicAnnotations(params: {
|
|
525
|
+
enabled: boolean;
|
|
526
|
+
/** World Y to use for annotations with no resolvable storey. Defaults to 0. */
|
|
527
|
+
fallbackY?: number;
|
|
528
|
+
}): Float32Array {
|
|
529
|
+
const { enabled, fallbackY = 0 } = params;
|
|
530
|
+
const stores = useActiveStores();
|
|
531
|
+
const version = useAnnotationParseTrigger(enabled, stores);
|
|
532
|
+
|
|
533
|
+
return useMemo(() => {
|
|
534
|
+
if (!enabled) return EMPTY_F32;
|
|
535
|
+
void version; // depend on parse-completion ticks
|
|
536
|
+
|
|
537
|
+
const verts: number[] = [];
|
|
538
|
+
let storeIdx = 0;
|
|
539
|
+
for (const store of stores) {
|
|
540
|
+
const key = sourceKey(store);
|
|
541
|
+
if (!key) { storeIdx++; continue; }
|
|
542
|
+
const cached = PARSE_CACHE.get(key);
|
|
543
|
+
if (!cached) {
|
|
544
|
+
if (debugEnabled()) console.log(`[annotations] store ${storeIdx}: parse not yet ready for key=${key}`);
|
|
545
|
+
storeIdx++;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (debugEnabled()) {
|
|
549
|
+
const buckets = cached.byStorey.size;
|
|
550
|
+
const looseLines = cached.loose.length;
|
|
551
|
+
console.log(`[annotations] store ${storeIdx}: lifting ${buckets} storey buckets + ${looseLines} loose lines (key=${key}, fallbackY=${fallbackY})`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
for (const bucket of cached.byStorey.values()) {
|
|
555
|
+
liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
|
|
556
|
+
}
|
|
557
|
+
liftTo3DLineList(cached.loose, fallbackY, verts);
|
|
558
|
+
storeIdx++;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (debugEnabled()) console.log(`[annotations] total 3D line vertices: ${verts.length / 3} from ${stores.length} stores`);
|
|
562
|
+
if (verts.length === 0) return EMPTY_F32;
|
|
563
|
+
return new Float32Array(verts);
|
|
564
|
+
}, [enabled, stores, version, fallbackY]);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* A text annotation lifted into 3D world space.
|
|
569
|
+
*
|
|
570
|
+
* `worldPos[1]` is the storey Y the annotation belongs to (or `fallbackY` for
|
|
571
|
+
* orphans). `dirX / dirZ` is the baseline direction in 3D (already mirrored
|
|
572
|
+
* from the IFC frame to match the section overlay's coordinate handedness).
|
|
573
|
+
* `height` is in world units.
|
|
574
|
+
*/
|
|
575
|
+
export interface AnnotationText3D {
|
|
576
|
+
worldPos: [number, number, number];
|
|
577
|
+
dirX: number;
|
|
578
|
+
dirZ: number;
|
|
579
|
+
height: number;
|
|
580
|
+
content: string;
|
|
581
|
+
alignment: string;
|
|
582
|
+
/** True when the glyph quad should rebuild in camera-aligned basis (grid tags). */
|
|
583
|
+
billboard?: boolean;
|
|
584
|
+
/** sRGB straight-alpha tint, 0..1. */
|
|
585
|
+
color?: [number, number, number, number];
|
|
586
|
+
/** Per-instance target cap height in screen pixels. */
|
|
587
|
+
targetPx?: number;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* A filled region lifted into 3D world space. `points` is a flat
|
|
592
|
+
* `[x, z, x, z, …]` ring buffer (Y is constant = `worldY`). Holes are tracked
|
|
593
|
+
* via `holesOffsets` (vertex indices into `points`); the renderer triangulates.
|
|
594
|
+
*/
|
|
595
|
+
export interface AnnotationFill3D {
|
|
596
|
+
points: Float32Array;
|
|
597
|
+
holesOffsets: Uint32Array;
|
|
598
|
+
worldY: number;
|
|
599
|
+
color: [number, number, number, number];
|
|
600
|
+
hatching?: AnnotationFill2D['hatching'];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Cheap stable empty arrays for the no-data path. */
|
|
604
|
+
const EMPTY_TEXTS: readonly AnnotationText3D[] = Object.freeze([]);
|
|
605
|
+
const EMPTY_FILLS: readonly AnnotationFill3D[] = Object.freeze([]);
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Hook for the 2D Section panel: filters the shared parse cache to
|
|
609
|
+
* annotations whose world position falls inside the section's view-range
|
|
610
|
+
* on the cut axis, returning data in the Drawing2D coordinate frame.
|
|
611
|
+
*
|
|
612
|
+
* For `axis='down'` (floor plan), the parser's 2D coords already match
|
|
613
|
+
* the drawing-2d coord frame directly (x = world x, y = world z, with
|
|
614
|
+
* worldY = the cut axis). For elevation views (`axis='front'`,
|
|
615
|
+
* `axis='side'`), this hook returns empty: most authored IFC annotations
|
|
616
|
+
* are floor-plan symbols (dimensions, leaders, room labels) and don't
|
|
617
|
+
* project meaningfully onto a vertical drawing without a separate
|
|
618
|
+
* reorientation pass. Wiring those up cleanly is a follow-up.
|
|
619
|
+
*
|
|
620
|
+
* The section position is in world units (already converted from the
|
|
621
|
+
* 0-100% slider via `axisMin + (position / 100) * (axisMax - axisMin)`
|
|
622
|
+
* by the caller — Section2DPanel computes the same value to feed the
|
|
623
|
+
* drawing generator).
|
|
624
|
+
*/
|
|
625
|
+
export interface DrawingAnnotationData {
|
|
626
|
+
lines: DrawingLine2D[];
|
|
627
|
+
texts: AnnotationText2D[];
|
|
628
|
+
fills: AnnotationFill2D[];
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const EMPTY_DRAWING_ANNOTATIONS: DrawingAnnotationData = {
|
|
632
|
+
lines: [],
|
|
633
|
+
texts: [],
|
|
634
|
+
fills: [],
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
export function useSymbolicAnnotationsForDrawing(params: {
|
|
638
|
+
enabled: boolean;
|
|
639
|
+
axis: 'down' | 'front' | 'side';
|
|
640
|
+
/** Section plane world-coord position along the cut axis. */
|
|
641
|
+
sectionPosWorld: number;
|
|
642
|
+
/** View depth in world units (typically half the model extent on the cut axis). */
|
|
643
|
+
viewDepth: number;
|
|
644
|
+
flipped: boolean;
|
|
645
|
+
/** Fallback world Y for annotations with no resolvable storey. */
|
|
646
|
+
fallbackY?: number;
|
|
647
|
+
}): DrawingAnnotationData {
|
|
648
|
+
const { enabled, axis, sectionPosWorld, viewDepth, flipped, fallbackY = 0 } = params;
|
|
649
|
+
const stores = useActiveStores();
|
|
650
|
+
const version = useAnnotationParseTrigger(enabled, stores);
|
|
651
|
+
|
|
652
|
+
return useMemo(() => {
|
|
653
|
+
if (!enabled) return EMPTY_DRAWING_ANNOTATIONS;
|
|
654
|
+
// Only floor plans (axis='down') are supported on this pass. Annotations
|
|
655
|
+
// for elevations/sections need a coord-reorientation pass that is not
|
|
656
|
+
// worth building until there's a real authored elevation symbol to test
|
|
657
|
+
// against. Returning empty quietly keeps the toggle a no-op there.
|
|
658
|
+
if (axis !== 'down') return EMPTY_DRAWING_ANNOTATIONS;
|
|
659
|
+
void version;
|
|
660
|
+
|
|
661
|
+
// Section view range in world Y. Matches the convention used by
|
|
662
|
+
// `profile-projector.isInProjectionRange`:
|
|
663
|
+
// not flipped → [sectionPos, sectionPos + viewDepth]
|
|
664
|
+
// flipped → [sectionPos - viewDepth, sectionPos]
|
|
665
|
+
// We expand the range by a small tolerance so annotations sitting
|
|
666
|
+
// exactly on the cut plane still match (storey elevations are
|
|
667
|
+
// typically the cut Y).
|
|
668
|
+
const TOL = 1e-3;
|
|
669
|
+
const rangeMin = (flipped ? sectionPosWorld - viewDepth : sectionPosWorld) - TOL;
|
|
670
|
+
const rangeMax = (flipped ? sectionPosWorld : sectionPosWorld + viewDepth) + TOL;
|
|
671
|
+
|
|
672
|
+
const lines: DrawingLine2D[] = [];
|
|
673
|
+
const texts: AnnotationText2D[] = [];
|
|
674
|
+
const fills: AnnotationFill2D[] = [];
|
|
675
|
+
|
|
676
|
+
for (const store of stores) {
|
|
677
|
+
const key = sourceKey(store);
|
|
678
|
+
if (!key) continue;
|
|
679
|
+
const cached = PARSE_CACHE.get(key);
|
|
680
|
+
if (!cached) continue;
|
|
681
|
+
|
|
682
|
+
for (const bucket of cached.byStorey.values()) {
|
|
683
|
+
const bucketY = resolveBucketY(bucket.storeyElevation, fallbackY);
|
|
684
|
+
if (bucketY < rangeMin || bucketY > rangeMax) continue;
|
|
685
|
+
for (const ln of bucket.lines) lines.push(ln);
|
|
686
|
+
for (const t of bucket.texts) texts.push(t);
|
|
687
|
+
for (const f of bucket.fills) fills.push(f);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Loose annotations have no resolvable storey — include them if the
|
|
691
|
+
// fallback Y lands in the view range. That keeps malformed exports
|
|
692
|
+
// (e.g. 3DEXPERIENCE files with orphaned storeys) usable when the
|
|
693
|
+
// user is looking at the storey the fallback resolves to.
|
|
694
|
+
if (fallbackY >= rangeMin && fallbackY <= rangeMax) {
|
|
695
|
+
for (const ln of cached.loose) lines.push(ln);
|
|
696
|
+
for (const t of cached.looseTexts) texts.push(t);
|
|
697
|
+
for (const f of cached.looseFills) fills.push(f);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (lines.length === 0 && texts.length === 0 && fills.length === 0) {
|
|
702
|
+
return EMPTY_DRAWING_ANNOTATIONS;
|
|
703
|
+
}
|
|
704
|
+
return { lines, texts, fills };
|
|
705
|
+
}, [enabled, axis, sectionPosWorld, viewDepth, flipped, fallbackY, stores, version]);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Hook for the WebGPU text + fill pipelines. Returns 3D-lifted texts and
|
|
710
|
+
* fills for every active model. Shares the parse cache with
|
|
711
|
+
* `useSymbolicAnnotations` so toggling on text+fill rendering after the
|
|
712
|
+
* line overlay is already up costs no extra parse work.
|
|
713
|
+
*/
|
|
714
|
+
export function useSymbolicAnnotationsRichData(params: {
|
|
715
|
+
enabled: boolean;
|
|
716
|
+
fallbackY?: number;
|
|
717
|
+
}): { texts: readonly AnnotationText3D[]; fills: readonly AnnotationFill3D[] } {
|
|
718
|
+
const { enabled, fallbackY = 0 } = params;
|
|
719
|
+
const stores = useActiveStores();
|
|
720
|
+
const version = useAnnotationParseTrigger(enabled, stores);
|
|
721
|
+
|
|
722
|
+
return useMemo(() => {
|
|
723
|
+
if (!enabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS };
|
|
724
|
+
void version;
|
|
725
|
+
|
|
726
|
+
const texts: AnnotationText3D[] = [];
|
|
727
|
+
const fills: AnnotationFill3D[] = [];
|
|
728
|
+
|
|
729
|
+
for (const store of stores) {
|
|
730
|
+
const key = sourceKey(store);
|
|
731
|
+
if (!key) continue;
|
|
732
|
+
const cached = PARSE_CACHE.get(key);
|
|
733
|
+
if (!cached) continue;
|
|
734
|
+
|
|
735
|
+
const pushText = (t: AnnotationText2D, y: number) => {
|
|
736
|
+
// lineYOffset stacks multi-line text downward in world-Y. Glyph
|
|
737
|
+
// upAxis is world-Y (see SymbolicTextPipeline), so subtracting
|
|
738
|
+
// here puts line 1 below line 0 on screen for any side/oblique
|
|
739
|
+
// 3D view of the floor plan.
|
|
740
|
+
texts.push({
|
|
741
|
+
worldPos: [t.x, y + (t.lineYOffset ?? 0), t.y],
|
|
742
|
+
dirX: t.dirX,
|
|
743
|
+
dirZ: t.dirY,
|
|
744
|
+
height: t.height,
|
|
745
|
+
content: t.content,
|
|
746
|
+
alignment: t.alignment,
|
|
747
|
+
billboard: t.billboard,
|
|
748
|
+
color: t.color,
|
|
749
|
+
targetPx: t.targetPx,
|
|
750
|
+
});
|
|
751
|
+
};
|
|
752
|
+
const pushFill = (f: AnnotationFill2D, y: number) => {
|
|
753
|
+
fills.push({
|
|
754
|
+
points: f.points,
|
|
755
|
+
holesOffsets: f.holesOffsets,
|
|
756
|
+
worldY: y,
|
|
757
|
+
color: f.color,
|
|
758
|
+
hatching: f.hatching,
|
|
759
|
+
});
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
for (const bucket of cached.byStorey.values()) {
|
|
763
|
+
const y = resolveBucketY(bucket.storeyElevation, fallbackY);
|
|
764
|
+
for (const t of bucket.texts) pushText(t, y);
|
|
765
|
+
for (const f of bucket.fills) pushFill(f, y);
|
|
766
|
+
}
|
|
767
|
+
for (const t of cached.looseTexts) pushText(t, fallbackY);
|
|
768
|
+
for (const f of cached.looseFills) pushFill(f, fallbackY);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
texts: texts.length ? texts : EMPTY_TEXTS,
|
|
773
|
+
fills: fills.length ? fills : EMPTY_FILLS,
|
|
774
|
+
};
|
|
775
|
+
}, [enabled, stores, version, fallbackY]);
|
|
776
|
+
}
|