@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
@@ -1,8 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import { useScene } from '@pascal-app/core'
4
- import { useViewer } from '@pascal-app/viewer'
5
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
+ import {
6
+ lingoUnitSpec,
7
+ measurementHint,
8
+ parseMeasurement,
9
+ } from '../../../lib/measurement-parser'
10
+ import { useLinearDisplay } from '../../../lib/use-linear-display'
6
11
  import { cn } from '../../../lib/utils'
7
12
 
8
13
  interface MetricControlProps {
@@ -32,12 +37,26 @@ export function MetricControl({
32
37
  unit = '',
33
38
  restoreOnCommit = true,
34
39
  }: MetricControlProps) {
35
- const viewerUnit = useViewer((state) => state.unit)
36
- const isImperial = viewerUnit === 'imperial' && unit === 'm'
37
- const multiplier = isImperial ? 3.280_84 : 1
38
- const displayUnit = isImperial ? 'ft' : unit
40
+ const {
41
+ isImperial,
42
+ displayUnit,
43
+ toDisplay: toDisplayValue,
44
+ toStored: toStoredValue,
45
+ } = useLinearDisplay(unit, precision)
39
46
 
40
- const displayValue = value * multiplier
47
+ const clamp = useCallback(
48
+ (val: number) => {
49
+ return Math.min(Math.max(val, min), max)
50
+ },
51
+ [min, max],
52
+ )
53
+ const roundStoredValueForDisplayPrecision = useCallback(
54
+ (storedValue: number) =>
55
+ clamp(toStoredValue(Number.parseFloat(toDisplayValue(storedValue).toFixed(precision)))),
56
+ [clamp, precision, toDisplayValue, toStoredValue],
57
+ )
58
+
59
+ const displayValue = toDisplayValue(value)
41
60
 
42
61
  const [isEditing, setIsEditing] = useState(false)
43
62
  const [isDragging, setIsDragging] = useState(false)
@@ -50,13 +69,6 @@ export function MetricControl({
50
69
  const valueRef = useRef(value)
51
70
  valueRef.current = value
52
71
 
53
- const clamp = useCallback(
54
- (val: number) => {
55
- return Math.min(Math.max(val, min), max)
56
- },
57
- [min, max],
58
- )
59
-
60
72
  const applyCommittedValue = useCallback(
61
73
  (nextValue: number) => {
62
74
  if (onCommit) {
@@ -84,12 +96,12 @@ export function MetricControl({
84
96
  e.preventDefault()
85
97
 
86
98
  const direction = e.deltaY < 0 ? 1 : -1
87
- let scrollStep = step / multiplier
88
- if (e.shiftKey) scrollStep = (step * 10) / multiplier
89
- else if (e.altKey) scrollStep = (step * 0.1) / multiplier
99
+ let scrollStep = toStoredValue(step)
100
+ if (e.shiftKey) scrollStep = toStoredValue(step * 10)
101
+ else if (e.altKey) scrollStep = toStoredValue(step * 0.1)
90
102
 
91
103
  const newValue = clamp(valueRef.current + direction * scrollStep)
92
- const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
104
+ const finalValue = roundStoredValueForDisplayPrecision(newValue)
93
105
 
94
106
  if (Math.abs(finalValue - valueRef.current) > 1e-6) {
95
107
  applyCommittedValue(finalValue)
@@ -98,7 +110,7 @@ export function MetricControl({
98
110
 
99
111
  container.addEventListener('wheel', handleWheel, { passive: false })
100
112
  return () => container.removeEventListener('wheel', handleWheel)
101
- }, [isEditing, step, clamp, applyCommittedValue, precision, multiplier])
113
+ }, [isEditing, step, clamp, applyCommittedValue, toStoredValue, roundStoredValueForDisplayPrecision])
102
114
 
103
115
  useEffect(() => {
104
116
  if (!isHovered || isEditing) return
@@ -110,13 +122,12 @@ export function MetricControl({
110
122
 
111
123
  if (direction !== 0) {
112
124
  e.preventDefault()
113
- let scrollStep = step / multiplier
114
- if (e.shiftKey) scrollStep = (step * 10) / multiplier
115
- else if (e.altKey) scrollStep = (step * 0.1) / multiplier
125
+ let scrollStep = toStoredValue(step)
126
+ if (e.shiftKey) scrollStep = toStoredValue(step * 10)
127
+ else if (e.altKey) scrollStep = toStoredValue(step * 0.1)
116
128
 
117
129
  const newValue = clamp(valueRef.current + direction * scrollStep)
118
- const finalValue =
119
- Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
130
+ const finalValue = roundStoredValueForDisplayPrecision(newValue)
120
131
 
121
132
  if (Math.abs(finalValue - valueRef.current) > 1e-6) {
122
133
  applyCommittedValue(finalValue)
@@ -126,7 +137,15 @@ export function MetricControl({
126
137
 
127
138
  window.addEventListener('keydown', handleKeyDown)
128
139
  return () => window.removeEventListener('keydown', handleKeyDown)
129
- }, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
140
+ }, [
141
+ isHovered,
142
+ isEditing,
143
+ step,
144
+ clamp,
145
+ applyCommittedValue,
146
+ toStoredValue,
147
+ roundStoredValueForDisplayPrecision,
148
+ ])
130
149
 
131
150
  const handlePointerDown = useCallback(
132
151
  (e: React.PointerEvent) => {
@@ -143,14 +162,13 @@ export function MetricControl({
143
162
  const handlePointerMove = (moveEvent: PointerEvent) => {
144
163
  const deltaX = moveEvent.clientX - startXRef.current
145
164
 
146
- let dragStep = step / multiplier
147
- if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier
148
- else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier
165
+ let dragStep = toStoredValue(step)
166
+ if (moveEvent.shiftKey) dragStep = toStoredValue(step * 10)
167
+ else if (moveEvent.altKey) dragStep = toStoredValue(step * 0.1)
149
168
 
150
169
  const deltaValue = deltaX * dragStep
151
170
  const newValue = clamp(startValueRef.current + deltaValue)
152
- const newFinalValue =
153
- Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
171
+ const newFinalValue = roundStoredValueForDisplayPrecision(newValue)
154
172
 
155
173
  if (Math.abs(newFinalValue - finalValue) > 1e-6) {
156
174
  finalValue = newFinalValue
@@ -182,27 +200,69 @@ export function MetricControl({
182
200
  document.addEventListener('pointermove', handlePointerMove)
183
201
  document.addEventListener('pointerup', handlePointerUp)
184
202
  },
185
- [isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier],
203
+ [
204
+ isEditing,
205
+ value,
206
+ onChange,
207
+ onCommit,
208
+ restoreOnCommit,
209
+ clamp,
210
+ step,
211
+ toStoredValue,
212
+ roundStoredValueForDisplayPrecision,
213
+ ],
186
214
  )
187
215
 
188
216
  const handleValueClick = useCallback(() => {
189
217
  setIsEditing(true)
190
- setInputValue((value * multiplier).toFixed(precision))
191
- }, [value, multiplier, precision])
218
+ setInputValue(toDisplayValue(value).toFixed(precision))
219
+ }, [value, toDisplayValue, precision])
192
220
 
193
221
  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
194
222
  setInputValue(e.target.value)
195
223
  }, [])
196
224
 
197
225
  const submitValue = useCallback(() => {
198
- const numValue = Number.parseFloat(inputValue)
199
- if (Number.isNaN(numValue)) {
200
- setInputValue((value * multiplier).toFixed(precision))
226
+ const spec = lingoUnitSpec(unit)
227
+ let stored = spec
228
+ ? parseMeasurement(inputValue, spec, {
229
+ bareUnit: isImperial ? 'ft' : spec.unitId,
230
+ system: isImperial ? 'us' : 'metric',
231
+ })
232
+ : null
233
+ if (stored === null) {
234
+ const numValue = Number.parseFloat(inputValue)
235
+ stored = Number.isFinite(numValue) ? toStoredValue(numValue) : null
236
+ }
237
+ if (stored === null) {
238
+ setInputValue(toDisplayValue(value).toFixed(precision))
201
239
  } else {
202
- applyCommittedValue(clamp(numValue / multiplier))
240
+ applyCommittedValue(clamp(stored))
203
241
  }
204
242
  setIsEditing(false)
205
- }, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
243
+ }, [
244
+ inputValue,
245
+ unit,
246
+ isImperial,
247
+ applyCommittedValue,
248
+ clamp,
249
+ toStoredValue,
250
+ value,
251
+ precision,
252
+ toDisplayValue,
253
+ ])
254
+
255
+ const spec = lingoUnitSpec(unit)
256
+ const hint =
257
+ isEditing && spec
258
+ ? measurementHint(inputValue, spec, {
259
+ bareUnit: isImperial ? 'ft' : spec.unitId,
260
+ system: isImperial ? 'us' : 'metric',
261
+ displayUnit: isImperial ? 'ft' : spec.unitId,
262
+ precision,
263
+ clamp,
264
+ })
265
+ : null
206
266
 
207
267
  const handleInputBlur = useCallback(() => {
208
268
  submitValue()
@@ -213,21 +273,21 @@ export function MetricControl({
213
273
  if (e.key === 'Enter') {
214
274
  submitValue()
215
275
  } else if (e.key === 'Escape') {
216
- setInputValue((value * multiplier).toFixed(precision))
276
+ setInputValue(toDisplayValue(value).toFixed(precision))
217
277
  setIsEditing(false)
218
278
  } else if (e.key === 'ArrowUp') {
219
279
  e.preventDefault()
220
- const newV = clamp(value + step / multiplier)
280
+ const newV = clamp(value + toStoredValue(step))
221
281
  applyCommittedValue(newV)
222
- setInputValue((newV * multiplier).toFixed(precision))
282
+ setInputValue(toDisplayValue(newV).toFixed(precision))
223
283
  } else if (e.key === 'ArrowDown') {
224
284
  e.preventDefault()
225
- const newV = clamp(value - step / multiplier)
285
+ const newV = clamp(value - toStoredValue(step))
226
286
  applyCommittedValue(newV)
227
- setInputValue((newV * multiplier).toFixed(precision))
287
+ setInputValue(toDisplayValue(newV).toFixed(precision))
228
288
  }
229
289
  },
230
- [submitValue, value, multiplier, precision, step, clamp, applyCommittedValue],
290
+ [submitValue, value, toDisplayValue, precision, step, clamp, applyCommittedValue, toStoredValue],
231
291
  )
232
292
 
233
293
  return (
@@ -256,6 +316,11 @@ export function MetricControl({
256
316
  <div className="flex shrink-0 justify-end">
257
317
  {isEditing ? (
258
318
  <div className="flex items-center">
319
+ {hint && (
320
+ <span className="mr-1.5 shrink-0 whitespace-nowrap text-[11px] text-muted-foreground/50 tabular-nums">
321
+ {hint}
322
+ </span>
323
+ )}
259
324
  <input
260
325
  autoFocus
261
326
  className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
@@ -265,7 +330,16 @@ export function MetricControl({
265
330
  type="text"
266
331
  value={inputValue}
267
332
  />
268
- {displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
333
+ {displayUnit && (
334
+ <span
335
+ className={cn(
336
+ 'ml-[1px] transition-opacity duration-150',
337
+ hint ? 'opacity-0' : 'text-muted-foreground/40',
338
+ )}
339
+ >
340
+ {displayUnit}
341
+ </span>
342
+ )}
269
343
  </div>
270
344
  ) : (
271
345
  <div
@@ -0,0 +1,247 @@
1
+ 'use client'
2
+
3
+ import {
4
+ generateSceneMaterialId,
5
+ type MaterialSchema,
6
+ type SceneMaterial,
7
+ type SceneMaterialId,
8
+ toSceneMaterialRef,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { Copy, Paintbrush, Pencil, Trash2 } from 'lucide-react'
12
+ import { useEffect, useMemo, useState } from 'react'
13
+ import useEditor from '../../../store/use-editor'
14
+ import { Button } from '../primitives/button'
15
+ import { Input } from '../primitives/input'
16
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
17
+ import { MaterialPropertiesEditor } from './material-properties-editor'
18
+
19
+ type SlotRecord = Record<string, string | undefined>
20
+
21
+ function getSlotRecord(node: unknown): SlotRecord | null {
22
+ if (!node || typeof node !== 'object' || !('slots' in node)) return null
23
+ const slots = (node as { slots?: unknown }).slots
24
+ if (!slots || typeof slots !== 'object' || Array.isArray(slots)) return null
25
+ return slots as SlotRecord
26
+ }
27
+
28
+ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId | null }) {
29
+ const materials = useScene((state) => state.materials)
30
+ const nodes = useScene((state) => state.nodes)
31
+ const addSceneMaterial = useScene((state) => state.addSceneMaterial)
32
+ const updateSceneMaterial = useScene((state) => state.updateSceneMaterial)
33
+ const removeSceneMaterial = useScene((state) => state.removeSceneMaterial)
34
+ const activePaintTarget = useEditor((state) => state.activePaintTarget)
35
+ const activePaintRef = useEditor((state) => state.activePaintMaterial?.materialPreset)
36
+ const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
37
+
38
+ const materialEntries = useMemo(
39
+ () => Object.entries(materials) as [SceneMaterialId, SceneMaterial][],
40
+ [materials],
41
+ )
42
+
43
+ const usageCounts = useMemo(() => {
44
+ const counts = new Map<SceneMaterialId, number>()
45
+ const refToId = new Map<string, SceneMaterialId>()
46
+
47
+ for (const [id] of materialEntries) {
48
+ counts.set(id, 0)
49
+ refToId.set(toSceneMaterialRef(id), id)
50
+ }
51
+
52
+ for (const node of Object.values(nodes)) {
53
+ const slots = getSlotRecord(node)
54
+ if (!slots) continue
55
+
56
+ for (const value of Object.values(slots)) {
57
+ if (typeof value !== 'string') continue
58
+ const materialId = refToId.get(value)
59
+ if (!materialId) continue
60
+ counts.set(materialId, (counts.get(materialId) ?? 0) + 1)
61
+ }
62
+ }
63
+
64
+ return counts
65
+ }, [materialEntries, nodes])
66
+
67
+ return (
68
+ <div className="space-y-2">
69
+ {materialEntries.map(([id, sceneMaterial]) => (
70
+ <SceneMaterialRow
71
+ addSceneMaterial={addSceneMaterial}
72
+ activePaintTarget={activePaintTarget}
73
+ autoEdit={autoEditId === id}
74
+ id={id}
75
+ isActive={activePaintRef === toSceneMaterialRef(id)}
76
+ key={id}
77
+ removeSceneMaterial={removeSceneMaterial}
78
+ sceneMaterial={sceneMaterial}
79
+ setActivePaintMaterial={setActivePaintMaterial}
80
+ updateSceneMaterial={updateSceneMaterial}
81
+ usageCount={usageCounts.get(id) ?? 0}
82
+ />
83
+ ))}
84
+ </div>
85
+ )
86
+ }
87
+
88
+ function SceneMaterialRow({
89
+ id,
90
+ sceneMaterial,
91
+ usageCount,
92
+ activePaintTarget,
93
+ autoEdit,
94
+ isActive,
95
+ addSceneMaterial,
96
+ updateSceneMaterial,
97
+ removeSceneMaterial,
98
+ setActivePaintMaterial,
99
+ }: {
100
+ id: SceneMaterialId
101
+ sceneMaterial: SceneMaterial
102
+ usageCount: number
103
+ activePaintTarget: ReturnType<typeof useEditor.getState>['activePaintTarget']
104
+ autoEdit: boolean
105
+ isActive: boolean
106
+ addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial']
107
+ updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial']
108
+ removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial']
109
+ setActivePaintMaterial: ReturnType<typeof useEditor.getState>['setActivePaintMaterial']
110
+ }) {
111
+ // A freshly-created material (via "+ Custom") mounts with its editor open.
112
+ const [isEditingMaterial, setIsEditingMaterial] = useState(autoEdit)
113
+ const [draftName, setDraftName] = useState(sceneMaterial.name)
114
+ const swatchColor = sceneMaterial.material.properties?.color ?? '#ffffff'
115
+
116
+ useEffect(() => {
117
+ setDraftName(sceneMaterial.name)
118
+ }, [sceneMaterial.name])
119
+
120
+ const commitName = () => {
121
+ const nextName = draftName.trim()
122
+ if (!nextName) {
123
+ setDraftName(sceneMaterial.name)
124
+ return
125
+ }
126
+ if (nextName !== sceneMaterial.name) {
127
+ updateSceneMaterial(id, { name: nextName })
128
+ }
129
+ }
130
+
131
+ const duplicateMaterial = () => {
132
+ addSceneMaterial({
133
+ id: generateSceneMaterialId(),
134
+ name: `${sceneMaterial.name} copy`,
135
+ material: structuredClone(sceneMaterial.material) as MaterialSchema,
136
+ })
137
+ }
138
+
139
+ return (
140
+ <div
141
+ className={`rounded-md border border-border/60 bg-background/40 p-2 ${
142
+ isActive ? 'ring-1 ring-primary ring-inset' : ''
143
+ }`}
144
+ >
145
+ <div className="flex items-center gap-2">
146
+ <span
147
+ className="h-8 w-8 shrink-0 rounded-md border border-border/70"
148
+ style={{ backgroundColor: swatchColor }}
149
+ />
150
+ <Input
151
+ className="h-8 px-2 text-sm"
152
+ onBlur={commitName}
153
+ onChange={(e) => setDraftName(e.target.value)}
154
+ onKeyDown={(e) => {
155
+ if (e.key === 'Enter') {
156
+ e.currentTarget.blur()
157
+ }
158
+ if (e.key === 'Escape') {
159
+ setDraftName(sceneMaterial.name)
160
+ e.currentTarget.blur()
161
+ }
162
+ }}
163
+ value={draftName}
164
+ />
165
+ </div>
166
+
167
+ <div className="mt-2 flex items-center justify-between gap-2">
168
+ <span className="text-muted-foreground text-xs">
169
+ Used by {usageCount} {usageCount === 1 ? 'part' : 'parts'}
170
+ </span>
171
+ <div className="flex items-center gap-1">
172
+ <Tooltip>
173
+ <TooltipTrigger asChild>
174
+ <Button
175
+ aria-label="Paint with"
176
+ onClick={() =>
177
+ setActivePaintMaterial({
178
+ materialPreset: toSceneMaterialRef(id),
179
+ sourceTarget: activePaintTarget,
180
+ })
181
+ }
182
+ size="icon-sm"
183
+ type="button"
184
+ variant="outline"
185
+ >
186
+ <Paintbrush />
187
+ </Button>
188
+ </TooltipTrigger>
189
+ <TooltipContent>Paint with</TooltipContent>
190
+ </Tooltip>
191
+ <Tooltip>
192
+ <TooltipTrigger asChild>
193
+ <Button
194
+ aria-label="Edit"
195
+ aria-pressed={isEditingMaterial}
196
+ onClick={() => setIsEditingMaterial((value) => !value)}
197
+ size="icon-sm"
198
+ type="button"
199
+ variant={isEditingMaterial ? 'default' : 'outline'}
200
+ >
201
+ <Pencil />
202
+ </Button>
203
+ </TooltipTrigger>
204
+ <TooltipContent>Edit</TooltipContent>
205
+ </Tooltip>
206
+ <Tooltip>
207
+ <TooltipTrigger asChild>
208
+ <Button
209
+ aria-label="Duplicate"
210
+ onClick={duplicateMaterial}
211
+ size="icon-sm"
212
+ type="button"
213
+ variant="outline"
214
+ >
215
+ <Copy />
216
+ </Button>
217
+ </TooltipTrigger>
218
+ <TooltipContent>Duplicate</TooltipContent>
219
+ </Tooltip>
220
+ <Tooltip>
221
+ <TooltipTrigger asChild>
222
+ <Button
223
+ aria-label="Delete"
224
+ onClick={() => removeSceneMaterial(id)}
225
+ size="icon-sm"
226
+ type="button"
227
+ variant="outline"
228
+ >
229
+ <Trash2 />
230
+ </Button>
231
+ </TooltipTrigger>
232
+ <TooltipContent>Delete</TooltipContent>
233
+ </Tooltip>
234
+ </div>
235
+ </div>
236
+
237
+ {isEditingMaterial ? (
238
+ <div className="mt-3 border-border/60 border-t pt-3">
239
+ <MaterialPropertiesEditor
240
+ onChange={(material) => updateSceneMaterial(id, { material })}
241
+ value={sceneMaterial.material}
242
+ />
243
+ </div>
244
+ ) : null}
245
+ </div>
246
+ )
247
+ }