@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,199 @@
1
+ import type { MeasurementPoint } from '@pascal-app/core'
2
+
3
+ export type LinearUnit = 'metric' | 'imperial'
4
+
5
+ export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
6
+ export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
7
+ export const MEASUREMENT_FLOORPLAN_COLOR = '#4f46e5'
8
+ export const MEASUREMENT_PERSISTENT_COLOR = '#111827'
9
+
10
+ export function measurementPresentationColor(dangling: boolean, active: boolean): string {
11
+ if (dangling) return MEASUREMENT_DANGLING_COLOR
12
+ return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_PERSISTENT_COLOR
13
+ }
14
+
15
+ export function measurementFloorplanPresentationColor(dangling: boolean, active: boolean): string {
16
+ if (dangling) return MEASUREMENT_DANGLING_COLOR
17
+ return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_FLOORPLAN_COLOR
18
+ }
19
+
20
+ type MeasurementAngleArcOptions = {
21
+ radius?: number
22
+ sampleCount?: number
23
+ }
24
+
25
+ const ANGLE_ARC_EPSILON = 1e-9
26
+
27
+ const subtractPoint = (point: MeasurementPoint, origin: MeasurementPoint): MeasurementPoint => [
28
+ point[0] - origin[0],
29
+ point[1] - origin[1],
30
+ point[2] - origin[2],
31
+ ]
32
+
33
+ const pointLength = (point: MeasurementPoint): number => Math.hypot(...point)
34
+
35
+ const scalePoint = (point: MeasurementPoint, scale: number): MeasurementPoint => [
36
+ point[0] * scale,
37
+ point[1] * scale,
38
+ point[2] * scale,
39
+ ]
40
+
41
+ const crossPoint = (first: MeasurementPoint, second: MeasurementPoint): MeasurementPoint => [
42
+ first[1] * second[2] - first[2] * second[1],
43
+ first[2] * second[0] - first[0] * second[2],
44
+ first[0] * second[1] - first[1] * second[0],
45
+ ]
46
+
47
+ const dotPoint = (first: MeasurementPoint, second: MeasurementPoint): number =>
48
+ first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
49
+
50
+ export function buildMeasurementAngleArcPoints(
51
+ start: MeasurementPoint,
52
+ vertex: MeasurementPoint,
53
+ end: MeasurementPoint,
54
+ options: MeasurementAngleArcOptions = {},
55
+ ): MeasurementPoint[] {
56
+ if (![...start, ...vertex, ...end].every(Number.isFinite)) return []
57
+
58
+ const startVector = subtractPoint(start, vertex)
59
+ const endVector = subtractPoint(end, vertex)
60
+ const startLength = pointLength(startVector)
61
+ const endLength = pointLength(endVector)
62
+ if (startLength <= ANGLE_ARC_EPSILON || endLength <= ANGLE_ARC_EPSILON) return []
63
+
64
+ const startDirection = scalePoint(startVector, 1 / startLength)
65
+ const endDirection = scalePoint(endVector, 1 / endLength)
66
+ const cosine = Math.max(-1, Math.min(1, dotPoint(startDirection, endDirection)))
67
+ const angle = Math.acos(cosine)
68
+ if (angle <= 1e-4) return []
69
+
70
+ let normal = crossPoint(startDirection, endDirection)
71
+ let normalLength = pointLength(normal)
72
+ if (normalLength <= ANGLE_ARC_EPSILON) {
73
+ const reference: MeasurementPoint = Math.abs(startDirection[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
74
+ normal = crossPoint(startDirection, reference)
75
+ normalLength = pointLength(normal)
76
+ }
77
+ if (normalLength <= ANGLE_ARC_EPSILON) return []
78
+ normal = scalePoint(normal, 1 / normalLength)
79
+ const tangent = crossPoint(normal, startDirection)
80
+
81
+ const shortestSide = Math.min(startLength, endLength)
82
+ const preferredRadius = options.radius ?? Math.min(Math.max(shortestSide * 0.28, 0.08), 0.75)
83
+ const radius = Math.min(Math.max(preferredRadius, 0), shortestSide * 0.45)
84
+ if (radius <= ANGLE_ARC_EPSILON) return []
85
+
86
+ const sampleCount = Math.min(
87
+ 64,
88
+ Math.max(4, Math.round(options.sampleCount ?? Math.max(8, (angle / Math.PI) * 32))),
89
+ )
90
+ return Array.from({ length: sampleCount + 1 }, (_, index) => {
91
+ const sampleAngle = angle * (index / sampleCount)
92
+ const direction: MeasurementPoint = [
93
+ startDirection[0] * Math.cos(sampleAngle) + tangent[0] * Math.sin(sampleAngle),
94
+ startDirection[1] * Math.cos(sampleAngle) + tangent[1] * Math.sin(sampleAngle),
95
+ startDirection[2] * Math.cos(sampleAngle) + tangent[2] * Math.sin(sampleAngle),
96
+ ]
97
+ return [
98
+ vertex[0] + direction[0] * radius,
99
+ vertex[1] + direction[1] * radius,
100
+ vertex[2] + direction[2] * radius,
101
+ ]
102
+ })
103
+ }
104
+
105
+ const METERS_PER_FOOT = 0.3048
106
+ const FEET_PER_METER = 1 / METERS_PER_FOOT
107
+
108
+ type LinearControlValueOptions = {
109
+ minMeters?: number
110
+ maxMeters?: number
111
+ }
112
+
113
+ export function metersToLinearUnit(meters: number, unit: LinearUnit): number {
114
+ return unit === 'imperial' ? meters * FEET_PER_METER : meters
115
+ }
116
+
117
+ export function linearUnitToMeters(value: number, unit: LinearUnit): number {
118
+ return unit === 'imperial' ? value * METERS_PER_FOOT : value
119
+ }
120
+
121
+ export function linearControlValueToMeters(
122
+ value: number,
123
+ unit: LinearUnit,
124
+ options: LinearControlValueOptions = {},
125
+ ): number {
126
+ const meters = linearUnitToMeters(value, unit)
127
+ const minMeters = options.minMeters ?? Number.NEGATIVE_INFINITY
128
+ const maxMeters = options.maxMeters ?? Number.POSITIVE_INFINITY
129
+
130
+ return Math.min(Math.max(meters, minMeters), maxMeters)
131
+ }
132
+
133
+ export function getLinearUnitLabel(unit: LinearUnit): string {
134
+ return unit === 'imperial' ? 'ft' : 'm'
135
+ }
136
+
137
+ const SQUARE_FEET_PER_SQUARE_METER = FEET_PER_METER * FEET_PER_METER
138
+ const CUBIC_FEET_PER_CUBIC_METER = SQUARE_FEET_PER_SQUARE_METER * FEET_PER_METER
139
+
140
+ export function squareMetersToAreaUnit(squareMeters: number, unit: LinearUnit): number {
141
+ return unit === 'imperial' ? squareMeters * SQUARE_FEET_PER_SQUARE_METER : squareMeters
142
+ }
143
+
144
+ export function getAreaUnitLabel(unit: LinearUnit): string {
145
+ return unit === 'imperial' ? 'ft²' : 'm²'
146
+ }
147
+
148
+ export function formatAreaLabel(
149
+ squareMeters: number,
150
+ unit: LinearUnit,
151
+ fractionDigits = 1,
152
+ ): string {
153
+ if (!Number.isFinite(squareMeters)) return '--'
154
+
155
+ return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
156
+ }
157
+
158
+ export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number {
159
+ return unit === 'imperial' ? cubicMeters * CUBIC_FEET_PER_CUBIC_METER : cubicMeters
160
+ }
161
+
162
+ export function getVolumeUnitLabel(unit: LinearUnit): string {
163
+ return unit === 'imperial' ? 'ft³' : 'm³'
164
+ }
165
+
166
+ export function formatVolumeLabel(
167
+ cubicMeters: number,
168
+ unit: LinearUnit,
169
+ fractionDigits = 1,
170
+ ): string {
171
+ if (!Number.isFinite(cubicMeters)) return '--'
172
+
173
+ return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
174
+ }
175
+
176
+ export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
177
+ if (!Number.isFinite(meters)) return '--'
178
+
179
+ const absoluteMeters = Math.abs(meters)
180
+
181
+ if (unit === 'imperial') {
182
+ const feet = metersToLinearUnit(absoluteMeters, unit)
183
+ let wholeFeet = Math.floor(feet)
184
+ let inches = Math.round((feet - wholeFeet) * 12)
185
+ if (inches === 12) {
186
+ wholeFeet += 1
187
+ inches = 0
188
+ }
189
+
190
+ const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : ''
191
+
192
+ return `${sign}${wholeFeet}'${inches}"`
193
+ }
194
+
195
+ const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
196
+ const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
197
+
198
+ return `${sign}${roundedMeters}m`
199
+ }
@@ -0,0 +1,454 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ detectSpacesForLevel,
5
+ type ItemNode,
6
+ type SlabNode,
7
+ type Space,
8
+ type WallNode,
9
+ } from '@pascal-app/core'
10
+ import {
11
+ availablePaintScopes,
12
+ cyclePaintScope,
13
+ type PaintHoverInfo,
14
+ type PaintScope,
15
+ paintScopeLabel,
16
+ resolvePaintScopeTargets,
17
+ type WallPaintHit,
18
+ } from './paint-scope'
19
+
20
+ describe('availablePaintScopes', () => {
21
+ it('every node offers single', () => {
22
+ expect(availablePaintScopes({ node: roof(), slotRoles: ['top'] })).toEqual(['single'])
23
+ })
24
+ it('more than one slot adds whole-object', () => {
25
+ expect(availablePaintScopes({ node: roof(), slotRoles: ['top', 'edge'] })).toEqual([
26
+ 'single',
27
+ 'object',
28
+ ])
29
+ })
30
+ it('a single slot does not add whole-object', () => {
31
+ expect(availablePaintScopes({ node: roof(), slotRoles: ['top'] })).not.toContain('object')
32
+ })
33
+ it('an asset adds all-matching (items)', () => {
34
+ expect(availablePaintScopes({ node: item('a', 'sofa'), slotRoles: ['seat'] })).toContain(
35
+ 'matching',
36
+ )
37
+ })
38
+ // `room` derives from the kind's registry `capabilities.paint.roomScope`, which
39
+ // isn't wired in this unit context; its resolver behaviour is covered below.
40
+ })
41
+
42
+ describe('cyclePaintScope', () => {
43
+ it('wraps within the given set', () => {
44
+ const set: PaintScope[] = ['single', 'object', 'matching']
45
+ expect(cyclePaintScope('single', set)).toBe('object')
46
+ expect(cyclePaintScope('object', set)).toBe('matching')
47
+ expect(cyclePaintScope('matching', set)).toBe('single')
48
+ })
49
+ it('a scope foreign to the set restarts at the first entry', () => {
50
+ expect(cyclePaintScope('matching', ['single', 'room'])).toBe('single')
51
+ })
52
+ it('an empty set stays single', () => {
53
+ expect(cyclePaintScope('single', [])).toBe('single')
54
+ })
55
+ })
56
+
57
+ describe('paintScopeLabel', () => {
58
+ const info = (over: Partial<PaintHoverInfo>): PaintHoverInfo => ({
59
+ scopes: ['single'],
60
+ slotLabel: 'Seat cushion',
61
+ nodeNoun: 'item',
62
+ ...over,
63
+ })
64
+ it('single shows the hovered slot label', () => {
65
+ expect(paintScopeLabel('single', info({ slotLabel: 'Seat cushion' }))).toBe('Seat cushion')
66
+ })
67
+ it('single falls back when there is no slot label', () => {
68
+ expect(paintScopeLabel('single', info({ slotLabel: '' }))).toBe('This surface')
69
+ })
70
+ it('object reads "Whole <noun>"', () => {
71
+ expect(paintScopeLabel('object', info({ nodeNoun: 'shelf' }))).toBe('Whole shelf')
72
+ })
73
+ it('matching / room are kind-agnostic', () => {
74
+ expect(paintScopeLabel('matching', info({}))).toBe('All matching')
75
+ expect(paintScopeLabel('room', info({}))).toBe('Room')
76
+ })
77
+ })
78
+
79
+ // ── resolvePaintScopeTargets ────────────────────────────────────────────────
80
+
81
+ function item(id: string, assetId: string): ItemNode {
82
+ return { id, type: 'item', asset: { id: assetId } } as unknown as ItemNode
83
+ }
84
+ function slab(id: string, polygon: Array<[number, number]>): SlabNode {
85
+ return { id, type: 'slab', polygon } as unknown as SlabNode
86
+ }
87
+ function wall(
88
+ id: string,
89
+ start: [number, number],
90
+ end: [number, number],
91
+ levelId = 'l1',
92
+ ): WallNode {
93
+ return {
94
+ id,
95
+ type: 'wall',
96
+ parentId: levelId,
97
+ start,
98
+ end,
99
+ thickness: 0.2,
100
+ frontSide: 'unknown',
101
+ backSide: 'unknown',
102
+ } as unknown as WallNode
103
+ }
104
+ function roof(): AnyNode {
105
+ return { id: 'r', type: 'roof' } as unknown as AnyNode
106
+ }
107
+ function asMap(nodes: AnyNode[]): Record<string, AnyNode> {
108
+ return Object.fromEntries(nodes.map((node) => [node.id, node]))
109
+ }
110
+ const noSlotRoles = () => [] as string[]
111
+
112
+ // `nodeId` is a branded id type; compare by plain `id:role` strings.
113
+ function keys(targets: Array<{ nodeId: string; role: string }>): string[] {
114
+ return targets.map((target) => `${target.nodeId}:${target.role}`)
115
+ }
116
+
117
+ function resolve(args: {
118
+ node: AnyNode
119
+ role?: string
120
+ scope: PaintScope
121
+ nodes: AnyNode[]
122
+ spaces?: Space[]
123
+ slotRolesOf?: (node: AnyNode) => string[]
124
+ wallHit?: WallPaintHit
125
+ }) {
126
+ return resolvePaintScopeTargets({
127
+ node: args.node,
128
+ role: args.role ?? 'surface',
129
+ scope: args.scope,
130
+ nodes: asMap(args.nodes),
131
+ spaces: Object.fromEntries((args.spaces ?? []).map((s) => [s.id, s])),
132
+ slotRolesOf: args.slotRolesOf ?? noSlotRoles,
133
+ wallHit: args.wallHit,
134
+ })
135
+ }
136
+
137
+ function adjacentRooms(levelId = 'l1') {
138
+ const walls = [
139
+ wall('bottom-left', [0, 0], [4, 0], levelId),
140
+ wall('bottom-right', [4, 0], [8, 0], levelId),
141
+ wall('right', [8, 0], [8, 4], levelId),
142
+ wall('top-right', [8, 4], [4, 4], levelId),
143
+ wall('top-left', [4, 4], [0, 4], levelId),
144
+ wall('left', [0, 4], [0, 0], levelId),
145
+ wall('shared', [4, 0], [4, 4], levelId),
146
+ ]
147
+ return { walls, spaces: detectSpacesForLevel(levelId, walls).spaces }
148
+ }
149
+
150
+ describe('resolvePaintScopeTargets', () => {
151
+ it('single always returns just the clicked surface', () => {
152
+ const a = item('a', 'sofa')
153
+ expect(
154
+ keys(resolve({ node: a, role: 'seat', scope: 'single', nodes: [a, item('b', 'sofa')] })),
155
+ ).toEqual(['a:seat'])
156
+ })
157
+
158
+ it('item matching fans the same slot across same-asset items only', () => {
159
+ const a = item('a', 'sofa')
160
+ const b = item('b', 'sofa')
161
+ const c = item('c', 'lamp')
162
+ const result = resolve({ node: a, role: 'seat', scope: 'matching', nodes: [a, b, c] })
163
+ expect(keys(result).sort()).toEqual(['a:seat', 'b:seat'])
164
+ })
165
+
166
+ it('item whole-item fans every enumerated slot of the clicked item', () => {
167
+ const a = item('a', 'sofa')
168
+ const result = resolve({
169
+ node: a,
170
+ role: 'seat',
171
+ scope: 'object',
172
+ nodes: [a],
173
+ slotRolesOf: () => ['seat', 'legs', 'cushion'],
174
+ })
175
+ expect(keys(result)).toEqual(['a:seat', 'a:legs', 'a:cushion'])
176
+ })
177
+
178
+ it('item whole-item falls back to the single slot when the subtree is unmounted', () => {
179
+ const a = item('a', 'sofa')
180
+ expect(keys(resolve({ node: a, role: 'seat', scope: 'object', nodes: [a] }))).toEqual([
181
+ 'a:seat',
182
+ ])
183
+ })
184
+
185
+ it('wall room selects the enclosed space on the clicked face of a shared wall', () => {
186
+ const { walls, spaces } = adjacentRooms()
187
+ const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
188
+
189
+ const leftRoom = resolve({
190
+ node: shared,
191
+ role: 'interior',
192
+ scope: 'room',
193
+ nodes: walls,
194
+ spaces,
195
+ wallHit: { face: 'front', point: [3.9, 2] },
196
+ })
197
+ expect(keys(leftRoom).sort()).toEqual([
198
+ 'bottom-left:interior',
199
+ 'left:interior',
200
+ 'shared:interior',
201
+ 'top-left:interior',
202
+ ])
203
+
204
+ const rightRoom = resolve({
205
+ node: shared,
206
+ role: 'exterior',
207
+ scope: 'room',
208
+ nodes: walls,
209
+ spaces,
210
+ wallHit: { face: 'back', point: [4.1, 2] },
211
+ })
212
+ expect(keys(rightRoom).sort()).toEqual([
213
+ 'bottom-right:interior',
214
+ 'right:interior',
215
+ 'shared:exterior',
216
+ 'top-right:interior',
217
+ ])
218
+ })
219
+
220
+ it('wall room preserves the vertical band while mapping each boundary face side', () => {
221
+ const { walls, spaces } = adjacentRooms()
222
+ const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
223
+ const result = resolve({
224
+ node: shared,
225
+ role: 'lowerExterior',
226
+ scope: 'room',
227
+ nodes: walls,
228
+ spaces,
229
+ wallHit: { face: 'back', point: [4.1, 2] },
230
+ })
231
+ expect(keys(result).sort()).toEqual([
232
+ 'bottom-right:lowerInterior',
233
+ 'right:lowerInterior',
234
+ 'shared:lowerExterior',
235
+ 'top-right:lowerInterior',
236
+ ])
237
+ })
238
+
239
+ it('wall room maps a reversed boundary wall to its rendered side', () => {
240
+ const { walls } = adjacentRooms()
241
+ const topRight = walls.find((candidate) => String(candidate.id) === 'top-right')!
242
+ topRight.start = [4, 4]
243
+ topRight.end = [8, 4]
244
+ const spaces = detectSpacesForLevel('l1', walls).spaces
245
+ const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
246
+ const result = resolve({
247
+ node: shared,
248
+ role: 'exterior',
249
+ scope: 'room',
250
+ nodes: walls,
251
+ spaces,
252
+ wallHit: { face: 'back', point: [4.1, 2] },
253
+ })
254
+
255
+ expect(keys(result)).toContain('top-right:exterior')
256
+ expect(keys(result)).not.toContain('top-right:interior')
257
+ })
258
+
259
+ it('wall room excludes duplicate geometry and spaces from another level', () => {
260
+ const levelA = adjacentRooms('l1')
261
+ const levelB = adjacentRooms('l2')
262
+ const levelBWalls = levelB.walls.map((candidate) => ({
263
+ ...candidate,
264
+ id: `other-${candidate.id}`,
265
+ })) as unknown as WallNode[]
266
+ const otherSpaces = detectSpacesForLevel('l2', levelBWalls).spaces
267
+ const shared = levelA.walls.find((candidate) => String(candidate.id) === 'shared')!
268
+ const result = resolve({
269
+ node: shared,
270
+ role: 'interior',
271
+ scope: 'room',
272
+ nodes: [...levelA.walls, ...levelBWalls],
273
+ spaces: [...levelA.spaces, ...otherSpaces],
274
+ wallHit: { face: 'front', point: [3.9, 2] },
275
+ })
276
+ expect(keys(result).every((key) => !key.startsWith('other-'))).toBe(true)
277
+ expect(result).toHaveLength(4)
278
+ })
279
+
280
+ it('wall room uses the hit subsegment when one long wall bounds adjacent bays', () => {
281
+ const long = wall('long', [0, 0], [8, 0])
282
+ const walls = [
283
+ long,
284
+ wall('left', [0, 0], [0, -3]),
285
+ wall('left-bottom', [0, -3], [4, -3]),
286
+ wall('divider', [4, -3], [4, 0]),
287
+ wall('right-bottom', [4, -3], [8, -3]),
288
+ wall('right', [8, -3], [8, 0]),
289
+ ]
290
+ const spaces = detectSpacesForLevel('l1', walls).spaces
291
+
292
+ const leftBay = resolve({
293
+ node: long,
294
+ role: 'exterior',
295
+ scope: 'room',
296
+ nodes: walls,
297
+ spaces,
298
+ wallHit: { face: 'back', point: [2, -0.1] },
299
+ })
300
+ const rightBay = resolve({
301
+ node: long,
302
+ role: 'exterior',
303
+ scope: 'room',
304
+ nodes: walls,
305
+ spaces,
306
+ wallHit: { face: 'back', point: [6, -0.1] },
307
+ })
308
+
309
+ expect(keys(leftBay).some((key) => key.startsWith('left-bottom:'))).toBe(true)
310
+ expect(keys(leftBay).some((key) => key.startsWith('right-bottom:'))).toBe(false)
311
+ expect(keys(rightBay).some((key) => key.startsWith('right-bottom:'))).toBe(true)
312
+ expect(keys(rightBay).some((key) => key.startsWith('left-bottom:'))).toBe(false)
313
+ })
314
+
315
+ it('wall room with no enclosing space falls back to single', () => {
316
+ const w1 = wall('w1', [0, 0], [4, 0])
317
+ expect(
318
+ keys(resolve({ node: w1, role: 'interior', scope: 'room', nodes: [w1], spaces: [] })),
319
+ ).toEqual(['w1:interior'])
320
+ })
321
+
322
+ it('wall room paints the connected exterior envelope from an exterior face', () => {
323
+ const walls = [
324
+ wall('bottom', [0, 0], [4, 0]),
325
+ wall('right', [4, 0], [4, 4]),
326
+ wall('top', [4, 4], [0, 4]),
327
+ wall('left', [0, 4], [0, 0]),
328
+ ]
329
+ const spaces = detectSpacesForLevel('l1', walls).spaces
330
+ expect(
331
+ keys(
332
+ resolve({
333
+ node: walls[0]!,
334
+ role: 'exterior',
335
+ scope: 'room',
336
+ nodes: walls,
337
+ spaces,
338
+ wallHit: { face: 'back', point: [2, -0.1] },
339
+ }),
340
+ ).sort(),
341
+ ).toEqual(['bottom:exterior', 'left:exterior', 'right:exterior', 'top:exterior'])
342
+ })
343
+
344
+ it('wall room does not cross to a disconnected exterior envelope', () => {
345
+ const first = [
346
+ wall('a-bottom', [0, 0], [4, 0]),
347
+ wall('a-right', [4, 0], [4, 4]),
348
+ wall('a-top', [4, 4], [0, 4]),
349
+ wall('a-left', [0, 4], [0, 0]),
350
+ ]
351
+ const second = [
352
+ wall('b-bottom', [10, 0], [14, 0]),
353
+ wall('b-right', [14, 0], [14, 4]),
354
+ wall('b-top', [14, 4], [10, 4]),
355
+ wall('b-left', [10, 4], [10, 0]),
356
+ ]
357
+ const walls = [...first, ...second]
358
+ const spaces = detectSpacesForLevel('l1', walls).spaces
359
+ const result = resolve({
360
+ node: first[0]!,
361
+ role: 'exterior',
362
+ scope: 'room',
363
+ nodes: walls,
364
+ spaces,
365
+ wallHit: { face: 'back', point: [2, -0.1] },
366
+ })
367
+
368
+ expect(result).toHaveLength(4)
369
+ expect(keys(result).every((key) => key.startsWith('a-'))).toBe(true)
370
+ })
371
+
372
+ it('wall room excludes shared interior walls from the exterior envelope', () => {
373
+ const { walls, spaces } = adjacentRooms()
374
+ const bottomLeft = walls.find((candidate) => String(candidate.id) === 'bottom-left')!
375
+ const result = resolve({
376
+ node: bottomLeft,
377
+ role: 'exterior',
378
+ scope: 'room',
379
+ nodes: walls,
380
+ spaces,
381
+ wallHit: { face: 'back', point: [2, -0.1] },
382
+ })
383
+
384
+ expect(result).toHaveLength(6)
385
+ expect(keys(result).some((key) => key.startsWith('shared:'))).toBe(false)
386
+ })
387
+
388
+ it('wall room follows an exterior wall that is logically split across rooms', () => {
389
+ const long = wall('long', [0, 0], [8, 0])
390
+ const walls = [
391
+ long,
392
+ wall('right', [8, 0], [8, 4]),
393
+ wall('top-right', [8, 4], [4, 4]),
394
+ wall('top-left', [4, 4], [0, 4]),
395
+ wall('left', [0, 4], [0, 0]),
396
+ wall('divider', [4, 0], [4, 4]),
397
+ ]
398
+ const spaces = detectSpacesForLevel('l1', walls).spaces
399
+ const result = resolve({
400
+ node: long,
401
+ role: 'exterior',
402
+ scope: 'room',
403
+ nodes: walls,
404
+ spaces,
405
+ wallHit: { face: 'back', point: [2, -0.1] },
406
+ })
407
+
408
+ expect(keys(result).filter((key) => key === 'long:exterior')).toHaveLength(1)
409
+ expect(keys(result).some((key) => key.startsWith('divider:'))).toBe(false)
410
+ expect(result).toHaveLength(5)
411
+ })
412
+
413
+ it('slab room fans across slabs whose centroid sits in the same space', () => {
414
+ const inside = slab('inA', [
415
+ [1, 1],
416
+ [3, 1],
417
+ [3, 3],
418
+ [1, 3],
419
+ ])
420
+ const alsoInside = slab('inB', [
421
+ [2, 2],
422
+ [2.5, 2],
423
+ [2.5, 2.5],
424
+ [2, 2.5],
425
+ ])
426
+ const outside = slab('out', [
427
+ [20, 20],
428
+ [21, 20],
429
+ [21, 21],
430
+ [20, 21],
431
+ ])
432
+ const space: Space = {
433
+ id: 's1',
434
+ levelId: 'l1',
435
+ polygon: [
436
+ [0, 0],
437
+ [10, 0],
438
+ [10, 10],
439
+ [0, 10],
440
+ ],
441
+ wallIds: [],
442
+ boundaryFaces: [],
443
+ isExterior: false,
444
+ }
445
+ const result = resolve({
446
+ node: inside,
447
+ role: 'surface',
448
+ scope: 'room',
449
+ nodes: [inside, alsoInside, outside],
450
+ spaces: [space],
451
+ })
452
+ expect(keys(result).sort()).toEqual(['inA:surface', 'inB:surface'])
453
+ })
454
+ })