@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
@@ -13,7 +13,6 @@ import {
13
13
  getEffectiveRoofSurfaceMaterial,
14
14
  getEffectiveSegmentSurfaceMaterial,
15
15
  getEffectiveStairSurfaceMaterial,
16
- getEffectiveWallSurfaceMaterial,
17
16
  getLibraryMaterialIdFromRef,
18
17
  type MaterialSchema,
19
18
  type MaterialTarget,
@@ -26,28 +25,30 @@ import {
26
25
  type SlabNode,
27
26
  type StairNode,
28
27
  type StairSurfaceMaterialRole,
29
- type WallNode,
30
28
  type WallSurfaceSide,
31
29
  } from '@pascal-app/core'
32
30
 
33
- export type PaintableMaterialTarget = Extract<
34
- MaterialTarget,
35
- | 'wall'
36
- | 'roof'
37
- | 'stair'
38
- | 'fence'
39
- | 'column'
40
- | 'slab'
41
- | 'ceiling'
42
- | 'shelf'
43
- | 'chimney'
44
- | 'dormer'
45
- | 'box-vent'
46
- | 'ridge-vent'
47
- | 'turbine-vent'
48
- | 'cupola'
49
- | 'eyebrow-vent'
50
- >
31
+ export type PaintableMaterialTarget =
32
+ | Extract<
33
+ MaterialTarget,
34
+ | 'wall'
35
+ | 'roof'
36
+ | 'stair'
37
+ | 'fence'
38
+ | 'column'
39
+ | 'slab'
40
+ | 'ceiling'
41
+ | 'shelf'
42
+ | 'cabinet'
43
+ | 'chimney'
44
+ | 'dormer'
45
+ | 'box-vent'
46
+ | 'ridge-vent'
47
+ | 'turbine-vent'
48
+ | 'cupola'
49
+ | 'eyebrow-vent'
50
+ >
51
+ | 'item'
51
52
 
52
53
  export type SingleSurfaceMaterialRole = 'surface'
53
54
 
@@ -76,32 +77,6 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null
76
77
  return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
77
78
  }
78
79
 
79
- export function buildWallSurfaceMaterialPatch(
80
- node: WallNode,
81
- targetSide: WallSurfaceSide,
82
- material: MaterialSchema | undefined,
83
- materialPreset: string | undefined,
84
- ): Partial<WallNode> {
85
- const nextSurfaceMaterial = { material, materialPreset }
86
- const nextInterior =
87
- targetSide === 'interior'
88
- ? nextSurfaceMaterial
89
- : getEffectiveWallSurfaceMaterial(node, 'interior')
90
- const nextExterior =
91
- targetSide === 'exterior'
92
- ? nextSurfaceMaterial
93
- : getEffectiveWallSurfaceMaterial(node, 'exterior')
94
-
95
- return {
96
- interiorMaterial: nextInterior.material,
97
- interiorMaterialPreset: nextInterior.materialPreset,
98
- exteriorMaterial: nextExterior.material,
99
- exteriorMaterialPreset: nextExterior.materialPreset,
100
- material: undefined,
101
- materialPreset: undefined,
102
- }
103
- }
104
-
105
80
  export function buildRoofSurfaceMaterialPatch(
106
81
  node: RoofNode,
107
82
  targetRole: RoofSurfaceMaterialRole,
@@ -179,6 +154,7 @@ export function buildResetSurfaceMaterialUpdates(
179
154
  if (
180
155
  key === 'material' ||
181
156
  key === 'materialPreset' ||
157
+ key === 'slots' ||
182
158
  key.endsWith('Material') ||
183
159
  key.endsWith('MaterialPreset')
184
160
  ) {
@@ -276,6 +252,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
276
252
  | ChimneyMaterialRole
277
253
  | DormerSurfaceMaterialRole
278
254
  | SingleSurfaceMaterialRole
255
+ | string
279
256
  } | null
280
257
  }): ActivePaintMaterial | null {
281
258
  const { nodes, selectedId, selectedMaterialTarget } = params
@@ -297,7 +274,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
297
274
  nodes,
298
275
  })
299
276
  if (surface) {
300
- const sourceTarget = selectedNode.type as PaintableMaterialTarget
277
+ const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget
301
278
  return hasActivePaintMaterial({
302
279
  material: surface.material,
303
280
  materialPreset: surface.materialPreset,
@@ -375,11 +352,13 @@ export function resolveActivePaintMaterialFromSelection(params: {
375
352
  // Wall / chimney / dormer flow through the registry-driven path
376
353
  // at the top of this function.
377
354
 
355
+ // Slot-backed kinds resolve via the registry-driven `getEffectiveMaterial`
356
+ // path at the top of this function, including legacy inline-material
357
+ // fallbacks when their capability exposes one.
358
+
378
359
  if (
379
360
  (selectedNode.type === 'fence' ||
380
361
  selectedNode.type === 'column' ||
381
- selectedNode.type === 'slab' ||
382
- selectedNode.type === 'ceiling' ||
383
362
  selectedNode.type === 'shelf') &&
384
363
  selectedMaterialTarget.role === 'surface'
385
364
  ) {
@@ -412,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: {
412
391
  const selectedNode = nodes[selectedId]
413
392
  if (!selectedNode) return null
414
393
 
394
+ const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
395
+ ?.materialTarget
396
+ if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget
397
+
415
398
  if (selectedNode.type === 'wall') {
416
399
  return 'wall'
417
400
  }
@@ -444,6 +427,10 @@ export function resolvePaintTargetFromSelection(params: {
444
427
  return 'shelf'
445
428
  }
446
429
 
430
+ if (selectedNode.type === 'item') {
431
+ return 'item'
432
+ }
433
+
447
434
  if (selectedNode.type === 'chimney') {
448
435
  return 'chimney'
449
436
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ CREATABLE_MEASUREMENT_KINDS,
4
+ DEFAULT_CREATABLE_MEASUREMENT_KIND,
5
+ normalizeCreatableMeasurementKind,
6
+ } from './measurement-kind'
7
+
8
+ describe('creatable measurement kinds', () => {
9
+ test('keeps the supported creation kinds', () => {
10
+ expect(CREATABLE_MEASUREMENT_KINDS).toEqual([
11
+ 'distance',
12
+ 'angle',
13
+ 'area',
14
+ 'perimeter',
15
+ 'volume',
16
+ ])
17
+ for (const kind of CREATABLE_MEASUREMENT_KINDS) {
18
+ expect(normalizeCreatableMeasurementKind(kind)).toBe(kind)
19
+ }
20
+ })
21
+
22
+ test.each([
23
+ 'smart',
24
+ 'unknown',
25
+ null,
26
+ undefined,
27
+ ])('falls back from a non-creatable persisted value: %s', (value) => {
28
+ expect(normalizeCreatableMeasurementKind(value)).toBe(DEFAULT_CREATABLE_MEASUREMENT_KIND)
29
+ })
30
+ })
@@ -0,0 +1,19 @@
1
+ export const CREATABLE_MEASUREMENT_KINDS = [
2
+ 'distance',
3
+ 'angle',
4
+ 'area',
5
+ 'perimeter',
6
+ 'volume',
7
+ ] as const
8
+
9
+ export type CreatableMeasurementKind = (typeof CREATABLE_MEASUREMENT_KINDS)[number]
10
+
11
+ export const DEFAULT_CREATABLE_MEASUREMENT_KIND: CreatableMeasurementKind = 'distance'
12
+
13
+ export function isCreatableMeasurementKind(value: unknown): value is CreatableMeasurementKind {
14
+ return CREATABLE_MEASUREMENT_KINDS.includes(value as CreatableMeasurementKind)
15
+ }
16
+
17
+ export function normalizeCreatableMeasurementKind(value: unknown): CreatableMeasurementKind {
18
+ return isCreatableMeasurementKind(value) ? value : DEFAULT_CREATABLE_MEASUREMENT_KIND
19
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { measurementCentroid, pointInPolygon2D } from '@pascal-app/core'
3
+ import { measurementPolygonLabelAnchor, triangulateMeasurementPolygon } from './measurement-label'
4
+
5
+ describe('measurementPolygonLabelAnchor', () => {
6
+ test('keeps a concave polygon label inside its visible fill', () => {
7
+ const base = [
8
+ [0, 0, 0],
9
+ [4, 0, 0],
10
+ [4, 0, 1],
11
+ [1, 0, 1],
12
+ [1, 0, 4],
13
+ [0, 0, 4],
14
+ ] as [number, number, number][]
15
+ const centroid = measurementCentroid(base)
16
+ const anchor = measurementPolygonLabelAnchor(base)
17
+
18
+ expect(centroid).not.toBeNull()
19
+ expect(
20
+ pointInPolygon2D(
21
+ [centroid![0], centroid![2]],
22
+ base.map(([x, , z]) => [x, z]),
23
+ ),
24
+ ).toBe(false)
25
+ expect(anchor).not.toBeNull()
26
+ expect(
27
+ pointInPolygon2D(
28
+ [anchor![0], anchor![2]],
29
+ base.map(([x, , z]) => [x, z]),
30
+ ),
31
+ ).toBe(true)
32
+ })
33
+
34
+ test('returns an on-plane anchor for a sloped polygon', () => {
35
+ const base = [
36
+ [0, 0, 0],
37
+ [4, 2, 0],
38
+ [4, 2, 4],
39
+ [0, 0, 4],
40
+ ] as [number, number, number][]
41
+ const anchor = measurementPolygonLabelAnchor(base)
42
+
43
+ expect(triangulateMeasurementPolygon(base)).toHaveLength(2)
44
+ expect(anchor).not.toBeNull()
45
+ expect(anchor![1]).toBeCloseTo(anchor![0] / 2)
46
+ })
47
+ })
@@ -0,0 +1,68 @@
1
+ import { type MeasurementPoint, measurementCentroid, measurementNormal } from '@pascal-app/core'
2
+ import { ShapeUtils, Vector2, Vector3 } from 'three'
3
+
4
+ type MeasurementPolygonProjection = {
5
+ contour: Vector2[]
6
+ triangles: number[][]
7
+ }
8
+
9
+ function projectMeasurementPolygon(
10
+ points: readonly MeasurementPoint[],
11
+ ): MeasurementPolygonProjection | null {
12
+ const normalValue = measurementNormal(points)
13
+ const originValue = points[0]
14
+ if (!(normalValue && originValue)) return null
15
+
16
+ const normal = new Vector3(...normalValue)
17
+ const reference = Math.abs(normal.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
18
+ const tangent = new Vector3().crossVectors(reference, normal).normalize()
19
+ const bitangent = new Vector3().crossVectors(normal, tangent).normalize()
20
+ const origin = new Vector3(...originValue)
21
+ const contour = points.map((point) => {
22
+ const relative = new Vector3(...point).sub(origin)
23
+ return new Vector2(relative.dot(tangent), relative.dot(bitangent))
24
+ })
25
+
26
+ return { contour, triangles: ShapeUtils.triangulateShape(contour, []) }
27
+ }
28
+
29
+ export function triangulateMeasurementPolygon(points: readonly MeasurementPoint[]): number[][] {
30
+ return projectMeasurementPolygon(points)?.triangles ?? []
31
+ }
32
+
33
+ export function measurementPolygonLabelAnchor(
34
+ points: readonly MeasurementPoint[],
35
+ ): MeasurementPoint | null {
36
+ const projection = projectMeasurementPolygon(points)
37
+ if (!projection || projection.triangles.length === 0) return measurementCentroid(points)
38
+
39
+ let largestTriangle: number[] | null = null
40
+ let largestArea = Number.NEGATIVE_INFINITY
41
+ for (const triangle of projection.triangles) {
42
+ const [firstIndex, secondIndex, thirdIndex] = triangle
43
+ const first = firstIndex === undefined ? null : projection.contour[firstIndex]
44
+ const second = secondIndex === undefined ? null : projection.contour[secondIndex]
45
+ const third = thirdIndex === undefined ? null : projection.contour[thirdIndex]
46
+ if (!(first && second && third)) continue
47
+ const area = Math.abs(
48
+ (second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x),
49
+ )
50
+ if (area > largestArea) {
51
+ largestArea = area
52
+ largestTriangle = triangle
53
+ }
54
+ }
55
+
56
+ if (!largestTriangle) return measurementCentroid(points)
57
+ const [firstIndex, secondIndex, thirdIndex] = largestTriangle
58
+ const first = firstIndex === undefined ? null : points[firstIndex]
59
+ const second = secondIndex === undefined ? null : points[secondIndex]
60
+ const third = thirdIndex === undefined ? null : points[thirdIndex]
61
+ if (!(first && second && third)) return measurementCentroid(points)
62
+
63
+ return [
64
+ (first[0] + second[0] + third[0]) / 3,
65
+ (first[1] + second[1] + third[1]) / 3,
66
+ (first[2] + second[2] + third[2]) / 3,
67
+ ]
68
+ }
@@ -0,0 +1,116 @@
1
+ import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
2
+
3
+ /**
4
+ * Natural-language measurement parsing for editor property fields, backed by
5
+ * `@pascal-app/lingo`. Lets a user type `6ft`, `180cm`, `1m80`, `5'11"`, `45°`
6
+ * or `1.57rad` into any measurement field and have it canonicalized to the
7
+ * unit the field stores its value in — independent of the metric/imperial
8
+ * display toggle.
9
+ *
10
+ * The editor stores linear values in meters and angular values in radians (a
11
+ * few fields store inches or degrees); lingo's `length` base is meters and its
12
+ * `angle` base is radians, so a field's `unit` prop already names the stored /
13
+ * canonical unit and doubles as the parse target.
14
+ */
15
+
16
+ export interface LingoUnitSpec {
17
+ kind: Kind
18
+ /** Unit id the field's stored `value` is expressed in — the parse target. */
19
+ unitId: string
20
+ }
21
+
22
+ /**
23
+ * Map an editor field `unit` prop to a lingo kind + canonical unit. Returns
24
+ * `null` for units we deliberately do NOT natural-language-parse (plain
25
+ * numbers, percentages, angular rates, counts) so those keep exact
26
+ * `Number.parseFloat` behavior.
27
+ */
28
+ export function lingoUnitSpec(unit: string | undefined): LingoUnitSpec | null {
29
+ switch (unit) {
30
+ case 'm':
31
+ case 'cm':
32
+ case 'mm':
33
+ case 'in':
34
+ case 'ft':
35
+ return { kind: 'length', unitId: unit }
36
+ case '°':
37
+ case 'deg':
38
+ case 'degrees':
39
+ return { kind: 'angle', unitId: 'deg' }
40
+ case 'rad':
41
+ case 'radians':
42
+ return { kind: 'angle', unitId: 'rad' }
43
+ default:
44
+ return null
45
+ }
46
+ }
47
+
48
+ export interface ParseMeasurementOptions {
49
+ /**
50
+ * Implied unit for a BARE number (e.g. `6` → `6 ft`). Defaults to the field's
51
+ * own `unitId`. A typed unit (`180cm`) is always honored regardless.
52
+ */
53
+ bareUnit?: string
54
+ /** Disambiguates gal/ton/cup families; harmless for length/angle. */
55
+ system?: 'metric' | 'us' | 'imperial'
56
+ }
57
+
58
+ /**
59
+ * Parse typed field text into the field's stored numeric unit. Returns `null`
60
+ * when the text can't be read as a quantity, so the caller can fall back to a
61
+ * plain number parse or revert to the previous value.
62
+ */
63
+ export function parseMeasurement(
64
+ raw: string,
65
+ spec: LingoUnitSpec,
66
+ options: ParseMeasurementOptions = {},
67
+ ): number | null {
68
+ const result = parseQuantity(raw, {
69
+ kind: spec.kind,
70
+ unit: options.bareUnit ?? spec.unitId,
71
+ system: options.system,
72
+ strictness: 'forgiving',
73
+ })
74
+ if (!result.ok) return null
75
+ const value = result.quantity.to(spec.unitId).value
76
+ return Number.isFinite(value) ? value : null
77
+ }
78
+
79
+ export interface MeasurementHintOptions extends ParseMeasurementOptions {
80
+ /** Unit id the preview is rendered in (the field's displayed unit). */
81
+ displayUnit?: string
82
+ /** Max fraction digits in the preview. */
83
+ precision?: number
84
+ /**
85
+ * Clamp applied to the parsed stored value before previewing, so the hint
86
+ * reflects what a commit would actually store on a bounded field.
87
+ */
88
+ clamp?: (stored: number) => number
89
+ }
90
+
91
+ const PLAIN_NUMBER = /^[+-]?\d*\.?\d*$/
92
+
93
+ /**
94
+ * A faint "= 1.83 m" preview of the value currently being typed, shown only
95
+ * when the user typed something beyond a plain decimal in the field's own unit
96
+ * (an explicit unit, a compound like `1m80`, or a number word) and it parses.
97
+ * Returns `null` when there's nothing useful to preview.
98
+ */
99
+ export function measurementHint(
100
+ raw: string,
101
+ spec: LingoUnitSpec,
102
+ options: MeasurementHintOptions = {},
103
+ ): string | null {
104
+ const trimmed = raw.trim()
105
+ if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null
106
+
107
+ const parsed = parseMeasurement(trimmed, spec, options)
108
+ if (parsed === null) return null
109
+ const stored = options.clamp ? options.clamp(parsed) : parsed
110
+
111
+ const displayUnit = options.displayUnit ?? spec.unitId
112
+ const precision = options.precision ?? 2
113
+ const q = quantity(stored, spec.unitId)
114
+ const shown = displayUnit === spec.unitId ? q : q.to(displayUnit)
115
+ return `= ${shown.format({ precision })}`
116
+ }
@@ -0,0 +1,183 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ buildMeasurementAngleArcPoints,
4
+ cubicMetersToVolumeUnit,
5
+ formatAreaLabel,
6
+ formatLinearMeasurement,
7
+ formatVolumeLabel,
8
+ getAreaUnitLabel,
9
+ getLinearUnitLabel,
10
+ getVolumeUnitLabel,
11
+ linearControlValueToMeters,
12
+ linearUnitToMeters,
13
+ MEASUREMENT_ACTIVE_COLOR,
14
+ MEASUREMENT_DANGLING_COLOR,
15
+ MEASUREMENT_FLOORPLAN_COLOR,
16
+ MEASUREMENT_PERSISTENT_COLOR,
17
+ measurementFloorplanPresentationColor,
18
+ measurementPresentationColor,
19
+ metersToLinearUnit,
20
+ squareMetersToAreaUnit,
21
+ } from './measurements'
22
+
23
+ describe('measurement presentation', () => {
24
+ test('uses black at rest, indigo while active, and red for dangling references', () => {
25
+ expect(measurementPresentationColor(false, false)).toBe(MEASUREMENT_PERSISTENT_COLOR)
26
+ expect(measurementPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
27
+ expect(measurementPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
28
+ expect(measurementPresentationColor(true, true)).toBe(MEASUREMENT_DANGLING_COLOR)
29
+ })
30
+
31
+ test('uses an indigo analysis color for resting 2D measurements', () => {
32
+ expect(measurementFloorplanPresentationColor(false, false)).toBe(MEASUREMENT_FLOORPLAN_COLOR)
33
+ expect(measurementFloorplanPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
34
+ expect(measurementFloorplanPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
35
+ })
36
+ })
37
+
38
+ describe('angle arc presentation', () => {
39
+ test('samples the smaller angle from the first ray to the second', () => {
40
+ const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 0, 1], {
41
+ radius: 0.25,
42
+ sampleCount: 8,
43
+ })
44
+
45
+ expect(arc).toHaveLength(9)
46
+ expect(arc[0]?.[0]).toBeCloseTo(0.25)
47
+ expect(arc[0]?.[2]).toBeCloseTo(0)
48
+ expect(arc.at(-1)?.[0]).toBeCloseTo(0)
49
+ expect(arc.at(-1)?.[2]).toBeCloseTo(0.25)
50
+ expect(arc[4]?.[0]).toBeGreaterThan(0)
51
+ expect(arc[4]?.[2]).toBeGreaterThan(0)
52
+ })
53
+
54
+ test('keeps a constant radius on an arbitrary 3D angle plane', () => {
55
+ const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 1, 0], {
56
+ radius: 0.3,
57
+ })
58
+
59
+ expect(arc.length).toBeGreaterThan(4)
60
+ for (const point of arc) expect(Math.hypot(...point)).toBeCloseTo(0.3)
61
+ expect(arc.at(-1)?.[1]).toBeCloseTo(0.3)
62
+ })
63
+
64
+ test('omits an arc when either ray is degenerate', () => {
65
+ expect(buildMeasurementAngleArcPoints([0, 0, 0], [0, 0, 0], [1, 0, 0])).toEqual([])
66
+ })
67
+ })
68
+
69
+ describe('linear measurements', () => {
70
+ test('formats metric measurements in meters', () => {
71
+ expect(formatLinearMeasurement(3, 'metric')).toBe('3m')
72
+ expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
73
+ })
74
+
75
+ test('formats imperial measurements as feet and inches', () => {
76
+ expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
77
+ expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
78
+ })
79
+
80
+ test('carries rounded 12 inches into the next foot', () => {
81
+ expect(formatLinearMeasurement(3.047, 'imperial')).toBe(`10'0"`)
82
+ })
83
+
84
+ test('returns a placeholder for non-finite measurements', () => {
85
+ expect(formatLinearMeasurement(NaN, 'imperial')).toBe('--')
86
+ expect(formatLinearMeasurement(Infinity, 'imperial')).toBe('--')
87
+ expect(formatLinearMeasurement(NaN, 'metric')).toBe('--')
88
+ })
89
+
90
+ test('formats zero measurements', () => {
91
+ expect(formatLinearMeasurement(0, 'imperial')).toBe(`0'0"`)
92
+ expect(formatLinearMeasurement(0, 'metric')).toBe('0m')
93
+ })
94
+
95
+ test('formats sub-foot imperial measurements', () => {
96
+ expect(formatLinearMeasurement(0.1524, 'imperial')).toBe(`0'6"`)
97
+ })
98
+
99
+ test('formats negative measurements with a sign', () => {
100
+ expect(formatLinearMeasurement(-0.1524, 'imperial')).toBe(`-0'6"`)
101
+ expect(formatLinearMeasurement(-0.1524, 'metric')).toBe('-0.15m')
102
+ })
103
+
104
+ test('converts between meters and the active linear unit', () => {
105
+ expect(metersToLinearUnit(0, 'imperial')).toBe(0)
106
+ expect(linearUnitToMeters(0, 'imperial')).toBe(0)
107
+
108
+ expect(metersToLinearUnit(1, 'metric')).toBe(1)
109
+ expect(linearUnitToMeters(1, 'metric')).toBe(1)
110
+
111
+ expect(metersToLinearUnit(0.3048, 'imperial')).toBeCloseTo(1)
112
+ expect(linearUnitToMeters(1, 'imperial')).toBeCloseTo(0.3048)
113
+ })
114
+
115
+ test('converts numeric control input back to meters for wall panel edits', () => {
116
+ expect(linearControlValueToMeters(10, 'imperial')).toBeCloseTo(3.048)
117
+ expect(linearControlValueToMeters(0.5, 'imperial')).toBeCloseTo(0.1524)
118
+ expect(linearControlValueToMeters(-1, 'imperial')).toBeCloseTo(-0.3048)
119
+ expect(linearControlValueToMeters(3.5, 'metric')).toBe(3.5)
120
+ })
121
+
122
+ test('clamps numeric control input after converting to meters', () => {
123
+ expect(linearControlValueToMeters(0.1, 'imperial', { minMeters: 0.1 })).toBe(0.1)
124
+ expect(linearControlValueToMeters(0.3, 'imperial', { minMeters: 0.1 })).toBe(0.1)
125
+ expect(linearControlValueToMeters(19.7, 'imperial', { maxMeters: 6 })).toBe(6)
126
+ expect(linearControlValueToMeters(0.2, 'metric', { minMeters: 0.1 })).toBe(0.2)
127
+ expect(linearControlValueToMeters(0.2, 'metric', { maxMeters: 0.15 })).toBe(0.15)
128
+ })
129
+
130
+ test('returns the display label for numeric controls', () => {
131
+ expect(getLinearUnitLabel('metric')).toBe('m')
132
+ expect(getLinearUnitLabel('imperial')).toBe('ft')
133
+ })
134
+ })
135
+
136
+ describe('area measurements', () => {
137
+ test('converts square meters to the active area unit', () => {
138
+ expect(squareMetersToAreaUnit(0, 'imperial')).toBe(0)
139
+ expect(squareMetersToAreaUnit(12.5, 'metric')).toBe(12.5)
140
+ expect(squareMetersToAreaUnit(1, 'imperial')).toBeCloseTo(10.7639)
141
+ })
142
+
143
+ test('returns the display label for area readouts', () => {
144
+ expect(getAreaUnitLabel('metric')).toBe('m²')
145
+ expect(getAreaUnitLabel('imperial')).toBe('ft²')
146
+ })
147
+
148
+ test('formats an area label with value and unit', () => {
149
+ expect(formatAreaLabel(12.34, 'metric')).toBe('12.3m²')
150
+ expect(formatAreaLabel(1, 'imperial')).toBe('10.8ft²')
151
+ expect(formatAreaLabel(12.34, 'metric', 2)).toBe('12.34m²')
152
+ })
153
+
154
+ test('returns a placeholder for non-finite areas', () => {
155
+ expect(formatAreaLabel(NaN, 'metric')).toBe('--')
156
+ expect(formatAreaLabel(Infinity, 'imperial')).toBe('--')
157
+ })
158
+ })
159
+
160
+ describe('volume measurements', () => {
161
+ test('converts cubic meters to the active volume unit', () => {
162
+ expect(cubicMetersToVolumeUnit(0, 'imperial')).toBe(0)
163
+ expect(cubicMetersToVolumeUnit(12.5, 'metric')).toBe(12.5)
164
+ expect(cubicMetersToVolumeUnit(1, 'imperial')).toBeCloseTo(35.3147)
165
+ })
166
+
167
+ test('returns the display label for volume readouts', () => {
168
+ expect(getVolumeUnitLabel('metric')).toBe('m³')
169
+ expect(getVolumeUnitLabel('imperial')).toBe('ft³')
170
+ })
171
+
172
+ test('formats a volume label with value and unit', () => {
173
+ expect(formatVolumeLabel(12.34, 'metric')).toBe('12.3m³')
174
+ expect(formatVolumeLabel(1, 'imperial')).toBe('35.3ft³')
175
+ expect(formatVolumeLabel(12.34, 'metric', 2)).toBe('12.34m³')
176
+ })
177
+
178
+ test('returns a placeholder for non-finite volumes', () => {
179
+ expect(formatVolumeLabel(NaN, 'metric')).toBe('--')
180
+ expect(formatVolumeLabel(Infinity, 'imperial')).toBe('--')
181
+ expect(formatVolumeLabel(Number.NEGATIVE_INFINITY, 'metric')).toBe('--')
182
+ })
183
+ })