@pascal-app/editor 1.0.0-beta.5 → 1.0.1

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 (297) 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/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  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 +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,254 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type BuildingNode,
5
+ type LevelNode,
6
+ pointInPolygon2D,
7
+ resolveAutoZonePolygon,
8
+ snapWorldXZToBuildingLocal,
9
+ UnitNode,
10
+ useScene,
11
+ type ZoneNode,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { useEffect } from 'react'
15
+ import useEditor, { type StructureLayer } from '../store/use-editor'
16
+ import { getActiveBuildingPose } from './world-grid-snap'
17
+
18
+ type Nodes = Readonly<Record<AnyNodeId, AnyNode>>
19
+
20
+ // Unit focus is a session gesture: the layer the user was on when focus began
21
+ // comes back when focus ends, unless they switched layers by hand meanwhile.
22
+ let restoreLayer: StructureLayer | null = null
23
+ let entering = false
24
+ let leaving = false
25
+
26
+ export function focusedUnitNode(): UnitNode | null {
27
+ const id = useViewer.getState().focusedUnitId
28
+ const node = id ? useScene.getState().nodes[id] : undefined
29
+ return node?.type === 'unit' ? node : null
30
+ }
31
+
32
+ /** Levels carrying member zones, lowest ordinal first. */
33
+ export function unitMemberLevels(unit: UnitNode, nodes: Nodes): LevelNode[] {
34
+ const levels = new Map<LevelNode['id'], LevelNode>()
35
+ for (const zoneId of unit.members) {
36
+ const zone = nodes[zoneId]
37
+ const level =
38
+ zone?.type === 'zone' && zone.parentId ? nodes[zone.parentId as AnyNodeId] : undefined
39
+ if (level?.type === 'level') levels.set(level.id, level)
40
+ }
41
+ return [...levels.values()].sort((a, b) => a.level - b.level)
42
+ }
43
+
44
+ export function enterUnitFocus(unitId: UnitNode['id']): void {
45
+ if (entering) return
46
+ entering = true
47
+ try {
48
+ const nodes = useScene.getState().nodes
49
+ const unit = nodes[unitId]
50
+ if (unit?.type !== 'unit') return
51
+ const editor = useEditor.getState()
52
+ if (restoreLayer === null) restoreLayer = editor.structureLayer
53
+ if (editor.phase !== 'structure') editor.setPhase('structure')
54
+ if (useEditor.getState().structureLayer !== 'zones') {
55
+ useEditor.getState().setStructureLayer('zones')
56
+ }
57
+ const viewer = useViewer.getState()
58
+ if (viewer.focusedUnitId !== unitId) viewer.setFocusedUnit(unitId)
59
+
60
+ const { selection, setSelection } = useViewer.getState()
61
+ const levels = unitMemberLevels(unit, nodes)
62
+ const levelId = levels.some((level) => level.id === selection.levelId)
63
+ ? selection.levelId
64
+ : (levels[0]?.id ?? selection.levelId)
65
+ const buildingId = (unit.parentId as BuildingNode['id'] | null) ?? selection.buildingId
66
+ const unitSelected = selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId
67
+ if (selection.buildingId !== buildingId || selection.levelId !== levelId || !unitSelected) {
68
+ setSelection({ buildingId, levelId, selectedIds: [unitId] })
69
+ }
70
+ } finally {
71
+ entering = false
72
+ }
73
+ }
74
+
75
+ function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
76
+ const layer = restoreLayer
77
+ restoreLayer = null
78
+ const editor = useEditor.getState()
79
+ if (
80
+ !keepLayer &&
81
+ layer &&
82
+ layer !== 'zones' &&
83
+ editor.phase === 'structure' &&
84
+ editor.structureLayer === 'zones'
85
+ ) {
86
+ editor.setStructureLayer(layer)
87
+ }
88
+ const { selection, setSelection } = useViewer.getState()
89
+ if (selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId) {
90
+ setSelection({ selectedIds: [] })
91
+ }
92
+ }
93
+
94
+ /** Ends unit focus. Returns false when no unit was focused. */
95
+ export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
96
+ const viewer = useViewer.getState()
97
+ const unitId = viewer.focusedUnitId
98
+ if (!unitId) return false
99
+ leaving = true
100
+ try {
101
+ viewer.setFocusedUnit(null)
102
+ } finally {
103
+ leaving = false
104
+ }
105
+ finishLeave(unitId, options?.keepLayer ?? false)
106
+ return true
107
+ }
108
+
109
+ /**
110
+ * Puts a zone in exactly one unit (or none): it leaves every other unit in the
111
+ * same store update, so the move is one undo step.
112
+ */
113
+ export function assignZoneToUnit(zoneId: ZoneNode['id'], unitId: UnitNode['id'] | null): void {
114
+ const { nodes, updateNodes } = useScene.getState()
115
+ const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
116
+ for (const node of Object.values(nodes)) {
117
+ if (node.type !== 'unit' || node.id === unitId || !node.members.includes(zoneId)) continue
118
+ updates.push({ id: node.id, data: { members: node.members.filter((id) => id !== zoneId) } })
119
+ }
120
+ const target = unitId ? nodes[unitId] : undefined
121
+ if (target?.type === 'unit' && !target.members.includes(zoneId)) {
122
+ updates.push({ id: target.id, data: { members: [...target.members, zoneId] } })
123
+ }
124
+ if (updates.length > 0) updateNodes(updates)
125
+ }
126
+
127
+ export function toggleZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
128
+ const unit = useScene.getState().nodes[unitId]
129
+ if (unit?.type !== 'unit') return
130
+ assignZoneToUnit(zoneId, unit.members.includes(zoneId) ? null : unitId)
131
+ }
132
+
133
+ /**
134
+ * The zone under a point on the current level, in building-local XZ. A 3D
135
+ * click lands on whatever surface is closest (a floor slab sits above the
136
+ * zone fill), so painting resolves the zone from the hit point instead of the
137
+ * hit mesh. Nested zones resolve to the smallest one.
138
+ */
139
+ export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
140
+ const levelId = useViewer.getState().selection.levelId
141
+ if (!levelId) return null
142
+ const nodes = useScene.getState().nodes
143
+ const resolve = (id: AnyNodeId) => nodes[id]
144
+ let best: ZoneNode | null = null
145
+ let bestArea = Number.POSITIVE_INFINITY
146
+ for (const node of Object.values(nodes)) {
147
+ if (node.type !== 'zone' || node.parentId !== levelId) continue
148
+ const polygon = resolveAutoZonePolygon(node, resolve)
149
+ if (!pointInPolygon2D([x, z], polygon)) continue
150
+ let area = 0
151
+ for (let i = 0; i < polygon.length; i++) {
152
+ const a = polygon[i]!
153
+ const b = polygon[(i + 1) % polygon.length]!
154
+ area += a[0] * b[1] - b[0] * a[1]
155
+ }
156
+ area = Math.abs(area) / 2
157
+ if (area < bestArea) {
158
+ best = node
159
+ bestArea = area
160
+ }
161
+ }
162
+ return best
163
+ }
164
+
165
+ export function zoneAtWorldPoint(worldX: number, worldZ: number): ZoneNode | null {
166
+ const pose = getActiveBuildingPose()
167
+ const [x, z] = pose
168
+ ? snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.rotationY, 0).local
169
+ : [worldX, worldZ]
170
+ return zoneAtLevelPoint(x, z)
171
+ }
172
+
173
+ export const ZONE_PAINT_DELAY_MS = 250
174
+ const pendingPaints = new Map<ZoneNode['id'], ReturnType<typeof setTimeout>>()
175
+
176
+ /**
177
+ * The canvas paint gesture. The toggle waits out the double-click window so
178
+ * the second click of a double-click cancels it instead of toggling twice;
179
+ * the double-click itself then selects the zone.
180
+ */
181
+ export function paintZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
182
+ if (cancelPendingZonePaint(zoneId)) return
183
+ pendingPaints.set(
184
+ zoneId,
185
+ setTimeout(() => {
186
+ pendingPaints.delete(zoneId)
187
+ toggleZoneMembership(unitId, zoneId)
188
+ }, ZONE_PAINT_DELAY_MS),
189
+ )
190
+ }
191
+
192
+ export function cancelPendingZonePaint(zoneId: ZoneNode['id']): boolean {
193
+ const pending = pendingPaints.get(zoneId)
194
+ if (!pending) return false
195
+ clearTimeout(pending)
196
+ pendingPaints.delete(zoneId)
197
+ return true
198
+ }
199
+
200
+ export function createUnitInBuilding(buildingId: BuildingNode['id']): UnitNode['id'] | null {
201
+ const scene = useScene.getState()
202
+ const building = scene.nodes[buildingId]
203
+ if (building?.type !== 'building') return null
204
+ const unitCount = building.children.filter((id) => scene.nodes[id]?.type === 'unit').length
205
+ const unit = UnitNode.parse({ name: `Unit ${unitCount + 1}` })
206
+ scene.createNode(unit, buildingId)
207
+ enterUnitFocus(unit.id)
208
+ return unit.id
209
+ }
210
+
211
+ /**
212
+ * Keeps unit focus coherent with the rest of the editor: a focus set from
213
+ * anywhere (the unit inspector calls the viewer store directly) gets the full
214
+ * enter treatment, and focus ends when the user switches layer or phase by
215
+ * hand, selects anything other than the focused unit, or the unit is deleted.
216
+ */
217
+ export function useUnitFocusRules(): void {
218
+ useEffect(() => {
219
+ const unsubscribeViewer = useViewer.subscribe((state, prev) => {
220
+ if (state.focusedUnitId !== prev.focusedUnitId) {
221
+ if (state.focusedUnitId) enterUnitFocus(state.focusedUnitId)
222
+ else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId, false)
223
+ return
224
+ }
225
+ const unitId = state.focusedUnitId
226
+ if (!unitId || state.selection === prev.selection) return
227
+ // Selecting a zone keeps focus: renaming or recolouring a zone row is
228
+ // part of arranging the unit, and zones live on the layer focus uses.
229
+ const { selectedIds } = state.selection
230
+ const selectsOther =
231
+ selectedIds.length > 0 && !(selectedIds.length === 1 && selectedIds[0] === unitId)
232
+ if (selectsOther) leaveUnitFocus()
233
+ })
234
+ const unsubscribeEditor = useEditor.subscribe((state, prev) => {
235
+ if (state.structureLayer === prev.structureLayer && state.phase === prev.phase) return
236
+ if (!useViewer.getState().focusedUnitId) return
237
+ if (state.phase !== 'structure' || state.structureLayer !== 'zones') {
238
+ leaveUnitFocus({ keepLayer: true })
239
+ }
240
+ })
241
+ const unsubscribeScene = useScene.subscribe((state, prev) => {
242
+ if (state.nodes === prev.nodes) return
243
+ const unitId = useViewer.getState().focusedUnitId
244
+ if (unitId && state.nodes[unitId]?.type !== 'unit') leaveUnitFocus()
245
+ })
246
+ return () => {
247
+ unsubscribeViewer()
248
+ unsubscribeEditor()
249
+ unsubscribeScene()
250
+ for (const pending of pendingPaints.values()) clearTimeout(pending)
251
+ pendingPaints.clear()
252
+ }
253
+ }, [])
254
+ }
@@ -0,0 +1,65 @@
1
+ import type { AnyNode } from '@pascal-app/core'
2
+ import type { Object3D } from 'three'
3
+ import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
4
+ import { createExportTextureUtils } from './export-texture-utils'
5
+ import {
6
+ DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
7
+ type GlbExport,
8
+ type GlbExportOptions,
9
+ preparePortableSceneFromViewer,
10
+ withExportDeadline,
11
+ } from './glb-export'
12
+ import { createUsdzScene, disposeExportResources } from './portable-export'
13
+
14
+ export type UsdzExportOptions = Pick<
15
+ GlbExportOptions,
16
+ 'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning' | 'timeoutMs'
17
+ >
18
+
19
+ /** Export a native, self-contained USDZ with no glTF conversion fallback. */
20
+ export async function exportSceneToUsdz(
21
+ sceneGroup: Object3D,
22
+ nodes: Record<string, AnyNode>,
23
+ options: UsdzExportOptions = {},
24
+ ): Promise<Uint8Array<ArrayBuffer>> {
25
+ const textureUtils = createExportTextureUtils()
26
+ const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
27
+ ...options,
28
+ textures: 'embed',
29
+ animations: 'none',
30
+ textureUtils,
31
+ })
32
+ // Assigned inside the raced closure, so plain `let`s narrow to `never` in
33
+ // the `finally`.
34
+ const run: { prepared: GlbExport | null; scene: Object3D | null; abandoned: boolean } = {
35
+ prepared: null,
36
+ scene: null,
37
+ abandoned: false,
38
+ }
39
+ try {
40
+ return await withExportDeadline(
41
+ (async () => {
42
+ run.prepared = await preparation
43
+ if (run.abandoned) throw new Error('USDZ export abandoned')
44
+ for (const warning of run.prepared.warnings) options.onWarning?.(warning)
45
+ run.scene = createUsdzScene(run.prepared.scene)
46
+ const exporter = new USDZExporter()
47
+ exporter.textureUtils = textureUtils as unknown as USDZExporter['textureUtils']
48
+ return exporter.parseAsync(run.scene, {
49
+ onlyVisible: options.onlyVisible ?? true,
50
+ quickLookCompatible: true,
51
+ })
52
+ })(),
53
+ options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
54
+ 'USDZ',
55
+ () => {
56
+ run.abandoned = true
57
+ },
58
+ )
59
+ } finally {
60
+ if (run.scene) disposeExportResources(run.scene, { textures: false })
61
+ if (run.prepared) run.prepared.dispose()
62
+ else preparation.then((late) => late.dispose()).catch(() => {})
63
+ await textureUtils.dispose()
64
+ }
65
+ }
@@ -1,40 +1,16 @@
1
1
  'use client'
2
2
 
3
3
  import { useViewer } from '@pascal-app/viewer'
4
- import { useCallback } from 'react'
5
- import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from './measurements'
4
+ import { useMemo } from 'react'
5
+ import { getLinearDisplay } from './linear-display'
6
6
 
7
- /**
8
- * Shared display/storage conversion for numeric property controls so that
9
- * every length input honors the metric/imperial toggle identically.
10
- *
11
- * Values are always STORED in the field's own unit (meters for `unit === 'm'`).
12
- * When the viewer preference is imperial AND the field is a meter length, the
13
- * value is DISPLAYED (and edited) in feet; otherwise the conversions are the
14
- * identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`,
15
- * …) behave exactly as before.
16
- *
17
- * Used by both `SliderControl` and `MetricControl` — keep the two in sync via
18
- * this single source of truth.
19
- */
20
- export function useLinearDisplay(unit: string, precision: number) {
7
+ /** Shared display/input conversion; values and bounds stay in the stored unit. */
8
+ export function useLinearDisplay(unit: string, precision: number, step = 1) {
21
9
  const viewerUnit = useViewer((state) => state.unit)
22
- const isImperial = viewerUnit === 'imperial' && unit === 'm'
23
- const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
10
+ const metricNotation = useViewer((state) => state.metricNotation)
24
11
 
25
- const toDisplay = useCallback(
26
- (stored: number) => (isImperial ? metersToLinearUnit(stored, 'imperial') : stored),
27
- [isImperial],
12
+ return useMemo(
13
+ () => getLinearDisplay(unit, viewerUnit, metricNotation, precision, step),
14
+ [unit, viewerUnit, metricNotation, precision, step],
28
15
  )
29
- const toStored = useCallback(
30
- (display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display),
31
- [isImperial],
32
- )
33
- // Round a stored value so it lands on a clean number of DISPLAY-unit digits.
34
- const roundStored = useCallback(
35
- (stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))),
36
- [toDisplay, toStored, precision],
37
- )
38
-
39
- return { isImperial, displayUnit, toDisplay, toStored, roundStored }
40
16
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Grab pointer lock on the viewer canvas for a walkthrough (walk / drone)
3
+ * entry. Must run synchronously inside a user-gesture task — callers flip the
4
+ * first-person flags in a `flushSync` first so the controls are mounted when
5
+ * the lock lands.
6
+ *
7
+ * `retryWhile`: the browser's re-lock cooldown (~1.25s after any unlock)
8
+ * rejects the request outright, which bites the natural "free the cursor,
9
+ * immediately pick the other camera" flow. When given, one delayed retry
10
+ * fires after the cooldown — only while the predicate still holds.
11
+ */
12
+ export function requestWalkthroughPointerLock(options?: { retryWhile?: () => boolean }) {
13
+ const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
14
+ if (!canvas) return
15
+
16
+ if (!canvas.hasAttribute('tabindex')) {
17
+ canvas.tabIndex = -1
18
+ }
19
+ canvas.focus({ preventScroll: true })
20
+
21
+ if (document.pointerLockElement === canvas) return
22
+
23
+ try {
24
+ // The request can also reject ASYNC (browser cooldown after a recent
25
+ // unlock) — swallow it like the P-resume path; clicking the canvas
26
+ // re-requests once the cooldown passes.
27
+ const result = canvas.requestPointerLock?.() as Promise<void> | undefined
28
+ if (result && typeof result.catch === 'function') {
29
+ result.catch(() => {
30
+ const retryWhile = options?.retryWhile
31
+ if (!retryWhile) return
32
+ window.setTimeout(() => {
33
+ if (!retryWhile()) return
34
+ if (document.pointerLockElement === canvas) return
35
+ try {
36
+ const retried = canvas.requestPointerLock?.() as Promise<void> | undefined
37
+ if (retried && typeof retried.catch === 'function') retried.catch(() => {})
38
+ } catch {
39
+ // Best effort — clicking the canvas still locks.
40
+ }
41
+ }, 1400)
42
+ })
43
+ }
44
+ } catch {
45
+ return
46
+ }
47
+ }
@@ -85,6 +85,7 @@ describe('live draft preview stores', () => {
85
85
  {
86
86
  angle: 90,
87
87
  branchAngle: 90,
88
+ damperAngle: 0,
88
89
  diameter: 6,
89
90
  diameter2: 6,
90
91
  ductMaterial: 'sheet-metal' as const,
@@ -94,6 +95,8 @@ describe('live draft preview stores', () => {
94
95
  id: 'duct-fitting_live-draft-0' as const,
95
96
  metadata: {},
96
97
  object: 'node' as const,
98
+ panelHeight: 0.15,
99
+ panelWidth: 0.25,
97
100
  parentId: 'level_1' as const,
98
101
  position: [1, 2, 3] as [number, number, number],
99
102
  rotation: [0, 0, 0] as [number, number, number],
@@ -52,8 +52,7 @@ describe('terrain-sculpt mode lifecycle', () => {
52
52
 
53
53
  test('a phase switch out of site drops the scope', () => {
54
54
  useEditor.getState().setMode('terrain-sculpt')
55
- // This path rewrites `mode` without going through `setMode`, which is
56
- // exactly why the scope sync is its own function.
55
+ // The phase setter delegates its mode rewrite to the ToolMode transition.
57
56
  useEditor.getState().setPhase('structure')
58
57
  expect(useInteractionScope.getState().scope.kind).toBe('idle')
59
58
  })
@@ -153,10 +152,8 @@ describe('the brush and the 3D canvas travel together', () => {
153
152
 
154
153
  describe('the modes that hide the editing canvas release the brush', () => {
155
154
  // Preview / walkthrough / studio all unmount `ToolManager` (via `noEditing`),
156
- // so the sculpt tool that would release the scope on unmount is gone. Each one
157
- // resets `mode` with a raw `set` rather than `setMode`, which is exactly the
158
- // bug class `setPhase` had: a `sculpting` scope leaked here disables selection
159
- // across the whole editor with nothing left mounted to clear it.
155
+ // so the sculpt tool that would release the scope on unmount is gone. Their
156
+ // ToolMode transitions must release the scope before the canvas disappears.
160
157
  test('entering preview releases it', () => {
161
158
  useEditor.getState().setMode('terrain-sculpt')
162
159
  useEditor.getState().setPreviewMode(true)
@@ -0,0 +1,87 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import useEditor, { normalizePersistedEditorUiState } from './use-editor'
3
+
4
+ function resetToolMode() {
5
+ useEditor.getState().setPhase('structure')
6
+ useEditor.getState().setStructureLayer('elements')
7
+ useEditor.getState().armToolMode({ mode: 'select' })
8
+ useEditor.getState().setActivePaintMaterial(null)
9
+ }
10
+
11
+ beforeEach(resetToolMode)
12
+ afterEach(resetToolMode)
13
+
14
+ describe('ToolMode transition', () => {
15
+ test('choosing a paint swatch while selected arms material paint', () => {
16
+ useEditor.getState().armToolMode({ mode: 'select' })
17
+ useEditor.getState().armMaterialPaint({
18
+ materialPreset: 'library:test-paint',
19
+ sourceTarget: 'wall',
20
+ })
21
+
22
+ expect(useEditor.getState().toolMode).toEqual({ mode: 'material-paint' })
23
+ expect(useEditor.getState().mode).toBe('material-paint')
24
+ expect(useEditor.getState().tool).toBeNull()
25
+ expect(useEditor.getState().activePaintMaterial?.materialPreset).toBe('library:test-paint')
26
+ })
27
+
28
+ test('leaving build clears the materialized tool', () => {
29
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
30
+ useEditor.getState().armToolMode({ mode: 'select' })
31
+
32
+ expect(useEditor.getState().toolMode).toEqual({ mode: 'select' })
33
+ expect(useEditor.getState().mode).toBe('select')
34
+ expect(useEditor.getState().tool).toBeNull()
35
+ })
36
+
37
+ test('the setMode compatibility wrapper elects a default build tool', () => {
38
+ useEditor.getState().setMode('build')
39
+
40
+ expect(useEditor.getState().toolMode).toEqual({ mode: 'build', tool: 'wall' })
41
+ expect(useEditor.getState().mode).toBe('build')
42
+ expect(useEditor.getState().tool).toBe('wall')
43
+ })
44
+
45
+ test('setTool enters build and null exits it', () => {
46
+ useEditor.getState().armToolMode({ mode: 'select' })
47
+ useEditor.getState().setTool('slab')
48
+
49
+ expect(useEditor.getState().toolMode).toEqual({ mode: 'build', tool: 'slab' })
50
+ expect(useEditor.getState().mode).toBe('build')
51
+ expect(useEditor.getState().tool).toBe('slab')
52
+
53
+ useEditor.getState().setTool(null)
54
+ expect(useEditor.getState().toolMode).toEqual({ mode: 'select' })
55
+ expect(useEditor.getState().tool).toBeNull()
56
+ })
57
+ })
58
+
59
+ describe('persisted ToolMode normalization', () => {
60
+ test('elects a default for build with a null tool', () => {
61
+ const state = normalizePersistedEditorUiState({
62
+ phase: 'structure',
63
+ toolMode: { mode: 'build', tool: null as never },
64
+ mode: 'select',
65
+ tool: 'slab',
66
+ structureLayer: 'elements',
67
+ })
68
+
69
+ expect(state.toolMode).toEqual({ mode: 'build', tool: 'wall' })
70
+ expect(state.mode).toBe('build')
71
+ expect(state.tool).toBe('wall')
72
+ })
73
+
74
+ test('clears a persisted tool from a non-build mode', () => {
75
+ const state = normalizePersistedEditorUiState({
76
+ phase: 'structure',
77
+ toolMode: { mode: 'select' },
78
+ mode: 'build',
79
+ tool: 'wall',
80
+ structureLayer: 'elements',
81
+ })
82
+
83
+ expect(state.toolMode).toEqual({ mode: 'select' })
84
+ expect(state.mode).toBe('select')
85
+ expect(state.tool).toBeNull()
86
+ })
87
+ })
@@ -0,0 +1,48 @@
1
+ import { create } from 'zustand'
2
+
3
+ /** The camera actions the canvas hint can name. */
4
+ export type CameraHintAction = 'Pan' | 'Rotate' | 'Zoom'
5
+
6
+ /**
7
+ * Which camera controls the canvas hint is currently allowed to name.
8
+ *
9
+ * `null` — the default — means all of them, which is what the editor shows on
10
+ * its own: a first-time user has no idea which button does what, and the panel
11
+ * is there to answer that once.
12
+ *
13
+ * A host teaching the camera one gesture at a time needs the opposite. Showing
14
+ * three controls while asking for one turns the answer into a search, and
15
+ * leaving all three up after the lesson leaves a permanent widget over the
16
+ * canvas explaining something the user has just been walked through. So a host
17
+ * can narrow the panel to the gesture it is asking for, and to nothing at all
18
+ * once it is done — an empty list hides the panel outright.
19
+ *
20
+ * Deliberately a store rather than a prop: the thing that knows which gesture
21
+ * is being taught is a host surface several levels away from the canvas, and
22
+ * threading it through would put a teaching concern in every component between.
23
+ * The editor knows only "show these actions"; it never learns why.
24
+ */
25
+ type CameraHintFocus = {
26
+ actions: readonly CameraHintAction[] | null
27
+ focus: (actions: readonly CameraHintAction[] | null) => void
28
+ }
29
+
30
+ export const useCameraHintFocus = create<CameraHintFocus>((set) => ({
31
+ actions: null,
32
+ focus: (actions) =>
33
+ set((state) => {
34
+ const current = state.actions
35
+ if (
36
+ current === actions ||
37
+ (current !== null &&
38
+ actions !== null &&
39
+ current.length === actions.length &&
40
+ current.every((action, index) => action === actions[index]))
41
+ ) {
42
+ return state
43
+ }
44
+ return { actions }
45
+ }),
46
+ }))
47
+
48
+ export default useCameraHintFocus