@pascal-app/editor 1.0.0-beta.5 → 1.0.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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +13 -7
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +42 -51
- package/src/components/editor/floorplan-panel.tsx +76 -12
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +140 -21
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/selection-manager.tsx +104 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +284 -237
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +135 -2
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +9 -12
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +22 -5
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +129 -62
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +82 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +1 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
package/src/lib/history.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
emitter,
|
|
5
|
+
getHistoryDirtyNodeIds,
|
|
6
|
+
useLiveNodeOverrides,
|
|
7
|
+
useLiveTransforms,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { markPerfAction } from '@pascal-app/viewer'
|
|
11
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
12
|
+
import { registeredDraftingConfig } from './interaction/registered-drafting'
|
|
2
13
|
|
|
3
14
|
export type HistoryCommandState = {
|
|
4
15
|
canRedo: boolean
|
|
@@ -61,29 +72,92 @@ function notifyHistoryCommandListeners() {
|
|
|
61
72
|
for (const listener of [...historyCommandListeners]) listener()
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
function
|
|
75
|
+
function capturePreviewLayout() {
|
|
76
|
+
const overrides = useLiveNodeOverrides.getState().overrides
|
|
77
|
+
if (overrides.size === 0) return null
|
|
78
|
+
const nodes = { ...useScene.getState().nodes }
|
|
79
|
+
for (const [id, values] of overrides) {
|
|
80
|
+
const node = nodes[id as AnyNodeId]
|
|
81
|
+
if (node) nodes[node.id] = { ...node, ...values } as AnyNode
|
|
82
|
+
}
|
|
83
|
+
return nodes
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function refreshSceneAfterHistoryJump(previewLayout: Record<string, AnyNode> | null) {
|
|
87
|
+
const target = useScene.getState().nodes
|
|
88
|
+
const previewDirty = previewLayout
|
|
89
|
+
? getHistoryDirtyNodeIds(previewLayout, target)
|
|
90
|
+
: new Set<AnyNodeId>()
|
|
91
|
+
const previewIds = new Set([
|
|
92
|
+
...useLiveTransforms.getState().transforms.keys(),
|
|
93
|
+
...useLiveNodeOverrides.getState().overrides.keys(),
|
|
94
|
+
])
|
|
95
|
+
const currentPreviewLayout = capturePreviewLayout()
|
|
96
|
+
if (currentPreviewLayout) {
|
|
97
|
+
for (const id of getHistoryDirtyNodeIds(currentPreviewLayout, target)) previewDirty.add(id)
|
|
98
|
+
}
|
|
65
99
|
useLiveNodeOverrides.getState().clearAll()
|
|
66
100
|
useLiveTransforms.getState().clearAll()
|
|
101
|
+
// Clearing overrides can republish stair holes while a live transform still
|
|
102
|
+
// exists. Capture that final publication before clearing it too.
|
|
103
|
+
const remainingOverrides = useLiveNodeOverrides.getState().overrides
|
|
104
|
+
if (remainingOverrides.size > 0) {
|
|
105
|
+
for (const id of remainingOverrides.keys()) previewIds.add(id)
|
|
106
|
+
const remainingLayout = capturePreviewLayout()
|
|
107
|
+
if (remainingLayout) {
|
|
108
|
+
for (const id of getHistoryDirtyNodeIds(remainingLayout, target)) previewDirty.add(id)
|
|
109
|
+
}
|
|
110
|
+
useLiveNodeOverrides.getState().clearAll()
|
|
111
|
+
}
|
|
67
112
|
|
|
68
113
|
const state = useScene.getState()
|
|
69
|
-
for (const
|
|
114
|
+
for (const id of previewDirty) {
|
|
115
|
+
if (state.nodes[id]) state.markDirty(id)
|
|
116
|
+
}
|
|
117
|
+
for (const id of previewIds) {
|
|
118
|
+
const node = state.nodes[id as AnyNodeId]
|
|
119
|
+
if (!node) continue
|
|
70
120
|
state.markDirty(node.id)
|
|
121
|
+
if (node.parentId && state.nodes[node.parentId as AnyNodeId]) {
|
|
122
|
+
state.markDirty(node.parentId as AnyNodeId)
|
|
123
|
+
}
|
|
71
124
|
}
|
|
72
125
|
}
|
|
73
126
|
|
|
127
|
+
export function shouldCancelDraftOnHistoryJump(): boolean {
|
|
128
|
+
const scope = useInteractionScope.getState().scope
|
|
129
|
+
return registeredDraftingConfig(scope)?.cancelOnHistoryJump === true
|
|
130
|
+
}
|
|
131
|
+
|
|
74
132
|
export function runUndo(): HistoryCommandResult {
|
|
75
|
-
if (
|
|
133
|
+
if (shouldCancelDraftOnHistoryJump()) emitter.emit('tool:cancel')
|
|
134
|
+
if (historyCommandDelegate) {
|
|
135
|
+
const result = historyCommandDelegate.undo()
|
|
136
|
+
// Mark only real jumps: a no-op undo must not open a receipt (or
|
|
137
|
+
// interrupt one that is still settling).
|
|
138
|
+
if (result.kind !== 'empty') markPerfAction('undo')
|
|
139
|
+
return result
|
|
140
|
+
}
|
|
76
141
|
if (useScene.temporal.getState().pastStates.length === 0) return { kind: 'empty' }
|
|
142
|
+
markPerfAction('undo')
|
|
143
|
+
const previewLayout = capturePreviewLayout()
|
|
77
144
|
useScene.temporal.getState().undo()
|
|
78
|
-
refreshSceneAfterHistoryJump()
|
|
145
|
+
refreshSceneAfterHistoryJump(previewLayout)
|
|
79
146
|
return { kind: 'applied', persistence: 'local' }
|
|
80
147
|
}
|
|
81
148
|
|
|
82
149
|
export function runRedo(): HistoryCommandResult {
|
|
83
|
-
if (
|
|
150
|
+
if (shouldCancelDraftOnHistoryJump()) emitter.emit('tool:cancel')
|
|
151
|
+
if (historyCommandDelegate) {
|
|
152
|
+
const result = historyCommandDelegate.redo()
|
|
153
|
+
if (result.kind !== 'empty') markPerfAction('redo')
|
|
154
|
+
return result
|
|
155
|
+
}
|
|
84
156
|
if (useScene.temporal.getState().futureStates.length === 0) return { kind: 'empty' }
|
|
157
|
+
markPerfAction('redo')
|
|
158
|
+
const previewLayout = capturePreviewLayout()
|
|
85
159
|
useScene.temporal.getState().redo()
|
|
86
|
-
refreshSceneAfterHistoryJump()
|
|
160
|
+
refreshSceneAfterHistoryJump(previewLayout)
|
|
87
161
|
return { kind: 'applied', persistence: 'local' }
|
|
88
162
|
}
|
|
89
163
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
editorHostTreeChildrenRegistry,
|
|
4
|
+
registerEditorHostTreeChildren,
|
|
5
|
+
} from './host-tree-children'
|
|
6
|
+
|
|
7
|
+
describe('editorHostTreeChildrenRegistry', () => {
|
|
8
|
+
afterEach(() => editorHostTreeChildrenRegistry.reset())
|
|
9
|
+
|
|
10
|
+
test('exposes host children by scene node kind and notifies mounted trees', () => {
|
|
11
|
+
let notifications = 0
|
|
12
|
+
const unsubscribe = editorHostTreeChildrenRegistry.subscribe(() => {
|
|
13
|
+
notifications += 1
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
registerEditorHostTreeChildren({
|
|
17
|
+
kind: 'scan',
|
|
18
|
+
component: () => null,
|
|
19
|
+
hasChildren: (node) => node.type === 'scan',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
expect(editorHostTreeChildrenRegistry.childrenForKind('scan')).toBeDefined()
|
|
23
|
+
expect(editorHostTreeChildrenRegistry.childrenForKind('wall')).toBeUndefined()
|
|
24
|
+
expect(notifications).toBe(1)
|
|
25
|
+
unsubscribe()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
|
2
|
+
import type { ComponentType } from 'react'
|
|
3
|
+
|
|
4
|
+
export type EditorHostTreeChildrenProps = {
|
|
5
|
+
nodeId: AnyNodeId
|
|
6
|
+
depth: number
|
|
7
|
+
parentVisible: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type EditorHostTreeChildren = {
|
|
11
|
+
kind: string
|
|
12
|
+
component: ComponentType<EditorHostTreeChildrenProps>
|
|
13
|
+
hasChildren: (node: AnyNode) => boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isDevMode(): boolean {
|
|
17
|
+
try {
|
|
18
|
+
const meta = import.meta as { env?: { DEV?: boolean } }
|
|
19
|
+
if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV
|
|
20
|
+
} catch {
|
|
21
|
+
// import.meta unavailable in some CJS contexts — fall through.
|
|
22
|
+
}
|
|
23
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
|
|
24
|
+
return process.env.NODE_ENV !== 'production'
|
|
25
|
+
}
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class EditorHostTreeChildrenRegistryImpl {
|
|
30
|
+
private readonly entries = new Map<string, EditorHostTreeChildren>()
|
|
31
|
+
private readonly listeners = new Set<() => void>()
|
|
32
|
+
private revision = 0
|
|
33
|
+
|
|
34
|
+
subscribe = (onChange: () => void): (() => void) => {
|
|
35
|
+
this.listeners.add(onChange)
|
|
36
|
+
return () => {
|
|
37
|
+
this.listeners.delete(onChange)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getSnapshot = (): number => this.revision
|
|
42
|
+
|
|
43
|
+
childrenForKind = (kind: string): EditorHostTreeChildren | undefined => this.entries.get(kind)
|
|
44
|
+
|
|
45
|
+
reset(): void {
|
|
46
|
+
this.entries.clear()
|
|
47
|
+
this.emit()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
register(entry: EditorHostTreeChildren): void {
|
|
51
|
+
if (typeof entry.kind !== 'string' || entry.kind.length === 0) {
|
|
52
|
+
throw new Error('[editor:host-tree-children] kind must be a non-empty string')
|
|
53
|
+
}
|
|
54
|
+
if (this.entries.has(entry.kind)) {
|
|
55
|
+
if (isDevMode()) {
|
|
56
|
+
console.warn(
|
|
57
|
+
`[editor:host-tree-children] re-registering children for "${entry.kind}" (HMR)`,
|
|
58
|
+
)
|
|
59
|
+
} else {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`[editor:host-tree-children] duplicate kind: "${entry.kind}" already registered`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.entries.set(entry.kind, entry)
|
|
66
|
+
this.emit()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private emit(): void {
|
|
70
|
+
this.revision += 1
|
|
71
|
+
for (const listener of this.listeners) listener()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const editorHostTreeChildrenRegistry = new EditorHostTreeChildrenRegistryImpl()
|
|
76
|
+
|
|
77
|
+
export function registerEditorHostTreeChildren(entry: EditorHostTreeChildren): void {
|
|
78
|
+
editorHostTreeChildrenRegistry.register(entry)
|
|
79
|
+
}
|
|
@@ -22,6 +22,13 @@ const wall: HotSetCandidate = {
|
|
|
22
22
|
exposesTop: false,
|
|
23
23
|
attachClass: 'surface',
|
|
24
24
|
}
|
|
25
|
+
const block: HotSetCandidate = {
|
|
26
|
+
type: 'block',
|
|
27
|
+
isFloorLike: false,
|
|
28
|
+
exposesTop: true,
|
|
29
|
+
exposesSides: true,
|
|
30
|
+
attachClass: 'surface',
|
|
31
|
+
}
|
|
25
32
|
const ceiling: HotSetCandidate = {
|
|
26
33
|
type: 'ceiling',
|
|
27
34
|
isFloorLike: false,
|
|
@@ -63,8 +70,9 @@ describe('attachClassOf', () => {
|
|
|
63
70
|
})
|
|
64
71
|
|
|
65
72
|
describe('isPickableForAttach — wall-mounted (window)', () => {
|
|
66
|
-
test('
|
|
73
|
+
test('walls and block faces are eligible; floor/ceiling/tops are not', () => {
|
|
67
74
|
expect(isPickableForAttach('wall', wall)).toBe(true)
|
|
75
|
+
expect(isPickableForAttach('wall', block)).toBe(true)
|
|
68
76
|
expect(isPickableForAttach('wall', floor)).toBe(false)
|
|
69
77
|
expect(isPickableForAttach('wall', ceiling)).toBe(false)
|
|
70
78
|
expect(isPickableForAttach('wall', table)).toBe(false)
|
|
@@ -109,6 +117,7 @@ describe('isCandidateInHotSet — by scope', () => {
|
|
|
109
117
|
nodeType: 'item',
|
|
110
118
|
view: '3d' as const,
|
|
111
119
|
pressDrag: false,
|
|
120
|
+
driver: 'move-tool' as const,
|
|
112
121
|
}
|
|
113
122
|
expect(isCandidateInHotSet(scope, surfaceClass, floor)).toBe(true)
|
|
114
123
|
expect(isCandidateInHotSet(scope, surfaceClass, ceilingFan)).toBe(false)
|
|
@@ -30,6 +30,8 @@ export type HotSetCandidate = {
|
|
|
30
30
|
// The candidate exposes a usable top surface (registry
|
|
31
31
|
// `capabilities.surfaces.top`) — a table, a shelf, a slab.
|
|
32
32
|
exposesTop: boolean
|
|
33
|
+
// The candidate declares wall-like side faces through registry surfaces.
|
|
34
|
+
exposesSides?: boolean
|
|
33
35
|
// The candidate's own attach class. A ceiling fan is `ceiling`: it hangs from
|
|
34
36
|
// the ceiling and must never act as a host top (Track E).
|
|
35
37
|
attachClass: AttachClass
|
|
@@ -38,7 +40,7 @@ export type HotSetCandidate = {
|
|
|
38
40
|
// For a node whose attach class is `placed`, is `candidate` a valid
|
|
39
41
|
// host/surface to pick during placement or move?
|
|
40
42
|
export function isPickableForAttach(placed: AttachClass, candidate: HotSetCandidate): boolean {
|
|
41
|
-
if (placed === 'wall') return candidate.type === 'wall'
|
|
43
|
+
if (placed === 'wall') return candidate.type === 'wall' || candidate.exposesSides === true
|
|
42
44
|
if (placed === 'ceiling') return candidate.type === 'ceiling'
|
|
43
45
|
// Surface-resting: the floor, or any host that exposes a top surface — but
|
|
44
46
|
// never a ceiling-mounted host (a floor lamp must not land on a ceiling fan).
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import type { AnyNode } from '@pascal-app/core'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
resolveFloatingActionMenuVisibility,
|
|
5
|
+
resolveOverlayPolicy,
|
|
6
|
+
shouldShowEditingControls,
|
|
7
|
+
} from './overlay-policy'
|
|
4
8
|
import type { ActiveInteractionScope } from './scope'
|
|
5
9
|
|
|
6
10
|
const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
|
|
@@ -13,6 +17,7 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [
|
|
|
13
17
|
nodeType: 'item',
|
|
14
18
|
view: '3d',
|
|
15
19
|
pressDrag: false,
|
|
20
|
+
driver: 'move-tool',
|
|
16
21
|
},
|
|
17
22
|
{ kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
|
|
18
23
|
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
|
|
@@ -50,3 +55,30 @@ describe('resolveOverlayPolicy', () => {
|
|
|
50
55
|
}
|
|
51
56
|
})
|
|
52
57
|
})
|
|
58
|
+
|
|
59
|
+
describe('shouldShowEditingControls', () => {
|
|
60
|
+
test('hides controls that can mutate a read-only scene', () => {
|
|
61
|
+
expect(shouldShowEditingControls(false)).toBe(true)
|
|
62
|
+
expect(shouldShowEditingControls(true)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('resolveFloatingActionMenuVisibility', () => {
|
|
67
|
+
test('keeps the active measurement pill while hiding action buttons during a height drag', () => {
|
|
68
|
+
const visibility = resolveFloatingActionMenuVisibility(
|
|
69
|
+
{ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' },
|
|
70
|
+
true,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
expect(visibility).toEqual({ root: true, actions: false })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('hides the whole menu during interactions without an active measurement pill', () => {
|
|
77
|
+
const visibility = resolveFloatingActionMenuVisibility(
|
|
78
|
+
{ kind: 'handle-drag', nodeId: 'wall_1', handle: 'elevation' },
|
|
79
|
+
false,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
expect(visibility).toEqual({ root: false, actions: false })
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -57,3 +57,19 @@ const ACTIVE_POLICY: OverlayPolicy = {
|
|
|
57
57
|
export function resolveOverlayPolicy(scope: InteractionScope): OverlayPolicy {
|
|
58
58
|
return isActive(scope) ? ACTIVE_POLICY : IDLE_POLICY
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
export function resolveFloatingActionMenuVisibility(
|
|
62
|
+
scope: InteractionScope,
|
|
63
|
+
hasActiveMeasurementPill: boolean,
|
|
64
|
+
): { root: boolean; actions: boolean } {
|
|
65
|
+
const policy = resolveOverlayPolicy(scope)
|
|
66
|
+
const actions = policy.conflictingControls === 'shown'
|
|
67
|
+
return {
|
|
68
|
+
root: actions || (hasActiveMeasurementPill && policy.activeAffordances === 'shown'),
|
|
69
|
+
actions,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function shouldShowEditingControls(readOnly: boolean): boolean {
|
|
74
|
+
return !readOnly
|
|
75
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeDefinition,
|
|
4
|
+
type GridEvent,
|
|
5
|
+
nodeRegistry,
|
|
6
|
+
} from '@pascal-app/core'
|
|
7
|
+
import type { InteractionScope } from './scope'
|
|
8
|
+
|
|
9
|
+
type RegisteredDraftingConfig = NonNullable<AnyNodeDefinition['drafting']>
|
|
10
|
+
type SurfaceHit = NonNullable<GridEvent['surfaceHit']>
|
|
11
|
+
|
|
12
|
+
export const DRAFTING_SURFACE_EXTENSION_KEY = 'pascal:editor/drafting-surface'
|
|
13
|
+
|
|
14
|
+
export type DraftingSurfaceExtension = {
|
|
15
|
+
kind: SurfaceHit['kind']
|
|
16
|
+
raycast?: 'underside'
|
|
17
|
+
classifyFace?: (
|
|
18
|
+
node: AnyNode | undefined,
|
|
19
|
+
localNormal: readonly [number, number, number],
|
|
20
|
+
) => Pick<SurfaceHit, 'face' | 'side'> | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registeredDraftingConfig(scope: InteractionScope): RegisteredDraftingConfig | null {
|
|
24
|
+
if (scope.kind !== 'drafting') return null
|
|
25
|
+
return nodeRegistry.get(scope.tool)?.drafting ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function registeredDraftingSurface(
|
|
29
|
+
definition: AnyNodeDefinition,
|
|
30
|
+
): DraftingSurfaceExtension | null {
|
|
31
|
+
return (
|
|
32
|
+
(definition.extensions?.[DRAFTING_SURFACE_EXTENSION_KEY] as
|
|
33
|
+
| DraftingSurfaceExtension
|
|
34
|
+
| undefined) ?? null
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -35,11 +35,31 @@ export type InteractionScope =
|
|
|
35
35
|
nodeType: string
|
|
36
36
|
view: InteractionView
|
|
37
37
|
pressDrag: boolean
|
|
38
|
+
driver: 'move-tool' | 'registry-tool'
|
|
38
39
|
}
|
|
39
40
|
// Moving an existing node.
|
|
40
41
|
| { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView }
|
|
41
42
|
// Dragging a resize/translate/rotate handle of a selected node.
|
|
42
43
|
| { kind: 'handle-drag'; nodeId: string; handle: string }
|
|
44
|
+
// Editing the internal topology of one block. This scope remains
|
|
45
|
+
// active for the whole edit-mode session so scene selection and whole-node
|
|
46
|
+
// movement cannot claim the same pointer stream.
|
|
47
|
+
| {
|
|
48
|
+
kind: 'mesh-editing'
|
|
49
|
+
nodeId: string
|
|
50
|
+
phase: 'selecting' | 'operating'
|
|
51
|
+
operator?:
|
|
52
|
+
| 'translate'
|
|
53
|
+
| 'rotate'
|
|
54
|
+
| 'scale'
|
|
55
|
+
| 'extrude'
|
|
56
|
+
| 'inset'
|
|
57
|
+
| 'merge'
|
|
58
|
+
| 'dissolve'
|
|
59
|
+
| 'loop-cut'
|
|
60
|
+
| 'bevel'
|
|
61
|
+
| 'delete'
|
|
62
|
+
}
|
|
43
63
|
// Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
|
|
44
64
|
| { kind: 'drafting'; tool: string }
|
|
45
65
|
// Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set
|
|
@@ -88,6 +108,7 @@ export function scopeNodeId(scope: InteractionScope): string | null {
|
|
|
88
108
|
case 'placing':
|
|
89
109
|
case 'moving':
|
|
90
110
|
case 'handle-drag':
|
|
111
|
+
case 'mesh-editing':
|
|
91
112
|
case 'reshaping':
|
|
92
113
|
return scope.nodeId
|
|
93
114
|
default:
|
|
@@ -111,6 +132,16 @@ export function selectionEnabled(scope: InteractionScope): boolean {
|
|
|
111
132
|
return scope.kind === 'idle'
|
|
112
133
|
}
|
|
113
134
|
|
|
135
|
+
export function meshEditScope(
|
|
136
|
+
nodeId: string,
|
|
137
|
+
phase: 'selecting' | 'operating' = 'selecting',
|
|
138
|
+
operator?: Extract<InteractionScope, { kind: 'mesh-editing' }>['operator'],
|
|
139
|
+
): ActiveInteractionScope {
|
|
140
|
+
return operator
|
|
141
|
+
? { kind: 'mesh-editing', nodeId, phase, operator }
|
|
142
|
+
: { kind: 'mesh-editing', nodeId, phase }
|
|
143
|
+
}
|
|
144
|
+
|
|
114
145
|
// Derived views of the scope that mirror the legacy `useEditor` flags they
|
|
115
146
|
// replaced. Each returns null unless that exact interaction is active, so a
|
|
116
147
|
// stale payload is unrepresentable: the value is a pure function of the single
|