@pascal-app/editor 1.0.0 → 1.0.1

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 (97) hide show
  1. package/package.json +7 -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/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -81,6 +81,12 @@ import {
81
81
  shouldPreserveSelectedRoofHostTarget,
82
82
  } from '../../lib/selection-routing'
83
83
  import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
84
+ import {
85
+ cancelPendingZonePaint,
86
+ paintZoneMembership,
87
+ zoneAtLevelPoint,
88
+ zoneAtWorldPoint,
89
+ } from '../../lib/units'
84
90
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
85
91
  import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
86
92
  import useInteractionScope, {
@@ -1609,6 +1615,23 @@ export const SelectionManager = () => {
1609
1615
  // the click falls through to the item underneath.
1610
1616
  if (node.type === 'ceiling' && !event.viaHandle) return
1611
1617
 
1618
+ // Unit focus turns clicks inside a zone into the paint gesture:
1619
+ // membership toggles, the zone stays unselected, focus stays.
1620
+ const focusedUnitId = useViewer.getState().focusedUnitId
1621
+ if (focusedUnitId) {
1622
+ const zone =
1623
+ node.type === 'zone' ? node : zoneAtWorldPoint(event.position[0], event.position[2])
1624
+ if (zone) {
1625
+ event.stopPropagation()
1626
+ clickHandledRef.current = true
1627
+ setTimeout(() => {
1628
+ clickHandledRef.current = false
1629
+ }, 50)
1630
+ paintZoneMembership(focusedUnitId, zone.id)
1631
+ return
1632
+ }
1633
+ }
1634
+
1612
1635
  let currentPhase = useEditor.getState().phase
1613
1636
  let currentStructureLayer = useEditor.getState().structureLayer
1614
1637
  const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
@@ -1809,6 +1832,14 @@ export const SelectionManager = () => {
1809
1832
  if (useInteractionScope.getState().scope.kind === 'mesh-editing') return
1810
1833
  const nativeEvent = event.nativeEvent
1811
1834
  if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
1835
+ // Unit focus: a ground click inside a zone paints it; elsewhere it
1836
+ // leaves focus and the unit selection alone.
1837
+ const focusedUnitId = useViewer.getState().focusedUnitId
1838
+ if (focusedUnitId) {
1839
+ const zone = zoneAtLevelPoint(event.localPosition[0], event.localPosition[2])
1840
+ if (zone) paintZoneMembership(focusedUnitId, zone.id)
1841
+ return
1842
+ }
1812
1843
  const { phase, structureLayer } = useEditor.getState()
1813
1844
  const activeStrategy = SELECTION_STRATEGIES[phase]
1814
1845
  if (activeStrategy) activeStrategy.handleDeselect()
@@ -1919,6 +1950,24 @@ export const SelectionManager = () => {
1919
1950
  }
1920
1951
  }
1921
1952
 
1953
+ // While a unit is focused a double-click inside a zone selects that
1954
+ // zone (focus stays); the two clicks before it cancel each other's paint.
1955
+ if (useViewer.getState().focusedUnitId) {
1956
+ const zone =
1957
+ node.type === 'zone' ? node : zoneAtWorldPoint(event.position[0], event.position[2])
1958
+ if (zone) {
1959
+ event.stopPropagation()
1960
+ cancelPendingZonePaint(zone.id)
1961
+ SELECTION_STRATEGIES.structure?.handleSelect(
1962
+ zone,
1963
+ event.nativeEvent,
1964
+ modifierKeysRef.current,
1965
+ [],
1966
+ )
1967
+ return
1968
+ }
1969
+ }
1970
+
1922
1971
  if (node.type === 'zone') {
1923
1972
  return
1924
1973
  }
@@ -13,6 +13,7 @@ import {
13
13
  GRID_LAYER,
14
14
  getVisibleWallMaterials,
15
15
  heroCameraPose,
16
+ refreshIsolation,
16
17
  SNAPSHOT_MAX_EDGE,
17
18
  SNAPSHOT_MIME,
18
19
  SNAPSHOT_QUALITY,
@@ -22,6 +23,7 @@ import {
22
23
  THUMBNAIL_WIDTH,
23
24
  temporarilyHideNodeTypes,
24
25
  temporarilyShowShadowOnly,
26
+ useSceneAtmosphere,
25
27
  useViewer,
26
28
  } from '@pascal-app/viewer'
27
29
  import type { CameraControls } from '@react-three/drei'
@@ -68,6 +70,7 @@ function clampSnapshotSize(width: number, height: number): { w: number; h: numbe
68
70
  export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
69
71
  const gl = useThree((state) => state.gl)
70
72
  const scene = useThree((state) => state.scene)
73
+ const atmosphere = useSceneAtmosphere()
71
74
  const getThree = useThree((state) => state.get)
72
75
  const controls = useThree((state) => state.controls) as CameraControls | null
73
76
  const isGenerating = useRef(false)
@@ -82,7 +85,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
82
85
  onThumbnailCaptureRef.current = onThumbnailCapture
83
86
  }, [onThumbnailCapture])
84
87
 
85
- // Build the thumbnail camera, SSGI pipeline, and render target once — reused on every capture.
88
+ // Reuse the camera and snapshot graph until the active atmosphere source changes.
86
89
  useEffect(() => {
87
90
  captureVersion.current += 1
88
91
  const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
@@ -97,6 +100,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
97
100
  renderer: gl as unknown as WebGPURenderer,
98
101
  scene,
99
102
  camera: cam,
103
+ atmosphere,
100
104
  })
101
105
  if (!mounted) {
102
106
  pipeline?.dispose()
@@ -114,7 +118,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
114
118
  pipelineRef.current?.dispose()
115
119
  pipelineRef.current = null
116
120
  }
117
- }, [gl, scene])
121
+ }, [gl, scene, atmosphere])
118
122
 
119
123
  const generate = useCallback(
120
124
  async (event: ThumbnailGenerateEvent) => {
@@ -274,6 +278,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
274
278
  }
275
279
  }
276
280
 
281
+ // Geometry and presentation systems may have mounted new meshes
282
+ // since the viewport's last frame. Apply the same isolation to
283
+ // those meshes before either snapshot render path submits them,
284
+ // and restore fog afterwards so the atmosphere never leaks in.
285
+ restore(refreshIsolation(scene))
277
286
  if (pipeline) return pipeline.capture({ captureMode, cropRegion, standardSize })
278
287
  gl.render(scene, thumbnailCamera)
279
288
  return undefined
@@ -27,6 +27,7 @@ import {
27
27
  prepareFreshPlacementRootDuplicate,
28
28
  } from '../../lib/fresh-planar-placement'
29
29
  import { curveReshapeScope } from '../../lib/interaction/scope'
30
+ import { duplicateWithoutMove, registryMoveDisabled } from '../../lib/node-action-movement'
30
31
  import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
31
32
  import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
32
33
  import { sfxEmitter } from '../../lib/sfx-bus'
@@ -250,7 +251,8 @@ export function FloorplanRegistryActionMenu() {
250
251
  // walls land on their bespoke `MoveWallTool` (perpendicular slide
251
252
  // with linked-wall cascade) via `affordanceTools.move`.
252
253
  const canMove =
253
- !!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
254
+ !registryMoveDisabled(node) &&
255
+ (!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move)
254
256
  const canDuplicate = def.capabilities.duplicable !== false
255
257
  const canDelete = def.capabilities.deletable !== false
256
258
  const canAddHole = node.type === 'slab' || node.type === 'ceiling'
@@ -320,6 +322,15 @@ export function FloorplanRegistryActionMenu() {
320
322
  const handleDuplicate = () => {
321
323
  if (!node.parentId) return
322
324
  sfxEmitter.emit('sfx:item-pick')
325
+ if (registryMoveDisabled(node)) {
326
+ try {
327
+ const id = duplicateWithoutMove(node)
328
+ if (id) useViewer.getState().setSelection({ selectedIds: [id] })
329
+ } catch (error) {
330
+ console.error('Failed to duplicate node', error)
331
+ }
332
+ return
333
+ }
323
334
  useScene.temporal.getState().pause()
324
335
  let draftId: AnyNodeId | null = null
325
336
  try {
@@ -40,6 +40,7 @@ import {
40
40
  useRef,
41
41
  useState,
42
42
  } from 'react'
43
+ import { useShallow } from 'zustand/react/shallow'
43
44
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
44
45
  import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
45
46
  import {
@@ -87,6 +88,7 @@ import {
87
88
  import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
88
89
  import { sfxEmitter } from '../../../lib/sfx-bus'
89
90
  import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
91
+ import { paintZoneMembership } from '../../../lib/units'
90
92
  import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
91
93
  import useDrawingView from '../../../store/use-drawing-view'
92
94
  import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
@@ -343,6 +345,13 @@ type FloorplanEntryDescriptor = {
343
345
  visibilityRootId?: AnyNodeId
344
346
  }
345
347
 
348
+ /** The focused unit and its members; one stable object per focus change so
349
+ * cached zone geometry invalidates exactly when the focus does. */
350
+ export type FloorplanUnitFocus = {
351
+ focusedUnitId: string
352
+ focusedUnitMemberIds: readonly string[]
353
+ }
354
+
346
355
  type NodeDeps = {
347
356
  automaticDimensions: boolean
348
357
  node: AnyNode
@@ -356,6 +365,7 @@ type NodeDeps = {
356
365
  moving: boolean
357
366
  liveOverride: LiveNodeOverrides | undefined
358
367
  palette: FloorplanPalette | undefined
368
+ unitFocus: FloorplanUnitFocus | undefined
359
369
  siblingEpoch: number
360
370
  committedNodes: Record<string, AnyNode> | null
361
371
  dependencyNodes: AnyNode[]
@@ -476,6 +486,7 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
476
486
  // Stable empty sentinel used by per-entry builders while the floor plan is
477
487
  // hidden; committed scene edits still flow through `useScene`.
478
488
  const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
489
+ const NO_MEMBER_IDS: readonly string[] = []
479
490
 
480
491
  export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
481
492
  const selectedLevelId = useViewer((s) => s.selection.levelId)
@@ -484,6 +495,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
484
495
  const metricNotation = useViewer((s) => s.metricNotation)
485
496
  const setSelection = useViewer((s) => s.setSelection)
486
497
  const nodes = useScene((s) => s.nodes)
498
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
499
+ const focusedUnitMemberIds = useScene(
500
+ useShallow((s) => {
501
+ const focusedUnit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
502
+ return focusedUnit?.type === 'unit' ? focusedUnit.members : NO_MEMBER_IDS
503
+ }),
504
+ )
505
+ const unitFocus = useMemo<FloorplanUnitFocus | undefined>(
506
+ () => (focusedUnitId ? { focusedUnitId, focusedUnitMemberIds } : undefined),
507
+ [focusedUnitId, focusedUnitMemberIds],
508
+ )
487
509
  const installedPlugins = useScene((s) => s.installedPlugins)
488
510
  const movingNode = useMovingNode()
489
511
  // When a building is being moved, its explicit selection may be
@@ -650,6 +672,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
650
672
 
651
673
  const applyEntrySelection = useCallback(
652
674
  (id: AnyNodeId, options: { shouldToggle: boolean; isolateMember: boolean }) => {
675
+ const focusedUnitId = useViewer.getState().focusedUnitId
676
+ const clickedNode = useScene.getState().nodes[id]
677
+ if (focusedUnitId && clickedNode?.type === 'zone') {
678
+ paintZoneMembership(focusedUnitId, clickedNode.id)
679
+ swallowNextClick(200)
680
+ return
681
+ }
653
682
  const currentSelectedIds = useViewer.getState().selection.selectedIds
654
683
  let nextSelectedIds: string[]
655
684
  if (options.shouldToggle) {
@@ -1529,6 +1558,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1529
1558
  onHandleDoubleClick={commitAffordanceAction}
1530
1559
  onHandlePointerDown={startAffordanceDrag}
1531
1560
  palette={palette}
1561
+ unitFocus={unitFocus}
1532
1562
  pass="base"
1533
1563
  sceneRotationDeg={sceneRotationDeg}
1534
1564
  setMovingNode={setMovingNode}
@@ -1577,6 +1607,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1577
1607
  onHandleDoubleClick={commitAffordanceAction}
1578
1608
  onHandlePointerDown={startAffordanceDrag}
1579
1609
  palette={palette}
1610
+ unitFocus={unitFocus}
1580
1611
  pass="overlay"
1581
1612
  sceneRotationDeg={sceneRotationDeg}
1582
1613
  setMovingNode={setMovingNode}
@@ -1908,6 +1939,7 @@ type FloorplanRegistryEntryProps = {
1908
1939
  rotationPivot?: FloorplanPoint,
1909
1940
  ) => void
1910
1941
  palette: FloorplanPalette | undefined
1942
+ unitFocus: FloorplanUnitFocus | undefined
1911
1943
  pass: FloorplanRenderPass
1912
1944
  sceneRotationDeg: number
1913
1945
  setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
@@ -1943,6 +1975,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1943
1975
  onHandleDoubleClick,
1944
1976
  onHandlePointerDown,
1945
1977
  palette,
1978
+ unitFocus,
1946
1979
  pass,
1947
1980
  sceneRotationDeg,
1948
1981
  setMovingNode,
@@ -1967,7 +2000,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1967
2000
  const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
1968
2001
  const setHoveredId = useViewer((state) => state.setHoveredId)
1969
2002
  const referencedAnnotationRole = useViewer((state) =>
1970
- floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
2003
+ floorplanEntryReferencedAnnotationRole(node, nodes, new Set(state.selection.selectedIds)),
1971
2004
  )
1972
2005
  const activeRotateNodeId = useDirectManipulationFeedback((state) =>
1973
2006
  state.activeRotateNodeId === nodeId ? nodeId : null,
@@ -2084,6 +2117,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2084
2117
  nodeId,
2085
2118
  nodes,
2086
2119
  palette,
2120
+ unitFocus,
2087
2121
  selected,
2088
2122
  siblingEpoch,
2089
2123
  unit,
@@ -2153,6 +2187,7 @@ type BuildFloorplanEntryGeometryArgs = {
2153
2187
  nodeId: AnyNodeId
2154
2188
  nodes: Record<string, AnyNode>
2155
2189
  palette: FloorplanPalette | undefined
2190
+ unitFocus?: FloorplanUnitFocus
2156
2191
  selected: boolean
2157
2192
  siblingEpoch: number
2158
2193
  unit: 'metric' | 'imperial'
@@ -2167,7 +2202,7 @@ export function collectFloorplanDependencyNodes(
2167
2202
  nodes: Record<string, AnyNode>,
2168
2203
  liveOverrides?: Map<string, LiveNodeOverrides>,
2169
2204
  ): AnyNode[] {
2170
- return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
2205
+ return (def.floorplanDependencies?.(node, nodes) ?? []).flatMap((id) => {
2171
2206
  const dependency = nodes[id]
2172
2207
  if (!dependency) return []
2173
2208
  const dependencyOverride = liveOverrides?.get(dependency.id)
@@ -2184,13 +2219,14 @@ export function collectFloorplanDependencyNodes(
2184
2219
 
2185
2220
  function floorplanEntryReferencedAnnotationRole(
2186
2221
  node: AnyNode,
2222
+ nodes: Record<string, AnyNode>,
2187
2223
  selectedIds: ReadonlySet<string>,
2188
2224
  ): FloorplanAnnotationRole | undefined {
2189
2225
  if (selectedIds.size === 0) return undefined
2190
2226
  const definition = nodeRegistry.get(node.type)
2191
2227
  const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
2192
2228
  if (!role) return undefined
2193
- const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
2229
+ const dependencyIds = definition?.floorplanDependencies?.(node, nodes) ?? []
2194
2230
  return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
2195
2231
  }
2196
2232
 
@@ -2211,6 +2247,7 @@ export function buildFloorplanEntryGeometry({
2211
2247
  nodeId,
2212
2248
  nodes,
2213
2249
  palette,
2250
+ unitFocus,
2214
2251
  selected,
2215
2252
  siblingEpoch,
2216
2253
  unit,
@@ -2250,6 +2287,7 @@ export function buildFloorplanEntryGeometry({
2250
2287
  moving,
2251
2288
  liveOverride,
2252
2289
  palette,
2290
+ unitFocus,
2253
2291
  siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
2254
2292
  // Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
2255
2293
  // committed state via `ctx`, so committed sibling edits still invalidate.
@@ -2332,6 +2370,8 @@ export function buildFloorplanEntryGeometry({
2332
2370
  highlighted,
2333
2371
  hovered,
2334
2372
  moving,
2373
+ focusedUnitId: unitFocus?.focusedUnitId,
2374
+ focusedUnitMemberIds: unitFocus?.focusedUnitMemberIds,
2335
2375
  palette,
2336
2376
  }
2337
2377
  const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
@@ -2360,6 +2400,8 @@ export function buildFloorplanEntryGeometry({
2360
2400
  highlighted,
2361
2401
  hovered,
2362
2402
  moving,
2403
+ focusedUnitId: unitFocus?.focusedUnitId,
2404
+ focusedUnitMemberIds: unitFocus?.focusedUnitMemberIds,
2363
2405
  palette,
2364
2406
  }
2365
2407
  : undefined,
@@ -3573,6 +3615,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
3573
3615
  'moving',
3574
3616
  'liveOverride',
3575
3617
  'palette',
3618
+ 'unitFocus',
3576
3619
  'siblingEpoch',
3577
3620
  'ctxOverrides',
3578
3621
  '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)