@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.
Files changed (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. 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,
@@ -30,7 +39,7 @@ describe('resolveSelectedIdsForNodeClick', () => {
30
39
  resolveSelectedIdsForNodeClick({
31
40
  baseSelectedIds: ['wall_1'],
32
41
  currentSelectedIds: [],
33
- modifierKeys: { meta: true, ctrl: false, shift: false },
42
+ modifierKeys: { meta: true, ctrl: false, shift: false, alt: false },
34
43
  nodeId: 'item_1',
35
44
  }),
36
45
  ).toEqual(['wall_1', 'item_1'])
@@ -41,11 +50,22 @@ describe('resolveSelectedIdsForNodeClick', () => {
41
50
  resolveSelectedIdsForNodeClick({
42
51
  baseSelectedIds: ['wall_1', 'item_1'],
43
52
  currentSelectedIds: [],
44
- modifierKeys: { meta: false, ctrl: false, shift: true },
53
+ modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
45
54
  nodeId: 'item_1',
46
55
  }),
47
56
  ).toEqual(['wall_1'])
48
57
  })
58
+
59
+ test('plain click expands session groups', () => {
60
+ expect(
61
+ resolveSelectedIdsForNodeClick({
62
+ currentSelectedIds: [],
63
+ modifierKeys: { meta: false, ctrl: false, shift: false, alt: false },
64
+ nodeId: 'item_2',
65
+ expandIdsForNode: () => ['item_2', 'item_1'],
66
+ }),
67
+ ).toEqual(['item_2', 'item_1'])
68
+ })
49
69
  })
50
70
 
51
71
  describe('emitCanvasNodeSelection', () => {
@@ -60,27 +80,89 @@ describe('emitCanvasNodeSelection', () => {
60
80
  emitter.off('selection:canvas-node-click', onSelection)
61
81
  expect(received).toEqual([node])
62
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
+ })
63
141
  })
64
142
 
65
143
  describe('selectionModifiersFromEvent', () => {
66
144
  test('falls back to tracked modifier state when the click event omits keys', () => {
67
- expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({
145
+ expect(
146
+ selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false, alt: false }),
147
+ ).toEqual({
68
148
  meta: false,
69
149
  ctrl: true,
70
150
  shift: false,
151
+ alt: false,
71
152
  })
72
153
  })
73
154
 
74
155
  test('prefers explicit event key state over stale tracked modifiers', () => {
75
156
  expect(
76
157
  selectionModifiersFromEvent(
77
- { metaKey: false, ctrlKey: false, shiftKey: false },
78
- { meta: true, ctrl: true, shift: true },
158
+ { metaKey: false, ctrlKey: false, shiftKey: false, altKey: false },
159
+ { meta: true, ctrl: true, shift: true, alt: true },
79
160
  ),
80
161
  ).toEqual({
81
162
  meta: false,
82
163
  ctrl: false,
83
164
  shift: false,
165
+ alt: false,
84
166
  })
85
167
  })
86
168
  })
@@ -272,6 +354,43 @@ describe('resolveCanvasSelectionNode', () => {
272
354
  }),
273
355
  ).toBe(proxyGroup)
274
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
+ })
275
394
  })
276
395
 
277
396
  describe('shouldPreserveSelectedRoofHostTarget', () => {