@pascal-app/editor 0.7.0 → 0.9.1
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 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
useLiveNodeOverrides,
|
|
7
|
+
useLiveTransforms,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
13
|
+
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
|
14
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
15
|
+
import useEditor from '../../store/use-editor'
|
|
16
|
+
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
17
|
+
import {
|
|
18
|
+
CORNER_OFFSET,
|
|
19
|
+
classifyParticipant,
|
|
20
|
+
collectParticipants,
|
|
21
|
+
computeGroupBox,
|
|
22
|
+
expandToComponent,
|
|
23
|
+
levelFrame,
|
|
24
|
+
type Vec2,
|
|
25
|
+
type Vec3,
|
|
26
|
+
} from './group-transform-shared'
|
|
27
|
+
import {
|
|
28
|
+
ARROW_COLOR,
|
|
29
|
+
ARROW_HOVER_COLOR,
|
|
30
|
+
ARROW_SCALE,
|
|
31
|
+
createRotateArrowHandleGeometry,
|
|
32
|
+
createRotateArrowHitAreaGeometry,
|
|
33
|
+
GuideRing,
|
|
34
|
+
InvisibleHandleHitArea,
|
|
35
|
+
NO_RAYCAST,
|
|
36
|
+
RotationGuide,
|
|
37
|
+
type RotationGuideData,
|
|
38
|
+
swallowNextClick,
|
|
39
|
+
useArrowMaterial,
|
|
40
|
+
useInvisibleHitAreaMaterial,
|
|
41
|
+
} from './node-arrow-handles'
|
|
42
|
+
|
|
43
|
+
const ROTATE_SNAP = Math.PI / 12 // 15°
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
|
|
47
|
+
* selected, a single rotation handle appears at the selection's bounding-box
|
|
48
|
+
* center. Dragging it spins every selected node rigidly around that shared
|
|
49
|
+
* center — orbiting each node's position AND turning its yaw by the same delta,
|
|
50
|
+
* so the group rotates as one piece.
|
|
51
|
+
*
|
|
52
|
+
* The single-selection case is handled by `NodeArrowHandles`; a full-level
|
|
53
|
+
* box-select promotes to a building selection, so neither reaches this gizmo.
|
|
54
|
+
*/
|
|
55
|
+
export function GroupRotateHandle() {
|
|
56
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
57
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
58
|
+
const mode = useEditor((s) => s.mode)
|
|
59
|
+
const movingNode = useEditor((s) => s.movingNode)
|
|
60
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
61
|
+
// Re-derive participants whenever the scene mutates (e.g. after a commit).
|
|
62
|
+
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
|
|
63
|
+
const nodes = useScene((s) => s.nodes)
|
|
64
|
+
|
|
65
|
+
const participantIds = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
selectedIds.filter(
|
|
68
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
69
|
+
),
|
|
70
|
+
[selectedIds, levelId, nodes],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// Gate on the explicit selection (so a single connected wall still gets the
|
|
74
|
+
// per-node handles), but transform the full connected wall/fence component so
|
|
75
|
+
// attached structure rotates rigidly as one piece.
|
|
76
|
+
const fullIds = useMemo(
|
|
77
|
+
() => expandToComponent(participantIds, nodes, levelId),
|
|
78
|
+
[participantIds, levelId, nodes],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const shouldRender =
|
|
82
|
+
participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
|
|
83
|
+
|
|
84
|
+
if (!shouldRender) return null
|
|
85
|
+
// Remount when the moving set changes so the rest pivot re-seeds cleanly.
|
|
86
|
+
return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} />
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
90
|
+
const { camera, raycaster, gl, scene } = useThree()
|
|
91
|
+
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
|
|
92
|
+
const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
|
|
93
|
+
const arrowMaterial = useArrowMaterial()
|
|
94
|
+
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
95
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
96
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
97
|
+
const [guide, setGuide] = useState<RotationGuideData | null>(null)
|
|
98
|
+
const dragCleanupRef = useRef<(() => void) | null>(null)
|
|
99
|
+
const frozenRest = useRef<{ pivot: Vector3; corner: Vector3 } | null>(null)
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
|
103
|
+
}, [arrowMaterial, isHovered])
|
|
104
|
+
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
|
105
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
106
|
+
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
|
107
|
+
useEffect(() => () => dragCleanupRef.current?.(), [])
|
|
108
|
+
|
|
109
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
110
|
+
const baseScale = zoom * ARROW_SCALE * 1.05
|
|
111
|
+
const scale = (isHovered ? 1.12 : 1) * baseScale
|
|
112
|
+
|
|
113
|
+
// World-space bounding box of the selected meshes. Levels are axis-aligned in
|
|
114
|
+
// XZ, so world XZ coincides with each node's level-local placement — letting
|
|
115
|
+
// us rotate `position` / `start` / `end` directly against the pivot without
|
|
116
|
+
// per-node frame conversion.
|
|
117
|
+
// - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
|
|
118
|
+
// - `corner` = front-right bbox corner at mid-height → where the gizmo sits
|
|
119
|
+
const rest = useMemo(() => {
|
|
120
|
+
const box = computeGroupBox(ids)
|
|
121
|
+
if (!box) return null
|
|
122
|
+
const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
|
|
123
|
+
const corner = new Vector3(
|
|
124
|
+
box.max.x + CORNER_OFFSET,
|
|
125
|
+
(box.min.y + box.max.y) / 2,
|
|
126
|
+
box.max.z + CORNER_OFFSET,
|
|
127
|
+
)
|
|
128
|
+
return { pivot, corner }
|
|
129
|
+
}, [ids])
|
|
130
|
+
|
|
131
|
+
if (!rest) return null
|
|
132
|
+
const active = isDragging && frozenRest.current ? frozenRest.current : rest
|
|
133
|
+
const corner = active.corner
|
|
134
|
+
|
|
135
|
+
const activate = (event: ThreeEvent<PointerEvent>) => {
|
|
136
|
+
event.stopPropagation()
|
|
137
|
+
suppressBoxSelectForPointer(event)
|
|
138
|
+
|
|
139
|
+
frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
|
|
140
|
+
const center = rest.pivot.clone()
|
|
141
|
+
|
|
142
|
+
// Snapshot the selected participants + connected wall/fence neighbours whose
|
|
143
|
+
// shared endpoints must follow the rotation (so junctions stay welded).
|
|
144
|
+
const levelId = useViewer.getState().selection.levelId
|
|
145
|
+
const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
|
|
146
|
+
if (starts.length === 0) return
|
|
147
|
+
|
|
148
|
+
// Placements live in the level frame; the world pivot must be converted into
|
|
149
|
+
// it before orbiting positions, or a rotated building displaces the centre.
|
|
150
|
+
// The swept angle is frame-invariant (both frames differ by a constant yaw,
|
|
151
|
+
// which cancels in `angleOf(move) - angleOf(start)`), so it's still measured
|
|
152
|
+
// in world against `center` — keeping the world-space guide overlay correct.
|
|
153
|
+
const localCenter = center.clone().applyMatrix4(levelFrame(levelId).inverse)
|
|
154
|
+
|
|
155
|
+
// Horizontal drag plane at the pivot; bearing measured around the pivot.
|
|
156
|
+
const plane = new Plane(new Vector3(0, 1, 0), -center.y)
|
|
157
|
+
const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x)
|
|
158
|
+
|
|
159
|
+
// Wedge radius tracks how far the group spreads from the pivot — sample each
|
|
160
|
+
// participant's anchor point(s).
|
|
161
|
+
let spread = 0
|
|
162
|
+
const reach = (x: number, z: number) => {
|
|
163
|
+
spread = Math.max(spread, Math.hypot(x - localCenter.x, z - localCenter.z))
|
|
164
|
+
}
|
|
165
|
+
for (const s of starts) {
|
|
166
|
+
if (s.kind === 'endpoint') {
|
|
167
|
+
reach(s.start[0], s.start[1])
|
|
168
|
+
reach(s.end[0], s.end[1])
|
|
169
|
+
} else {
|
|
170
|
+
reach(s.position[0], s.position[2])
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const guideRadius = Math.min(Math.max(spread * 0.6, 0.3), 3)
|
|
174
|
+
|
|
175
|
+
const ndc = new Vector2()
|
|
176
|
+
const setNDC = (clientX: number, clientY: number) => {
|
|
177
|
+
const rect = gl.domElement.getBoundingClientRect()
|
|
178
|
+
ndc.set(
|
|
179
|
+
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
180
|
+
-((clientY - rect.top) / rect.height) * 2 + 1,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
|
|
185
|
+
raycaster.setFromCamera(ndc, camera)
|
|
186
|
+
const hit = new Vector3()
|
|
187
|
+
if (!raycaster.ray.intersectPlane(plane, hit)) return
|
|
188
|
+
const initialAngle = angleOf(hit)
|
|
189
|
+
|
|
190
|
+
document.body.style.cursor = 'grabbing'
|
|
191
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
192
|
+
useViewer.getState().setInputDragging(true)
|
|
193
|
+
useScene.temporal.getState().pause()
|
|
194
|
+
setIsDragging(true)
|
|
195
|
+
|
|
196
|
+
const onMove = (e: PointerEvent) => {
|
|
197
|
+
setNDC(e.clientX, e.clientY)
|
|
198
|
+
raycaster.setFromCamera(ndc, camera)
|
|
199
|
+
const moveHit = new Vector3()
|
|
200
|
+
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
|
|
201
|
+
let delta = angleOf(moveHit) - initialAngle
|
|
202
|
+
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
203
|
+
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
204
|
+
if (e.shiftKey) delta = Math.round(delta / ROTATE_SNAP) * ROTATE_SNAP
|
|
205
|
+
|
|
206
|
+
// Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and
|
|
207
|
+
// turn its yaw by `-delta` to match three.js Y-rotation handedness (same
|
|
208
|
+
// convention as the single-item rotate handle in item/definition.ts).
|
|
209
|
+
// Endpoint nodes (walls/fences) have no yaw — swinging both endpoints
|
|
210
|
+
// around the pivot rotates them rigidly; their curveOffset sagitta is
|
|
211
|
+
// rotation-invariant, so arcs are preserved.
|
|
212
|
+
const cos = Math.cos(delta)
|
|
213
|
+
const sin = Math.sin(delta)
|
|
214
|
+
const rot = (x: number, z: number): Vec2 => {
|
|
215
|
+
const dx = x - localCenter.x
|
|
216
|
+
const dz = z - localCenter.z
|
|
217
|
+
return [localCenter.x + dx * cos - dz * sin, localCenter.z + dx * sin + dz * cos]
|
|
218
|
+
}
|
|
219
|
+
const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
|
|
220
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
221
|
+
for (const s of starts) {
|
|
222
|
+
if (s.kind === 'endpoint') {
|
|
223
|
+
overrideEntries.push([
|
|
224
|
+
s.id,
|
|
225
|
+
{ start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) },
|
|
226
|
+
])
|
|
227
|
+
} else {
|
|
228
|
+
const [px, pz] = rot(s.position[0], s.position[2])
|
|
229
|
+
const position: Vec3 = [px, s.position[1], pz]
|
|
230
|
+
const rotation =
|
|
231
|
+
s.kind === 'vec3'
|
|
232
|
+
? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
|
|
233
|
+
: s.rotation - delta
|
|
234
|
+
overrideEntries.push([s.id, { position, rotation }])
|
|
235
|
+
if (s.kind === 'scalar') {
|
|
236
|
+
liveTransforms.set(s.id, { position, rotation: s.rotation - delta })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
useScene.getState().markDirty(s.id)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Drag each linked neighbour's shared endpoint to the same rotated spot
|
|
243
|
+
// (rot is deterministic, so it lands exactly on the selected wall's
|
|
244
|
+
// rotated endpoint), keeping the junction welded; the far end stays put.
|
|
245
|
+
for (const l of links) {
|
|
246
|
+
overrideEntries.push([
|
|
247
|
+
l.id,
|
|
248
|
+
{
|
|
249
|
+
start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
|
|
250
|
+
end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
|
|
251
|
+
},
|
|
252
|
+
])
|
|
253
|
+
useScene.getState().markDirty(l.id)
|
|
254
|
+
}
|
|
255
|
+
useLiveNodeOverrides.getState().setMany(overrideEntries)
|
|
256
|
+
|
|
257
|
+
if (Math.abs(delta) < 0.0087) {
|
|
258
|
+
setGuide(null)
|
|
259
|
+
} else {
|
|
260
|
+
const midAngle = initialAngle + delta / 2
|
|
261
|
+
const labelRadius = guideRadius + 0.22
|
|
262
|
+
setGuide({
|
|
263
|
+
center: [center.x, center.y, center.z],
|
|
264
|
+
startAngle: initialAngle,
|
|
265
|
+
endAngle: initialAngle + delta,
|
|
266
|
+
radius: guideRadius,
|
|
267
|
+
labelPos: [
|
|
268
|
+
center.x + Math.cos(midAngle) * labelRadius,
|
|
269
|
+
center.y + 0.02,
|
|
270
|
+
center.z + Math.sin(midAngle) * labelRadius,
|
|
271
|
+
],
|
|
272
|
+
sweep: Math.abs(delta),
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
278
|
+
const clearLivePreviews = () => {
|
|
279
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
280
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
281
|
+
for (const id of affectedIds) {
|
|
282
|
+
overrides.clear(id)
|
|
283
|
+
liveTransforms.clear(id)
|
|
284
|
+
useScene.getState().markDirty(id)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const cleanup = () => {
|
|
289
|
+
window.removeEventListener('pointermove', onMove)
|
|
290
|
+
window.removeEventListener('pointerup', onUp)
|
|
291
|
+
window.removeEventListener('pointercancel', onCancel)
|
|
292
|
+
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
293
|
+
useScene.temporal.getState().resume()
|
|
294
|
+
useViewer.getState().setInputDragging(false)
|
|
295
|
+
setIsDragging(false)
|
|
296
|
+
setGuide(null)
|
|
297
|
+
frozenRest.current = null
|
|
298
|
+
dragCleanupRef.current = null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const commitFromOverrides = () => {
|
|
302
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
303
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
304
|
+
for (const id of affectedIds) {
|
|
305
|
+
const patch = overrides.get(id)
|
|
306
|
+
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
|
|
307
|
+
}
|
|
308
|
+
return updates
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const onUp = () => {
|
|
312
|
+
// Eat the click that follows pointer-up so the selection manager doesn't
|
|
313
|
+
// treat it as a canvas click and clear the multi-selection.
|
|
314
|
+
swallowNextClick()
|
|
315
|
+
sfxEmitter.emit('sfx:item-place')
|
|
316
|
+
const updates = commitFromOverrides()
|
|
317
|
+
// Resume before the commit so the single batched `updateNodes` is the
|
|
318
|
+
// one tracked set — collapsing the whole group rotation into one undo.
|
|
319
|
+
useScene.temporal.getState().resume()
|
|
320
|
+
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
|
321
|
+
clearLivePreviews()
|
|
322
|
+
cleanup()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const onCancel = () => {
|
|
326
|
+
// Revert: drop overrides + mark dirty so renderers rebuild from the store.
|
|
327
|
+
clearLivePreviews()
|
|
328
|
+
cleanup()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
dragCleanupRef.current = () => {
|
|
332
|
+
clearLivePreviews()
|
|
333
|
+
cleanup()
|
|
334
|
+
}
|
|
335
|
+
for (const id of affectedIds) {
|
|
336
|
+
useLiveTransforms.getState().clear(id)
|
|
337
|
+
}
|
|
338
|
+
window.addEventListener('pointermove', onMove)
|
|
339
|
+
window.addEventListener('pointerup', onUp)
|
|
340
|
+
window.addEventListener('pointercancel', onCancel)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return createPortal(
|
|
344
|
+
<>
|
|
345
|
+
{(isHovered || isDragging) && (
|
|
346
|
+
<group position={[corner.x, corner.y, corner.z]}>
|
|
347
|
+
<GuideRing radius={0.2 * scale} y={0} />
|
|
348
|
+
</group>
|
|
349
|
+
)}
|
|
350
|
+
<group position={[corner.x, corner.y, corner.z]}>
|
|
351
|
+
<InvisibleHandleHitArea
|
|
352
|
+
geometry={hitGeometry}
|
|
353
|
+
material={hitMaterial}
|
|
354
|
+
onPointerDown={activate}
|
|
355
|
+
onPointerEnter={(event) => {
|
|
356
|
+
event.stopPropagation()
|
|
357
|
+
setIsHovered(true)
|
|
358
|
+
if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
|
|
359
|
+
}}
|
|
360
|
+
onPointerLeave={(event) => {
|
|
361
|
+
event.stopPropagation()
|
|
362
|
+
setIsHovered(false)
|
|
363
|
+
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
|
|
364
|
+
}}
|
|
365
|
+
scale={baseScale}
|
|
366
|
+
/>
|
|
367
|
+
<mesh
|
|
368
|
+
frustumCulled={false}
|
|
369
|
+
geometry={arrowGeometry}
|
|
370
|
+
material={arrowMaterial}
|
|
371
|
+
raycast={NO_RAYCAST}
|
|
372
|
+
renderOrder={1010}
|
|
373
|
+
scale={scale}
|
|
374
|
+
/>
|
|
375
|
+
</group>
|
|
376
|
+
{guide ? <RotationGuide data={guide} /> : null}
|
|
377
|
+
</>,
|
|
378
|
+
scene,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export default GroupRotateHandle
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { classifyParticipant, collectParticipants } from './group-transform-shared'
|
|
5
|
+
|
|
6
|
+
const BUILDING_SCOPED_KIND = 'group-transform-building-scoped-test'
|
|
7
|
+
|
|
8
|
+
function registerBuildingScopedTestKind() {
|
|
9
|
+
if (nodeRegistry.has(BUILDING_SCOPED_KIND)) return
|
|
10
|
+
|
|
11
|
+
registerNode({
|
|
12
|
+
kind: BUILDING_SCOPED_KIND,
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
schema: z.object({ type: z.literal(BUILDING_SCOPED_KIND) }) as never,
|
|
15
|
+
category: 'structure',
|
|
16
|
+
defaults: () => ({}),
|
|
17
|
+
capabilities: {},
|
|
18
|
+
floorplanScope: 'building',
|
|
19
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
20
|
+
} as AnyNodeDefinition)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function registerElevatorTestKind() {
|
|
24
|
+
if (nodeRegistry.has('elevator')) return
|
|
25
|
+
|
|
26
|
+
registerNode({
|
|
27
|
+
kind: 'elevator',
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
schema: z.object({ type: z.literal('elevator') }) as never,
|
|
30
|
+
category: 'structure',
|
|
31
|
+
defaults: () => ({}),
|
|
32
|
+
capabilities: { selectable: {} },
|
|
33
|
+
floorplanScope: 'building',
|
|
34
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
35
|
+
} as AnyNodeDefinition)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('group transform participants', () => {
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
registerBuildingScopedTestKind()
|
|
41
|
+
registerElevatorTestKind()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('includes building-scoped positioned nodes for the active level building', () => {
|
|
45
|
+
const nodes = {
|
|
46
|
+
building_test: {
|
|
47
|
+
id: 'building_test',
|
|
48
|
+
type: 'building',
|
|
49
|
+
children: ['level_test', 'elevator_test'],
|
|
50
|
+
},
|
|
51
|
+
level_test: {
|
|
52
|
+
id: 'level_test',
|
|
53
|
+
type: 'level',
|
|
54
|
+
parentId: 'building_test',
|
|
55
|
+
children: [],
|
|
56
|
+
},
|
|
57
|
+
elevator_test: {
|
|
58
|
+
id: 'elevator_test',
|
|
59
|
+
type: BUILDING_SCOPED_KIND,
|
|
60
|
+
parentId: 'building_test',
|
|
61
|
+
position: [1, 0, 2],
|
|
62
|
+
rotation: 0,
|
|
63
|
+
},
|
|
64
|
+
} as unknown as Record<string, AnyNode>
|
|
65
|
+
|
|
66
|
+
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
|
|
67
|
+
|
|
68
|
+
const participants = collectParticipants(['elevator_test'], nodes, 'level_test')
|
|
69
|
+
expect(participants.starts).toEqual([
|
|
70
|
+
{
|
|
71
|
+
id: 'elevator_test',
|
|
72
|
+
kind: 'scalar',
|
|
73
|
+
position: [1, 0, 2],
|
|
74
|
+
rotation: 0,
|
|
75
|
+
},
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('excludes building-scoped positioned nodes from other buildings', () => {
|
|
80
|
+
const nodes = {
|
|
81
|
+
building_active: {
|
|
82
|
+
id: 'building_active',
|
|
83
|
+
type: 'building',
|
|
84
|
+
children: ['level_test'],
|
|
85
|
+
},
|
|
86
|
+
building_other: {
|
|
87
|
+
id: 'building_other',
|
|
88
|
+
type: 'building',
|
|
89
|
+
children: ['elevator_test'],
|
|
90
|
+
},
|
|
91
|
+
level_test: {
|
|
92
|
+
id: 'level_test',
|
|
93
|
+
type: 'level',
|
|
94
|
+
parentId: 'building_active',
|
|
95
|
+
children: [],
|
|
96
|
+
},
|
|
97
|
+
elevator_test: {
|
|
98
|
+
id: 'elevator_test',
|
|
99
|
+
type: BUILDING_SCOPED_KIND,
|
|
100
|
+
parentId: 'building_other',
|
|
101
|
+
position: [1, 0, 2],
|
|
102
|
+
rotation: 0,
|
|
103
|
+
},
|
|
104
|
+
} as unknown as Record<string, AnyNode>
|
|
105
|
+
|
|
106
|
+
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBeNull()
|
|
107
|
+
expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('uses current elevator defaults for legacy elevators with no saved rotation', () => {
|
|
111
|
+
const nodes = {
|
|
112
|
+
building_test: {
|
|
113
|
+
id: 'building_test',
|
|
114
|
+
type: 'building',
|
|
115
|
+
children: ['level_test', 'elevator_test'],
|
|
116
|
+
},
|
|
117
|
+
level_test: {
|
|
118
|
+
id: 'level_test',
|
|
119
|
+
type: 'level',
|
|
120
|
+
parentId: 'building_test',
|
|
121
|
+
children: [],
|
|
122
|
+
},
|
|
123
|
+
elevator_test: {
|
|
124
|
+
id: 'elevator_test',
|
|
125
|
+
type: 'elevator',
|
|
126
|
+
parentId: 'building_test',
|
|
127
|
+
position: [3, 0, 4],
|
|
128
|
+
},
|
|
129
|
+
} as unknown as Record<string, AnyNode>
|
|
130
|
+
|
|
131
|
+
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
|
|
132
|
+
expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
|
|
133
|
+
{
|
|
134
|
+
id: 'elevator_test',
|
|
135
|
+
kind: 'scalar',
|
|
136
|
+
position: [3, 0, 4],
|
|
137
|
+
rotation: 0,
|
|
138
|
+
},
|
|
139
|
+
])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('resolves building-scoped elevators when legacy level parentId is missing', () => {
|
|
143
|
+
const nodes = {
|
|
144
|
+
building_test: {
|
|
145
|
+
id: 'building_test',
|
|
146
|
+
type: 'building',
|
|
147
|
+
children: ['level_test', 'elevator_test'],
|
|
148
|
+
},
|
|
149
|
+
level_test: {
|
|
150
|
+
id: 'level_test',
|
|
151
|
+
type: 'level',
|
|
152
|
+
parentId: null,
|
|
153
|
+
children: [],
|
|
154
|
+
},
|
|
155
|
+
elevator_test: {
|
|
156
|
+
id: 'elevator_test',
|
|
157
|
+
type: 'elevator',
|
|
158
|
+
parentId: 'building_test',
|
|
159
|
+
position: [7, 0, 8],
|
|
160
|
+
},
|
|
161
|
+
} as unknown as Record<string, AnyNode>
|
|
162
|
+
|
|
163
|
+
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
|
|
164
|
+
expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
|
|
165
|
+
{
|
|
166
|
+
id: 'elevator_test',
|
|
167
|
+
kind: 'scalar',
|
|
168
|
+
position: [7, 0, 8],
|
|
169
|
+
rotation: 0,
|
|
170
|
+
},
|
|
171
|
+
])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('supports legacy level-parented elevators already loaded in the editor', () => {
|
|
175
|
+
const nodes = {
|
|
176
|
+
building_test: {
|
|
177
|
+
id: 'building_test',
|
|
178
|
+
type: 'building',
|
|
179
|
+
children: ['level_test'],
|
|
180
|
+
},
|
|
181
|
+
level_test: {
|
|
182
|
+
id: 'level_test',
|
|
183
|
+
type: 'level',
|
|
184
|
+
parentId: 'building_test',
|
|
185
|
+
children: ['elevator_test'],
|
|
186
|
+
},
|
|
187
|
+
elevator_test: {
|
|
188
|
+
id: 'elevator_test',
|
|
189
|
+
type: 'elevator',
|
|
190
|
+
parentId: 'level_test',
|
|
191
|
+
position: [5, 0, 6],
|
|
192
|
+
},
|
|
193
|
+
} as unknown as Record<string, AnyNode>
|
|
194
|
+
|
|
195
|
+
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
|
|
196
|
+
expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
|
|
197
|
+
{
|
|
198
|
+
id: 'elevator_test',
|
|
199
|
+
kind: 'scalar',
|
|
200
|
+
position: [5, 0, 6],
|
|
201
|
+
rotation: 0,
|
|
202
|
+
},
|
|
203
|
+
])
|
|
204
|
+
})
|
|
205
|
+
})
|