@pascal-app/editor 0.9.2 → 1.0.0-beta.2
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/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +265 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand'
|
|
4
|
+
import { persist } from 'zustand/middleware'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_FLOORPLAN_MODE,
|
|
7
|
+
type FloorplanMode,
|
|
8
|
+
normalizeFloorplanModesByProject,
|
|
9
|
+
} from '../lib/floorplan/floorplan-mode'
|
|
10
|
+
|
|
11
|
+
export type FloorplanModeNotice = {
|
|
12
|
+
id: number
|
|
13
|
+
kind: 'info' | 'switch-to-expert'
|
|
14
|
+
message: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type FloorplanModeState = {
|
|
18
|
+
mode: FloorplanMode
|
|
19
|
+
projectId: string | null
|
|
20
|
+
modesByProject: Record<string, FloorplanMode>
|
|
21
|
+
hasShownDefaultReassurance: boolean
|
|
22
|
+
notice: FloorplanModeNotice | null
|
|
23
|
+
dismissNotice: () => void
|
|
24
|
+
setMode: (mode: FloorplanMode) => void
|
|
25
|
+
setProjectId: (projectId: string | null) => void
|
|
26
|
+
showExpertModeNotice: (toolLabel: string) => void
|
|
27
|
+
showNotice: (message: string) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let nextNoticeId = 0
|
|
31
|
+
|
|
32
|
+
const useFloorplanMode = create<FloorplanModeState>()(
|
|
33
|
+
persist(
|
|
34
|
+
(set) => ({
|
|
35
|
+
mode: DEFAULT_FLOORPLAN_MODE,
|
|
36
|
+
projectId: null,
|
|
37
|
+
modesByProject: {},
|
|
38
|
+
hasShownDefaultReassurance: false,
|
|
39
|
+
notice: null,
|
|
40
|
+
dismissNotice: () => set({ notice: null }),
|
|
41
|
+
setMode: (mode) =>
|
|
42
|
+
set((state) => {
|
|
43
|
+
const shouldReassure =
|
|
44
|
+
state.mode === 'expert' && mode === 'default' && !state.hasShownDefaultReassurance
|
|
45
|
+
return {
|
|
46
|
+
mode,
|
|
47
|
+
modesByProject: state.projectId
|
|
48
|
+
? { ...state.modesByProject, [state.projectId]: mode }
|
|
49
|
+
: state.modesByProject,
|
|
50
|
+
hasShownDefaultReassurance: state.hasShownDefaultReassurance || shouldReassure,
|
|
51
|
+
notice: shouldReassure
|
|
52
|
+
? {
|
|
53
|
+
id: ++nextNoticeId,
|
|
54
|
+
kind: 'info',
|
|
55
|
+
message:
|
|
56
|
+
'Default mode hides Expert annotations. Your saved work was not deleted.',
|
|
57
|
+
}
|
|
58
|
+
: state.notice,
|
|
59
|
+
}
|
|
60
|
+
}),
|
|
61
|
+
setProjectId: (projectId) =>
|
|
62
|
+
set((state) => ({
|
|
63
|
+
projectId,
|
|
64
|
+
mode: projectId
|
|
65
|
+
? (state.modesByProject[projectId] ?? DEFAULT_FLOORPLAN_MODE)
|
|
66
|
+
: DEFAULT_FLOORPLAN_MODE,
|
|
67
|
+
notice: null,
|
|
68
|
+
})),
|
|
69
|
+
showExpertModeNotice: (toolLabel) =>
|
|
70
|
+
set({
|
|
71
|
+
notice: {
|
|
72
|
+
id: ++nextNoticeId,
|
|
73
|
+
kind: 'switch-to-expert',
|
|
74
|
+
message: `${toolLabel} is available in Expert mode.`,
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
showNotice: (message) =>
|
|
78
|
+
set({
|
|
79
|
+
notice: {
|
|
80
|
+
id: ++nextNoticeId,
|
|
81
|
+
kind: 'info',
|
|
82
|
+
message,
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
}),
|
|
86
|
+
{
|
|
87
|
+
name: 'pascal-floorplan-mode-by-project',
|
|
88
|
+
merge: (persistedState, currentState) => {
|
|
89
|
+
const persisted = persistedState as
|
|
90
|
+
| { hasShownDefaultReassurance?: unknown; modesByProject?: unknown }
|
|
91
|
+
| undefined
|
|
92
|
+
const modesByProject = normalizeFloorplanModesByProject(persisted?.modesByProject)
|
|
93
|
+
return {
|
|
94
|
+
...currentState,
|
|
95
|
+
modesByProject,
|
|
96
|
+
hasShownDefaultReassurance: persisted?.hasShownDefaultReassurance === true,
|
|
97
|
+
mode: currentState.projectId
|
|
98
|
+
? (modesByProject[currentState.projectId] ?? DEFAULT_FLOORPLAN_MODE)
|
|
99
|
+
: DEFAULT_FLOORPLAN_MODE,
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
partialize: (state) => ({
|
|
103
|
+
hasShownDefaultReassurance: state.hasShownDefaultReassurance,
|
|
104
|
+
modesByProject: state.modesByProject,
|
|
105
|
+
}),
|
|
106
|
+
},
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
export default useFloorplanMode
|
|
@@ -2,10 +2,12 @@ import { afterEach, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import type { AnyNode } from '@pascal-app/core'
|
|
3
3
|
import {
|
|
4
4
|
type ActiveInteractionScope,
|
|
5
|
+
curveReshapeScope,
|
|
5
6
|
editingHoleInfo,
|
|
6
7
|
handleDragInfo,
|
|
7
8
|
isActive,
|
|
8
9
|
isIdle,
|
|
10
|
+
isToolDrivenReshape,
|
|
9
11
|
scopeNodeId,
|
|
10
12
|
selectionEnabled,
|
|
11
13
|
} from '../lib/interaction/scope'
|
|
@@ -137,7 +139,13 @@ describe('derived flag views are leak-free (no parallel flags)', () => {
|
|
|
137
139
|
|
|
138
140
|
test('editingHoleInfo mirrors a hole reshape and clears on end', () => {
|
|
139
141
|
const s = useInteractionScope.getState()
|
|
140
|
-
s.begin({
|
|
142
|
+
s.begin({
|
|
143
|
+
kind: 'reshaping',
|
|
144
|
+
nodeId: 'slab_1',
|
|
145
|
+
reshape: 'hole',
|
|
146
|
+
driver: 'tool',
|
|
147
|
+
holeIndex: 2,
|
|
148
|
+
})
|
|
141
149
|
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 })
|
|
142
150
|
s.end()
|
|
143
151
|
expect(editingHoleInfo(scope())).toBeNull()
|
|
@@ -145,16 +153,27 @@ describe('derived flag views are leak-free (no parallel flags)', () => {
|
|
|
145
153
|
|
|
146
154
|
test('a non-hole reshape never reads as an editing hole', () => {
|
|
147
155
|
const s = useInteractionScope.getState()
|
|
148
|
-
s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve' })
|
|
156
|
+
s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve', driver: 'tool' })
|
|
149
157
|
expect(editingHoleInfo(scope())).toBeNull()
|
|
150
158
|
})
|
|
151
159
|
|
|
160
|
+
test('a floorplan-owned curve drag does not activate the registry reshape tool', () => {
|
|
161
|
+
expect(isToolDrivenReshape(curveReshapeScope('wall_1', 'floorplan'))).toBe(false)
|
|
162
|
+
expect(isToolDrivenReshape(curveReshapeScope('wall_1', 'tool'))).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
152
165
|
test('switching interactions never leaks the prior derived view', () => {
|
|
153
166
|
const s = useInteractionScope.getState()
|
|
154
167
|
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
|
155
168
|
// Single-owner replacement: the handle-drag view must vanish the instant a
|
|
156
169
|
// different interaction begins — the two cannot be simultaneously active.
|
|
157
|
-
s.begin({
|
|
170
|
+
s.begin({
|
|
171
|
+
kind: 'reshaping',
|
|
172
|
+
nodeId: 'slab_1',
|
|
173
|
+
reshape: 'hole',
|
|
174
|
+
driver: 'tool',
|
|
175
|
+
holeIndex: 0,
|
|
176
|
+
})
|
|
158
177
|
expect(handleDragInfo(scope())).toBeNull()
|
|
159
178
|
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 })
|
|
160
179
|
})
|
|
@@ -174,6 +193,7 @@ describe('derived flag views are leak-free (no parallel flags)', () => {
|
|
|
174
193
|
{ kind: 'drafting', tool: 'wall' },
|
|
175
194
|
{ kind: 'box-select' },
|
|
176
195
|
{ kind: 'painting' },
|
|
196
|
+
{ kind: 'sculpting' },
|
|
177
197
|
]
|
|
178
198
|
for (const k of kinds) {
|
|
179
199
|
s.begin(k)
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
IDLE_SCOPE,
|
|
14
14
|
type InteractionScope,
|
|
15
15
|
isCurveReshape,
|
|
16
|
+
isFloorplanDrivenReshape,
|
|
17
|
+
isToolDrivenReshape,
|
|
16
18
|
movingNodeOf,
|
|
17
19
|
reshapingNodeId,
|
|
18
20
|
tangentReshapeInfo,
|
|
@@ -84,6 +86,12 @@ export const getIsCurveReshape = (): boolean => isCurveReshape(useInteractionSco
|
|
|
84
86
|
// recovered by reading the reshaped node's type from `useReshapingNode`.
|
|
85
87
|
export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCurveReshape(s.scope))
|
|
86
88
|
|
|
89
|
+
export const useIsToolDrivenReshape = (): boolean =>
|
|
90
|
+
useInteractionScope((s) => isToolDrivenReshape(s.scope))
|
|
91
|
+
|
|
92
|
+
export const useIsFloorplanDrivenReshape = (): boolean =>
|
|
93
|
+
useInteractionScope((s) => isFloorplanDrivenReshape(s.scope))
|
|
94
|
+
|
|
87
95
|
// Replaces the legacy `movingWallEndpoint` / `movingFenceEndpoint` payloads,
|
|
88
96
|
// minus the node (fetch it from `useReshapingNode`).
|
|
89
97
|
export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null =>
|
|
@@ -195,6 +195,7 @@ describe('measurement draft transitions', () => {
|
|
|
195
195
|
}),
|
|
196
196
|
).toBe(true)
|
|
197
197
|
expect(draft.finishVertexDrag('3d')).toBe(true)
|
|
198
|
+
expect(useMeasurementDraft.getState().points[0]).toEqual(point(0, 0, 0))
|
|
198
199
|
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
|
|
199
200
|
point: point(0, 0, 0),
|
|
200
201
|
normal: point(0, 1, 0),
|
|
@@ -208,6 +209,34 @@ describe('measurement draft transitions', () => {
|
|
|
208
209
|
expect(useMeasurementDraft.getState().collectionPlane).toBeNull()
|
|
209
210
|
})
|
|
210
211
|
|
|
212
|
+
test('projects every later polygon point and feature fallback onto the first surface plane', () => {
|
|
213
|
+
const draft = useMeasurementDraft.getState()
|
|
214
|
+
const anchor = {
|
|
215
|
+
kind: 'feature' as const,
|
|
216
|
+
reference: {
|
|
217
|
+
nodeId: 'slab_host',
|
|
218
|
+
featureId: 'slab:boundary',
|
|
219
|
+
parameters: { t: 0.25 },
|
|
220
|
+
},
|
|
221
|
+
fallback: point(2, 0.4, 0),
|
|
222
|
+
}
|
|
223
|
+
draft.setKind('area')
|
|
224
|
+
draft.addPoint('3d', point(0, 0, 0), undefined, point(0, 1, 0))
|
|
225
|
+
draft.addPoint('3d', anchor.fallback, anchor, point(1, 0, 0))
|
|
226
|
+
draft.addPoint('3d', point(2, -0.7, 2), undefined, point(0, -1, 0))
|
|
227
|
+
|
|
228
|
+
expect(useMeasurementDraft.getState().points).toEqual([
|
|
229
|
+
point(0, 0, 0),
|
|
230
|
+
point(2, 0, 0),
|
|
231
|
+
point(2, 0, 2),
|
|
232
|
+
])
|
|
233
|
+
expect(useMeasurementDraft.getState().anchors[1]).toMatchObject({
|
|
234
|
+
fallback: point(2, 0, 0),
|
|
235
|
+
})
|
|
236
|
+
expect(draft.closeBase('3d')).toBe(true)
|
|
237
|
+
expect(useMeasurementDraft.getState().error).toBeNull()
|
|
238
|
+
})
|
|
239
|
+
|
|
211
240
|
test('closes a volume base before accepting explicit extrusion', () => {
|
|
212
241
|
const draft = useMeasurementDraft.getState()
|
|
213
242
|
draft.setKind('volume')
|
|
@@ -108,6 +108,29 @@ function normalizePoint(point: MeasurementPoint | undefined): MeasurementPoint |
|
|
|
108
108
|
return length > 1e-9 ? [point[0] / length, point[1] / length, point[2] / length] : null
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
function projectPointToPlane(
|
|
112
|
+
point: MeasurementPoint,
|
|
113
|
+
plane: { point: MeasurementPoint; normal: MeasurementPoint } | null,
|
|
114
|
+
): MeasurementPoint {
|
|
115
|
+
if (!plane) return clonePoint(point)
|
|
116
|
+
const distance =
|
|
117
|
+
(point[0] - plane.point[0]) * plane.normal[0] +
|
|
118
|
+
(point[1] - plane.point[1]) * plane.normal[1] +
|
|
119
|
+
(point[2] - plane.point[2]) * plane.normal[2]
|
|
120
|
+
return [
|
|
121
|
+
point[0] - plane.normal[0] * distance,
|
|
122
|
+
point[1] - plane.normal[1] * distance,
|
|
123
|
+
point[2] - plane.normal[2] * distance,
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function anchorWithFallback(
|
|
128
|
+
anchor: MeasurementFeatureAnchor | undefined,
|
|
129
|
+
fallback: MeasurementPoint,
|
|
130
|
+
): MeasurementFeatureAnchor | null {
|
|
131
|
+
return anchor ? { ...anchor, fallback: clonePoint(fallback) } : null
|
|
132
|
+
}
|
|
133
|
+
|
|
111
134
|
export function measurementPolygonMidpoints(
|
|
112
135
|
points: readonly MeasurementPoint[],
|
|
113
136
|
): Array<{ edgeIndex: number; point: MeasurementPoint }> {
|
|
@@ -277,17 +300,18 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
|
|
|
277
300
|
return false
|
|
278
301
|
}
|
|
279
302
|
|
|
303
|
+
const projectedPoint = projectPointToPlane(hover.point, state.collectionPlane)
|
|
280
304
|
const points = state.points.map((point, index) =>
|
|
281
|
-
index === drag.index ?
|
|
305
|
+
index === drag.index ? projectedPoint : point,
|
|
282
306
|
)
|
|
283
307
|
const anchors = state.anchors.map((anchor, index) =>
|
|
284
|
-
index === drag.index ? (hover.anchor
|
|
308
|
+
index === drag.index ? anchorWithFallback(hover.anchor, projectedPoint) : anchor,
|
|
285
309
|
)
|
|
286
310
|
set({
|
|
287
311
|
points,
|
|
288
312
|
anchors,
|
|
289
313
|
hover: {
|
|
290
|
-
point: clonePoint(
|
|
314
|
+
point: clonePoint(projectedPoint),
|
|
291
315
|
normal: clonePoint(hover.normal),
|
|
292
316
|
targetNodeId: hover.targetNodeId,
|
|
293
317
|
anchor: hover.anchor,
|
|
@@ -352,9 +376,12 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
|
|
|
352
376
|
if (state.kind === 'distance' && state.points.length >= 2) return false
|
|
353
377
|
if (state.kind === 'angle' && state.points.length >= 3) return false
|
|
354
378
|
|
|
355
|
-
const points = [...state.points, clonePoint(point)]
|
|
356
|
-
const anchors = [...state.anchors, anchor ?? null]
|
|
357
379
|
const polygon = state.kind === 'area' || state.kind === 'perimeter' || state.kind === 'volume'
|
|
380
|
+
const projectedPoint = polygon
|
|
381
|
+
? projectPointToPlane(point, state.collectionPlane)
|
|
382
|
+
: clonePoint(point)
|
|
383
|
+
const points = [...state.points, projectedPoint]
|
|
384
|
+
const anchors = [...state.anchors, anchorWithFallback(anchor, projectedPoint)]
|
|
358
385
|
const planeNormal = polygon && state.points.length === 0 ? normalizePoint(surfaceNormal) : null
|
|
359
386
|
const ready =
|
|
360
387
|
(state.kind === 'distance' && points.length === 2) ||
|
|
@@ -365,7 +392,7 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
|
|
|
365
392
|
points,
|
|
366
393
|
anchors,
|
|
367
394
|
collectionPlane: planeNormal
|
|
368
|
-
? { point: clonePoint(
|
|
395
|
+
? { point: clonePoint(projectedPoint), normal: planeNormal }
|
|
369
396
|
: state.collectionPlane,
|
|
370
397
|
stage: ready ? 'ready' : 'collecting',
|
|
371
398
|
hover: null,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
2
|
+
import { create } from 'zustand'
|
|
3
|
+
|
|
4
|
+
export type PathDraftKind = 'duct-segment' | 'lineset' | 'liquid-line' | 'pipe-segment'
|
|
5
|
+
export type PathDraftPoint = [number, number, number]
|
|
6
|
+
export type PathDraftParameter = boolean | number | string
|
|
7
|
+
export type PathDraftParameters = Record<string, PathDraftParameter>
|
|
8
|
+
|
|
9
|
+
type PathDraftPreviewState = {
|
|
10
|
+
kind: PathDraftKind | null
|
|
11
|
+
points: PathDraftPoint[]
|
|
12
|
+
cursor: PathDraftPoint | null
|
|
13
|
+
parameters: PathDraftParameters
|
|
14
|
+
relatedNodes: AnyNode[]
|
|
15
|
+
setDraft(
|
|
16
|
+
kind: PathDraftKind,
|
|
17
|
+
points: readonly PathDraftPoint[],
|
|
18
|
+
cursor: PathDraftPoint | null,
|
|
19
|
+
parameters?: Readonly<PathDraftParameters>,
|
|
20
|
+
relatedNodes?: readonly AnyNode[],
|
|
21
|
+
): void
|
|
22
|
+
clear(kind: PathDraftKind): void
|
|
23
|
+
reset(): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pointEquals(a: PathDraftPoint | null, b: PathDraftPoint | null) {
|
|
27
|
+
return (!a && !b) || Boolean(a && b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pointsEqual(a: readonly PathDraftPoint[], b: readonly PathDraftPoint[]) {
|
|
31
|
+
return a.length === b.length && a.every((point, index) => pointEquals(point, b[index] ?? null))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parametersEqual(a: PathDraftParameters, b: Readonly<PathDraftParameters>) {
|
|
35
|
+
const keys = Object.keys(a)
|
|
36
|
+
const nextKeys = Object.keys(b)
|
|
37
|
+
return keys.length === nextKeys.length && keys.every((key) => a[key] === b[key])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function relatedNodesEqual(a: readonly AnyNode[], b: readonly AnyNode[]) {
|
|
41
|
+
return (
|
|
42
|
+
a.length === b.length &&
|
|
43
|
+
a.every((node, index) => JSON.stringify(node) === JSON.stringify(b[index]))
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY_DRAFT: Pick<
|
|
48
|
+
PathDraftPreviewState,
|
|
49
|
+
'cursor' | 'kind' | 'parameters' | 'points' | 'relatedNodes'
|
|
50
|
+
> = {
|
|
51
|
+
cursor: null,
|
|
52
|
+
kind: null,
|
|
53
|
+
parameters: {},
|
|
54
|
+
points: [],
|
|
55
|
+
relatedNodes: [],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const usePathDraftPreview = create<PathDraftPreviewState>((set) => ({
|
|
59
|
+
...EMPTY_DRAFT,
|
|
60
|
+
setDraft: (kind, points, cursor, parameters = {}, relatedNodes = []) =>
|
|
61
|
+
set((state) => {
|
|
62
|
+
if (
|
|
63
|
+
state.kind === kind &&
|
|
64
|
+
pointEquals(state.cursor, cursor) &&
|
|
65
|
+
pointsEqual(state.points, points) &&
|
|
66
|
+
parametersEqual(state.parameters, parameters) &&
|
|
67
|
+
relatedNodesEqual(state.relatedNodes, relatedNodes)
|
|
68
|
+
) {
|
|
69
|
+
return state
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
cursor: cursor ? [...cursor] : null,
|
|
73
|
+
kind,
|
|
74
|
+
parameters: { ...parameters },
|
|
75
|
+
points: points.map((point) => [...point]),
|
|
76
|
+
relatedNodes: relatedNodes.map((node) => structuredClone(node)),
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
clear: (kind) => set((state) => (state.kind === kind ? EMPTY_DRAFT : state)),
|
|
80
|
+
reset: () =>
|
|
81
|
+
set((state) =>
|
|
82
|
+
state.kind === null &&
|
|
83
|
+
state.points.length === 0 &&
|
|
84
|
+
state.cursor === null &&
|
|
85
|
+
Object.keys(state.parameters).length === 0 &&
|
|
86
|
+
state.relatedNodes.length === 0
|
|
87
|
+
? state
|
|
88
|
+
: EMPTY_DRAFT,
|
|
89
|
+
),
|
|
90
|
+
}))
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { create } from 'zustand'
|
|
12
12
|
|
|
13
|
-
type StairPreviewPoint = [number, number]
|
|
13
|
+
export type StairPreviewPoint = [number, number]
|
|
14
14
|
|
|
15
15
|
type StairBuildPreviewState = {
|
|
16
16
|
/** Snapped plan-XZ point the ghost staircase sits at; `null` when idle. */
|
|
@@ -21,6 +21,7 @@ type StairBuildPreviewState = {
|
|
|
21
21
|
* don't re-render) when the point is unchanged — `grid:move` fires far more
|
|
22
22
|
* often than the snapped cell actually changes. */
|
|
23
23
|
setPoint(point: StairPreviewPoint | null): void
|
|
24
|
+
setPreview(point: StairPreviewPoint | null, rotation: number): void
|
|
24
25
|
rotateBy(deltaRadians: number): void
|
|
25
26
|
reset(): void
|
|
26
27
|
}
|
|
@@ -33,7 +34,16 @@ export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
|
|
|
33
34
|
const prev = state.point
|
|
34
35
|
if (!point && !prev) return state
|
|
35
36
|
if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
|
|
36
|
-
return { point }
|
|
37
|
+
return { point: point ? [point[0], point[1]] : null }
|
|
38
|
+
}),
|
|
39
|
+
setPreview: (point, rotation) =>
|
|
40
|
+
set((state) => {
|
|
41
|
+
const samePoint =
|
|
42
|
+
(!point && !state.point) ||
|
|
43
|
+
Boolean(point && state.point && state.point[0] === point[0] && state.point[1] === point[1])
|
|
44
|
+
return samePoint && state.rotation === rotation
|
|
45
|
+
? state
|
|
46
|
+
: { point: point ? [point[0], point[1]] : null, rotation }
|
|
37
47
|
}),
|
|
38
48
|
rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })),
|
|
39
49
|
reset: () =>
|