@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
@@ -0,0 +1,224 @@
1
+ import {
2
+ type AnyNodeId,
3
+ getLevelDisplayName,
4
+ type LevelNode,
5
+ type UnitNode,
6
+ unitWarnings,
7
+ useScene,
8
+ type ZoneNode,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { AlertTriangle, Trash2, X } from 'lucide-react'
12
+ import { memo, useCallback, useState } from 'react'
13
+ import { useShallow } from 'zustand/react/shallow'
14
+ import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
15
+ import { assignZoneToUnit, enterUnitFocus } from './../../../../../lib/units'
16
+ import { InlineRenameInput } from './inline-rename-input'
17
+ import { routeTreeSelectionToNode, TreeNodeWrapper } from './tree-node'
18
+
19
+ type UnitWarningCode = ReturnType<typeof unitWarnings>[number]['code']
20
+
21
+ const WARNING_LABELS: Record<UnitWarningCode, string> = {
22
+ empty: 'No zones in this unit',
23
+ 'non-adjacent-levels': 'Members sit on non-adjacent levels',
24
+ }
25
+
26
+ const ACTION_BUTTON_CLASS =
27
+ 'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10'
28
+
29
+ interface UnitZoneRowProps {
30
+ zoneId: ZoneNode['id']
31
+ depth: number
32
+ isLast?: boolean
33
+ onRemove?: (zoneId: ZoneNode['id']) => void
34
+ }
35
+
36
+ /** One member zone under a unit: name + level, click selects it. */
37
+ export const UnitZoneRow = memo(function UnitZoneRow({
38
+ zoneId,
39
+ depth,
40
+ isLast,
41
+ onRemove,
42
+ }: UnitZoneRowProps) {
43
+ const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
44
+ const levelLabel = useScene((s) => {
45
+ const level = zone?.parentId ? (s.nodes[zone.parentId as AnyNodeId] as LevelNode | undefined) : undefined
46
+ return level?.type === 'level' ? getLevelDisplayName(level) : null
47
+ })
48
+ const isSelected = useViewer((s) => s.selection.zoneId === zoneId)
49
+ const isHovered = useViewer((s) => s.hoveredId === zoneId)
50
+ const setSelection = useViewer((s) => s.setSelection)
51
+ const setHoveredId = useViewer((s) => s.setHoveredId)
52
+
53
+ const handleClick = useCallback(
54
+ (event: React.MouseEvent) => {
55
+ event.stopPropagation()
56
+ if (!zone) return
57
+ setSelection({ levelId: zone.parentId as LevelNode['id'], zoneId })
58
+ routeTreeSelectionToNode(zone)
59
+ },
60
+ [zone, zoneId, setSelection],
61
+ )
62
+
63
+ if (!zone) return null
64
+
65
+ return (
66
+ <TreeNodeWrapper
67
+ actions={
68
+ onRemove ? (
69
+ <button
70
+ className={ACTION_BUTTON_CLASS}
71
+ onClick={(event) => {
72
+ event.stopPropagation()
73
+ onRemove(zoneId)
74
+ }}
75
+ title="Remove from unit"
76
+ type="button"
77
+ >
78
+ <X className="h-3 w-3" />
79
+ </button>
80
+ ) : undefined
81
+ }
82
+ depth={depth}
83
+ expanded={false}
84
+ hasChildren={false}
85
+ icon={
86
+ <span
87
+ className="h-3 w-3 rounded-sm border border-border/50"
88
+ style={{ backgroundColor: zone.color }}
89
+ />
90
+ }
91
+ isHovered={isHovered}
92
+ isLast={isLast}
93
+ isSelected={isSelected}
94
+ keepIconColor
95
+ label={
96
+ <span className="flex min-w-0 items-baseline gap-1.5">
97
+ <span className="truncate">{zone.name || 'Zone'}</span>
98
+ {levelLabel && (
99
+ <span className="shrink-0 text-muted-foreground text-xs">{levelLabel}</span>
100
+ )}
101
+ </span>
102
+ }
103
+ nodeId={zoneId}
104
+ onClick={handleClick}
105
+ onMouseEnter={() => setHoveredId(zoneId)}
106
+ onMouseLeave={() => setHoveredId(null)}
107
+ onToggle={() => {}}
108
+ />
109
+ )
110
+ })
111
+
112
+ interface UnitTreeNodeProps {
113
+ nodeId: AnyNodeId
114
+ depth: number
115
+ isLast?: boolean
116
+ }
117
+
118
+ export const UnitTreeNode = memo(function UnitTreeNode({
119
+ nodeId,
120
+ depth,
121
+ isLast,
122
+ }: UnitTreeNodeProps) {
123
+ const unitId = nodeId as UnitNode['id']
124
+ const [isEditing, setIsEditing] = useState(false)
125
+ const [expanded, setExpanded] = useState(false)
126
+ const updateNode = useScene((s) => s.updateNode)
127
+ const deleteNode = useScene((s) => s.deleteNode)
128
+ const color = useScene((s) => (s.nodes[nodeId] as UnitNode | undefined)?.color)
129
+ const members = useScene(
130
+ useShallow((s) => (s.nodes[nodeId] as UnitNode | undefined)?.members ?? []),
131
+ )
132
+ const warningCodes = useScene(
133
+ useShallow((s) => {
134
+ const unit = s.nodes[nodeId]
135
+ return unit?.type === 'unit' ? unitWarnings(unit, s.nodes).map((w) => w.code) : []
136
+ }),
137
+ )
138
+ const isSelected = useViewer((s) => s.selection.selectedIds.includes(nodeId))
139
+ const isHovered = useViewer((s) => s.hoveredId === nodeId)
140
+ const setHoveredId = useViewer((s) => s.setHoveredId)
141
+ const isFocused = useViewer((s) => s.focusedUnitId === unitId)
142
+
143
+ const handleClick = useCallback(
144
+ (event: React.MouseEvent) => {
145
+ event.stopPropagation()
146
+ enterUnitFocus(unitId)
147
+ },
148
+ [unitId],
149
+ )
150
+
151
+ const handleRemoveMember = useCallback(
152
+ (zoneId: ZoneNode['id']) => assignZoneToUnit(zoneId, null),
153
+ [],
154
+ )
155
+
156
+ const warningTitle = [...new Set(warningCodes)].map((code) => WARNING_LABELS[code]).join('\n')
157
+
158
+ return (
159
+ <TreeNodeWrapper
160
+ actions={
161
+ <button
162
+ className={ACTION_BUTTON_CLASS}
163
+ onClick={(event) => {
164
+ event.stopPropagation()
165
+ deleteNode(unitId)
166
+ }}
167
+ title="Delete unit"
168
+ type="button"
169
+ >
170
+ <Trash2 className="h-3 w-3" />
171
+ </button>
172
+ }
173
+ depth={depth}
174
+ expanded={expanded}
175
+ hasChildren={members.length > 0}
176
+ icon={
177
+ <ColorDot
178
+ color={color ?? '#f59e0b'}
179
+ onChange={(next) => updateNode(unitId, { color: next })}
180
+ />
181
+ }
182
+ isHovered={isHovered}
183
+ isLast={isLast}
184
+ isSelected={isSelected}
185
+ keepIconColor
186
+ label={
187
+ <span className="flex min-w-0 items-center gap-1.5">
188
+ <InlineRenameInput
189
+ defaultName="Unit"
190
+ isEditing={isEditing}
191
+ nodeId={nodeId}
192
+ onStartEditing={() => setIsEditing(true)}
193
+ onStopEditing={() => setIsEditing(false)}
194
+ />
195
+ {isFocused && (
196
+ <span className="shrink-0 rounded-full bg-primary/15 px-1.5 text-[10px] text-primary leading-4">
197
+ Focused
198
+ </span>
199
+ )}
200
+ {warningCodes.length > 0 && (
201
+ <span className="shrink-0 text-muted-foreground" title={warningTitle}>
202
+ <AlertTriangle className="h-3 w-3" />
203
+ </span>
204
+ )}
205
+ </span>
206
+ }
207
+ nodeId={nodeId}
208
+ onClick={handleClick}
209
+ onMouseEnter={() => setHoveredId(nodeId)}
210
+ onMouseLeave={() => setHoveredId(null)}
211
+ onToggle={() => setExpanded((value) => !value)}
212
+ >
213
+ {members.map((zoneId, index) => (
214
+ <UnitZoneRow
215
+ depth={depth + 1}
216
+ isLast={index === members.length - 1}
217
+ key={zoneId}
218
+ onRemove={handleRemoveMember}
219
+ zoneId={zoneId}
220
+ />
221
+ ))}
222
+ </TreeNodeWrapper>
223
+ )
224
+ })
@@ -0,0 +1,22 @@
1
+ import { Checkbox } from './../../../primitives/checkbox'
2
+
3
+ /** Membership toggle shown on zone rows while a unit is focused. */
4
+ export function ZoneMembershipCheckbox({
5
+ checked,
6
+ onToggle,
7
+ unitName,
8
+ }: {
9
+ checked: boolean
10
+ onToggle: () => void
11
+ unitName: string
12
+ }) {
13
+ return (
14
+ <Checkbox
15
+ aria-label={`In ${unitName}`}
16
+ checked={checked}
17
+ className="mr-2"
18
+ onCheckedChange={onToggle}
19
+ onDoubleClick={(event) => event.stopPropagation()}
20
+ />
21
+ )
22
+ }
@@ -3,9 +3,11 @@ import { useViewer } from '@pascal-app/viewer'
3
3
  import { memo, useCallback, useState } from 'react'
4
4
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
5
5
  import { formatAreaLabel } from './../../../../../lib/measurements'
6
+ import { toggleZoneMembership } from './../../../../../lib/units'
6
7
  import { InlineRenameInput } from './inline-rename-input'
7
8
  import { focusTreeNode, TreeNodeWrapper } from './tree-node'
8
9
  import { TreeNodeActions } from './tree-node-actions'
10
+ import { ZoneMembershipCheckbox } from './zone-membership-checkbox'
9
11
 
10
12
  interface ZoneTreeNodeProps {
11
13
  nodeId: ZoneNode['id']
@@ -28,6 +30,11 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
28
30
  const setSelection = useViewer((state) => state.setSelection)
29
31
  const setHoveredId = useViewer((state) => state.setHoveredId)
30
32
  const unit = useViewer((state) => state.unit)
33
+ const focusedUnitId = useViewer((state) => state.focusedUnitId)
34
+ const focusedUnit = useScene((s) => {
35
+ const focused = focusedUnitId ? s.nodes[focusedUnitId] : undefined
36
+ return focused?.type === 'unit' ? focused : null
37
+ })
31
38
 
32
39
  const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
33
40
  const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
@@ -50,15 +57,25 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
50
57
  isHovered={isHovered}
51
58
  isLast={isLast}
52
59
  isSelected={isSelected}
60
+ keepIconColor
53
61
  isVisible={isVisible}
54
62
  label={
55
- <InlineRenameInput
56
- defaultName={defaultName}
57
- isEditing={isEditing}
58
- nodeId={nodeId}
59
- onStartEditing={handleStartEditing}
60
- onStopEditing={handleStopEditing}
61
- />
63
+ <span className="flex min-w-0 items-center">
64
+ {focusedUnit && (
65
+ <ZoneMembershipCheckbox
66
+ checked={focusedUnit.members.includes(nodeId)}
67
+ onToggle={() => toggleZoneMembership(focusedUnit.id, nodeId)}
68
+ unitName={focusedUnit.name || 'Unit'}
69
+ />
70
+ )}
71
+ <InlineRenameInput
72
+ defaultName={defaultName}
73
+ isEditing={isEditing}
74
+ nodeId={nodeId}
75
+ onStartEditing={handleStartEditing}
76
+ onStopEditing={handleStopEditing}
77
+ />
78
+ </span>
62
79
  }
63
80
  onClick={handleClick}
64
81
  onDoubleClick={handleDoubleClick}
@@ -1,4 +1,5 @@
1
1
  import type { AnyNode, AssetInput } from '@pascal-app/core'
2
+ import { isProceduralItem } from '@pascal-app/core/procedural-items'
2
3
  import type { ReactNode } from 'react'
3
4
  import { cn } from '../../lib/utils'
4
5
 
@@ -35,6 +36,7 @@ export function resolveAssetSnapTarget(attachTo: AssetInput['attachTo']): SnapTa
35
36
 
36
37
  export function resolveNodeSnapTarget(node: AnyNode | null | undefined): SnapTarget | null {
37
38
  if (!node) return null
39
+ if (isProceduralItem(node)) return resolveAssetSnapTarget(node.recipe.mounting?.attachTo)
38
40
  if ('roofSegmentId' in node && typeof node.roofSegmentId === 'string') return 'roof'
39
41
  if (node.type === 'downspout') return 'roof'
40
42
  if (node.type === 'door' || node.type === 'window') return 'wall'
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode } from '@pascal-app/core'
3
+ import { isVisibleInFloorplan } from './floorplan-preview-visibility'
4
+
5
+ const node = (id: string, type: string, parentId: string | null, visible = true) =>
6
+ ({ object: 'node', id, type, parentId, visible, metadata: {} }) as unknown as AnyNode
7
+
8
+ describe('isVisibleInFloorplan', () => {
9
+ test('a hidden Site keeps the buildings on it in the plan', () => {
10
+ const nodes: Record<string, AnyNode> = {
11
+ site_a: node('site_a', 'site', null, false),
12
+ building_a: node('building_a', 'building', 'site_a'),
13
+ level_a: node('level_a', 'level', 'building_a'),
14
+ wall_a: node('wall_a', 'wall', 'level_a'),
15
+ wall_b: node('wall_b', 'wall', 'level_a', false),
16
+ }
17
+ expect(isVisibleInFloorplan(nodes.wall_a!, nodes)).toBe(true)
18
+ expect(isVisibleInFloorplan(nodes.wall_b!, nodes)).toBe(false)
19
+ expect(isVisibleInFloorplan(nodes.site_a!, nodes)).toBe(false)
20
+ })
21
+
22
+ test('a hidden building or level still hides its subtree', () => {
23
+ const nodes: Record<string, AnyNode> = {
24
+ site_a: node('site_a', 'site', null),
25
+ building_a: node('building_a', 'building', 'site_a', false),
26
+ level_a: node('level_a', 'level', 'building_a'),
27
+ wall_a: node('wall_a', 'wall', 'level_a'),
28
+ building_b: node('building_b', 'building', 'site_a'),
29
+ level_b: node('level_b', 'level', 'building_b', false),
30
+ wall_b: node('wall_b', 'wall', 'level_b'),
31
+ }
32
+ expect(isVisibleInFloorplan(nodes.wall_a!, nodes)).toBe(false)
33
+ expect(isVisibleInFloorplan(nodes.wall_b!, nodes)).toBe(false)
34
+ })
35
+ })
@@ -0,0 +1,19 @@
1
+ import { type AnyNode, hidesDescendants } from '@pascal-app/core'
2
+
3
+ /**
4
+ * A node draws in the plan unless it, or an ancestor whose flag reaches its
5
+ * descendants, is hidden. A hidden Site keeps its buildings on the plan; see
6
+ * `hidesDescendants`.
7
+ */
8
+ export function isVisibleInFloorplan(node: AnyNode, nodes: Record<string, AnyNode>): boolean {
9
+ if (node.visible === false) return false
10
+ const seen = new Set<string>([node.id])
11
+ let current: AnyNode | undefined = node.parentId ? nodes[node.parentId] : undefined
12
+ while (current) {
13
+ if (seen.has(current.id)) return true
14
+ seen.add(current.id)
15
+ if (current.visible === false && hidesDescendants(current)) return false
16
+ current = current.parentId ? nodes[current.parentId] : undefined
17
+ }
18
+ return true
19
+ }
@@ -62,6 +62,7 @@ import {
62
62
  rotateFloorplanPoint,
63
63
  visibleFloorplanViewWidth,
64
64
  } from './floorplan-preview-navigation'
65
+ import { isVisibleInFloorplan } from './floorplan-preview-visibility'
65
66
 
66
67
  const READ_ONLY_PALETTE: FloorplanPalette = {
67
68
  selectedStroke: '#4f46e5',
@@ -193,18 +194,6 @@ export function normalizeFloorplanPreviewNodes(
193
194
  return normalized
194
195
  }
195
196
 
196
- function isVisibleInFloorplan(node: AnyNode, nodes: Record<string, AnyNode>): boolean {
197
- const seen = new Set<string>()
198
- let current: AnyNode | undefined = node
199
- while (current) {
200
- if (seen.has(current.id)) return true
201
- seen.add(current.id)
202
- if (current.visible === false) return false
203
- current = current.parentId ? nodes[current.parentId] : undefined
204
- }
205
- return true
206
- }
207
-
208
197
  function buildFloorplanGeometries(
209
198
  nodes: Record<string, AnyNode>,
210
199
  installedPlugins: readonly string[] | undefined,
@@ -15,6 +15,7 @@ import Link from 'next/link'
15
15
  import type { ReactNode } from 'react'
16
16
  import { useShallow } from 'zustand/react/shallow'
17
17
  import { cn } from '../../lib/utils'
18
+ import { ViewerUnitsPanel } from './viewer-units-panel'
18
19
 
19
20
  const getNodeName = (node: AnyNode): string => {
20
21
  if ('name' in node && node.name) return node.name
@@ -63,6 +64,7 @@ export const ViewerSceneHeader = ({
63
64
  const selectedNode = useScene((s) =>
64
65
  firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
65
66
  )
67
+ const sceneNodes = useScene((s) => s.nodes)
66
68
  // Highest first so the list reads top-down like a building section.
67
69
  const levels = useScene(
68
70
  useShallow((s) => {
@@ -231,6 +233,7 @@ export const ViewerSceneHeader = ({
231
233
  </div>
232
234
  </div>
233
235
  )}
236
+ {building && <ViewerUnitsPanel nodes={sceneNodes} />}
234
237
  </div>
235
238
  )
236
239
  }
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ buildUnitReport,
6
+ type LevelNode,
7
+ type UnitKind,
8
+ type UnitNode,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { useMemo } from 'react'
12
+ import { getAreaUnitLabel, squareMetersToAreaUnit } from '../../lib/measurements'
13
+ import { cn } from '../../lib/utils'
14
+
15
+ const UNIT_KIND_LABELS: Record<UnitKind, string> = {
16
+ apartment: 'Apartment',
17
+ 'hotel-room': 'Hotel room',
18
+ commercial: 'Commercial',
19
+ common: 'Common',
20
+ }
21
+
22
+ type UnitRow = {
23
+ unit: UnitNode
24
+ zoneCount: number
25
+ areaM2: number
26
+ /** Levels carrying member zones, lowest ordinal first. */
27
+ levelIds: LevelNode['id'][]
28
+ }
29
+
30
+ /**
31
+ * Read-only unit list shared by the editor preview and the published viewer.
32
+ * Clicking a row focuses the unit and reveals a member floor through the shared
33
+ * `useViewer.selection`; a second click clears the focus. Hidden without units.
34
+ */
35
+ export function ViewerUnitsPanel({ nodes }: { nodes: Readonly<Record<string, AnyNode>> }) {
36
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
37
+ const areaUnit = useViewer((s) => s.unit)
38
+
39
+ const rows = useMemo<UnitRow[]>(
40
+ () =>
41
+ Object.values(nodes)
42
+ .filter((node): node is UnitNode => node.type === 'unit')
43
+ .map((unit) => {
44
+ const report = buildUnitReport(unit, nodes)
45
+ const ordinals = new Map<LevelNode['id'], number>()
46
+ for (const member of report.members) {
47
+ if (member.levelId && member.levelOrdinal !== null) {
48
+ ordinals.set(member.levelId, member.levelOrdinal)
49
+ }
50
+ }
51
+ return {
52
+ unit,
53
+ zoneCount: report.memberCount,
54
+ areaM2: report.grossAreaM2,
55
+ levelIds: [...ordinals.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id),
56
+ }
57
+ }),
58
+ [nodes],
59
+ )
60
+
61
+ if (rows.length === 0) return null
62
+
63
+ return (
64
+ <div className="corner-smooth pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-elevation-4 backdrop-blur-xl">
65
+ <span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
66
+ Units
67
+ </span>
68
+ <div className="flex flex-col">
69
+ {rows.map((row) => {
70
+ const isFocused = row.unit.id === focusedUnitId
71
+ const area = Math.round(squareMetersToAreaUnit(row.areaM2, areaUnit))
72
+ const details = `${UNIT_KIND_LABELS[row.unit.kind]} · ${row.zoneCount} ${row.zoneCount === 1 ? 'zone' : 'zones'} · ${area} ${getAreaUnitLabel(areaUnit)}`
73
+ return (
74
+ <button
75
+ className={cn(
76
+ 'group/row relative flex w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 py-1.5 text-sm transition-all duration-200',
77
+ isFocused
78
+ ? 'border-r-3 border-r-white bg-accent/50 text-foreground'
79
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
80
+ )}
81
+ key={row.unit.id}
82
+ onClick={() => {
83
+ const viewer = useViewer.getState()
84
+ if (isFocused) {
85
+ viewer.setFocusedUnit(null)
86
+ return
87
+ }
88
+ viewer.setFocusedUnit(row.unit.id)
89
+ // Same rule as the editor: keep the current floor when it
90
+ // already carries a member zone, otherwise reveal the lowest one.
91
+ const current = viewer.selection.levelId
92
+ const levelId =
93
+ current && row.levelIds.includes(current) ? current : row.levelIds[0]
94
+ if (levelId && levelId !== current) viewer.setSelection({ levelId })
95
+ }}
96
+ type="button"
97
+ >
98
+ <div className="flex min-w-0 flex-1 items-center gap-2">
99
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center">
100
+ <span
101
+ className="h-2.5 w-2.5 rounded-full"
102
+ style={{ backgroundColor: row.unit.color }}
103
+ />
104
+ </span>
105
+ <div className="min-w-0 flex-1 text-left">
106
+ <div className="truncate">{row.unit.name}</div>
107
+ <div className="truncate text-[11px] text-muted-foreground">{details}</div>
108
+ </div>
109
+ </div>
110
+ </button>
111
+ )
112
+ })}
113
+ </div>
114
+ </div>
115
+ )
116
+ }
@@ -33,10 +33,14 @@ export const ViewerZoneSystem = () => {
33
33
  // Zone geometry: visible in zone mode on the right level, OR when this zone is selected.
34
34
  // The editor ZoneSystem handles the selected zone's opacity animation.
35
35
  const isSelected = id === zoneId
36
+ // A zone the author hid (sidebar eye) takes the group with it — this
37
+ // per-frame write would otherwise undo the renderer's `visible` prop.
38
+ const nodeVisible = zone.visible !== false
36
39
  const shouldShowGeometry =
40
+ nodeVisible &&
37
41
  !isCaptureMode &&
38
42
  ((structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected)
39
- if (!obj.visible) obj.visible = true
43
+ if (obj.visible !== nodeVisible) obj.visible = nodeVisible
40
44
  obj.traverse((child) => {
41
45
  if ((child as Mesh).isMesh) {
42
46
  child.visible = shouldShowGeometry
@@ -44,7 +48,8 @@ export const ViewerZoneSystem = () => {
44
48
  })
45
49
 
46
50
  // Labels: always visible on the current level (regardless of mode or zone selection)
47
- const showLabel = !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
51
+ const showLabel =
52
+ nodeVisible && !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
48
53
  const targetOpacity = showLabel ? '1' : '0'
49
54
  const labelEl = document.getElementById(`${id}-label`)
50
55
  if (labelEl && labelEl.style.opacity !== targetOpacity) {