@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,461 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ generateSceneMaterialId,
5
+ type ItemNode,
6
+ type MaterialSchema,
7
+ nodeRegistry,
8
+ pointInPolygon2D,
9
+ resolveLevelId,
10
+ type SceneMaterial,
11
+ type SceneMaterialId,
12
+ type SlabNode,
13
+ type Space,
14
+ slotLabelFromId,
15
+ toSceneMaterialRef,
16
+ useScene,
17
+ type WallNode,
18
+ } from '@pascal-app/core'
19
+
20
+ /**
21
+ * Painter application scope — how far one paint click spreads. The scope set is
22
+ * DERIVED from the hovered node, not a per-kind table: any slot-model node with
23
+ * more than one slot offers `object` (whole node); a node with an `asset` offers
24
+ * `matching` (every instance of that asset); a kind that declares
25
+ * `capabilities.paint.roomScope` offers `room`. One global mode (not per-tool),
26
+ * defaulting to the narrowest `'single'`; the active interaction's HUD shows +
27
+ * cycles it within the hovered node's set.
28
+ */
29
+ export type PaintScope = 'single' | 'object' | 'matching' | 'room'
30
+
31
+ /** What the paint HUD needs to render + cycle the scope chip for a hover. */
32
+ export type PaintHoverInfo = {
33
+ /** The scopes available for the hovered node, in cycle order (always ≥ 1). */
34
+ scopes: PaintScope[]
35
+ /** Display name of the hovered slot — the label for the `'single'` scope. */
36
+ slotLabel: string
37
+ /** Kind noun for the `'object'` label (e.g. "Whole shelf"). */
38
+ nodeNoun: string
39
+ }
40
+
41
+ function nodeHasAsset(node: AnyNode): boolean {
42
+ return Boolean((node as { asset?: { id?: string } }).asset?.id)
43
+ }
44
+
45
+ function nodeOffersRoomScope(node: AnyNode): boolean {
46
+ return nodeRegistry.get(node.type)?.capabilities?.paint?.roomScope === true
47
+ }
48
+
49
+ /**
50
+ * The scopes a hovered node offers, derived from the node itself: every node
51
+ * paints `single`; > 1 slot adds `object`; an `asset` adds `matching`; a
52
+ * `roomScope`-declaring kind adds `room`. `slotRoles` is the node's full slot set
53
+ * (declared or mesh-derived), passed in by the caller.
54
+ */
55
+ export function availablePaintScopes(args: { node: AnyNode; slotRoles: string[] }): PaintScope[] {
56
+ const scopes: PaintScope[] = ['single']
57
+ if (args.slotRoles.length > 1) scopes.push('object')
58
+ if (nodeHasAsset(args.node)) scopes.push('matching')
59
+ if (nodeOffersRoomScope(args.node)) scopes.push('room')
60
+ return scopes
61
+ }
62
+
63
+ export function cyclePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintScope {
64
+ const list = scopes.length > 0 ? scopes : (['single'] as PaintScope[])
65
+ const index = list.indexOf(scope)
66
+ return list[(index + 1) % list.length] ?? 'single'
67
+ }
68
+
69
+ export function paintScopeLabel(scope: PaintScope, info: PaintHoverInfo): string {
70
+ switch (scope) {
71
+ case 'object':
72
+ return `Whole ${info.nodeNoun}`
73
+ case 'matching':
74
+ return 'All matching'
75
+ case 'room':
76
+ return 'Room'
77
+ default:
78
+ return info.slotLabel || 'This surface'
79
+ }
80
+ }
81
+
82
+ /**
83
+ * All paintable slot roles of a node. Prefers the kind's declared
84
+ * `capabilities.slots` (node-authored, stable); falls back to the runtime mesh
85
+ * tags via the injected `meshSlotRoles` for kinds whose slots come from a GLB
86
+ * (items) rather than a declaration.
87
+ */
88
+ export function nodeSlotRoles(node: AnyNode, meshSlotRoles: (node: AnyNode) => string[]): string[] {
89
+ const declared = nodeRegistry.get(node.type)?.capabilities?.slots?.(node)
90
+ if (declared && declared.length > 0) return declared.map((slot) => slot.slotId)
91
+ return meshSlotRoles(node)
92
+ }
93
+
94
+ /** Display label for the hovered slot — declared label wins, else derived from the id. */
95
+ export function slotDisplayLabel(node: AnyNode, role: string): string {
96
+ const declared = nodeRegistry
97
+ .get(node.type)
98
+ ?.capabilities?.slots?.(node)
99
+ ?.find((slot) => slot.slotId === role)
100
+ return declared?.label ?? slotLabelFromId(role)
101
+ }
102
+
103
+ // ── Fan-out resolution ──────────────────────────────────────────────────────
104
+
105
+ type SlotsNode = AnyNode & { slots?: Record<string, string> }
106
+
107
+ export type WallPaintHit = {
108
+ face: 'front' | 'back'
109
+ point: [number, number]
110
+ }
111
+
112
+ type WallBoundaryFace = Space['boundaryFaces'][number]
113
+
114
+ function distanceToSegment(
115
+ point: readonly [number, number],
116
+ start: readonly [number, number],
117
+ end: readonly [number, number],
118
+ ): number {
119
+ const dx = end[0] - start[0]
120
+ const dz = end[1] - start[1]
121
+ const lengthSquared = dx * dx + dz * dz
122
+ if (lengthSquared < 1e-12) return Math.hypot(point[0] - start[0], point[1] - start[1])
123
+ const t = Math.max(
124
+ 0,
125
+ Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
126
+ )
127
+ return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dz * t))
128
+ }
129
+
130
+ function distanceToPolyline(
131
+ point: readonly [number, number],
132
+ points: ReadonlyArray<readonly [number, number]>,
133
+ ): number {
134
+ let distance = Number.POSITIVE_INFINITY
135
+ for (let index = 0; index < points.length - 1; index += 1) {
136
+ const start = points[index]
137
+ const end = points[index + 1]
138
+ if (!(start && end)) continue
139
+ distance = Math.min(distance, distanceToSegment(point, start, end))
140
+ }
141
+ return distance
142
+ }
143
+
144
+ function wallRoleForRoomFace(role: string, wall: WallNode, face: 'front' | 'back'): string | null {
145
+ const semantic = face === 'front' ? wall.frontSide : wall.backSide
146
+ const fallback = face === 'front' ? 'interior' : 'exterior'
147
+ const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback
148
+
149
+ if (role === 'interior' || role === 'exterior') return side
150
+ if (role.endsWith('Interior'))
151
+ return `${role.slice(0, -'Interior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
152
+ if (role.endsWith('Exterior'))
153
+ return `${role.slice(0, -'Exterior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
154
+ return null
155
+ }
156
+
157
+ function resolveWallPaintSpace(args: {
158
+ wall: WallNode
159
+ wallHit: WallPaintHit
160
+ nodes: Record<string, AnyNode>
161
+ spaces: Record<string, Space>
162
+ }): Space | null {
163
+ const { wall, wallHit, nodes, spaces } = args
164
+ const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
165
+ const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
166
+ let best: { space: Space; distance: number } | null = null
167
+
168
+ for (const space of Object.values(spaces)) {
169
+ if (space.levelId !== levelId) continue
170
+ for (const boundary of space.boundaryFaces) {
171
+ if (boundary.wallId !== wall.id || boundary.face !== wallHit.face) continue
172
+ const distance = distanceToPolyline(wallHit.point, boundary.points)
173
+ if (distance > tolerance || (best && distance >= best.distance)) continue
174
+ best = { space, distance }
175
+ }
176
+ }
177
+
178
+ return best?.space ?? null
179
+ }
180
+
181
+ function boundaryPointKey(point: readonly [number, number]): string {
182
+ return `${point[0].toFixed(3)},${point[1].toFixed(3)}`
183
+ }
184
+
185
+ function boundarySegmentKey(boundary: WallBoundaryFace): string {
186
+ const forward = boundary.points.map(boundaryPointKey).join('|')
187
+ const reverse = [...boundary.points].reverse().map(boundaryPointKey).join('|')
188
+ return `${boundary.wallId}:${forward < reverse ? forward : reverse}`
189
+ }
190
+
191
+ function oppositeWallFace(face: 'front' | 'back'): 'front' | 'back' {
192
+ return face === 'front' ? 'back' : 'front'
193
+ }
194
+
195
+ function connectedExteriorBoundaries(args: {
196
+ wall: WallNode
197
+ wallHit: WallPaintHit
198
+ levelId: string
199
+ spaces: Record<string, Space>
200
+ }): WallBoundaryFace[] {
201
+ const { wall, wallHit, levelId, spaces } = args
202
+ const occurrences = new Map<string, WallBoundaryFace[]>()
203
+
204
+ for (const space of Object.values(spaces)) {
205
+ if (space.levelId !== levelId) continue
206
+ for (const boundary of space.boundaryFaces) {
207
+ const key = boundarySegmentKey(boundary)
208
+ const entries = occurrences.get(key) ?? []
209
+ entries.push(boundary)
210
+ occurrences.set(key, entries)
211
+ }
212
+ }
213
+
214
+ const exterior = [...occurrences.values()].flatMap((entries) => {
215
+ const boundary = entries.length === 1 ? entries[0] : undefined
216
+ if (!boundary) return []
217
+ return [{ ...boundary, face: oppositeWallFace(boundary.face) }]
218
+ })
219
+ const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
220
+ const seed = exterior
221
+ .filter((boundary) => boundary.wallId === wall.id && boundary.face === wallHit.face)
222
+ .map((boundary) => ({ boundary, distance: distanceToPolyline(wallHit.point, boundary.points) }))
223
+ .filter((candidate) => candidate.distance <= tolerance)
224
+ .sort((a, b) => a.distance - b.distance)[0]?.boundary
225
+ if (!seed) return []
226
+
227
+ const boundariesByEndpoint = new Map<string, WallBoundaryFace[]>()
228
+ for (const boundary of exterior) {
229
+ const first = boundary.points[0]
230
+ const last = boundary.points[boundary.points.length - 1]
231
+ for (const point of [first, last]) {
232
+ if (!point) continue
233
+ const key = boundaryPointKey(point)
234
+ const entries = boundariesByEndpoint.get(key) ?? []
235
+ entries.push(boundary)
236
+ boundariesByEndpoint.set(key, entries)
237
+ }
238
+ }
239
+
240
+ const connected: WallBoundaryFace[] = []
241
+ const visited = new Set<string>()
242
+ const queue = [seed]
243
+ while (queue.length > 0) {
244
+ const boundary = queue.shift()
245
+ if (!boundary) continue
246
+ const key = `${boundarySegmentKey(boundary)}:${boundary.face}`
247
+ if (visited.has(key)) continue
248
+ visited.add(key)
249
+ connected.push(boundary)
250
+
251
+ const first = boundary.points[0]
252
+ const last = boundary.points[boundary.points.length - 1]
253
+ for (const point of [first, last]) {
254
+ if (!point) continue
255
+ for (const neighbour of boundariesByEndpoint.get(boundaryPointKey(point)) ?? []) {
256
+ queue.push(neighbour)
257
+ }
258
+ }
259
+ }
260
+
261
+ return connected
262
+ }
263
+
264
+ function wallTargetsForBoundaries(args: {
265
+ boundaries: WallBoundaryFace[]
266
+ role: string
267
+ levelId: string
268
+ nodes: Record<string, AnyNode>
269
+ }): Array<{ nodeId: AnyNodeId; role: string }> {
270
+ const { boundaries, role, levelId, nodes } = args
271
+ const targets = new Map<string, { nodeId: AnyNodeId; role: string }>()
272
+ for (const boundary of boundaries) {
273
+ const targetWall = nodes[boundary.wallId]
274
+ if (
275
+ targetWall?.type !== 'wall' ||
276
+ (targetWall.parentId ?? resolveLevelId(targetWall, nodes)) !== levelId
277
+ ) {
278
+ continue
279
+ }
280
+ const targetRole = wallRoleForRoomFace(role, targetWall, boundary.face)
281
+ if (!targetRole) continue
282
+ const key = `${targetWall.id}:${targetRole}`
283
+ targets.set(key, { nodeId: targetWall.id as AnyNodeId, role: targetRole })
284
+ }
285
+ return [...targets.values()]
286
+ }
287
+
288
+ function polygonCentroid(
289
+ points: ReadonlyArray<readonly [number, number]>,
290
+ ): [number, number] | null {
291
+ if (points.length === 0) return null
292
+ let x = 0
293
+ let z = 0
294
+ for (const point of points) {
295
+ x += point[0]
296
+ z += point[1]
297
+ }
298
+ return [x / points.length, z / points.length]
299
+ }
300
+
301
+ /**
302
+ * Expand one paint hit (`node` + resolved `role`) into the full list of
303
+ * (node, role) targets the current `scope` should paint. Returns just the
304
+ * clicked surface for `'single'`, for any target whose scope set doesn't
305
+ * include the current scope, and whenever the spread resolves to a single
306
+ * element — so callers can keep the kind-specific single-node commit for that
307
+ * case and only batch when there's genuinely more than one target.
308
+ *
309
+ * `slotRolesOf` enumerates the node's full slot set (declared or mesh-derived,
310
+ * injected by the caller) for the whole-object scope.
311
+ */
312
+ export function resolvePaintScopeTargets(args: {
313
+ node: AnyNode
314
+ role: string
315
+ scope: PaintScope
316
+ nodes: Record<string, AnyNode>
317
+ spaces: Record<string, Space>
318
+ slotRolesOf: (node: AnyNode) => string[]
319
+ wallHit?: WallPaintHit
320
+ }): Array<{ nodeId: AnyNodeId; role: string }> {
321
+ const { node, role, scope, nodes, spaces, slotRolesOf, wallHit } = args
322
+ const single = [{ nodeId: node.id as AnyNodeId, role }]
323
+ if (scope === 'single') return single
324
+
325
+ // Whole object: paint every slot of the clicked node. Generic across any
326
+ // slot-model kind (item, shelf, door, …) — not item-specific.
327
+ if (scope === 'object') {
328
+ const roles = slotRolesOf(node)
329
+ const set = roles.length > 0 ? roles : [role]
330
+ return set.map((slotRole) => ({ nodeId: node.id as AnyNodeId, role: slotRole }))
331
+ }
332
+
333
+ // All matching: same slot across every instance of the node's asset (items).
334
+ if (scope === 'matching') {
335
+ const assetId = (node as ItemNode).asset?.id
336
+ if (!assetId) return single
337
+ return Object.values(nodes)
338
+ .filter((other) => other.type === 'item' && (other as ItemNode).asset?.id === assetId)
339
+ .map((other) => ({ nodeId: other.id as AnyNodeId, role }))
340
+ }
341
+
342
+ if (node.type === 'wall' && scope === 'room') {
343
+ const wall = node as WallNode
344
+ if (!wallHit) return single
345
+ const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
346
+ if (!levelId) return single
347
+ const space = resolveWallPaintSpace({ wall, wallHit, nodes, spaces })
348
+ const boundaries = space
349
+ ? space.boundaryFaces
350
+ : connectedExteriorBoundaries({ wall, wallHit, levelId, spaces })
351
+ const targets = wallTargetsForBoundaries({ boundaries, role, levelId, nodes })
352
+ return targets.length > 0 ? targets : single
353
+ }
354
+
355
+ if (node.type === 'slab' && scope === 'room') {
356
+ const centroid = polygonCentroid((node as SlabNode).polygon)
357
+ if (!centroid) return single
358
+ const space = Object.values(spaces).find((candidate) =>
359
+ pointInPolygon2D(centroid, candidate.polygon),
360
+ )
361
+ if (!space) return single
362
+ return Object.values(nodes)
363
+ .filter((other) => {
364
+ if (other.type !== 'slab') return false
365
+ const otherCentroid = polygonCentroid((other as SlabNode).polygon)
366
+ return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon)
367
+ })
368
+ .map((other) => ({ nodeId: other.id as AnyNodeId, role }))
369
+ }
370
+
371
+ return single
372
+ }
373
+
374
+ // ── Batched commit ──────────────────────────────────────────────────────────
375
+
376
+ // Structural equality for the one-off-colour dedup below. The slot model is
377
+ // uniform across item / wall / slab (`node.slots[role] = ref`), so the same
378
+ // matcher the per-kind commits use applies to the whole fan-out.
379
+ function materialsEqual(a: unknown, b: unknown): boolean {
380
+ if (Object.is(a, b)) return true
381
+ if (typeof a !== typeof b || a === null || b === null) return false
382
+ if (Array.isArray(a) || Array.isArray(b)) {
383
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
384
+ return a.every((value, index) => materialsEqual(value, b[index]))
385
+ }
386
+ if (typeof a === 'object') {
387
+ const aRecord = a as Record<string, unknown>
388
+ const bRecord = b as Record<string, unknown>
389
+ const aKeys = Object.keys(aRecord)
390
+ if (aKeys.length !== Object.keys(bRecord).length) return false
391
+ return aKeys.every(
392
+ (key) => Object.hasOwn(bRecord, key) && materialsEqual(aRecord[key], bRecord[key]),
393
+ )
394
+ }
395
+ return false
396
+ }
397
+
398
+ /**
399
+ * Apply one paint to many slot-model targets in a single undo step. Resolves
400
+ * the slot ref ONCE — a one-off colour creates a single shared scene material
401
+ * for the whole fan-out, not one per node — then writes every `node.slots[role]`
402
+ * (or deletes it, for the eraser) in one `useScene.setState`. Only ever called
403
+ * for item / wall / slab fan-outs, all of which use the unified slot model.
404
+ */
405
+ export function commitPaintScopeFanout(
406
+ targets: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>,
407
+ material: MaterialSchema | undefined,
408
+ materialPreset: string | undefined,
409
+ ): void {
410
+ if (targets.length === 0) return
411
+ const state = useScene.getState()
412
+
413
+ let ref: string | undefined
414
+ let newSceneMaterial: SceneMaterial | null = null
415
+ if (material === undefined && materialPreset === undefined) {
416
+ ref = undefined // eraser → clear the slot back to its default
417
+ } else if (materialPreset) {
418
+ ref = materialPreset
419
+ } else if (material) {
420
+ const existing = Object.values(state.materials).find((scene) =>
421
+ materialsEqual(scene.material, material),
422
+ )
423
+ if (existing) {
424
+ ref = toSceneMaterialRef(existing.id)
425
+ } else {
426
+ const id = generateSceneMaterialId()
427
+ newSceneMaterial = {
428
+ id,
429
+ name: `Material ${Object.keys(state.materials).length + 1}`,
430
+ material,
431
+ }
432
+ ref = toSceneMaterialRef(id)
433
+ }
434
+ } else {
435
+ return
436
+ }
437
+
438
+ useScene.setState((current) => {
439
+ if (current.readOnly) return current
440
+ const nextNodes = { ...current.nodes }
441
+ let changed = false
442
+ for (const { nodeId, role } of targets) {
443
+ const node = nextNodes[nodeId] as SlotsNode | undefined
444
+ if (!node) continue
445
+ const nextSlots = { ...(node.slots ?? {}) }
446
+ if (ref) nextSlots[role] = ref
447
+ else delete nextSlots[role]
448
+ nextNodes[nodeId] = { ...node, slots: nextSlots } as AnyNode
449
+ changed = true
450
+ }
451
+ if (!changed) return current
452
+ return {
453
+ nodes: nextNodes,
454
+ materials: newSceneMaterial
455
+ ? { ...current.materials, [newSceneMaterial.id as SceneMaterialId]: newSceneMaterial }
456
+ : current.materials,
457
+ }
458
+ })
459
+
460
+ for (const { nodeId } of targets) state.markDirty(nodeId)
461
+ }
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+
3
+ import useEditor from '../store/use-editor'
4
+
5
+ function swallowNextClick(timeoutMs = 300) {
6
+ const swallow = (event: Event) => {
7
+ event.stopPropagation()
8
+ event.preventDefault()
9
+ }
10
+
11
+ window.addEventListener('click', swallow, { capture: true, once: true })
12
+ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), timeoutMs)
13
+ }
14
+
15
+ export function consumePlacementDragRelease(event: PointerEvent): boolean {
16
+ if (!useEditor.getState().placementDragMode) return false
17
+ if (event.button !== 0) return false
18
+
19
+ event.stopPropagation()
20
+ event.preventDefault()
21
+ swallowNextClick()
22
+ return true
23
+ }
@@ -40,4 +40,61 @@ describe('resolvePlanarCursorPosition', () => {
40
40
  expect(moved.point).toEqual([11, 19])
41
41
  expect(moved.anchor).toEqual([4.1, 6.1])
42
42
  })
43
+
44
+ // Track B regression: "off-slab cursor, on-slab footprint stays at center".
45
+ // When the gizmo is grabbed off the footprint center (e.g. near a slab edge),
46
+ // the resolved center must track original + cursorDelta and be independent of
47
+ // the initial grab offset — so a footprint fully inside a slab cannot be
48
+ // pushed off the edge just because the cursor sample landed off-center.
49
+ test('relative mode cancels the off-center gizmo grab offset so the committed center is offset-independent', () => {
50
+ const original: [number, number] = [2, 2]
51
+ const firstSample: [number, number] = [2.3, 2.3]
52
+ const cursor: [number, number] = [3.1, 1.6]
53
+
54
+ const start = resolvePlanarCursorPosition({
55
+ cursor: firstSample,
56
+ original,
57
+ anchor: null,
58
+ mode: 'relative',
59
+ })
60
+
61
+ // First sample absorbs the off-center grab: the footprint stays put.
62
+ expect(start.point).toEqual(original)
63
+ expect(start.anchor).toEqual(firstSample)
64
+
65
+ const moved = resolvePlanarCursorPosition({
66
+ cursor,
67
+ original,
68
+ anchor: start.anchor,
69
+ mode: 'relative',
70
+ })
71
+
72
+ // Committed center = original + (cursor - firstSample), i.e. the gizmo
73
+ // offset is cancelled regardless of where on the footprint it was grabbed.
74
+ const expected: [number, number] = [
75
+ original[0] + (cursor[0] - firstSample[0]),
76
+ original[1] + (cursor[1] - firstSample[1]),
77
+ ]
78
+ expect(moved.point[0]).toBeCloseTo(expected[0])
79
+ expect(moved.point[1]).toBeCloseTo(expected[1])
80
+
81
+ // The result must not depend on the absolute grab offset: grabbing the same
82
+ // footprint dead-center and moving by the same delta yields the same center.
83
+ const centerStart = resolvePlanarCursorPosition({
84
+ cursor: original,
85
+ original,
86
+ anchor: null,
87
+ mode: 'relative',
88
+ })
89
+ const delta: [number, number] = [cursor[0] - firstSample[0], cursor[1] - firstSample[1]]
90
+ const centerMoved = resolvePlanarCursorPosition({
91
+ cursor: [original[0] + delta[0], original[1] + delta[1]],
92
+ original,
93
+ anchor: centerStart.anchor,
94
+ mode: 'relative',
95
+ })
96
+
97
+ expect(centerMoved.point[0]).toBeCloseTo(moved.point[0])
98
+ expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
99
+ })
43
100
  })
@@ -0,0 +1,19 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { editorHostPanelRegistry, registerEditorHostPanel } from './plugin-panels'
3
+
4
+ describe('editorHostPanelRegistry', () => {
5
+ afterEach(() => editorHostPanelRegistry.reset())
6
+
7
+ test('maps registered node kinds back to their owning host panel', () => {
8
+ registerEditorHostPanel({
9
+ id: 'pascal:trees:trees',
10
+ label: 'Nature',
11
+ icon: { kind: 'url', src: '/nature.webp' },
12
+ component: async () => ({ default: () => null }),
13
+ kinds: ['trees:tree', 'trees:flower', 'trees:grass'],
14
+ })
15
+
16
+ expect(editorHostPanelRegistry.panelForKind('trees:flower')).toBe('pascal:trees:trees')
17
+ expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined()
18
+ })
19
+ })
@@ -0,0 +1,91 @@
1
+ import type { IconRef, LazyComponent } from '@pascal-app/core'
2
+
3
+ export type EditorHostPanelWorkspace = string & {}
4
+
5
+ export type EditorHostPanel = {
6
+ id: string
7
+ label: string
8
+ icon: IconRef
9
+ component: LazyComponent
10
+ kinds?: readonly string[]
11
+ workspaces?: readonly EditorHostPanelWorkspace[]
12
+ pluginId?: string
13
+ description?: string
14
+ creator?: {
15
+ name: string
16
+ url?: string
17
+ }
18
+ pluginUrl?: string
19
+ defaultInstalled?: boolean
20
+ }
21
+
22
+ function isDevMode(): boolean {
23
+ try {
24
+ const meta = import.meta as { env?: { DEV?: boolean } }
25
+ if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV
26
+ } catch {
27
+ // import.meta unavailable in some CJS contexts — fall through.
28
+ }
29
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
30
+ return process.env.NODE_ENV !== 'production'
31
+ }
32
+ return false
33
+ }
34
+
35
+ class EditorHostPanelRegistryImpl {
36
+ private readonly panels = new Map<string, EditorHostPanel>()
37
+ private readonly listeners = new Set<() => void>()
38
+ private cached: EditorHostPanel[] = []
39
+
40
+ subscribe = (onChange: () => void): (() => void) => {
41
+ this.listeners.add(onChange)
42
+ return () => {
43
+ this.listeners.delete(onChange)
44
+ }
45
+ }
46
+
47
+ getSnapshot = (): EditorHostPanel[] => this.cached
48
+
49
+ panelForKind = (kind: string): string | undefined =>
50
+ this.cached.find((panel) => panel.kinds?.includes(kind))?.id
51
+
52
+ getDefaultInstalledPluginIds = (): string[] =>
53
+ Array.from(
54
+ new Set(
55
+ this.cached
56
+ .filter((panel) => panel.pluginId && panel.defaultInstalled)
57
+ .map((panel) => panel.pluginId as string),
58
+ ),
59
+ )
60
+
61
+ reset(): void {
62
+ this.panels.clear()
63
+ this.emit()
64
+ }
65
+
66
+ registerPanel(panel: EditorHostPanel): void {
67
+ if (typeof panel.id !== 'string' || panel.id.length === 0) {
68
+ throw new Error('[editor:host-panels] panel id must be a non-empty string')
69
+ }
70
+ if (this.panels.has(panel.id)) {
71
+ if (isDevMode()) {
72
+ console.warn(`[editor:host-panels] re-registering panel "${panel.id}" (HMR)`)
73
+ } else {
74
+ throw new Error(`[editor:host-panels] duplicate panel id: "${panel.id}" already registered`)
75
+ }
76
+ }
77
+ this.panels.set(panel.id, panel)
78
+ this.emit()
79
+ }
80
+
81
+ private emit(): void {
82
+ this.cached = Array.from(this.panels.values())
83
+ for (const listener of this.listeners) listener()
84
+ }
85
+ }
86
+
87
+ export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl()
88
+
89
+ export function registerEditorHostPanel(panel: EditorHostPanel): void {
90
+ editorHostPanelRegistry.registerPanel(panel)
91
+ }