@pascal-app/editor 0.9.1 → 0.9.2
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 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
|
+
DEFAULT_ANGLE_STEP,
|
|
7
|
+
pauseSpaceDetection,
|
|
8
|
+
resumeSpaceDetection,
|
|
6
9
|
useLiveNodeOverrides,
|
|
7
10
|
useLiveTransforms,
|
|
8
11
|
useScene,
|
|
@@ -11,8 +14,14 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
11
14
|
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|
12
15
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
13
16
|
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
|
17
|
+
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
18
|
+
import { isHistoryShortcut } from '../../lib/history'
|
|
14
19
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
15
20
|
import useEditor from '../../store/use-editor'
|
|
21
|
+
import useInteractionScope, {
|
|
22
|
+
useActiveHandleDrag,
|
|
23
|
+
useMovingNode,
|
|
24
|
+
} from '../../store/use-interaction-scope'
|
|
16
25
|
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
17
26
|
import {
|
|
18
27
|
CORNER_OFFSET,
|
|
@@ -21,7 +30,7 @@ import {
|
|
|
21
30
|
computeGroupBox,
|
|
22
31
|
expandToComponent,
|
|
23
32
|
levelFrame,
|
|
24
|
-
|
|
33
|
+
rotateGroupPatches,
|
|
25
34
|
type Vec3,
|
|
26
35
|
} from './group-transform-shared'
|
|
27
36
|
import {
|
|
@@ -31,16 +40,13 @@ import {
|
|
|
31
40
|
createRotateArrowHandleGeometry,
|
|
32
41
|
createRotateArrowHitAreaGeometry,
|
|
33
42
|
GuideRing,
|
|
34
|
-
InvisibleHandleHitArea,
|
|
35
|
-
NO_RAYCAST,
|
|
36
43
|
RotationGuide,
|
|
37
44
|
type RotationGuideData,
|
|
38
45
|
swallowNextClick,
|
|
39
46
|
useArrowMaterial,
|
|
40
47
|
useInvisibleHitAreaMaterial,
|
|
41
48
|
} from './node-arrow-handles'
|
|
42
|
-
|
|
43
|
-
const ROTATE_SNAP = Math.PI / 12 // 15°
|
|
49
|
+
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
|
|
44
50
|
|
|
45
51
|
/**
|
|
46
52
|
* Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
|
|
@@ -56,11 +62,14 @@ export function GroupRotateHandle() {
|
|
|
56
62
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
57
63
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
58
64
|
const mode = useEditor((s) => s.mode)
|
|
59
|
-
const movingNode =
|
|
65
|
+
const movingNode = useMovingNode()
|
|
66
|
+
const activeHandleDrag = useActiveHandleDrag()
|
|
60
67
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
61
68
|
// Re-derive participants whenever the scene mutates (e.g. after a commit).
|
|
62
69
|
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
|
|
63
70
|
const nodes = useScene((s) => s.nodes)
|
|
71
|
+
// Re-measure the pivot/corner once the meshes settle after a scene change.
|
|
72
|
+
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
64
73
|
|
|
65
74
|
const participantIds = useMemo(
|
|
66
75
|
() =>
|
|
@@ -79,14 +88,20 @@ export function GroupRotateHandle() {
|
|
|
79
88
|
)
|
|
80
89
|
|
|
81
90
|
const shouldRender =
|
|
82
|
-
participantIds.length >= 2 &&
|
|
91
|
+
participantIds.length >= 2 &&
|
|
92
|
+
mode !== 'delete' &&
|
|
93
|
+
!movingNode &&
|
|
94
|
+
!isFloorplanHovered &&
|
|
95
|
+
// Hide while the sibling move gizmo drags the group — the frozen corner
|
|
96
|
+
// this handle would sit at goes stale as the group slides under it.
|
|
97
|
+
activeHandleDrag?.label !== GROUP_MOVE_DRAG_LABEL
|
|
83
98
|
|
|
84
99
|
if (!shouldRender) return null
|
|
85
100
|
// Remount when the moving set changes so the rest pivot re-seeds cleanly.
|
|
86
|
-
return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} />
|
|
101
|
+
return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} meshEpoch={meshEpoch} />
|
|
87
102
|
}
|
|
88
103
|
|
|
89
|
-
function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
104
|
+
function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
|
|
90
105
|
const { camera, raycaster, gl, scene } = useThree()
|
|
91
106
|
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
|
|
92
107
|
const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
|
|
@@ -126,13 +141,26 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
126
141
|
box.max.z + CORNER_OFFSET,
|
|
127
142
|
)
|
|
128
143
|
return { pivot, corner }
|
|
129
|
-
|
|
144
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
145
|
+
}, [ids, meshEpoch])
|
|
130
146
|
|
|
131
147
|
if (!rest) return null
|
|
132
148
|
const active = isDragging && frozenRest.current ? frozenRest.current : rest
|
|
133
149
|
const corner = active.corner
|
|
134
150
|
|
|
151
|
+
const onHoverEnter = (event: ThreeEvent<PointerEvent>) => {
|
|
152
|
+
event.stopPropagation()
|
|
153
|
+
setIsHovered(true)
|
|
154
|
+
if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
|
|
155
|
+
}
|
|
156
|
+
const onHoverLeave = (event: ThreeEvent<PointerEvent>) => {
|
|
157
|
+
event.stopPropagation()
|
|
158
|
+
setIsHovered(false)
|
|
159
|
+
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
|
|
160
|
+
}
|
|
161
|
+
|
|
135
162
|
const activate = (event: ThreeEvent<PointerEvent>) => {
|
|
163
|
+
if (event.button !== 0) return
|
|
136
164
|
event.stopPropagation()
|
|
137
165
|
suppressBoxSelectForPointer(event)
|
|
138
166
|
|
|
@@ -166,6 +194,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
166
194
|
if (s.kind === 'endpoint') {
|
|
167
195
|
reach(s.start[0], s.start[1])
|
|
168
196
|
reach(s.end[0], s.end[1])
|
|
197
|
+
} else if (s.kind === 'polygon') {
|
|
198
|
+
for (const [x, z] of s.polygon) {
|
|
199
|
+
reach(x, z)
|
|
200
|
+
}
|
|
169
201
|
} else {
|
|
170
202
|
reach(s.position[0], s.position[2])
|
|
171
203
|
}
|
|
@@ -191,6 +223,11 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
191
223
|
sfxEmitter.emit('sfx:item-pick')
|
|
192
224
|
useViewer.getState().setInputDragging(true)
|
|
193
225
|
useScene.temporal.getState().pause()
|
|
226
|
+
useInteractionScope.getState().begin({
|
|
227
|
+
kind: 'handle-drag',
|
|
228
|
+
nodeId: ids[0] ?? '',
|
|
229
|
+
handle: GROUP_ROTATE_DRAG_LABEL,
|
|
230
|
+
})
|
|
194
231
|
setIsDragging(true)
|
|
195
232
|
|
|
196
233
|
const onMove = (e: PointerEvent) => {
|
|
@@ -201,55 +238,31 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
201
238
|
let delta = angleOf(moveHit) - initialAngle
|
|
202
239
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
203
240
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
204
|
-
if (e.shiftKey) delta = Math.round(delta /
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
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>]> = []
|
|
241
|
+
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
|
242
|
+
|
|
243
|
+
// Shared rigid-rotation math (also used by the keyboard group R/T);
|
|
244
|
+
// see `rotateGroupPatches` for the orbit/yaw handedness contract.
|
|
245
|
+
const overrideEntries = rotateGroupPatches(
|
|
246
|
+
starts,
|
|
247
|
+
links,
|
|
248
|
+
{ x: localCenter.x, z: localCenter.z },
|
|
249
|
+
delta,
|
|
250
|
+
)
|
|
251
|
+
const patchById = new Map(overrideEntries)
|
|
220
252
|
const liveTransforms = useLiveTransforms.getState()
|
|
221
253
|
for (const s of starts) {
|
|
222
|
-
if (s.kind === '
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 })
|
|
254
|
+
if (s.kind === 'scalar') {
|
|
255
|
+
const patch = patchById.get(s.id)
|
|
256
|
+
if (patch) {
|
|
257
|
+
liveTransforms.set(s.id, {
|
|
258
|
+
position: patch.position as Vec3,
|
|
259
|
+
rotation: patch.rotation as number,
|
|
260
|
+
})
|
|
237
261
|
}
|
|
238
262
|
}
|
|
239
263
|
useScene.getState().markDirty(s.id)
|
|
240
264
|
}
|
|
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
265
|
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
266
|
useScene.getState().markDirty(l.id)
|
|
254
267
|
}
|
|
255
268
|
useLiveNodeOverrides.getState().setMany(overrideEntries)
|
|
@@ -289,9 +302,13 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
289
302
|
window.removeEventListener('pointermove', onMove)
|
|
290
303
|
window.removeEventListener('pointerup', onUp)
|
|
291
304
|
window.removeEventListener('pointercancel', onCancel)
|
|
305
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
292
306
|
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
293
307
|
useScene.temporal.getState().resume()
|
|
294
308
|
useViewer.getState().setInputDragging(false)
|
|
309
|
+
useInteractionScope
|
|
310
|
+
.getState()
|
|
311
|
+
.endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_ROTATE_DRAG_LABEL)
|
|
295
312
|
setIsDragging(false)
|
|
296
313
|
setGuide(null)
|
|
297
314
|
frozenRest.current = null
|
|
@@ -316,8 +333,12 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
316
333
|
const updates = commitFromOverrides()
|
|
317
334
|
// Resume before the commit so the single batched `updateNodes` is the
|
|
318
335
|
// one tracked set — collapsing the whole group rotation into one undo.
|
|
336
|
+
// Space detection stays out: a rigid rotation of existing walls must
|
|
337
|
+
// not re-create the room's auto floors/ceilings at the new bearing.
|
|
338
|
+
pauseSpaceDetection()
|
|
319
339
|
useScene.temporal.getState().resume()
|
|
320
340
|
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
|
341
|
+
resumeSpaceDetection()
|
|
321
342
|
clearLivePreviews()
|
|
322
343
|
cleanup()
|
|
323
344
|
}
|
|
@@ -328,6 +349,16 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
328
349
|
cleanup()
|
|
329
350
|
}
|
|
330
351
|
|
|
352
|
+
// Escape / ⌘Z abort the rotate — capture phase so they win over the global
|
|
353
|
+
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
|
|
354
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
355
|
+
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
356
|
+
e.preventDefault()
|
|
357
|
+
e.stopPropagation()
|
|
358
|
+
swallowNextClick()
|
|
359
|
+
onCancel()
|
|
360
|
+
}
|
|
361
|
+
|
|
331
362
|
dragCleanupRef.current = () => {
|
|
332
363
|
clearLivePreviews()
|
|
333
364
|
cleanup()
|
|
@@ -338,6 +369,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
338
369
|
window.addEventListener('pointermove', onMove)
|
|
339
370
|
window.addEventListener('pointerup', onUp)
|
|
340
371
|
window.addEventListener('pointercancel', onCancel)
|
|
372
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
341
373
|
}
|
|
342
374
|
|
|
343
375
|
return createPortal(
|
|
@@ -348,27 +380,33 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|
|
348
380
|
</group>
|
|
349
381
|
)}
|
|
350
382
|
<group position={[corner.x, corner.y, corner.z]}>
|
|
351
|
-
|
|
383
|
+
{/* Fat invisible grab target (torus wrapping the arrow). A plain
|
|
384
|
+
default-layer mesh with the standard raycast — the shared
|
|
385
|
+
`InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never
|
|
386
|
+
received pointer events in this portalled context: R3F's
|
|
387
|
+
`createPortal` gives the portal root its own fresh `Raycaster`
|
|
388
|
+
(default mask = layer 0 only), so the EDITOR_LAYER enable applied
|
|
389
|
+
to the root raycaster in `custom-camera-controls` never reaches
|
|
390
|
+
it. Harmless render-wise — the material is colorWrite:false /
|
|
391
|
+
depthWrite:false, so nothing leaks into thumbnails or the ink
|
|
392
|
+
pass. A proper fix would enable EDITOR_LAYER on the portal
|
|
393
|
+
raycaster from inside the portal. */}
|
|
394
|
+
<mesh
|
|
395
|
+
frustumCulled={false}
|
|
352
396
|
geometry={hitGeometry}
|
|
353
397
|
material={hitMaterial}
|
|
354
398
|
onPointerDown={activate}
|
|
355
|
-
onPointerEnter={
|
|
356
|
-
|
|
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
|
-
}}
|
|
399
|
+
onPointerEnter={onHoverEnter}
|
|
400
|
+
onPointerLeave={onHoverLeave}
|
|
365
401
|
scale={baseScale}
|
|
366
402
|
/>
|
|
367
403
|
<mesh
|
|
368
404
|
frustumCulled={false}
|
|
369
405
|
geometry={arrowGeometry}
|
|
370
406
|
material={arrowMaterial}
|
|
371
|
-
|
|
407
|
+
onPointerDown={activate}
|
|
408
|
+
onPointerEnter={onHoverEnter}
|
|
409
|
+
onPointerLeave={onHoverLeave}
|
|
372
410
|
renderOrder={1010}
|
|
373
411
|
scale={scale}
|
|
374
412
|
/>
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { type ThreeEvent, useThree } from '@react-three/fiber'
|
|
6
|
+
import { useEffect, useMemo } from 'react'
|
|
7
|
+
import * as THREE from 'three'
|
|
8
|
+
import useEditor from '../../store/use-editor'
|
|
9
|
+
import { useMovingNode } from '../../store/use-interaction-scope'
|
|
10
|
+
import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
|
|
11
|
+
import { armGroupMove3d } from './group-move-3d'
|
|
12
|
+
import {
|
|
13
|
+
classifyParticipant,
|
|
14
|
+
computeGroupBox,
|
|
15
|
+
expandToComponent,
|
|
16
|
+
levelFrame,
|
|
17
|
+
} from './group-transform-shared'
|
|
18
|
+
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
|
|
19
|
+
|
|
20
|
+
// Matches the 2D dashed selection box's stroke.
|
|
21
|
+
const BOX_COLOR = '#3b82f6'
|
|
22
|
+
// Small clearance so the dashes don't z-fight the selection's outer faces.
|
|
23
|
+
const BOX_PAD = 0.06
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 3D sibling of the 2D dashed group selection box: a dashed wireframe around
|
|
27
|
+
* the multi-selection's transformable participants (expanded to the welded
|
|
28
|
+
* wall/fence component) that doubles as the group's whole-volume drag
|
|
29
|
+
* handle — move cursor across it, press-drag anywhere on it slides the group,
|
|
30
|
+
* a plain click picks it up. Holding a selection modifier passes the press
|
|
31
|
+
* through so members inside can still be toggled. Rides the live drag delta
|
|
32
|
+
* so it tracks the group mid-gesture.
|
|
33
|
+
*/
|
|
34
|
+
export function GroupSelectionBox3D() {
|
|
35
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
36
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
37
|
+
const nodes = useScene((s) => s.nodes)
|
|
38
|
+
const mode = useEditor((s) => s.mode)
|
|
39
|
+
const movingNode = useMovingNode()
|
|
40
|
+
const delta = useFloorplanGroupDrag((s) => s.delta)
|
|
41
|
+
const { camera, raycaster, gl } = useThree()
|
|
42
|
+
|
|
43
|
+
const participantIds = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
selectedIds.length > 1
|
|
46
|
+
? selectedIds.filter(
|
|
47
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
48
|
+
)
|
|
49
|
+
: [],
|
|
50
|
+
[selectedIds, levelId, nodes],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// Re-measure once the meshes settle after a scene change (undo included).
|
|
54
|
+
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
55
|
+
const box = useMemo(() => {
|
|
56
|
+
if (participantIds.length === 0) return null
|
|
57
|
+
const fullIds = expandToComponent(participantIds, nodes, levelId)
|
|
58
|
+
const world = computeGroupBox(fullIds)
|
|
59
|
+
if (!world) return null
|
|
60
|
+
const size = new THREE.Vector3()
|
|
61
|
+
world.getSize(size)
|
|
62
|
+
const center = new THREE.Vector3()
|
|
63
|
+
world.getCenter(center)
|
|
64
|
+
return {
|
|
65
|
+
size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [
|
|
66
|
+
number,
|
|
67
|
+
number,
|
|
68
|
+
number,
|
|
69
|
+
],
|
|
70
|
+
center,
|
|
71
|
+
}
|
|
72
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
73
|
+
}, [participantIds, nodes, levelId, meshEpoch])
|
|
74
|
+
|
|
75
|
+
// Dashed wireframe. Built per box size (rare — selection / commit changes)
|
|
76
|
+
// because LineDashedMaterial measures dashes along the line, so scaling a
|
|
77
|
+
// unit box would stretch them per axis.
|
|
78
|
+
const edges = useMemo(() => {
|
|
79
|
+
if (!box) return null
|
|
80
|
+
const geometry = new THREE.EdgesGeometry(new THREE.BoxGeometry(...box.size))
|
|
81
|
+
const line = new THREE.LineSegments(
|
|
82
|
+
geometry,
|
|
83
|
+
new THREE.LineDashedMaterial({
|
|
84
|
+
color: BOX_COLOR,
|
|
85
|
+
dashSize: 0.18,
|
|
86
|
+
gapSize: 0.12,
|
|
87
|
+
opacity: 0.9,
|
|
88
|
+
transparent: true,
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
line.computeLineDistances()
|
|
92
|
+
return line
|
|
93
|
+
}, [box])
|
|
94
|
+
useEffect(
|
|
95
|
+
() => () => {
|
|
96
|
+
if (edges) {
|
|
97
|
+
edges.geometry.dispose()
|
|
98
|
+
;(edges.material as THREE.Material).dispose()
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
[edges],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
// Mid-drag the sessions publish a level-frame delta; map it to world so the
|
|
105
|
+
// box rides the group (shared with the 2D box's store).
|
|
106
|
+
const worldOffset = useMemo(() => {
|
|
107
|
+
if (!delta) return null
|
|
108
|
+
const { matrix } = levelFrame(levelId)
|
|
109
|
+
const origin = new THREE.Vector3().applyMatrix4(matrix)
|
|
110
|
+
return new THREE.Vector3(delta[0], 0, delta[1]).applyMatrix4(matrix).sub(origin)
|
|
111
|
+
}, [delta, levelId])
|
|
112
|
+
|
|
113
|
+
if (!box || !edges || movingNode || mode !== 'select') return null
|
|
114
|
+
|
|
115
|
+
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
|
|
116
|
+
const native = event.nativeEvent
|
|
117
|
+
if (native.button !== 0) return
|
|
118
|
+
// A selection modifier passes through so members inside the box can
|
|
119
|
+
// still be toggled in and out of the selection.
|
|
120
|
+
if (native.metaKey || native.ctrlKey || native.shiftKey || native.altKey) return
|
|
121
|
+
const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
|
|
122
|
+
const sceneNodes = useScene.getState().nodes
|
|
123
|
+
const anchor = currentIds.find(
|
|
124
|
+
(id) => classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
|
|
125
|
+
)
|
|
126
|
+
if (!anchor) return
|
|
127
|
+
const armed = armGroupMove3d({
|
|
128
|
+
nodeId: anchor as AnyNodeId,
|
|
129
|
+
clientX: native.clientX,
|
|
130
|
+
clientY: native.clientY,
|
|
131
|
+
pointerId: native.pointerId,
|
|
132
|
+
nativeEvent: native,
|
|
133
|
+
camera,
|
|
134
|
+
raycaster,
|
|
135
|
+
domElement: gl.domElement,
|
|
136
|
+
})
|
|
137
|
+
// Own the press: deeper hits (members, ground) must not also select.
|
|
138
|
+
if (armed) event.stopPropagation()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Set on move (not just enter): the canvas may carry an app default
|
|
142
|
+
// cursor, and member hovers inside the box write theirs — keep 'move'
|
|
143
|
+
// asserted across the whole volume, except while a drag shows 'grabbing'.
|
|
144
|
+
const applyMoveCursor = () => {
|
|
145
|
+
const cursor = gl.domElement.style.cursor
|
|
146
|
+
if (cursor !== 'move' && cursor !== 'grabbing') gl.domElement.style.cursor = 'move'
|
|
147
|
+
}
|
|
148
|
+
const handlePointerLeave = () => {
|
|
149
|
+
if (gl.domElement.style.cursor === 'move') gl.domElement.style.cursor = ''
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const position: [number, number, number] = [
|
|
153
|
+
box.center.x + (worldOffset?.x ?? 0),
|
|
154
|
+
box.center.y + (worldOffset?.y ?? 0),
|
|
155
|
+
box.center.z + (worldOffset?.z ?? 0),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<group position={position}>
|
|
160
|
+
<primitive object={edges} />
|
|
161
|
+
{/* Invisible whole-volume hit target — the box IS the drag handle. */}
|
|
162
|
+
<mesh
|
|
163
|
+
onPointerDown={handlePointerDown}
|
|
164
|
+
onPointerEnter={applyMoveCursor}
|
|
165
|
+
onPointerLeave={handlePointerLeave}
|
|
166
|
+
onPointerMove={applyMoveCursor}
|
|
167
|
+
>
|
|
168
|
+
<boxGeometry args={box.size} />
|
|
169
|
+
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
|
|
170
|
+
</mesh>
|
|
171
|
+
</group>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
classifyParticipant,
|
|
6
|
+
collectParticipants,
|
|
7
|
+
rotateGroupPatches,
|
|
8
|
+
translateGroupPatches,
|
|
9
|
+
} from './group-transform-shared'
|
|
5
10
|
|
|
6
11
|
const BUILDING_SCOPED_KIND = 'group-transform-building-scoped-test'
|
|
7
12
|
|
|
@@ -171,6 +176,192 @@ describe('group transform participants', () => {
|
|
|
171
176
|
])
|
|
172
177
|
})
|
|
173
178
|
|
|
179
|
+
test('classifies and transforms polygon kinds (slab / ceiling / zone)', () => {
|
|
180
|
+
const nodes = {
|
|
181
|
+
building_test: {
|
|
182
|
+
id: 'building_test',
|
|
183
|
+
type: 'building',
|
|
184
|
+
children: ['level_test'],
|
|
185
|
+
},
|
|
186
|
+
level_test: {
|
|
187
|
+
id: 'level_test',
|
|
188
|
+
type: 'level',
|
|
189
|
+
parentId: 'building_test',
|
|
190
|
+
children: ['slab_test', 'zone_test'],
|
|
191
|
+
},
|
|
192
|
+
slab_test: {
|
|
193
|
+
id: 'slab_test',
|
|
194
|
+
type: 'slab',
|
|
195
|
+
parentId: 'level_test',
|
|
196
|
+
polygon: [
|
|
197
|
+
[0, 0],
|
|
198
|
+
[2, 0],
|
|
199
|
+
[2, 2],
|
|
200
|
+
[0, 2],
|
|
201
|
+
],
|
|
202
|
+
holes: [
|
|
203
|
+
[
|
|
204
|
+
[0.5, 0.5],
|
|
205
|
+
[1, 0.5],
|
|
206
|
+
[1, 1],
|
|
207
|
+
],
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
zone_test: {
|
|
211
|
+
id: 'zone_test',
|
|
212
|
+
type: 'zone',
|
|
213
|
+
parentId: 'level_test',
|
|
214
|
+
polygon: [
|
|
215
|
+
[0, 0],
|
|
216
|
+
[1, 0],
|
|
217
|
+
[1, 1],
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
slab_elsewhere: {
|
|
221
|
+
id: 'slab_elsewhere',
|
|
222
|
+
type: 'slab',
|
|
223
|
+
parentId: 'level_other',
|
|
224
|
+
polygon: [
|
|
225
|
+
[0, 0],
|
|
226
|
+
[1, 0],
|
|
227
|
+
[1, 1],
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
} as unknown as Record<string, AnyNode>
|
|
231
|
+
|
|
232
|
+
expect(classifyParticipant(nodes.slab_test, 'level_test', nodes)).toBe('polygon')
|
|
233
|
+
expect(classifyParticipant(nodes.zone_test, 'level_test', nodes)).toBe('polygon')
|
|
234
|
+
// Not in the active level frame → excluded.
|
|
235
|
+
expect(classifyParticipant(nodes.slab_elsewhere, 'level_test', nodes)).toBeNull()
|
|
236
|
+
|
|
237
|
+
const { starts, links } = collectParticipants(['slab_test', 'zone_test'], nodes, 'level_test')
|
|
238
|
+
expect(links).toEqual([])
|
|
239
|
+
expect(starts).toEqual([
|
|
240
|
+
{
|
|
241
|
+
id: 'slab_test',
|
|
242
|
+
kind: 'polygon',
|
|
243
|
+
polygon: [
|
|
244
|
+
[0, 0],
|
|
245
|
+
[2, 0],
|
|
246
|
+
[2, 2],
|
|
247
|
+
[0, 2],
|
|
248
|
+
],
|
|
249
|
+
holes: [
|
|
250
|
+
[
|
|
251
|
+
[0.5, 0.5],
|
|
252
|
+
[1, 0.5],
|
|
253
|
+
[1, 1],
|
|
254
|
+
],
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: 'zone_test',
|
|
259
|
+
kind: 'polygon',
|
|
260
|
+
polygon: [
|
|
261
|
+
[0, 0],
|
|
262
|
+
[1, 0],
|
|
263
|
+
[1, 1],
|
|
264
|
+
],
|
|
265
|
+
holes: null,
|
|
266
|
+
},
|
|
267
|
+
])
|
|
268
|
+
|
|
269
|
+
// Translate: every vertex (holes included) shifts; the hole-less zone's
|
|
270
|
+
// patch never grows a `holes` field.
|
|
271
|
+
const moved = translateGroupPatches(starts, [], 1, -2)
|
|
272
|
+
expect(moved).toEqual([
|
|
273
|
+
[
|
|
274
|
+
'slab_test',
|
|
275
|
+
{
|
|
276
|
+
polygon: [
|
|
277
|
+
[1, -2],
|
|
278
|
+
[3, -2],
|
|
279
|
+
[3, 0],
|
|
280
|
+
[1, 0],
|
|
281
|
+
],
|
|
282
|
+
holes: [
|
|
283
|
+
[
|
|
284
|
+
[1.5, -1.5],
|
|
285
|
+
[2, -1.5],
|
|
286
|
+
[2, -1],
|
|
287
|
+
],
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
[
|
|
292
|
+
'zone_test',
|
|
293
|
+
{
|
|
294
|
+
polygon: [
|
|
295
|
+
[1, -2],
|
|
296
|
+
[2, -2],
|
|
297
|
+
[2, -1],
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
// Rotate 90° in the atan2 x→z sense around the origin: (x, z) → (-z, x).
|
|
304
|
+
const rotated = rotateGroupPatches(
|
|
305
|
+
starts.filter((s) => s.id === 'zone_test'),
|
|
306
|
+
[],
|
|
307
|
+
{ x: 0, z: 0 },
|
|
308
|
+
Math.PI / 2,
|
|
309
|
+
)
|
|
310
|
+
expect(rotated).toHaveLength(1)
|
|
311
|
+
const rotatedPolygon = (rotated[0]![1] as { polygon: [number, number][] }).polygon
|
|
312
|
+
const expected = [
|
|
313
|
+
[0, 0],
|
|
314
|
+
[0, 1],
|
|
315
|
+
[-1, 1],
|
|
316
|
+
]
|
|
317
|
+
rotatedPolygon.forEach((point, i) => {
|
|
318
|
+
expect(point[0]).toBeCloseTo(expected[i]![0]!)
|
|
319
|
+
expect(point[1]).toBeCloseTo(expected[i]![1]!)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('polygon hosts carry their attached positioned children (ceiling items)', () => {
|
|
324
|
+
const nodes = {
|
|
325
|
+
building_test: { id: 'building_test', type: 'building', children: ['level_test'] },
|
|
326
|
+
level_test: {
|
|
327
|
+
id: 'level_test',
|
|
328
|
+
type: 'level',
|
|
329
|
+
parentId: 'building_test',
|
|
330
|
+
children: ['ceiling_test'],
|
|
331
|
+
},
|
|
332
|
+
ceiling_test: {
|
|
333
|
+
id: 'ceiling_test',
|
|
334
|
+
type: 'ceiling',
|
|
335
|
+
parentId: 'level_test',
|
|
336
|
+
children: ['item_lamp'],
|
|
337
|
+
polygon: [
|
|
338
|
+
[0, 0],
|
|
339
|
+
[2, 0],
|
|
340
|
+
[2, 2],
|
|
341
|
+
[0, 2],
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
item_lamp: {
|
|
345
|
+
id: 'item_lamp',
|
|
346
|
+
type: 'item',
|
|
347
|
+
parentId: 'ceiling_test',
|
|
348
|
+
position: [1, 2.4, 1],
|
|
349
|
+
rotation: [0, 0.5, 0],
|
|
350
|
+
},
|
|
351
|
+
} as unknown as Record<string, AnyNode>
|
|
352
|
+
|
|
353
|
+
// The lamp itself is not level-parented, so it is not an independent
|
|
354
|
+
// participant — it rides its host ceiling.
|
|
355
|
+
expect(classifyParticipant(nodes.item_lamp, 'level_test', nodes)).toBeNull()
|
|
356
|
+
|
|
357
|
+
const { starts } = collectParticipants(['ceiling_test'], nodes, 'level_test')
|
|
358
|
+
expect(starts.map((s) => s.id).sort()).toEqual(['ceiling_test', 'item_lamp'])
|
|
359
|
+
|
|
360
|
+
const moved = translateGroupPatches(starts, [], 2, 1)
|
|
361
|
+
const lampPatch = Object.fromEntries(moved).item_lamp as { position: [number, number, number] }
|
|
362
|
+
expect(lampPatch.position).toEqual([3, 2.4, 2])
|
|
363
|
+
})
|
|
364
|
+
|
|
174
365
|
test('supports legacy level-parented elevators already loaded in the editor', () => {
|
|
175
366
|
const nodes = {
|
|
176
367
|
building_test: {
|