@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.
Files changed (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. 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
- mode: fallbackUiState.phase === 'site' ? fallbackUiState.mode : 'select',
162
- tool: null,
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
- useEditor.setState(
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
- useEditor.setState(restoredEditorUiState)
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
- useEditor.setState(restoredEditorUiState)
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
- useEditor.setState(selectionDrivenEditorUiState)
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 { type AnyNode, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
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
 
@@ -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?.()
@@ -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