@ifc-lite/viewer 1.17.6 → 1.18.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 (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -0,0 +1,540 @@
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
+ * Live 3D placement preview for the Add Element tool.
7
+ *
8
+ * Renders SVG lines / rectangles / polygons over the canvas, anchored
9
+ * to renderer-frame world coords pulled from the addElement slice
10
+ * (`pendingPoints` + `hoverPoint`). Each point is projected to screen
11
+ * via the camera's `projectToScreen` callback so the preview tracks
12
+ * the camera in real time.
13
+ *
14
+ * What it draws (per element type):
15
+ * - column: nothing (single click — snap dot is enough)
16
+ * - wall: first click → marker; on hover → marker → cursor + length
17
+ * - beam: identical to wall
18
+ * - slab rectangle: first click → corner marker; on hover → axis-
19
+ * aligned rectangle with the diagonal, plus W/D readouts
20
+ * - slab polygon: pending edges + closing-edge ghost back to start
21
+ * when ≥3 points exist (so the user can preview the close)
22
+ */
23
+
24
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
25
+ import { useViewerStore } from '@/store';
26
+ import { useIfc } from '@/hooks/useIfc';
27
+ import type { AddElementVec3 } from '@/store/slices/addElementSlice';
28
+
29
+ type Pt = { x: number; y: number };
30
+ type Project = (worldPos: { x: number; y: number; z: number }) => { x: number; y: number } | null;
31
+
32
+ const PRIMARY = '#10b981'; // emerald-500
33
+ const PRIMARY_LIGHT = 'rgba(16, 185, 129, 0.18)';
34
+ const GHOST = 'rgba(16, 185, 129, 0.45)';
35
+
36
+ export function AddElementOverlay() {
37
+ const activeTool = useViewerStore((s) => s.activeTool);
38
+ const type = useViewerStore((s) => s.addElementType);
39
+ const slabMode = useViewerStore((s) => s.addElementSlabMode);
40
+ const pendingPoints = useViewerStore((s) => s.addElementPendingPoints);
41
+ const hoverPoint = useViewerStore((s) => s.addElementHoverPoint);
42
+ const autoSpacePreview = useViewerStore((s) => s.addElementAutoSpacePreview);
43
+ const projectToScreen = useViewerStore((s) => s.cameraCallbacks.projectToScreen);
44
+ const { models, ifcDataStore } = useIfc();
45
+ const addElementModelId = useViewerStore((s) => s.addElementModelId);
46
+ const activeModelId = useViewerStore((s) => s.activeModelId);
47
+
48
+ // Camera realtime updates intentionally bypass React renders for
49
+ // performance (see `updateCameraRotationRealtime`), so we drive our
50
+ // own RAF tick while the tool is active to re-project pending +
51
+ // hover points each frame. The tick state is just a number that
52
+ // forces a re-render; the projection itself is read fresh from the
53
+ // store callback.
54
+ const [frameTick, setFrameTick] = useState(0);
55
+ const rafRef = useRef<number | null>(null);
56
+ useEffect(() => {
57
+ if (activeTool !== 'addElement') return;
58
+ let mounted = true;
59
+ const loop = () => {
60
+ if (!mounted) return;
61
+ setFrameTick((t) => (t + 1) & 0xffff);
62
+ rafRef.current = requestAnimationFrame(loop);
63
+ };
64
+ rafRef.current = requestAnimationFrame(loop);
65
+ return () => {
66
+ mounted = false;
67
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
68
+ rafRef.current = null;
69
+ };
70
+ }, [activeTool]);
71
+
72
+ const projection = useMemo(
73
+ () => makeProjection(projectToScreen),
74
+ // Re-creating the memoized projection on every tick is wasted —
75
+ // the underlying function reference rarely changes. We only
76
+ // depend on `projectToScreen` itself; the RAF tick triggers the
77
+ // re-render that calls the projection again with current camera.
78
+ [projectToScreen],
79
+ );
80
+
81
+ // Reading frameTick keeps React from optimizing the render away.
82
+ void frameTick;
83
+
84
+ if (activeTool !== 'addElement') return null;
85
+ if (!projection) return null;
86
+
87
+ // Resolve storey elevation for the auto-space preview projection.
88
+ // IFC Z (storey elevation) maps directly to renderer Y (Y-up).
89
+ let storeyElevation = 0;
90
+ if (autoSpacePreview) {
91
+ const effectiveModelId = addElementModelId ?? activeModelId ?? null;
92
+ const ds = effectiveModelId
93
+ ? models.get(effectiveModelId)?.ifcDataStore ?? ifcDataStore
94
+ : ifcDataStore;
95
+ const elev = ds?.spatialHierarchy?.storeyElevations?.get(autoSpacePreview.storeyExpressId);
96
+ if (typeof elev === 'number' && Number.isFinite(elev)) storeyElevation = elev;
97
+ }
98
+ const ifcToRenderer = (xy: [number, number]) =>
99
+ projection({ x: xy[0], y: storeyElevation, z: -xy[1] });
100
+
101
+ const screenPending = pendingPoints
102
+ .map(projection)
103
+ .filter((p): p is Pt => p !== null);
104
+ const hover = hoverPoint ? projection(hoverPoint) : null;
105
+ const hasPreview = !!autoSpacePreview && autoSpacePreview.outlines.length > 0;
106
+
107
+ if (screenPending.length === 0 && !hover && !hasPreview) return null;
108
+
109
+ return (
110
+ <svg
111
+ className="absolute inset-0 pointer-events-none z-20"
112
+ style={{ overflow: 'visible' }}
113
+ >
114
+ <defs>
115
+ <filter id="add-elem-glow">
116
+ <feGaussianBlur stdDeviation="2" result="blur" />
117
+ <feMerge>
118
+ <feMergeNode in="blur" />
119
+ <feMergeNode in="SourceGraphic" />
120
+ </feMerge>
121
+ </filter>
122
+ </defs>
123
+
124
+ {/* Hover-ghost for single-click placements — column/door/window. */}
125
+ {(type === 'column' || type === 'door' || type === 'window') && hoverPoint && (
126
+ <SingleClickGhost
127
+ type={type}
128
+ hoverWorld={hoverPoint}
129
+ projection={projection}
130
+ />
131
+ )}
132
+
133
+ {/* Two-click axial placements share the same start→end preview. */}
134
+ {type === 'wall' || type === 'beam' || type === 'member' ? (
135
+ <WallBeamPreview
136
+ pending={screenPending}
137
+ hover={hover}
138
+ pendingWorld={pendingPoints}
139
+ hoverWorld={hoverPoint}
140
+ projection={projection}
141
+ />
142
+ ) : null}
143
+
144
+ {/* Rectangle profile (slab / roof / plate / space) — flat rect on storey floor. */}
145
+ {(type === 'slab' || type === 'roof' || type === 'plate' || type === 'space') && slabMode === 'rectangle' ? (
146
+ <SlabRectanglePreview
147
+ pending={screenPending}
148
+ hover={hover}
149
+ pendingWorld={pendingPoints}
150
+ hoverWorld={hoverPoint}
151
+ projection={projection}
152
+ />
153
+ ) : null}
154
+
155
+ {/* Polygon profile (same set of types) — pending polyline + ghost close. */}
156
+ {(type === 'slab' || type === 'roof' || type === 'plate' || type === 'space') && slabMode === 'polygon' ? (
157
+ <SlabPolygonPreview pending={screenPending} hover={hover} />
158
+ ) : null}
159
+
160
+ {/* Pending point markers — drawn on top so they're always visible. */}
161
+ {screenPending.map((p, i) => (
162
+ <circle key={i} cx={p.x} cy={p.y} r={4.5} fill="white" stroke={PRIMARY} strokeWidth={2} />
163
+ ))}
164
+
165
+ {/* Auto-space preview: candidate outlines from the wall-graph
166
+ face finder. Distinct from the click-to-place preview to
167
+ avoid confusion when both are active. */}
168
+ {hasPreview && autoSpacePreview!.outlines.map((outline, idx) => {
169
+ const pts: Pt[] = [];
170
+ for (const xy of outline) {
171
+ const sp = ifcToRenderer(xy);
172
+ if (sp) pts.push(sp);
173
+ }
174
+ if (pts.length < 3) return null;
175
+ const polygon = pts.map((p) => `${p.x},${p.y}`).join(' ');
176
+ const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
177
+ const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
178
+ const region = autoSpacePreview!.regions[idx];
179
+ return (
180
+ <g key={`auto-${idx}`}>
181
+ <polygon
182
+ points={polygon}
183
+ fill={PRIMARY_LIGHT}
184
+ stroke={PRIMARY}
185
+ strokeWidth={1.5}
186
+ strokeDasharray="4,3"
187
+ />
188
+ {region && (
189
+ <Label x={cx} y={cy} text={`${region.area.toFixed(1)} m²`} />
190
+ )}
191
+ </g>
192
+ );
193
+ })}
194
+ </svg>
195
+ );
196
+ }
197
+
198
+ /* ------------------------------------------------------------------ */
199
+ /* Per-type preview components */
200
+ /* ------------------------------------------------------------------ */
201
+
202
+ function WallBeamPreview({
203
+ pending,
204
+ hover,
205
+ pendingWorld,
206
+ hoverWorld,
207
+ projection,
208
+ }: {
209
+ pending: Pt[];
210
+ hover: Pt | null;
211
+ pendingWorld: AddElementVec3[];
212
+ hoverWorld: AddElementVec3 | null;
213
+ projection: Project;
214
+ }) {
215
+ if (pending.length === 0 || !hover) return null;
216
+ const start = pending[0];
217
+ const startWorld = pendingWorld[0];
218
+ const length = hoverWorld ? worldDistance2D(startWorld, hoverWorld) : 0;
219
+ const mid = { x: (start.x + hover.x) / 2, y: (start.y + hover.y) / 2 };
220
+
221
+ // 3D ghost box — read the per-type params from the store so the
222
+ // outline matches the about-to-commit element's actual size.
223
+ const ghost = useViewerStore.getState();
224
+ const type = ghost.addElementType;
225
+ const thick = type === 'wall'
226
+ ? ghost.addElementWallParams.Thickness
227
+ : type === 'beam'
228
+ ? ghost.addElementBeamParams.Width
229
+ : ghost.addElementMemberParams.Width;
230
+ const height = type === 'wall'
231
+ ? ghost.addElementWallParams.Height
232
+ : type === 'beam'
233
+ ? ghost.addElementBeamParams.Height
234
+ : ghost.addElementMemberParams.Height;
235
+
236
+ let ghostOutline: string | null = null;
237
+ if (hoverWorld) {
238
+ const corners = linearBoxCorners(startWorld, hoverWorld, thick, height);
239
+ const projected = corners.map((c) => projection({ x: c[0], y: c[1], z: c[2] }));
240
+ if (projected.every((p): p is Pt => p !== null)) {
241
+ ghostOutline = projectedHullOutline(projected as Pt[]);
242
+ }
243
+ }
244
+
245
+ return (
246
+ <>
247
+ {ghostOutline && (
248
+ <polygon
249
+ points={ghostOutline}
250
+ fill={PRIMARY_LIGHT}
251
+ stroke={GHOST}
252
+ strokeWidth={1}
253
+ strokeDasharray="3,3"
254
+ />
255
+ )}
256
+ <line
257
+ x1={start.x}
258
+ y1={start.y}
259
+ x2={hover.x}
260
+ y2={hover.y}
261
+ stroke={PRIMARY}
262
+ strokeWidth={2}
263
+ strokeDasharray="6,4"
264
+ filter="url(#add-elem-glow)"
265
+ />
266
+ {length > 0.001 && <Label x={mid.x} y={mid.y} text={`${length.toFixed(2)} m`} />}
267
+ </>
268
+ );
269
+ }
270
+
271
+ /**
272
+ * Single-click ghost for column / door / window — projects the
273
+ * about-to-commit axis box at the cursor so the user sees where
274
+ * the leaf / cross-section actually lands before clicking.
275
+ */
276
+ function SingleClickGhost({
277
+ type,
278
+ hoverWorld,
279
+ projection,
280
+ }: {
281
+ type: 'column' | 'door' | 'window';
282
+ hoverWorld: AddElementVec3;
283
+ projection: Project;
284
+ }) {
285
+ const state = useViewerStore.getState();
286
+ let sx: number, sy: number, sz: number;
287
+ if (type === 'column') {
288
+ const p = state.addElementColumnParams;
289
+ sx = p.Width; sy = p.Depth; sz = p.Height;
290
+ } else if (type === 'door') {
291
+ const p = state.addElementDoorParams;
292
+ sx = p.Width; sy = p.FrameThickness; sz = p.Height;
293
+ } else {
294
+ const p = state.addElementWindowParams;
295
+ sx = p.Width; sy = p.FrameThickness; sz = p.Height;
296
+ }
297
+ // Hover is in renderer-frame; project the axis-aligned box around it.
298
+ const hx = sx / 2;
299
+ const hz = sy / 2; // renderer Z
300
+ const cy = hoverWorld.y;
301
+ const cx = hoverWorld.x;
302
+ const cz = hoverWorld.z;
303
+ const corners: Array<[number, number, number]> = [
304
+ [cx - hx, cy, cz - hz],
305
+ [cx + hx, cy, cz - hz],
306
+ [cx + hx, cy, cz + hz],
307
+ [cx - hx, cy, cz + hz],
308
+ [cx - hx, cy + sz, cz - hz],
309
+ [cx + hx, cy + sz, cz - hz],
310
+ [cx + hx, cy + sz, cz + hz],
311
+ [cx - hx, cy + sz, cz + hz],
312
+ ];
313
+ const projected = corners.map((c) => projection({ x: c[0], y: c[1], z: c[2] }));
314
+ if (!projected.every((p): p is Pt => p !== null)) return null;
315
+ const outline = projectedHullOutline(projected as Pt[]);
316
+ return (
317
+ <polygon
318
+ points={outline}
319
+ fill={PRIMARY_LIGHT}
320
+ stroke={GHOST}
321
+ strokeWidth={1}
322
+ strokeDasharray="3,3"
323
+ />
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Eight renderer-frame corners of a thickness-extruded segment
329
+ * (wall / beam / member). Bottom and top rings each track their
330
+ * endpoint's Y so a sloped beam previews as a sloped prism instead of
331
+ * being flattened to the start elevation.
332
+ */
333
+ function linearBoxCorners(
334
+ startWorld: AddElementVec3,
335
+ endWorld: AddElementVec3,
336
+ thickness: number,
337
+ height: number,
338
+ ): Array<[number, number, number]> {
339
+ const dx = endWorld.x - startWorld.x;
340
+ const dz = endWorld.z - startWorld.z;
341
+ const len = Math.hypot(dx, dz);
342
+ if (len < 1e-6) return [];
343
+ const ax = dx / len, az = dz / len;
344
+ // Perpendicular in the ground plane (renderer X/Z, Y is up).
345
+ const nx = -az, nz = ax;
346
+ const half = thickness / 2;
347
+ const startBaseY = startWorld.y;
348
+ const endBaseY = endWorld.y;
349
+ const startTopY = startBaseY + height;
350
+ const endTopY = endBaseY + height;
351
+ return [
352
+ [startWorld.x + nx * half, startBaseY, startWorld.z + nz * half],
353
+ [endWorld.x + nx * half, endBaseY, endWorld.z + nz * half],
354
+ [endWorld.x - nx * half, endBaseY, endWorld.z - nz * half],
355
+ [startWorld.x - nx * half, startBaseY, startWorld.z - nz * half],
356
+ [startWorld.x + nx * half, startTopY, startWorld.z + nz * half],
357
+ [endWorld.x + nx * half, endTopY, endWorld.z + nz * half],
358
+ [endWorld.x - nx * half, endTopY, endWorld.z - nz * half],
359
+ [startWorld.x - nx * half, startTopY, startWorld.z - nz * half],
360
+ ];
361
+ }
362
+
363
+ /**
364
+ * 2D convex hull of projected screen points → SVG polygon string.
365
+ * The 8 box corners projected to screen don't always trace a clean
366
+ * outline edge-by-edge (back faces overlap), so we just render the
367
+ * silhouette envelope. Andrew's monotone-chain on (x, y).
368
+ */
369
+ function projectedHullOutline(pts: Pt[]): string {
370
+ if (pts.length < 3) return pts.map((p) => `${p.x},${p.y}`).join(' ');
371
+ const sorted = [...pts].sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x);
372
+ const cross = (o: Pt, a: Pt, b: Pt) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
373
+ const lower: Pt[] = [];
374
+ for (const p of sorted) {
375
+ while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
376
+ lower.push(p);
377
+ }
378
+ const upper: Pt[] = [];
379
+ for (let i = sorted.length - 1; i >= 0; i--) {
380
+ const p = sorted[i];
381
+ while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
382
+ upper.push(p);
383
+ }
384
+ upper.pop();
385
+ lower.pop();
386
+ return [...lower, ...upper].map((p) => `${p.x},${p.y}`).join(' ');
387
+ }
388
+
389
+ function SlabRectanglePreview({
390
+ pending,
391
+ hover,
392
+ pendingWorld,
393
+ hoverWorld,
394
+ projection,
395
+ }: {
396
+ pending: Pt[];
397
+ hover: Pt | null;
398
+ pendingWorld: AddElementVec3[];
399
+ hoverWorld: AddElementVec3 | null;
400
+ projection: Project;
401
+ }) {
402
+ if (pending.length === 0 || !hover || !pendingWorld[0] || !hoverWorld) return null;
403
+ // Build the four world-space corners on the storey floor (renderer
404
+ // Y is the world up axis, so rectangle corners share Y with the
405
+ // first click — gives a flat axis-aligned outline regardless of the
406
+ // hover point's height).
407
+ const a = pendingWorld[0];
408
+ const b = hoverWorld;
409
+ const y = a.y;
410
+ const cornersWorld: AddElementVec3[] = [
411
+ { x: a.x, y, z: a.z },
412
+ { x: b.x, y, z: a.z },
413
+ { x: b.x, y, z: b.z },
414
+ { x: a.x, y, z: b.z },
415
+ ];
416
+ const cornersScreen = cornersWorld.map(projection).filter((p): p is Pt => p !== null);
417
+ if (cornersScreen.length !== 4) return null;
418
+ const points = cornersScreen.map((p) => `${p.x},${p.y}`).join(' ');
419
+
420
+ // Width and Depth in IFC X/Y (renderer X / -Z).
421
+ const width = Math.abs(b.x - a.x);
422
+ const depth = Math.abs(b.z - a.z); // renderer Z magnitude maps to IFC Y magnitude
423
+ const widthMid = midpoint(cornersScreen[0], cornersScreen[1]);
424
+ const depthMid = midpoint(cornersScreen[1], cornersScreen[2]);
425
+
426
+ return (
427
+ <>
428
+ <polygon points={points} fill={PRIMARY_LIGHT} stroke={PRIMARY} strokeWidth={2} strokeDasharray="6,4" />
429
+ {width > 0.001 && <Label x={widthMid.x} y={widthMid.y} text={`${width.toFixed(2)} m`} />}
430
+ {depth > 0.001 && <Label x={depthMid.x} y={depthMid.y} text={`${depth.toFixed(2)} m`} />}
431
+ </>
432
+ );
433
+ }
434
+
435
+ function SlabPolygonPreview({ pending, hover }: { pending: Pt[]; hover: Pt | null }) {
436
+ if (pending.length === 0) return null;
437
+ const liveEnd = hover ?? pending[pending.length - 1];
438
+ const path = pending.map((p) => `${p.x},${p.y}`).join(' ');
439
+
440
+ return (
441
+ <>
442
+ {/* Solid path through committed points. */}
443
+ <polyline
444
+ points={path}
445
+ fill="none"
446
+ stroke={PRIMARY}
447
+ strokeWidth={2}
448
+ filter="url(#add-elem-glow)"
449
+ />
450
+ {/* Pending edge from last committed point to cursor. */}
451
+ {hover && (
452
+ <line
453
+ x1={pending[pending.length - 1].x}
454
+ y1={pending[pending.length - 1].y}
455
+ x2={liveEnd.x}
456
+ y2={liveEnd.y}
457
+ stroke={PRIMARY}
458
+ strokeWidth={2}
459
+ strokeDasharray="6,4"
460
+ />
461
+ )}
462
+ {/* Closing-edge ghost when ≥ 3 points exist so the user previews how the polygon closes. */}
463
+ {pending.length >= 3 && hover && (
464
+ <line
465
+ x1={liveEnd.x}
466
+ y1={liveEnd.y}
467
+ x2={pending[0].x}
468
+ y2={pending[0].y}
469
+ stroke={GHOST}
470
+ strokeWidth={1.5}
471
+ strokeDasharray="3,4"
472
+ />
473
+ )}
474
+ {pending.length >= 3 && !hover && (
475
+ <line
476
+ x1={pending[pending.length - 1].x}
477
+ y1={pending[pending.length - 1].y}
478
+ x2={pending[0].x}
479
+ y2={pending[0].y}
480
+ stroke={GHOST}
481
+ strokeWidth={1.5}
482
+ strokeDasharray="3,4"
483
+ />
484
+ )}
485
+ </>
486
+ );
487
+ }
488
+
489
+ /* ------------------------------------------------------------------ */
490
+ /* Helpers */
491
+ /* ------------------------------------------------------------------ */
492
+
493
+ function makeProjection(projectToScreen: Project | undefined): Project | null {
494
+ if (!projectToScreen) return null;
495
+ return projectToScreen;
496
+ }
497
+
498
+ function worldDistance2D(a: AddElementVec3, b: AddElementVec3): number {
499
+ // Renderer Y is the world up axis; the storey floor sits in the X/Z
500
+ // plane, so length is a 2D distance in renderer X/Z.
501
+ const dx = b.x - a.x;
502
+ const dz = b.z - a.z;
503
+ return Math.hypot(dx, dz);
504
+ }
505
+
506
+ function midpoint(a: Pt, b: Pt): Pt {
507
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
508
+ }
509
+
510
+ interface LabelProps {
511
+ x: number;
512
+ y: number;
513
+ text: string;
514
+ }
515
+
516
+ function Label({ x, y, text }: LabelProps) {
517
+ return (
518
+ <g pointerEvents="none">
519
+ <rect
520
+ x={x - text.length * 4 - 6}
521
+ y={y - 11}
522
+ width={text.length * 8 + 12}
523
+ height={16}
524
+ rx={3}
525
+ fill="rgba(15, 23, 42, 0.92)"
526
+ />
527
+ <text
528
+ x={x}
529
+ y={y}
530
+ fill="white"
531
+ fontSize="11"
532
+ fontFamily="ui-monospace,SFMono-Regular,Menlo,monospace"
533
+ textAnchor="middle"
534
+ dominantBaseline="middle"
535
+ >
536
+ {text}
537
+ </text>
538
+ </g>
539
+ );
540
+ }
@@ -0,0 +1,77 @@
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
+ * Global ⌘D / Ctrl+D shortcut for duplicating the selected entity.
7
+ *
8
+ * Lives outside `useKeyboardControls` so the camera-movement loop
9
+ * stays focused on its job; the duplicate flow doesn't need
10
+ * keyState tracking or per-frame work, just a one-shot trigger.
11
+ *
12
+ * Mirrors the right-click menu's gating: only fires when there's a
13
+ * selection and the active model has a live mutation view.
14
+ */
15
+
16
+ import { useEffect } from 'react';
17
+ import { useViewerStore, resolveEntityRef } from '@/store';
18
+ import { toast } from '@/components/ui/toast';
19
+
20
+ export function useDuplicateShortcut() {
21
+ const duplicateEntity = useViewerStore((s) => s.duplicateEntity);
22
+ const getMutationView = useViewerStore((s) => s.getMutationView);
23
+ const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
24
+
25
+ useEffect(() => {
26
+ const handler = (e: KeyboardEvent) => {
27
+ if (!(e.metaKey || e.ctrlKey)) return;
28
+ if (e.key !== 'd' && e.key !== 'D') return;
29
+
30
+ // Ignore when the user is typing somewhere — Ctrl+D in an
31
+ // input usually means "delete word forward" or browser-bookmark.
32
+ const target = e.target as HTMLElement | null;
33
+ if (
34
+ target?.tagName === 'INPUT' ||
35
+ target?.tagName === 'TEXTAREA' ||
36
+ target?.isContentEditable
37
+ ) {
38
+ return;
39
+ }
40
+
41
+ const state = useViewerStore.getState();
42
+ const selectedId = state.selectedEntityId;
43
+ if (selectedId === null) return;
44
+
45
+ const ref = resolveEntityRef(selectedId);
46
+ if (!ref) return;
47
+
48
+ // Suppress the browser's bookmark default for any duplicate
49
+ // shortcut we recognise — even when the model has no editable
50
+ // mutation view, otherwise Ctrl/⌘+D opens the bookmark dialog
51
+ // while we're "silently no-op'ing" below.
52
+ e.preventDefault();
53
+ e.stopPropagation();
54
+
55
+ // Match the menu's canEdit gating — silently no-op on
56
+ // native-metadata models.
57
+ const view = getMutationView(ref.modelId);
58
+ if (!view) return;
59
+
60
+ // ⌘D + Shift = +Z (up), ⌘D + Alt = +Y (north), default = +X (east).
61
+ // Power users can chain modifiers without leaving the keyboard;
62
+ // the menu's chip row covers everyone else.
63
+ const direction = e.shiftKey ? '+Z' : e.altKey ? '+Y' : '+X';
64
+
65
+ const result = duplicateEntity(ref.modelId, ref.expressId, direction);
66
+ if ('error' in result) {
67
+ toast.error(`Couldn't duplicate: ${result.error}`);
68
+ } else {
69
+ setSelectedEntityId(result.globalId);
70
+ toast.success(`Duplicated as #${result.expressId} (${direction}) — undo to remove`);
71
+ }
72
+ };
73
+
74
+ window.addEventListener('keydown', handler);
75
+ return () => window.removeEventListener('keydown', handler);
76
+ }, [duplicateEntity, getMutationView, setSelectedEntityId]);
77
+ }
@@ -22,6 +22,7 @@ import type {
22
22
  import type { MeasurementConstraintEdge, OrthogonalAxis, Vec3 } from '@/store/types.js';
23
23
  import { getEntityCenter } from '../../utils/viewportUtils.js';
24
24
  import type { MouseHandlerContext } from './mouseHandlerTypes.js';
25
+ import { useViewerStore } from '@/store';
25
26
  import {
26
27
  handleMeasureDown,
27
28
  handleMeasureDrag,
@@ -29,7 +30,7 @@ import {
29
30
  handleMeasureUp,
30
31
  updateMeasureScreenCoords,
31
32
  } from './measureHandlers.js';
32
- import { handleSelectionClick, handleContextMenu as handleContextMenuSelection } from './selectionHandlers.js';
33
+ import { handleSelectionClick, handleContextMenu as handleContextMenuSelection, handleAddElementHover } from './selectionHandlers.js';
33
34
 
34
35
  export interface MouseState {
35
36
  isDragging: boolean;
@@ -368,6 +369,13 @@ export function useMouseControls(params: UseMouseControlsParams): void {
368
369
  if (handleMeasureHover(ctx, x, y)) return;
369
370
  }
370
371
 
372
+ // Add-element tool hover preview. Always runs (regardless of
373
+ // snap toggle) so the live edge/rectangle/polygon overlay can
374
+ // track the cursor; magnetic snap is layered on when enabled.
375
+ if (tool === 'addElement' && !mouseState.isDragging) {
376
+ if (handleAddElementHover(ctx, x, y)) return;
377
+ }
378
+
371
379
  // Handle orbit/pan for other tools (or measure tool with shift+drag or no active measurement)
372
380
  if (mouseState.isDragging && (tool !== 'measure' || !activeMeasurementRef.current)) {
373
381
  const dx = e.clientX - mouseState.lastX;