@ifc-lite/viewer 1.24.0 → 1.25.1

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 (43) hide show
  1. package/.turbo/turbo-build.log +30 -30
  2. package/CHANGELOG.md +86 -0
  3. package/dist/assets/{basketViewActivator-BxyL3ITR.js → basketViewActivator-Dkn92C04.js} +7 -7
  4. package/dist/assets/{bcf-OInQ7hh6.js → bcf-DP2AK1-_.js} +23 -23
  5. package/dist/assets/{deflate-D0Sm0vyt.js → deflate-BYqYwhkl.js} +1 -1
  6. package/dist/assets/{exporters-CsSbJLHQ.js → exporters-CZe0D8N-.js} +13 -13
  7. package/dist/assets/{geometry-controller.worker-DuPxLFYp.js → geometry-controller.worker-pD49_fH6.js} +2 -2
  8. package/dist/assets/{geometry.worker-DvOb53b0.js → geometry.worker-D4c-06r5.js} +1 -1
  9. package/dist/assets/{geotiff-DBMPIaHW.js → geotiff-By06vdeL.js} +10 -10
  10. package/dist/assets/{ids-DCWn6VHu.js → ids-DDkkb4mo.js} +3 -3
  11. package/dist/assets/{ifc-lite-BUH-uP-q.js → ifc-lite-DxGqDbjO.js} +2 -2
  12. package/dist/assets/{ifc-lite_bg-CeGXwVPt.wasm → ifc-lite_bg-BNeu7R_V.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-DHXAIZHs.wasm → ifc-lite_bg-DuxUZomW.wasm} +0 -0
  14. package/dist/assets/{index-DAbQbcNs.js → index-CqBdDOAZ.js} +34448 -33855
  15. package/dist/assets/{jpeg-Btyb9xCl.js → jpeg-B4IBTphL.js} +1 -1
  16. package/dist/assets/lens-PYsLu_MA.js +1 -0
  17. package/dist/assets/{lerc-4m4sf12j.js → lerc-DQ3jI0Ke.js} +1 -1
  18. package/dist/assets/{lzw-12qxv42v.js → lzw-CtdH775t.js} +1 -1
  19. package/dist/assets/{native-bridge-vefdDEJb.js → native-bridge-DA8wxaN_.js} +2 -2
  20. package/dist/assets/{packbits-Sk1wXwnQ.js → packbits-DG3zn49C.js} +1 -1
  21. package/dist/assets/{parser.worker-CIEsmhWI.js → parser.worker-BZZcO7DB.js} +1 -1
  22. package/dist/assets/raw-DY7Y_acr.js +1 -0
  23. package/dist/assets/{sandbox-B_rh0uLM.js → sandbox-D1pQT-5R.js} +9 -8
  24. package/dist/assets/{server-client-B-rg3bm8.js → server-client-D9xO_8yX.js} +1 -1
  25. package/dist/assets/{wasm-bridge-BMLcD_0Y.js → wasm-bridge-DMX8Acuf.js} +1 -1
  26. package/dist/assets/{webimage-BzKBQcAG.js → webimage-_-qCDjkn.js} +1 -1
  27. package/dist/assets/{workerHelpers-Bo7aHk9e.js → workerHelpers-Crstj4Oa.js} +1 -1
  28. package/dist/assets/{zstd-CfDyQt3x.js → zstd-DlfgC8gA.js} +1 -1
  29. package/dist/index.html +7 -7
  30. package/package.json +9 -9
  31. package/src/components/viewer/BCFPanel.tsx +8 -1
  32. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  33. package/src/components/viewer/LensPanel.tsx +50 -0
  34. package/src/components/viewer/Section2DPanel.tsx +62 -2
  35. package/src/components/viewer/bcf/BCFTopicDetail.tsx +24 -0
  36. package/src/hooks/useBCF.ts +98 -16
  37. package/src/hooks/useDrawingGeneration.ts +149 -3
  38. package/src/hooks/useSymbolicAnnotations.ts +156 -11
  39. package/src/lib/lens/adapter.ts +14 -0
  40. package/src/store/index.ts +1 -0
  41. package/src/store/slices/drawing2DSlice.ts +8 -0
  42. package/dist/assets/lens-CpjUdqpw.js +0 -1
  43. package/dist/assets/raw-BCj_dK21.js +0 -1
@@ -9,10 +9,12 @@ import {
9
9
  type Drawing2D,
10
10
  type ElementData,
11
11
  } from '@ifc-lite/drawing-2d';
12
+ import type { DrawingLine2D } from '@ifc-lite/renderer';
12
13
  import { formatDistance } from './tools/formatDistance';
13
14
  import { formatArea, computePolygonCentroid } from './tools/computePolygonArea';
14
15
  import { drawCloudOnCanvas } from './tools/cloudPathGenerator';
15
16
  import type { PolygonArea2DResult, TextAnnotation2D, CloudAnnotation2D, Annotation2DTool, Point2D, SelectedAnnotation2D } from '@/store/slices/drawing2DSlice';
17
+ import type { AnnotationFill2D, AnnotationText2D } from '@/hooks/useSymbolicAnnotations';
16
18
 
17
19
  // Fill colors for IFC types (architectural convention)
18
20
  const IFC_TYPE_FILL_COLORS: Record<string, string> = {
@@ -53,6 +55,142 @@ export function getFillColorForType(ifcType: string): string {
53
55
  return IFC_TYPE_FILL_COLORS[ifcType] || IFC_TYPE_FILL_COLORS.default;
54
56
  }
55
57
 
58
+ // ─── IFC annotation overlay helpers (issue #812) ─────────────────────────────
59
+
60
+ /** Linear sRGB straight-alpha [0..1] tuple → CSS `rgba(...)`. */
61
+ function rgbaToCss(c: readonly [number, number, number, number]): string {
62
+ const r = Math.round(Math.max(0, Math.min(1, c[0])) * 255);
63
+ const g = Math.round(Math.max(0, Math.min(1, c[1])) * 255);
64
+ const b = Math.round(Math.max(0, Math.min(1, c[2])) * 255);
65
+ return `rgba(${r}, ${g}, ${b}, ${Math.max(0, Math.min(1, c[3]))})`;
66
+ }
67
+
68
+ /**
69
+ * Map IFC `BoxAlignment` to canvas2d `textAlign` + `textBaseline`. Mirrors
70
+ * the renderer's `parseBoxAlignment` semantics so the 2D overlay anchors
71
+ * text the same way the 3D pipeline does. Unknown / empty strings default
72
+ * to bottom-left (IFC4 IfcTextLiteralWithExtent default).
73
+ */
74
+ function alignmentToCanvas(s: string): { align: CanvasTextAlign; baseline: CanvasTextBaseline } {
75
+ const norm = (s ?? '').toLowerCase().trim();
76
+ let align: CanvasTextAlign = 'left';
77
+ let baseline: CanvasTextBaseline = 'alphabetic';
78
+ if (norm.includes('right')) align = 'right';
79
+ else if (norm.includes('center')) align = 'center';
80
+ if (norm.includes('top')) baseline = 'top';
81
+ else if (norm.includes('middle') || (norm.includes('center') && !norm.includes('center-'))) baseline = 'middle';
82
+ return { align, baseline };
83
+ }
84
+
85
+ /**
86
+ * Render IFC annotation fills, lines, and text into the canvas, in screen
87
+ * pixels. The caller supplies:
88
+ * - `modelToScreen` – drawing-coord → screen-pixel conversion (accounts
89
+ * for axis flips and sheet-mode paper scale)
90
+ * - `mmLineToScreen` – mm line weight → screen px stroke width
91
+ * - `worldHeightToScreenPx` – world-units text height → screen px font size
92
+ *
93
+ * Called from both the sheet-mode and direct-mode render paths in
94
+ * `Drawing2DCanvas`. Splitting it out keeps the two paths in sync without
95
+ * duplicating ~80 lines of canvas calls.
96
+ *
97
+ * Text rendering uses identity transform (no canvas rotate-with-scale) so
98
+ * `direct mode`'s y-flip doesn't mirror glyphs. The baseline direction is
99
+ * recovered in screen space from `dirX/dirY` mapped through `modelToScreen`.
100
+ */
101
+ function drawIfcAnnotationsScreenSpace(
102
+ ctx: CanvasRenderingContext2D,
103
+ lines: readonly DrawingLine2D[] | undefined,
104
+ texts: readonly AnnotationText2D[] | undefined,
105
+ fills: readonly AnnotationFill2D[] | undefined,
106
+ modelToScreen: (x: number, y: number) => { x: number; y: number },
107
+ mmLineToScreen: (mmWeight: number) => number,
108
+ worldHeightToScreenPx: (worldHeight: number) => number,
109
+ ): void {
110
+ // Fills first so lines/text composite cleanly on top.
111
+ if (fills && fills.length > 0) {
112
+ for (const fill of fills) {
113
+ const pts = fill.points;
114
+ if (pts.length < 6) continue;
115
+ const holes = fill.holesOffsets;
116
+
117
+ ctx.fillStyle = rgbaToCss(fill.color);
118
+ ctx.beginPath();
119
+
120
+ // Outer ring runs from index 0 up to the first hole offset (or end of
121
+ // points if no holes). Each hole offset is a vertex index where the
122
+ // next ring starts. Path subpaths use moveTo + lineTo + closePath; the
123
+ // even-odd fill rule handles the holes.
124
+ const ringStarts: number[] = [0];
125
+ for (let i = 0; i < holes.length; i++) ringStarts.push(holes[i]);
126
+ ringStarts.push(pts.length / 2); // sentinel end
127
+
128
+ for (let ri = 0; ri < ringStarts.length - 1; ri++) {
129
+ const start = ringStarts[ri];
130
+ const end = ringStarts[ri + 1];
131
+ if (end - start < 3) continue;
132
+ const first = modelToScreen(pts[start * 2], pts[start * 2 + 1]);
133
+ ctx.moveTo(first.x, first.y);
134
+ for (let i = start + 1; i < end; i++) {
135
+ const p = modelToScreen(pts[i * 2], pts[i * 2 + 1]);
136
+ ctx.lineTo(p.x, p.y);
137
+ }
138
+ ctx.closePath();
139
+ }
140
+ ctx.fill('evenodd');
141
+ }
142
+ }
143
+
144
+ if (lines && lines.length > 0) {
145
+ // Slightly heavier than the drawing-2d 'annotation' category (0.13 mm)
146
+ // so coplanar overlays read clearly against the cut polygons beneath.
147
+ // This is the 2D equivalent of the 3D thicker-lines suggestion in #812.
148
+ const lineWidthMm = 0.2;
149
+ ctx.strokeStyle = '#000000';
150
+ ctx.lineWidth = mmLineToScreen(lineWidthMm);
151
+ ctx.setLineDash([]);
152
+ ctx.beginPath();
153
+ for (const ln of lines) {
154
+ const a = modelToScreen(ln.line.start.x, ln.line.start.y);
155
+ const b = modelToScreen(ln.line.end.x, ln.line.end.y);
156
+ ctx.moveTo(a.x, a.y);
157
+ ctx.lineTo(b.x, b.y);
158
+ }
159
+ ctx.stroke();
160
+ }
161
+
162
+ if (texts && texts.length > 0) {
163
+ for (const t of texts) {
164
+ if (!t.content) continue;
165
+ const anchor = modelToScreen(t.x, t.y);
166
+ // Recover baseline direction in SCREEN space. modelToScreen may flip
167
+ // axes (e.g. direct-mode flips Y), so atan2(dirY, dirX) on raw model
168
+ // dirs would draw the text mirrored on those axes.
169
+ const baselineEnd = modelToScreen(t.x + t.dirX, t.y + t.dirY);
170
+ const sx = baselineEnd.x - anchor.x;
171
+ const sy = baselineEnd.y - anchor.y;
172
+ const angle = Math.abs(sx) + Math.abs(sy) > 1e-6 ? Math.atan2(sy, sx) : 0;
173
+
174
+ const fontPx = t.targetPx && t.targetPx > 0 ? t.targetPx : worldHeightToScreenPx(t.height);
175
+ const { align, baseline } = alignmentToCanvas(t.alignment);
176
+ // Multi-line literals stack downward in world Y in 3D. In 2D screen
177
+ // space the equivalent is `+ fontPx` per line below the anchor along
178
+ // the baseline-perpendicular (handled by the canvas rotate below).
179
+ const lineOffsetPx = (t.lineYOffset ?? 0) * (fontPx / Math.max(1e-6, t.height));
180
+
181
+ ctx.save();
182
+ ctx.fillStyle = t.color ? rgbaToCss(t.color) : '#000000';
183
+ ctx.font = `${fontPx}px system-ui, sans-serif`;
184
+ ctx.textAlign = align;
185
+ ctx.textBaseline = baseline;
186
+ ctx.translate(anchor.x, anchor.y);
187
+ ctx.rotate(angle);
188
+ ctx.fillText(t.content, 0, lineOffsetPx);
189
+ ctx.restore();
190
+ }
191
+ }
192
+ }
193
+
56
194
  // Static constants to avoid creating new objects/arrays on every render
57
195
  const CANVAS_STYLE = { imageRendering: 'crisp-edges' as const };
58
196
  const EMPTY_MEASURE_RESULTS: Measure2DResultData[] = [];
@@ -97,6 +235,10 @@ interface Drawing2DCanvasProps {
97
235
  cloudAnnotations?: CloudAnnotation2D[];
98
236
  // Selection
99
237
  selectedAnnotation?: SelectedAnnotation2D | null;
238
+ // IFC annotation overlay (issue #812)
239
+ ifcAnnotationLines?: readonly DrawingLine2D[];
240
+ ifcAnnotationTexts?: readonly AnnotationText2D[];
241
+ ifcAnnotationFills?: readonly AnnotationFill2D[];
100
242
  }
101
243
 
102
244
  export function Drawing2DCanvas({
@@ -126,6 +268,9 @@ export function Drawing2DCanvas({
126
268
  cloudAnnotationPoints = [],
127
269
  cloudAnnotations = [],
128
270
  selectedAnnotation = null,
271
+ ifcAnnotationLines,
272
+ ifcAnnotationTexts,
273
+ ifcAnnotationFills,
129
274
  }: Drawing2DCanvasProps): React.ReactElement {
130
275
  const canvasRef = useRef<HTMLCanvasElement>(null);
131
276
  const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
@@ -630,6 +775,17 @@ export function Drawing2DCanvas({
630
775
  ctx.stroke();
631
776
  ctx.setLineDash([]);
632
777
  }
778
+
779
+ // IFC annotation overlay (issue #812)
780
+ drawIfcAnnotationsScreenSpace(
781
+ ctx,
782
+ ifcAnnotationLines,
783
+ ifcAnnotationTexts,
784
+ ifcAnnotationFills,
785
+ modelToScreen,
786
+ (mm) => Math.max(0.5, mmToScreen(mm) * 0.3),
787
+ (worldHeight) => Math.max(8, worldHeight * drawingTransform.scaleFactor * transform.scale),
788
+ );
633
789
  };
634
790
 
635
791
  drawModelContent();
@@ -945,6 +1101,27 @@ export function Drawing2DCanvas({
945
1101
  }
946
1102
 
947
1103
  ctx.restore();
1104
+
1105
+ // IFC annotation overlay (issue #812). Rendered after ctx.restore so we
1106
+ // can size lines and text in screen pixels rather than fighting the
1107
+ // ctx.scale applied above (which would inverse-scale everything).
1108
+ const directScaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
1109
+ const directScaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
1110
+ drawIfcAnnotationsScreenSpace(
1111
+ ctx,
1112
+ ifcAnnotationLines,
1113
+ ifcAnnotationTexts,
1114
+ ifcAnnotationFills,
1115
+ (x, y) => ({ x: x * directScaleX + transform.x, y: y * directScaleY + transform.y }),
1116
+ // No paper scale here: take a baseline 0.3 px per "default mm" so
1117
+ // weights match the heavier projection lines visually. Annotation
1118
+ // strokes in IFC are intentionally lighter than projection lines,
1119
+ // but a hair too thin on a 1× screen disappears entirely.
1120
+ (mmWeight) => Math.max(0.5, mmWeight * transform.scale * 0.3),
1121
+ // World height directly to screen pixels through the active zoom.
1122
+ // 8 px floor so labels stay legible when zoomed way out.
1123
+ (worldHeight) => Math.max(8, worldHeight * transform.scale),
1124
+ );
948
1125
  }
949
1126
 
950
1127
  // ═══════════════════════════════════════════════════════════════════════
@@ -1399,7 +1576,7 @@ export function Drawing2DCanvas({
1399
1576
  }
1400
1577
  }
1401
1578
  }
1402
- }, [drawing, transform, showHiddenLines, canvasSize, overrideEngine, overridesEnabled, entityColorMap, useIfcMaterials, measureMode, measureStart, measureCurrent, measureResults, measureSnapPoint, sheetEnabled, activeSheet, sectionAxis, isPinned, annotation2DActiveTool, annotation2DCursorPos, polygonAreaPoints, polygonAreaResults, textAnnotations, textAnnotationEditing, cloudAnnotationPoints, cloudAnnotations, selectedAnnotation]);
1579
+ }, [drawing, transform, showHiddenLines, canvasSize, overrideEngine, overridesEnabled, entityColorMap, useIfcMaterials, measureMode, measureStart, measureCurrent, measureResults, measureSnapPoint, sheetEnabled, activeSheet, sectionAxis, isPinned, annotation2DActiveTool, annotation2DCursorPos, polygonAreaPoints, polygonAreaResults, textAnnotations, textAnnotationEditing, cloudAnnotationPoints, cloudAnnotations, selectedAnnotation, ifcAnnotationLines, ifcAnnotationTexts, ifcAnnotationFills]);
1403
1580
 
1404
1581
  return (
1405
1582
  <canvas
@@ -44,6 +44,7 @@ const TYPE_LABELS: Record<string, string> = {
44
44
  quantity: 'Quantity',
45
45
  classification: 'Classification',
46
46
  material: 'Material',
47
+ model: 'Model',
47
48
  };
48
49
 
49
50
  interface LensPanelProps {
@@ -285,6 +286,11 @@ function RuleEditor({
285
286
  onRequestDiscovery: (categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => void;
286
287
  }) {
287
288
  const criteriaType = rule.criteria.type;
289
+ const loadedModels = useViewerStore((s) => s.models);
290
+ const modelOptions = useMemo(
291
+ () => Array.from(loadedModels.values()).sort((a, b) => a.name.localeCompare(b.name)),
292
+ [loadedModels],
293
+ );
288
294
 
289
295
  // Trigger lazy discovery when user selects a criteria type that needs it
290
296
  useEffect(() => {
@@ -300,6 +306,18 @@ function RuleEditor({
300
306
  }
301
307
  }, [criteriaType, discovered, onRequestDiscovery]);
302
308
 
309
+ // Auto-populate the single available model so the selector-hidden branch
310
+ // doesn't leave a model rule permanently invalid.
311
+ useEffect(() => {
312
+ if (criteriaType !== 'model') return;
313
+ if (modelOptions.length !== 1) return;
314
+ if (rule.criteria.modelId) return;
315
+ const updated = { ...rule.criteria, modelId: modelOptions[0].id };
316
+ onChange({ criteria: updated, name: deriveRuleName(updated) });
317
+ // deriveRuleName is stable for this render; depending on rule.criteria/onChange is enough.
318
+ // eslint-disable-next-line react-hooks/exhaustive-deps
319
+ }, [criteriaType, modelOptions, rule.criteria, onChange]);
320
+
303
321
  // Derived lists from discovered data
304
322
  const ifcClasses = useMemo(() => discovered?.classes ?? [], [discovered]);
305
323
  const psetNames = useMemo((): string[] => {
@@ -350,6 +368,9 @@ function RuleEditor({
350
368
  case 'material':
351
369
  base.materialName = '';
352
370
  break;
371
+ case 'model':
372
+ base.modelId = modelOptions.length === 1 ? modelOptions[0].id : '';
373
+ break;
353
374
  }
354
375
  onChange({ criteria: base, name: rule.name === 'New Rule' ? TYPE_LABELS[newType] : rule.name });
355
376
  };
@@ -363,6 +384,10 @@ function RuleEditor({
363
384
  case 'quantity': return criteria.quantityName || 'Quantity';
364
385
  case 'classification': return criteria.classificationCode || criteria.classificationSystem || 'Classification';
365
386
  case 'material': return criteria.materialName || 'Material';
387
+ case 'model': {
388
+ const selected = modelOptions.find(m => m.id === criteria.modelId);
389
+ return selected?.name || 'Model';
390
+ }
366
391
  default: return 'Rule';
367
392
  }
368
393
  };
@@ -516,6 +541,30 @@ function RuleEditor({
516
541
  />
517
542
  )}
518
543
 
544
+ {/* Model: dropdown from loaded federated models */}
545
+ {criteriaType === 'model' && (
546
+ modelOptions.length <= 1 ? (
547
+ <span className="flex-1 min-w-0 text-xs text-zinc-400 dark:text-zinc-500 truncate">
548
+ {modelOptions.length === 0 ? 'No models loaded' : modelOptions[0]?.name ?? 'Model'}
549
+ </span>
550
+ ) : (
551
+ <select
552
+ value={rule.criteria.modelId ?? ''}
553
+ onChange={(e) => {
554
+ const modelId = e.target.value;
555
+ const updated = { ...rule.criteria, modelId };
556
+ onChange({ criteria: updated, name: deriveRuleName(updated) });
557
+ }}
558
+ className={cn(selectClass, 'flex-1 min-w-0')}
559
+ >
560
+ <option value="">Model...</option>
561
+ {modelOptions.map(m => (
562
+ <option key={m.id} value={m.id}>{m.name}</option>
563
+ ))}
564
+ </select>
565
+ )
566
+ )}
567
+
519
568
  <button
520
569
  onClick={onRemove}
521
570
  className="text-zinc-400 hover:text-red-500 dark:text-zinc-500 dark:hover:text-red-400 p-0.5 flex-shrink-0"
@@ -644,6 +693,7 @@ function LensEditor({
644
693
  case 'quantity': return !!c.quantitySet && !!c.quantityName;
645
694
  case 'classification': return !!c.classificationSystem || !!c.classificationCode;
646
695
  case 'material': return !!c.materialName;
696
+ case 'model': return !!c.modelId;
647
697
  default: return false;
648
698
  }
649
699
  };
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
15
- import { X, Download, Eye, EyeOff, Maximize2, ZoomIn, ZoomOut, Loader2, Printer, GripVertical, MoreHorizontal, RefreshCw, Pin, PinOff, Palette, Ruler, Trash2, FileText, Shapes, Box, PenTool, Hexagon, Type, Cloud, MousePointer2 } from 'lucide-react';
15
+ import { X, Download, Eye, EyeOff, Maximize2, ZoomIn, ZoomOut, Loader2, Printer, GripVertical, MoreHorizontal, RefreshCw, Pin, PinOff, Palette, Ruler, Trash2, FileText, Shapes, Box, PenTool, Hexagon, Type, Cloud, MousePointer2, Tag } from 'lucide-react';
16
16
  import { Button } from '@/components/ui/button';
17
17
  import {
18
18
  DropdownMenu,
@@ -31,11 +31,12 @@ import { SheetSetupPanel } from './SheetSetupPanel';
31
31
  import { TitleBlockEditor } from './TitleBlockEditor';
32
32
  import { TextAnnotationEditor } from './TextAnnotationEditor';
33
33
  import { Drawing2DCanvas } from './Drawing2DCanvas';
34
- import { useDrawingGeneration } from '@/hooks/useDrawingGeneration';
34
+ import { useDrawingGeneration, AXIS_MAP, ANNOTATION_VIEW_DEPTH } from '@/hooks/useDrawingGeneration';
35
35
  import { useMeasure2D } from '@/hooks/useMeasure2D';
36
36
  import { useAnnotation2D } from '@/hooks/useAnnotation2D';
37
37
  import { useViewControls } from '@/hooks/useViewControls';
38
38
  import { useDrawingExport } from '@/hooks/useDrawingExport';
39
+ import { useSymbolicAnnotationsForDrawing } from '@/hooks/useSymbolicAnnotations';
39
40
 
40
41
  interface Section2DPanelProps {
41
42
  mergedGeometry?: GeometryResult | null;
@@ -279,6 +280,47 @@ export function Section2DPanel({
279
280
  setMeasure2DSnapPoint, cancelMeasure2D, completeMeasure2D,
280
281
  });
281
282
 
283
+ // ─── IFC annotation overlay (issue #812) ──────────────────────────────────
284
+ // Re-derive the section's world-coord cut position from the same bounds
285
+ // useDrawingGeneration uses, so the annotation filter stays in lockstep
286
+ // with the cut. Empty/missing bounds collapse to an inert range → hook
287
+ // returns empty, the overlay simply does nothing.
288
+ const ifcAnnotationsForDrawing = useMemo(() => {
289
+ const bounds = geometryResult?.coordinateInfo?.shiftedBounds;
290
+ if (!bounds) {
291
+ return { sectionPosWorld: 0, viewDepth: 0, fallbackY: 0 };
292
+ }
293
+ const axis = AXIS_MAP[sectionPlane.axis];
294
+ const axisMin = bounds.min[axis];
295
+ const axisMax = bounds.max[axis];
296
+ const sectionPosWorld = axisMin + (sectionPlane.position / 100) * (axisMax - axisMin);
297
+ // IFC annotations get a tight 1.2 m view-depth slab — typical plan-view
298
+ // convention so dimension chains from the next storey don't stack onto
299
+ // the cut floor. The body cutter still uses half-extent for its own
300
+ // projection edges; the slab is annotation-specific.
301
+ const viewDepth = ANNOTATION_VIEW_DEPTH;
302
+ // For loose annotations (no resolvable storey), fall back to mid-Y like
303
+ // the 3D viewport does. This lets storeyless models still surface their
304
+ // annotations on the relevant section.
305
+ const yMin = bounds.min.y;
306
+ const yMax = bounds.max.y;
307
+ const fallbackY = Number.isFinite(yMin) && Number.isFinite(yMax) ? (yMin + yMax) * 0.5 : 0;
308
+ return { sectionPosWorld, viewDepth, fallbackY };
309
+ }, [geometryResult, sectionPlane.axis, sectionPlane.position]);
310
+
311
+ const ifcAnnotationData = useSymbolicAnnotationsForDrawing({
312
+ enabled: displayOptions.showIfcAnnotations && status === 'ready',
313
+ axis: sectionPlane.axis,
314
+ sectionPosWorld: ifcAnnotationsForDrawing.sectionPosWorld,
315
+ viewDepth: ifcAnnotationsForDrawing.viewDepth,
316
+ flipped: sectionPlane.flipped,
317
+ fallbackY: ifcAnnotationsForDrawing.fallbackY,
318
+ });
319
+
320
+ const toggleIfcAnnotations = useCallback(() => {
321
+ updateDisplayOptions({ showIfcAnnotations: !displayOptions.showIfcAnnotations });
322
+ }, [displayOptions.showIfcAnnotations, updateDisplayOptions]);
323
+
282
324
  const annotationHandlers = useAnnotation2D({
283
325
  drawing, viewTransform, sectionAxis: sectionPlane.axis, containerRef,
284
326
  activeTool: annotation2DActiveTool, setActiveTool: setAnnotation2DActiveTool,
@@ -536,6 +578,17 @@ export function Section2DPanel({
536
578
  {displayOptions.useSymbolicRepresentations ? <Shapes className="h-4 w-4" /> : <Box className="h-4 w-4" />}
537
579
  </Button>
538
580
 
581
+ {/* IFC Annotations overlay toggle (issue #812) */}
582
+ <Button
583
+ variant={displayOptions.showIfcAnnotations ? 'default' : 'ghost'}
584
+ size="icon-sm"
585
+ onClick={toggleIfcAnnotations}
586
+ title={displayOptions.showIfcAnnotations ? 'Hide IFC annotations on this section' : 'Show IFC annotations on this section'}
587
+ disabled={sectionPlane.axis !== 'down'}
588
+ >
589
+ <Tag className="h-4 w-4" />
590
+ </Button>
591
+
539
592
  {/* Annotation Tools Dropdown */}
540
593
  <DropdownMenu>
541
594
  <DropdownMenuTrigger asChild>
@@ -703,6 +756,10 @@ export function Section2DPanel({
703
756
  {displayOptions.useSymbolicRepresentations ? <Shapes className="h-4 w-4 mr-2" /> : <Box className="h-4 w-4 mr-2" />}
704
757
  {displayOptions.useSymbolicRepresentations ? 'Symbolic (Plan)' : 'Section Cut (Body)'}
705
758
  </DropdownMenuItem>
759
+ <DropdownMenuItem onClick={toggleIfcAnnotations} disabled={sectionPlane.axis !== 'down'}>
760
+ <Tag className="h-4 w-4 mr-2" />
761
+ IFC Annotations {displayOptions.showIfcAnnotations ? 'On' : 'Off'}
762
+ </DropdownMenuItem>
706
763
  <DropdownMenuItem onClick={() => setAnnotation2DActiveTool('none')}>
707
764
  <MousePointer2 className="h-4 w-4 mr-2" />
708
765
  Select / Pan {annotation2DActiveTool === 'none' ? '(On)' : ''}
@@ -849,6 +906,9 @@ export function Section2DPanel({
849
906
  cloudAnnotationPoints={cloudAnnotation2DPoints}
850
907
  cloudAnnotations={cloudAnnotations2D}
851
908
  selectedAnnotation={selectedAnnotation2D}
909
+ ifcAnnotationLines={ifcAnnotationData.lines}
910
+ ifcAnnotationTexts={ifcAnnotationData.texts}
911
+ ifcAnnotationFills={ifcAnnotationData.fills}
852
912
  />
853
913
  {/* Subtle updating indicator - shows while regenerating without hiding the drawing */}
854
914
  {isRegenerating && (
@@ -18,8 +18,14 @@ import {
18
18
  MousePointer2,
19
19
  Focus,
20
20
  EyeOff,
21
+ Crosshair,
21
22
  } from 'lucide-react';
22
23
  import { Button } from '@/components/ui/button';
24
+ import {
25
+ Tooltip,
26
+ TooltipContent,
27
+ TooltipTrigger,
28
+ } from '@/components/ui/tooltip';
23
29
  import { Input } from '@/components/ui/input';
24
30
  import { Badge } from '@/components/ui/badge';
25
31
  import {
@@ -46,6 +52,8 @@ export interface BCFTopicDetailProps {
46
52
  onActivateViewpoint: (viewpoint: BCFViewpoint) => void;
47
53
  onDeleteViewpoint: (viewpointGuid: string) => void;
48
54
  onUpdateStatus: (status: string) => void;
55
+ onZoomToTopic: () => void;
56
+ canZoomToTopic: boolean;
49
57
  onDeleteTopic: () => void;
50
58
  // Viewer state info for capture feedback
51
59
  selectionCount: number;
@@ -65,6 +73,8 @@ export function BCFTopicDetail({
65
73
  onActivateViewpoint,
66
74
  onDeleteViewpoint,
67
75
  onUpdateStatus,
76
+ onZoomToTopic,
77
+ canZoomToTopic,
68
78
  onDeleteTopic,
69
79
  selectionCount,
70
80
  hasIsolation,
@@ -107,6 +117,20 @@ export function BCFTopicDetail({
107
117
  <ChevronLeft className="h-4 w-4" />
108
118
  </Button>
109
119
  <h3 className="font-medium text-sm flex-1 truncate">{topic.title}</h3>
120
+ <Tooltip>
121
+ <TooltipTrigger asChild>
122
+ <Button
123
+ variant="ghost"
124
+ size="sm"
125
+ onClick={onZoomToTopic}
126
+ disabled={!canZoomToTopic}
127
+ aria-label="Zoom to topic"
128
+ >
129
+ <Crosshair className="h-4 w-4" aria-hidden />
130
+ </Button>
131
+ </TooltipTrigger>
132
+ <TooltipContent>Zoom to</TooltipContent>
133
+ </Tooltip>
110
134
  <Button
111
135
  variant="ghost"
112
136
  size="sm"
@@ -13,13 +13,15 @@
13
13
 
14
14
  import { useCallback, useRef } from 'react';
15
15
  import { useViewerStore } from '@/store';
16
- import type { BCFViewpoint } from '@ifc-lite/bcf';
16
+ import type { BCFTopic, BCFViewpoint } from '@ifc-lite/bcf';
17
17
  import {
18
18
  createViewpoint,
19
19
  extractViewpointState,
20
+ computeMarkerPositions,
20
21
  type ViewerCameraState,
21
22
  type ViewerSectionPlane,
22
23
  type ViewerBounds,
24
+ type OverlayBBox,
23
25
  } from '@ifc-lite/bcf';
24
26
  import type { Renderer } from '@ifc-lite/renderer';
25
27
  import {
@@ -52,6 +54,10 @@ interface UseBCFResult {
52
54
  createViewpointFromState: (options?: CreateViewpointOptions) => Promise<BCFViewpoint | null>;
53
55
  /** Apply a viewpoint to the viewer */
54
56
  applyViewpoint: (viewpoint: BCFViewpoint, animate?: boolean) => void;
57
+ /** Animate the camera to a BCF topic's 3D location (without changing selection/visibility) */
58
+ zoomToTopic: (topic: BCFTopic) => void;
59
+ /** Whether a topic has enough data to zoom to */
60
+ canZoomToTopic: (topic: BCFTopic) => boolean;
55
61
  /** Capture a snapshot from the canvas */
56
62
  captureSnapshot: () => Promise<string | null>;
57
63
  /** Set the canvas ref for snapshot capture */
@@ -96,6 +102,21 @@ export function clearGlobalRefs(): void {
96
102
  globalRendererRef = null;
97
103
  }
98
104
 
105
+ /** Apply extracted BCF camera state without touching selection, visibility, or section plane. */
106
+ function applyCameraState(
107
+ renderer: Renderer,
108
+ camera: NonNullable<ReturnType<typeof extractViewpointState>['camera']>,
109
+ animate: boolean,
110
+ ): void {
111
+ const rendererCamera = renderer.getCamera();
112
+ if (animate) {
113
+ rendererCamera.animateTo(camera.position, camera.target, 300);
114
+ } else {
115
+ rendererCamera.setPosition(camera.position.x, camera.position.y, camera.position.z);
116
+ rendererCamera.setTarget(camera.target.x, camera.target.y, camera.target.z);
117
+ }
118
+ }
119
+
99
120
  // ============================================================================
100
121
  // Hook
101
122
  // ============================================================================
@@ -351,6 +372,28 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
351
372
  ]
352
373
  );
353
374
 
375
+ /** Restore only the viewpoint camera (used by zoom-to-topic fallback). */
376
+ const applyViewpointCamera = useCallback(
377
+ (viewpoint: BCFViewpoint, animate = true) => {
378
+ const renderer = getRenderer();
379
+ if (!renderer) {
380
+ console.warn('[useBCF] Cannot apply viewpoint camera: no renderer');
381
+ return;
382
+ }
383
+
384
+ const bounds = getBounds() ?? undefined;
385
+ const state = extractViewpointState(
386
+ viewpoint,
387
+ bounds,
388
+ renderer.getCamera().getDistance(),
389
+ );
390
+ if (state.camera) {
391
+ applyCameraState(renderer, state.camera, animate);
392
+ }
393
+ },
394
+ [getRenderer, getBounds],
395
+ );
396
+
354
397
  /**
355
398
  * Apply a viewpoint to the viewer
356
399
  */
@@ -372,22 +415,8 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
372
415
  );
373
416
  const { camera, sectionPlane: viewpointSectionPlane } = state;
374
417
 
375
- // Apply camera
376
418
  if (camera) {
377
- const rendererCamera = renderer.getCamera();
378
-
379
- if (animate) {
380
- // Animate to new position
381
- rendererCamera.animateTo(
382
- camera.position,
383
- camera.target,
384
- 300 // 300ms animation
385
- );
386
- } else {
387
- // Set immediately
388
- rendererCamera.setPosition(camera.position.x, camera.position.y, camera.position.z);
389
- rendererCamera.setTarget(camera.target.x, camera.target.y, camera.target.z);
390
- }
419
+ applyCameraState(renderer, camera, animate);
391
420
  }
392
421
 
393
422
  // Apply section plane
@@ -482,9 +511,62 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
482
511
  ]
483
512
  );
484
513
 
514
+ const canZoomToTopic = useCallback((topic: BCFTopic): boolean => {
515
+ return topic.viewpoints.length > 0;
516
+ }, []);
517
+
518
+ const zoomToTopic = useCallback(
519
+ (topic: BCFTopic) => {
520
+ const renderer = getRenderer();
521
+ if (!renderer || topic.viewpoints.length === 0) return;
522
+
523
+ const boundsLookup = (ifcGuid: string): OverlayBBox | null => {
524
+ const result = globalIdToExpressId(ifcGuid);
525
+ if (!result) return null;
526
+ return renderer.getScene().getEntityBoundingBox(result.expressId);
527
+ };
528
+
529
+ const markers = computeMarkerPositions([topic], boundsLookup, {
530
+ targetDistance: renderer.getCamera().getDistance(),
531
+ });
532
+
533
+ if (markers.length > 0) {
534
+ const marker = markers[0];
535
+
536
+ if (marker.positionSource === 'component') {
537
+ for (let i = topic.viewpoints.length - 1; i >= 0; i--) {
538
+ const vp = topic.viewpoints[i];
539
+ const guids = [
540
+ ...(vp.components?.selection ?? []),
541
+ ...(vp.components?.visibility?.exceptions ?? []),
542
+ ];
543
+ for (const comp of guids) {
544
+ if (!comp.ifcGuid) continue;
545
+ const bbox = boundsLookup(comp.ifcGuid);
546
+ if (bbox) {
547
+ void renderer.getCamera().frameBounds(bbox.min, bbox.max);
548
+ return;
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ const point = marker.connectorAnchor ?? marker.position;
555
+ void renderer.getCamera().framePoint(point);
556
+ return;
557
+ }
558
+
559
+ // Fallback: camera from latest viewpoint only — preserve selection/visibility
560
+ applyViewpointCamera(topic.viewpoints[topic.viewpoints.length - 1], true);
561
+ },
562
+ [applyViewpointCamera, getRenderer, globalIdToExpressId],
563
+ );
564
+
485
565
  return {
486
566
  createViewpointFromState,
487
567
  applyViewpoint,
568
+ zoomToTopic,
569
+ canZoomToTopic,
488
570
  captureSnapshot,
489
571
  setCanvasRef,
490
572
  setRendererRef,