@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.
- package/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- 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
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 (
|
|
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 =
|
|
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) {
|