@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
@@ -28,7 +28,15 @@ import {
28
28
  useScene,
29
29
  } from '@pascal-app/core'
30
30
  import { markPerfAction, useViewer } from '@pascal-app/viewer'
31
- import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
31
+ import {
32
+ ClipboardPaste,
33
+ Copy,
34
+ GripVertical,
35
+ MoreVertical,
36
+ Plus,
37
+ Trash2,
38
+ X,
39
+ } from 'lucide-react'
32
40
  import {
33
41
  type ButtonHTMLAttributes,
34
42
  type CSSProperties,
@@ -45,6 +53,7 @@ import {
45
53
  } from '../../lib/level-duplication'
46
54
  import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
47
55
  import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
56
+ import { unitMemberLevels, leaveUnitFocus } from '../../lib/units'
48
57
  import { useLinearDisplay } from '../../lib/use-linear-display'
49
58
  import { cn } from '../../lib/utils'
50
59
  import { ActionButton } from './controls/action-button'
@@ -131,12 +140,15 @@ function LevelRow({
131
140
  onDuplicate,
132
141
  onPaste,
133
142
  onRequestDelete,
143
+ unitDotColor,
134
144
  }: {
135
145
  level: LevelNode
136
146
  isSelected: boolean
137
147
  isDragging?: boolean
138
148
  dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>
139
149
  dragHandleRef?: (element: HTMLButtonElement | null) => void
150
+ /** Focused-unit color when the unit has zones on this level. */
151
+ unitDotColor?: string
140
152
  onSelect: () => void
141
153
  onDuplicate: (preset?: LevelDuplicatePreset) => void
142
154
  onPaste?: () => void
@@ -145,11 +157,14 @@ function LevelRow({
145
157
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
146
158
  const [isEditing, setIsEditing] = useState(false)
147
159
  const updateNode = useScene((s) => s.updateNode)
148
- const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2)
160
+ const { isImperial, toDisplay, displayUnit, precision: displayPrecision } = useLinearDisplay('m', 2)
149
161
 
150
162
  const storeyHeight = getStoredLevelHeight(level)
151
- // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays.
152
- const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}`
163
+ // Decimal units keep the compact readout; integer millimeters must retain trailing zeroes.
164
+ const formattedStoreyHeight = toDisplay(storeyHeight).toFixed(displayPrecision)
165
+ const storeyHeightLabel = `${
166
+ displayPrecision > 0 ? formattedStoreyHeight.replace(/0$/, '') : formattedStoreyHeight
167
+ } ${displayUnit}`
153
168
  // Same rule as the site panel and command palette: the ordinal-0 ground
154
169
  // floor is the vertical model's zero anchor and must never be deletable.
155
170
  const canDeleteLevel = level.level !== 0
@@ -215,6 +230,13 @@ function LevelRow({
215
230
  type="button"
216
231
  >
217
232
  <span className="truncate">{getLevelDisplayName(level)}</span>
233
+ {unitDotColor && (
234
+ <span
235
+ className="ml-1.5 h-1.5 w-1.5 shrink-0 rounded-full"
236
+ style={{ backgroundColor: unitDotColor }}
237
+ title="Focused unit has zones here"
238
+ />
239
+ )}
218
240
  </button>
219
241
 
220
242
  {/* Storey height badge — opens the height popover */}
@@ -244,7 +266,7 @@ function LevelRow({
244
266
  precision={3}
245
267
  step={0.1}
246
268
  unit="m"
247
- value={Math.round(storeyHeight * 1000) / 1000}
269
+ value={storeyHeight}
248
270
  />
249
271
  <div className="mt-1.5 grid grid-cols-3 gap-1.5">
250
272
  {heightPresets.map((preset) => (
@@ -343,9 +365,11 @@ function SortableLevelRow({
343
365
  onDuplicate,
344
366
  onPaste,
345
367
  onRequestDelete,
368
+ unitDotColor,
346
369
  }: {
347
370
  level: LevelNode
348
371
  isSelected: boolean
372
+ unitDotColor?: string
349
373
  onSelect: () => void
350
374
  onDuplicate: (preset?: LevelDuplicatePreset) => void
351
375
  onPaste?: () => void
@@ -381,7 +405,42 @@ function SortableLevelRow({
381
405
  onPaste={onPaste}
382
406
  onRequestDelete={onRequestDelete}
383
407
  onSelect={onSelect}
408
+ unitDotColor={unitDotColor}
409
+ />
410
+ </div>
411
+ )
412
+ }
413
+
414
+ // ── Unit focus chip: name + × to end focus, only while a unit is focused ────
415
+
416
+ function UnitFocusChip() {
417
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
418
+ const focusedUnit = useScene((s) => {
419
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
420
+ return unit?.type === 'unit' ? unit : null
421
+ })
422
+
423
+ if (!focusedUnit) return null
424
+
425
+ return (
426
+ <div
427
+ className="mt-4 flex h-7 max-w-48 items-center gap-1.5 rounded-full border border-border bg-background/90 pr-1 pl-2.5 font-medium text-xs shadow-2xl backdrop-blur-md"
428
+ data-testid="unit-focus-chip"
429
+ >
430
+ <span
431
+ className="h-2 w-2 shrink-0 rounded-full"
432
+ style={{ backgroundColor: focusedUnit.color }}
384
433
  />
434
+ <span className="truncate">{focusedUnit.name || 'Unit'}</span>
435
+ <button
436
+ aria-label="Exit unit focus"
437
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
438
+ onClick={() => leaveUnitFocus()}
439
+ title="Exit unit focus"
440
+ type="button"
441
+ >
442
+ <X className="h-3 w-3" />
443
+ </button>
385
444
  </div>
386
445
  )
387
446
  }
@@ -398,6 +457,17 @@ export function FloatingLevelSelector() {
398
457
 
399
458
  const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
400
459
  const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
460
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
461
+ const focusedUnitColor = useScene((s) => {
462
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
463
+ return unit?.type === 'unit' ? unit.color : null
464
+ })
465
+ const focusedUnitLevelIds = useScene(
466
+ useShallow((s) => {
467
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
468
+ return unit?.type === 'unit' ? unitMemberLevels(unit, s.nodes).map((level) => level.id) : []
469
+ }),
470
+ )
401
471
  const sensors = useSensors(
402
472
  useSensor(PointerSensor, {
403
473
  activationConstraint: { distance: 4 },
@@ -628,6 +698,11 @@ export function FloatingLevelSelector() {
628
698
  <SortableLevelRow
629
699
  isSelected={isSelected}
630
700
  level={level}
701
+ unitDotColor={
702
+ focusedUnitColor && focusedUnitLevelIds.includes(level.id)
703
+ ? focusedUnitColor
704
+ : undefined
705
+ }
631
706
  onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
632
707
  onPaste={() => handlePasteToLevel(level)}
633
708
  onRequestDelete={() => setDeletingLevel(level)}
@@ -658,6 +733,7 @@ export function FloatingLevelSelector() {
658
733
  </SortableContext>
659
734
  </DndContext>
660
735
  </div>
736
+ <UnitFocusChip />
661
737
  </div>
662
738
 
663
739
  {/* Delete confirmation dialog */}
@@ -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
  )
@@ -72,8 +72,9 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode
72
72
  )}
73
73
  {parametrics.groups.map((group, gi) => (
74
74
  <MultiGroupFields
75
+ defaultExpanded={group.defaultExpanded}
75
76
  fields={group.fields as ParamField<AnyNode>[]}
76
- key={`group-${gi}`}
77
+ key={`${nodeType}-${group.label}-${gi}`}
77
78
  nodeIds={nodeIds}
78
79
  nodeType={nodeType}
79
80
  parametrics={parametrics}
@@ -88,12 +89,14 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode
88
89
  }
89
90
 
90
91
  function MultiGroupFields({
92
+ defaultExpanded,
91
93
  title,
92
94
  fields,
93
95
  nodeIds,
94
96
  nodeType,
95
97
  parametrics,
96
98
  }: {
99
+ defaultExpanded?: boolean
97
100
  title: string
98
101
  fields: ParamField<AnyNode>[]
99
102
  nodeIds: AnyNodeId[]
@@ -108,7 +111,7 @@ function MultiGroupFields({
108
111
  )
109
112
  if (genericFields.length === 0 || !anyVisible) return null
110
113
  return (
111
- <PanelSection title={title}>
114
+ <PanelSection defaultExpanded={defaultExpanded} title={title}>
112
115
  {genericFields.map((field, fi) => {
113
116
  if (
114
117
  String(field.key) === 'height' &&
@@ -254,7 +257,7 @@ function MultiVec3Field({
254
257
  restoreOnCommit={false}
255
258
  step={0.05}
256
259
  unit="m"
257
- value={Math.round(axisValue * 100) / 100}
260
+ value={axisValue}
258
261
  />
259
262
  )
260
263
  })}
@@ -159,7 +159,7 @@ export function ParametricFieldControl({
159
159
  restoreOnCommit={!onCommit}
160
160
  step={0.05}
161
161
  unit="m"
162
- value={Math.round(axisValue * 100) / 100}
162
+ value={axisValue}
163
163
  />
164
164
  )
165
165
  })}
@@ -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(() => {
@@ -157,7 +145,11 @@ export function ParametricInspector({
157
145
  width={320}
158
146
  >
159
147
  {parametrics.groups.map((group, gi) => (
160
- <PanelSection key={`group-${gi}`} title={group.label}>
148
+ <PanelSection
149
+ defaultExpanded={group.defaultExpanded}
150
+ key={`${nodeType}-${group.label}-${gi}`}
151
+ title={group.label}
152
+ >
161
153
  {group.fields.map((field, fi) => (
162
154
  <FieldRenderer
163
155
  key={`field-${gi}-${fi}-${String(field.key)}`}
@@ -175,7 +167,7 @@ export function ParametricInspector({
175
167
  )}
176
168
  {(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
177
169
  <PanelSection title="Actions">
178
- <ActionGroup className={isZone ? 'flex-col' : undefined}>
170
+ <ActionGroup className={isZone ? 'flex-col' : parametrics.actions?.length ? 'grid grid-cols-2 gap-2' : undefined}>
179
171
  {canMove && (
180
172
  <ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
181
173
  )}
@@ -376,7 +376,7 @@ export function ReferencePanel() {
376
376
  precision={2}
377
377
  step={0.1}
378
378
  unit="m"
379
- value={Math.round(node.position[0] * 100) / 100}
379
+ value={node.position[0]}
380
380
  />
381
381
  <SliderControl
382
382
  label={
@@ -394,7 +394,7 @@ export function ReferencePanel() {
394
394
  precision={2}
395
395
  step={0.1}
396
396
  unit="m"
397
- value={Math.round(node.position[1] * 100) / 100}
397
+ value={node.position[1]}
398
398
  />
399
399
  <SliderControl
400
400
  label={
@@ -412,7 +412,7 @@ export function ReferencePanel() {
412
412
  precision={2}
413
413
  step={0.1}
414
414
  unit="m"
415
- value={Math.round(node.position[2] * 100) / 100}
415
+ value={node.position[2]}
416
416
  />
417
417
  </PanelSection>
418
418
 
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ import { Check } from 'lucide-react'
4
+ import type { ButtonHTMLAttributes } from 'react'
5
+ import { cn } from './../../../lib/utils'
6
+
7
+ interface CheckboxProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
8
+ checked: boolean
9
+ onCheckedChange: (checked: boolean) => void
10
+ }
11
+
12
+ export function Checkbox({ checked, onCheckedChange, className, ...props }: CheckboxProps) {
13
+ return (
14
+ <button
15
+ aria-checked={checked}
16
+ className={cn(
17
+ 'flex h-3.5 w-3.5 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring',
18
+ checked
19
+ ? 'border-primary bg-primary text-primary-foreground'
20
+ : 'border-border bg-background/60 text-transparent hover:border-foreground/40',
21
+ className,
22
+ )}
23
+ role="checkbox"
24
+ type="button"
25
+ {...props}
26
+ onClick={(event) => {
27
+ event.stopPropagation()
28
+ onCheckedChange(!checked)
29
+ }}
30
+ >
31
+ <Check className="h-2.5 w-2.5" strokeWidth={3} />
32
+ </button>
33
+ )
34
+ }
@@ -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">