@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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type DoorNode,
|
|
4
|
+
getWallThickness,
|
|
5
|
+
type WallNode,
|
|
6
|
+
type WindowNode,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
import * as THREE from 'three'
|
|
9
|
+
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
10
|
+
|
|
11
|
+
// Manufacturing geometry belongs to the editor layer, not the read-only viewer runtime.
|
|
12
|
+
const DIMENSION_EPSILON = 1e-7
|
|
13
|
+
const SOLID_JOIN_OVERLAP = 1e-5
|
|
14
|
+
|
|
15
|
+
type PrintWallOpening = DoorNode | WindowNode
|
|
16
|
+
|
|
17
|
+
type OpeningInterval = {
|
|
18
|
+
node: PrintWallOpening
|
|
19
|
+
left: number
|
|
20
|
+
right: number
|
|
21
|
+
bottom: number
|
|
22
|
+
top: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PrintWallSolidDiagnostic = {
|
|
26
|
+
severity: 'error'
|
|
27
|
+
code:
|
|
28
|
+
| 'invalid_wall_print_dimensions'
|
|
29
|
+
| 'unsupported_wall_print_curve'
|
|
30
|
+
| 'unsupported_wall_print_terrain'
|
|
31
|
+
| 'unsupported_wall_print_opening_shape'
|
|
32
|
+
| 'invalid_wall_print_opening'
|
|
33
|
+
| 'unresolved_wall_print_child'
|
|
34
|
+
message: string
|
|
35
|
+
nodeIds: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PrintWallSolidOptions = {
|
|
39
|
+
effectiveHeight: number
|
|
40
|
+
includedNodeIds?: ReadonlySet<string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type PrintWallSolidResult =
|
|
44
|
+
| { status: 'ready'; object: THREE.Group; diagnostics: [] }
|
|
45
|
+
| { status: 'blocked'; object: null; diagnostics: PrintWallSolidDiagnostic[] }
|
|
46
|
+
|
|
47
|
+
function finite(values: number[]): boolean {
|
|
48
|
+
return values.every(Number.isFinite)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function openingInterval(
|
|
52
|
+
wall: WallNode,
|
|
53
|
+
opening: PrintWallOpening,
|
|
54
|
+
length: number,
|
|
55
|
+
height: number,
|
|
56
|
+
): { interval: OpeningInterval | null; diagnostic: PrintWallSolidDiagnostic | null } {
|
|
57
|
+
if (opening.wallId && opening.wallId !== wall.id) {
|
|
58
|
+
return {
|
|
59
|
+
interval: null,
|
|
60
|
+
diagnostic: {
|
|
61
|
+
severity: 'error',
|
|
62
|
+
code: 'invalid_wall_print_opening',
|
|
63
|
+
message: `Opening ${opening.id} is listed by wall ${wall.id} but references ${opening.wallId}.`,
|
|
64
|
+
nodeIds: [wall.id, opening.id].sort(),
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (opening.openingShape !== 'rectangle') {
|
|
69
|
+
return {
|
|
70
|
+
interval: null,
|
|
71
|
+
diagnostic: {
|
|
72
|
+
severity: 'error',
|
|
73
|
+
code: 'unsupported_wall_print_opening_shape',
|
|
74
|
+
message: `Opening ${opening.id} uses a ${opening.openingShape} profile that does not yet have a printable wall fixture.`,
|
|
75
|
+
nodeIds: [wall.id, opening.id].sort(),
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [centerX, centerY] = opening.position
|
|
81
|
+
const { width, height: openingHeight } = opening
|
|
82
|
+
if (
|
|
83
|
+
!finite([centerX, centerY, width, openingHeight]) ||
|
|
84
|
+
width <= DIMENSION_EPSILON ||
|
|
85
|
+
openingHeight <= DIMENSION_EPSILON
|
|
86
|
+
) {
|
|
87
|
+
return {
|
|
88
|
+
interval: null,
|
|
89
|
+
diagnostic: {
|
|
90
|
+
severity: 'error',
|
|
91
|
+
code: 'invalid_wall_print_opening',
|
|
92
|
+
message: `Opening ${opening.id} has invalid printable dimensions.`,
|
|
93
|
+
nodeIds: [wall.id, opening.id].sort(),
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const interval = {
|
|
99
|
+
node: opening,
|
|
100
|
+
left: centerX - width / 2,
|
|
101
|
+
right: centerX + width / 2,
|
|
102
|
+
bottom: centerY - openingHeight / 2,
|
|
103
|
+
top: centerY + openingHeight / 2,
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
interval.left < -DIMENSION_EPSILON ||
|
|
107
|
+
interval.right > length + DIMENSION_EPSILON ||
|
|
108
|
+
interval.bottom < -DIMENSION_EPSILON ||
|
|
109
|
+
interval.top > height + DIMENSION_EPSILON ||
|
|
110
|
+
interval.left >= interval.right - DIMENSION_EPSILON ||
|
|
111
|
+
interval.bottom >= interval.top - DIMENSION_EPSILON
|
|
112
|
+
) {
|
|
113
|
+
return {
|
|
114
|
+
interval: null,
|
|
115
|
+
diagnostic: {
|
|
116
|
+
severity: 'error',
|
|
117
|
+
code: 'invalid_wall_print_opening',
|
|
118
|
+
message: `Opening ${opening.id} extends outside printable wall ${wall.id}.`,
|
|
119
|
+
nodeIds: [wall.id, opening.id].sort(),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interval.left = THREE.MathUtils.clamp(interval.left, 0, length)
|
|
125
|
+
interval.right = THREE.MathUtils.clamp(interval.right, 0, length)
|
|
126
|
+
interval.bottom = THREE.MathUtils.clamp(interval.bottom, 0, height)
|
|
127
|
+
interval.top = THREE.MathUtils.clamp(interval.top, 0, height)
|
|
128
|
+
return { interval, diagnostic: null }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function collectOpenings(
|
|
132
|
+
wall: WallNode,
|
|
133
|
+
nodes: Record<string, AnyNode> | undefined,
|
|
134
|
+
options: PrintWallSolidOptions,
|
|
135
|
+
length: number,
|
|
136
|
+
): { openings: OpeningInterval[]; diagnostics: PrintWallSolidDiagnostic[] } {
|
|
137
|
+
const openings: OpeningInterval[] = []
|
|
138
|
+
const diagnostics: PrintWallSolidDiagnostic[] = []
|
|
139
|
+
|
|
140
|
+
for (const childId of wall.children) {
|
|
141
|
+
const child = nodes?.[childId]
|
|
142
|
+
if (!child) {
|
|
143
|
+
diagnostics.push({
|
|
144
|
+
severity: 'error',
|
|
145
|
+
code: 'unresolved_wall_print_child',
|
|
146
|
+
message: `Wall ${wall.id} references unresolved child ${childId}.`,
|
|
147
|
+
nodeIds: [wall.id, childId].sort(),
|
|
148
|
+
})
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (options.includedNodeIds && !options.includedNodeIds.has(child.id)) continue
|
|
152
|
+
if (child.type !== 'door' && child.type !== 'window') continue
|
|
153
|
+
|
|
154
|
+
const result = openingInterval(wall, child, length, options.effectiveHeight)
|
|
155
|
+
if (result.diagnostic) diagnostics.push(result.diagnostic)
|
|
156
|
+
if (result.interval) openings.push(result.interval)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { openings, diagnostics }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function uniqueBreakpoints(values: number[]): number[] {
|
|
163
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
164
|
+
const result: number[] = []
|
|
165
|
+
for (const value of sorted) {
|
|
166
|
+
if (result.length === 0 || value - result[result.length - 1]! > DIMENSION_EPSILON) {
|
|
167
|
+
result.push(value)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function mergedVerticalCuts(openings: OpeningInterval[], x: number): [number, number][] {
|
|
174
|
+
const intervals = openings
|
|
175
|
+
.filter((opening) => opening.left < x && opening.right > x)
|
|
176
|
+
.map((opening) => [opening.bottom, opening.top] as [number, number])
|
|
177
|
+
.sort((a, b) => a[0] - b[0])
|
|
178
|
+
const merged: [number, number][] = []
|
|
179
|
+
|
|
180
|
+
for (const interval of intervals) {
|
|
181
|
+
const previous = merged[merged.length - 1]
|
|
182
|
+
if (!previous || interval[0] > previous[1] + DIMENSION_EPSILON) {
|
|
183
|
+
merged.push([...interval])
|
|
184
|
+
} else {
|
|
185
|
+
previous[1] = Math.max(previous[1], interval[1])
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return merged
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function addSolid(
|
|
192
|
+
root: THREE.Group,
|
|
193
|
+
wallId: string,
|
|
194
|
+
index: number,
|
|
195
|
+
left: number,
|
|
196
|
+
right: number,
|
|
197
|
+
bottom: number,
|
|
198
|
+
top: number,
|
|
199
|
+
thickness: number,
|
|
200
|
+
wallLength: number,
|
|
201
|
+
) {
|
|
202
|
+
if (right - left <= DIMENSION_EPSILON || top - bottom <= DIMENSION_EPSILON) return
|
|
203
|
+
const joinedLeft = Math.max(0, left - (left > DIMENSION_EPSILON ? SOLID_JOIN_OVERLAP : 0))
|
|
204
|
+
const joinedRight = Math.min(
|
|
205
|
+
wallLength,
|
|
206
|
+
right + (right < wallLength - DIMENSION_EPSILON ? SOLID_JOIN_OVERLAP : 0),
|
|
207
|
+
)
|
|
208
|
+
const box = new THREE.BoxGeometry(joinedRight - joinedLeft, top - bottom, thickness)
|
|
209
|
+
box.deleteAttribute('normal')
|
|
210
|
+
box.deleteAttribute('uv')
|
|
211
|
+
const geometry = mergeVertices(box, DIMENSION_EPSILON)
|
|
212
|
+
box.dispose()
|
|
213
|
+
geometry.computeVertexNormals()
|
|
214
|
+
const mesh = new THREE.Mesh(geometry)
|
|
215
|
+
mesh.name = `print-wall-solid-${index}`
|
|
216
|
+
mesh.position.set((joinedLeft + joinedRight) / 2, (bottom + top) / 2, 0)
|
|
217
|
+
mesh.userData = { pascalId: wallId }
|
|
218
|
+
root.add(mesh)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function buildPrintableWallSolids(
|
|
222
|
+
node: WallNode,
|
|
223
|
+
options: PrintWallSolidOptions,
|
|
224
|
+
nodes?: Record<string, AnyNode>,
|
|
225
|
+
): PrintWallSolidResult {
|
|
226
|
+
const dx = node.end[0] - node.start[0]
|
|
227
|
+
const dz = node.end[1] - node.start[1]
|
|
228
|
+
const length = Math.hypot(dx, dz)
|
|
229
|
+
const thickness = getWallThickness(node)
|
|
230
|
+
const diagnostics: PrintWallSolidDiagnostic[] = []
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
!finite([
|
|
234
|
+
node.start[0],
|
|
235
|
+
node.start[1],
|
|
236
|
+
node.end[0],
|
|
237
|
+
node.end[1],
|
|
238
|
+
length,
|
|
239
|
+
thickness,
|
|
240
|
+
options.effectiveHeight,
|
|
241
|
+
]) ||
|
|
242
|
+
length <= DIMENSION_EPSILON ||
|
|
243
|
+
thickness <= DIMENSION_EPSILON ||
|
|
244
|
+
options.effectiveHeight <= DIMENSION_EPSILON
|
|
245
|
+
) {
|
|
246
|
+
diagnostics.push({
|
|
247
|
+
severity: 'error',
|
|
248
|
+
code: 'invalid_wall_print_dimensions',
|
|
249
|
+
message: `Wall ${node.id} has invalid printable length, thickness, or height.`,
|
|
250
|
+
nodeIds: [node.id],
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
if (Math.abs(node.curveOffset ?? 0) > DIMENSION_EPSILON) {
|
|
254
|
+
diagnostics.push({
|
|
255
|
+
severity: 'error',
|
|
256
|
+
code: 'unsupported_wall_print_curve',
|
|
257
|
+
message: `Curved wall ${node.id} does not yet have a canonical printable solid.`,
|
|
258
|
+
nodeIds: [node.id],
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
if (node.fillToTerrain) {
|
|
262
|
+
diagnostics.push({
|
|
263
|
+
severity: 'error',
|
|
264
|
+
code: 'unsupported_wall_print_terrain',
|
|
265
|
+
message: `Terrain-filled wall ${node.id} requires a terrain-aware printable base fixture.`,
|
|
266
|
+
nodeIds: [node.id],
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics }
|
|
270
|
+
|
|
271
|
+
const collected = collectOpenings(node, nodes, options, length)
|
|
272
|
+
diagnostics.push(...collected.diagnostics)
|
|
273
|
+
if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics }
|
|
274
|
+
|
|
275
|
+
const root = new THREE.Group()
|
|
276
|
+
root.name = 'print-wall-solids'
|
|
277
|
+
root.userData = { pascalId: node.id }
|
|
278
|
+
root.position.set(node.start[0], 0, node.start[1])
|
|
279
|
+
root.rotation.y = -Math.atan2(dz, dx)
|
|
280
|
+
|
|
281
|
+
const breakpoints = uniqueBreakpoints([
|
|
282
|
+
0,
|
|
283
|
+
length,
|
|
284
|
+
...collected.openings.flatMap((opening) => [opening.left, opening.right]),
|
|
285
|
+
])
|
|
286
|
+
let solidIndex = 0
|
|
287
|
+
for (let index = 0; index < breakpoints.length - 1; index += 1) {
|
|
288
|
+
const left = breakpoints[index]!
|
|
289
|
+
const right = breakpoints[index + 1]!
|
|
290
|
+
if (right - left <= DIMENSION_EPSILON) continue
|
|
291
|
+
const cuts = mergedVerticalCuts(collected.openings, (left + right) / 2)
|
|
292
|
+
let bottom = 0
|
|
293
|
+
for (const [cutBottom, cutTop] of cuts) {
|
|
294
|
+
addSolid(root, node.id, solidIndex, left, right, bottom, cutBottom, thickness, length)
|
|
295
|
+
if (cutBottom - bottom > DIMENSION_EPSILON) solidIndex += 1
|
|
296
|
+
bottom = Math.max(bottom, cutTop)
|
|
297
|
+
}
|
|
298
|
+
addSolid(
|
|
299
|
+
root,
|
|
300
|
+
node.id,
|
|
301
|
+
solidIndex,
|
|
302
|
+
left,
|
|
303
|
+
right,
|
|
304
|
+
bottom,
|
|
305
|
+
options.effectiveHeight,
|
|
306
|
+
thickness,
|
|
307
|
+
length,
|
|
308
|
+
)
|
|
309
|
+
if (options.effectiveHeight - bottom > DIMENSION_EPSILON) solidIndex += 1
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (root.children.length === 0) {
|
|
313
|
+
return {
|
|
314
|
+
status: 'blocked',
|
|
315
|
+
object: null,
|
|
316
|
+
diagnostics: [
|
|
317
|
+
{
|
|
318
|
+
severity: 'error',
|
|
319
|
+
code: 'invalid_wall_print_opening',
|
|
320
|
+
message: `Openings remove all printable material from wall ${node.id}.`,
|
|
321
|
+
nodeIds: [node.id, ...collected.openings.map((opening) => opening.node.id)].sort(),
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { status: 'ready', object: root, diagnostics: [] }
|
|
328
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { resolveAttachmentPreviewRotation, rigidPlanSvgTransform } from './rigid-plan-svg-transform'
|
|
3
|
+
|
|
4
|
+
describe('resolveAttachmentPreviewRotation', () => {
|
|
5
|
+
test('uses wall yaw only while attached and restores the free yaw after detaching', () => {
|
|
6
|
+
const freeRotation = Math.PI / 4
|
|
7
|
+
|
|
8
|
+
expect(resolveAttachmentPreviewRotation(freeRotation, 0)).toBe(0)
|
|
9
|
+
expect(resolveAttachmentPreviewRotation(freeRotation, null)).toBe(freeRotation)
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('rigidPlanSvgTransform', () => {
|
|
14
|
+
test('preserves the existing translation-only preview when yaw is unchanged', () => {
|
|
15
|
+
expect(
|
|
16
|
+
rigidPlanSvgTransform({
|
|
17
|
+
from: [1, 2],
|
|
18
|
+
fromRotation: 0,
|
|
19
|
+
to: [1.5, 3],
|
|
20
|
+
toRotation: 0,
|
|
21
|
+
}),
|
|
22
|
+
).toBe('translate(0.5 1)')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('rotates the plan entry around the moved node origin while translating it', () => {
|
|
26
|
+
expect(
|
|
27
|
+
rigidPlanSvgTransform({
|
|
28
|
+
from: [1, 2],
|
|
29
|
+
fromRotation: Math.PI / 2,
|
|
30
|
+
to: [3, 4],
|
|
31
|
+
toRotation: 0,
|
|
32
|
+
}),
|
|
33
|
+
).toBe('translate(3 4) rotate(90) translate(-1 -2)')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function resolveAttachmentPreviewRotation(
|
|
2
|
+
freeRotation: number,
|
|
3
|
+
attachmentRotation: number | null,
|
|
4
|
+
): number {
|
|
5
|
+
return attachmentRotation ?? freeRotation
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function rigidPlanSvgTransform({
|
|
9
|
+
from,
|
|
10
|
+
fromRotation,
|
|
11
|
+
to,
|
|
12
|
+
toRotation,
|
|
13
|
+
}: {
|
|
14
|
+
from: readonly [number, number]
|
|
15
|
+
fromRotation: number
|
|
16
|
+
to: readonly [number, number]
|
|
17
|
+
toRotation: number
|
|
18
|
+
}): string {
|
|
19
|
+
const rotationDegrees = (-(toRotation - fromRotation) * 180) / Math.PI
|
|
20
|
+
if (Math.abs(rotationDegrees) < 1e-10) {
|
|
21
|
+
return `translate(${to[0] - from[0]} ${to[1] - from[1]})`
|
|
22
|
+
}
|
|
23
|
+
return `translate(${to[0]} ${to[1]}) rotate(${rotationDegrees}) translate(${-from[0]} ${-from[1]})`
|
|
24
|
+
}
|
package/src/lib/scene.ts
CHANGED
|
@@ -155,11 +155,18 @@ function getEditorUiStateForRestoredSelection(
|
|
|
155
155
|
fallbackUiState: PersistedEditorUiState,
|
|
156
156
|
): PersistedEditorUiState {
|
|
157
157
|
if (!selection.levelId) {
|
|
158
|
+
const mode = fallbackUiState.phase === 'site' ? fallbackUiState.mode : 'select'
|
|
158
159
|
return {
|
|
159
160
|
...fallbackUiState,
|
|
160
161
|
phase: 'site',
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
toolMode:
|
|
163
|
+
mode === 'build'
|
|
164
|
+
? { mode, tool: 'property-line' }
|
|
165
|
+
: mode === 'terrain-sculpt'
|
|
166
|
+
? { mode }
|
|
167
|
+
: { mode: 'select' },
|
|
168
|
+
mode,
|
|
169
|
+
tool: mode === 'build' ? 'property-line' : null,
|
|
163
170
|
structureLayer: 'elements',
|
|
164
171
|
catalogCategory: null,
|
|
165
172
|
}
|
|
@@ -169,6 +176,7 @@ function getEditorUiStateForRestoredSelection(
|
|
|
169
176
|
return {
|
|
170
177
|
...fallbackUiState,
|
|
171
178
|
phase: 'structure',
|
|
179
|
+
toolMode: { mode: 'select' },
|
|
172
180
|
mode: 'select',
|
|
173
181
|
tool: null,
|
|
174
182
|
structureLayer: 'zones',
|
|
@@ -192,6 +200,7 @@ function getEditorUiStateForRestoredSelection(
|
|
|
192
200
|
return {
|
|
193
201
|
...fallbackUiState,
|
|
194
202
|
phase: shouldRestoreFurnishPhase ? 'furnish' : 'structure',
|
|
203
|
+
toolMode: { mode: 'select' },
|
|
195
204
|
mode: 'select',
|
|
196
205
|
tool: null,
|
|
197
206
|
structureLayer: 'elements',
|
|
@@ -304,14 +313,14 @@ export function syncEditorSelectionFromCurrentScene() {
|
|
|
304
313
|
// SelectionPath expects branded ids. The runtime values match the
|
|
305
314
|
// brand; the cast bridges the static gap.
|
|
306
315
|
useViewer.getState().setSelection(restoredSelection as never)
|
|
307
|
-
|
|
316
|
+
restoreEditorUiState(
|
|
308
317
|
restoredEditorUiState.phase === 'site'
|
|
309
318
|
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
|
|
310
319
|
: restoredEditorUiState,
|
|
311
320
|
)
|
|
312
321
|
} else if (restoredEditorUiState.phase === 'site') {
|
|
313
322
|
useViewer.getState().resetSelection()
|
|
314
|
-
|
|
323
|
+
restoreEditorUiState(restoredEditorUiState)
|
|
315
324
|
} else {
|
|
316
325
|
useViewer.getState().setSelection({
|
|
317
326
|
buildingId: firstBuilding.id,
|
|
@@ -319,7 +328,7 @@ export function syncEditorSelectionFromCurrentScene() {
|
|
|
319
328
|
selectedIds: [],
|
|
320
329
|
zoneId: null,
|
|
321
330
|
})
|
|
322
|
-
|
|
331
|
+
restoreEditorUiState(restoredEditorUiState)
|
|
323
332
|
}
|
|
324
333
|
return
|
|
325
334
|
}
|
|
@@ -327,7 +336,7 @@ export function syncEditorSelectionFromCurrentScene() {
|
|
|
327
336
|
if (restoredSelection) {
|
|
328
337
|
useViewer.getState().setSelection(restoredSelection as never)
|
|
329
338
|
if (selectionDrivenEditorUiState) {
|
|
330
|
-
|
|
339
|
+
restoreEditorUiState(selectionDrivenEditorUiState)
|
|
331
340
|
}
|
|
332
341
|
return
|
|
333
342
|
}
|
|
@@ -351,6 +360,12 @@ export function syncEditorSelectionFromCurrentScene() {
|
|
|
351
360
|
}
|
|
352
361
|
}
|
|
353
362
|
|
|
363
|
+
function restoreEditorUiState(state: PersistedEditorUiState) {
|
|
364
|
+
const { toolMode, mode: _mode, tool: _tool, ...rest } = state
|
|
365
|
+
useEditor.setState(rest)
|
|
366
|
+
useEditor.getState().armToolMode(toolMode)
|
|
367
|
+
}
|
|
368
|
+
|
|
354
369
|
function resetEditorInteractionState() {
|
|
355
370
|
useViewer.getState().setHoveredId(null)
|
|
356
371
|
useViewer.getState().resetSelection()
|
|
@@ -362,8 +377,6 @@ function resetEditorInteractionState() {
|
|
|
362
377
|
sceneRegistry.clear()
|
|
363
378
|
useEditor.setState({
|
|
364
379
|
phase: 'site',
|
|
365
|
-
mode: 'select',
|
|
366
|
-
tool: null,
|
|
367
380
|
structureLayer: 'elements',
|
|
368
381
|
catalogCategory: null,
|
|
369
382
|
selectedItem: null,
|
|
@@ -372,6 +385,7 @@ function resetEditorInteractionState() {
|
|
|
372
385
|
hoveredHole: null,
|
|
373
386
|
isPreviewMode: false,
|
|
374
387
|
})
|
|
388
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
375
389
|
}
|
|
376
390
|
|
|
377
391
|
function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is SceneGraph {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
BlockNode,
|
|
5
|
+
emitter,
|
|
6
|
+
nodeRegistry,
|
|
7
|
+
registerNode,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
11
|
import { z } from 'zod'
|
|
12
|
+
import useEditor from '../store/use-editor'
|
|
4
13
|
import {
|
|
5
14
|
emitCanvasNodeSelection,
|
|
6
15
|
resolveCanvasSelectionNode,
|
|
@@ -71,6 +80,64 @@ describe('emitCanvasNodeSelection', () => {
|
|
|
71
80
|
emitter.off('selection:canvas-node-click', onSelection)
|
|
72
81
|
expect(received).toEqual([node])
|
|
73
82
|
})
|
|
83
|
+
|
|
84
|
+
test('deletes an accepted floorplan node when Delete mode is active', () => {
|
|
85
|
+
const node = BlockNode.parse({ id: 'block_floorplan-delete-target' })
|
|
86
|
+
const previousToolMode = useEditor.getState().toolMode
|
|
87
|
+
const previousScene = useScene.getState()
|
|
88
|
+
const previousSelection = useViewer.getState().selection
|
|
89
|
+
const received: AnyNode[] = []
|
|
90
|
+
const listener = (selectedNode: AnyNode) => received.push(selectedNode)
|
|
91
|
+
|
|
92
|
+
emitter.on('selection:canvas-node-click', listener)
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
useEditor.getState().armToolMode({ mode: 'delete' })
|
|
96
|
+
useScene.setState({
|
|
97
|
+
nodes: { [node.id]: node },
|
|
98
|
+
rootNodeIds: [node.id],
|
|
99
|
+
readOnly: false,
|
|
100
|
+
})
|
|
101
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
102
|
+
|
|
103
|
+
emitCanvasNodeSelection(node)
|
|
104
|
+
|
|
105
|
+
expect(useScene.getState().nodes[node.id]).toBeUndefined()
|
|
106
|
+
expect(useViewer.getState().selection.selectedIds).toEqual([])
|
|
107
|
+
expect(received).toEqual([])
|
|
108
|
+
} finally {
|
|
109
|
+
emitter.off('selection:canvas-node-click', listener)
|
|
110
|
+
useEditor.getState().armToolMode(previousToolMode)
|
|
111
|
+
useScene.setState(previousScene)
|
|
112
|
+
useViewer.setState({ selection: previousSelection })
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('preserves a floorplan node and its selection when the scene is read-only', () => {
|
|
117
|
+
const node = BlockNode.parse({ id: 'block_floorplan-read-only-target' })
|
|
118
|
+
const previousToolMode = useEditor.getState().toolMode
|
|
119
|
+
const previousScene = useScene.getState()
|
|
120
|
+
const previousSelection = useViewer.getState().selection
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
useEditor.getState().armToolMode({ mode: 'delete' })
|
|
124
|
+
useScene.setState({
|
|
125
|
+
nodes: { [node.id]: node },
|
|
126
|
+
rootNodeIds: [node.id],
|
|
127
|
+
readOnly: true,
|
|
128
|
+
})
|
|
129
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
130
|
+
|
|
131
|
+
emitCanvasNodeSelection(node)
|
|
132
|
+
|
|
133
|
+
expect(useScene.getState().nodes[node.id]).toEqual(node)
|
|
134
|
+
expect(useViewer.getState().selection.selectedIds).toEqual([node.id])
|
|
135
|
+
} finally {
|
|
136
|
+
useEditor.getState().armToolMode(previousToolMode)
|
|
137
|
+
useScene.setState(previousScene)
|
|
138
|
+
useViewer.setState({ selection: previousSelection })
|
|
139
|
+
}
|
|
140
|
+
})
|
|
74
141
|
})
|
|
75
142
|
|
|
76
143
|
describe('selectionModifiersFromEvent', () => {
|
|
@@ -287,6 +354,43 @@ describe('resolveCanvasSelectionNode', () => {
|
|
|
287
354
|
}),
|
|
288
355
|
).toBe(proxyGroup)
|
|
289
356
|
})
|
|
357
|
+
|
|
358
|
+
test('routes proxied lean-to roof children to the owning extension', () => {
|
|
359
|
+
const leanTo = {
|
|
360
|
+
id: 'lean_to_1',
|
|
361
|
+
type: 'lean-to-extension',
|
|
362
|
+
metadata: {},
|
|
363
|
+
} as unknown as AnyNode
|
|
364
|
+
const roof = {
|
|
365
|
+
id: 'roof_lean_to',
|
|
366
|
+
type: 'roof',
|
|
367
|
+
parentId: leanTo.id,
|
|
368
|
+
metadata: {
|
|
369
|
+
managedByLeanTo: leanTo.id,
|
|
370
|
+
leanToRole: 'roof',
|
|
371
|
+
nodeSelectionProxyId: leanTo.id,
|
|
372
|
+
},
|
|
373
|
+
} as unknown as AnyNode
|
|
374
|
+
const segment = {
|
|
375
|
+
id: 'rseg_lean_to',
|
|
376
|
+
type: 'roof-segment',
|
|
377
|
+
parentId: roof.id,
|
|
378
|
+
metadata: {
|
|
379
|
+
managedByLeanTo: leanTo.id,
|
|
380
|
+
leanToRole: 'roof-segment',
|
|
381
|
+
nodeSelectionProxyId: leanTo.id,
|
|
382
|
+
},
|
|
383
|
+
} as unknown as AnyNode
|
|
384
|
+
|
|
385
|
+
const nodes = {
|
|
386
|
+
[leanTo.id]: leanTo,
|
|
387
|
+
[roof.id]: roof,
|
|
388
|
+
[segment.id]: segment,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
expect(resolveCanvasSelectionNode({ node: roof, nodes, selectedIds: [] })).toBe(leanTo)
|
|
392
|
+
expect(resolveCanvasSelectionNode({ node: segment, nodes, selectedIds: [] })).toBe(leanTo)
|
|
393
|
+
})
|
|
290
394
|
})
|
|
291
395
|
|
|
292
396
|
describe('shouldPreserveSelectedRoofHostTarget', () => {
|
|
@@ -1,10 +1,15 @@
|
|
|
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
|
|
@@ -20,6 +25,20 @@ export type NodeSelectionTarget = {
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
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
|
+
|
|
23
42
|
emitter.emit('selection:canvas-node-click', node)
|
|
24
43
|
}
|
|
25
44
|
|
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
|