@ifc-lite/viewer 1.17.4 → 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.
- package/.turbo/turbo-build.log +20 -17
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +630 -0
- package/DESKTOP_CONTRACT_VERSION +1 -1
- package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
- package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
- package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/ids-DQ5jY0E8.js +1 -0
- package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
- package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +8 -7
- package/index.html +1 -0
- package/package.json +13 -13
- package/src/App.tsx +16 -2
- 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/CesiumOverlay.tsx +62 -19
- package/src/components/viewer/ChatPanel.tsx +259 -93
- 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 -13
- package/src/components/viewer/PropertiesPanel.tsx +237 -23
- 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/SettingsPage.tsx +252 -101
- package/src/components/viewer/ThemeSwitch.tsx +63 -7
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +25 -4
- package/src/components/viewer/Viewport.tsx +25 -3
- package/src/components/viewer/ViewportContainer.tsx +51 -64
- package/src/components/viewer/ViewportOverlays.tsx +5 -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 +4 -4
- package/src/components/viewer/chat/ModelSelector.tsx +90 -54
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
- package/src/components/viewer/properties/LocationMap.tsx +9 -7
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
- 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 +540 -0
- package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
- package/src/components/viewer/tools/SectionPanel.tsx +39 -18
- package/src/components/viewer/useAnimationLoop.ts +9 -1
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/useRenderUpdates.ts +1 -1
- package/src/hooks/ids/idsDataAccessor.ts +60 -24
- package/src/hooks/ingest/viewerModelIngest.ts +7 -2
- package/src/hooks/useIfcFederation.ts +326 -71
- package/src/hooks/useIfcLoader.ts +23 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/hooks/useViewControls.ts +13 -5
- package/src/index.css +550 -10
- package/src/lib/desktop-entitlement.ts +2 -4
- package/src/lib/geo/cesium-bridge.ts +15 -7
- package/src/lib/geo/effective-georef.test.ts +73 -0
- package/src/lib/geo/effective-georef.ts +111 -0
- package/src/lib/geo/reproject.ts +105 -19
- package/src/lib/llm/byok-guard.test.ts +77 -0
- package/src/lib/llm/byok-guard.ts +39 -0
- package/src/lib/llm/free-models.test.ts +0 -6
- package/src/lib/llm/models.ts +104 -42
- package/src/lib/llm/stream-client.ts +74 -110
- package/src/lib/llm/stream-direct.test.ts +130 -0
- package/src/lib/llm/stream-direct.ts +316 -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 +20 -2
- 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/main.tsx +1 -10
- 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/api-keys.ts +73 -0
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/constants.ts +20 -2
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +82 -6
- 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/cesiumSlice.ts +5 -0
- package/src/store/slices/chatSlice.test.ts +6 -76
- package/src/store/slices/chatSlice.ts +17 -58
- 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/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/sectionSlice.test.ts +87 -7
- package/src/store/slices/sectionSlice.ts +151 -5
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/slices/uiSlice.ts +28 -5
- package/src/store/types.ts +26 -0
- package/src/store.ts +14 -0
- package/src/utils/nativeSpatialDataStore.ts +4 -1
- package/src/utils/viewportUtils.ts +7 -2
- package/src/vite-env.d.ts +0 -4
- package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
- package/dist/assets/ids-B4jTqB1O.js +0 -1
- package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
- package/dist/assets/index-DckuDqlv.css +0 -1
- package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
- package/src/components/viewer/UpgradePage.tsx +0 -71
- package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
- package/src/lib/llm/ClerkChatSync.tsx +0 -74
- package/src/lib/llm/clerk-auth.ts +0 -62
|
@@ -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
|
+
}
|