@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,758 @@
|
|
|
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
|
+
* Add Element panel — right-side authoring surface for dropping
|
|
7
|
+
* walls / slabs / beams / columns onto a parsed model. Tool-driven
|
|
8
|
+
* (rendered when `activeTool === 'addElement'`); the actual drop
|
|
9
|
+
* happens on a 3D click handled in `selectionHandlers.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Activation only via the command palette — no menubar button. The
|
|
12
|
+
* tool stays active across drops so the user can place several
|
|
13
|
+
* elements in a row; Esc returns to the select tool.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
17
|
+
import { Box, Cog, DoorOpen, Home, Layers, Minus, Square, SquareDashedBottom, Wand2, X } from 'lucide-react';
|
|
18
|
+
import { toast } from '@/components/ui/toast';
|
|
19
|
+
import { Button } from '@/components/ui/button';
|
|
20
|
+
import { Input } from '@/components/ui/input';
|
|
21
|
+
import { Label } from '@/components/ui/label';
|
|
22
|
+
import {
|
|
23
|
+
Select,
|
|
24
|
+
SelectContent,
|
|
25
|
+
SelectItem,
|
|
26
|
+
SelectTrigger,
|
|
27
|
+
SelectValue,
|
|
28
|
+
} from '@/components/ui/select';
|
|
29
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
30
|
+
import { useViewerStore } from '@/store';
|
|
31
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
32
|
+
import { EntityNode } from '@ifc-lite/query';
|
|
33
|
+
import type { AddElementType } from '@/store/slices/addElementSlice';
|
|
34
|
+
|
|
35
|
+
interface ElementOption {
|
|
36
|
+
type: AddElementType;
|
|
37
|
+
label: string;
|
|
38
|
+
Icon: typeof Box;
|
|
39
|
+
/** Short description shown below the type chips. */
|
|
40
|
+
hint: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ELEMENT_OPTIONS: ElementOption[] = [
|
|
44
|
+
{ type: 'wall', label: 'Wall', Icon: Minus, hint: 'Click Start, then End. Cross-section = Thickness × Height, profile spans the click-to-click axis.' },
|
|
45
|
+
{ type: 'slab', label: 'Slab', Icon: Square, hint: 'Rectangle: 2 corner clicks. Polygon: N clicks + Enter to close. Extruded up by Thickness.' },
|
|
46
|
+
{ type: 'beam', label: 'Beam', Icon: Layers, hint: 'Click Start, then End. Cross-section (Width × Height) is centred on the beam axis.' },
|
|
47
|
+
{ type: 'column', label: 'Column', Icon: Box, hint: 'Single click sets the base centre. Width × Depth cross-section, extruded up by Height.' },
|
|
48
|
+
{ type: 'door', label: 'Door', Icon: DoorOpen, hint: 'Single click sets the bottom-centre. Width × Height leaf with a thin frame depth. Free-standing — refine wall hosting via Raw STEP if needed.' },
|
|
49
|
+
{ type: 'window', label: 'Window', Icon: SquareDashedBottom, hint: 'Single click sets the sill-centre. Width × Height sash with a thin frame depth.' },
|
|
50
|
+
{ type: 'space', label: 'Space', Icon: Home, hint: 'Rectangle: 2 corner clicks. Polygon: N clicks + Enter. Extruded up by Height into a room volume; aggregated to the storey via IfcRelAggregates.' },
|
|
51
|
+
{ type: 'roof', label: 'Roof', Icon: Square, hint: 'Same shape as a slab — flat-roof emit with .FLAT_ROOF. PredefinedType. Pitched roofs need IfcCreator.addIfcGableRoof.' },
|
|
52
|
+
{ type: 'plate', label: 'Plate', Icon: Square, hint: 'Thin flat plate (steel / gusset). Rectangle or polygon profile, extruded by Thickness.' },
|
|
53
|
+
{ type: 'member', label: 'Member', Icon: Cog, hint: 'Generic structural member (brace, post, strut). Click Start, then End. Pick PredefinedType to set role.' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
interface StoreyOption {
|
|
57
|
+
expressId: number;
|
|
58
|
+
label: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface AddElementPanelProps {
|
|
62
|
+
onClose: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function AddElementPanel({ onClose }: AddElementPanelProps) {
|
|
66
|
+
const { models, ifcDataStore } = useIfc();
|
|
67
|
+
|
|
68
|
+
const addElementType = useViewerStore((s) => s.addElementType);
|
|
69
|
+
const setAddElementType = useViewerStore((s) => s.setAddElementType);
|
|
70
|
+
|
|
71
|
+
const addElementModelId = useViewerStore((s) => s.addElementModelId);
|
|
72
|
+
const setAddElementModelId = useViewerStore((s) => s.setAddElementModelId);
|
|
73
|
+
const addElementStoreyId = useViewerStore((s) => s.addElementStoreyId);
|
|
74
|
+
const setAddElementStoreyId = useViewerStore((s) => s.setAddElementStoreyId);
|
|
75
|
+
|
|
76
|
+
const wallParams = useViewerStore((s) => s.addElementWallParams);
|
|
77
|
+
const setWallParams = useViewerStore((s) => s.setAddElementWallParams);
|
|
78
|
+
const slabParams = useViewerStore((s) => s.addElementSlabParams);
|
|
79
|
+
const setSlabParams = useViewerStore((s) => s.setAddElementSlabParams);
|
|
80
|
+
const beamParams = useViewerStore((s) => s.addElementBeamParams);
|
|
81
|
+
const setBeamParams = useViewerStore((s) => s.setAddElementBeamParams);
|
|
82
|
+
const columnParams = useViewerStore((s) => s.addElementColumnParams);
|
|
83
|
+
const setColumnParams = useViewerStore((s) => s.setAddElementColumnParams);
|
|
84
|
+
const doorParams = useViewerStore((s) => s.addElementDoorParams);
|
|
85
|
+
const setDoorParams = useViewerStore((s) => s.setAddElementDoorParams);
|
|
86
|
+
const windowParams = useViewerStore((s) => s.addElementWindowParams);
|
|
87
|
+
const setWindowParams = useViewerStore((s) => s.setAddElementWindowParams);
|
|
88
|
+
const spaceParams = useViewerStore((s) => s.addElementSpaceParams);
|
|
89
|
+
const setSpaceParams = useViewerStore((s) => s.setAddElementSpaceParams);
|
|
90
|
+
const roofParams = useViewerStore((s) => s.addElementRoofParams);
|
|
91
|
+
const setRoofParams = useViewerStore((s) => s.setAddElementRoofParams);
|
|
92
|
+
const plateParams = useViewerStore((s) => s.addElementPlateParams);
|
|
93
|
+
const setPlateParams = useViewerStore((s) => s.setAddElementPlateParams);
|
|
94
|
+
const memberParams = useViewerStore((s) => s.addElementMemberParams);
|
|
95
|
+
const setMemberParams = useViewerStore((s) => s.setAddElementMemberParams);
|
|
96
|
+
|
|
97
|
+
const slabMode = useViewerStore((s) => s.addElementSlabMode);
|
|
98
|
+
const setSlabMode = useViewerStore((s) => s.setAddElementSlabMode);
|
|
99
|
+
const pendingPoints = useViewerStore((s) => s.addElementPendingPoints);
|
|
100
|
+
const hoverPoint = useViewerStore((s) => s.addElementHoverPoint);
|
|
101
|
+
const clearPending = useViewerStore((s) => s.clearAddElementPending);
|
|
102
|
+
|
|
103
|
+
const activeModelId = useViewerStore((s) => s.activeModelId);
|
|
104
|
+
|
|
105
|
+
// Resolve the effective model + its storeys for the selects. When
|
|
106
|
+
// the user hasn't pinned a model the panel auto-tracks the active
|
|
107
|
+
// model; same for storey (auto-tracks first when null).
|
|
108
|
+
const effectiveModelId = addElementModelId ?? activeModelId ?? (models.size > 0 ? models.keys().next().value ?? null : null);
|
|
109
|
+
|
|
110
|
+
const modelOptions = useMemo(() => {
|
|
111
|
+
const opts: { id: string; label: string }[] = [];
|
|
112
|
+
for (const [id, model] of models) {
|
|
113
|
+
if (!model.ifcDataStore) continue;
|
|
114
|
+
opts.push({ id, label: model.name || id });
|
|
115
|
+
}
|
|
116
|
+
return opts;
|
|
117
|
+
}, [models]);
|
|
118
|
+
|
|
119
|
+
const storeyOptions = useMemo<StoreyOption[]>(() => {
|
|
120
|
+
const dataStore = effectiveModelId
|
|
121
|
+
? models.get(effectiveModelId)?.ifcDataStore ?? null
|
|
122
|
+
: ifcDataStore;
|
|
123
|
+
if (!dataStore) return [];
|
|
124
|
+
const ids = dataStore.entityIndex.byType.get('IFCBUILDINGSTOREY') ?? [];
|
|
125
|
+
const opts: StoreyOption[] = [];
|
|
126
|
+
for (const expressId of ids) {
|
|
127
|
+
const node = new EntityNode(dataStore, expressId);
|
|
128
|
+
const name = node.name || `Storey #${expressId}`;
|
|
129
|
+
opts.push({ expressId, label: name });
|
|
130
|
+
}
|
|
131
|
+
return opts;
|
|
132
|
+
}, [effectiveModelId, models, ifcDataStore]);
|
|
133
|
+
|
|
134
|
+
// Auto-pick the first storey when the user hasn't chosen one or
|
|
135
|
+
// the previous choice no longer exists in the active model. Also
|
|
136
|
+
// reset on model change — storey express ids are model-local, so a
|
|
137
|
+
// colliding numeric id from a different federated model would
|
|
138
|
+
// otherwise be silently reused as the placement target.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (storeyOptions.length === 0) return;
|
|
141
|
+
if (addElementStoreyId === null) return;
|
|
142
|
+
const stillValid = storeyOptions.some((s) => s.expressId === addElementStoreyId);
|
|
143
|
+
if (!stillValid) setAddElementStoreyId(null);
|
|
144
|
+
}, [storeyOptions, addElementStoreyId, setAddElementStoreyId, effectiveModelId]);
|
|
145
|
+
|
|
146
|
+
const hasModel = !!effectiveModelId;
|
|
147
|
+
const hasStorey = storeyOptions.length > 0;
|
|
148
|
+
const ready = hasModel && hasStorey;
|
|
149
|
+
|
|
150
|
+
const activeOption = ELEMENT_OPTIONS.find((o) => o.type === addElementType) ?? ELEMENT_OPTIONS[0];
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="h-full flex flex-col bg-white dark:bg-black">
|
|
154
|
+
{/* Header */}
|
|
155
|
+
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-950">
|
|
156
|
+
<div className="flex items-center gap-2">
|
|
157
|
+
<Box className="h-4 w-4 text-emerald-600" />
|
|
158
|
+
<h2 className="font-bold uppercase tracking-wider text-xs text-zinc-900 dark:text-zinc-100">
|
|
159
|
+
Add Element
|
|
160
|
+
</h2>
|
|
161
|
+
</div>
|
|
162
|
+
<Tooltip>
|
|
163
|
+
<TooltipTrigger asChild>
|
|
164
|
+
<Button
|
|
165
|
+
variant="ghost"
|
|
166
|
+
size="icon"
|
|
167
|
+
className="h-6 w-6"
|
|
168
|
+
onClick={onClose}
|
|
169
|
+
aria-label="Close add element panel"
|
|
170
|
+
>
|
|
171
|
+
<X className="h-3.5 w-3.5" />
|
|
172
|
+
</Button>
|
|
173
|
+
</TooltipTrigger>
|
|
174
|
+
<TooltipContent>Close (Esc)</TooltipContent>
|
|
175
|
+
</Tooltip>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
|
|
179
|
+
{/* Element type chips */}
|
|
180
|
+
<section className="space-y-1.5">
|
|
181
|
+
<Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
|
182
|
+
Type
|
|
183
|
+
</Label>
|
|
184
|
+
<div className="grid grid-cols-3 gap-1">
|
|
185
|
+
{ELEMENT_OPTIONS.map(({ type, label, Icon }) => {
|
|
186
|
+
const selected = addElementType === type;
|
|
187
|
+
return (
|
|
188
|
+
<button
|
|
189
|
+
key={type}
|
|
190
|
+
type="button"
|
|
191
|
+
onClick={() => setAddElementType(type)}
|
|
192
|
+
aria-pressed={selected}
|
|
193
|
+
className={[
|
|
194
|
+
'flex items-center justify-center gap-1 h-8 px-1.5 rounded-sm text-[10px] font-mono uppercase tracking-wide',
|
|
195
|
+
'border transition-colors',
|
|
196
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
|
197
|
+
selected
|
|
198
|
+
? 'bg-emerald-500 border-emerald-500 text-white hover:bg-emerald-600'
|
|
199
|
+
: 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-200 hover:border-emerald-300 dark:hover:border-emerald-800',
|
|
200
|
+
].join(' ')}
|
|
201
|
+
>
|
|
202
|
+
<Icon className="h-3 w-3 shrink-0" />
|
|
203
|
+
<span className="truncate">{label}</span>
|
|
204
|
+
</button>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
<p className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400 leading-snug pt-1">
|
|
209
|
+
{activeOption.hint}
|
|
210
|
+
</p>
|
|
211
|
+
</section>
|
|
212
|
+
|
|
213
|
+
{/* Model + storey context */}
|
|
214
|
+
{modelOptions.length > 1 && (
|
|
215
|
+
<section className="space-y-1.5">
|
|
216
|
+
<Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
|
217
|
+
Model
|
|
218
|
+
</Label>
|
|
219
|
+
<Select
|
|
220
|
+
value={effectiveModelId ?? undefined}
|
|
221
|
+
onValueChange={(v) => setAddElementModelId(v)}
|
|
222
|
+
>
|
|
223
|
+
<SelectTrigger className="h-8 font-mono text-xs">
|
|
224
|
+
<SelectValue placeholder="Select model…" />
|
|
225
|
+
</SelectTrigger>
|
|
226
|
+
<SelectContent>
|
|
227
|
+
{modelOptions.map(({ id, label }) => (
|
|
228
|
+
<SelectItem key={id} value={id} className="font-mono text-xs">
|
|
229
|
+
{label}
|
|
230
|
+
</SelectItem>
|
|
231
|
+
))}
|
|
232
|
+
</SelectContent>
|
|
233
|
+
</Select>
|
|
234
|
+
</section>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
<section className="space-y-1.5">
|
|
238
|
+
<Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
|
239
|
+
Storey
|
|
240
|
+
</Label>
|
|
241
|
+
{storeyOptions.length > 0 ? (
|
|
242
|
+
<Select
|
|
243
|
+
value={(addElementStoreyId ?? storeyOptions[0]?.expressId ?? '').toString()}
|
|
244
|
+
onValueChange={(v) => setAddElementStoreyId(Number(v))}
|
|
245
|
+
>
|
|
246
|
+
<SelectTrigger className="h-8 font-mono text-xs">
|
|
247
|
+
<SelectValue placeholder="Pick a storey…" />
|
|
248
|
+
</SelectTrigger>
|
|
249
|
+
<SelectContent>
|
|
250
|
+
{storeyOptions.map(({ expressId, label }) => (
|
|
251
|
+
<SelectItem key={expressId} value={expressId.toString()} className="font-mono text-xs">
|
|
252
|
+
{label}
|
|
253
|
+
</SelectItem>
|
|
254
|
+
))}
|
|
255
|
+
</SelectContent>
|
|
256
|
+
</Select>
|
|
257
|
+
) : (
|
|
258
|
+
<p className="text-[11px] font-mono text-amber-600 dark:text-amber-400">
|
|
259
|
+
{hasModel
|
|
260
|
+
? 'This model has no IfcBuildingStorey — load a model with a spatial hierarchy.'
|
|
261
|
+
: 'Load a model to begin.'}
|
|
262
|
+
</p>
|
|
263
|
+
)}
|
|
264
|
+
</section>
|
|
265
|
+
|
|
266
|
+
{/* Slab mode toggle — rectangle (2 clicks) vs polygon (N clicks + Enter) */}
|
|
267
|
+
{/* Profile mode toggle — applies to slab, roof, plate, space (anything that supports both rect + polygon) */}
|
|
268
|
+
{(addElementType === 'slab' || addElementType === 'roof' || addElementType === 'plate' || addElementType === 'space') && (
|
|
269
|
+
<section className="space-y-1.5">
|
|
270
|
+
<Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
|
271
|
+
{activeOption.label} profile
|
|
272
|
+
</Label>
|
|
273
|
+
<div className="grid grid-cols-2 gap-1">
|
|
274
|
+
<ModeChip selected={slabMode === 'rectangle'} onClick={() => setSlabMode('rectangle')}>
|
|
275
|
+
Rectangle (2 clicks)
|
|
276
|
+
</ModeChip>
|
|
277
|
+
<ModeChip selected={slabMode === 'polygon'} onClick={() => setSlabMode('polygon')}>
|
|
278
|
+
Polygon (N + Enter)
|
|
279
|
+
</ModeChip>
|
|
280
|
+
</div>
|
|
281
|
+
</section>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{/* Type-specific dimensions */}
|
|
285
|
+
<section className="space-y-2 pt-1">
|
|
286
|
+
<Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
|
287
|
+
{activeOption.label} dimensions
|
|
288
|
+
</Label>
|
|
289
|
+
|
|
290
|
+
{addElementType === 'wall' && (
|
|
291
|
+
<div className="grid grid-cols-2 gap-2">
|
|
292
|
+
<NumberField label="Thickness" suffix="m" value={wallParams.Thickness} min={0.01} onChange={(v) => setWallParams({ Thickness: v })} />
|
|
293
|
+
<NumberField label="Height" suffix="m" value={wallParams.Height} min={0.01} onChange={(v) => setWallParams({ Height: v })} />
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{addElementType === 'slab' && (
|
|
298
|
+
<NumberField label="Thickness" suffix="m" value={slabParams.Thickness} min={0.01} onChange={(v) => setSlabParams({ Thickness: v })} />
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{addElementType === 'beam' && (
|
|
302
|
+
<div className="grid grid-cols-2 gap-2">
|
|
303
|
+
<NumberField label="Width" suffix="m" value={beamParams.Width} min={0.01} onChange={(v) => setBeamParams({ Width: v })} />
|
|
304
|
+
<NumberField label="Height" suffix="m" value={beamParams.Height} min={0.01} onChange={(v) => setBeamParams({ Height: v })} />
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{addElementType === 'column' && (
|
|
309
|
+
<div className="grid grid-cols-3 gap-2">
|
|
310
|
+
<NumberField label="Width" suffix="m" value={columnParams.Width} min={0.01} onChange={(v) => setColumnParams({ Width: v })} />
|
|
311
|
+
<NumberField label="Depth" suffix="m" value={columnParams.Depth} min={0.01} onChange={(v) => setColumnParams({ Depth: v })} />
|
|
312
|
+
<NumberField label="Height" suffix="m" value={columnParams.Height} min={0.01} onChange={(v) => setColumnParams({ Height: v })} />
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{addElementType === 'door' && (
|
|
317
|
+
<div className="grid grid-cols-3 gap-2">
|
|
318
|
+
<NumberField label="Width" suffix="m" value={doorParams.Width} min={0.01} onChange={(v) => setDoorParams({ Width: v })} />
|
|
319
|
+
<NumberField label="Height" suffix="m" value={doorParams.Height} min={0.01} onChange={(v) => setDoorParams({ Height: v })} />
|
|
320
|
+
<NumberField label="Frame" suffix="m" value={doorParams.FrameThickness} min={0.005} onChange={(v) => setDoorParams({ FrameThickness: v })} />
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{addElementType === 'window' && (
|
|
325
|
+
<div className="grid grid-cols-3 gap-2">
|
|
326
|
+
<NumberField label="Width" suffix="m" value={windowParams.Width} min={0.01} onChange={(v) => setWindowParams({ Width: v })} />
|
|
327
|
+
<NumberField label="Height" suffix="m" value={windowParams.Height} min={0.01} onChange={(v) => setWindowParams({ Height: v })} />
|
|
328
|
+
<NumberField label="Frame" suffix="m" value={windowParams.FrameThickness} min={0.005} onChange={(v) => setWindowParams({ FrameThickness: v })} />
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
|
|
332
|
+
{addElementType === 'space' && (
|
|
333
|
+
<NumberField label="Height" suffix="m" value={spaceParams.Height} min={0.01} onChange={(v) => setSpaceParams({ Height: v })} />
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
{addElementType === 'roof' && (
|
|
337
|
+
<NumberField label="Thickness" suffix="m" value={roofParams.Thickness} min={0.01} onChange={(v) => setRoofParams({ Thickness: v })} />
|
|
338
|
+
)}
|
|
339
|
+
|
|
340
|
+
{addElementType === 'plate' && (
|
|
341
|
+
<NumberField label="Thickness" suffix="m" value={plateParams.Thickness} min={0.001} onChange={(v) => setPlateParams({ Thickness: v })} />
|
|
342
|
+
)}
|
|
343
|
+
|
|
344
|
+
{addElementType === 'member' && (
|
|
345
|
+
<div className="grid grid-cols-2 gap-2">
|
|
346
|
+
<NumberField label="Width" suffix="m" value={memberParams.Width} min={0.01} onChange={(v) => setMemberParams({ Width: v })} />
|
|
347
|
+
<NumberField label="Height" suffix="m" value={memberParams.Height} min={0.01} onChange={(v) => setMemberParams({ Height: v })} />
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
</section>
|
|
351
|
+
|
|
352
|
+
{/* Auto Spaces — wall-graph face finder, runs only when the
|
|
353
|
+
current type is 'space' so the panel stays focused. */}
|
|
354
|
+
{addElementType === 'space' && (
|
|
355
|
+
<AutoSpacesSection
|
|
356
|
+
modelId={effectiveModelId}
|
|
357
|
+
storeyId={addElementStoreyId ?? storeyOptions[0]?.expressId ?? null}
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* Click-state guidance — drives the user through the multi-click flow */}
|
|
362
|
+
<DropGuidance
|
|
363
|
+
ready={ready}
|
|
364
|
+
type={addElementType}
|
|
365
|
+
slabMode={slabMode}
|
|
366
|
+
pendingCount={pendingPoints.length}
|
|
367
|
+
hoverDistance={pendingPoints.length > 0 && hoverPoint
|
|
368
|
+
? distance2D(pendingPoints[pendingPoints.length - 1], hoverPoint)
|
|
369
|
+
: null}
|
|
370
|
+
onClearPending={clearPending}
|
|
371
|
+
/>
|
|
372
|
+
|
|
373
|
+
<p className="text-[10px] font-mono text-zinc-400 dark:text-zinc-600 leading-snug">
|
|
374
|
+
Snap to vertices, edges, and faces is on by default — toggle with <span className="font-semibold">S</span>.
|
|
375
|
+
Z is fixed to the storey floor; refine via the Raw STEP tab after dropping.
|
|
376
|
+
</p>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function distance2D(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
|
383
|
+
return Math.hypot(b.x - a.x, b.y - a.y);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
interface ModeChipProps {
|
|
387
|
+
selected: boolean;
|
|
388
|
+
onClick: () => void;
|
|
389
|
+
children: React.ReactNode;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function ModeChip({ selected, onClick, children }: ModeChipProps) {
|
|
393
|
+
return (
|
|
394
|
+
<button
|
|
395
|
+
type="button"
|
|
396
|
+
onClick={onClick}
|
|
397
|
+
aria-pressed={selected}
|
|
398
|
+
className={[
|
|
399
|
+
'h-7 px-2 rounded-sm text-[11px] font-mono uppercase tracking-wide',
|
|
400
|
+
'border transition-colors',
|
|
401
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
|
402
|
+
selected
|
|
403
|
+
? 'bg-emerald-500 border-emerald-500 text-white hover:bg-emerald-600'
|
|
404
|
+
: 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-200 hover:border-emerald-300 dark:hover:border-emerald-800',
|
|
405
|
+
].join(' ')}
|
|
406
|
+
>
|
|
407
|
+
{children}
|
|
408
|
+
</button>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
interface DropGuidanceProps {
|
|
413
|
+
ready: boolean;
|
|
414
|
+
type: AddElementType;
|
|
415
|
+
slabMode: 'rectangle' | 'polygon';
|
|
416
|
+
pendingCount: number;
|
|
417
|
+
hoverDistance: number | null;
|
|
418
|
+
onClearPending: () => void;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Stateful guidance pane — mirrors the multi-click flow so the user always knows what comes next. */
|
|
422
|
+
function DropGuidance({ ready, type, slabMode, pendingCount, hoverDistance, onClearPending }: DropGuidanceProps) {
|
|
423
|
+
if (!ready) {
|
|
424
|
+
return (
|
|
425
|
+
<section className="mt-2 rounded-sm border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-950 p-3 text-[11px] font-mono text-zinc-500 dark:text-zinc-400">
|
|
426
|
+
Authoring is disabled until a model with a building storey is loaded.
|
|
427
|
+
</section>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
let primary: string;
|
|
432
|
+
let secondary: string;
|
|
433
|
+
// Single-click placements share the same prompt shape.
|
|
434
|
+
if (type === 'column' || type === 'door' || type === 'window') {
|
|
435
|
+
primary = `Click in 3D to drop the ${type}.`;
|
|
436
|
+
secondary = 'Keep clicking to place more — Esc to exit.';
|
|
437
|
+
} else if (type === 'wall' || type === 'beam' || type === 'member') {
|
|
438
|
+
// Two-click axial placements (start → end).
|
|
439
|
+
if (pendingCount === 0) {
|
|
440
|
+
primary = `Click the ${type} start point.`;
|
|
441
|
+
secondary = 'Snap to vertex/edge for precise placement.';
|
|
442
|
+
} else {
|
|
443
|
+
primary = `Click the ${type} end point.`;
|
|
444
|
+
secondary = hoverDistance !== null
|
|
445
|
+
? `Length so far: ${hoverDistance.toFixed(2)} m — Esc to restart.`
|
|
446
|
+
: 'Esc to restart.';
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
// slab / roof / plate / space — rectangle (2 clicks) or polygon (N + Enter).
|
|
450
|
+
const polygonable = `${type[0].toUpperCase()}${type.slice(1)}`;
|
|
451
|
+
if (slabMode === 'rectangle') {
|
|
452
|
+
if (pendingCount === 0) {
|
|
453
|
+
primary = `Click the first ${type} corner.`;
|
|
454
|
+
secondary = 'A second click sets the opposite corner.';
|
|
455
|
+
} else {
|
|
456
|
+
primary = 'Click the opposite corner.';
|
|
457
|
+
secondary = 'Esc to restart, or switch to Polygon mode for irregular outlines.';
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
if (pendingCount === 0) {
|
|
461
|
+
primary = `Click the ${polygonable} polygon's first point.`;
|
|
462
|
+
secondary = 'Need at least 3 points; press Enter to close.';
|
|
463
|
+
} else if (pendingCount < 3) {
|
|
464
|
+
primary = `Click point ${pendingCount + 1} (need at least 3).`;
|
|
465
|
+
secondary = 'Esc to restart.';
|
|
466
|
+
} else {
|
|
467
|
+
primary = `Click point ${pendingCount + 1} or press Enter to close.`;
|
|
468
|
+
secondary = 'Esc to restart the polygon.';
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<section
|
|
475
|
+
className="mt-2 rounded-sm border border-emerald-300 dark:border-emerald-800 bg-emerald-50/50 dark:bg-emerald-950/20 p-3 text-[11px] font-mono leading-relaxed text-emerald-800 dark:text-emerald-300"
|
|
476
|
+
aria-live="polite"
|
|
477
|
+
>
|
|
478
|
+
<div className="flex items-start gap-2 justify-between">
|
|
479
|
+
<div className="min-w-0">
|
|
480
|
+
<span className="block font-semibold">{primary}</span>
|
|
481
|
+
<span className="block text-[10px] opacity-80 mt-0.5">{secondary}</span>
|
|
482
|
+
</div>
|
|
483
|
+
{pendingCount > 0 && (
|
|
484
|
+
<button
|
|
485
|
+
type="button"
|
|
486
|
+
onClick={onClearPending}
|
|
487
|
+
className="shrink-0 text-[10px] underline-offset-2 hover:underline opacity-80 hover:opacity-100"
|
|
488
|
+
aria-label="Discard pending points"
|
|
489
|
+
>
|
|
490
|
+
Reset
|
|
491
|
+
</button>
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
</section>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface NumberFieldProps {
|
|
499
|
+
label: string;
|
|
500
|
+
suffix?: string;
|
|
501
|
+
value: number;
|
|
502
|
+
min: number;
|
|
503
|
+
onChange: (v: number) => void;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
interface AutoSpacesSectionProps {
|
|
507
|
+
modelId: string | null;
|
|
508
|
+
storeyId: number | null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Compact "Auto Spaces" pane: wires the per-storey wall-graph face
|
|
513
|
+
* finder to the viewer slice. Preview button runs detection without
|
|
514
|
+
* emitting; Generate commits each candidate as an IfcSpace.
|
|
515
|
+
*/
|
|
516
|
+
function AutoSpacesSection({ modelId, storeyId }: AutoSpacesSectionProps) {
|
|
517
|
+
const params = useViewerStore((s) => s.addElementAutoSpaceParams);
|
|
518
|
+
const setParams = useViewerStore((s) => s.setAddElementAutoSpaceParams);
|
|
519
|
+
const preview = useViewerStore((s) => s.addElementAutoSpacePreview);
|
|
520
|
+
const setPreview = useViewerStore((s) => s.setAddElementAutoSpacePreview);
|
|
521
|
+
const generate = useViewerStore((s) => s.generateSpacesFromWalls);
|
|
522
|
+
const [busy, setBusy] = useState(false);
|
|
523
|
+
|
|
524
|
+
const ready = modelId !== null && storeyId !== null;
|
|
525
|
+
|
|
526
|
+
const [debugLogging, setDebugLogging] = useState(false);
|
|
527
|
+
|
|
528
|
+
const runPreview = () => {
|
|
529
|
+
if (!ready || busy) return;
|
|
530
|
+
setBusy(true);
|
|
531
|
+
try {
|
|
532
|
+
const result = generate(modelId!, storeyId!, {
|
|
533
|
+
snapTolerance: params.SnapTolerance,
|
|
534
|
+
minArea: params.MinArea,
|
|
535
|
+
height: params.Height,
|
|
536
|
+
namePattern: params.NamePattern,
|
|
537
|
+
predefinedType: params.PredefinedType,
|
|
538
|
+
dryRun: true,
|
|
539
|
+
debug: debugLogging,
|
|
540
|
+
});
|
|
541
|
+
if ('error' in result) {
|
|
542
|
+
toast.error(result.error);
|
|
543
|
+
setPreview(null);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const skipReasons: Record<string, number> = {};
|
|
547
|
+
for (const s of result.wallsSkipped) {
|
|
548
|
+
skipReasons[s.reason] = (skipReasons[s.reason] ?? 0) + 1;
|
|
549
|
+
}
|
|
550
|
+
setPreview({
|
|
551
|
+
storeyExpressId: storeyId!,
|
|
552
|
+
outlines: result.detected.map((d) => d.outline.map((p) => [p[0], p[1]])),
|
|
553
|
+
regions: result.detected.map((d) => ({ area: d.area })),
|
|
554
|
+
wallsConsidered: result.wallsConsidered,
|
|
555
|
+
wallsContributing: result.wallsContributing,
|
|
556
|
+
diagnostics: {
|
|
557
|
+
vertices: result.detectionStats.vertices,
|
|
558
|
+
edgesAfterSplit: result.detectionStats.segmentsAfterSplit,
|
|
559
|
+
facesTotal: result.detectionStats.faces,
|
|
560
|
+
outerFacesDropped: result.detectionStats.outerFacesDropped,
|
|
561
|
+
belowMinAreaDropped: result.detectionStats.belowMinAreaDropped,
|
|
562
|
+
largestArea: result.detectionStats.largestArea,
|
|
563
|
+
skipReasons,
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
if (result.detected.length === 0) {
|
|
567
|
+
toast.info('No enclosed regions detected. Check wall geometry or snap tolerance.');
|
|
568
|
+
}
|
|
569
|
+
} finally {
|
|
570
|
+
setBusy(false);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const runCommit = () => {
|
|
575
|
+
if (!ready || busy) return;
|
|
576
|
+
setBusy(true);
|
|
577
|
+
try {
|
|
578
|
+
const result = generate(modelId!, storeyId!, {
|
|
579
|
+
snapTolerance: params.SnapTolerance,
|
|
580
|
+
minArea: params.MinArea,
|
|
581
|
+
height: params.Height,
|
|
582
|
+
namePattern: params.NamePattern,
|
|
583
|
+
predefinedType: params.PredefinedType,
|
|
584
|
+
debug: debugLogging,
|
|
585
|
+
});
|
|
586
|
+
if ('error' in result) {
|
|
587
|
+
toast.error(result.error);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
setPreview(null);
|
|
591
|
+
const count = result.emitted.length;
|
|
592
|
+
if (count === 0) {
|
|
593
|
+
toast.info('No enclosed regions to generate.');
|
|
594
|
+
} else {
|
|
595
|
+
toast.success(`Generated ${count} IfcSpace${count === 1 ? '' : 's'}.`);
|
|
596
|
+
}
|
|
597
|
+
} finally {
|
|
598
|
+
setBusy(false);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
return (
|
|
603
|
+
<section className="space-y-2 pt-1">
|
|
604
|
+
<div className="flex items-center gap-1.5">
|
|
605
|
+
<Wand2 className="h-3 w-3 text-emerald-600" />
|
|
606
|
+
<Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
|
607
|
+
Auto Spaces (from walls)
|
|
608
|
+
</Label>
|
|
609
|
+
</div>
|
|
610
|
+
|
|
611
|
+
<div className="grid grid-cols-2 gap-2">
|
|
612
|
+
<NumberField
|
|
613
|
+
label="Snap" suffix="m"
|
|
614
|
+
value={params.SnapTolerance} min={0.001}
|
|
615
|
+
onChange={(v) => setParams({ SnapTolerance: v })}
|
|
616
|
+
/>
|
|
617
|
+
<NumberField
|
|
618
|
+
label="Min area" suffix="m²"
|
|
619
|
+
value={params.MinArea} min={0}
|
|
620
|
+
onChange={(v) => setParams({ MinArea: v })}
|
|
621
|
+
/>
|
|
622
|
+
<NumberField
|
|
623
|
+
label="Height" suffix="m"
|
|
624
|
+
value={params.Height} min={0.01}
|
|
625
|
+
onChange={(v) => setParams({ Height: v })}
|
|
626
|
+
/>
|
|
627
|
+
<div className="space-y-1">
|
|
628
|
+
<Label className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400" htmlFor="auto-space-type">
|
|
629
|
+
Type
|
|
630
|
+
</Label>
|
|
631
|
+
<Select
|
|
632
|
+
value={params.PredefinedType}
|
|
633
|
+
onValueChange={(v) => setParams({ PredefinedType: v })}
|
|
634
|
+
>
|
|
635
|
+
<SelectTrigger id="auto-space-type" className="h-8 font-mono text-xs">
|
|
636
|
+
<SelectValue />
|
|
637
|
+
</SelectTrigger>
|
|
638
|
+
<SelectContent>
|
|
639
|
+
<SelectItem value="INTERNAL" className="font-mono text-xs">INTERNAL</SelectItem>
|
|
640
|
+
<SelectItem value="EXTERNAL" className="font-mono text-xs">EXTERNAL</SelectItem>
|
|
641
|
+
<SelectItem value="SPACE" className="font-mono text-xs">SPACE</SelectItem>
|
|
642
|
+
<SelectItem value="PARKING" className="font-mono text-xs">PARKING</SelectItem>
|
|
643
|
+
<SelectItem value="GFA" className="font-mono text-xs">GFA</SelectItem>
|
|
644
|
+
<SelectItem value="USERDEFINED" className="font-mono text-xs">USERDEFINED</SelectItem>
|
|
645
|
+
<SelectItem value="NOTDEFINED" className="font-mono text-xs">NOTDEFINED</SelectItem>
|
|
646
|
+
</SelectContent>
|
|
647
|
+
</Select>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
|
|
651
|
+
<div className="space-y-1">
|
|
652
|
+
<Label htmlFor="auto-space-name" className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400">
|
|
653
|
+
Name pattern <span className="text-zinc-400 dark:text-zinc-600 ml-1">({'{n}'} = index)</span>
|
|
654
|
+
</Label>
|
|
655
|
+
<Input
|
|
656
|
+
id="auto-space-name"
|
|
657
|
+
type="text"
|
|
658
|
+
value={params.NamePattern}
|
|
659
|
+
onChange={(e) => setParams({ NamePattern: e.target.value })}
|
|
660
|
+
className="h-8 font-mono text-xs"
|
|
661
|
+
/>
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
<div className="grid grid-cols-2 gap-2 pt-1">
|
|
665
|
+
<Button
|
|
666
|
+
variant="outline"
|
|
667
|
+
size="sm"
|
|
668
|
+
onClick={runPreview}
|
|
669
|
+
disabled={!ready || busy}
|
|
670
|
+
className="h-8 text-[11px] font-mono"
|
|
671
|
+
>
|
|
672
|
+
Preview
|
|
673
|
+
</Button>
|
|
674
|
+
<Button
|
|
675
|
+
variant="default"
|
|
676
|
+
size="sm"
|
|
677
|
+
onClick={runCommit}
|
|
678
|
+
disabled={!ready || busy}
|
|
679
|
+
className="h-8 text-[11px] font-mono bg-emerald-600 hover:bg-emerald-700"
|
|
680
|
+
>
|
|
681
|
+
Generate
|
|
682
|
+
</Button>
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
<label className="flex items-center gap-1.5 text-[10px] font-mono text-zinc-500 dark:text-zinc-400 select-none cursor-pointer">
|
|
686
|
+
<input
|
|
687
|
+
type="checkbox"
|
|
688
|
+
checked={debugLogging}
|
|
689
|
+
onChange={(e) => setDebugLogging(e.target.checked)}
|
|
690
|
+
className="h-3 w-3 accent-emerald-600"
|
|
691
|
+
/>
|
|
692
|
+
Verbose console logging (open devtools)
|
|
693
|
+
</label>
|
|
694
|
+
|
|
695
|
+
{preview && (
|
|
696
|
+
<div className="rounded-sm border border-emerald-200 dark:border-emerald-900 bg-emerald-50/60 dark:bg-emerald-950/20 px-2 py-1.5 text-[10px] font-mono text-emerald-800 dark:text-emerald-300 leading-snug">
|
|
697
|
+
<div>
|
|
698
|
+
{preview.regions.length} region{preview.regions.length === 1 ? '' : 's'} detected
|
|
699
|
+
{' · '}{preview.wallsContributing}/{preview.wallsConsidered} walls
|
|
700
|
+
</div>
|
|
701
|
+
{preview.regions.length > 0 && (
|
|
702
|
+
<div className="opacity-80">
|
|
703
|
+
Total area: {preview.regions.reduce((sum, r) => sum + r.area, 0).toFixed(1)} m²
|
|
704
|
+
</div>
|
|
705
|
+
)}
|
|
706
|
+
{preview.diagnostics && (
|
|
707
|
+
<div className="opacity-80 mt-1">
|
|
708
|
+
graph: {preview.diagnostics.vertices}v / {preview.diagnostics.edgesAfterSplit}e / {preview.diagnostics.facesTotal}f
|
|
709
|
+
{' · '}dropped {preview.diagnostics.outerFacesDropped} outer + {preview.diagnostics.belowMinAreaDropped} small
|
|
710
|
+
</div>
|
|
711
|
+
)}
|
|
712
|
+
{preview.diagnostics && Object.keys(preview.diagnostics.skipReasons).length > 0 && (
|
|
713
|
+
<div className="opacity-80">
|
|
714
|
+
skipped walls:{' '}
|
|
715
|
+
{Object.entries(preview.diagnostics.skipReasons)
|
|
716
|
+
.map(([reason, count]) => `${count}× ${reason}`)
|
|
717
|
+
.join(', ')}
|
|
718
|
+
</div>
|
|
719
|
+
)}
|
|
720
|
+
{preview.regions.length === 0 && preview.wallsContributing > 0 && (
|
|
721
|
+
<div className="mt-1 text-amber-700 dark:text-amber-400">
|
|
722
|
+
Walls extracted but no enclosed regions formed — check that walls actually meet at corners (try a larger Snap value).
|
|
723
|
+
</div>
|
|
724
|
+
)}
|
|
725
|
+
{preview.wallsContributing === 0 && preview.wallsConsidered > 0 && (
|
|
726
|
+
<div className="mt-1 text-amber-700 dark:text-amber-400">
|
|
727
|
+
No wall axes could be extracted. Toggle "Verbose console logging" for per-wall diagnostics.
|
|
728
|
+
</div>
|
|
729
|
+
)}
|
|
730
|
+
</div>
|
|
731
|
+
)}
|
|
732
|
+
</section>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function NumberField({ label, suffix, value, min, onChange }: NumberFieldProps) {
|
|
737
|
+
const id = `add-elem-${label.toLowerCase()}`;
|
|
738
|
+
return (
|
|
739
|
+
<div className="space-y-1">
|
|
740
|
+
<Label htmlFor={id} className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400">
|
|
741
|
+
{label}
|
|
742
|
+
{suffix && <span className="text-zinc-400 dark:text-zinc-600 ml-1">({suffix})</span>}
|
|
743
|
+
</Label>
|
|
744
|
+
<Input
|
|
745
|
+
id={id}
|
|
746
|
+
type="number"
|
|
747
|
+
step={0.05}
|
|
748
|
+
min={min}
|
|
749
|
+
value={Number.isFinite(value) ? value : ''}
|
|
750
|
+
onChange={(e) => {
|
|
751
|
+
const next = Number(e.target.value);
|
|
752
|
+
if (Number.isFinite(next) && next >= min) onChange(next);
|
|
753
|
+
}}
|
|
754
|
+
className="h-8 font-mono text-xs"
|
|
755
|
+
/>
|
|
756
|
+
</div>
|
|
757
|
+
);
|
|
758
|
+
}
|