@pascal-app/editor 1.0.1 → 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 (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
@@ -11,6 +11,8 @@ interface SliderControlProps {
11
11
  value: number
12
12
  onChange: (value: number) => void
13
13
  onCommit?: (value: number) => void
14
+ onCancel?: () => void
15
+ previewWhileTyping?: boolean
14
16
  min?: number
15
17
  max?: number
16
18
  precision?: number
@@ -54,6 +56,8 @@ export function SliderControl({
54
56
  value,
55
57
  onChange,
56
58
  onCommit,
59
+ onCancel,
60
+ previewWhileTyping = false,
57
61
  min = Number.NEGATIVE_INFINITY,
58
62
  max = Number.POSITIVE_INFINITY,
59
63
  precision: storedPrecision = 0,
@@ -87,6 +91,9 @@ export function SliderControl({
87
91
  anchorValue: number
88
92
  stepMultiplier: number
89
93
  } | null>(null)
94
+ const cancelRef = useRef(onCancel)
95
+ cancelRef.current = onCancel
96
+ const editingRef = useRef(false)
90
97
  const labelRef = useRef<HTMLDivElement>(null)
91
98
  const shown = dragDisplay ?? value
92
99
  const valueRef = useRef(shown)
@@ -217,45 +224,74 @@ export function SliderControl({
217
224
  [onChange, onCommit, restoreOnCommit],
218
225
  )
219
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
+
220
258
  const handleValueClick = useCallback(() => {
259
+ editingRef.current = true
221
260
  setIsEditing(true)
222
261
  setInputValue(toDisplay(value).toFixed(precision))
223
262
  }, [value, precision, toDisplay])
224
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
+
225
282
  const submitValue = useCallback(() => {
226
- const spec = lingoUnitSpec(unit)
227
- let stored = spec
228
- ? parseMeasurement(inputValue, spec, {
229
- bareUnit: parseUnit ?? spec.unitId,
230
- system: isImperial ? 'us' : 'metric',
231
- })
232
- : null
233
- if (stored === null) {
234
- // Fallback: a bare number typed in the DISPLAY unit → convert to stored.
235
- const numValue = Number.parseFloat(inputValue)
236
- stored = Number.isFinite(numValue) ? toStored(numValue) : null
237
- }
238
- if (stored === null) {
283
+ if (!editingRef.current) return
284
+ editingRef.current = false
285
+ const nextValue = parseInputValue(inputValue)
286
+ if (nextValue === null) {
287
+ onCancel?.()
239
288
  setInputValue(toDisplay(value).toFixed(precision))
240
289
  } else {
241
- const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
242
290
  onChange(nextValue)
243
291
  onCommit?.(nextValue)
244
292
  }
245
293
  setIsEditing(false)
246
- }, [
247
- inputValue,
248
- unit,
249
- isImperial,
250
- parseUnit,
251
- onChange,
252
- onCommit,
253
- clamp,
254
- precision,
255
- value,
256
- toDisplay,
257
- toStored,
258
- ])
294
+ }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, precision])
259
295
 
260
296
  const spec = lingoUnitSpec(unit)
261
297
  const hint =
@@ -274,8 +310,7 @@ export function SliderControl({
274
310
  if (e.key === 'Enter') {
275
311
  submitValue()
276
312
  } else if (e.key === 'Escape') {
277
- setInputValue(toDisplay(value).toFixed(precision))
278
- setIsEditing(false)
313
+ cancelEdit()
279
314
  } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
280
315
  e.preventDefault()
281
316
  const direction = e.key === 'ArrowUp' ? 1 : -1
@@ -285,7 +320,7 @@ export function SliderControl({
285
320
  setInputValue(toDisplay(newV).toFixed(precision))
286
321
  }
287
322
  },
288
- [submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
323
+ [submitValue, cancelEdit, value, precision, step, applyDisplayDelta, onChange, toDisplay],
289
324
  )
290
325
 
291
326
  const displayValue = toDisplay(shown)
@@ -309,6 +344,10 @@ export function SliderControl({
309
344
  onPointerDown={handleLabelPointerDown}
310
345
  onPointerMove={handleLabelPointerMove}
311
346
  onPointerUp={handleLabelPointerUp}
347
+ onPointerCancel={cancelEdit}
348
+ onLostPointerCapture={() => {
349
+ if (dragRef.current) cancelEdit()
350
+ }}
312
351
  ref={labelRef}
313
352
  >
314
353
  {/* Grip dots — 2×3 grid */}
@@ -340,7 +379,13 @@ export function SliderControl({
340
379
  autoFocus
341
380
  className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
342
381
  onBlur={submitValue}
343
- 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
+ }}
344
389
  onKeyDown={handleInputKeyDown}
345
390
  type="text"
346
391
  value={inputValue}
@@ -29,6 +29,7 @@ import useEditor, { getActiveContinuationContext } from '../../../store/use-edit
29
29
  import useInteractionScope, {
30
30
  useActiveHandleDrag,
31
31
  useMovingNode,
32
+ useReshapingNode,
32
33
  } from '../../../store/use-interaction-scope'
33
34
  import { BuildingHelper } from './building-helper'
34
35
  import { ContextualHelperPanel } from './contextual-helper-panel'
@@ -84,6 +85,7 @@ function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualSho
84
85
  }
85
86
 
86
87
  type ActiveModifierKeys = {
88
+ alt: boolean
87
89
  command: boolean
88
90
  shift: boolean
89
91
  }
@@ -93,6 +95,7 @@ const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {}
93
95
 
94
96
  function useActiveModifierKeys(): ActiveModifierKeys {
95
97
  const [modifiers, setModifiers] = useState<ActiveModifierKeys>({
98
+ alt: false,
96
99
  command: false,
97
100
  shift: false,
98
101
  })
@@ -101,6 +104,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
101
104
  const updateModifiers = (event: KeyboardEvent) => {
102
105
  const isKeyDown = event.type === 'keydown'
103
106
  setModifiers({
107
+ alt: event.altKey || (isKeyDown && event.key === 'Alt'),
104
108
  command:
105
109
  event.metaKey ||
106
110
  event.ctrlKey ||
@@ -109,7 +113,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
109
113
  })
110
114
  }
111
115
  const clearModifiers = () => {
112
- setModifiers({ command: false, shift: false })
116
+ setModifiers({ alt: false, command: false, shift: false })
113
117
  }
114
118
 
115
119
  window.addEventListener('keydown', updateModifiers)
@@ -135,6 +139,7 @@ export function HelperManager() {
135
139
  const workspaceMode = useEditor((s) => s.workspaceMode)
136
140
  const scope = useInteractionScope((s) => s.scope)
137
141
  const movingNode = useMovingNode()
142
+ const reshapingNode = useReshapingNode()
138
143
  const activeHandleDrag = useActiveHandleDrag()
139
144
  const selectedIds = useViewer((s) => s.selection.selectedIds)
140
145
  const isMobile = useIsMobile()
@@ -190,10 +195,12 @@ export function HelperManager() {
190
195
  : single?.type === 'duct-fitting' || single?.type === 'pipe-fitting'
191
196
  ? 'fitting'
192
197
  : null
198
+ const hasOpeningRadiusSelection = single?.type === 'door' || single?.type === 'window'
193
199
  return resolveSelectModeHelpHints({
194
200
  selectedCount: selectedNodes.length,
195
201
  hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
196
202
  hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
203
+ hasOpeningRadiusSelection,
197
204
  commandPressed: modifiers.command,
198
205
  shiftPressed: modifiers.shift,
199
206
  mepSelection,
@@ -211,15 +218,16 @@ export function HelperManager() {
211
218
  // tools — editor shortcut hints would only mislead there.
212
219
  if (workspaceMode === 'studio') return null
213
220
 
214
- // Rotating a node (or a multi-selection group) via its in-world gizmo:
215
- // advertise Shift = free rotation, the same angle-step bypass wall drafting
216
- // exposes. Takes priority over the idle select-mode hints since a handle
217
- // drag is the active interaction.
218
221
  if (
219
222
  activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL ||
220
223
  activeHandleDrag?.label === GROUP_ROTATE_DRAG_LABEL
221
224
  ) {
222
- return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
225
+ return (
226
+ <ContextualHelperPanel
227
+ hints={resolveRotateHandleHelpHints(modifiers.alt)}
228
+ snapContext={snapContext}
229
+ />
230
+ )
223
231
  }
224
232
 
225
233
  // Group-move drag / pick-up: the drag resolves to the 'item' snap context
@@ -254,6 +262,15 @@ export function HelperManager() {
254
262
  // before the select branch so the idle "drag selected / add objects" hints
255
263
  // never leak over an in-progress reshape — and it gets its own snapping chip.
256
264
  if (scope.kind === 'reshaping') {
265
+ // A kind's own reshape brings its hints (`def.affordanceHints[reshape]`).
266
+ const hints = reshapingNode
267
+ ? nodeRegistry.get(reshapingNode.type)?.affordanceHints?.[scope.reshape]
268
+ : undefined
269
+ if (hints) {
270
+ return (
271
+ <RegisteredToolHelper hints={hints} shiftPressed={modifiers.shift} snapContext={snapContext} />
272
+ )
273
+ }
257
274
  return <ContextualHelperPanel hints={reshapingHints(scope.reshape)} snapContext={snapContext} />
258
275
  }
259
276
 
@@ -297,6 +314,7 @@ export function HelperManager() {
297
314
  return <ContextualHelperPanel hints={contextualEditHints} snapContext={snapContext} />
298
315
  }
299
316
 
317
+
300
318
  // Idle select only — an active scope (handle-drag, box-select, …) must not show
301
319
  // the idle selection hints.
302
320
  if (mode === 'select' && scope.kind === 'idle') {
@@ -1,8 +1,7 @@
1
1
  import type { ToolHint } from '@pascal-app/core'
2
- import { useMemo, useSyncExternalStore } from 'react'
3
2
  import type { ContinuationContext } from '../../../lib/continuation'
4
3
  import type { SnapContext } from '../../../lib/snapping-mode'
5
- import useEditor from '../../../store/use-editor'
4
+ import { useVisibleToolHints } from '../../../lib/panel-tool-options'
6
5
  import { ContextualHelperPanel } from './contextual-helper-panel'
7
6
 
8
7
  /**
@@ -25,36 +24,7 @@ export function RegisteredToolHelper({
25
24
  snapContext?: SnapContext | null
26
25
  continuationContext?: ContinuationContext | null
27
26
  }) {
28
- // Live vertex count of an in-progress polygon draft, so hints gated on a
29
- // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
30
- const draftVertexCount = useEditor((s) => s.draftVertexCount)
31
- const visibilityStore = useMemo(
32
- () => ({
33
- subscribe: (onChange: () => void) => {
34
- const unsubscribers = hints.flatMap((hint) =>
35
- hint.visible ? [hint.visible.subscribe(onChange)] : [],
36
- )
37
- return () => {
38
- for (const unsubscribe of unsubscribers) unsubscribe()
39
- }
40
- },
41
- getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
42
- }),
43
- [hints],
44
- )
45
- useSyncExternalStore(
46
- visibilityStore.subscribe,
47
- visibilityStore.getSnapshot,
48
- visibilityStore.getSnapshot,
49
- )
50
- // Some hints are replaced by live contextual chips, so keep the generic
51
- // registry renderer from duplicating stale/static versions.
52
- const visible = hints.filter(
53
- (hint) =>
54
- !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
55
- hint.visible?.value() !== false &&
56
- (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
57
- )
27
+ const visible = useVisibleToolHints(hints)
58
28
  if (visible.length === 0 && !snapContext && !continuationContext) return null
59
29
  // Hints carrying a live-state `chip` render as mode chips next to the
60
30
  // snapping / continuation rows; the rest stay static key rows.
@@ -1,13 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import type { AssetInput } from '@pascal-app/core'
4
- import { resolveCdnUrl, useViewer } from '@pascal-app/viewer'
4
+ import { resolveCdnUrl } from '@pascal-app/viewer'
5
5
  import { useEffect } from 'react'
6
6
  import { triggerSFX } from './../../../lib/sfx-bus'
7
7
  import { cn } from './../../../lib/utils'
8
8
  import useEditor, { type CatalogCategory } from './../../../store/use-editor'
9
9
  import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge'
10
- import { CATALOG_ITEMS, type CatalogItem } from './catalog-items'
10
+ import { activateCatalogItem, filterCatalogItems, isCatalogItemSelected } from '../../../lib/catalog-panel-model'
11
11
 
12
12
  export function ItemCatalog({
13
13
  category,
@@ -32,26 +32,7 @@ export function ItemCatalog({
32
32
  emptyState?: React.ReactNode
33
33
  }) {
34
34
  const selectedItem = useEditor((state) => state.selectedItem)
35
- const setSelectedItem = useEditor((state) => state.setSelectedItem)
36
- const setMode = useEditor((state) => state.setMode)
37
- const setTool = useEditor((state) => state.setTool)
38
-
39
- const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS
40
- // Server-provided results bypass all local filtering; otherwise filter by category/search/tags
41
- const filteredItems: CatalogItem[] =
42
- overrideItems ??
43
- (() => {
44
- const categoryItems = search
45
- ? sourceItems
46
- : sourceItems.filter((item) => item.category === category)
47
- return categoryItems.filter((item) => {
48
- const tags = item.tags ?? []
49
- if (activePlacementTag && !tags.includes(activePlacementTag)) return false
50
- if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
51
- if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
52
- return true
53
- })
54
- })()
35
+ const filteredItems = filterCatalogItems({ category, items: itemsOverride, overrideItems, search, activePlacementTag, activeFunctionalTag })
55
36
 
56
37
  if (filteredItems.length === 0 && emptyState) {
57
38
  return <>{emptyState}</>
@@ -64,7 +45,7 @@ export function ItemCatalog({
64
45
  >
65
46
  {leadingTile}
66
47
  {filteredItems.map((item, index) => {
67
- const isSelected = selectedItem?.src === item?.src
48
+ const isSelected = isCatalogItemSelected(item, selectedItem)
68
49
  const snapTarget = resolveAssetSnapTarget(item?.attachTo)
69
50
  return (
70
51
  <button
@@ -75,13 +56,7 @@ export function ItemCatalog({
75
56
  key={index}
76
57
  onClick={() => {
77
58
  triggerSFX('sfx:menu-click')
78
- // Drop the current selection before arming placement — keeping
79
- // it would route shortcuts (rotate & co) to both the ghost and
80
- // the selected node.
81
- useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
82
- setSelectedItem(item)
83
- setTool(item.tool ?? 'item')
84
- setMode('build')
59
+ activateCatalogItem(item)
85
60
  }}
86
61
  onMouseEnter={() => triggerSFX('sfx:menu-hover')}
87
62
  type="button"
@@ -58,6 +58,32 @@ function ceilingCustomHeight(node: CeilingNode, nodes: Record<string, AnyNode>):
58
58
  return Math.min(resolved, max)
59
59
  }
60
60
 
61
+ export function applyMultiHeightMode(
62
+ nodeIds: AnyNodeId[],
63
+ next: 'storey' | 'custom',
64
+ parametrics: ParametricDescriptor<AnyNode>,
65
+ ) {
66
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
67
+ commitMultiNodeFields(
68
+ nodeIds,
69
+ (node) => {
70
+ const isCustom = (node as { height?: number }).height != null
71
+ if (next === 'custom') {
72
+ if (isCustom) return {}
73
+ if (node.type === 'ceiling') return { height: ceilingCustomHeight(node, nodes) }
74
+ if (node.type === 'wall') {
75
+ return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes)) }
76
+ }
77
+ return {}
78
+ }
79
+ if (!isCustom) return {}
80
+ if (node.type === 'wall') return wallFollowsLevelPatch(node, nodes)
81
+ return { height: undefined }
82
+ },
83
+ parametrics,
84
+ )
85
+ }
86
+
61
87
  export function MultiHeightModeField({
62
88
  nodeIds,
63
89
  nodeType,
@@ -97,27 +123,7 @@ export function MultiHeightModeField({
97
123
 
98
124
  const applyMode = useCallback(
99
125
  (next: 'storey' | 'custom') => {
100
- const nodes = useScene.getState().nodes as Record<string, AnyNode>
101
- commitMultiNodeFields(
102
- nodeIds,
103
- (node) => {
104
- const isCustom = (node as { height?: number }).height != null
105
- if (next === 'custom') {
106
- if (isCustom) return {}
107
- if (node.type === 'ceiling') {
108
- return { height: ceilingCustomHeight(node, nodes) }
109
- }
110
- if (node.type === 'wall') {
111
- return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes)) }
112
- }
113
- return {}
114
- }
115
- if (!isCustom) return {}
116
- if (node.type === 'wall') return wallFollowsLevelPatch(node, nodes)
117
- return { height: undefined }
118
- },
119
- parametrics,
120
- )
126
+ applyMultiHeightMode(nodeIds, next, parametrics)
121
127
  },
122
128
  [nodeIds, parametrics],
123
129
  )
@@ -15,6 +15,7 @@ import { Icon } from '@iconify/react'
15
15
  import { Move, Trash2 } from 'lucide-react'
16
16
  import { type ComponentType, lazy, Suspense, useCallback } from 'react'
17
17
  import { resolveMoveActionNode } from '../../../lib/direct-manipulation'
18
+ import { commitParametricNodeFields } from '../../../lib/parametric-node-update'
18
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
19
20
  import { collectZoneContentIds } from '../../../lib/zone-content'
20
21
  import useEditor from '../../../store/use-editor'
@@ -61,22 +62,9 @@ export function ParametricInspector({
61
62
  const handleUpdate = useCallback(
62
63
  (patch: Partial<AnyNode>) => {
63
64
  if (!selectedId) return
64
- const scene = useScene.getState()
65
- const node = scene.nodes[selectedId]
66
- if (parametrics?.derive && node) {
67
- const next = { ...node, ...patch } as AnyNode
68
- patch = { ...patch, ...parametrics.derive(next, patch, node as AnyNode) }
69
- }
70
- // Bundle the edited node + any reconcile follow-ups into ONE
71
- // updateNodes call so a single inspector edit is a single undo step.
72
- const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [{ id: selectedId, data: patch }]
73
- if (parametrics?.reconcile && node) {
74
- const next = { ...node, ...patch } as AnyNode
75
- updates.push(...parametrics.reconcile(node as AnyNode, next))
76
- }
77
- scene.updateNodes(updates)
65
+ commitParametricNodeFields(selectedId, patch)
78
66
  },
79
- [selectedId, parametrics],
67
+ [selectedId],
80
68
  )
81
69
 
82
70
  const clearSelection = useCallback(() => {
@@ -20,6 +20,10 @@ const MOUSE_SHORTCUTS = {
20
20
  icon: 'ph:mouse-right-click-fill',
21
21
  label: 'Right click',
22
22
  },
23
+ Scroll: {
24
+ icon: 'ph:mouse-scroll-fill',
25
+ label: 'Scroll',
26
+ },
23
27
  } as const
24
28
 
25
29
  // The platform-agnostic command modifier. Both Cmd and Ctrl bind the action; we
@@ -28,12 +28,12 @@ export function AudioSettingsDialog() {
28
28
  <DialogTrigger asChild>
29
29
  <Button className="w-full justify-start gap-2" variant="outline">
30
30
  {muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
31
- Audio Settings
31
+ Audio settings
32
32
  </Button>
33
33
  </DialogTrigger>
34
34
  <DialogContent className="sm:max-w-[425px]">
35
35
  <DialogHeader>
36
- <DialogTitle>Audio Settings</DialogTitle>
36
+ <DialogTitle>Audio settings</DialogTitle>
37
37
  <DialogDescription>Adjust volume levels and mute settings</DialogDescription>
38
38
  </DialogHeader>
39
39
  <div className="space-y-6 py-4">