@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
@@ -9,7 +9,6 @@ import {
9
9
  } from '@pascal-app/core'
10
10
  import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer'
11
11
  import { useEffect } from 'react'
12
- import { Vector3 } from 'three'
13
12
  import {
14
13
  cutSelectionToEditorClipboard,
15
14
  deleteSelection,
@@ -18,9 +17,9 @@ import {
18
17
  import {
19
18
  classifyParticipant,
20
19
  collectParticipants,
21
- computeGroupBox,
22
- expandToComponent,
20
+ groupPlanBounds,
23
21
  levelFrame,
22
+ planBoundsCenter,
24
23
  rotateGroupPatches,
25
24
  } from '../components/editor/group-transform-shared'
26
25
  import { steppedRotation } from '../components/tools/item/placement-math'
@@ -36,7 +35,10 @@ import { leaveUnitFocus } from '../lib/units'
36
35
  import { toggleWindowOpenState } from '../lib/window-interaction'
37
36
  import useDeleteConfirmation from '../store/use-delete-confirmation'
38
37
  import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
39
- import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
38
+ import useInteractionScope, {
39
+ getMovingNode,
40
+ isInteractionSubtreeDraft,
41
+ } from '../store/use-interaction-scope'
40
42
  import { groupCurrentSelection, ungroupCurrentSelection } from '../store/use-session-groups'
41
43
 
42
44
  // References (guide/scan) are selected via `useEditor.selectedReferenceId`, not
@@ -66,27 +68,20 @@ function rotateGroupSelection(direction: 1 | -1): boolean {
66
68
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
67
69
  )
68
70
  if (participantIds.length === 0) return false
69
- const fullIds = expandToComponent(participantIds, nodes, levelId)
70
- const { starts, links } = collectParticipants(fullIds, nodes, levelId)
71
+ const { starts, links } = collectParticipants(participantIds, nodes, levelId)
71
72
  if (starts.length === 0) return false
72
73
 
73
- // Same pivot as the 3D gizmo: the selection's world bbox center, converted
74
- // into the level frame before orbiting placements (a rotated building would
75
- // otherwise displace the centre).
76
- const box = computeGroupBox(fullIds)
77
- if (!box) return false
78
- const worldCenter = new Vector3(
79
- (box.min.x + box.max.x) / 2,
80
- box.min.y,
81
- (box.min.z + box.max.z) / 2,
82
- )
83
- const localCenter = worldCenter.applyMatrix4(levelFrame(levelId).inverse)
74
+ // Same pivot as the dashed boxes and the rotate gizmo: the selection's box
75
+ // centre in the level frame.
76
+ const bounds = groupPlanBounds(starts, levelFrame(levelId).inverse)
77
+ if (!bounds) return false
78
+ const [pivotX, pivotZ] = planBoundsCenter(bounds)
84
79
 
85
80
  // R (+45° yaw) orbits by -45° in the atan2 x→z sense: yaw = rotation - delta
86
81
  // (see rotateGroupPatches), so keyboard direction matches the single-node
87
82
  // steppedRotation sense.
88
83
  const delta = -direction * (Math.PI / 4)
89
- const patches = rotateGroupPatches(starts, links, { x: localCenter.x, z: localCenter.z }, delta)
84
+ const patches = rotateGroupPatches(starts, links, { x: pivotX, z: pivotZ }, delta)
90
85
  // Space detection stays out: a rigid rotation of existing walls must not
91
86
  // re-create the room's auto floors/ceilings at the new bearing.
92
87
  pauseSpaceDetection()
@@ -116,7 +111,13 @@ const exitToSelectAfterUnconsumedCancel = () => {
116
111
  const currentPhase = useEditor.getState().phase
117
112
  const currentStructureLayer = useEditor.getState().structureLayer
118
113
 
119
- useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
114
+ useInteractionScope
115
+ .getState()
116
+ .endIf(
117
+ (sc) =>
118
+ (sc.kind === 'placing' && isInteractionSubtreeDraft()) ||
119
+ (sc.kind === 'reshaping' && sc.reshape === 'hole'),
120
+ )
120
121
 
121
122
  // From zone mode, return to structure select
122
123
  if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
@@ -134,6 +135,20 @@ const exitToSelectAfterUnconsumedCancel = () => {
134
135
  useEditor.getState().setSelectedReferenceId(null)
135
136
  }
136
137
 
138
+ // Cancel the active editor action with the same consume-or-exit semantics as
139
+ // Escape. Spatial inputs use this instead of unconditionally selecting the
140
+ // Select tool, so multi-step tools can keep their tool active after clearing
141
+ // the current draft.
142
+ export const cancelActiveTool = () => {
143
+ _toolCancelConsumed = false
144
+ emitter.emit('tool:cancel')
145
+ if (!_toolCancelConsumed) {
146
+ if (leaveUnitFocus()) useEditor.getState().armToolMode({ mode: 'select' })
147
+ else exitToSelectAfterUnconsumedCancel()
148
+ }
149
+ return _toolCancelConsumed
150
+ }
151
+
137
152
  // ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…)
138
153
  // reads as "abort this action", not history undo — behave exactly like Escape
139
154
  // and report whether anything was in flight so the undo/redo arms know to
@@ -176,7 +191,8 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => {
176
191
  return true
177
192
  }
178
193
 
179
- export const isToolOwnedRotation = () => {
194
+ /** Whether an armed tool owns the rotation key (`R` or `T`) instead of the selection. */
195
+ export const isToolOwnedRotation = (key: 'r' | 't' = 'r') => {
180
196
  const editor = useEditor.getState()
181
197
  const moving = getMovingNode()
182
198
  if (
@@ -196,7 +212,10 @@ export const isToolOwnedRotation = () => {
196
212
  // exist. Without this check, selecting an existing item in the 2D plan
197
213
  // while the item tool is armed silently drops the global rotate key.
198
214
  (editor.tool === 'item' && editor.selectedItem !== null) ||
199
- editor.tool === 'lean-to-extension')
215
+ editor.tool === 'lean-to-extension' ||
216
+ // R toggles the wall tool between line and rectangle drawing; T stays
217
+ // the selection's.
218
+ (editor.tool === 'wall' && key === 'r'))
200
219
  )
201
220
  }
202
221
 
@@ -385,15 +404,9 @@ export const useKeyboard = ({
385
404
  return
386
405
  }
387
406
 
388
- _toolCancelConsumed = false
389
- emitter.emit('tool:cancel')
390
-
391
407
  // Only switch to select mode if no tool had an active mid-action to cancel.
392
408
  // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
393
- if (!_toolCancelConsumed) {
394
- if (leaveUnitFocus()) useEditor.getState().armToolMode({ mode: 'select' })
395
- else exitToSelectAfterUnconsumedCancel()
396
- }
409
+ cancelActiveTool()
397
410
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
398
411
  e.preventDefault()
399
412
  useEditor.getState().setPhase('site')
@@ -619,7 +632,7 @@ export const useKeyboard = ({
619
632
  } else if (
620
633
  (e.key === 't' || e.key === 'T') &&
621
634
  !isVersionPreviewMode &&
622
- !isToolOwnedRotation() &&
635
+ !isToolOwnedRotation('t') &&
623
636
  canRunGlobalRotationShortcut()
624
637
  ) {
625
638
  // Rotate selected node counter-clockwise
package/src/index.tsx CHANGED
@@ -49,7 +49,7 @@ export { FloatingActionMenu as FloatingMenu } from './components/editor/floating
49
49
  // camera controls via the `useViewer.inputDragging` / `useEditor.movingNode`
50
50
  // flags. Tools place onto `useViewer.selection.levelId`, so the host must set a
51
51
  // building + level selection first.
52
- export { Grid } from './components/editor/grid'
52
+ export { EDITOR_GRID_INPUT_NAME, Grid } from './components/editor/grid'
53
53
  export {
54
54
  DimensionPill,
55
55
  type DimensionPillPart,
@@ -79,12 +79,18 @@ export {
79
79
  useInvisibleHitAreaMaterial,
80
80
  } from './components/editor/node-arrow-handles'
81
81
  export { QuickMeasurementCard } from './components/editor/quick-measurement-card'
82
+ export { SelectionManager } from './components/editor/selection-manager'
82
83
  export {
83
84
  type SnapshotCameraData,
84
85
  ThumbnailGenerator,
85
86
  } from './components/editor/thumbnail-generator'
87
+ export { WallMoveSideHandles } from './components/editor/wall-move-side-handles'
86
88
  export { useFloorplanRender } from './components/editor-2d/floorplan-render-context'
87
89
  export { FloorplanDimensionRenderer } from './components/editor-2d/renderers/floorplan-dimension-renderer'
90
+ export {
91
+ type DraftWallMeasurement,
92
+ FloorplanDraftWallMeasurement,
93
+ } from './components/editor-2d/renderers/floorplan-draft-wall-measurement'
88
94
  export { FloorplanGeometryRenderer } from './components/editor-2d/renderers/floorplan-geometry-renderer'
89
95
  export {
90
96
  FloorplanNodePreview,
@@ -140,6 +146,10 @@ export {
140
146
  export { MoveRegistryNodeTool } from './components/tools/registry/move-registry-node-tool'
141
147
  export { useRegistryToolContext } from './components/tools/registry-tool-context'
142
148
  export { CursorSphere } from './components/tools/shared/cursor-sphere'
149
+ export {
150
+ DRAFT_LABEL_Y_OFFSET,
151
+ DraftMeasurementLabel,
152
+ } from './components/tools/shared/draft-measurement-label'
143
153
  export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
144
154
  export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview'
145
155
  export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility'
@@ -221,6 +231,7 @@ export {
221
231
  CameraActions as ToolbarRight,
222
232
  CameraActions as ViewerToolbarRight,
223
233
  } from './components/ui/action-menu/camera-actions'
234
+ export { furnishTools } from './components/ui/action-menu/furnish-tools'
224
235
  export {
225
236
  ViewToggles as ToolbarLeft,
226
237
  ViewToggles as ViewerToolbarLeft,
@@ -247,6 +258,19 @@ export { FloatingLevelSelector } from './components/ui/floating-level-selector'
247
258
  export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
248
259
  // Item collections UI — used by the kind-owned ItemPanel in nodes/.
249
260
  export { CollectionsPopover } from './components/ui/panels/collections/collections-popover'
261
+ export {
262
+ resolveHomogeneousSelection,
263
+ resolveUniqueSelectionIds,
264
+ } from './components/ui/panels/homogeneous-selection'
265
+ export {
266
+ commitMultiNodeFields,
267
+ fieldVisibleForAll,
268
+ firstNumericFieldValue,
269
+ firstVec3FieldValue,
270
+ reduceFieldValue,
271
+ reduceHeightBoundMode,
272
+ } from './components/ui/panels/multi-field-value'
273
+ export { applyMultiHeightMode } from './components/ui/panels/multi-height-mode'
250
274
  // Phase 5 Stage E — kinds with bespoke editors (slab holes list,
251
275
  // ceiling height presets, etc.) use `parametrics.customPanel` to mount
252
276
  // a kind-owned panel and need PanelWrapper for the chrome.
@@ -269,6 +293,7 @@ export {
269
293
  } from './components/ui/primitives/shortcut-token'
270
294
  export { useSidebarStore } from './components/ui/primitives/sidebar'
271
295
  export { Slider } from './components/ui/primitives/slider'
296
+ export { Tooltip, TooltipContent, TooltipTrigger } from './components/ui/primitives/tooltip'
272
297
  export { SceneLoader } from './components/ui/scene-loader'
273
298
  export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
274
299
  export { ItemsPanel } from './components/ui/sidebar/panels/items-panel'
@@ -330,7 +355,7 @@ export type { SaveStatus } from './hooks/use-auto-save'
330
355
  // can express their affordances declaratively in their own folder.
331
356
  export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
332
357
  // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
333
- export { markToolCancelConsumed } from './hooks/use-keyboard'
358
+ export { cancelActiveTool, markToolCancelConsumed } from './hooks/use-keyboard'
334
359
  export { useReducedMotion } from './hooks/use-reduced-motion'
335
360
  export { type Selection, useSelection } from './hooks/use-selection'
336
361
  export {
@@ -339,6 +364,11 @@ export {
339
364
  type PlacementSurface,
340
365
  publishPlacementSurface,
341
366
  } from './lib/active-placement-surface'
367
+ export {
368
+ activateCatalogItem,
369
+ filterCatalogItems,
370
+ isCatalogItemSelected,
371
+ } from './lib/catalog-panel-model'
342
372
  export {
343
373
  CEILING_ALIGNMENT_THRESHOLD_M,
344
374
  type CeilingPlanSnapInput,
@@ -361,6 +391,7 @@ export {
361
391
  continuationContextOf,
362
392
  nextContinuation,
363
393
  } from './lib/continuation'
394
+ export { canDirectMoveNode } from './lib/direct-manipulation'
364
395
  export { createEditorApi } from './lib/editor-api'
365
396
  export {
366
397
  clearStructuralElevationGuide,
@@ -435,6 +466,7 @@ export {
435
466
  type FloorplanMode,
436
467
  isFloorplanToolAvailableInMode,
437
468
  } from './lib/floorplan/floorplan-mode'
469
+ export { clientToPlan } from './lib/floorplan/plan-coords'
438
470
  export {
439
471
  commitFreshPlacementSubtree,
440
472
  createFreshPlacementSubtree,
@@ -474,6 +506,8 @@ export {
474
506
  movingNodeOf,
475
507
  scopeNodeId,
476
508
  } from './lib/interaction/scope'
509
+ export { isEditableKeyboardTarget } from './lib/keyboard-pan'
510
+ export { useMaterialCatalogModel } from './lib/material-catalog-model'
477
511
  export {
478
512
  type ActivePaintMaterial,
479
513
  buildResetSurfaceMaterialUpdates,
@@ -483,6 +517,7 @@ export {
483
517
  getActivePaintMaterialLabel,
484
518
  hasActivePaintMaterial,
485
519
  } from './lib/material-paint'
520
+ export { useMaterialPaintPanelModel } from './lib/material-paint-panel-model'
486
521
  export {
487
522
  CREATABLE_MEASUREMENT_KINDS,
488
523
  type CreatableMeasurementKind,
@@ -529,6 +564,21 @@ export type {
529
564
  ModelExportFormat,
530
565
  ModelExportOptions,
531
566
  } from './lib/model-export'
567
+ export {
568
+ cyclePaintScope,
569
+ type PaintHoverInfo,
570
+ type PaintScope,
571
+ paintScopeLabel,
572
+ } from './lib/paint-scope'
573
+ export {
574
+ getNodePanelModel,
575
+ type NodePanelModel,
576
+ PANEL_MODEL_EXTENSION,
577
+ type PanelAction,
578
+ type PanelRow,
579
+ } from './lib/panel-rows'
580
+ export { type PanelToolOption, usePanelToolHints } from './lib/panel-tool-options'
581
+ export { commitParametricNodeFields } from './lib/parametric-node-update'
532
582
  export { consumePlacementDragRelease } from './lib/placement-drag-release'
533
583
  export {
534
584
  addFreshPlacementMetadata,
@@ -562,7 +612,7 @@ export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from '.
562
612
  export type { SceneGraph } from './lib/scene'
563
613
  export { applySceneGraphToEditor } from './lib/scene'
564
614
  export { movementSfxStepKey } from './lib/sfx/movement-tick'
565
- export { triggerSFX } from './lib/sfx-bus'
615
+ export { emitDeleteSFX, triggerSFX } from './lib/sfx-bus'
566
616
  export { playSFX, type SFXName, type SFXPlaybackOptions } from './lib/sfx-player'
567
617
  export {
568
618
  clearSlabSnapFeedback,
@@ -575,12 +625,14 @@ export {
575
625
  type SlabPlanSnapResult,
576
626
  } from './lib/slab-plan-snap'
577
627
  export {
628
+ cycleSnappingModeIn,
578
629
  getSnappingModeLabel,
579
630
  resolveSnapFlags,
580
631
  type SnapContext,
581
632
  type SnapFlags,
582
633
  type SnappingMode,
583
634
  } from './lib/snapping-mode'
635
+ export { getSpatialPointerId, spatialPointerInput } from './lib/spatial-pointer-input'
584
636
  export { duplicateStairSubtree } from './lib/stair-duplication'
585
637
  export {
586
638
  getBuildingLevelsForLevel,
@@ -590,6 +642,7 @@ export {
590
642
  resolveStairPlacementLevelId,
591
643
  resolveStairToLevelId,
592
644
  } from './lib/stair-levels'
645
+ export { surfaceAttachmentId, surfaceFramePose, updateSurfaceNode } from './lib/surface-attachment'
593
646
  export {
594
647
  clearSurfacePlanSnapFeedback,
595
648
  resolveSurfacePlanPointSnap,
@@ -597,14 +650,20 @@ export {
597
650
  type SurfacePlanSnapInput,
598
651
  type SurfacePlanSnapResult,
599
652
  } from './lib/surface-plan-snap'
653
+ export { useTerrainPanelRows } from './lib/terrain-panel-model'
600
654
  export {
655
+ brushRadiusRange,
656
+ clipTerrainPatchToSite,
657
+ commitStroke,
601
658
  fieldExtentForSite,
602
659
  flattenSite,
603
660
  resetSiteTerrain,
604
661
  resolveFlattenTarget,
605
662
  sculptFieldForSite,
663
+ terrainPointInsideSite,
606
664
  } from './lib/terrain-sculpt'
607
665
  export { exportSceneToUsdz, type UsdzExportOptions } from './lib/usdz-export'
666
+ export { useLinearDisplay } from './lib/use-linear-display'
608
667
  // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
609
668
  // nodes` so they don't need their own copy / their own tailwind-merge
610
669
  // dependency.
@@ -652,6 +711,7 @@ export {
652
711
  isAngleSnapActive,
653
712
  isGridSnapActive,
654
713
  isMagneticSnapActive,
714
+ selectDefaultBuildingAndLevel,
655
715
  } from './store/use-editor'
656
716
  export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
657
717
  export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
@@ -0,0 +1,39 @@
1
+ import { useViewer } from '@pascal-app/viewer'
2
+ import { CATALOG_ITEMS, type CatalogItem } from '../components/ui/item-catalog/catalog-items'
3
+ import useEditor, { type CatalogCategory } from '../store/use-editor'
4
+
5
+ export function filterCatalogItems({
6
+ category,
7
+ items = CATALOG_ITEMS,
8
+ search = '',
9
+ activePlacementTag,
10
+ activeFunctionalTag,
11
+ overrideItems,
12
+ }: {
13
+ category?: CatalogCategory
14
+ items?: readonly CatalogItem[]
15
+ search?: string
16
+ activePlacementTag?: string | null
17
+ activeFunctionalTag?: string | null
18
+ overrideItems?: readonly CatalogItem[]
19
+ }): readonly CatalogItem[] {
20
+ return (
21
+ overrideItems ??
22
+ items.filter((item) => {
23
+ if (!search && category && item.category !== category) return false
24
+ if (activePlacementTag && !item.tags?.includes(activePlacementTag)) return false
25
+ if (activeFunctionalTag && !item.tags?.includes(activeFunctionalTag)) return false
26
+ return !search || item.name.toLowerCase().includes(search.toLowerCase())
27
+ })
28
+ )
29
+ }
30
+
31
+ export function activateCatalogItem(item: CatalogItem) {
32
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
33
+ const editor = useEditor.getState()
34
+ editor.setSelectedItem(item)
35
+ editor.setTool(item.tool ?? 'item')
36
+ editor.setMode('build')
37
+ }
38
+
39
+ export { isCatalogItemSelected } from './catalog-selection'
@@ -0,0 +1,27 @@
1
+ import { test } from 'bun:test'
2
+ import assert from 'node:assert/strict'
3
+ import { isCatalogItemSelected } from './catalog-selection'
4
+
5
+ test('no selection leaves every tile unhighlighted, including items without model URLs', () => {
6
+ for (const item of [{ id: 'chair' }, { id: 'cabinet' }]) {
7
+ assert.equal(isCatalogItemSelected(item, null), false)
8
+ }
9
+ })
10
+
11
+ test('only the chosen item is highlighted when model URLs are missing or shared', () => {
12
+ for (const src of [undefined, '/shared-model.glb']) {
13
+ const items = [
14
+ { id: 'first', src },
15
+ { id: 'second', src },
16
+ ]
17
+ assert.deepEqual(
18
+ items.map((item) => isCatalogItemSelected(item, items[1]!)),
19
+ [false, true],
20
+ )
21
+ }
22
+ })
23
+
24
+ test('selection follows identity across reloaded catalog objects', () => {
25
+ assert.equal(isCatalogItemSelected({ id: 'chair' }, { id: 'chair' }), true)
26
+ assert.equal(isCatalogItemSelected({ id: 'chair' }, { id: 'table' }), false)
27
+ })
@@ -0,0 +1,8 @@
1
+ import type { CatalogItem } from '../components/ui/item-catalog/catalog-items'
2
+
3
+ export function isCatalogItemSelected(
4
+ item: Pick<CatalogItem, 'id'>,
5
+ selected: Pick<CatalogItem, 'id'> | null,
6
+ ) {
7
+ return selected != null && item.id === selected.id
8
+ }
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { resolveSelectModeHelpHints } from './contextual-help'
2
+ import { resolveRotateHandleHelpHints, resolveSelectModeHelpHints } from './contextual-help'
3
+
4
+ test('rotation advertises Alt as the temporary free-rotation override', () => {
5
+ expect(resolveRotateHandleHelpHints(false)).toEqual([
6
+ { keys: ['Alt'], label: 'Hold to rotate freely', active: false },
7
+ ])
8
+ expect(resolveRotateHandleHelpHints(true)).toEqual([
9
+ { keys: ['Alt'], label: 'Rotating freely (no angle step)', active: true },
10
+ ])
11
+ })
3
12
 
4
13
  describe('resolveSelectModeHelpHints', () => {
5
14
  test('stays hidden in idle select mode with no selection', () => {
@@ -117,4 +126,21 @@ describe('resolveSelectModeHelpHints', () => {
117
126
  },
118
127
  ])
119
128
  })
129
+
130
+ test('shows the single-side radius hint for rounded openings', () => {
131
+ expect(
132
+ resolveSelectModeHelpHints({
133
+ selectedCount: 1,
134
+ hasMovableSelection: true,
135
+ hasRotatableSelection: false,
136
+ hasOpeningRadiusSelection: true,
137
+ commandPressed: false,
138
+ shiftPressed: false,
139
+ }),
140
+ ).toContainEqual({
141
+ keys: ['Shift'],
142
+ label: 'Adjust one side’s corner radius',
143
+ active: false,
144
+ })
145
+ })
120
146
  })
@@ -11,8 +11,8 @@ export type ContextualShortcutHint = {
11
11
  }
12
12
 
13
13
  // `activeHandleDrag.label` value a rotate gizmo sets while dragging, so the
14
- // contextual HUD can surface the Shift = free-rotation toggle for the duration
15
- // (mirrors how wall drafting advertises Shift). Distinct from resize handles,
14
+ // contextual HUD can surface the rotation snapping mode for the duration.
15
+ // Distinct from resize handles,
16
16
  // which route their own measurement label here.
17
17
  export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
18
18
 
@@ -29,15 +29,12 @@ export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
29
29
  export const GROUP_MOVE_DRAG_LABEL = 'group-move-handle'
30
30
  export const GROUP_ROTATE_DRAG_LABEL = 'group-rotate-handle'
31
31
 
32
- // Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
33
- // (free rotation), the same toggle wall drafting exposes. `active` lights the
34
- // pill while Shift is held.
35
- export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] {
32
+ export function resolveRotateHandleHelpHints(altPressed: boolean): ContextualShortcutHint[] {
36
33
  return [
37
34
  {
38
- keys: [SHIFT_KEY],
39
- label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely',
40
- active: shiftPressed,
35
+ keys: [ALT_KEY],
36
+ label: altPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely',
37
+ active: altPressed,
41
38
  },
42
39
  ]
43
40
  }
@@ -46,6 +43,7 @@ export type SelectModeHelpContext = {
46
43
  selectedCount: number
47
44
  hasMovableSelection: boolean
48
45
  hasRotatableSelection: boolean
46
+ hasOpeningRadiusSelection?: boolean
49
47
  commandPressed: boolean
50
48
  shiftPressed: boolean
51
49
  // When a single MEP node is selected its in-world handle rig (click a dot to
@@ -67,6 +65,7 @@ export function resolveSelectModeHelpHints({
67
65
  selectedCount,
68
66
  hasMovableSelection,
69
67
  hasRotatableSelection,
68
+ hasOpeningRadiusSelection = false,
70
69
  commandPressed,
71
70
  shiftPressed,
72
71
  mepSelection = null,
@@ -142,6 +141,14 @@ export function resolveSelectModeHelpHints({
142
141
  })
143
142
  }
144
143
 
144
+ if (hasOpeningRadiusSelection) {
145
+ hints.push({
146
+ keys: [SHIFT_KEY],
147
+ label: 'Adjust one side’s corner radius',
148
+ active: shiftPressed,
149
+ })
150
+ }
151
+
145
152
  hints.push({
146
153
  keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
147
154
  label: 'Add or remove objects from the selection',
@@ -774,7 +774,9 @@ describe('collectFloorplanGeometry', () => {
774
774
  'finished-faces',
775
775
  [enabledPluginId],
776
776
  )
777
- expect(hiddenSite.map(({ id }) => id)).toEqual(['level_architecture'])
777
+ // A hidden Site hides only its own ground and boundary; the site-scoped
778
+ // nodes on it keep their own flag (see `hidesDescendants`).
779
+ expect(hiddenSite.map(({ id }) => id)).toEqual(['site_overlay', 'level_architecture'])
778
780
 
779
781
  const structure = collectFloorplanGeometry(
780
782
  nodes,
@@ -57,13 +57,29 @@ export type FloorplanToolContext = {
57
57
 
58
58
  export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
59
59
  tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
60
+ directDrag?: boolean
60
61
  availableModes?: readonly FloorplanToolMode[]
61
62
  preferredView?: '2d' | '3d'
62
63
  referencedSelectionAnnotationRole?: FloorplanAnnotationRole
63
64
  contextualDimensions?: (node: N, ctx: GeometryContext) => FloorplanGeometry | null
64
65
  actionMenu?: {
65
66
  canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
67
+ /** Buttons the kind adds to the selection's action menu (2D and 3D); each decides its own visibility. */
68
+ actions?: () => Promise<{ default: ComponentType }>
66
69
  }
70
+ /**
71
+ * Plan layers mounted while a node of this kind is in a `reshaping` scope of
72
+ * that name (the 2D sibling of `def.affordanceTools[reshape]`).
73
+ */
74
+ reshapeLayers?: Record<string, () => Promise<{ default: ComponentType<FloorplanToolContext> }>>
75
+ /**
76
+ * Nodes the plan treats as one with this node when toggling it out of a
77
+ * selection (a slab and its ceiling share an outline and a click).
78
+ */
79
+ selectionCounterparts?: (args: {
80
+ node: N
81
+ nodes: Readonly<Record<AnyNodeId, AnyNode>>
82
+ }) => AnyNodeId[]
67
83
  schedule?: (args: {
68
84
  siblings: ReadonlyArray<N>
69
85
  nodes: Readonly<Record<string, AnyNode>>