@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import type { AnyNodeId } from '@pascal-app/core'
4
- import { DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core'
4
+ import { DEFAULT_LEVEL_HEIGHT, LevelNode, type UnitNode, useScene } from '@pascal-app/core'
5
5
  import { useViewer } from '@pascal-app/viewer'
6
6
  import {
7
7
  AppWindow,
@@ -15,6 +15,7 @@ import {
15
15
  EyeOff,
16
16
  FileJson,
17
17
  Grid3X3,
18
+ Group,
18
19
  Hexagon,
19
20
  Layers,
20
21
  Map,
@@ -39,6 +40,7 @@ import {
39
40
  import { useEffect } from 'react'
40
41
  import { getHistoryCommandState, runRedo, runUndo } from '../../../lib/history'
41
42
  import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
43
+ import { createUnitInBuilding, enterUnitFocus, leaveUnitFocus } from '../../../lib/units'
42
44
  import { useCommandRegistry } from '../../../store/use-command-registry'
43
45
  import type { StructureTool } from '../../../store/use-editor'
44
46
  import useEditor from '../../../store/use-editor'
@@ -56,13 +58,25 @@ export function EditorCommands() {
56
58
  const setPreviewMode = useEditor((s) => s.setPreviewMode)
57
59
 
58
60
  const exportScene = useViewer((s) => s.exportScene)
61
+ // Focusable units are listed one command each; the key changes only when a
62
+ // unit is added, removed or renamed.
63
+ const unitListKey = useScene((s) =>
64
+ Object.values(s.nodes)
65
+ .filter((n): n is UnitNode => n.type === 'unit')
66
+ .map((n) => `${n.id}:${n.name ?? ''}`)
67
+ .join('|'),
68
+ )
59
69
 
60
70
  // Re-register when exportScene availability changes (it's a conditional action)
61
71
  useEffect(() => {
72
+ void unitListKey
62
73
  const run = (fn: () => void) => {
63
74
  fn()
64
75
  setOpen(false)
65
76
  }
77
+ const focusableUnits = Object.values(useScene.getState().nodes).filter(
78
+ (n): n is UnitNode => n.type === 'unit',
79
+ )
66
80
 
67
81
  const activateTool = (tool: StructureTool) => {
68
82
  run(() => {
@@ -249,6 +263,44 @@ export function EditorCommands() {
249
263
  }),
250
264
  },
251
265
 
266
+ // ── Units ────────────────────────────────────────────────────────────
267
+ {
268
+ id: 'editor.unit.new',
269
+ label: 'New unit',
270
+ group: 'Units',
271
+ icon: <Group className="h-4 w-4" />,
272
+ keywords: ['unit', 'apartment', 'hotel', 'room', 'add', 'create', 'new'],
273
+ when: () => Object.values(useScene.getState().nodes).some((n) => n.type === 'building'),
274
+ execute: () =>
275
+ run(() => {
276
+ const { nodes } = useScene.getState()
277
+ const selectedBuildingId = useViewer.getState().selection.buildingId
278
+ const building =
279
+ (selectedBuildingId ? nodes[selectedBuildingId] : undefined) ??
280
+ Object.values(nodes).find((n) => n.type === 'building')
281
+ if (building?.type !== 'building') return
282
+ createUnitInBuilding(building.id)
283
+ }),
284
+ },
285
+ ...focusableUnits.map((unit) => ({
286
+ id: `editor.unit.focus.${unit.id}`,
287
+ label: `Focus unit: ${unit.name || 'Unit'}`,
288
+ group: 'Units',
289
+ icon: <Group className="h-4 w-4" />,
290
+ keywords: ['unit', 'focus', 'apartment', unit.name || 'Unit'],
291
+ when: () => useViewer.getState().focusedUnitId !== unit.id,
292
+ execute: () => run(() => enterUnitFocus(unit.id)),
293
+ })),
294
+ {
295
+ id: 'editor.unit.exit-focus',
296
+ label: 'Exit unit focus',
297
+ group: 'Units',
298
+ icon: <Group className="h-4 w-4" />,
299
+ keywords: ['unit', 'focus', 'exit', 'leave', 'apartment'],
300
+ when: () => !!useViewer.getState().focusedUnitId,
301
+ execute: () => run(() => leaveUnitFocus()),
302
+ },
303
+
252
304
  // ── Viewer Controls ──────────────────────────────────────────────────
253
305
  {
254
306
  id: 'editor.viewer.wall-mode',
@@ -431,6 +483,7 @@ export function EditorCommands() {
431
483
  isPreviewMode,
432
484
  setPreviewMode,
433
485
  exportScene,
486
+ unitListKey,
434
487
  ])
435
488
 
436
489
  return null
@@ -1,20 +1,9 @@
1
1
  'use client'
2
2
 
3
- import {
4
- type AnyNodeId,
5
- generateSceneMaterialId,
6
- type SceneMaterialId,
7
- toSceneMaterialRef,
8
- useScene,
9
- } from '@pascal-app/core'
10
- import { useViewer } from '@pascal-app/viewer'
3
+ import { type SceneMaterialId } from '@pascal-app/core'
11
4
  import { Eraser, Plus, RotateCcw } from 'lucide-react'
12
- import { useEffect, useState } from 'react'
13
- import {
14
- buildResetSurfaceMaterialUpdates,
15
- resolvePaintTargetFromSelection,
16
- } from './../../../lib/material-paint'
17
- import useEditor from './../../../store/use-editor'
5
+ import { useState } from 'react'
6
+ import { useMaterialPaintPanelModel } from '../../../lib/material-paint-panel-model'
18
7
  import { Button } from '../primitives/button'
19
8
  import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
20
9
  import { MaterialPicker } from './material-picker'
@@ -33,57 +22,9 @@ export type MaterialPaintPanelProps = {
33
22
  }
34
23
 
35
24
  export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) {
36
- const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
37
- const activePaintTarget = useEditor((state) => state.activePaintTarget)
38
- const armMaterialPaint = useEditor((state) => state.armMaterialPaint)
39
- const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
40
- const paintEraser = useEditor((state) => state.paintEraser)
41
- const setPaintEraser = useEditor((state) => state.setPaintEraser)
42
- // Id of a just-created scene material whose inline editor should open on mount.
25
+ const { activePaintMaterial, paintEraser, setPaintEraser, canResetSelection, resetSelection, materialCount, selectMaterial, createCustomMaterial: createMaterial } = useMaterialPaintPanelModel()
43
26
  const [autoEditMaterialId, setAutoEditMaterialId] = useState<SceneMaterialId | null>(null)
44
- const selectedIds = useViewer((state) => state.selection.selectedIds)
45
- const nodes = useScene((state) => state.nodes)
46
- const materialCount = useScene((state) => Object.keys(state.materials).length)
47
- const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
48
- const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
49
- const canResetSelection =
50
- selectedNode != null && resolvePaintTargetFromSelection({ nodes, selectedId }) != null
51
-
52
- useEffect(() => {
53
- const selectedPaintTarget = resolvePaintTargetFromSelection({ nodes, selectedId })
54
- if (selectedPaintTarget) {
55
- setActivePaintTarget(selectedPaintTarget)
56
- }
57
- }, [nodes, selectedId, setActivePaintTarget])
58
-
59
- const resetSelection = () => {
60
- if (!selectedNode) return
61
- useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode))
62
- }
63
-
64
- // Create a blank custom scene material, select it as the brush (`scene:` ref so
65
- // edits propagate), and open its inline editor. Available from any category.
66
- const createCustomMaterial = () => {
67
- const id = generateSceneMaterialId()
68
- const count = Object.keys(useScene.getState().materials).length
69
- useScene.getState().addSceneMaterial({
70
- id,
71
- name: `Material ${count + 1}`,
72
- material: {
73
- preset: 'custom',
74
- properties: {
75
- color: '#ffffff',
76
- roughness: 0.5,
77
- metalness: 0,
78
- opacity: 1,
79
- transparent: false,
80
- side: 'front',
81
- },
82
- },
83
- })
84
- armMaterialPaint({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
85
- setAutoEditMaterialId(id)
86
- }
27
+ const createCustomMaterial = () => setAutoEditMaterialId(createMaterial())
87
28
 
88
29
  return (
89
30
  <div className="flex h-full min-h-0 w-full flex-col">
@@ -117,9 +58,7 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
117
58
  <div className="min-h-0 flex-1" data-guide-target="paint-material">
118
59
  <MaterialPicker
119
60
  onCreateMaterialRequest={onCreateMaterialRequest}
120
- onSelectMaterialPreset={(materialPreset) => {
121
- armMaterialPaint({ materialPreset, sourceTarget: activePaintTarget })
122
- }}
61
+ onSelectMaterialPreset={selectMaterial}
123
62
  selectedMaterialPreset={activePaintMaterial?.materialPreset}
124
63
  />
125
64
  </div>
@@ -1,23 +1,11 @@
1
1
  'use client'
2
2
 
3
- import {
4
- getCatalogMaterialById,
5
- getDynamicLibraryMaterials,
6
- getLibraryMaterialIdFromRef,
7
- getLibraryMaterialsVersion,
8
- getMaterialsForCategory,
9
- MATERIAL_CATEGORIES,
10
- type MaterialCatalogItem,
11
- type MaterialSource,
12
- type MaterialTarget,
13
- subscribeLibraryMaterials,
14
- toLibraryMaterialRef,
15
- } from '@pascal-app/core'
3
+ import { type MaterialTarget, toLibraryMaterialRef } from '@pascal-app/core'
16
4
  import { Plus } from 'lucide-react'
17
- import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
5
+ import { useMaterialCatalogModel, type MaterialSourceFilter } from '../../../lib/material-catalog-model'
18
6
  import { triggerSFX } from '../../../lib/sfx-bus'
19
7
 
20
- export type MaterialSourceFilter = MaterialSource
8
+ export type { MaterialSourceFilter } from '../../../lib/material-catalog-model'
21
9
 
22
10
  export type MaterialPickerProps = {
23
11
  selectedMaterialPreset?: string
@@ -28,23 +16,10 @@ export type MaterialPickerProps = {
28
16
  onCreateMaterialRequest?: () => void
29
17
  }
30
18
 
31
- // No 'All': the browse surfaces (Items / Rooms / Build) dropped it and default
32
- // to the Pascal library — the combined list buried the curated set.
33
- const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
34
- { id: 'pascal', label: 'Pascal' },
35
- { id: 'mine', label: 'Mine' },
36
- { id: 'workspace', label: 'Workspace' },
37
- { id: 'community', label: 'Community' },
38
- ]
39
-
40
- function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
19
+ function getCategoryLabel(category: string) {
41
20
  return category.charAt(0).toUpperCase() + category.slice(1)
42
21
  }
43
22
 
44
- function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) {
45
- return items.filter((item) => (item.source ?? 'pascal') === filter)
46
- }
47
-
48
23
  /**
49
24
  * Catalog material picker: a fixed row of category tabs and a source filter row
50
25
  * over a scrollable grid of swatches. Scene-material creation lives in the
@@ -57,40 +32,7 @@ export function MaterialPicker({
57
32
  disabled = false,
58
33
  onCreateMaterialRequest,
59
34
  }: MaterialPickerProps) {
60
- const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
61
- MATERIAL_CATEGORIES[0],
62
- )
63
- const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('pascal')
64
- // Version counter so host registrations/unregistrations re-render the picker.
65
- const libraryVersion = useSyncExternalStore(
66
- subscribeLibraryMaterials,
67
- getLibraryMaterialsVersion,
68
- getLibraryMaterialsVersion,
69
- )
70
- const hasWorkspaceMaterials = useMemo(
71
- () => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'),
72
- [libraryVersion],
73
- )
74
- const visibleSourceFilters = SOURCE_FILTERS.filter(
75
- (filter) => filter.id !== 'workspace' || hasWorkspaceMaterials,
76
- )
77
- const availableCategories = MATERIAL_CATEGORIES.filter(
78
- (category) => getMaterialsForCategory(category).length > 0,
79
- )
80
- const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter)
81
-
82
- // Keep the visible category in sync with the externally-selected catalog
83
- // material (a `scene:` ref matches no catalog entry, so the tab stays put).
84
- useEffect(() => {
85
- const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined
86
- const entry = getCatalogMaterialById(catalogId)
87
- if (entry?.category) setSelectedCategory(entry.category)
88
- }, [selectedMaterialPreset])
89
-
90
- const handleCatalogSelect = (materialId: string) => {
91
- if (disabled) return
92
- onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
93
- }
35
+ const { selectedCategory, setSelectedCategory, sourceFilter, setSourceFilter, visibleSourceFilters, availableCategories, catalogItems, select: handleCatalogSelect } = useMaterialCatalogModel(selectedMaterialPreset, onSelectMaterialPreset, disabled)
94
36
 
95
37
  return (
96
38
  <div
@@ -108,10 +50,6 @@ export function MaterialPicker({
108
50
  key={category}
109
51
  onClick={() => {
110
52
  setSelectedCategory(category)
111
- // Auto-select the first material in the category so the brush is
112
- // immediately ready (and the swatch shows as selected).
113
- const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0]
114
- if (first) handleCatalogSelect(first.id)
115
53
  }}
116
54
  type="button"
117
55
  >
@@ -2,11 +2,7 @@
2
2
 
3
3
  import { useScene } from '@pascal-app/core'
4
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
- import {
6
- lingoUnitSpec,
7
- measurementHint,
8
- parseMeasurement,
9
- } from '../../../lib/measurement-parser'
5
+ import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
10
6
  import { useLinearDisplay } from '../../../lib/use-linear-display'
11
7
  import { cn } from '../../../lib/utils'
12
8
 
@@ -31,8 +27,8 @@ export function MetricControl({
31
27
  onCommit,
32
28
  min = Number.NEGATIVE_INFINITY,
33
29
  max = Number.POSITIVE_INFINITY,
34
- precision = 2,
35
- step = 1,
30
+ precision: storedPrecision = 2,
31
+ step: storedStep = 1,
36
32
  className,
37
33
  unit = '',
38
34
  restoreOnCommit = true,
@@ -40,9 +36,12 @@ export function MetricControl({
40
36
  const {
41
37
  isImperial,
42
38
  displayUnit,
39
+ parseUnit,
40
+ precision,
41
+ step,
43
42
  toDisplay: toDisplayValue,
44
43
  toStored: toStoredValue,
45
- } = useLinearDisplay(unit, precision)
44
+ } = useLinearDisplay(unit, storedPrecision, storedStep)
46
45
 
47
46
  const clamp = useCallback(
48
47
  (val: number) => {
@@ -110,7 +109,14 @@ export function MetricControl({
110
109
 
111
110
  container.addEventListener('wheel', handleWheel, { passive: false })
112
111
  return () => container.removeEventListener('wheel', handleWheel)
113
- }, [isEditing, step, clamp, applyCommittedValue, toStoredValue, roundStoredValueForDisplayPrecision])
112
+ }, [
113
+ isEditing,
114
+ step,
115
+ clamp,
116
+ applyCommittedValue,
117
+ toStoredValue,
118
+ roundStoredValueForDisplayPrecision,
119
+ ])
114
120
 
115
121
  useEffect(() => {
116
122
  if (!isHovered || isEditing) return
@@ -226,7 +232,7 @@ export function MetricControl({
226
232
  const spec = lingoUnitSpec(unit)
227
233
  let stored = spec
228
234
  ? parseMeasurement(inputValue, spec, {
229
- bareUnit: isImperial ? 'ft' : spec.unitId,
235
+ bareUnit: parseUnit ?? spec.unitId,
230
236
  system: isImperial ? 'us' : 'metric',
231
237
  })
232
238
  : null
@@ -244,6 +250,7 @@ export function MetricControl({
244
250
  inputValue,
245
251
  unit,
246
252
  isImperial,
253
+ parseUnit,
247
254
  applyCommittedValue,
248
255
  clamp,
249
256
  toStoredValue,
@@ -256,9 +263,9 @@ export function MetricControl({
256
263
  const hint =
257
264
  isEditing && spec
258
265
  ? measurementHint(inputValue, spec, {
259
- bareUnit: isImperial ? 'ft' : spec.unitId,
266
+ bareUnit: parseUnit ?? spec.unitId,
260
267
  system: isImperial ? 'us' : 'metric',
261
- displayUnit: isImperial ? 'ft' : spec.unitId,
268
+ displayUnit: parseUnit ?? spec.unitId,
262
269
  precision,
263
270
  clamp,
264
271
  })
@@ -287,7 +294,16 @@ export function MetricControl({
287
294
  setInputValue(toDisplayValue(newV).toFixed(precision))
288
295
  }
289
296
  },
290
- [submitValue, value, toDisplayValue, precision, step, clamp, applyCommittedValue, toStoredValue],
297
+ [
298
+ submitValue,
299
+ value,
300
+ toDisplayValue,
301
+ precision,
302
+ step,
303
+ clamp,
304
+ applyCommittedValue,
305
+ toStoredValue,
306
+ ],
291
307
  )
292
308
 
293
309
  return (
@@ -2,11 +2,7 @@
2
2
 
3
3
  import { useScene } from '@pascal-app/core'
4
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
- import {
6
- lingoUnitSpec,
7
- measurementHint,
8
- parseMeasurement,
9
- } from '../../../lib/measurement-parser'
5
+ import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
10
6
  import { useLinearDisplay } from '../../../lib/use-linear-display'
11
7
  import { cn } from '../../../lib/utils'
12
8
 
@@ -15,6 +11,8 @@ interface SliderControlProps {
15
11
  value: number
16
12
  onChange: (value: number) => void
17
13
  onCommit?: (value: number) => void
14
+ onCancel?: () => void
15
+ previewWhileTyping?: boolean
18
16
  min?: number
19
17
  max?: number
20
18
  precision?: number
@@ -58,21 +56,20 @@ export function SliderControl({
58
56
  value,
59
57
  onChange,
60
58
  onCommit,
59
+ onCancel,
60
+ previewWhileTyping = false,
61
61
  min = Number.NEGATIVE_INFINITY,
62
62
  max = Number.POSITIVE_INFINITY,
63
- precision = 0,
64
- step = 1,
63
+ precision: storedPrecision = 0,
64
+ step: storedStep = 1,
65
65
  className,
66
66
  unit = '',
67
67
  restoreOnCommit = true,
68
68
  mixed = false,
69
69
  }: SliderControlProps) {
70
- // Display/storage conversion so the value honors the metric/imperial toggle.
71
- // `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the
72
- // stored unit (meters for `unit === 'm'`); the step, drag deltas, text field
73
- // and rendered number are in the DISPLAY unit (feet when imperial). For
74
- // metric and non-length units these conversions are the identity.
75
- const { isImperial, displayUnit, toDisplay, toStored } = useLinearDisplay(unit, precision)
70
+ // Values and bounds stay in meters; gestures and input use the displayed unit.
71
+ const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } =
72
+ useLinearDisplay(unit, storedPrecision, storedStep)
76
73
 
77
74
  const [isEditing, setIsEditing] = useState(false)
78
75
  const [isDragging, setIsDragging] = useState(false)
@@ -94,6 +91,9 @@ export function SliderControl({
94
91
  anchorValue: number
95
92
  stepMultiplier: number
96
93
  } | null>(null)
94
+ const cancelRef = useRef(onCancel)
95
+ cancelRef.current = onCancel
96
+ const editingRef = useRef(false)
97
97
  const labelRef = useRef<HTMLDivElement>(null)
98
98
  const shown = dragDisplay ?? value
99
99
  const valueRef = useRef(shown)
@@ -106,7 +106,9 @@ export function SliderControl({
106
106
  (storedValue: number, displayDelta: number, displayStep: number) =>
107
107
  clamp(
108
108
  toStored(
109
- Number.parseFloat((toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep))),
109
+ Number.parseFloat(
110
+ (toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep)),
111
+ ),
110
112
  ),
111
113
  ),
112
114
  [clamp, toDisplay, toStored],
@@ -222,41 +224,82 @@ export function SliderControl({
222
224
  [onChange, onCommit, restoreOnCommit],
223
225
  )
224
226
 
227
+ const cancelEdit = useCallback(() => {
228
+ const drag = dragRef.current
229
+ dragRef.current = null
230
+ editingRef.current = false
231
+ if (drag) {
232
+ if (!onCancel) onChange(drag.originValue)
233
+ useScene.temporal.getState().resume()
234
+ }
235
+ onCancel?.()
236
+ setIsDragging(false)
237
+ setIsEditing(false)
238
+ setDragDisplay(null)
239
+ }, [onCancel, onChange])
240
+
241
+ useEffect(
242
+ () => () => {
243
+ if (dragRef.current) useScene.temporal.getState().resume()
244
+ if (dragRef.current || editingRef.current) cancelRef.current?.()
245
+ },
246
+ [],
247
+ )
248
+
249
+ useEffect(() => {
250
+ if (!isDragging) return
251
+ const escape = (event: KeyboardEvent) => {
252
+ if (event.key === 'Escape') cancelEdit()
253
+ }
254
+ window.addEventListener('keydown', escape)
255
+ return () => window.removeEventListener('keydown', escape)
256
+ }, [isDragging, cancelEdit])
257
+
225
258
  const handleValueClick = useCallback(() => {
259
+ editingRef.current = true
226
260
  setIsEditing(true)
227
261
  setInputValue(toDisplay(value).toFixed(precision))
228
262
  }, [value, precision, toDisplay])
229
263
 
264
+ const parseInputValue = useCallback(
265
+ (text: string) => {
266
+ const spec = lingoUnitSpec(unit)
267
+ const parsed = spec
268
+ ? parseMeasurement(text, spec, {
269
+ bareUnit: parseUnit ?? spec.unitId,
270
+ system: isImperial ? 'us' : 'metric',
271
+ })
272
+ : null
273
+ const bare = Number(text)
274
+ const stored = parsed ?? (text.trim() && Number.isFinite(bare) ? toStored(bare) : null)
275
+ return stored === null
276
+ ? null
277
+ : clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
278
+ },
279
+ [unit, parseUnit, isImperial, toStored, clamp, toDisplay, precision],
280
+ )
281
+
230
282
  const submitValue = useCallback(() => {
231
- const spec = lingoUnitSpec(unit)
232
- let stored = spec
233
- ? parseMeasurement(inputValue, spec, {
234
- bareUnit: isImperial ? 'ft' : spec.unitId,
235
- system: isImperial ? 'us' : 'metric',
236
- })
237
- : null
238
- if (stored === null) {
239
- // Fallback: a bare number typed in the DISPLAY unit → convert to stored.
240
- const numValue = Number.parseFloat(inputValue)
241
- stored = Number.isFinite(numValue) ? toStored(numValue) : null
242
- }
243
- if (stored === null) {
283
+ if (!editingRef.current) return
284
+ editingRef.current = false
285
+ const nextValue = parseInputValue(inputValue)
286
+ if (nextValue === null) {
287
+ onCancel?.()
244
288
  setInputValue(toDisplay(value).toFixed(precision))
245
289
  } else {
246
- const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
247
290
  onChange(nextValue)
248
291
  onCommit?.(nextValue)
249
292
  }
250
293
  setIsEditing(false)
251
- }, [inputValue, unit, isImperial, onChange, onCommit, clamp, precision, value, toDisplay, toStored])
294
+ }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, precision])
252
295
 
253
296
  const spec = lingoUnitSpec(unit)
254
297
  const hint =
255
298
  isEditing && spec
256
299
  ? measurementHint(inputValue, spec, {
257
- bareUnit: isImperial ? 'ft' : spec.unitId,
300
+ bareUnit: parseUnit ?? spec.unitId,
258
301
  system: isImperial ? 'us' : 'metric',
259
- displayUnit: isImperial ? 'ft' : spec.unitId,
302
+ displayUnit: parseUnit ?? spec.unitId,
260
303
  precision,
261
304
  clamp,
262
305
  })
@@ -267,8 +310,7 @@ export function SliderControl({
267
310
  if (e.key === 'Enter') {
268
311
  submitValue()
269
312
  } else if (e.key === 'Escape') {
270
- setInputValue(toDisplay(value).toFixed(precision))
271
- setIsEditing(false)
313
+ cancelEdit()
272
314
  } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
273
315
  e.preventDefault()
274
316
  const direction = e.key === 'ArrowUp' ? 1 : -1
@@ -278,7 +320,7 @@ export function SliderControl({
278
320
  setInputValue(toDisplay(newV).toFixed(precision))
279
321
  }
280
322
  },
281
- [submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
323
+ [submitValue, cancelEdit, value, precision, step, applyDisplayDelta, onChange, toDisplay],
282
324
  )
283
325
 
284
326
  const displayValue = toDisplay(shown)
@@ -302,6 +344,10 @@ export function SliderControl({
302
344
  onPointerDown={handleLabelPointerDown}
303
345
  onPointerMove={handleLabelPointerMove}
304
346
  onPointerUp={handleLabelPointerUp}
347
+ onPointerCancel={cancelEdit}
348
+ onLostPointerCapture={() => {
349
+ if (dragRef.current) cancelEdit()
350
+ }}
305
351
  ref={labelRef}
306
352
  >
307
353
  {/* Grip dots — 2×3 grid */}
@@ -333,7 +379,13 @@ export function SliderControl({
333
379
  autoFocus
334
380
  className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
335
381
  onBlur={submitValue}
336
- onChange={(e) => setInputValue(e.target.value)}
382
+ onChange={(e) => {
383
+ setInputValue(e.target.value)
384
+ if (previewWhileTyping) {
385
+ const next = parseInputValue(e.target.value)
386
+ if (next !== null) onChange(next)
387
+ }
388
+ }}
337
389
  onKeyDown={handleInputKeyDown}
338
390
  type="text"
339
391
  value={inputValue}