@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,71 @@
1
+ export type PaintPreviewCleanup = (() => void) & { commit?: () => void }
2
+
3
+ type Interaction = {
4
+ key: string
5
+ apply: (() => void) | null
6
+ preview: (() => PaintPreviewCleanup | null) | null
7
+ }
8
+
9
+ export function combinePaintPreviews(previews: PaintPreviewCleanup[]): PaintPreviewCleanup {
10
+ const finish = (committed: boolean) => {
11
+ const pending = previews.splice(0).reverse()
12
+ let failure: unknown
13
+ for (const cleanup of pending) {
14
+ try {
15
+ if (committed) cleanup.commit?.()
16
+ else cleanup()
17
+ } catch (error) {
18
+ failure ??= error
19
+ }
20
+ }
21
+ if (failure) throw failure
22
+ }
23
+ return Object.assign(() => finish(false), { commit: () => finish(true) })
24
+ }
25
+
26
+ export function createPaintPreviewOwner() {
27
+ let active: { key: string; cleanup: PaintPreviewCleanup } | null = null
28
+ const end = (committed = false) => {
29
+ const previous = active
30
+ active = null
31
+ if (committed) previous?.cleanup.commit?.()
32
+ else previous?.cleanup()
33
+ }
34
+ return {
35
+ wrap<T extends Interaction>(interaction: T | null): T | null {
36
+ if (!interaction) return null
37
+ return {
38
+ ...interaction,
39
+ preview: interaction.preview
40
+ ? () => {
41
+ end()
42
+ const cleanup = interaction.preview!()
43
+ if (!cleanup) return null
44
+ const owned = { key: interaction.key, cleanup }
45
+ active = owned
46
+ return () => {
47
+ if (active === owned) end()
48
+ }
49
+ }
50
+ : null,
51
+ apply: interaction.apply
52
+ ? () => {
53
+ if (active && active.key !== interaction.key) end()
54
+ try {
55
+ interaction.apply!()
56
+ } catch (error) {
57
+ // A subscriber can throw after the scene write has already been published.
58
+ try {
59
+ end(true)
60
+ } catch {
61
+ // Preserve the apply error even if a hold listener also throws.
62
+ }
63
+ throw error
64
+ }
65
+ end(true)
66
+ }
67
+ : null,
68
+ }
69
+ },
70
+ }
71
+ }
@@ -81,8 +81,8 @@ describe('paintScopeLabel', () => {
81
81
  function item(id: string, assetId: string): ItemNode {
82
82
  return { id, type: 'item', asset: { id: assetId } } as unknown as ItemNode
83
83
  }
84
- function slab(id: string, polygon: Array<[number, number]>): SlabNode {
85
- return { id, type: 'slab', polygon } as unknown as SlabNode
84
+ function slab(id: string, polygon: Array<[number, number]>, levelId = 'l1'): SlabNode {
85
+ return { id, type: 'slab', polygon, parentId: levelId } as unknown as SlabNode
86
86
  }
87
87
  function wall(
88
88
  id: string,
@@ -451,4 +451,60 @@ describe('resolvePaintScopeTargets', () => {
451
451
  })
452
452
  expect(keys(result).sort()).toEqual(['inA:surface', 'inB:surface'])
453
453
  })
454
+
455
+ it('slab room stops at the level boundary', () => {
456
+ // Stacked storeys share a footprint, so the upper slab's centroid sits inside
457
+ // the ground floor's space polygon. Painting downstairs must not reach it.
458
+ const ground = slab(
459
+ 'ground',
460
+ [
461
+ [1, 1],
462
+ [3, 1],
463
+ [3, 3],
464
+ [1, 3],
465
+ ],
466
+ 'l1',
467
+ )
468
+ const upstairs = slab(
469
+ 'upstairs',
470
+ [
471
+ [1, 1],
472
+ [3, 1],
473
+ [3, 3],
474
+ [1, 3],
475
+ ],
476
+ 'l2',
477
+ )
478
+ const footprint: Array<[number, number]> = [
479
+ [0, 0],
480
+ [10, 0],
481
+ [10, 10],
482
+ [0, 10],
483
+ ]
484
+ const result = resolve({
485
+ node: ground,
486
+ role: 'surface',
487
+ scope: 'room',
488
+ nodes: [ground, upstairs],
489
+ spaces: [
490
+ {
491
+ id: 's1',
492
+ levelId: 'l1',
493
+ polygon: footprint,
494
+ wallIds: [],
495
+ boundaryFaces: [],
496
+ isExterior: false,
497
+ },
498
+ {
499
+ id: 's2',
500
+ levelId: 'l2',
501
+ polygon: footprint,
502
+ wallIds: [],
503
+ boundaryFaces: [],
504
+ isExterior: false,
505
+ },
506
+ ],
507
+ })
508
+ expect(keys(result)).toEqual(['ground:surface'])
509
+ })
454
510
  })
@@ -355,13 +355,19 @@ export function resolvePaintScopeTargets(args: {
355
355
  if (node.type === 'slab' && scope === 'room') {
356
356
  const centroid = polygonCentroid((node as SlabNode).polygon)
357
357
  if (!centroid) return single
358
- const space = Object.values(spaces).find((candidate) =>
359
- pointInPolygon2D(centroid, candidate.polygon),
358
+ // Space polygons are per-level footprints, and stacked storeys share a
359
+ // footprint — so the level has to gate both the space lookup and the fan-out
360
+ // or one click paints the floor above too.
361
+ const levelId = node.parentId ?? resolveLevelId(node, nodes)
362
+ if (!levelId) return single
363
+ const space = Object.values(spaces).find(
364
+ (candidate) => candidate.levelId === levelId && pointInPolygon2D(centroid, candidate.polygon),
360
365
  )
361
366
  if (!space) return single
362
367
  return Object.values(nodes)
363
368
  .filter((other) => {
364
369
  if (other.type !== 'slab') return false
370
+ if ((other.parentId ?? resolveLevelId(other, nodes)) !== levelId) return false
365
371
  const otherCentroid = polygonCentroid((other as SlabNode).polygon)
366
372
  return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon)
367
373
  })
@@ -1,9 +1,90 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { resolvePlanarCursorPosition } from './planar-cursor-placement'
2
+ import {
3
+ offsetPlanPositionByLocalCenter,
4
+ resolvePlanarCursorPosition,
5
+ resolvePrioritizedPlanarCursorPosition,
6
+ } from './planar-cursor-placement'
3
7
 
4
8
  const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5
5
9
 
6
10
  describe('resolvePlanarCursorPosition', () => {
11
+ test('absolute mode puts the unrotated footprint centre at the snapped cursor', () => {
12
+ const result = resolvePlanarCursorPosition({
13
+ cursor: [4.24, 2.26],
14
+ original: [0, 0],
15
+ anchor: null,
16
+ mode: 'absolute',
17
+ localCenter: [1.3, 0.5, 0.2],
18
+ snap: snapHalf,
19
+ })
20
+
21
+ expect(result.point).toEqual([2.7, 2.3])
22
+ expect(result.anchor).toBeNull()
23
+ })
24
+
25
+ test('absolute mode snaps the centre before deriving the rotated origin', () => {
26
+ const proposals: [number, number][] = []
27
+ const localCenter: [number, number, number] = [1.3, 0.5, 0.2]
28
+ const result = resolvePlanarCursorPosition({
29
+ cursor: [4.24, 2.26],
30
+ original: [0, 0],
31
+ anchor: null,
32
+ mode: 'absolute',
33
+ localCenter,
34
+ rotationY: Math.PI / 2,
35
+ snapPoint: (point) => {
36
+ proposals.push(point)
37
+ return [snapHalf(point[0]), snapHalf(point[1])]
38
+ },
39
+ })
40
+
41
+ expect(proposals).toEqual([[4.24, 2.26]])
42
+ expect(result.point[0]).toBeCloseTo(3.8)
43
+ expect(result.point[1]).toBeCloseTo(3.8)
44
+ const centre = offsetPlanPositionByLocalCenter(
45
+ [result.point[0], 0, result.point[1]],
46
+ localCenter,
47
+ Math.PI / 2,
48
+ )
49
+ expect(centre[0]).toBeCloseTo(4)
50
+ expect(centre[2]).toBeCloseTo(2.5)
51
+ })
52
+
53
+ test('absolute mode follows the unsnapped cursor at an oblique rotation', () => {
54
+ const localCenter: [number, number, number] = [1.5, 0.5, -0.3]
55
+ const result = resolvePlanarCursorPosition({
56
+ cursor: [-2.13, 6.27],
57
+ original: [0, 0],
58
+ anchor: null,
59
+ mode: 'absolute',
60
+ localCenter,
61
+ rotationY: -Math.PI / 4,
62
+ })
63
+ const centre = offsetPlanPositionByLocalCenter(
64
+ [result.point[0], 0, result.point[1]],
65
+ localCenter,
66
+ -Math.PI / 4,
67
+ )
68
+
69
+ expect(centre[0]).toBeCloseTo(-2.13)
70
+ expect(centre[2]).toBeCloseTo(6.27)
71
+ })
72
+
73
+ test('relative mode ignores the footprint centre and rotation', () => {
74
+ const result = resolvePlanarCursorPosition({
75
+ cursor: [4.9, 5.2],
76
+ original: [10, 20],
77
+ anchor: [4.1, 6.1],
78
+ mode: 'relative',
79
+ localCenter: [1.3, 0.5, 0.2],
80
+ rotationY: Math.PI / 2,
81
+ snap: snapHalf,
82
+ })
83
+
84
+ expect(result.point).toEqual([11, 19])
85
+ expect(result.anchor).toEqual([4.1, 6.1])
86
+ })
87
+
7
88
  test('absolute mode places the point directly at the snapped cursor', () => {
8
89
  const result = resolvePlanarCursorPosition({
9
90
  cursor: [1.24, -2.26],
@@ -98,3 +179,99 @@ describe('resolvePlanarCursorPosition', () => {
98
179
  expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
99
180
  })
100
181
  })
182
+
183
+ describe('resolvePrioritizedPlanarCursorPosition', () => {
184
+ test('attachment receives the corrected raw origin and returns the final origin', () => {
185
+ const proposals: [number, number][] = []
186
+ const result = resolvePrioritizedPlanarCursorPosition({
187
+ cursor: [4.24, 2.26],
188
+ original: [0, 0],
189
+ anchor: null,
190
+ mode: 'absolute',
191
+ localCenter: [1.3, 0.5, 0.2],
192
+ rotationY: Math.PI / 2,
193
+ snapPoint: () => {
194
+ throw new Error('Grid snapping must not run after attachment')
195
+ },
196
+ resolveAttachment: (proposal) => {
197
+ proposals.push(proposal)
198
+ return [3, 5]
199
+ },
200
+ })
201
+
202
+ expect(proposals).toHaveLength(1)
203
+ expect(proposals[0]![0]).toBeCloseTo(4.04)
204
+ expect(proposals[0]![1]).toBeCloseTo(3.56)
205
+ expect(result.point).toEqual([3, 5])
206
+ expect(result.attachmentSnapped).toBe(true)
207
+ })
208
+
209
+ test('snaps the footprint centre when attachment declines the corrected origin', () => {
210
+ const result = resolvePrioritizedPlanarCursorPosition({
211
+ cursor: [4.24, 2.26],
212
+ original: [0, 0],
213
+ anchor: null,
214
+ mode: 'absolute',
215
+ localCenter: [1.3, 0.5, 0.2],
216
+ snapPoint: ([x, z]) => [snapHalf(x), snapHalf(z)],
217
+ resolveAttachment: () => null,
218
+ })
219
+
220
+ expect(result.point).toEqual([2.7, 2.3])
221
+ expect(result.attachmentSnapped).toBe(false)
222
+ })
223
+
224
+ test('wall attachment receives the raw proposal and wins over grid snapping', () => {
225
+ const attachmentProposals: [number, number][] = []
226
+ const result = resolvePrioritizedPlanarCursorPosition({
227
+ cursor: [0.73, 0.32],
228
+ original: [0, 0],
229
+ anchor: null,
230
+ mode: 'absolute',
231
+ snap: snapHalf,
232
+ resolveAttachment: (proposal) => {
233
+ attachmentProposals.push(proposal)
234
+ return [proposal[0], 0.39]
235
+ },
236
+ })
237
+
238
+ expect(attachmentProposals).toEqual([[0.73, 0.32]])
239
+ expect(result.point).toEqual([0.73, 0.39])
240
+ expect(result.attachmentSnapped).toBe(true)
241
+ })
242
+
243
+ test('falls back to the grid proposal when there is no attachment', () => {
244
+ const result = resolvePrioritizedPlanarCursorPosition({
245
+ cursor: [0.73, 0.32],
246
+ original: [0, 0],
247
+ anchor: null,
248
+ mode: 'absolute',
249
+ snap: snapHalf,
250
+ resolveAttachment: () => null,
251
+ })
252
+
253
+ expect(result.point).toEqual([0.5, 0.5])
254
+ expect(result.attachmentSnapped).toBe(false)
255
+ })
256
+
257
+ test('supports footprint-aware point snapping after attachment resolution', () => {
258
+ const pointProposals: [number, number][] = []
259
+ const result = resolvePrioritizedPlanarCursorPosition({
260
+ cursor: [1.03, 2.04],
261
+ original: [0.8, 1.8],
262
+ anchor: [0.9, 1.9],
263
+ mode: 'relative',
264
+ snapPoint: (proposal) => {
265
+ pointProposals.push(proposal)
266
+ return [0.8, 2.29]
267
+ },
268
+ resolveAttachment: () => null,
269
+ })
270
+
271
+ expect(pointProposals).toHaveLength(1)
272
+ expect(pointProposals[0]![0]).toBeCloseTo(0.93)
273
+ expect(pointProposals[0]![1]).toBeCloseTo(1.94)
274
+ expect(result.point).toEqual([0.8, 2.29])
275
+ expect(result.attachmentSnapped).toBe(false)
276
+ })
277
+ })
@@ -7,7 +7,10 @@ type ResolvePlanarCursorPositionArgs = {
7
7
  original: PlanarPoint
8
8
  anchor: PlanarPoint | null
9
9
  mode: PlanarCursorPlacementMode
10
+ localCenter?: [number, number, number]
11
+ rotationY?: number
10
12
  snap?: (value: number) => number
13
+ snapPoint?: (point: PlanarPoint) => PlanarPoint
11
14
  }
12
15
 
13
16
  type ResolvePlanarCursorPositionResult = {
@@ -15,28 +18,81 @@ type ResolvePlanarCursorPositionResult = {
15
18
  anchor: PlanarPoint | null
16
19
  }
17
20
 
21
+ type ResolvePrioritizedPlanarCursorPositionArgs = ResolvePlanarCursorPositionArgs & {
22
+ resolveAttachment?: (proposal: PlanarPoint) => PlanarPoint | null
23
+ }
24
+
25
+ type ResolvePrioritizedPlanarCursorPositionResult = ResolvePlanarCursorPositionResult & {
26
+ attachmentSnapped: boolean
27
+ }
28
+
18
29
  const identity = (value: number) => value
19
30
 
31
+ export function offsetPlanPositionByLocalCenter(
32
+ position: [number, number, number],
33
+ center: [number, number, number],
34
+ rotationY: number,
35
+ ): [number, number, number] {
36
+ const cos = Math.cos(rotationY)
37
+ const sin = Math.sin(rotationY)
38
+ return [
39
+ position[0] + center[0] * cos + center[2] * sin,
40
+ position[1] + center[1],
41
+ position[2] - center[0] * sin + center[2] * cos,
42
+ ]
43
+ }
44
+
20
45
  export function resolvePlanarCursorPosition({
21
46
  cursor,
22
47
  original,
23
48
  anchor,
24
49
  mode,
50
+ localCenter,
51
+ rotationY = 0,
25
52
  snap = identity,
53
+ snapPoint,
26
54
  }: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult {
27
55
  if (mode === 'absolute') {
56
+ const proposal: PlanarPoint = [cursor[0], cursor[1]]
57
+ const snapped: PlanarPoint = snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])]
58
+ const origin: [number, number, number] = localCenter
59
+ ? offsetPlanPositionByLocalCenter(
60
+ [snapped[0], 0, snapped[1]],
61
+ [-localCenter[0], 0, -localCenter[2]],
62
+ rotationY,
63
+ )
64
+ : [snapped[0], 0, snapped[1]]
28
65
  return {
29
- point: [snap(cursor[0]), snap(cursor[1])],
66
+ point: [origin[0], origin[2]],
30
67
  anchor,
31
68
  }
32
69
  }
33
70
 
34
71
  const resolvedAnchor = anchor ?? cursor
72
+ const delta: PlanarPoint = [cursor[0] - resolvedAnchor[0], cursor[1] - resolvedAnchor[1]]
73
+ const proposal: PlanarPoint = [original[0] + delta[0], original[1] + delta[1]]
35
74
  return {
36
- point: [
37
- original[0] + snap(cursor[0] - resolvedAnchor[0]),
38
- original[1] + snap(cursor[1] - resolvedAnchor[1]),
39
- ],
75
+ point: snapPoint?.(proposal) ?? [original[0] + snap(delta[0]), original[1] + snap(delta[1])],
40
76
  anchor: resolvedAnchor,
41
77
  }
42
78
  }
79
+
80
+ export function resolvePrioritizedPlanarCursorPosition({
81
+ resolveAttachment,
82
+ ...args
83
+ }: ResolvePrioritizedPlanarCursorPositionArgs): ResolvePrioritizedPlanarCursorPositionResult {
84
+ const raw = resolvePlanarCursorPosition({ ...args, snap: identity, snapPoint: undefined })
85
+ const attached = resolveAttachment?.(raw.point) ?? null
86
+ if (attached) {
87
+ return {
88
+ point: attached,
89
+ anchor: raw.anchor,
90
+ attachmentSnapped: true,
91
+ }
92
+ }
93
+
94
+ return {
95
+ ...resolvePlanarCursorPosition(args),
96
+ attachmentSnapped: false,
97
+ }
98
+ }
@@ -1,5 +1,11 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
- import { editorHostPanelRegistry, registerEditorHostPanel } from './plugin-panels'
2
+ import {
3
+ type EditorHostPanel,
4
+ editorHostPanelRegistry,
5
+ managedPluginIds,
6
+ registerEditorHostPanel,
7
+ showsPluginManager,
8
+ } from './plugin-panels'
3
9
 
4
10
  describe('editorHostPanelRegistry', () => {
5
11
  afterEach(() => editorHostPanelRegistry.reset())
@@ -17,3 +23,88 @@ describe('editorHostPanelRegistry', () => {
17
23
  expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined()
18
24
  })
19
25
  })
26
+
27
+ const panel = (id: string, pluginId?: string): EditorHostPanel => ({
28
+ component: async () => ({ default: () => null }),
29
+ icon: { kind: 'url', src: '/x.webp' },
30
+ id,
31
+ label: id,
32
+ ...(pluginId ? { pluginId } : {}),
33
+ })
34
+
35
+ describe('managedPluginIds', () => {
36
+ test('counts plugins, not panels — one plugin with two panels is one plugin', () => {
37
+ expect(
38
+ managedPluginIds([
39
+ panel('pascal:boots:game', 'pascal:boots'),
40
+ panel('pascal:boots:keep', 'pascal:boots'),
41
+ panel('pascal:trees:nature', 'pascal:trees'),
42
+ ]),
43
+ ).toEqual(['pascal:boots', 'pascal:trees'])
44
+ })
45
+
46
+ test("the editor's own panels are not plugins", () => {
47
+ // No `pluginId` means it came from the host app, not from a plugin, and
48
+ // there is nothing to install or uninstall.
49
+ expect(managedPluginIds([panel('site'), panel('settings')])).toEqual([])
50
+ expect(managedPluginIds([])).toEqual([])
51
+ })
52
+ })
53
+
54
+ /**
55
+ * THE EMPTY LOBBY PANEL (owner report 2026-08-31). `/play/<id>` mounts the
56
+ * editor read-only and registers no host panels, so the plugin *manager* was
57
+ * the only tab in the rail: it opened by default onto a bare "Plugins" heading
58
+ * eating ~40% of a visitor's window. Dropping the last tab makes the v2 layout
59
+ * drop the left column entirely, which is the lobby as designed.
60
+ *
61
+ * The line to hold is that this hides an EMPTY manager and never a populated
62
+ * one — a read-only viewer in the real editor can still see what a project uses.
63
+ */
64
+ describe('showsPluginManager', () => {
65
+ test('the open lobby — read-only with nothing registered — gets no rail at all', () => {
66
+ expect(
67
+ showsPluginManager({ managedPluginCount: 0, readOnly: true, workspaceMode: 'edit' }),
68
+ ).toBe(false)
69
+ })
70
+
71
+ test('a read-only editor keeps the manager as soon as a plugin is registered', () => {
72
+ // Browsing what a project uses is a read, and the install button is
73
+ // already disabled on its own.
74
+ expect(
75
+ showsPluginManager({ managedPluginCount: 1, readOnly: true, workspaceMode: 'edit' }),
76
+ ).toBe(true)
77
+ })
78
+
79
+ test('a writable scene always keeps it, even with zero plugins', () => {
80
+ // The empty state is still useful to an owner: it is where "Create a
81
+ // Pascal plugin" lives.
82
+ expect(
83
+ showsPluginManager({ managedPluginCount: 0, readOnly: false, workspaceMode: 'edit' }),
84
+ ).toBe(true)
85
+ expect(
86
+ showsPluginManager({ managedPluginCount: 3, readOnly: false, workspaceMode: 'edit' }),
87
+ ).toBe(true)
88
+ })
89
+
90
+ test('never outside the edit workspace — studio has its own rail', () => {
91
+ for (const readOnly of [false, true]) {
92
+ for (const managedPluginCount of [0, 2]) {
93
+ expect(
94
+ showsPluginManager({ managedPluginCount, readOnly, workspaceMode: 'studio' }),
95
+ `readOnly=${readOnly} count=${managedPluginCount}`,
96
+ ).toBe(false)
97
+ }
98
+ }
99
+ })
100
+
101
+ test('the pre-existing behaviour is unchanged for the normal editor', () => {
102
+ // Regression fence: before this gate the rule was `workspaceMode === 'edit'`
103
+ // alone. Every writable edit-workspace case must still answer the same.
104
+ for (const managedPluginCount of [0, 1, 5]) {
105
+ expect(
106
+ showsPluginManager({ managedPluginCount, readOnly: false, workspaceMode: 'edit' }),
107
+ ).toBe(true)
108
+ }
109
+ })
110
+ })
@@ -89,3 +89,51 @@ export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl()
89
89
  export function registerEditorHostPanel(panel: EditorHostPanel): void {
90
90
  editorHostPanelRegistry.registerPanel(panel)
91
91
  }
92
+
93
+ /**
94
+ * The distinct plugins the manager can act on — every registered panel that
95
+ * declares a `pluginId`, deduplicated, because one plugin may contribute
96
+ * several panels and the manager lists plugins, not panels.
97
+ *
98
+ * Registration is what makes a plugin *manageable*, not installation: an
99
+ * uninstalled plugin still has to appear so it can be installed.
100
+ */
101
+ export function managedPluginIds(panels: readonly EditorHostPanel[]): string[] {
102
+ return Array.from(
103
+ new Set(panels.filter((panel) => panel.pluginId).map((panel) => panel.pluginId as string)),
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Does the plugin *manager* tab belong in the rail?
109
+ *
110
+ * It is a management surface — it installs and uninstalls plugins into the
111
+ * scene — so it earns a slot when there is something to manage, or when the
112
+ * scene is writable and the "create a plugin" path is still worth offering to
113
+ * whoever owns it.
114
+ *
115
+ * That leaves exactly one case out, and it is a real screen rather than a
116
+ * hypothetical: the open lobby (`/play/<id>`) mounts the editor under a
117
+ * read-only lease and registers NO host panels, so the manager was the only
118
+ * tab in the rail. The rail therefore opened by default onto a "Plugins"
119
+ * heading with nothing under it, covering roughly 40% of a visitor's window
120
+ * over the world they had come to play in (owner report 2026-08-31). With no
121
+ * tabs at all the v2 layout drops the whole left column, which is the lobby as
122
+ * intended: the canvas, and nothing else.
123
+ *
124
+ * A read-only *editor* keeps the tab as long as plugins are registered — a
125
+ * viewer can still read what a project uses; only the install button is
126
+ * disabled. So this hides an empty panel, never a populated one.
127
+ */
128
+ export function showsPluginManager({
129
+ managedPluginCount,
130
+ readOnly,
131
+ workspaceMode,
132
+ }: {
133
+ managedPluginCount: number
134
+ readOnly: boolean
135
+ workspaceMode: string
136
+ }): boolean {
137
+ if (workspaceMode !== 'edit') return false
138
+ return managedPluginCount > 0 || !readOnly
139
+ }