@pascal-app/editor 1.0.0-beta.4 → 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 +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- 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 +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -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 +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- 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 +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- 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 +295 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- 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 +204 -114
- 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 +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- 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 +447 -180
- 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 +63 -8
- 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-brush-cursor.tsx +2 -1
- 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 +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- 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 +12 -13
- 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 +23 -6
- 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 +78 -26
- 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/panel-wrapper.tsx +228 -12
- 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/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -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 +128 -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 +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- 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 +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- 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 +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -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/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -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.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -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 +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -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/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- 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 +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -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/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- 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/store/use-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
3
4
|
emitter,
|
|
4
5
|
type ItemNode,
|
|
5
6
|
nodeRegistry,
|
|
6
7
|
resolveSelectionProxyId,
|
|
8
|
+
useScene,
|
|
7
9
|
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import useEditor from '../store/use-editor'
|
|
12
|
+
import { emitDeleteSFX } from './sfx-bus'
|
|
8
13
|
|
|
9
14
|
export type SelectionModifierKeys = {
|
|
10
15
|
meta: boolean
|
|
11
16
|
ctrl: boolean
|
|
12
17
|
shift: boolean
|
|
18
|
+
/** Alt alone: select one session-group member without expanding. */
|
|
19
|
+
alt: boolean
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export type NodeSelectionTarget = {
|
|
@@ -18,6 +25,20 @@ export type NodeSelectionTarget = {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export function emitCanvasNodeSelection(node: AnyNode): void {
|
|
28
|
+
if (useEditor.getState().mode === 'delete') {
|
|
29
|
+
const scene = useScene.getState()
|
|
30
|
+
if (scene.readOnly) return
|
|
31
|
+
|
|
32
|
+
emitDeleteSFX(node.type)
|
|
33
|
+
scene.deleteNode(node.id as AnyNodeId)
|
|
34
|
+
if (node.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
|
35
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
36
|
+
if (useViewer.getState().hoveredId === node.id) {
|
|
37
|
+
useViewer.setState({ hoveredId: null })
|
|
38
|
+
}
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
emitter.emit('selection:canvas-node-click', node)
|
|
22
43
|
}
|
|
23
44
|
|
|
@@ -59,17 +80,19 @@ export function selectionModifiersFromEvent(
|
|
|
59
80
|
metaKey?: boolean
|
|
60
81
|
ctrlKey?: boolean
|
|
61
82
|
shiftKey?: boolean
|
|
83
|
+
altKey?: boolean
|
|
62
84
|
nativeEvent?: {
|
|
63
85
|
metaKey?: boolean
|
|
64
86
|
ctrlKey?: boolean
|
|
65
87
|
shiftKey?: boolean
|
|
88
|
+
altKey?: boolean
|
|
66
89
|
}
|
|
67
90
|
} | null,
|
|
68
91
|
fallback?: Partial<SelectionModifierKeys>,
|
|
69
92
|
): SelectionModifierKeys {
|
|
70
93
|
const fromEvent = (
|
|
71
94
|
key: keyof SelectionModifierKeys,
|
|
72
|
-
eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey',
|
|
95
|
+
eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey',
|
|
73
96
|
) => {
|
|
74
97
|
if (typeof event?.[eventKey] === 'boolean') return event[eventKey]
|
|
75
98
|
if (typeof event?.nativeEvent?.[eventKey] === 'boolean') return event.nativeEvent[eventKey]
|
|
@@ -80,6 +103,7 @@ export function selectionModifiersFromEvent(
|
|
|
80
103
|
meta: fromEvent('meta', 'metaKey'),
|
|
81
104
|
ctrl: fromEvent('ctrl', 'ctrlKey'),
|
|
82
105
|
shift: fromEvent('shift', 'shiftKey'),
|
|
106
|
+
alt: fromEvent('alt', 'altKey'),
|
|
83
107
|
}
|
|
84
108
|
}
|
|
85
109
|
|
|
@@ -88,11 +112,14 @@ export function resolveSelectedIdsForNodeClick({
|
|
|
88
112
|
currentSelectedIds,
|
|
89
113
|
modifierKeys,
|
|
90
114
|
nodeId,
|
|
115
|
+
expandIdsForNode,
|
|
91
116
|
}: {
|
|
92
117
|
baseSelectedIds?: readonly string[]
|
|
93
118
|
currentSelectedIds: readonly string[]
|
|
94
119
|
modifierKeys: SelectionModifierKeys
|
|
95
120
|
nodeId: string
|
|
121
|
+
/** Session-group expand on plain click (not on modifier/Alt). */
|
|
122
|
+
expandIdsForNode?: (nodeId: string) => string[] | null
|
|
96
123
|
}): string[] {
|
|
97
124
|
if (isSelectionModifierActive(modifierKeys)) {
|
|
98
125
|
const selectedIds = baseSelectedIds ?? currentSelectedIds
|
|
@@ -102,6 +129,12 @@ export function resolveSelectedIdsForNodeClick({
|
|
|
102
129
|
return [...selectedIds, nodeId]
|
|
103
130
|
}
|
|
104
131
|
|
|
132
|
+
if (modifierKeys.alt) {
|
|
133
|
+
return [nodeId]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const expanded = expandIdsForNode?.(nodeId)
|
|
137
|
+
if (expanded && expanded.length > 1) return expanded
|
|
105
138
|
return [nodeId]
|
|
106
139
|
}
|
|
107
140
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
canCreateSessionGroup,
|
|
4
|
+
createSessionGroup,
|
|
5
|
+
expandSessionGroupMembers,
|
|
6
|
+
liveSessionGroups,
|
|
7
|
+
nextSessionGroupId,
|
|
8
|
+
resetSessionGroupIdSerial,
|
|
9
|
+
type SessionSelectionGroup,
|
|
10
|
+
selectionIntersectsSessionGroup,
|
|
11
|
+
selectionMatchesSessionGroup,
|
|
12
|
+
ungroupSessionSelection,
|
|
13
|
+
} from './session-groups'
|
|
14
|
+
|
|
15
|
+
function group(id: string, memberIds: string[], label = id): SessionSelectionGroup {
|
|
16
|
+
return { id, memberIds, label }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('session-groups', () => {
|
|
20
|
+
test('createSessionGroup requires two live members', () => {
|
|
21
|
+
resetSessionGroupIdSerial()
|
|
22
|
+
expect(
|
|
23
|
+
createSessionGroup([], ['a'], { idFactory: () => 'g1', labelFactory: () => 'G1' }).created,
|
|
24
|
+
).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('create and expand and ungroup', () => {
|
|
28
|
+
resetSessionGroupIdSerial(0)
|
|
29
|
+
const { groups, created } = createSessionGroup([], ['a', 'b', 'c'], {
|
|
30
|
+
idFactory: () => 'g1',
|
|
31
|
+
labelFactory: () => 'Group 1',
|
|
32
|
+
})
|
|
33
|
+
expect(created?.memberIds).toEqual(['a', 'b', 'c'])
|
|
34
|
+
expect(expandSessionGroupMembers(groups, 'b')).toEqual(['b', 'a', 'c'])
|
|
35
|
+
expect(selectionMatchesSessionGroup(groups, ['a', 'b', 'c'])?.label).toBe('Group 1')
|
|
36
|
+
expect(canCreateSessionGroup(groups, ['a', 'b', 'c'])).toBe(false)
|
|
37
|
+
expect(canCreateSessionGroup(groups, ['a', 'b'])).toBe(true)
|
|
38
|
+
expect(ungroupSessionSelection(groups, ['a']).groups).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('liveSessionGroups hides groups that fall under two live members', () => {
|
|
42
|
+
resetSessionGroupIdSerial(0)
|
|
43
|
+
expect(nextSessionGroupId()).toBe('session-group-1')
|
|
44
|
+
expect(liveSessionGroups([group('g1', ['a', 'gone'])], new Set(['a']))).toEqual([])
|
|
45
|
+
expect(liveSessionGroups([group('g1', ['a', 'b', 'gone'])], new Set(['a', 'b']))).toEqual([
|
|
46
|
+
group('g1', ['a', 'b']),
|
|
47
|
+
])
|
|
48
|
+
expect(selectionIntersectsSessionGroup([group('g1', ['a', 'b'])], ['b'])).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a deleted member is excluded from reads but kept in membership for undo', () => {
|
|
52
|
+
const groups = [group('g1', ['a', 'b', 'c'], 'Suite')]
|
|
53
|
+
|
|
54
|
+
// `a` deleted: reads narrow to the survivors...
|
|
55
|
+
expect(expandSessionGroupMembers(groups, 'b', new Set(['b', 'c']))).toEqual(['b', 'c'])
|
|
56
|
+
// ...and `a` and `b` deleted drops the group below the floor, so it goes inert.
|
|
57
|
+
expect(expandSessionGroupMembers(groups, 'c', new Set(['c']))).toBeNull()
|
|
58
|
+
expect(selectionIntersectsSessionGroup(groups, ['c'], new Set(['c']))).toBe(false)
|
|
59
|
+
|
|
60
|
+
// Undo restores the nodes, and membership was never rewritten — so the whole
|
|
61
|
+
// group comes back. A destructive prune would have lost `a` (or the group).
|
|
62
|
+
expect(expandSessionGroupMembers(groups, 'a', new Set(['a', 'b', 'c']))).toEqual([
|
|
63
|
+
'a',
|
|
64
|
+
'b',
|
|
65
|
+
'c',
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('ungroup leaves inert groups alone so undo can revive them', () => {
|
|
70
|
+
const groups = [group('g1', ['a', 'b'], 'Pair')]
|
|
71
|
+
// Only `a` is live, so the group is invisible to the user — Ctrl+Shift+G on
|
|
72
|
+
// `a` must not dissolve what it cannot see.
|
|
73
|
+
const result = ungroupSessionSelection(groups, ['a'], new Set(['a']))
|
|
74
|
+
expect(result.dissolved).toEqual([])
|
|
75
|
+
expect(result.groups).toEqual(groups)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('grouping a member of an existing group dissolves that group whole', () => {
|
|
79
|
+
resetSessionGroupIdSerial(0)
|
|
80
|
+
const groups = [group('g1', ['a', 'b', 'c'], 'Suite')]
|
|
81
|
+
const { groups: next, created } = createSessionGroup(groups, ['c', 'd'], {
|
|
82
|
+
idFactory: () => 'g2',
|
|
83
|
+
labelFactory: () => 'Group 2',
|
|
84
|
+
})
|
|
85
|
+
expect(created?.memberIds).toEqual(['c', 'd'])
|
|
86
|
+
expect(next).toEqual([group('g2', ['c', 'd'], 'Group 2')])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for editor-only session selection groups.
|
|
3
|
+
* Not scene-graph nodes; not saved with the project.
|
|
4
|
+
* Ctrl/Cmd+G creates, Ctrl/Cmd+Shift+G dissolves; plain click expands.
|
|
5
|
+
*
|
|
6
|
+
* Stored membership is never rewritten to drop deleted nodes — `liveIds`
|
|
7
|
+
* filtering happens on every read instead. A destructive prune would make
|
|
8
|
+
* delete-then-undo lose the restored node's group, and would drop the group
|
|
9
|
+
* outright once it fell under the two-member floor.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type SessionSelectionGroup = {
|
|
13
|
+
id: string
|
|
14
|
+
memberIds: readonly string[]
|
|
15
|
+
label: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SessionGroupIdFactory = () => string
|
|
19
|
+
|
|
20
|
+
let sessionGroupSerial = 0
|
|
21
|
+
|
|
22
|
+
export function nextSessionGroupId(): string {
|
|
23
|
+
sessionGroupSerial += 1
|
|
24
|
+
return `session-group-${sessionGroupSerial}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function nextSessionGroupLabel(): string {
|
|
28
|
+
return `Group ${Math.max(1, sessionGroupSerial)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resetSessionGroupIdSerial(value = 0): void {
|
|
32
|
+
sessionGroupSerial = value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function uniquePreserveOrder(ids: readonly string[]): string[] {
|
|
36
|
+
const seen = new Set<string>()
|
|
37
|
+
const out: string[] = []
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
if (!id || seen.has(id)) continue
|
|
40
|
+
seen.add(id)
|
|
41
|
+
out.push(id)
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLive(id: string, liveIds?: ReadonlySet<string> | null): boolean {
|
|
47
|
+
if (!liveIds) return true
|
|
48
|
+
return liveIds.has(id)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function withLabel(
|
|
52
|
+
group: SessionSelectionGroup,
|
|
53
|
+
memberIds: readonly string[],
|
|
54
|
+
): SessionSelectionGroup {
|
|
55
|
+
return {
|
|
56
|
+
id: group.id,
|
|
57
|
+
label: group.label || 'Group',
|
|
58
|
+
memberIds,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function liveMembers(group: SessionSelectionGroup, liveIds?: ReadonlySet<string> | null): string[] {
|
|
63
|
+
return uniquePreserveOrder(group.memberIds.filter((id) => isLive(id, liveIds)))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read-time view: groups narrowed to their live members, dropping any that fall
|
|
68
|
+
* under two. A group below the floor is inert, not gone — deleting members never
|
|
69
|
+
* touches storage, so undo brings it back.
|
|
70
|
+
*/
|
|
71
|
+
export function liveSessionGroups(
|
|
72
|
+
groups: readonly SessionSelectionGroup[],
|
|
73
|
+
liveIds?: ReadonlySet<string> | null,
|
|
74
|
+
): SessionSelectionGroup[] {
|
|
75
|
+
const next: SessionSelectionGroup[] = []
|
|
76
|
+
for (const group of groups) {
|
|
77
|
+
const members = liveMembers(group, liveIds)
|
|
78
|
+
if (members.length < 2) continue
|
|
79
|
+
next.push(withLabel(group, members))
|
|
80
|
+
}
|
|
81
|
+
return next
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createSessionGroup(
|
|
85
|
+
groups: readonly SessionSelectionGroup[],
|
|
86
|
+
memberIds: readonly string[],
|
|
87
|
+
options?: {
|
|
88
|
+
liveIds?: ReadonlySet<string> | null
|
|
89
|
+
idFactory?: SessionGroupIdFactory
|
|
90
|
+
labelFactory?: () => string
|
|
91
|
+
},
|
|
92
|
+
): {
|
|
93
|
+
groups: SessionSelectionGroup[]
|
|
94
|
+
created: SessionSelectionGroup | null
|
|
95
|
+
alreadyGrouped: boolean
|
|
96
|
+
} {
|
|
97
|
+
const liveIds = options?.liveIds
|
|
98
|
+
const members = uniquePreserveOrder(memberIds.filter((id) => isLive(id, liveIds)))
|
|
99
|
+
if (members.length < 2) {
|
|
100
|
+
return { groups: [...groups], created: null, alreadyGrouped: false }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const memberSet = new Set(members)
|
|
104
|
+
const exact = liveSessionGroups(groups, liveIds).find(
|
|
105
|
+
(group) =>
|
|
106
|
+
group.memberIds.length === members.length && group.memberIds.every((id) => memberSet.has(id)),
|
|
107
|
+
)
|
|
108
|
+
if (exact) {
|
|
109
|
+
return { groups: [...groups], created: exact, alreadyGrouped: true }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Grouping a selection takes its members out of whatever group they were in,
|
|
113
|
+
// dissolving that group whole rather than leaving a remnant.
|
|
114
|
+
const kept = groups.filter((group) => !group.memberIds.some((id) => memberSet.has(id)))
|
|
115
|
+
const id = (options?.idFactory ?? nextSessionGroupId)()
|
|
116
|
+
const label =
|
|
117
|
+
options?.labelFactory?.() ??
|
|
118
|
+
(options?.idFactory ? `Group ${kept.length + 1}` : nextSessionGroupLabel())
|
|
119
|
+
const created: SessionSelectionGroup = { id, label, memberIds: members }
|
|
120
|
+
return { groups: [...kept, created], created, alreadyGrouped: false }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function ungroupSessionSelection(
|
|
124
|
+
groups: readonly SessionSelectionGroup[],
|
|
125
|
+
selectedIds: readonly string[],
|
|
126
|
+
liveIds?: ReadonlySet<string> | null,
|
|
127
|
+
): { groups: SessionSelectionGroup[]; dissolved: SessionSelectionGroup[] } {
|
|
128
|
+
const selected = new Set(selectedIds)
|
|
129
|
+
if (selected.size === 0) {
|
|
130
|
+
return { groups: [...groups], dissolved: [] }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const kept: SessionSelectionGroup[] = []
|
|
134
|
+
const dissolved: SessionSelectionGroup[] = []
|
|
135
|
+
for (const group of groups) {
|
|
136
|
+
// Only a live-viable group is visible to the user, so only that can be
|
|
137
|
+
// dissolved. An inert one (members deleted) is left alone for undo.
|
|
138
|
+
const live = liveMembers(group, liveIds)
|
|
139
|
+
if (live.length >= 2 && live.some((id) => selected.has(id))) {
|
|
140
|
+
dissolved.push(withLabel(group, live))
|
|
141
|
+
} else {
|
|
142
|
+
kept.push(group)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { groups: kept, dissolved }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function expandSessionGroupMembers(
|
|
149
|
+
groups: readonly SessionSelectionGroup[],
|
|
150
|
+
nodeId: string,
|
|
151
|
+
liveIds?: ReadonlySet<string> | null,
|
|
152
|
+
): string[] | null {
|
|
153
|
+
if (!nodeId) return null
|
|
154
|
+
for (const group of groups) {
|
|
155
|
+
if (!group.memberIds.includes(nodeId)) continue
|
|
156
|
+
const members = liveMembers(group, liveIds)
|
|
157
|
+
if (members.length < 2) return null
|
|
158
|
+
if (members[0] === nodeId) return members
|
|
159
|
+
return [nodeId, ...members.filter((id) => id !== nodeId)]
|
|
160
|
+
}
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function selectionMatchesSessionGroup(
|
|
165
|
+
groups: readonly SessionSelectionGroup[],
|
|
166
|
+
selectedIds: readonly string[],
|
|
167
|
+
liveIds?: ReadonlySet<string> | null,
|
|
168
|
+
): SessionSelectionGroup | null {
|
|
169
|
+
if (selectedIds.length < 2) return null
|
|
170
|
+
const selected = uniquePreserveOrder(selectedIds.filter((id) => isLive(id, liveIds)))
|
|
171
|
+
if (selected.length < 2) return null
|
|
172
|
+
const selectedSet = new Set(selected)
|
|
173
|
+
for (const group of liveSessionGroups(groups, liveIds)) {
|
|
174
|
+
if (group.memberIds.length !== selected.length) continue
|
|
175
|
+
if (group.memberIds.every((id) => selectedSet.has(id))) return group
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function selectionIntersectsSessionGroup(
|
|
181
|
+
groups: readonly SessionSelectionGroup[],
|
|
182
|
+
selectedIds: readonly string[],
|
|
183
|
+
liveIds?: ReadonlySet<string> | null,
|
|
184
|
+
): boolean {
|
|
185
|
+
if (selectedIds.length === 0) return false
|
|
186
|
+
const selected = new Set(selectedIds)
|
|
187
|
+
for (const group of liveSessionGroups(groups, liveIds)) {
|
|
188
|
+
if (group.memberIds.some((id) => selected.has(id))) return true
|
|
189
|
+
}
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function canCreateSessionGroup(
|
|
194
|
+
groups: readonly SessionSelectionGroup[],
|
|
195
|
+
selectedIds: readonly string[],
|
|
196
|
+
liveIds?: ReadonlySet<string> | null,
|
|
197
|
+
): boolean {
|
|
198
|
+
const live = uniquePreserveOrder(selectedIds.filter((id) => isLive(id, liveIds)))
|
|
199
|
+
if (live.length < 2) return false
|
|
200
|
+
return selectionMatchesSessionGroup(groups, live, liveIds) === null
|
|
201
|
+
}
|
package/src/lib/sfx-bus.ts
CHANGED
|
@@ -27,6 +27,7 @@ type SFXEvents = {
|
|
|
27
27
|
'sfx:menu-hover': undefined
|
|
28
28
|
'sfx:menu-click': undefined
|
|
29
29
|
'sfx:paint-apply': undefined
|
|
30
|
+
'sfx:success': undefined
|
|
30
31
|
'sfx:terrain-sculpt-start': TerrainVerb
|
|
31
32
|
'sfx:terrain-sculpt-stop': undefined
|
|
32
33
|
}
|
|
@@ -56,6 +57,7 @@ const handleSnapshotCapture = () => playSFX('snapshotCapture')
|
|
|
56
57
|
const handleMenuHover = () => playSFX('menuHover')
|
|
57
58
|
const handleMenuClick = () => playSFX('menuClick')
|
|
58
59
|
const handlePaintApply = () => playSFX('paintApply')
|
|
60
|
+
const handleSuccess = () => playSFX('success')
|
|
59
61
|
const TERRAIN_LOOP_BY_VERB = {
|
|
60
62
|
raise: 'terrainRaise',
|
|
61
63
|
lower: 'terrainLower',
|
|
@@ -86,6 +88,7 @@ export function initSFXBus() {
|
|
|
86
88
|
sfxEmitter.on('sfx:menu-hover', handleMenuHover)
|
|
87
89
|
sfxEmitter.on('sfx:menu-click', handleMenuClick)
|
|
88
90
|
sfxEmitter.on('sfx:paint-apply', handlePaintApply)
|
|
91
|
+
sfxEmitter.on('sfx:success', handleSuccess)
|
|
89
92
|
sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart)
|
|
90
93
|
sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
|
|
91
94
|
unsubscribeAudio = useAudio.subscribe(updateSFXVolumes)
|
|
@@ -106,6 +109,7 @@ export function disposeSFXBus() {
|
|
|
106
109
|
sfxEmitter.off('sfx:menu-hover', handleMenuHover)
|
|
107
110
|
sfxEmitter.off('sfx:menu-click', handleMenuClick)
|
|
108
111
|
sfxEmitter.off('sfx:paint-apply', handlePaintApply)
|
|
112
|
+
sfxEmitter.off('sfx:success', handleSuccess)
|
|
109
113
|
sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart)
|
|
110
114
|
sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
|
|
111
115
|
unsubscribeAudio?.()
|
package/src/lib/sfx-player.ts
CHANGED
|
@@ -112,6 +112,14 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
112
112
|
volumeRange: [0.85, 1.0],
|
|
113
113
|
minIntervalMs: 60,
|
|
114
114
|
},
|
|
115
|
+
// A three-second jingle for finishing something, not a click cue. No pitch or
|
|
116
|
+
// volume jitter — a fanfare that lands a semitone off reads as broken rather
|
|
117
|
+
// than as varied — and a gap longer than the sound itself, so two milestones
|
|
118
|
+
// that land together play once instead of phasing over each other.
|
|
119
|
+
success: {
|
|
120
|
+
src: '/audios/sfx/success.mp3',
|
|
121
|
+
minIntervalMs: 3_000,
|
|
122
|
+
},
|
|
115
123
|
} as const
|
|
116
124
|
|
|
117
125
|
export type SFXName = keyof typeof SFX
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
|
|
2
|
+
import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
|
|
3
3
|
import {
|
|
4
4
|
cycleSnappingModeIn,
|
|
5
5
|
DEFAULT_SNAPPING_MODE,
|
|
@@ -49,12 +49,24 @@ describe('resolveSnapFlags', () => {
|
|
|
49
49
|
})
|
|
50
50
|
|
|
51
51
|
describe('per-context snapping', () => {
|
|
52
|
-
it('items default to
|
|
53
|
-
expect(defaultSnappingModeFor('item')).toBe('
|
|
52
|
+
it('items default to magnetic alignment with no angle lock', () => {
|
|
53
|
+
expect(defaultSnappingModeFor('item')).toBe('lines')
|
|
54
54
|
expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
|
|
55
55
|
expect(snappingModesFor('item')).not.toContain('angles')
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
it('group moves use the magnetic item context by default', () => {
|
|
59
|
+
const context = snapContextOf({
|
|
60
|
+
scope: { kind: 'handle-drag', handle: GROUP_MOVE_DRAG_LABEL },
|
|
61
|
+
mode: 'select',
|
|
62
|
+
tool: null,
|
|
63
|
+
profileOf: () => undefined,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(context).toBe('item')
|
|
67
|
+
expect(resolveSnapFlags(defaultSnappingModeFor(context!)).magnetic).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
58
70
|
it('walls default to grid and expose the angle lock; polygons do NOT', () => {
|
|
59
71
|
expect(defaultSnappingModeFor('wall')).toBe('grid')
|
|
60
72
|
expect(defaultSnappingModeFor('polygon')).toBe('grid')
|
|
@@ -83,10 +95,17 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
83
95
|
ceiling: 'structural',
|
|
84
96
|
roof: 'structural',
|
|
85
97
|
zone: 'structural',
|
|
98
|
+
block: 'structural',
|
|
86
99
|
}
|
|
87
100
|
const profileOf = (t: string) => declared[t]
|
|
88
101
|
const profileOfNode = (id: string) =>
|
|
89
|
-
id === 'cabinet-module_1'
|
|
102
|
+
id === 'cabinet-module_1'
|
|
103
|
+
? declared.item
|
|
104
|
+
: id === 'wall_1'
|
|
105
|
+
? declared.wall
|
|
106
|
+
: id === 'block_1'
|
|
107
|
+
? declared['block']
|
|
108
|
+
: undefined
|
|
90
109
|
const ctx = (
|
|
91
110
|
scope: {
|
|
92
111
|
kind: string
|
|
@@ -95,6 +114,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
95
114
|
nodeId?: string
|
|
96
115
|
tool?: string
|
|
97
116
|
handle?: string
|
|
117
|
+
operator?: string
|
|
98
118
|
},
|
|
99
119
|
mode = 'select',
|
|
100
120
|
tool: string | null = null,
|
|
@@ -115,6 +135,14 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
115
135
|
).toBeNull()
|
|
116
136
|
})
|
|
117
137
|
|
|
138
|
+
it('gives mesh rotation the angle context and other edit operations polygon snapping', () => {
|
|
139
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1' })).toBe('polygon')
|
|
140
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'translate' })).toBe('polygon')
|
|
141
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'scale' })).toBe('polygon')
|
|
142
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'loop-cut' })).toBe('polygon')
|
|
143
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'rotate' })).toBe('wall')
|
|
144
|
+
})
|
|
145
|
+
|
|
118
146
|
it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => {
|
|
119
147
|
expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall')
|
|
120
148
|
expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon')
|
package/src/lib/snapping-mode.ts
CHANGED
|
@@ -4,10 +4,9 @@ import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-he
|
|
|
4
4
|
/**
|
|
5
5
|
* Snapping mode is a single global, user-cyclable control that maps onto the
|
|
6
6
|
* two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* does any snap math get suppressed.
|
|
7
|
+
* Each context chooses its own default. Item movement defaults to magnetic
|
|
8
|
+
* alignment so a picked-up group catches neighboring geometry; grid and off
|
|
9
|
+
* remain explicit alternatives in the contextual chip.
|
|
11
10
|
*/
|
|
12
11
|
export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off'
|
|
13
12
|
|
|
@@ -82,9 +81,9 @@ type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
|
|
|
82
81
|
const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
|
|
83
82
|
// Wall / fence drafting + endpoint reshape: direction matters → angle lock.
|
|
84
83
|
wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
|
|
85
|
-
// Item placement / move:
|
|
86
|
-
//
|
|
87
|
-
item: { modes: ['lines', 'grid', 'off'], default: '
|
|
84
|
+
// Item placement / move: magnetic alignment by default; grid is an explicit
|
|
85
|
+
// alternative, and angle lock is meaningless for a footprint.
|
|
86
|
+
item: { modes: ['lines', 'grid', 'off'], default: 'lines' },
|
|
88
87
|
// Structural / surface, no direction to set: slab / ceiling / roof draft+move,
|
|
89
88
|
// whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
|
|
90
89
|
// default, NO angle lock.
|
|
@@ -148,6 +147,7 @@ export function snapContextOf(args: {
|
|
|
148
147
|
nodeId?: string
|
|
149
148
|
tool?: string
|
|
150
149
|
handle?: string
|
|
150
|
+
operator?: string
|
|
151
151
|
}
|
|
152
152
|
mode: string
|
|
153
153
|
tool: string | null
|
|
@@ -166,6 +166,10 @@ export function snapContextOf(args: {
|
|
|
166
166
|
return 'item'
|
|
167
167
|
}
|
|
168
168
|
switch (scope.kind) {
|
|
169
|
+
case 'mesh-editing':
|
|
170
|
+
return scope.nodeId
|
|
171
|
+
? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate')
|
|
172
|
+
: null
|
|
169
173
|
case 'handle-drag':
|
|
170
174
|
if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null
|
|
171
175
|
return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
const native = globalThis.structuredClone
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
globalThis.structuredClone = native
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
async function installFallback() {
|
|
10
|
+
// Fresh module instance each time — the shim is a module side effect.
|
|
11
|
+
await import(`./structured-clone-fallback?${Math.random()}`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('structuredClone fallback', () => {
|
|
15
|
+
test('leaves a native implementation alone', async () => {
|
|
16
|
+
await installFallback()
|
|
17
|
+
expect(globalThis.structuredClone).toBe(native)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('installs a JSON-based clone when the global is missing', async () => {
|
|
21
|
+
// @ts-expect-error — simulating a browser without the global.
|
|
22
|
+
globalThis.structuredClone = undefined
|
|
23
|
+
await installFallback()
|
|
24
|
+
|
|
25
|
+
const source = { units: [{ factor: 1, id: 'm' }], kind: 'length' }
|
|
26
|
+
const copy = structuredClone(source)
|
|
27
|
+
|
|
28
|
+
expect(copy).toEqual(source)
|
|
29
|
+
expect(copy).not.toBe(source)
|
|
30
|
+
expect(copy.units).not.toBe(source.units)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('lets lingo build its registry without the global', async () => {
|
|
34
|
+
// @ts-expect-error — simulating a browser without the global.
|
|
35
|
+
globalThis.structuredClone = undefined
|
|
36
|
+
await installFallback()
|
|
37
|
+
|
|
38
|
+
// The actual regression: lingo clones its kind table at module-eval time,
|
|
39
|
+
// so this import throws on a browser lacking structuredClone.
|
|
40
|
+
const { parseQuantity } = await import('@pascal-app/lingo')
|
|
41
|
+
const result = parseQuantity('180cm', { kind: 'length', unit: 'm' })
|
|
42
|
+
expect(result.ok).toBe(true)
|
|
43
|
+
if (result.ok) expect(result.quantity.to('m').value).toBeCloseTo(1.8, 6)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@pascal-app/lingo` builds its unit registry at module-eval time, and
|
|
3
|
+
* `registerKind` deep-copies each kind definition with `structuredClone`. On a
|
|
4
|
+
* browser without that global (Chromium <98 — reported from Honor Browser 9.8
|
|
5
|
+
* as `ReferenceError: structuredClone is not defined`) the throw happens while
|
|
6
|
+
* the module graph is still evaluating, so a component-level guard can never
|
|
7
|
+
* run in time and the whole editor bundle fails to load.
|
|
8
|
+
*
|
|
9
|
+
* This has to be imported for its side effect from the module that pulls lingo
|
|
10
|
+
* in, so it is installed wherever `@pascal-app/editor` is loaded — the OSS app,
|
|
11
|
+
* the hosted app, and any npm consumer. An app-level polyfill would only cover
|
|
12
|
+
* the app that declares it.
|
|
13
|
+
*
|
|
14
|
+
* Scoped deliberately narrowly: the kind definitions lingo clones are plain
|
|
15
|
+
* JSON (strings, numbers, arrays of those), so a JSON round-trip is sufficient
|
|
16
|
+
* and avoids a dependency. This is NOT a spec-compliant `structuredClone` — it
|
|
17
|
+
* has no support for Map/Set/Date/ArrayBuffer/cycles and throws a `TypeError`
|
|
18
|
+
* rather than a `DataCloneError`. Anything needing real structured-clone
|
|
19
|
+
* semantics must not rely on this shim.
|
|
20
|
+
*/
|
|
21
|
+
if (typeof globalThis.structuredClone !== 'function') {
|
|
22
|
+
globalThis.structuredClone = (<T>(value: T): T =>
|
|
23
|
+
JSON.parse(JSON.stringify(value)) as T) as typeof structuredClone
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
2
|
+
import type { Object3D } from 'three'
|
|
3
|
+
import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
|
|
4
|
+
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
|
|
5
|
+
import { type GlbExportOptions, preparePortableSceneFromViewer } from './glb-export'
|
|
6
|
+
import { createUsdzScene, disposeExportResources } from './portable-export'
|
|
7
|
+
|
|
8
|
+
export type UsdzExportOptions = Pick<
|
|
9
|
+
GlbExportOptions,
|
|
10
|
+
'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning'
|
|
11
|
+
>
|
|
12
|
+
|
|
13
|
+
/** Export a native, self-contained USDZ with no glTF conversion fallback. */
|
|
14
|
+
export async function exportSceneToUsdz(
|
|
15
|
+
sceneGroup: Object3D,
|
|
16
|
+
nodes: Record<string, AnyNode>,
|
|
17
|
+
options: UsdzExportOptions = {},
|
|
18
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
19
|
+
const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, {
|
|
20
|
+
...options,
|
|
21
|
+
textures: 'embed',
|
|
22
|
+
})
|
|
23
|
+
for (const warning of prepared.warnings) options.onWarning?.(warning)
|
|
24
|
+
|
|
25
|
+
let scene: Object3D | null = null
|
|
26
|
+
try {
|
|
27
|
+
scene = createUsdzScene(prepared.scene)
|
|
28
|
+
const exporter = new USDZExporter()
|
|
29
|
+
exporter.textureUtils = WebGPUTextureUtils
|
|
30
|
+
return await exporter.parseAsync(scene, {
|
|
31
|
+
onlyVisible: options.onlyVisible ?? true,
|
|
32
|
+
quickLookCompatible: true,
|
|
33
|
+
})
|
|
34
|
+
} finally {
|
|
35
|
+
if (scene) disposeExportResources(scene, { textures: false })
|
|
36
|
+
prepared.dispose()
|
|
37
|
+
}
|
|
38
|
+
}
|