@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
|
@@ -3,14 +3,22 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
cloneNodesInto,
|
|
5
5
|
collectSubtree,
|
|
6
|
+
createSceneApi,
|
|
6
7
|
type DuplicableConfig,
|
|
8
|
+
getSurfaceProvider,
|
|
7
9
|
nodeRegistry,
|
|
10
|
+
resolveSurfacePlacement,
|
|
11
|
+
type SurfaceRejectReason,
|
|
8
12
|
useScene,
|
|
9
13
|
} from '@pascal-app/core'
|
|
14
|
+
import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
15
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
16
|
+
import usePlacementPreview from '../store/use-placement-preview'
|
|
10
17
|
import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
|
|
11
18
|
import {
|
|
12
19
|
surfaceAttachmentId,
|
|
13
20
|
surfaceAttachmentUpdates,
|
|
21
|
+
surfaceFramePose,
|
|
14
22
|
updateSurfaceNode,
|
|
15
23
|
} from './surface-attachment'
|
|
16
24
|
|
|
@@ -32,7 +40,13 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
|
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
|
|
35
|
-
|
|
43
|
+
// A surface-local root needs the same attachment lifecycle even without descendants.
|
|
44
|
+
if (surfaceAttachmentId(node) !== null) return true
|
|
45
|
+
const policy = duplicableConfigFor(node)?.subtree
|
|
46
|
+
return (
|
|
47
|
+
policy === true ||
|
|
48
|
+
(policy === 'with-children' && 'children' in node && node.children.length > 0)
|
|
49
|
+
)
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/**
|
|
@@ -99,57 +113,175 @@ export function createFreshPlacementSubtree(
|
|
|
99
113
|
parentId,
|
|
100
114
|
})
|
|
101
115
|
|
|
102
|
-
|
|
103
|
-
.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
116
|
+
scene.applyNodeChanges({
|
|
117
|
+
create: cloned.nodes.map((node, index) =>
|
|
118
|
+
index === 0 && parentId ? { node, parentId } : { node },
|
|
119
|
+
),
|
|
120
|
+
update: surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceAttachmentId(subtree.root)),
|
|
121
|
+
})
|
|
107
122
|
|
|
123
|
+
const created = useScene.getState().nodes[cloned.rootId]
|
|
124
|
+
if (!created) return null
|
|
125
|
+
useInteractionScope.getState().noteSubtreeCreation(created)
|
|
108
126
|
return cloned.rootId
|
|
109
127
|
}
|
|
110
128
|
|
|
129
|
+
export function discardFreshPlacementSubtree(rootId: AnyNodeId): void {
|
|
130
|
+
const scene = useScene.getState()
|
|
131
|
+
if (!scene.nodes[rootId]) return
|
|
132
|
+
scene.applyNodeChanges({
|
|
133
|
+
delete: [rootId],
|
|
134
|
+
update: surfaceAttachmentUpdates(rootId, null, null),
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function namedSurfaceRejection(
|
|
139
|
+
root: AnyNode,
|
|
140
|
+
surfaceId: string | null,
|
|
141
|
+
): SurfaceRejectReason | null {
|
|
142
|
+
if (surfaceId === null) return null
|
|
143
|
+
const scene = createSceneApi(useScene)
|
|
144
|
+
const host = root.parentId ? scene.get(root.parentId as AnyNodeId) : undefined
|
|
145
|
+
if (!host || !('position' in root) || !Array.isArray(root.rotation)) return 'no-surface'
|
|
146
|
+
const surface = getSurfaceProvider(host)
|
|
147
|
+
.surfaces?.(host, { scene })
|
|
148
|
+
.find((s) => s.id === surfaceId)
|
|
149
|
+
if (!surface) return 'no-surface'
|
|
150
|
+
const pose = surfaceFramePose(
|
|
151
|
+
root.parentId,
|
|
152
|
+
surfaceId,
|
|
153
|
+
root as {
|
|
154
|
+
position: [number, number, number]
|
|
155
|
+
rotation: [number, number, number]
|
|
156
|
+
},
|
|
157
|
+
false,
|
|
158
|
+
)
|
|
159
|
+
const capabilities = nodeRegistry.get(root.type)?.capabilities
|
|
160
|
+
const evaluated = isProceduralItem(root) ? evaluateRecipe(root.recipe, root.parameters) : null
|
|
161
|
+
const bounds =
|
|
162
|
+
capabilities?.dragBounds?.(root, scene.nodes()) ??
|
|
163
|
+
(evaluated
|
|
164
|
+
? {
|
|
165
|
+
size: evaluated.dimensions,
|
|
166
|
+
center: evaluated.min.map((v, i) => (v + evaluated.max[i]!) / 2) as [
|
|
167
|
+
number,
|
|
168
|
+
number,
|
|
169
|
+
number,
|
|
170
|
+
],
|
|
171
|
+
}
|
|
172
|
+
: undefined)
|
|
173
|
+
const size = bounds?.size ??
|
|
174
|
+
capabilities?.floorPlaced?.footprint?.(root, { nodes: scene.nodes() }).dimensions ?? [0, 0, 0]
|
|
175
|
+
const center = bounds?.center
|
|
176
|
+
const localBounds = center
|
|
177
|
+
? {
|
|
178
|
+
min: center.map((value, axis) => value - size[axis]! / 2) as [number, number, number],
|
|
179
|
+
max: center.map((value, axis) => value + size[axis]! / 2) as [number, number, number],
|
|
180
|
+
}
|
|
181
|
+
: undefined
|
|
182
|
+
let reason: SurfaceRejectReason | null = null
|
|
183
|
+
const placement = resolveSurfacePlacement({
|
|
184
|
+
host,
|
|
185
|
+
surface,
|
|
186
|
+
childKind: root.type,
|
|
187
|
+
childId: root.id,
|
|
188
|
+
childFootprint: {
|
|
189
|
+
size,
|
|
190
|
+
rotationY: pose.rotation[1],
|
|
191
|
+
rotation: pose.rotation,
|
|
192
|
+
localBounds,
|
|
193
|
+
},
|
|
194
|
+
hit: { point: pose.position, normalWorldY: 1 },
|
|
195
|
+
origin: pose.position,
|
|
196
|
+
scene,
|
|
197
|
+
onReject: (value) => {
|
|
198
|
+
reason = value
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
if (!placement) return reason ?? 'no-surface'
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
111
205
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* Fresh drafts already exist in the scene so renderers and move tools can
|
|
115
|
-
* preview real geometry. On commit we delete that draft while history is
|
|
116
|
-
* paused, then create a clean clone at the final cursor position with history
|
|
117
|
-
* resumed. Undo therefore removes the placed node instead of resurrecting the
|
|
118
|
-
* hidden draft at its origin.
|
|
206
|
+
* Replace the draft in one validated write. History already excludes fresh
|
|
207
|
+
* subtrees, so this records one creation without first deleting the preview.
|
|
119
208
|
*/
|
|
120
209
|
export function commitFreshPlacementSubtree(
|
|
121
210
|
rootId: AnyNodeId,
|
|
122
211
|
rootPatch: Partial<AnyNode>,
|
|
212
|
+
onReject?: (reason: SurfaceRejectReason) => void,
|
|
123
213
|
): AnyNodeId | null {
|
|
124
214
|
const scene = useScene.getState()
|
|
125
215
|
const subtree = collectSubtree(scene.nodes, rootId)
|
|
126
|
-
if (!subtree) return null
|
|
216
|
+
if (!subtree || scene.readOnly) return null
|
|
127
217
|
|
|
128
|
-
const root = cleanPlacementMetadata({
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
218
|
+
const root = cleanPlacementMetadata({ ...subtree.root, ...rootPatch } as AnyNode)
|
|
219
|
+
const surfaceId =
|
|
220
|
+
root.parentId === subtree.root.parentId ? surfaceAttachmentId(subtree.root) : null
|
|
221
|
+
const rejection = namedSurfaceRejection(root, surfaceId)
|
|
222
|
+
if (rejection) {
|
|
223
|
+
onReject?.(rejection)
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
132
226
|
const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
|
|
133
227
|
const parentId = parentIdOf(root)
|
|
134
|
-
const cloned = cloneNodesInto([root, ...descendants], {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
228
|
+
const cloned = cloneNodesInto([root, ...descendants], { rootId, parentId })
|
|
229
|
+
const updates = surfaceAttachmentUpdates(rootId, null, null)
|
|
230
|
+
for (const update of surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceId)) {
|
|
231
|
+
const attachments = { ...(update.data as { attachments: Record<string, string> }).attachments }
|
|
232
|
+
delete attachments[rootId]
|
|
233
|
+
const previous = updates.findIndex((entry) => entry.id === update.id)
|
|
234
|
+
const merged = { id: update.id, data: { attachments } }
|
|
235
|
+
if (previous === -1) updates.push(merged)
|
|
236
|
+
else updates[previous] = merged
|
|
237
|
+
}
|
|
138
238
|
|
|
139
|
-
const
|
|
239
|
+
const scope = useInteractionScope.getState()
|
|
240
|
+
const factoryCreated =
|
|
241
|
+
scope.ownedSubtree?.creation.rootId === rootId || scope.pendingSubtree?.rootId === rootId
|
|
140
242
|
const temporal = useScene.temporal.getState()
|
|
141
|
-
const wasTracking =
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
243
|
+
const wasTracking = temporal.isTracking
|
|
244
|
+
if (!factoryCreated && descendants.length === 0 && surfaceId === null) {
|
|
245
|
+
// Preserve the established root-only lifecycle for placements outside the subtree factory.
|
|
246
|
+
if (wasTracking) temporal.pause()
|
|
247
|
+
updateSurfaceNode(rootId, {}, null)
|
|
248
|
+
useScene.getState().deleteNode(rootId)
|
|
249
|
+
temporal.resume()
|
|
250
|
+
useScene.getState().applyNodeChanges({
|
|
251
|
+
create: cloned.nodes.map((node, index) =>
|
|
252
|
+
index === 0 && parentId ? { node, parentId } : { node },
|
|
253
|
+
),
|
|
254
|
+
update: updates,
|
|
255
|
+
})
|
|
256
|
+
if (!wasTracking) temporal.pause()
|
|
257
|
+
} else {
|
|
258
|
+
temporal.resume()
|
|
259
|
+
try {
|
|
260
|
+
// applyNodeChanges validates the complete proposed graph before publishing any part of it.
|
|
261
|
+
scene.applyNodeChanges({
|
|
262
|
+
delete: [rootId],
|
|
263
|
+
create: cloned.nodes.map((node, index) =>
|
|
264
|
+
index === 0 && parentId ? { node, parentId } : { node },
|
|
265
|
+
),
|
|
266
|
+
update: updates,
|
|
267
|
+
})
|
|
268
|
+
for (const node of [subtree.root, ...subtree.descendants]) scene.clearDirty(node.id)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// Zustand publishes before notifying subscribers. A subscriber error must restore
|
|
271
|
+
// the draft and its ownership/history, never masquerade as a placement refusal.
|
|
272
|
+
temporal.pause()
|
|
273
|
+
try {
|
|
274
|
+
if (useScene.getState() !== scene) useScene.setState(scene, true)
|
|
275
|
+
} finally {
|
|
276
|
+
useInteractionScope.setState(scope)
|
|
277
|
+
useScene.temporal.setState(temporal)
|
|
278
|
+
}
|
|
279
|
+
throw error
|
|
280
|
+
} finally {
|
|
281
|
+
if (!wasTracking) temporal.pause()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
useInteractionScope.getState().finishSubtree(rootId)
|
|
285
|
+
if (usePlacementPreview.getState().node?.id === rootId) usePlacementPreview.getState().clear()
|
|
154
286
|
return cloned.rootId
|
|
155
287
|
}
|
|
@@ -590,19 +590,64 @@ describe('prepareSceneForExport', () => {
|
|
|
590
590
|
expect(animations).toHaveLength(0)
|
|
591
591
|
})
|
|
592
592
|
|
|
593
|
-
test('
|
|
593
|
+
test('keeps the buildings on a hidden Site and drops only the Site ground', () => {
|
|
594
|
+
// A layout authored outside the editor can hide the root Site (the
|
|
595
|
+
// renderer ignores that flag) while every node on it stays visible. The
|
|
596
|
+
// Site's own flag must stop at the Site: it is the parcel reference, not
|
|
597
|
+
// a container the building inherits visibility from.
|
|
598
|
+
const root = new THREE.Group()
|
|
599
|
+
const siteGroup = new THREE.Group()
|
|
600
|
+
const siteGround = meshWithNodeMaterial(nodeMaterial())
|
|
601
|
+
const buildingGroup = new THREE.Group()
|
|
602
|
+
const levelGroup = new THREE.Group()
|
|
603
|
+
const itemGroup = new THREE.Group()
|
|
604
|
+
itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
|
|
605
|
+
levelGroup.add(itemGroup)
|
|
606
|
+
buildingGroup.add(levelGroup)
|
|
607
|
+
siteGroup.add(buildingGroup, siteGround)
|
|
608
|
+
root.add(siteGroup)
|
|
609
|
+
|
|
610
|
+
const siteId = 'site_hidden'
|
|
611
|
+
const buildingId = 'building_on_hidden_site'
|
|
612
|
+
const levelId = 'level_on_hidden_site'
|
|
613
|
+
const itemId = 'item_on_hidden_site'
|
|
614
|
+
sceneRegistry.nodes.set(siteId, siteGroup)
|
|
615
|
+
sceneRegistry.nodes.set(buildingId, buildingGroup)
|
|
616
|
+
sceneRegistry.nodes.set(levelId, levelGroup)
|
|
617
|
+
sceneRegistry.nodes.set(itemId, itemGroup)
|
|
618
|
+
const node = (id: string, type: string, parentId: string | null, visible: boolean) =>
|
|
619
|
+
({ object: 'node', id, type, parentId, visible }) as unknown as AnyNode
|
|
620
|
+
const nodes: Record<string, AnyNode> = {
|
|
621
|
+
[siteId]: node(siteId, 'site', null, false),
|
|
622
|
+
[buildingId]: node(buildingId, 'building', siteId, true),
|
|
623
|
+
[levelId]: node(levelId, 'level', buildingId, true),
|
|
624
|
+
[itemId]: node(itemId, 'item', levelId, true),
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const { scene } = prepareSceneForExport(root, nodes)
|
|
628
|
+
|
|
629
|
+
const meshes: THREE.Mesh[] = []
|
|
630
|
+
scene.traverse((object) => {
|
|
631
|
+
if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
|
|
632
|
+
})
|
|
633
|
+
expect(scene.getObjectByName(buildingId)).toBeDefined()
|
|
634
|
+
expect(scene.getObjectByName(itemId)).toBeDefined()
|
|
635
|
+
expect(meshes).toHaveLength(1)
|
|
636
|
+
expect(meshes[0]?.parent?.name).toBe(itemId)
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
test('keeps the nodes a hidden Site hosts, declared or detached, unless hidden themselves', async () => {
|
|
594
640
|
const restoreRegistry = nodeRegistry._snapshot()
|
|
595
641
|
try {
|
|
596
|
-
const kind = 'test:
|
|
642
|
+
const kind = 'test:hidden-site-host'
|
|
597
643
|
const childId = 'detached_site_child'
|
|
598
644
|
const descendantId = 'detached_site_descendant'
|
|
599
645
|
const explicitId = 'explicit_site_child'
|
|
600
|
-
const
|
|
646
|
+
const hiddenChildId = 'hidden_site_child'
|
|
601
647
|
const hiddenSite = SiteNode.parse({
|
|
602
648
|
visible: false,
|
|
603
|
-
children: [childId, explicitId],
|
|
649
|
+
children: [childId, explicitId, hiddenChildId],
|
|
604
650
|
})
|
|
605
|
-
const visibleSite = SiteNode.parse({ visible: true })
|
|
606
651
|
registerNode({
|
|
607
652
|
kind,
|
|
608
653
|
schemaVersion: 1,
|
|
@@ -615,17 +660,19 @@ describe('prepareSceneForExport', () => {
|
|
|
615
660
|
} as AnyNodeDefinition)
|
|
616
661
|
const nodes = {
|
|
617
662
|
[hiddenSite.id]: hiddenSite,
|
|
618
|
-
[visibleSite.id]: visibleSite,
|
|
619
663
|
[childId]: { id: childId, type: kind, parentId: null, visible: true },
|
|
620
664
|
[descendantId]: { id: descendantId, type: kind, parentId: childId, visible: true },
|
|
621
|
-
[explicitId]: { id: explicitId, type: kind, parentId:
|
|
622
|
-
[
|
|
665
|
+
[explicitId]: { id: explicitId, type: kind, parentId: hiddenSite.id, visible: true },
|
|
666
|
+
[hiddenChildId]: { id: hiddenChildId, type: kind, parentId: hiddenSite.id, visible: false },
|
|
623
667
|
} as unknown as Record<string, AnyNode>
|
|
624
|
-
const allIds = [childId, descendantId, explicitId,
|
|
668
|
+
const allIds = [childId, descendantId, explicitId, hiddenChildId]
|
|
625
669
|
const root = new THREE.Group()
|
|
626
|
-
|
|
670
|
+
const siteObject = new THREE.Group()
|
|
671
|
+
root.add(siteObject)
|
|
672
|
+
sceneRegistry.nodes.set(hiddenSite.id, siteObject)
|
|
673
|
+
for (const id of allIds) {
|
|
627
674
|
const object = new THREE.Group()
|
|
628
|
-
|
|
675
|
+
siteObject.add(object)
|
|
629
676
|
sceneRegistry.nodes.set(id, object)
|
|
630
677
|
}
|
|
631
678
|
const exportedIds = async (onlyVisible?: boolean) => {
|
|
@@ -637,12 +684,8 @@ describe('prepareSceneForExport', () => {
|
|
|
637
684
|
}
|
|
638
685
|
}
|
|
639
686
|
|
|
640
|
-
expect(await exportedIds()).toEqual([
|
|
687
|
+
expect(await exportedIds()).toEqual([childId, descendantId, explicitId])
|
|
641
688
|
expect(await exportedIds(false)).toEqual(allIds)
|
|
642
|
-
nodes[hiddenSite.id] = { ...hiddenSite, visible: true }
|
|
643
|
-
expect(await exportedIds()).toEqual(allIds)
|
|
644
|
-
nodes[hiddenSite.id] = hiddenSite
|
|
645
|
-
expect(await exportedIds()).toEqual([explicitId, unownedId])
|
|
646
689
|
} finally {
|
|
647
690
|
restoreRegistry()
|
|
648
691
|
}
|
package/src/lib/glb-export.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
findLevelAncestorId,
|
|
8
8
|
type GeometryContext,
|
|
9
9
|
getLevelDisplayName,
|
|
10
|
+
hidesDescendants,
|
|
10
11
|
isNodeKindEnabled,
|
|
11
12
|
isOperationDoorType,
|
|
12
13
|
itemClipRegistry,
|
|
@@ -715,16 +716,6 @@ function pruneHiddenSceneNodes(
|
|
|
715
716
|
registryEntries: readonly RegistryEntry[],
|
|
716
717
|
) {
|
|
717
718
|
const visibility = new Map<string, boolean>()
|
|
718
|
-
const declaredSiteParents = new Map<string, string>()
|
|
719
|
-
for (const node of Object.values(nodes)) {
|
|
720
|
-
if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue
|
|
721
|
-
for (const childId of node.children) {
|
|
722
|
-
const child = nodes[childId]
|
|
723
|
-
if (child && !child.parentId && !declaredSiteParents.has(childId)) {
|
|
724
|
-
declaredSiteParents.set(childId, node.id)
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
719
|
|
|
729
720
|
const isVisible = (id: string, path: Set<string>): boolean => {
|
|
730
721
|
const cached = visibility.get(id)
|
|
@@ -736,8 +727,9 @@ function pruneHiddenSceneNodes(
|
|
|
736
727
|
visibility.set(id, false)
|
|
737
728
|
return false
|
|
738
729
|
}
|
|
739
|
-
const parentId = node.parentId
|
|
740
|
-
|
|
730
|
+
const parentId = node.parentId
|
|
731
|
+
const parent = parentId ? nodes[parentId] : undefined
|
|
732
|
+
if (!parentId || path.has(id) || (parent && !hidesDescendants(parent))) {
|
|
741
733
|
visibility.set(id, true)
|
|
742
734
|
return true
|
|
743
735
|
}
|
|
@@ -749,12 +741,33 @@ function pruneHiddenSceneNodes(
|
|
|
749
741
|
return visible
|
|
750
742
|
}
|
|
751
743
|
|
|
744
|
+
const nodeClones = new Set<THREE.Object3D>()
|
|
745
|
+
for (const [, original] of registryEntries) {
|
|
746
|
+
const clone = cloneByOriginal.get(original)
|
|
747
|
+
if (clone) nodeClones.add(clone)
|
|
748
|
+
}
|
|
749
|
+
|
|
752
750
|
for (const [id, original] of registryEntries) {
|
|
753
751
|
if (isVisible(id, new Set())) continue
|
|
754
|
-
cloneByOriginal.get(original)
|
|
752
|
+
const clone = cloneByOriginal.get(original)
|
|
753
|
+
if (!clone) continue
|
|
754
|
+
const node = nodes[id]
|
|
755
|
+
if (!node || hidesDescendants(node)) {
|
|
756
|
+
clone.removeFromParent()
|
|
757
|
+
continue
|
|
758
|
+
}
|
|
759
|
+
// Drop the hidden Site's own ground fill and boundary; keep what it hosts.
|
|
760
|
+
for (const child of [...clone.children]) {
|
|
761
|
+
if (!hostsSceneNode(child, nodeClones)) child.removeFromParent()
|
|
762
|
+
}
|
|
755
763
|
}
|
|
756
764
|
}
|
|
757
765
|
|
|
766
|
+
function hostsSceneNode(object: THREE.Object3D, nodeClones: Set<THREE.Object3D>): boolean {
|
|
767
|
+
if (nodeClones.has(object)) return true
|
|
768
|
+
return object.children.some((child) => hostsSceneNode(child, nodeClones))
|
|
769
|
+
}
|
|
770
|
+
|
|
758
771
|
function retainedClones(
|
|
759
772
|
root: THREE.Object3D,
|
|
760
773
|
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { Group, Vector3 } from 'three'
|
|
3
|
+
import { gridLocalNormal, gridLocalPoint } from './grid-frame'
|
|
4
|
+
|
|
5
|
+
test('grid floor and cursor stay in model coordinates after God-view pan, scale and yaw', () => {
|
|
6
|
+
const root = new Group()
|
|
7
|
+
root.position.set(2, 4, -3)
|
|
8
|
+
root.rotation.y = Math.PI / 3
|
|
9
|
+
root.scale.setScalar(0.2)
|
|
10
|
+
root.updateMatrixWorld(true)
|
|
11
|
+
const local = new Vector3(3, 2.5, 7)
|
|
12
|
+
const world = root.localToWorld(local.clone())
|
|
13
|
+
expect(gridLocalPoint(root, world, new Vector3()).distanceTo(local)).toBeLessThan(1e-8)
|
|
14
|
+
const normal = new Vector3(1, 0, 0).transformDirection(root.matrixWorld)
|
|
15
|
+
expect(
|
|
16
|
+
gridLocalNormal(root, normal, new Vector3()).distanceTo(new Vector3(1, 0, 0)),
|
|
17
|
+
).toBeLessThan(1e-8)
|
|
18
|
+
expect(world.distanceTo(local)).toBeGreaterThan(1)
|
|
19
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Object3D, Vector3 } from 'three'
|
|
2
|
+
|
|
3
|
+
// Placement surfaces are world-space; the grid lives under the scalable XR
|
|
4
|
+
// scene root. Keep its lattice and reveal in that root's model coordinates.
|
|
5
|
+
export function gridLocalPoint(parent: Object3D | null, world: Vector3, target: Vector3) {
|
|
6
|
+
target.copy(world)
|
|
7
|
+
if (parent) {
|
|
8
|
+
parent.updateWorldMatrix(true, false)
|
|
9
|
+
parent.worldToLocal(target)
|
|
10
|
+
}
|
|
11
|
+
return target
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function gridLocalNormal(parent: Object3D | null, normal: Vector3, target: Vector3) {
|
|
15
|
+
target.copy(normal)
|
|
16
|
+
if (parent) target.transformDirection(parent.matrixWorld.clone().invert())
|
|
17
|
+
return target
|
|
18
|
+
}
|
|
@@ -19,7 +19,15 @@ export type ReshapeDriver = 'tool' | 'floorplan'
|
|
|
19
19
|
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
|
20
20
|
// (rather than four sibling scopes) keeps the union small while still making
|
|
21
21
|
// "curving and hole-editing at once" unrepresentable.
|
|
22
|
-
export type ReshapeKind =
|
|
22
|
+
export type ReshapeKind =
|
|
23
|
+
| 'curve'
|
|
24
|
+
| 'hole'
|
|
25
|
+
| 'endpoint'
|
|
26
|
+
| 'boundary'
|
|
27
|
+
| 'control-point'
|
|
28
|
+
| 'tangent'
|
|
29
|
+
// A kind's own reshape (wall 'split'): mounted from `def.affordanceTools[reshape]`.
|
|
30
|
+
| 'split'
|
|
23
31
|
|
|
24
32
|
export type InteractionScope =
|
|
25
33
|
| { kind: 'idle' }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
acceptsKeyboardPan,
|
|
4
|
+
clearKeyboardPanKeys,
|
|
5
|
+
hasKeyboardPanInput,
|
|
6
|
+
isKeyboardPanKey,
|
|
7
|
+
type KeyboardPanState,
|
|
8
|
+
keyboardPanDirection,
|
|
9
|
+
keyboardPanSpeed,
|
|
10
|
+
setKeyboardPanKey,
|
|
11
|
+
} from './keyboard-pan'
|
|
12
|
+
|
|
13
|
+
const idle = (): KeyboardPanState => ({
|
|
14
|
+
forward: false,
|
|
15
|
+
backward: false,
|
|
16
|
+
left: false,
|
|
17
|
+
right: false,
|
|
18
|
+
})
|
|
19
|
+
const key = (init: Partial<KeyboardEvent>) => ({ target: null, ...init }) as KeyboardEvent
|
|
20
|
+
|
|
21
|
+
// No DOM in this runner: stand in for the element classes the editable check reads.
|
|
22
|
+
const DOM_CLASSES = ['HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'HTMLSelectElement']
|
|
23
|
+
const stubbed: string[] = []
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
const scope = globalThis as Record<string, unknown>
|
|
26
|
+
for (const name of DOM_CLASSES) {
|
|
27
|
+
if (scope[name]) continue
|
|
28
|
+
scope[name] = class {}
|
|
29
|
+
stubbed.push(name)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
for (const name of stubbed) delete (globalThis as Record<string, unknown>)[name]
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('physical WASD keys drive a screen-space direction; letters on other layouts do not', () => {
|
|
37
|
+
const state = idle()
|
|
38
|
+
expect(isKeyboardPanKey('KeyZ')).toBe(false)
|
|
39
|
+
expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(true)
|
|
40
|
+
expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(false)
|
|
41
|
+
setKeyboardPanKey(state, 'KeyD', true)
|
|
42
|
+
expect(keyboardPanDirection(state)).toEqual({ horizontal: 1, vertical: 1 })
|
|
43
|
+
setKeyboardPanKey(state, 'KeyA', true)
|
|
44
|
+
expect(keyboardPanDirection(state)).toEqual({ horizontal: 0, vertical: 1 })
|
|
45
|
+
clearKeyboardPanKeys(state)
|
|
46
|
+
expect(hasKeyboardPanInput(state)).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('modifier chords stay shortcuts, and typing in a field never pans', () => {
|
|
50
|
+
expect(acceptsKeyboardPan(key({}))).toBe(true)
|
|
51
|
+
for (const modifier of ['metaKey', 'ctrlKey', 'altKey'] as const)
|
|
52
|
+
expect(acceptsKeyboardPan(key({ [modifier]: true }))).toBe(false)
|
|
53
|
+
const Input = (globalThis as unknown as { HTMLInputElement: new () => EventTarget })
|
|
54
|
+
.HTMLInputElement
|
|
55
|
+
expect(acceptsKeyboardPan(key({ target: new Input() }))).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('speed scales with the visible width within fixed bounds', () => {
|
|
59
|
+
expect(keyboardPanSpeed(0)).toBe(2)
|
|
60
|
+
expect(keyboardPanSpeed(20)).toBeCloseTo(13)
|
|
61
|
+
expect(keyboardPanSpeed(1000)).toBe(55)
|
|
62
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// WASD navigation shared by the 3D camera and the 2D floor plan, so both views
|
|
2
|
+
// move the same way. Keys match by physical position (`event.code`): the
|
|
3
|
+
// cluster stays under the left hand on any layout (Z/Q/S/D on AZERTY).
|
|
4
|
+
|
|
5
|
+
export type KeyboardPanState = {
|
|
6
|
+
forward: boolean
|
|
7
|
+
backward: boolean
|
|
8
|
+
left: boolean
|
|
9
|
+
right: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
|
|
13
|
+
const KEYBOARD_PAN_MIN_SPEED = 2
|
|
14
|
+
const KEYBOARD_PAN_MAX_SPEED = 55
|
|
15
|
+
|
|
16
|
+
export function isEditableKeyboardTarget(target: EventTarget | null) {
|
|
17
|
+
return (
|
|
18
|
+
target instanceof HTMLInputElement ||
|
|
19
|
+
target instanceof HTMLTextAreaElement ||
|
|
20
|
+
target instanceof HTMLSelectElement ||
|
|
21
|
+
(target instanceof HTMLElement && target.isContentEditable)
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setKeyboardPanKey(
|
|
26
|
+
state: KeyboardPanState,
|
|
27
|
+
code: string,
|
|
28
|
+
pressed: boolean,
|
|
29
|
+
): boolean {
|
|
30
|
+
if (code === 'KeyW') {
|
|
31
|
+
const changed = state.forward !== pressed
|
|
32
|
+
state.forward = pressed
|
|
33
|
+
return changed
|
|
34
|
+
}
|
|
35
|
+
if (code === 'KeyS') {
|
|
36
|
+
const changed = state.backward !== pressed
|
|
37
|
+
state.backward = pressed
|
|
38
|
+
return changed
|
|
39
|
+
}
|
|
40
|
+
if (code === 'KeyA') {
|
|
41
|
+
const changed = state.left !== pressed
|
|
42
|
+
state.left = pressed
|
|
43
|
+
return changed
|
|
44
|
+
}
|
|
45
|
+
if (code === 'KeyD') {
|
|
46
|
+
const changed = state.right !== pressed
|
|
47
|
+
state.right = pressed
|
|
48
|
+
return changed
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isKeyboardPanKey(code: string): boolean {
|
|
54
|
+
return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hasKeyboardPanInput(state: KeyboardPanState): boolean {
|
|
58
|
+
return state.forward || state.backward || state.left || state.right
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearKeyboardPanKeys(state: KeyboardPanState) {
|
|
62
|
+
state.forward = false
|
|
63
|
+
state.backward = false
|
|
64
|
+
state.left = false
|
|
65
|
+
state.right = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Pan keys are ignored with a modifier held (shortcuts) or while typing. */
|
|
69
|
+
export function acceptsKeyboardPan(event: KeyboardEvent) {
|
|
70
|
+
return (
|
|
71
|
+
!(event.metaKey || event.ctrlKey || event.altKey) && !isEditableKeyboardTarget(event.target)
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Screen-space direction: `horizontal` +1 is right, `vertical` +1 is forward (up). */
|
|
76
|
+
export function keyboardPanDirection(state: KeyboardPanState) {
|
|
77
|
+
return {
|
|
78
|
+
horizontal: (state.right ? 1 : 0) - (state.left ? 1 : 0),
|
|
79
|
+
vertical: (state.forward ? 1 : 0) - (state.backward ? 1 : 0),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** World units per second for a view `viewWidth` wide. */
|
|
84
|
+
export function keyboardPanSpeed(viewWidth: number) {
|
|
85
|
+
return Math.min(
|
|
86
|
+
Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
|
|
87
|
+
KEYBOARD_PAN_MAX_SPEED,
|
|
88
|
+
)
|
|
89
|
+
}
|