@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.
Files changed (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. 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
+ }