@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
@@ -12,6 +12,7 @@ import {
12
12
  type FloorplanPoint,
13
13
  type FloorplanScope,
14
14
  type GeometryContext,
15
+ hidesDescendants,
15
16
  isNodeKindEnabled,
16
17
  isRegistryMovable,
17
18
  kindsWithFloorplanScope,
@@ -40,6 +41,7 @@ import {
40
41
  useRef,
41
42
  useState,
42
43
  } from 'react'
44
+ import { useShallow } from 'zustand/react/shallow'
43
45
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
44
46
  import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
45
47
  import {
@@ -87,9 +89,10 @@ import {
87
89
  import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
88
90
  import { sfxEmitter } from '../../../lib/sfx-bus'
89
91
  import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
92
+ import { paintZoneMembership } from '../../../lib/units'
90
93
  import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
91
94
  import useDrawingView from '../../../store/use-drawing-view'
92
- import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
95
+ import useEditor, { isAngleSnapActive, type Mode } from '../../../store/use-editor'
93
96
  import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
94
97
  import useFloorplanMode from '../../../store/use-floorplan-mode'
95
98
  import useInteractionScope, {
@@ -343,6 +346,13 @@ type FloorplanEntryDescriptor = {
343
346
  visibilityRootId?: AnyNodeId
344
347
  }
345
348
 
349
+ /** The focused unit and its members; one stable object per focus change so
350
+ * cached zone geometry invalidates exactly when the focus does. */
351
+ export type FloorplanUnitFocus = {
352
+ focusedUnitId: string
353
+ focusedUnitMemberIds: readonly string[]
354
+ }
355
+
346
356
  type NodeDeps = {
347
357
  automaticDimensions: boolean
348
358
  node: AnyNode
@@ -356,6 +366,7 @@ type NodeDeps = {
356
366
  moving: boolean
357
367
  liveOverride: LiveNodeOverrides | undefined
358
368
  palette: FloorplanPalette | undefined
369
+ unitFocus: FloorplanUnitFocus | undefined
359
370
  siblingEpoch: number
360
371
  committedNodes: Record<string, AnyNode> | null
361
372
  dependencyNodes: AnyNode[]
@@ -446,15 +457,32 @@ export function isFloorplanOpeningPlacementState({
446
457
  )
447
458
  }
448
459
 
449
- function isFloorplanOpeningPlacementActiveNow(): boolean {
460
+ /**
461
+ * Whether a press on an entry belongs to the active tool instead of selecting
462
+ * the entry. Build tools own the plan the way their 3D tools own the canvas,
463
+ * where selection only runs in select mode: a wall drawn onto another wall
464
+ * must place its point (T-junction), not select the wall under the cursor.
465
+ */
466
+ export function floorplanEntryYieldsToTool(state: {
467
+ mode: Mode
468
+ openingPlacement: boolean
469
+ }): boolean {
470
+ return state.mode === 'build' || state.openingPlacement
471
+ }
472
+
473
+ function floorplanEntryYieldsToToolNow(): boolean {
450
474
  const { phase, mode, tool } = useEditor.getState()
451
475
  const movingNode = getMovingNode()
452
- return isFloorplanOpeningPlacementState({
453
- phase,
476
+ return floorplanEntryYieldsToTool({
454
477
  mode,
455
- tool,
456
- movingNodeHasWallOpeningPlacement:
457
- movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
478
+ openingPlacement: isFloorplanOpeningPlacementState({
479
+ phase,
480
+ mode,
481
+ tool,
482
+ movingNodeHasWallOpeningPlacement:
483
+ movingNode != null &&
484
+ !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
485
+ }),
458
486
  })
459
487
  }
460
488
 
@@ -476,6 +504,7 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
476
504
  // Stable empty sentinel used by per-entry builders while the floor plan is
477
505
  // hidden; committed scene edits still flow through `useScene`.
478
506
  const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
507
+ const NO_MEMBER_IDS: readonly string[] = []
479
508
 
480
509
  export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
481
510
  const selectedLevelId = useViewer((s) => s.selection.levelId)
@@ -484,6 +513,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
484
513
  const metricNotation = useViewer((s) => s.metricNotation)
485
514
  const setSelection = useViewer((s) => s.setSelection)
486
515
  const nodes = useScene((s) => s.nodes)
516
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
517
+ const focusedUnitMemberIds = useScene(
518
+ useShallow((s) => {
519
+ const focusedUnit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
520
+ return focusedUnit?.type === 'unit' ? focusedUnit.members : NO_MEMBER_IDS
521
+ }),
522
+ )
523
+ const unitFocus = useMemo<FloorplanUnitFocus | undefined>(
524
+ () => (focusedUnitId ? { focusedUnitId, focusedUnitMemberIds } : undefined),
525
+ [focusedUnitId, focusedUnitMemberIds],
526
+ )
487
527
  const installedPlugins = useScene((s) => s.installedPlugins)
488
528
  const movingNode = useMovingNode()
489
529
  // When a building is being moved, its explicit selection may be
@@ -536,13 +576,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
536
576
  const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
537
577
  const setMovingNode = useEditor((s) => s.setMovingNode)
538
578
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
539
- // Door / window placement (both build and move) needs the SVG's
540
- // background click handler to run — it finds the closest wall via
541
- // `findClosestWallPoint` and emits `wall:click` for the door / window
542
- // tool. When the user clicks *on top of* a wall in this mode, the
579
+ // Build tools and door / window placement (both build and move) need the
580
+ // SVG's background handlers to run — wall drafting places its point there,
581
+ // and opening placement finds the closest wall via `findClosestWallPoint`
582
+ // and emits `wall:click`. When the user clicks *on top of* a wall, the
543
583
  // wall's registry entry would otherwise swallow the click via
544
- // `handleClickStop` / `handleSelect`, so the placement never fires.
545
- // Pass clicks through in that case.
584
+ // `handleClickStop` / `handleSelect`, so the tool never sees it. Pass
585
+ // clicks through in that case (`floorplanEntryYieldsToToolNow`).
546
586
  const editorMode = useEditor((s) => s.mode)
547
587
  const structureLayer = useEditor((s) => s.structureLayer)
548
588
  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
@@ -650,12 +690,33 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
650
690
 
651
691
  const applyEntrySelection = useCallback(
652
692
  (id: AnyNodeId, options: { shouldToggle: boolean; isolateMember: boolean }) => {
693
+ const focusedUnitId = useViewer.getState().focusedUnitId
694
+ const clickedNode = useScene.getState().nodes[id]
695
+ if (focusedUnitId && clickedNode?.type === 'zone') {
696
+ paintZoneMembership(focusedUnitId, clickedNode.id)
697
+ swallowNextClick(200)
698
+ return
699
+ }
653
700
  const currentSelectedIds = useViewer.getState().selection.selectedIds
654
701
  let nextSelectedIds: string[]
655
702
  if (options.shouldToggle) {
656
- nextSelectedIds = currentSelectedIds.includes(id)
657
- ? currentSelectedIds.filter((selectedId) => selectedId !== id)
658
- : [...currentSelectedIds, id]
703
+ if (currentSelectedIds.includes(id)) {
704
+ // A room's slab and ceiling overlap in the plan and only the slab
705
+ // takes the click, so removing one removes the other too (a marquee
706
+ // picks both). 3D keeps single toggles: each is clickable there.
707
+ const nodes = useScene.getState().nodes
708
+ const node = nodes[id as AnyNodeId]
709
+ const counterparts = node
710
+ ? (getFloorplanNodeExtension(nodeRegistry.get(node.type))?.selectionCounterparts?.({
711
+ node,
712
+ nodes,
713
+ }) ?? [])
714
+ : []
715
+ const removed = new Set<string>([id, ...counterparts])
716
+ nextSelectedIds = currentSelectedIds.filter((selectedId) => !removed.has(selectedId))
717
+ } else {
718
+ nextSelectedIds = [...currentSelectedIds, id]
719
+ }
659
720
  } else if (options.isolateMember) {
660
721
  nextSelectedIds = [id]
661
722
  } else {
@@ -693,7 +754,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
693
754
  )
694
755
 
695
756
  const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
696
- if (isFloorplanOpeningPlacementActiveNow()) return
757
+ if (floorplanEntryYieldsToToolNow()) return
697
758
  event.stopPropagation()
698
759
  }, [])
699
760
 
@@ -704,7 +765,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
704
765
  const node = useScene.getState().nodes[id]
705
766
  if (!node || !isRegistryMovable(node.type)) return false
706
767
  const currentSelectedIds = useViewer.getState().selection.selectedIds
707
- const allowPlainDrag = nodeRegistry.get(node.type)?.capabilities?.movable?.directDrag === true
768
+ const definition = nodeRegistry.get(node.type)
769
+ const allowPlainDrag =
770
+ getFloorplanNodeExtension(definition)?.directDrag === true ||
771
+ definition?.capabilities?.movable?.directDrag === true
708
772
  const commandModifier = event.metaKey || event.ctrlKey
709
773
  if (
710
774
  !shouldStartDirectMoveDrag({
@@ -951,7 +1015,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
951
1015
  // the stores at event time. Commit clears the interaction scope before
952
1016
  // React paints the next frame; a render-time `undefined` handler leaves
953
1017
  // a short dead zone where the first post-placement selection is lost.
954
- if (isFloorplanOpeningPlacementActiveNow()) return
1018
+ if (floorplanEntryYieldsToToolNow()) return
955
1019
  if (startDirectMoveDrag(id, event)) return
956
1020
  if (startDirectRotateDrag(id, event)) return
957
1021
  if (startGroupMoveDrag(id, event)) return
@@ -1529,6 +1593,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1529
1593
  onHandleDoubleClick={commitAffordanceAction}
1530
1594
  onHandlePointerDown={startAffordanceDrag}
1531
1595
  palette={palette}
1596
+ unitFocus={unitFocus}
1532
1597
  pass="base"
1533
1598
  sceneRotationDeg={sceneRotationDeg}
1534
1599
  setMovingNode={setMovingNode}
@@ -1577,6 +1642,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1577
1642
  onHandleDoubleClick={commitAffordanceAction}
1578
1643
  onHandlePointerDown={startAffordanceDrag}
1579
1644
  palette={palette}
1645
+ unitFocus={unitFocus}
1580
1646
  pass="overlay"
1581
1647
  sceneRotationDeg={sceneRotationDeg}
1582
1648
  setMovingNode={setMovingNode}
@@ -1908,6 +1974,7 @@ type FloorplanRegistryEntryProps = {
1908
1974
  rotationPivot?: FloorplanPoint,
1909
1975
  ) => void
1910
1976
  palette: FloorplanPalette | undefined
1977
+ unitFocus: FloorplanUnitFocus | undefined
1911
1978
  pass: FloorplanRenderPass
1912
1979
  sceneRotationDeg: number
1913
1980
  setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
@@ -1943,6 +2010,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1943
2010
  onHandleDoubleClick,
1944
2011
  onHandlePointerDown,
1945
2012
  palette,
2013
+ unitFocus,
1946
2014
  pass,
1947
2015
  sceneRotationDeg,
1948
2016
  setMovingNode,
@@ -1967,7 +2035,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1967
2035
  const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
1968
2036
  const setHoveredId = useViewer((state) => state.setHoveredId)
1969
2037
  const referencedAnnotationRole = useViewer((state) =>
1970
- floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
2038
+ floorplanEntryReferencedAnnotationRole(node, nodes, new Set(state.selection.selectedIds)),
1971
2039
  )
1972
2040
  const activeRotateNodeId = useDirectManipulationFeedback((state) =>
1973
2041
  state.activeRotateNodeId === nodeId ? nodeId : null,
@@ -2084,6 +2152,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2084
2152
  nodeId,
2085
2153
  nodes,
2086
2154
  palette,
2155
+ unitFocus,
2087
2156
  selected,
2088
2157
  siblingEpoch,
2089
2158
  unit,
@@ -2153,6 +2222,7 @@ type BuildFloorplanEntryGeometryArgs = {
2153
2222
  nodeId: AnyNodeId
2154
2223
  nodes: Record<string, AnyNode>
2155
2224
  palette: FloorplanPalette | undefined
2225
+ unitFocus?: FloorplanUnitFocus
2156
2226
  selected: boolean
2157
2227
  siblingEpoch: number
2158
2228
  unit: 'metric' | 'imperial'
@@ -2167,7 +2237,7 @@ export function collectFloorplanDependencyNodes(
2167
2237
  nodes: Record<string, AnyNode>,
2168
2238
  liveOverrides?: Map<string, LiveNodeOverrides>,
2169
2239
  ): AnyNode[] {
2170
- return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
2240
+ return (def.floorplanDependencies?.(node, nodes) ?? []).flatMap((id) => {
2171
2241
  const dependency = nodes[id]
2172
2242
  if (!dependency) return []
2173
2243
  const dependencyOverride = liveOverrides?.get(dependency.id)
@@ -2184,13 +2254,14 @@ export function collectFloorplanDependencyNodes(
2184
2254
 
2185
2255
  function floorplanEntryReferencedAnnotationRole(
2186
2256
  node: AnyNode,
2257
+ nodes: Record<string, AnyNode>,
2187
2258
  selectedIds: ReadonlySet<string>,
2188
2259
  ): FloorplanAnnotationRole | undefined {
2189
2260
  if (selectedIds.size === 0) return undefined
2190
2261
  const definition = nodeRegistry.get(node.type)
2191
2262
  const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
2192
2263
  if (!role) return undefined
2193
- const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
2264
+ const dependencyIds = definition?.floorplanDependencies?.(node, nodes) ?? []
2194
2265
  return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
2195
2266
  }
2196
2267
 
@@ -2211,6 +2282,7 @@ export function buildFloorplanEntryGeometry({
2211
2282
  nodeId,
2212
2283
  nodes,
2213
2284
  palette,
2285
+ unitFocus,
2214
2286
  selected,
2215
2287
  siblingEpoch,
2216
2288
  unit,
@@ -2250,6 +2322,7 @@ export function buildFloorplanEntryGeometry({
2250
2322
  moving,
2251
2323
  liveOverride,
2252
2324
  palette,
2325
+ unitFocus,
2253
2326
  siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
2254
2327
  // Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
2255
2328
  // committed state via `ctx`, so committed sibling edits still invalidate.
@@ -2332,6 +2405,8 @@ export function buildFloorplanEntryGeometry({
2332
2405
  highlighted,
2333
2406
  hovered,
2334
2407
  moving,
2408
+ focusedUnitId: unitFocus?.focusedUnitId,
2409
+ focusedUnitMemberIds: unitFocus?.focusedUnitMemberIds,
2335
2410
  palette,
2336
2411
  }
2337
2412
  const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
@@ -2360,6 +2435,8 @@ export function buildFloorplanEntryGeometry({
2360
2435
  highlighted,
2361
2436
  hovered,
2362
2437
  moving,
2438
+ focusedUnitId: unitFocus?.focusedUnitId,
2439
+ focusedUnitMemberIds: unitFocus?.focusedUnitMemberIds,
2363
2440
  palette,
2364
2441
  }
2365
2442
  : undefined,
@@ -3292,15 +3369,26 @@ export function isFloorplanHierarchyVisible(
3292
3369
  liveOverrides: Map<string, LiveNodeOverrides>,
3293
3370
  rootId: AnyNodeId,
3294
3371
  ): boolean {
3372
+ // The root is checked on its own because a site-scoped node can be declared
3373
+ // on the Site without a `parentId` link. A Site's flag never reaches the
3374
+ // nodes on it; see `hidesDescendants`.
3295
3375
  const root = nodes[rootId]
3296
- if (root && !isFloorplanNodeVisible(root, liveOverrides.get(root.id))) return false
3376
+ if (
3377
+ root &&
3378
+ root.id !== node.id &&
3379
+ hidesDescendants(root) &&
3380
+ !isFloorplanNodeVisible(root, liveOverrides.get(root.id))
3381
+ ) {
3382
+ return false
3383
+ }
3297
3384
 
3298
3385
  let current: AnyNode | undefined = node
3299
3386
  const seen = new Set<AnyNodeId>()
3300
3387
  while (current) {
3301
3388
  if (seen.has(current.id)) return true
3302
3389
  seen.add(current.id)
3303
- if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
3390
+ const reaches = current.id === node.id || hidesDescendants(current)
3391
+ if (reaches && !isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
3304
3392
  if (current.id === rootId) return true
3305
3393
  const parentId = current.parentId as AnyNodeId | null
3306
3394
  if (!parentId) return true
@@ -3573,6 +3661,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
3573
3661
  'moving',
3574
3662
  'liveOverride',
3575
3663
  'palette',
3664
+ 'unitFocus',
3576
3665
  'siblingEpoch',
3577
3666
  'ctxOverrides',
3578
3667
  'committedNodes',
@@ -0,0 +1,223 @@
1
+ 'use client'
2
+ import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
3
+ import {
4
+ ProceduralItemNode,
5
+ snapParameters,
6
+ validateProceduralRelations,
7
+ } from '@pascal-app/core/procedural-items'
8
+ import { useViewer } from '@pascal-app/viewer'
9
+ import { useEffect, useState } from 'react'
10
+ import { useHandleGroup } from '../../store/use-handle-group'
11
+ import { PanelSection } from '../ui/controls/panel-section'
12
+ import { SliderControl } from '../ui/controls/slider-control'
13
+ import { PanelWrapper } from '../ui/panels/panel-wrapper'
14
+ export function ProceduralInspector({
15
+ nodeId,
16
+ partId,
17
+ onPartChange,
18
+ }: {
19
+ nodeId: string
20
+ partId: string | null
21
+ onPartChange: (id: string | null) => void
22
+ }) {
23
+ const committed = useScene((s) => s.nodes[nodeId as AnyNodeId]) as unknown as
24
+ | ProceduralItemNode
25
+ | undefined
26
+ const override = useLiveNodeOverrides((s) => s.overrides.get(nodeId))
27
+ const node = committed ? ({ ...committed, ...override } as ProceduralItemNode) : null
28
+ useEffect(() => {
29
+ useHandleGroup.setState({ active: partId ? { nodeId, group: partId } : null })
30
+ return () => {
31
+ if (useHandleGroup.getState().active?.nodeId === nodeId)
32
+ useHandleGroup.setState({ active: null })
33
+ }
34
+ }, [nodeId, partId])
35
+ const [error, setError] = useState('')
36
+ useEffect(() => () => useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId), [nodeId])
37
+ if (!node || !committed) return null
38
+ const change = (id: string, value: number, commit: boolean) => {
39
+ const values = { ...node.parameters, [id]: value }
40
+ const parameters = commit ? snapParameters(node.recipe, values) : values
41
+ const parsed = ProceduralItemNode.safeParse({ ...node, parameters })
42
+ if (!parsed.success) {
43
+ setError(parsed.error.issues[0]?.message ?? 'Invalid edit')
44
+ if (commit) useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
45
+ return
46
+ }
47
+ try {
48
+ validateProceduralRelations({ ...node, parameters }, useScene.getState().nodes)
49
+ } catch (e) {
50
+ setError(e instanceof Error ? e.message : 'Invalid attachment')
51
+ if (commit) useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
52
+ return
53
+ }
54
+ setError('')
55
+ if (commit) {
56
+ useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
57
+ useScene.getState().updateNode(nodeId as AnyNodeId, { parameters } as never)
58
+ } else useLiveNodeOverrides.getState().set(nodeId as AnyNodeId, { parameters } as never)
59
+ }
60
+ const fields = node.recipe.parameters.filter((p) => (partId ? p.part === partId : !p.part))
61
+ return (
62
+ <div className="space-y-5">
63
+ <div className="flex items-center justify-between">
64
+ <h2 className="font-semibold text-lg">
65
+ {partId ? node.recipe.parts.find((p) => p.id === partId)?.label : 'Adjust design'}
66
+ </h2>
67
+ {partId && (
68
+ <button
69
+ className="rounded-full border px-3 py-1 text-xs"
70
+ onClick={() => onPartChange(null)}
71
+ type="button"
72
+ >
73
+ Back
74
+ </button>
75
+ )}
76
+ </div>
77
+ <div className="space-y-3">
78
+ {fields.map((p) => (
79
+ <SliderControl
80
+ key={p.id}
81
+ label={p.label}
82
+ value={node.parameters[p.id] ?? p.default}
83
+ min={p.min}
84
+ max={p.max}
85
+ step={p.step}
86
+ precision={3}
87
+ unit={p.unit === 'm' ? 'm' : ''}
88
+ onChange={(v) => change(p.id, v, false)}
89
+ onCommit={(v) => change(p.id, v, true)}
90
+ restoreOnCommit={false}
91
+ />
92
+ ))}
93
+ </div>
94
+ {partId && (
95
+ <button
96
+ type="button"
97
+ className="rounded-full border px-3 py-1 text-xs"
98
+ onClick={() => {
99
+ const parameters = { ...committed.parameters }
100
+ for (const p of fields) delete parameters[p.id]
101
+ try {
102
+ ProceduralItemNode.parse({ ...committed, parameters })
103
+ useScene.getState().updateNode(nodeId as AnyNodeId, { parameters } as never)
104
+ setError('')
105
+ } catch (e) {
106
+ setError(e instanceof Error ? e.message : 'Invalid reset')
107
+ }
108
+ }}
109
+ >
110
+ Reset this part
111
+ </button>
112
+ )}
113
+ {error && (
114
+ <p role="alert" className="text-red-600 text-sm">
115
+ {error}
116
+ </p>
117
+ )}
118
+ {!partId && node.recipe.parameters.some((p) => p.part) && (
119
+ <div>
120
+ <h3 className="mb-2 font-medium text-sm">Edit parts</h3>
121
+ <div className="flex flex-wrap gap-2">
122
+ {node.recipe.parts
123
+ .filter((part) => node.recipe.parameters.some((p) => p.part === part.id))
124
+ .map((p) => (
125
+ <button
126
+ key={p.id}
127
+ type="button"
128
+ className="rounded-full border px-3 py-1.5 text-sm text-foreground hover:bg-muted hover:text-foreground"
129
+ onClick={() => onPartChange(p.id)}
130
+ >
131
+ {p.label}
132
+ </button>
133
+ ))}
134
+ </div>
135
+ </div>
136
+ )}
137
+ </div>
138
+ )
139
+ }
140
+
141
+ export default function ProceduralItemPanel({ node }: { node: ProceduralItemNode }) {
142
+ return <ProceduralPanel key={node.id} node={node} />
143
+ }
144
+
145
+ function ProceduralPanel({ node }: { node: ProceduralItemNode }) {
146
+ const [partId, setPartId] = useState<string | null>(null)
147
+ return (
148
+ <PanelWrapper
149
+ title={node.name ?? node.recipe.name}
150
+ onClose={() => useViewer.getState().setSelection({ selectedIds: [] })}
151
+ >
152
+ <div className="space-y-5 p-3">
153
+ <ProceduralInspector
154
+ key={node.id}
155
+ nodeId={node.id}
156
+ partId={partId}
157
+ onPartChange={setPartId}
158
+ />
159
+ <ProceduralPlacementControls node={node} />
160
+ </div>
161
+ </PanelWrapper>
162
+ )
163
+ }
164
+
165
+ function ProceduralPlacementControls({ node }: { node: ProceduralItemNode }) {
166
+ const [error, setError] = useState('')
167
+ const update = (patch: Partial<ProceduralItemNode>) => {
168
+ try {
169
+ useScene.getState().updateNode(node.id as AnyNodeId, patch as never)
170
+ setError('')
171
+ } catch (e) {
172
+ setError(e instanceof Error ? e.message : 'Invalid placement')
173
+ }
174
+ }
175
+ const changePosition = (index: number, value: number, commit: boolean) => {
176
+ const live = useLiveNodeOverrides.getState().overrides.get(node.id)
177
+ const position = [
178
+ ...((live?.position as typeof node.position | undefined) ?? node.position),
179
+ ] as typeof node.position
180
+ position[index] = Number(value.toFixed(3))
181
+ const patch = { position }
182
+ try {
183
+ validateProceduralRelations({ ...node, ...patch }, useScene.getState().nodes)
184
+ if (commit) {
185
+ useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
186
+ update(patch)
187
+ } else useLiveNodeOverrides.getState().set(node.id as AnyNodeId, patch)
188
+ setError('')
189
+ } catch (e) {
190
+ if (commit) useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
191
+ setError(e instanceof Error ? e.message : 'Invalid position')
192
+ }
193
+ }
194
+ return (
195
+ <PanelSection title="Position">
196
+ {(['X', 'Y', 'Z'] as const).map((axis, index) => (
197
+ <SliderControl
198
+ key={axis}
199
+ label={
200
+ <>
201
+ {axis}
202
+ <sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
203
+ </>
204
+ }
205
+ value={node.position[index]!}
206
+ min={node.position[index]! - 2}
207
+ max={node.position[index]! + 2}
208
+ precision={2}
209
+ step={0.01}
210
+ unit="m"
211
+ onChange={(value) => changePosition(index, value, false)}
212
+ onCommit={(value) => changePosition(index, value, true)}
213
+ restoreOnCommit={false}
214
+ />
215
+ ))}
216
+ {error && (
217
+ <p role="alert" className="text-destructive text-xs">
218
+ {error}
219
+ </p>
220
+ )}
221
+ </PanelSection>
222
+ )
223
+ }
@@ -1,4 +1,5 @@
1
1
  import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
2
+ import { isProceduralItem } from '@pascal-app/core/procedural-items'
2
3
  import { useViewer } from '@pascal-app/viewer'
3
4
  import { useEffect } from 'react'
4
5
  import { Color, type Material, type Mesh } from 'three'
@@ -98,7 +99,8 @@ export const CeilingSystem = () => {
98
99
  const isCeilingToolActive =
99
100
  tool === 'ceiling' ||
100
101
  selectedItem?.attachTo === 'ceiling' ||
101
- (movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
102
+ (movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling') ||
103
+ (isProceduralItem(movingNode) && movingNode.recipe.mounting?.attachTo === 'ceiling')
102
104
 
103
105
  if (isCeilingToolActive && activeLevelId) {
104
106
  levelsToShowCeilings.add(activeLevelId)
@@ -58,9 +58,13 @@ export const ZoneSystem = () => {
58
58
  const isDeleteHovered = editorMode === 'delete' && hoveredId === zoneId
59
59
 
60
60
  // Keep group visible (so <Html> labels stay active), hide/show meshes only.
61
- // Show meshes when: in zone mode, selected, or delete-hovered.
62
- if (!obj.visible) obj.visible = true
63
- const meshVisible = !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered)
61
+ // Show meshes when: in zone mode, selected, or delete-hovered. A zone the
62
+ // author hid (sidebar eye) is the one case where the group itself goes:
63
+ // otherwise this per-frame write would undo the renderer's `visible` prop.
64
+ const nodeVisible = zone?.visible !== false
65
+ if (obj.visible !== nodeVisible) obj.visible = nodeVisible
66
+ const meshVisible =
67
+ nodeVisible && !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered)
64
68
  const targetOpacity = isCaptureMode
65
69
  ? 0
66
70
  : isSelected || isDeleteHovered
@@ -103,7 +107,7 @@ export const ZoneSystem = () => {
103
107
  // Labels: visible on the current level (regardless of mode), but never
104
108
  // during snapshot capture.
105
109
  const showLabel =
106
- !isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
110
+ nodeVisible && !isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
107
111
  const labelOpacity = showLabel ? '1' : '0'
108
112
  const labelEl = document.getElementById(`${zoneId}-label`)
109
113
  if (labelEl && labelEl.style.opacity !== labelOpacity) {
@@ -0,0 +1,10 @@
1
+ import { type AnyNodeDefinition, registerNode } from '@pascal-app/core'
2
+
3
+ export function registerHostingTestNode(definition: AnyNodeDefinition) {
4
+ registerNode({
5
+ ...definition,
6
+ geometry: () => {
7
+ throw new Error('Unit-test host geometry must not be rendered')
8
+ },
9
+ })
10
+ }