@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
|
@@ -15,6 +15,19 @@ import { type ComponentType, lazy } from 'react'
|
|
|
15
15
|
*/
|
|
16
16
|
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
|
|
17
17
|
|
|
18
|
+
export async function preloadRegistryAffordanceTools(kind: string): Promise<void> {
|
|
19
|
+
const tools = nodeRegistry.get(kind)?.affordanceTools
|
|
20
|
+
await Promise.allSettled(
|
|
21
|
+
Object.values(tools ?? {}).map(async (loader) => {
|
|
22
|
+
if (!loader || lazyToolCache.has(loader)) return
|
|
23
|
+
const module = (await loader()) as { default: ComponentType<any> }
|
|
24
|
+
// Keep an already-mounted lazy component stable. Otherwise use the resolved
|
|
25
|
+
// component directly so grabbing a preloaded handle never suspends its tool.
|
|
26
|
+
if (!lazyToolCache.has(loader)) lazyToolCache.set(loader, module.default)
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
export function getRegistryAffordanceTool(
|
|
19
32
|
kind: string,
|
|
20
33
|
affordance: string,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Html } from '@react-three/drei'
|
|
4
|
+
|
|
5
|
+
/** Lift of a draft's readout above the top of what it measures, in metres. */
|
|
6
|
+
export const DRAFT_LABEL_Y_OFFSET = 0.22
|
|
7
|
+
|
|
8
|
+
/** Screen-space readout for a draft in 3D (lengths, angles, split segments). */
|
|
9
|
+
export function DraftMeasurementLabel({
|
|
10
|
+
color,
|
|
11
|
+
label,
|
|
12
|
+
position,
|
|
13
|
+
shadowColor,
|
|
14
|
+
}: {
|
|
15
|
+
color: string
|
|
16
|
+
label: string
|
|
17
|
+
position: [number, number, number]
|
|
18
|
+
shadowColor: string
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<Html
|
|
22
|
+
center
|
|
23
|
+
position={position}
|
|
24
|
+
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
25
|
+
zIndexRange={[100, 0]}
|
|
26
|
+
>
|
|
27
|
+
<div
|
|
28
|
+
className="whitespace-nowrap font-bold font-mono text-[15px]"
|
|
29
|
+
style={{
|
|
30
|
+
color,
|
|
31
|
+
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{label}
|
|
35
|
+
</div>
|
|
36
|
+
</Html>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
MeshBasicMaterial,
|
|
20
20
|
OrthographicCamera,
|
|
21
21
|
PerspectiveCamera,
|
|
22
|
+
Ray,
|
|
22
23
|
Vector3,
|
|
23
24
|
} from 'three'
|
|
24
25
|
import { z } from 'zod'
|
|
@@ -161,6 +162,32 @@ describe('resolvePointerSupportSurface node tops', () => {
|
|
|
161
162
|
expect(support?.worldPoint).toEqual([0, 2, 0])
|
|
162
163
|
})
|
|
163
164
|
|
|
165
|
+
test('XR floor drawing uses the controller ray even when the camera sees a raised surface', () => {
|
|
166
|
+
addPluginPlatform(2, [4, 2, 2])
|
|
167
|
+
const camera = new PerspectiveCamera()
|
|
168
|
+
camera.position.set(0, 5, 5)
|
|
169
|
+
camera.updateMatrixWorld(true)
|
|
170
|
+
const controllerRay = new Ray(new Vector3(0, 1.5, 0), new Vector3(0, -1, 0))
|
|
171
|
+
const desktop = resolvePointerSupportSurface(camera, [0, 0, 0], {
|
|
172
|
+
includeNodeTopSurfaces: true,
|
|
173
|
+
})
|
|
174
|
+
expect(desktop?.sourceNodeId).toBe(PLATFORM_ID)
|
|
175
|
+
const xr = resolvePointerSupportSurface(camera, [0, 0, 0], {
|
|
176
|
+
includeNodeTopSurfaces: true,
|
|
177
|
+
pointerRay: controllerRay,
|
|
178
|
+
})
|
|
179
|
+
expect(xr?.sourceNodeId).toBeNull()
|
|
180
|
+
expect(xr?.worldPoint).toEqual([0, 0, 0])
|
|
181
|
+
camera.position.set(12, 8, -10)
|
|
182
|
+
camera.updateMatrixWorld(true)
|
|
183
|
+
expect(
|
|
184
|
+
resolvePointerSupportSurface(camera, [0, 0, 0], {
|
|
185
|
+
includeNodeTopSurfaces: true,
|
|
186
|
+
pointerRay: controllerRay,
|
|
187
|
+
})?.worldPoint,
|
|
188
|
+
).toEqual([0, 0, 0])
|
|
189
|
+
})
|
|
190
|
+
|
|
164
191
|
test('keeps an unhovered item top above a batched slab across batch transitions', () => {
|
|
165
192
|
const restoreRegistry = nodeRegistry._snapshot()
|
|
166
193
|
const item = ItemNode.parse({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AnyNode,
|
|
2
3
|
type AnyNodeId,
|
|
3
4
|
canHostOnTop,
|
|
4
5
|
GROUND_SUPPORT_ID,
|
|
@@ -10,10 +11,11 @@ import {
|
|
|
10
11
|
useScene,
|
|
11
12
|
} from '@pascal-app/core'
|
|
12
13
|
import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
|
|
13
|
-
import { type Camera,
|
|
14
|
+
import { type Camera, type Object3D, type Ray, Raycaster, Vector3 } from 'three'
|
|
14
15
|
import { resolveTerrainGroundHit } from '../../../lib/ground-surface'
|
|
15
16
|
import { scopeNodeId } from '../../../lib/interaction/scope'
|
|
16
17
|
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
18
|
+
import { surfaceWorldNormalY } from './surface-hit'
|
|
17
19
|
|
|
18
20
|
const originScratch = new Vector3()
|
|
19
21
|
const hitScratch = new Vector3()
|
|
@@ -23,8 +25,6 @@ const worldRayOrigin = new Vector3()
|
|
|
23
25
|
const worldRayDirection = new Vector3()
|
|
24
26
|
const nodeTopRaycaster = new Raycaster()
|
|
25
27
|
setSurfaceRaycastLayers(nodeTopRaycaster.layers)
|
|
26
|
-
const nodeTopNormal = new Vector3()
|
|
27
|
-
const nodeTopNormalMatrix = new Matrix3()
|
|
28
28
|
|
|
29
29
|
export type PointerSupportSurface = {
|
|
30
30
|
/** Level-local elevation of the pointed surface — the election cap. */
|
|
@@ -79,16 +79,22 @@ export type PointerSupportSurface = {
|
|
|
79
79
|
export function resolvePointerSupportSurface(
|
|
80
80
|
camera: Camera,
|
|
81
81
|
worldHit: readonly [number, number, number],
|
|
82
|
-
options?: { includeNodeTopSurfaces?: boolean },
|
|
82
|
+
options?: { includeNodeTopSurfaces?: boolean; pointerRay?: Ray },
|
|
83
83
|
): PointerSupportSurface | null {
|
|
84
84
|
const levelId = useViewer.getState().selection.levelId
|
|
85
85
|
if (!levelId) return null
|
|
86
86
|
|
|
87
87
|
// The world ray, kept before the level conversion below: the terrain field is
|
|
88
88
|
// world-space (site geometry, not level-local), so the march needs this frame.
|
|
89
|
-
|
|
89
|
+
if (options?.pointerRay) worldRayOrigin.copy(options.pointerRay.origin)
|
|
90
|
+
else camera.getWorldPosition(worldRayOrigin)
|
|
91
|
+
const isOrthographic =
|
|
92
|
+
!options?.pointerRay &&
|
|
93
|
+
(camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera === true
|
|
90
94
|
const cameraToHit = hitScratch.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin)
|
|
91
|
-
if (
|
|
95
|
+
if (options?.pointerRay) {
|
|
96
|
+
worldRayDirection.copy(options.pointerRay.direction).normalize()
|
|
97
|
+
} else if (isOrthographic) {
|
|
92
98
|
// For an orthographic camera every screen pixel has the same direction. The
|
|
93
99
|
// hit point is offset from the camera along the view plane, so using
|
|
94
100
|
// `camera.position -> hit` tilts the ray toward the screen centre and makes
|
|
@@ -102,7 +108,13 @@ export function resolvePointerSupportSurface(
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
originScratch.copy(worldRayOrigin)
|
|
105
|
-
|
|
111
|
+
// Second world point defining the ray fed to the surface solve. In the plain
|
|
112
|
+
// perspective case `worldHit` already lies on the true pointer ray, so using
|
|
113
|
+
// it keeps `t === 1` (and the returned plan point exact) whenever the pointed
|
|
114
|
+
// surface IS the event plane — the pointer-ray and orthographic branches have
|
|
115
|
+
// no such hit, so they step one unit along the resolved direction.
|
|
116
|
+
if (options?.pointerRay || isOrthographic) hitScratch.copy(worldRayOrigin).add(worldRayDirection)
|
|
117
|
+
else hitScratch.set(worldHit[0], worldHit[1], worldHit[2])
|
|
106
118
|
// Slab polygons/elevations live in the level frame; the level mesh
|
|
107
119
|
// carries the storey Y offset and any building rotation.
|
|
108
120
|
const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId)
|
|
@@ -194,7 +206,7 @@ export function resolvePointerSupportSurface(
|
|
|
194
206
|
// convention — the election owns the invariant.
|
|
195
207
|
const interactingNodeId = scopeNodeId(useInteractionScope.getState().scope)
|
|
196
208
|
const isEligibleCandidate = (nodeId: AnyNodeId) => {
|
|
197
|
-
let current = nodes[nodeId]
|
|
209
|
+
let current: AnyNode | undefined = nodes[nodeId]
|
|
198
210
|
const visited = new Set<AnyNodeId>()
|
|
199
211
|
while (current && !visited.has(current.id)) {
|
|
200
212
|
if (current.id === interactingNodeId) return false
|
|
@@ -247,11 +259,13 @@ export function resolvePointerSupportSurface(
|
|
|
247
259
|
ownerObject = ownerObject.parent
|
|
248
260
|
}
|
|
249
261
|
if (belongsToNestedNode) continue
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
262
|
+
if (
|
|
263
|
+
surfaceWorldNormalY(
|
|
264
|
+
intersection.face.normal.toArray(),
|
|
265
|
+
intersection.object.matrixWorld,
|
|
266
|
+
) < 0.75
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
255
269
|
nearest = {
|
|
256
270
|
distance: intersection.distance,
|
|
257
271
|
nodeId,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
createSceneApi,
|
|
4
|
+
getSurfaceProvider,
|
|
5
|
+
sceneRegistry,
|
|
6
|
+
surfaceRegionContainsPoint,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { Box3, type Camera, Matrix4, Ray, Vector3 } from 'three'
|
|
10
|
+
|
|
11
|
+
export function createShelfStickiness() {
|
|
12
|
+
const ray = new Ray()
|
|
13
|
+
const box = new Box3()
|
|
14
|
+
const matrix = new Matrix4()
|
|
15
|
+
const cameraPosition = new Vector3()
|
|
16
|
+
|
|
17
|
+
// A ray through a board gap can hit the floor behind the shelf while still targeting its volume.
|
|
18
|
+
return (
|
|
19
|
+
shelfId: string | null | undefined,
|
|
20
|
+
camera: Camera,
|
|
21
|
+
worldPoint: readonly [number, number, number],
|
|
22
|
+
) => {
|
|
23
|
+
if (!shelfId) return false
|
|
24
|
+
const mesh = sceneRegistry.nodes.get(shelfId)
|
|
25
|
+
const shelf = useScene.getState().nodes[shelfId as AnyNodeId]
|
|
26
|
+
if (!mesh || !shelf || (shelf.type !== 'shelf' && shelf.type !== 'cabinet')) return false
|
|
27
|
+
camera.getWorldPosition(cameraPosition)
|
|
28
|
+
ray.origin.copy(cameraPosition)
|
|
29
|
+
ray.direction
|
|
30
|
+
.set(...worldPoint)
|
|
31
|
+
.sub(cameraPosition)
|
|
32
|
+
.normalize()
|
|
33
|
+
ray.applyMatrix4(matrix.copy(mesh.matrixWorld).invert())
|
|
34
|
+
if (shelf.type === 'cabinet') {
|
|
35
|
+
const surfaces =
|
|
36
|
+
getSurfaceProvider(shelf).surfaces?.(shelf, { scene: createSceneApi(useScene) }) ?? []
|
|
37
|
+
return surfaces.some((surface) => {
|
|
38
|
+
if (Math.abs(ray.direction.y) < 1e-8) return false
|
|
39
|
+
const distance = (surface.position[1] - ray.origin.y) / ray.direction.y
|
|
40
|
+
if (distance < 0) return false
|
|
41
|
+
const point = ray.at(distance, new Vector3())
|
|
42
|
+
return surfaceRegionContainsPoint(surface.region, [
|
|
43
|
+
point.x - surface.position[0],
|
|
44
|
+
point.z - surface.position[2],
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
if (!shelf.width || !shelf.depth || !shelf.height) return false
|
|
49
|
+
const margin = 0.08
|
|
50
|
+
box.min.set(-shelf.width / 2 - margin, -margin, -shelf.depth / 2 - margin)
|
|
51
|
+
box.max.set(shelf.width / 2 + margin, shelf.height + margin, shelf.depth / 2 + margin)
|
|
52
|
+
return ray.intersectsBox(box)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { type ItemEvent, ItemNode, sceneRegistry } from '@pascal-app/core'
|
|
3
|
+
import { Group, Matrix3, Vector3 } from 'three'
|
|
4
|
+
import { itemEventToSurfaceHit } from './surface-hit'
|
|
5
|
+
|
|
6
|
+
const host = ItemNode.parse({
|
|
7
|
+
id: 'item_hit-adapter',
|
|
8
|
+
asset: { id: 'host', name: 'Host', category: 'furniture', thumbnail: '', src: '/host.glb' },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => sceneRegistry.nodes.delete(host.id))
|
|
12
|
+
|
|
13
|
+
test('matches the legacy host-local point and hit-mesh normal exactly under rotation and scale', () => {
|
|
14
|
+
const mesh = new Group()
|
|
15
|
+
mesh.position.set(4.3, 2.1, -3.7)
|
|
16
|
+
mesh.rotation.set(0.2, 0.7, -0.1)
|
|
17
|
+
mesh.scale.set(2, 0.7, 1.3)
|
|
18
|
+
const object = new Group()
|
|
19
|
+
object.name = 'surface-mesh'
|
|
20
|
+
object.position.set(0.4, 0.8, -0.2)
|
|
21
|
+
object.rotation.set(-0.1, 0.3, 0.2)
|
|
22
|
+
object.scale.set(0.8, 1.4, 0.6)
|
|
23
|
+
mesh.add(object)
|
|
24
|
+
mesh.updateMatrixWorld(true)
|
|
25
|
+
sceneRegistry.nodes.set(host.id, mesh)
|
|
26
|
+
const event = {
|
|
27
|
+
node: host,
|
|
28
|
+
object,
|
|
29
|
+
position: object.localToWorld(new Vector3(0.37, 0.81, -0.39)).toArray(),
|
|
30
|
+
localPosition: [0.37, 0.81, -0.39],
|
|
31
|
+
normal: [0.2, 1, -0.3],
|
|
32
|
+
nativeEvent: {} as ItemEvent['nativeEvent'],
|
|
33
|
+
stopPropagation() {},
|
|
34
|
+
} as ItemEvent
|
|
35
|
+
const before = structuredClone({ position: event.position, normal: event.normal })
|
|
36
|
+
const expectedPoint = mesh.worldToLocal(new Vector3(...event.position)).toArray()
|
|
37
|
+
const expectedNormal = new Vector3(...event.normal!)
|
|
38
|
+
.applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld))
|
|
39
|
+
.normalize().y
|
|
40
|
+
const hit = itemEventToSurfaceHit(host, event)!
|
|
41
|
+
expect(hit.point).toEqual(expectedPoint)
|
|
42
|
+
expect(hit.point).not.toEqual(event.localPosition)
|
|
43
|
+
expect(hit.normalWorldY).toBe(expectedNormal)
|
|
44
|
+
expect(hit.meshName).toBe(object.name)
|
|
45
|
+
expect({ position: event.position, normal: event.normal }).toEqual(before)
|
|
46
|
+
|
|
47
|
+
const roundTrip = mesh.localToWorld(new Vector3(...expectedPoint)).toArray()
|
|
48
|
+
expect(itemEventToSurfaceHit(host, { ...event, position: roundTrip })!.point).toEqual(
|
|
49
|
+
mesh.worldToLocal(new Vector3(...roundTrip)).toArray(),
|
|
50
|
+
)
|
|
51
|
+
expect(itemEventToSurfaceHit(host, { ...event, normal: undefined })!.normalWorldY).toBeNaN()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a missing registered host mesh yields no hit', () => {
|
|
55
|
+
expect(itemEventToSurfaceHit(host, {} as ItemEvent)).toBeNull()
|
|
56
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type AnyNode, type NodeEvent, type SurfaceHit, sceneRegistry } from '@pascal-app/core'
|
|
2
|
+
import { Matrix3, type Matrix4, Vector3 } from 'three'
|
|
3
|
+
|
|
4
|
+
export function surfaceWorldNormalY(
|
|
5
|
+
normal: NodeEvent<AnyNode>['normal'],
|
|
6
|
+
matrixWorld: Matrix4,
|
|
7
|
+
): number {
|
|
8
|
+
return normal
|
|
9
|
+
? new Vector3(...normal)
|
|
10
|
+
.applyNormalMatrix(new Matrix3().getNormalMatrix(matrixWorld))
|
|
11
|
+
.normalize().y
|
|
12
|
+
: Number.NaN
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function itemEventToSurfaceHit(host: AnyNode, event: NodeEvent<AnyNode>): SurfaceHit | null {
|
|
16
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
17
|
+
if (!mesh) return null
|
|
18
|
+
return {
|
|
19
|
+
point: mesh.worldToLocal(new Vector3(...event.position)).toArray(),
|
|
20
|
+
normalWorldY: surfaceWorldNormalY(event.normal, event.object.matrixWorld),
|
|
21
|
+
meshName: event.object.name,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
ItemNode,
|
|
5
|
+
nodeRegistry,
|
|
6
|
+
resolveSurfacePlacement,
|
|
7
|
+
type SceneApi,
|
|
8
|
+
type SurfaceProvider,
|
|
9
|
+
type SurfaceRejectReason,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { registerHostingTestNode } from '../__fixtures__/hosting'
|
|
12
|
+
import { createSurfaceRejectionFeedback } from './surface-rejection'
|
|
13
|
+
|
|
14
|
+
let restore: () => void
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
restore = nodeRegistry._snapshot()
|
|
17
|
+
nodeRegistry._reset()
|
|
18
|
+
})
|
|
19
|
+
afterEach(() => restore())
|
|
20
|
+
|
|
21
|
+
for (const reason of [
|
|
22
|
+
'footprint-outside-surface',
|
|
23
|
+
'footprint-exceeds-host',
|
|
24
|
+
'surface-cutout',
|
|
25
|
+
'child-not-accepted',
|
|
26
|
+
'host-not-eligible',
|
|
27
|
+
'no-surface',
|
|
28
|
+
'invalid-hit',
|
|
29
|
+
] as const) {
|
|
30
|
+
test(`${reason}: eligible-host diagnostics remain available; ineligible hosts defer silently`, () => {
|
|
31
|
+
const provider: SurfaceProvider = {
|
|
32
|
+
childFrame: 'host-local',
|
|
33
|
+
resolveHit: () =>
|
|
34
|
+
reason === 'no-surface'
|
|
35
|
+
? null
|
|
36
|
+
: {
|
|
37
|
+
id: 'top',
|
|
38
|
+
position: [0, 1, 0],
|
|
39
|
+
normal: [0, 1, 0],
|
|
40
|
+
region: {
|
|
41
|
+
kind: 'rect',
|
|
42
|
+
size: [1, 1],
|
|
43
|
+
...(reason === 'surface-cutout'
|
|
44
|
+
? {
|
|
45
|
+
holes: [
|
|
46
|
+
[
|
|
47
|
+
[-0.2, -0.2],
|
|
48
|
+
[0.2, -0.2],
|
|
49
|
+
[0.2, 0.2],
|
|
50
|
+
[-0.2, 0.2],
|
|
51
|
+
],
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
accepts: () => reason !== 'child-not-accepted',
|
|
58
|
+
}
|
|
59
|
+
registerHostingTestNode({
|
|
60
|
+
kind: 'test-host',
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
schema: ItemNode,
|
|
63
|
+
category: 'furnish',
|
|
64
|
+
defaults: () => ({}),
|
|
65
|
+
capabilities:
|
|
66
|
+
reason === 'footprint-exceeds-host'
|
|
67
|
+
? { dragBounds: () => ({ size: [0.1, 1, 0.1] }) }
|
|
68
|
+
: { surfaces: { hosting: provider } },
|
|
69
|
+
})
|
|
70
|
+
const feedback = createSurfaceRejectionFeedback()
|
|
71
|
+
expect(
|
|
72
|
+
resolveSurfacePlacement({
|
|
73
|
+
host: {
|
|
74
|
+
id: 'test_host',
|
|
75
|
+
type: reason === 'host-not-eligible' ? 'guide' : 'test-host',
|
|
76
|
+
} as unknown as AnyNode,
|
|
77
|
+
childKind: 'item',
|
|
78
|
+
childId: 'item_existing',
|
|
79
|
+
childFootprint: {
|
|
80
|
+
size: reason === 'footprint-outside-surface' ? [3, 1, 3] : [0.3, 0.3, 0.3],
|
|
81
|
+
rotationY: 0,
|
|
82
|
+
},
|
|
83
|
+
hit: {
|
|
84
|
+
point: [reason.startsWith('footprint-') ? 2 : 0, reason === 'invalid-hit' ? NaN : 1, 0],
|
|
85
|
+
normalWorldY: 1,
|
|
86
|
+
},
|
|
87
|
+
scene: { get: () => undefined, nodes: () => ({}) } as unknown as SceneApi,
|
|
88
|
+
onReject: (r) => feedback.reject(r),
|
|
89
|
+
}),
|
|
90
|
+
).toBeNull()
|
|
91
|
+
expect(feedback.reason).toBe(reason === 'host-not-eligible' ? null : reason)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('the paired grid cannot erase a refusal; a new floor move and cleanup clear it', () => {
|
|
96
|
+
const changes: (SurfaceRejectReason | null)[] = []
|
|
97
|
+
const feedback = createSurfaceRejectionFeedback((r) => changes.push(r))
|
|
98
|
+
const event = {}
|
|
99
|
+
feedback.reject('footprint-exceeds-host', event)
|
|
100
|
+
feedback.grid(event)
|
|
101
|
+
expect(feedback.reason).toBe('footprint-exceeds-host')
|
|
102
|
+
feedback.grid({})
|
|
103
|
+
expect(feedback.reason).toBeNull()
|
|
104
|
+
feedback.reject('surface-cutout', event)
|
|
105
|
+
feedback.clear()
|
|
106
|
+
expect(changes).toEqual(['footprint-exceeds-host', null, 'surface-cutout', null])
|
|
107
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SurfaceRejectReason } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function createSurfaceRejectionFeedback(
|
|
4
|
+
onChange?: (reason: SurfaceRejectReason | null) => void,
|
|
5
|
+
) {
|
|
6
|
+
let reason: SurfaceRejectReason | null = null
|
|
7
|
+
let rejectedEvent: object | undefined
|
|
8
|
+
const set = (next: SurfaceRejectReason | null) => {
|
|
9
|
+
reason = next
|
|
10
|
+
onChange?.(next)
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
get reason() {
|
|
14
|
+
return reason
|
|
15
|
+
},
|
|
16
|
+
reject(next: SurfaceRejectReason, event?: object) {
|
|
17
|
+
if (event) rejectedEvent = event
|
|
18
|
+
set(next)
|
|
19
|
+
},
|
|
20
|
+
clear() {
|
|
21
|
+
rejectedEvent = undefined
|
|
22
|
+
set(null)
|
|
23
|
+
},
|
|
24
|
+
grid(event: object) {
|
|
25
|
+
// A host refusal and the floor ray beneath it belong to the same pointer move.
|
|
26
|
+
if (event !== rejectedEvent) this.clear()
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// R3F reuses one native event across enter/move dispatch and bubbled ancestor hits.
|
|
32
|
+
export function createSurfaceEventOwnership() {
|
|
33
|
+
const owners = new WeakMap<object, string>()
|
|
34
|
+
return {
|
|
35
|
+
allows(hostId: string, event: object) {
|
|
36
|
+
const owner = owners.get(event)
|
|
37
|
+
return owner === undefined || owner === hostId
|
|
38
|
+
},
|
|
39
|
+
claim(hostId: string, event: object) {
|
|
40
|
+
if (!owners.has(event)) owners.set(event, hostId)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { useViewer } from '@pascal-app/viewer'
|
|
14
14
|
import { type ComponentType, lazy, Suspense, useMemo } from 'react'
|
|
15
15
|
import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled'
|
|
16
|
+
import type { ReshapeKind } from '../../lib/interaction/scope'
|
|
16
17
|
import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
|
|
17
18
|
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
|
18
19
|
import useInteractionScope, {
|
|
@@ -33,7 +34,10 @@ import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
|
|
|
33
34
|
import { ElevatorTool } from './elevator/elevator-tool'
|
|
34
35
|
import { MoveTool } from './item/move-tool'
|
|
35
36
|
import { RegistryToolProvider } from './registry-tool-context'
|
|
36
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
getRegistryAffordanceTool,
|
|
39
|
+
preloadRegistryAffordanceTools,
|
|
40
|
+
} from './shared/affordance-dispatch'
|
|
37
41
|
import { FacingPoseIndicator } from './shared/facing-pose-indicator'
|
|
38
42
|
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
|
39
43
|
import { TerrainSculptTool } from './site/terrain-sculpt-tool'
|
|
@@ -44,6 +48,15 @@ import { ZoneTool } from './zone/zone-tool'
|
|
|
44
48
|
// Cache lazy tool components keyed by their loader so React.lazy isn't
|
|
45
49
|
// re-invoked across renders.
|
|
46
50
|
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
|
|
51
|
+
// Reshapes with their own arm below; anything else resolves by name.
|
|
52
|
+
const DEDICATED_RESHAPES = new Set<ReshapeKind>([
|
|
53
|
+
'curve',
|
|
54
|
+
'hole',
|
|
55
|
+
'endpoint',
|
|
56
|
+
'boundary',
|
|
57
|
+
'control-point',
|
|
58
|
+
'tangent',
|
|
59
|
+
])
|
|
47
60
|
const registryToolPreloadCache = new WeakMap<AnyNodeDefinition, Promise<void>>()
|
|
48
61
|
|
|
49
62
|
export function preloadRegistryToolModules(tool: string | null): Promise<void> {
|
|
@@ -60,8 +73,7 @@ export function preloadRegistryToolModules(tool: string | null): Promise<void> {
|
|
|
60
73
|
if (def.system) loaders.push(def.system.module)
|
|
61
74
|
if (def.parametrics?.customPanel) loaders.push(def.parametrics.customPanel)
|
|
62
75
|
if (def.parametrics?.trailingSection) loaders.push(def.parametrics.trailingSection)
|
|
63
|
-
|
|
64
|
-
if (moveTool) loaders.push(moveTool)
|
|
76
|
+
loaders.push(() => preloadRegistryAffordanceTools(tool))
|
|
65
77
|
|
|
66
78
|
const preload = Promise.allSettled(loaders.map((loader) => loader())).then(() => undefined)
|
|
67
79
|
registryToolPreloadCache.set(def, preload)
|
|
@@ -114,6 +126,11 @@ export const ToolManager: React.FC = () => {
|
|
|
114
126
|
const tangentReshape = useTangentReshape()
|
|
115
127
|
const isCurveReshape = useIsCurveReshape()
|
|
116
128
|
const isToolDrivenReshape = useIsToolDrivenReshape()
|
|
129
|
+
const registryReshape = useInteractionScope((state) =>
|
|
130
|
+
state.scope.kind === 'reshaping' && !DEDICATED_RESHAPES.has(state.scope.reshape)
|
|
131
|
+
? state.scope.reshape
|
|
132
|
+
: null,
|
|
133
|
+
)
|
|
117
134
|
const isFloorplanDrivenReshape = useIsFloorplanDrivenReshape()
|
|
118
135
|
const reshapingNode = useReshapingNode()
|
|
119
136
|
// The endpoint affordance tool's `target` is kind-specific
|
|
@@ -384,6 +401,22 @@ export const ToolManager: React.FC = () => {
|
|
|
384
401
|
</Suspense>
|
|
385
402
|
) : null
|
|
386
403
|
})()}
|
|
404
|
+
{/* Reshape kinds without an arm above mount the kind's own affordance tool
|
|
405
|
+
under the reshape's name (`def.affordanceTools[reshape]`, e.g. wall 'split'). */}
|
|
406
|
+
{isToolDrivenReshape &&
|
|
407
|
+
reshapingNode &&
|
|
408
|
+
registryReshape &&
|
|
409
|
+
(() => {
|
|
410
|
+
const RegistryAffordance = getRegistryAffordanceTool(
|
|
411
|
+
reshapingNode.type,
|
|
412
|
+
registryReshape,
|
|
413
|
+
)
|
|
414
|
+
return RegistryAffordance ? (
|
|
415
|
+
<Suspense fallback={null}>
|
|
416
|
+
<RegistryAffordance node={reshapingNode} />
|
|
417
|
+
</Suspense>
|
|
418
|
+
) : null
|
|
419
|
+
})()}
|
|
387
420
|
{showMover && movingNode.type !== 'building' && (
|
|
388
421
|
<MoveTool
|
|
389
422
|
onNodeMoved={handlePlacedNodeSelected}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
emitter,
|
|
4
4
|
type GridEvent,
|
|
5
5
|
type LevelNode,
|
|
6
|
+
runAsSingleSceneHistoryStep,
|
|
6
7
|
snapPointAlongAngleRay,
|
|
7
8
|
useScene,
|
|
8
9
|
ZoneNode,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
clearSurfacePlanSnapFeedback,
|
|
17
18
|
resolveSurfacePlanPointSnap,
|
|
18
19
|
} from './../../../lib/surface-plan-snap'
|
|
20
|
+
import { focusedUnitNode } from './../../../lib/units'
|
|
19
21
|
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
|
|
20
22
|
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
|
|
21
23
|
import { useFloorplanDraftPreview } from './../../../store/use-floorplan-draft-preview'
|
|
@@ -27,7 +29,7 @@ const Y_OFFSET = 0.02
|
|
|
27
29
|
* Creates a zone with the given polygon points
|
|
28
30
|
*/
|
|
29
31
|
const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
|
|
30
|
-
const { createNode, nodes } = useScene.getState()
|
|
32
|
+
const { createNode, updateNode, nodes } = useScene.getState()
|
|
31
33
|
|
|
32
34
|
// Count existing zones for naming and color cycling
|
|
33
35
|
const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
|
|
@@ -42,10 +44,17 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
|
|
|
42
44
|
color,
|
|
43
45
|
})
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
const focusedUnit = focusedUnitNode()
|
|
46
48
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
+
// Joining the focused unit rides in the zone's own undo step.
|
|
50
|
+
runAsSingleSceneHistoryStep(useScene, () => {
|
|
51
|
+
createNode(zone, levelId)
|
|
52
|
+
if (focusedUnit) updateNode(focusedUnit.id, { members: [...focusedUnit.members, zone.id] })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Selecting the zone would end unit focus; while painting a unit the new
|
|
56
|
+
// zone just joins it and drawing continues.
|
|
57
|
+
if (!focusedUnit) useViewer.getState().setSelection({ zoneId: zone.id })
|
|
49
58
|
|
|
50
59
|
// Play structure build sound
|
|
51
60
|
sfxEmitter.emit('sfx:structure-build')
|