@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.
- package/package.json +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- 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/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- 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-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 +67 -21
- 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 +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- 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 +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- 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/slider-control.tsx +76 -31
- 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/parametric-inspector.tsx +3 -15
- 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 +585 -369
- 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.tsx +1 -1
- 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-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 +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- 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 +42 -29
- package/src/index.tsx +63 -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/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- 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/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -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/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- 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/terrain-panel-model.ts +117 -0
- 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-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
|
-
|
|
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
|
|
463
|
-
phase,
|
|
476
|
+
return floorplanEntryYieldsToTool({
|
|
464
477
|
mode,
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
//
|
|
562
|
-
// background
|
|
563
|
-
//
|
|
564
|
-
//
|
|
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
|
|
567
|
-
//
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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 (
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
registerHostingTestNode({
|
|
41
35
|
kind: 'item',
|
|
42
36
|
schemaVersion: 1,
|
|
43
37
|
schema: ItemNode,
|