@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
@@ -0,0 +1,166 @@
1
+ 'use client'
2
+
3
+ import type { WallPlanPoint } from '@pascal-app/core'
4
+
5
+ /** Length plate + angle arcs for a drafted wall segment, in plan space. */
6
+ export type DraftWallMeasurement = {
7
+ lengthLabel: string
8
+ midpoint: WallPlanPoint
9
+ direction: WallPlanPoint
10
+ angleLabels: {
11
+ id: string
12
+ label: string
13
+ center: WallPlanPoint
14
+ radius: number
15
+ startAngle: number
16
+ endAngle: number
17
+ midAngle: number
18
+ }[]
19
+ }
20
+
21
+ export function FloorplanDraftWallMeasurement({
22
+ measurement,
23
+ measurementStroke,
24
+ labelBackground,
25
+ labelText,
26
+ sceneRotationDeg,
27
+ unitsPerPixel,
28
+ }: {
29
+ measurement: DraftWallMeasurement
30
+ measurementStroke: string
31
+ labelBackground: string
32
+ labelText: string
33
+ sceneRotationDeg: number
34
+ unitsPerPixel: number
35
+ }) {
36
+ const stroke = measurementStroke
37
+ const labelBg = labelBackground
38
+
39
+ const upx = unitsPerPixel
40
+ const fontSize = Math.max(upx * 10, 0.08)
41
+ const padX = upx * 6
42
+ const padY = upx * 3
43
+
44
+ // Length plate: rotates to follow the wall direction, but flips 180°
45
+ // when its on-screen orientation would read upside-down (same trick as
46
+ // `floorplan-registry-layer.tsx` for dimension labels).
47
+ const wallAngleDeg =
48
+ (Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI
49
+ let labelAngleDeg = wallAngleDeg
50
+ let screenDeg = wallAngleDeg + sceneRotationDeg
51
+ screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
52
+ if (screenDeg > 90) labelAngleDeg -= 180
53
+ else if (screenDeg <= -90) labelAngleDeg += 180
54
+
55
+ // Push the plate perpendicular to the wall so the dashed footprint
56
+ // stays visible underneath.
57
+ const perpX = -measurement.direction[1]
58
+ const perpY = measurement.direction[0]
59
+ const offset = upx * 18
60
+ const cx = measurement.midpoint[0] + perpX * offset
61
+ const cy = measurement.midpoint[1] + perpY * offset
62
+
63
+ const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2
64
+ const lengthPlateW = lengthTextWidth + padX * 2
65
+ const lengthPlateH = fontSize + padY * 2
66
+
67
+ const arcSampleCount = 32
68
+
69
+ return (
70
+ <g pointerEvents="none">
71
+ <g transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}>
72
+ <rect
73
+ fill={labelBg}
74
+ height={lengthPlateH}
75
+ opacity={0.92}
76
+ rx={upx * 3}
77
+ ry={upx * 3}
78
+ stroke={stroke}
79
+ strokeWidth={upx * 0.5}
80
+ vectorEffect="non-scaling-stroke"
81
+ width={lengthPlateW}
82
+ x={-lengthPlateW / 2}
83
+ y={-lengthPlateH / 2}
84
+ />
85
+ <text
86
+ dominantBaseline="middle"
87
+ fill={labelText}
88
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
89
+ fontSize={fontSize}
90
+ fontWeight={600}
91
+ textAnchor="middle"
92
+ x={0}
93
+ y={0}
94
+ >
95
+ {measurement.lengthLabel}
96
+ </text>
97
+ </g>
98
+
99
+ {measurement.angleLabels.map((arc) => {
100
+ // Sample the arc as a polyline — avoids the SVG arc command's
101
+ // sweep-flag direction quirks across negative/positive sweeps.
102
+ const points: string[] = []
103
+ for (let i = 0; i <= arcSampleCount; i += 1) {
104
+ const t = i / arcSampleCount
105
+ const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t
106
+ const px = arc.center[0] + Math.cos(a) * arc.radius
107
+ const py = arc.center[1] + Math.sin(a) * arc.radius
108
+ points.push(`${px},${py}`)
109
+ }
110
+
111
+ const aFontSize = Math.max(upx * 9, 0.075)
112
+ const aPadX = upx * 5
113
+ const aPadY = upx * 2.5
114
+ const aTextWidth = arc.label.length * upx * 6.2
115
+ const aPlateW = aTextWidth + aPadX * 2
116
+ const aPlateH = aFontSize + aPadY * 2
117
+
118
+ const labelDist = arc.radius + upx * 16
119
+ const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist
120
+ const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist
121
+
122
+ return (
123
+ <g key={`draft-angle-${arc.id}`}>
124
+ <polyline
125
+ fill="none"
126
+ points={points.join(' ')}
127
+ stroke={stroke}
128
+ strokeLinecap="round"
129
+ strokeLinejoin="round"
130
+ strokeOpacity={0.95}
131
+ strokeWidth={upx * 1.2}
132
+ vectorEffect="non-scaling-stroke"
133
+ />
134
+ <g transform={`translate(${lx} ${ly})`}>
135
+ <rect
136
+ fill={labelBg}
137
+ height={aPlateH}
138
+ opacity={0.92}
139
+ rx={upx * 3}
140
+ ry={upx * 3}
141
+ stroke={stroke}
142
+ strokeWidth={upx * 0.5}
143
+ vectorEffect="non-scaling-stroke"
144
+ width={aPlateW}
145
+ x={-aPlateW / 2}
146
+ y={-aPlateH / 2}
147
+ />
148
+ <text
149
+ dominantBaseline="middle"
150
+ fill={labelText}
151
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
152
+ fontSize={aFontSize}
153
+ fontWeight={600}
154
+ textAnchor="middle"
155
+ x={0}
156
+ y={0}
157
+ >
158
+ {arc.label}
159
+ </text>
160
+ </g>
161
+ </g>
162
+ )
163
+ })}
164
+ </g>
165
+ )
166
+ }
@@ -23,8 +23,10 @@ import {
23
23
  collectFloorplanLinkedLevelNodes,
24
24
  computeAffectedSiblingIds,
25
25
  floorplanAffordanceReshapeScope,
26
+ floorplanEntryYieldsToTool,
26
27
  floorplanHandleDoubleClickAffordance,
27
28
  InteractiveGeometry,
29
+ isFloorplanHierarchyVisible,
28
30
  isFloorplanOpeningPlacementState,
29
31
  resolveFloorplanHandleUnitsPerPixel,
30
32
  siteToFloorplanTransform,
@@ -780,3 +782,50 @@ describe('collectFloorplanLinkedLevelNodes', () => {
780
782
  ).toEqual([])
781
783
  })
782
784
  })
785
+
786
+ describe('floorplan entry routing while a tool is active', () => {
787
+ test('build tools get presses on entries, as in 3D; select and delete keep selecting', () => {
788
+ expect(floorplanEntryYieldsToTool({ mode: 'build', openingPlacement: false })).toBe(true)
789
+ expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: true })).toBe(true)
790
+ expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: false })).toBe(false)
791
+ expect(floorplanEntryYieldsToTool({ mode: 'delete', openingPlacement: false })).toBe(false)
792
+ })
793
+ })
794
+
795
+ describe('isFloorplanHierarchyVisible', () => {
796
+ const node = (id: string, type: string, parentId: string | null, visible = true) =>
797
+ ({ object: 'node', id, type, parentId, visible, metadata: {} }) as unknown as AnyNode
798
+ const noOverrides = new Map<string, LiveNodeOverrides>()
799
+ const visibleUnder = (nodes: Record<string, AnyNode>, rootId: string, id: string) =>
800
+ isFloorplanHierarchyVisible(nodes[id]!, nodes, noOverrides, rootId as AnyNodeId)
801
+
802
+ test('a hidden Site root keeps the nodes on it, linked or detached', () => {
803
+ const nodes: Record<string, AnyNode> = {
804
+ site_a: node('site_a', 'site', null, false),
805
+ building_a: node('building_a', 'building', 'site_a'),
806
+ level_a: node('level_a', 'level', 'building_a'),
807
+ wall_a: node('wall_a', 'wall', 'level_a'),
808
+ wall_b: node('wall_b', 'wall', 'level_a', false),
809
+ tree_a: node('tree_a', 'trees:tree', null),
810
+ }
811
+ expect(visibleUnder(nodes, 'site_a', 'wall_a')).toBe(true)
812
+ expect(visibleUnder(nodes, 'site_a', 'tree_a')).toBe(true)
813
+ expect(visibleUnder(nodes, 'site_a', 'wall_b')).toBe(false)
814
+ expect(visibleUnder(nodes, 'site_a', 'site_a')).toBe(false)
815
+ })
816
+
817
+ test('a hidden building or level root still hides what it hosts', () => {
818
+ const nodes: Record<string, AnyNode> = {
819
+ site_a: node('site_a', 'site', null),
820
+ building_a: node('building_a', 'building', 'site_a', false),
821
+ level_a: node('level_a', 'level', 'building_a'),
822
+ wall_a: node('wall_a', 'wall', 'level_a'),
823
+ elevator_a: node('elevator_a', 'elevator', null),
824
+ }
825
+ expect(visibleUnder(nodes, 'site_a', 'wall_a')).toBe(false)
826
+ expect(visibleUnder(nodes, 'building_a', 'elevator_a')).toBe(false)
827
+ // A level plan is scoped to its level: the walk stops at the root and
828
+ // never consults the building above it.
829
+ expect(visibleUnder(nodes, 'level_a', 'wall_a')).toBe(true)
830
+ })
831
+ })
@@ -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,
@@ -91,7 +92,7 @@ import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
91
92
  import { paintZoneMembership } from '../../../lib/units'
92
93
  import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
93
94
  import useDrawingView from '../../../store/use-drawing-view'
94
- import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
95
+ import useEditor, { isAngleSnapActive, type Mode } from '../../../store/use-editor'
95
96
  import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
96
97
  import useFloorplanMode from '../../../store/use-floorplan-mode'
97
98
  import useInteractionScope, {
@@ -456,15 +457,32 @@ export function isFloorplanOpeningPlacementState({
456
457
  )
457
458
  }
458
459
 
459
- 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 {
460
474
  const { phase, mode, tool } = useEditor.getState()
461
475
  const movingNode = getMovingNode()
462
- return isFloorplanOpeningPlacementState({
463
- phase,
476
+ return floorplanEntryYieldsToTool({
464
477
  mode,
465
- tool,
466
- movingNodeHasWallOpeningPlacement:
467
- 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
+ }),
468
486
  })
469
487
  }
470
488
 
@@ -558,13 +576,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
558
576
  const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
559
577
  const setMovingNode = useEditor((s) => s.setMovingNode)
560
578
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
561
- // Door / window placement (both build and move) needs the SVG's
562
- // background click handler to run — it finds the closest wall via
563
- // `findClosestWallPoint` and emits `wall:click` for the door / window
564
- // 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
565
583
  // wall's registry entry would otherwise swallow the click via
566
- // `handleClickStop` / `handleSelect`, so the placement never fires.
567
- // Pass clicks through in that case.
584
+ // `handleClickStop` / `handleSelect`, so the tool never sees it. Pass
585
+ // clicks through in that case (`floorplanEntryYieldsToToolNow`).
568
586
  const editorMode = useEditor((s) => s.mode)
569
587
  const structureLayer = useEditor((s) => s.structureLayer)
570
588
  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
@@ -682,9 +700,23 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
682
700
  const currentSelectedIds = useViewer.getState().selection.selectedIds
683
701
  let nextSelectedIds: string[]
684
702
  if (options.shouldToggle) {
685
- nextSelectedIds = currentSelectedIds.includes(id)
686
- ? currentSelectedIds.filter((selectedId) => selectedId !== id)
687
- : [...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
+ }
688
720
  } else if (options.isolateMember) {
689
721
  nextSelectedIds = [id]
690
722
  } else {
@@ -722,7 +754,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
722
754
  )
723
755
 
724
756
  const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
725
- if (isFloorplanOpeningPlacementActiveNow()) return
757
+ if (floorplanEntryYieldsToToolNow()) return
726
758
  event.stopPropagation()
727
759
  }, [])
728
760
 
@@ -733,7 +765,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
733
765
  const node = useScene.getState().nodes[id]
734
766
  if (!node || !isRegistryMovable(node.type)) return false
735
767
  const currentSelectedIds = useViewer.getState().selection.selectedIds
736
- 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
737
772
  const commandModifier = event.metaKey || event.ctrlKey
738
773
  if (
739
774
  !shouldStartDirectMoveDrag({
@@ -980,7 +1015,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
980
1015
  // the stores at event time. Commit clears the interaction scope before
981
1016
  // React paints the next frame; a render-time `undefined` handler leaves
982
1017
  // a short dead zone where the first post-placement selection is lost.
983
- if (isFloorplanOpeningPlacementActiveNow()) return
1018
+ if (floorplanEntryYieldsToToolNow()) return
984
1019
  if (startDirectMoveDrag(id, event)) return
985
1020
  if (startDirectRotateDrag(id, event)) return
986
1021
  if (startGroupMoveDrag(id, event)) return
@@ -3334,15 +3369,26 @@ export function isFloorplanHierarchyVisible(
3334
3369
  liveOverrides: Map<string, LiveNodeOverrides>,
3335
3370
  rootId: AnyNodeId,
3336
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`.
3337
3375
  const root = nodes[rootId]
3338
- 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
+ }
3339
3384
 
3340
3385
  let current: AnyNode | undefined = node
3341
3386
  const seen = new Set<AnyNodeId>()
3342
3387
  while (current) {
3343
3388
  if (seen.has(current.id)) return true
3344
3389
  seen.add(current.id)
3345
- 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
3346
3392
  if (current.id === rootId) return true
3347
3393
  const parentId = current.parentId as AnyNodeId | null
3348
3394
  if (!parentId) return true
@@ -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
+ }
@@ -1,15 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
- import {
3
- type ItemEvent,
4
- ItemNode,
5
- nodeRegistry,
6
- registerNode,
7
- sceneRegistry,
8
- useScene,
9
- } from '@pascal-app/core'
2
+ import { type ItemEvent, ItemNode, nodeRegistry, sceneRegistry, useScene } from '@pascal-app/core'
10
3
  import { Group, Vector3 } from 'three'
11
4
  import useEditor from '../../../store/use-editor'
12
5
  import useInteractionScope from '../../../store/use-interaction-scope'
6
+ import { registerHostingTestNode } from '../__fixtures__/hosting'
13
7
  import { itemSurfaceStrategy } from './placement-strategies'
14
8
  import type { PlacementContext } from './placement-types'
15
9
 
@@ -28,7 +22,7 @@ const scopeState = useInteractionScope.getState()
28
22
  beforeEach(() => {
29
23
  restoreRegistry = nodeRegistry._snapshot()
30
24
  nodeRegistry._reset()
31
- registerNode({
25
+ registerHostingTestNode({
32
26
  kind: 'item',
33
27
  schemaVersion: 1,
34
28
  schema: ItemNode,
@@ -1,16 +1,10 @@
1
1
  import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test'
2
2
  import * as core from '@pascal-app/core'
3
- import {
4
- type ItemEvent,
5
- ItemNode,
6
- nodeRegistry,
7
- registerNode,
8
- sceneRegistry,
9
- useScene,
10
- } from '@pascal-app/core'
3
+ import { type ItemEvent, ItemNode, nodeRegistry, sceneRegistry, useScene } from '@pascal-app/core'
11
4
  import { Group, Vector3 } from 'three'
12
5
  import useEditor from '../../../store/use-editor'
13
6
  import useInteractionScope from '../../../store/use-interaction-scope'
7
+ import { registerHostingTestNode } from '../__fixtures__/hosting'
14
8
  import { itemSurfaceStrategy } from './placement-strategies'
15
9
  import type { PlacementContext } from './placement-types'
16
10
 
@@ -37,7 +31,7 @@ let resolver: ReturnType<typeof spyOn<typeof core, 'resolveSurfacePlacement'>>
37
31
  beforeEach(() => {
38
32
  restoreRegistry = nodeRegistry._snapshot()
39
33
  nodeRegistry._reset()
40
- registerNode({
34
+ registerHostingTestNode({
41
35
  kind: 'item',
42
36
  schemaVersion: 1,
43
37
  schema: ItemNode,