@ifc-lite/viewer 1.17.6 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +20 -15
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +949 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +1 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
- package/dist/assets/sandbox-Baez7n-t.js +9682 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +11 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +73 -12
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +29 -2
- package/src/components/viewer/ViewportContainer.tsx +45 -5
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +89 -13
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +8 -3
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +79 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/pointCloudSlice.ts +102 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/types.ts +7 -0
- package/src/store.ts +14 -0
- package/vite.config.ts +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -0,0 +1,581 @@
|
|
|
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
|
+
//
|
|
55
|
+
// Two perf gates:
|
|
56
|
+
// 1. Skip the loop entirely when there's nothing to project.
|
|
57
|
+
// pendingPoints / hoverPoint / autoSpacePreview already trigger
|
|
58
|
+
// React re-renders via the store, so the only reason we'd need
|
|
59
|
+
// a per-frame tick is to track the camera while content exists.
|
|
60
|
+
// 2. Only re-render when the camera actually moved since last tick.
|
|
61
|
+
// A held tool with a static camera does ~0 work.
|
|
62
|
+
const getViewpoint = useViewerStore((s) => s.cameraCallbacks.getViewpoint);
|
|
63
|
+
const hasOverlayContent =
|
|
64
|
+
pendingPoints.length > 0 ||
|
|
65
|
+
hoverPoint !== null ||
|
|
66
|
+
(autoSpacePreview != null && autoSpacePreview.outlines.length > 0);
|
|
67
|
+
const [frameTick, setFrameTick] = useState(0);
|
|
68
|
+
const rafRef = useRef<number | null>(null);
|
|
69
|
+
const lastViewpointRef = useRef<{
|
|
70
|
+
px: number; py: number; pz: number;
|
|
71
|
+
tx: number; ty: number; tz: number;
|
|
72
|
+
fov: number;
|
|
73
|
+
} | null>(null);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (activeTool !== 'addElement') return;
|
|
76
|
+
if (!hasOverlayContent) return;
|
|
77
|
+
let mounted = true;
|
|
78
|
+
const loop = () => {
|
|
79
|
+
if (!mounted) return;
|
|
80
|
+
const vp = getViewpoint?.();
|
|
81
|
+
if (vp) {
|
|
82
|
+
const last = lastViewpointRef.current;
|
|
83
|
+
const moved =
|
|
84
|
+
!last ||
|
|
85
|
+
last.px !== vp.position.x || last.py !== vp.position.y || last.pz !== vp.position.z ||
|
|
86
|
+
last.tx !== vp.target.x || last.ty !== vp.target.y || last.tz !== vp.target.z ||
|
|
87
|
+
last.fov !== vp.fov;
|
|
88
|
+
if (moved) {
|
|
89
|
+
lastViewpointRef.current = {
|
|
90
|
+
px: vp.position.x, py: vp.position.y, pz: vp.position.z,
|
|
91
|
+
tx: vp.target.x, ty: vp.target.y, tz: vp.target.z,
|
|
92
|
+
fov: vp.fov,
|
|
93
|
+
};
|
|
94
|
+
setFrameTick((t) => (t + 1) & 0xffff);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
// Fallback for environments without getViewpoint — preserves the
|
|
98
|
+
// pre-fix behaviour of an unconditional tick so the projection
|
|
99
|
+
// can't get stuck stale.
|
|
100
|
+
setFrameTick((t) => (t + 1) & 0xffff);
|
|
101
|
+
}
|
|
102
|
+
rafRef.current = requestAnimationFrame(loop);
|
|
103
|
+
};
|
|
104
|
+
rafRef.current = requestAnimationFrame(loop);
|
|
105
|
+
return () => {
|
|
106
|
+
mounted = false;
|
|
107
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
108
|
+
rafRef.current = null;
|
|
109
|
+
lastViewpointRef.current = null;
|
|
110
|
+
};
|
|
111
|
+
}, [activeTool, hasOverlayContent, getViewpoint]);
|
|
112
|
+
|
|
113
|
+
const projection = useMemo(
|
|
114
|
+
() => makeProjection(projectToScreen),
|
|
115
|
+
// Re-creating the memoized projection on every tick is wasted —
|
|
116
|
+
// the underlying function reference rarely changes. We only
|
|
117
|
+
// depend on `projectToScreen` itself; the RAF tick triggers the
|
|
118
|
+
// re-render that calls the projection again with current camera.
|
|
119
|
+
[projectToScreen],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Reading frameTick keeps React from optimizing the render away.
|
|
123
|
+
void frameTick;
|
|
124
|
+
|
|
125
|
+
if (activeTool !== 'addElement') return null;
|
|
126
|
+
if (!projection) return null;
|
|
127
|
+
|
|
128
|
+
// Resolve storey elevation for the auto-space preview projection.
|
|
129
|
+
// IFC Z (storey elevation) maps directly to renderer Y (Y-up).
|
|
130
|
+
let storeyElevation = 0;
|
|
131
|
+
if (autoSpacePreview) {
|
|
132
|
+
const effectiveModelId = addElementModelId ?? activeModelId ?? null;
|
|
133
|
+
const ds = effectiveModelId
|
|
134
|
+
? models.get(effectiveModelId)?.ifcDataStore ?? ifcDataStore
|
|
135
|
+
: ifcDataStore;
|
|
136
|
+
const elev = ds?.spatialHierarchy?.storeyElevations?.get(autoSpacePreview.storeyExpressId);
|
|
137
|
+
if (typeof elev === 'number' && Number.isFinite(elev)) storeyElevation = elev;
|
|
138
|
+
}
|
|
139
|
+
const ifcToRenderer = (xy: [number, number]) =>
|
|
140
|
+
projection({ x: xy[0], y: storeyElevation, z: -xy[1] });
|
|
141
|
+
|
|
142
|
+
const screenPending = pendingPoints
|
|
143
|
+
.map(projection)
|
|
144
|
+
.filter((p): p is Pt => p !== null);
|
|
145
|
+
const hover = hoverPoint ? projection(hoverPoint) : null;
|
|
146
|
+
const hasPreview = !!autoSpacePreview && autoSpacePreview.outlines.length > 0;
|
|
147
|
+
|
|
148
|
+
if (screenPending.length === 0 && !hover && !hasPreview) return null;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<svg
|
|
152
|
+
className="absolute inset-0 pointer-events-none z-20"
|
|
153
|
+
style={{ overflow: 'visible' }}
|
|
154
|
+
>
|
|
155
|
+
<defs>
|
|
156
|
+
<filter id="add-elem-glow">
|
|
157
|
+
<feGaussianBlur stdDeviation="2" result="blur" />
|
|
158
|
+
<feMerge>
|
|
159
|
+
<feMergeNode in="blur" />
|
|
160
|
+
<feMergeNode in="SourceGraphic" />
|
|
161
|
+
</feMerge>
|
|
162
|
+
</filter>
|
|
163
|
+
</defs>
|
|
164
|
+
|
|
165
|
+
{/* Hover-ghost for single-click placements — column/door/window. */}
|
|
166
|
+
{(type === 'column' || type === 'door' || type === 'window') && hoverPoint && (
|
|
167
|
+
<SingleClickGhost
|
|
168
|
+
type={type}
|
|
169
|
+
hoverWorld={hoverPoint}
|
|
170
|
+
projection={projection}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Two-click axial placements share the same start→end preview. */}
|
|
175
|
+
{type === 'wall' || type === 'beam' || type === 'member' ? (
|
|
176
|
+
<WallBeamPreview
|
|
177
|
+
pending={screenPending}
|
|
178
|
+
hover={hover}
|
|
179
|
+
pendingWorld={pendingPoints}
|
|
180
|
+
hoverWorld={hoverPoint}
|
|
181
|
+
projection={projection}
|
|
182
|
+
/>
|
|
183
|
+
) : null}
|
|
184
|
+
|
|
185
|
+
{/* Rectangle profile (slab / roof / plate / space) — flat rect on storey floor. */}
|
|
186
|
+
{(type === 'slab' || type === 'roof' || type === 'plate' || type === 'space') && slabMode === 'rectangle' ? (
|
|
187
|
+
<SlabRectanglePreview
|
|
188
|
+
pending={screenPending}
|
|
189
|
+
hover={hover}
|
|
190
|
+
pendingWorld={pendingPoints}
|
|
191
|
+
hoverWorld={hoverPoint}
|
|
192
|
+
projection={projection}
|
|
193
|
+
/>
|
|
194
|
+
) : null}
|
|
195
|
+
|
|
196
|
+
{/* Polygon profile (same set of types) — pending polyline + ghost close. */}
|
|
197
|
+
{(type === 'slab' || type === 'roof' || type === 'plate' || type === 'space') && slabMode === 'polygon' ? (
|
|
198
|
+
<SlabPolygonPreview pending={screenPending} hover={hover} />
|
|
199
|
+
) : null}
|
|
200
|
+
|
|
201
|
+
{/* Pending point markers — drawn on top so they're always visible. */}
|
|
202
|
+
{screenPending.map((p, i) => (
|
|
203
|
+
<circle key={i} cx={p.x} cy={p.y} r={4.5} fill="white" stroke={PRIMARY} strokeWidth={2} />
|
|
204
|
+
))}
|
|
205
|
+
|
|
206
|
+
{/* Auto-space preview: candidate outlines from the wall-graph
|
|
207
|
+
face finder. Distinct from the click-to-place preview to
|
|
208
|
+
avoid confusion when both are active. */}
|
|
209
|
+
{hasPreview && autoSpacePreview!.outlines.map((outline, idx) => {
|
|
210
|
+
const pts: Pt[] = [];
|
|
211
|
+
for (const xy of outline) {
|
|
212
|
+
const sp = ifcToRenderer(xy);
|
|
213
|
+
if (sp) pts.push(sp);
|
|
214
|
+
}
|
|
215
|
+
if (pts.length < 3) return null;
|
|
216
|
+
const polygon = pts.map((p) => `${p.x},${p.y}`).join(' ');
|
|
217
|
+
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
|
|
218
|
+
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
|
|
219
|
+
const region = autoSpacePreview!.regions[idx];
|
|
220
|
+
return (
|
|
221
|
+
<g key={`auto-${idx}`}>
|
|
222
|
+
<polygon
|
|
223
|
+
points={polygon}
|
|
224
|
+
fill={PRIMARY_LIGHT}
|
|
225
|
+
stroke={PRIMARY}
|
|
226
|
+
strokeWidth={1.5}
|
|
227
|
+
strokeDasharray="4,3"
|
|
228
|
+
/>
|
|
229
|
+
{region && (
|
|
230
|
+
<Label x={cx} y={cy} text={`${region.area.toFixed(1)} m²`} />
|
|
231
|
+
)}
|
|
232
|
+
</g>
|
|
233
|
+
);
|
|
234
|
+
})}
|
|
235
|
+
</svg>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ------------------------------------------------------------------ */
|
|
240
|
+
/* Per-type preview components */
|
|
241
|
+
/* ------------------------------------------------------------------ */
|
|
242
|
+
|
|
243
|
+
function WallBeamPreview({
|
|
244
|
+
pending,
|
|
245
|
+
hover,
|
|
246
|
+
pendingWorld,
|
|
247
|
+
hoverWorld,
|
|
248
|
+
projection,
|
|
249
|
+
}: {
|
|
250
|
+
pending: Pt[];
|
|
251
|
+
hover: Pt | null;
|
|
252
|
+
pendingWorld: AddElementVec3[];
|
|
253
|
+
hoverWorld: AddElementVec3 | null;
|
|
254
|
+
projection: Project;
|
|
255
|
+
}) {
|
|
256
|
+
if (pending.length === 0 || !hover) return null;
|
|
257
|
+
const start = pending[0];
|
|
258
|
+
const startWorld = pendingWorld[0];
|
|
259
|
+
const length = hoverWorld ? worldDistance2D(startWorld, hoverWorld) : 0;
|
|
260
|
+
const mid = { x: (start.x + hover.x) / 2, y: (start.y + hover.y) / 2 };
|
|
261
|
+
|
|
262
|
+
// 3D ghost box — read the per-type params from the store so the
|
|
263
|
+
// outline matches the about-to-commit element's actual size.
|
|
264
|
+
const ghost = useViewerStore.getState();
|
|
265
|
+
const type = ghost.addElementType;
|
|
266
|
+
const thick = type === 'wall'
|
|
267
|
+
? ghost.addElementWallParams.Thickness
|
|
268
|
+
: type === 'beam'
|
|
269
|
+
? ghost.addElementBeamParams.Width
|
|
270
|
+
: ghost.addElementMemberParams.Width;
|
|
271
|
+
const height = type === 'wall'
|
|
272
|
+
? ghost.addElementWallParams.Height
|
|
273
|
+
: type === 'beam'
|
|
274
|
+
? ghost.addElementBeamParams.Height
|
|
275
|
+
: ghost.addElementMemberParams.Height;
|
|
276
|
+
|
|
277
|
+
let ghostOutline: string | null = null;
|
|
278
|
+
if (hoverWorld) {
|
|
279
|
+
const corners = linearBoxCorners(startWorld, hoverWorld, thick, height);
|
|
280
|
+
const projected = corners.map((c) => projection({ x: c[0], y: c[1], z: c[2] }));
|
|
281
|
+
if (projected.every((p): p is Pt => p !== null)) {
|
|
282
|
+
ghostOutline = projectedHullOutline(projected as Pt[]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<>
|
|
288
|
+
{ghostOutline && (
|
|
289
|
+
<polygon
|
|
290
|
+
points={ghostOutline}
|
|
291
|
+
fill={PRIMARY_LIGHT}
|
|
292
|
+
stroke={GHOST}
|
|
293
|
+
strokeWidth={1}
|
|
294
|
+
strokeDasharray="3,3"
|
|
295
|
+
/>
|
|
296
|
+
)}
|
|
297
|
+
<line
|
|
298
|
+
x1={start.x}
|
|
299
|
+
y1={start.y}
|
|
300
|
+
x2={hover.x}
|
|
301
|
+
y2={hover.y}
|
|
302
|
+
stroke={PRIMARY}
|
|
303
|
+
strokeWidth={2}
|
|
304
|
+
strokeDasharray="6,4"
|
|
305
|
+
filter="url(#add-elem-glow)"
|
|
306
|
+
/>
|
|
307
|
+
{length > 0.001 && <Label x={mid.x} y={mid.y} text={`${length.toFixed(2)} m`} />}
|
|
308
|
+
</>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Single-click ghost for column / door / window — projects the
|
|
314
|
+
* about-to-commit axis box at the cursor so the user sees where
|
|
315
|
+
* the leaf / cross-section actually lands before clicking.
|
|
316
|
+
*/
|
|
317
|
+
function SingleClickGhost({
|
|
318
|
+
type,
|
|
319
|
+
hoverWorld,
|
|
320
|
+
projection,
|
|
321
|
+
}: {
|
|
322
|
+
type: 'column' | 'door' | 'window';
|
|
323
|
+
hoverWorld: AddElementVec3;
|
|
324
|
+
projection: Project;
|
|
325
|
+
}) {
|
|
326
|
+
const state = useViewerStore.getState();
|
|
327
|
+
let sx: number, sy: number, sz: number;
|
|
328
|
+
if (type === 'column') {
|
|
329
|
+
const p = state.addElementColumnParams;
|
|
330
|
+
sx = p.Width; sy = p.Depth; sz = p.Height;
|
|
331
|
+
} else if (type === 'door') {
|
|
332
|
+
const p = state.addElementDoorParams;
|
|
333
|
+
sx = p.Width; sy = p.FrameThickness; sz = p.Height;
|
|
334
|
+
} else {
|
|
335
|
+
const p = state.addElementWindowParams;
|
|
336
|
+
sx = p.Width; sy = p.FrameThickness; sz = p.Height;
|
|
337
|
+
}
|
|
338
|
+
// Hover is in renderer-frame; project the axis-aligned box around it.
|
|
339
|
+
const hx = sx / 2;
|
|
340
|
+
const hz = sy / 2; // renderer Z
|
|
341
|
+
const cy = hoverWorld.y;
|
|
342
|
+
const cx = hoverWorld.x;
|
|
343
|
+
const cz = hoverWorld.z;
|
|
344
|
+
const corners: Array<[number, number, number]> = [
|
|
345
|
+
[cx - hx, cy, cz - hz],
|
|
346
|
+
[cx + hx, cy, cz - hz],
|
|
347
|
+
[cx + hx, cy, cz + hz],
|
|
348
|
+
[cx - hx, cy, cz + hz],
|
|
349
|
+
[cx - hx, cy + sz, cz - hz],
|
|
350
|
+
[cx + hx, cy + sz, cz - hz],
|
|
351
|
+
[cx + hx, cy + sz, cz + hz],
|
|
352
|
+
[cx - hx, cy + sz, cz + hz],
|
|
353
|
+
];
|
|
354
|
+
const projected = corners.map((c) => projection({ x: c[0], y: c[1], z: c[2] }));
|
|
355
|
+
if (!projected.every((p): p is Pt => p !== null)) return null;
|
|
356
|
+
const outline = projectedHullOutline(projected as Pt[]);
|
|
357
|
+
return (
|
|
358
|
+
<polygon
|
|
359
|
+
points={outline}
|
|
360
|
+
fill={PRIMARY_LIGHT}
|
|
361
|
+
stroke={GHOST}
|
|
362
|
+
strokeWidth={1}
|
|
363
|
+
strokeDasharray="3,3"
|
|
364
|
+
/>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Eight renderer-frame corners of a thickness-extruded segment
|
|
370
|
+
* (wall / beam / member). Bottom and top rings each track their
|
|
371
|
+
* endpoint's Y so a sloped beam previews as a sloped prism instead of
|
|
372
|
+
* being flattened to the start elevation.
|
|
373
|
+
*/
|
|
374
|
+
function linearBoxCorners(
|
|
375
|
+
startWorld: AddElementVec3,
|
|
376
|
+
endWorld: AddElementVec3,
|
|
377
|
+
thickness: number,
|
|
378
|
+
height: number,
|
|
379
|
+
): Array<[number, number, number]> {
|
|
380
|
+
const dx = endWorld.x - startWorld.x;
|
|
381
|
+
const dz = endWorld.z - startWorld.z;
|
|
382
|
+
const len = Math.hypot(dx, dz);
|
|
383
|
+
if (len < 1e-6) return [];
|
|
384
|
+
const ax = dx / len, az = dz / len;
|
|
385
|
+
// Perpendicular in the ground plane (renderer X/Z, Y is up).
|
|
386
|
+
const nx = -az, nz = ax;
|
|
387
|
+
const half = thickness / 2;
|
|
388
|
+
const startBaseY = startWorld.y;
|
|
389
|
+
const endBaseY = endWorld.y;
|
|
390
|
+
const startTopY = startBaseY + height;
|
|
391
|
+
const endTopY = endBaseY + height;
|
|
392
|
+
return [
|
|
393
|
+
[startWorld.x + nx * half, startBaseY, startWorld.z + nz * half],
|
|
394
|
+
[endWorld.x + nx * half, endBaseY, endWorld.z + nz * half],
|
|
395
|
+
[endWorld.x - nx * half, endBaseY, endWorld.z - nz * half],
|
|
396
|
+
[startWorld.x - nx * half, startBaseY, startWorld.z - nz * half],
|
|
397
|
+
[startWorld.x + nx * half, startTopY, startWorld.z + nz * half],
|
|
398
|
+
[endWorld.x + nx * half, endTopY, endWorld.z + nz * half],
|
|
399
|
+
[endWorld.x - nx * half, endTopY, endWorld.z - nz * half],
|
|
400
|
+
[startWorld.x - nx * half, startTopY, startWorld.z - nz * half],
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 2D convex hull of projected screen points → SVG polygon string.
|
|
406
|
+
* The 8 box corners projected to screen don't always trace a clean
|
|
407
|
+
* outline edge-by-edge (back faces overlap), so we just render the
|
|
408
|
+
* silhouette envelope. Andrew's monotone-chain on (x, y).
|
|
409
|
+
*/
|
|
410
|
+
function projectedHullOutline(pts: Pt[]): string {
|
|
411
|
+
if (pts.length < 3) return pts.map((p) => `${p.x},${p.y}`).join(' ');
|
|
412
|
+
const sorted = [...pts].sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x);
|
|
413
|
+
const cross = (o: Pt, a: Pt, b: Pt) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
414
|
+
const lower: Pt[] = [];
|
|
415
|
+
for (const p of sorted) {
|
|
416
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
|
417
|
+
lower.push(p);
|
|
418
|
+
}
|
|
419
|
+
const upper: Pt[] = [];
|
|
420
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
421
|
+
const p = sorted[i];
|
|
422
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
|
|
423
|
+
upper.push(p);
|
|
424
|
+
}
|
|
425
|
+
upper.pop();
|
|
426
|
+
lower.pop();
|
|
427
|
+
return [...lower, ...upper].map((p) => `${p.x},${p.y}`).join(' ');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function SlabRectanglePreview({
|
|
431
|
+
pending,
|
|
432
|
+
hover,
|
|
433
|
+
pendingWorld,
|
|
434
|
+
hoverWorld,
|
|
435
|
+
projection,
|
|
436
|
+
}: {
|
|
437
|
+
pending: Pt[];
|
|
438
|
+
hover: Pt | null;
|
|
439
|
+
pendingWorld: AddElementVec3[];
|
|
440
|
+
hoverWorld: AddElementVec3 | null;
|
|
441
|
+
projection: Project;
|
|
442
|
+
}) {
|
|
443
|
+
if (pending.length === 0 || !hover || !pendingWorld[0] || !hoverWorld) return null;
|
|
444
|
+
// Build the four world-space corners on the storey floor (renderer
|
|
445
|
+
// Y is the world up axis, so rectangle corners share Y with the
|
|
446
|
+
// first click — gives a flat axis-aligned outline regardless of the
|
|
447
|
+
// hover point's height).
|
|
448
|
+
const a = pendingWorld[0];
|
|
449
|
+
const b = hoverWorld;
|
|
450
|
+
const y = a.y;
|
|
451
|
+
const cornersWorld: AddElementVec3[] = [
|
|
452
|
+
{ x: a.x, y, z: a.z },
|
|
453
|
+
{ x: b.x, y, z: a.z },
|
|
454
|
+
{ x: b.x, y, z: b.z },
|
|
455
|
+
{ x: a.x, y, z: b.z },
|
|
456
|
+
];
|
|
457
|
+
const cornersScreen = cornersWorld.map(projection).filter((p): p is Pt => p !== null);
|
|
458
|
+
if (cornersScreen.length !== 4) return null;
|
|
459
|
+
const points = cornersScreen.map((p) => `${p.x},${p.y}`).join(' ');
|
|
460
|
+
|
|
461
|
+
// Width and Depth in IFC X/Y (renderer X / -Z).
|
|
462
|
+
const width = Math.abs(b.x - a.x);
|
|
463
|
+
const depth = Math.abs(b.z - a.z); // renderer Z magnitude maps to IFC Y magnitude
|
|
464
|
+
const widthMid = midpoint(cornersScreen[0], cornersScreen[1]);
|
|
465
|
+
const depthMid = midpoint(cornersScreen[1], cornersScreen[2]);
|
|
466
|
+
|
|
467
|
+
return (
|
|
468
|
+
<>
|
|
469
|
+
<polygon points={points} fill={PRIMARY_LIGHT} stroke={PRIMARY} strokeWidth={2} strokeDasharray="6,4" />
|
|
470
|
+
{width > 0.001 && <Label x={widthMid.x} y={widthMid.y} text={`${width.toFixed(2)} m`} />}
|
|
471
|
+
{depth > 0.001 && <Label x={depthMid.x} y={depthMid.y} text={`${depth.toFixed(2)} m`} />}
|
|
472
|
+
</>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function SlabPolygonPreview({ pending, hover }: { pending: Pt[]; hover: Pt | null }) {
|
|
477
|
+
if (pending.length === 0) return null;
|
|
478
|
+
const liveEnd = hover ?? pending[pending.length - 1];
|
|
479
|
+
const path = pending.map((p) => `${p.x},${p.y}`).join(' ');
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<>
|
|
483
|
+
{/* Solid path through committed points. */}
|
|
484
|
+
<polyline
|
|
485
|
+
points={path}
|
|
486
|
+
fill="none"
|
|
487
|
+
stroke={PRIMARY}
|
|
488
|
+
strokeWidth={2}
|
|
489
|
+
filter="url(#add-elem-glow)"
|
|
490
|
+
/>
|
|
491
|
+
{/* Pending edge from last committed point to cursor. */}
|
|
492
|
+
{hover && (
|
|
493
|
+
<line
|
|
494
|
+
x1={pending[pending.length - 1].x}
|
|
495
|
+
y1={pending[pending.length - 1].y}
|
|
496
|
+
x2={liveEnd.x}
|
|
497
|
+
y2={liveEnd.y}
|
|
498
|
+
stroke={PRIMARY}
|
|
499
|
+
strokeWidth={2}
|
|
500
|
+
strokeDasharray="6,4"
|
|
501
|
+
/>
|
|
502
|
+
)}
|
|
503
|
+
{/* Closing-edge ghost when ≥ 3 points exist so the user previews how the polygon closes. */}
|
|
504
|
+
{pending.length >= 3 && hover && (
|
|
505
|
+
<line
|
|
506
|
+
x1={liveEnd.x}
|
|
507
|
+
y1={liveEnd.y}
|
|
508
|
+
x2={pending[0].x}
|
|
509
|
+
y2={pending[0].y}
|
|
510
|
+
stroke={GHOST}
|
|
511
|
+
strokeWidth={1.5}
|
|
512
|
+
strokeDasharray="3,4"
|
|
513
|
+
/>
|
|
514
|
+
)}
|
|
515
|
+
{pending.length >= 3 && !hover && (
|
|
516
|
+
<line
|
|
517
|
+
x1={pending[pending.length - 1].x}
|
|
518
|
+
y1={pending[pending.length - 1].y}
|
|
519
|
+
x2={pending[0].x}
|
|
520
|
+
y2={pending[0].y}
|
|
521
|
+
stroke={GHOST}
|
|
522
|
+
strokeWidth={1.5}
|
|
523
|
+
strokeDasharray="3,4"
|
|
524
|
+
/>
|
|
525
|
+
)}
|
|
526
|
+
</>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/* ------------------------------------------------------------------ */
|
|
531
|
+
/* Helpers */
|
|
532
|
+
/* ------------------------------------------------------------------ */
|
|
533
|
+
|
|
534
|
+
function makeProjection(projectToScreen: Project | undefined): Project | null {
|
|
535
|
+
if (!projectToScreen) return null;
|
|
536
|
+
return projectToScreen;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function worldDistance2D(a: AddElementVec3, b: AddElementVec3): number {
|
|
540
|
+
// Renderer Y is the world up axis; the storey floor sits in the X/Z
|
|
541
|
+
// plane, so length is a 2D distance in renderer X/Z.
|
|
542
|
+
const dx = b.x - a.x;
|
|
543
|
+
const dz = b.z - a.z;
|
|
544
|
+
return Math.hypot(dx, dz);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function midpoint(a: Pt, b: Pt): Pt {
|
|
548
|
+
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
interface LabelProps {
|
|
552
|
+
x: number;
|
|
553
|
+
y: number;
|
|
554
|
+
text: string;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function Label({ x, y, text }: LabelProps) {
|
|
558
|
+
return (
|
|
559
|
+
<g pointerEvents="none">
|
|
560
|
+
<rect
|
|
561
|
+
x={x - text.length * 4 - 6}
|
|
562
|
+
y={y - 11}
|
|
563
|
+
width={text.length * 8 + 12}
|
|
564
|
+
height={16}
|
|
565
|
+
rx={3}
|
|
566
|
+
fill="rgba(15, 23, 42, 0.92)"
|
|
567
|
+
/>
|
|
568
|
+
<text
|
|
569
|
+
x={x}
|
|
570
|
+
y={y}
|
|
571
|
+
fill="white"
|
|
572
|
+
fontSize="11"
|
|
573
|
+
fontFamily="ui-monospace,SFMono-Regular,Menlo,monospace"
|
|
574
|
+
textAnchor="middle"
|
|
575
|
+
dominantBaseline="middle"
|
|
576
|
+
>
|
|
577
|
+
{text}
|
|
578
|
+
</text>
|
|
579
|
+
</g>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
@@ -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;
|