@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,7 +15,12 @@ import useSessionGroups, {
|
|
|
15
15
|
ungroupCurrentSelection,
|
|
16
16
|
} from '../../store/use-session-groups'
|
|
17
17
|
import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
classifyParticipant,
|
|
20
|
+
computeGroupBox,
|
|
21
|
+
computeGroupPlanBox,
|
|
22
|
+
levelFrame,
|
|
23
|
+
} from './group-transform-shared'
|
|
19
24
|
import { NodeActionMenu } from './node-action-menu'
|
|
20
25
|
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
|
|
21
26
|
|
|
@@ -69,15 +74,20 @@ export function GroupFloatingActionMenu() {
|
|
|
69
74
|
const anchor = useMemo(() => {
|
|
70
75
|
void meshEpoch
|
|
71
76
|
if (participantIds.length === 0) return null
|
|
72
|
-
const
|
|
73
|
-
const box = computeGroupBox(fullIds)
|
|
77
|
+
const box = computeGroupBox(participantIds)
|
|
74
78
|
if (!box) return null
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// Over the level-frame box's centre (the dashed boxes' and gizmo's), so the
|
|
80
|
+
// pill stays on the box under a rotated building; the mesh box gives the top.
|
|
81
|
+
const levelId = useViewer.getState().selection.levelId
|
|
82
|
+
const plan = computeGroupPlanBox(participantIds, levelId)
|
|
83
|
+
const anchor = plan
|
|
84
|
+
? new THREE.Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4(
|
|
85
|
+
levelFrame(levelId).matrix,
|
|
86
|
+
)
|
|
87
|
+
: new THREE.Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2)
|
|
88
|
+
anchor.y = box.max.y + MENU_Y_OFFSET
|
|
89
|
+
return anchor
|
|
90
|
+
}, [participantIds, meshEpoch])
|
|
81
91
|
|
|
82
92
|
useFrame((state) => {
|
|
83
93
|
if (!(menuScaleRef.current && groupRef.current)) return
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
classifyParticipant,
|
|
32
32
|
collectParticipants,
|
|
33
33
|
computeGroupBox,
|
|
34
|
-
expandToComponent,
|
|
35
34
|
type GroupPlanBounds,
|
|
36
35
|
groupPlanBounds,
|
|
37
36
|
levelFrame,
|
|
@@ -107,16 +106,15 @@ export function armGroupMove3d(args: {
|
|
|
107
106
|
const participantIds = selectedIds.filter(
|
|
108
107
|
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
109
108
|
)
|
|
110
|
-
//
|
|
111
|
-
const
|
|
112
|
-
const { starts, links } = collectParticipants(fullIds, nodes, levelId)
|
|
109
|
+
// Only the selection moves; connected walls stretch through `links`, as in 2D.
|
|
110
|
+
const { starts, links } = collectParticipants(participantIds, nodes, levelId)
|
|
113
111
|
if (starts.length === 0) return null
|
|
114
112
|
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
115
113
|
|
|
116
114
|
// Horizontal drag plane at the group's base; placements live in the level
|
|
117
115
|
// frame, so world-space plane hits convert through it (a rotated building
|
|
118
116
|
// would otherwise drift off-axis from the cursor).
|
|
119
|
-
const restBox = computeGroupBox(
|
|
117
|
+
const restBox = computeGroupBox(participantIds)
|
|
120
118
|
if (!restBox) return null
|
|
121
119
|
const plane = new Plane(new Vector3(0, 1, 0), -restBox.min.y)
|
|
122
120
|
const { inverse: frameInv } = levelFrame(levelId)
|
|
@@ -136,7 +134,7 @@ export function armGroupMove3d(args: {
|
|
|
136
134
|
if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
|
|
137
135
|
}
|
|
138
136
|
const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
|
|
139
|
-
const restBounds = groupPlanBounds(
|
|
137
|
+
const restBounds = groupPlanBounds(starts, frameInv)
|
|
140
138
|
if (!restBounds) return null
|
|
141
139
|
const restAnchors = bboxCornerAnchors(
|
|
142
140
|
'group-move',
|
|
@@ -13,11 +13,13 @@ import {
|
|
|
13
13
|
import { useViewer } from '@pascal-app/viewer'
|
|
14
14
|
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|
15
15
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
16
|
-
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
|
16
|
+
import { OrthographicCamera, Plane, type Ray, Vector2, Vector3 } from 'three'
|
|
17
17
|
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
18
18
|
import { isHistoryShortcut } from '../../lib/history'
|
|
19
19
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
20
|
-
import
|
|
20
|
+
import { intersectSpatialDragPlane } from '../../lib/spatial-drag-plane'
|
|
21
|
+
import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input'
|
|
22
|
+
import useEditor, { isAngleSnapActive } from '../../store/use-editor'
|
|
21
23
|
import useInteractionScope, {
|
|
22
24
|
useActiveHandleDrag,
|
|
23
25
|
useMovingNode,
|
|
@@ -28,7 +30,7 @@ import {
|
|
|
28
30
|
classifyParticipant,
|
|
29
31
|
collectParticipants,
|
|
30
32
|
computeGroupBox,
|
|
31
|
-
|
|
33
|
+
computeGroupPlanBox,
|
|
32
34
|
levelFrame,
|
|
33
35
|
rotateGroupPatches,
|
|
34
36
|
type Vec3,
|
|
@@ -79,14 +81,6 @@ export function GroupRotateHandle() {
|
|
|
79
81
|
[selectedIds, levelId, nodes],
|
|
80
82
|
)
|
|
81
83
|
|
|
82
|
-
// Gate on the explicit selection (so a single connected wall still gets the
|
|
83
|
-
// per-node handles), but transform the full connected wall/fence component so
|
|
84
|
-
// attached structure rotates rigidly as one piece.
|
|
85
|
-
const fullIds = useMemo(
|
|
86
|
-
() => expandToComponent(participantIds, nodes, levelId),
|
|
87
|
-
[participantIds, levelId, nodes],
|
|
88
|
-
)
|
|
89
|
-
|
|
90
84
|
const shouldRender =
|
|
91
85
|
participantIds.length >= 2 &&
|
|
92
86
|
mode !== 'delete' &&
|
|
@@ -98,7 +92,13 @@ export function GroupRotateHandle() {
|
|
|
98
92
|
|
|
99
93
|
if (!shouldRender) return null
|
|
100
94
|
// Remount when the moving set changes so the rest pivot re-seeds cleanly.
|
|
101
|
-
return
|
|
95
|
+
return (
|
|
96
|
+
<GroupRotateHandleInner
|
|
97
|
+
ids={participantIds}
|
|
98
|
+
key={participantIds.join(',')}
|
|
99
|
+
meshEpoch={meshEpoch}
|
|
100
|
+
/>
|
|
101
|
+
)
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
|
|
@@ -125,22 +125,28 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
125
125
|
const baseScale = zoom * ARROW_SCALE * 1.05
|
|
126
126
|
const scale = (isHovered ? 1.12 : 1) * baseScale
|
|
127
127
|
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
// - `
|
|
133
|
-
// - `corner` = front-right bbox corner at mid-height → where the gizmo sits
|
|
128
|
+
// Both rest points come from the level-frame box (the dashed boxes' box, also
|
|
129
|
+
// keyboard R/T's pivot), carried to world so they stay on it under a rotated
|
|
130
|
+
// building; the world mesh box only supplies heights.
|
|
131
|
+
// - `pivot` = box center, Y at the group's base → the rotation origin
|
|
132
|
+
// - `corner` = far box corner at mid-height → where the gizmo sits
|
|
134
133
|
const rest = useMemo(() => {
|
|
135
134
|
void meshEpoch
|
|
136
135
|
const box = computeGroupBox(ids)
|
|
137
136
|
if (!box) return null
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
const levelId = useViewer.getState().selection.levelId
|
|
138
|
+
const { matrix } = levelFrame(levelId)
|
|
139
|
+
const plan = computeGroupPlanBox(ids, levelId)
|
|
140
|
+
const pivot = plan
|
|
141
|
+
? new Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4(
|
|
142
|
+
matrix,
|
|
143
|
+
)
|
|
144
|
+
: new Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2)
|
|
145
|
+
pivot.y = box.min.y
|
|
146
|
+
const corner = plan
|
|
147
|
+
? new Vector3(plan.maxX + CORNER_OFFSET, 0, plan.maxZ + CORNER_OFFSET).applyMatrix4(matrix)
|
|
148
|
+
: new Vector3(box.max.x + CORNER_OFFSET, 0, box.max.z + CORNER_OFFSET)
|
|
149
|
+
corner.y = (box.min.y + box.max.y) / 2
|
|
144
150
|
return { pivot, corner }
|
|
145
151
|
}, [ids, meshEpoch])
|
|
146
152
|
|
|
@@ -163,6 +169,12 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
163
169
|
if (event.button !== 0) return
|
|
164
170
|
event.stopPropagation()
|
|
165
171
|
suppressBoxSelectForPointer(event)
|
|
172
|
+
const spatialPointerId = getSpatialPointerId(event.nativeEvent)
|
|
173
|
+
const spatialRay = spatialPointerId ? event.ray.clone() : null
|
|
174
|
+
const pointerTarget = event.object as typeof event.object & {
|
|
175
|
+
releasePointerCapture?: (pointerId: number) => void
|
|
176
|
+
setPointerCapture?: (pointerId: number) => void
|
|
177
|
+
}
|
|
166
178
|
|
|
167
179
|
frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
|
|
168
180
|
const center = rest.pivot.clone()
|
|
@@ -213,11 +225,15 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
213
225
|
)
|
|
214
226
|
}
|
|
215
227
|
|
|
216
|
-
|
|
217
|
-
|
|
228
|
+
if (!spatialRay) {
|
|
229
|
+
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
|
|
230
|
+
raycaster.setFromCamera(ndc, camera)
|
|
231
|
+
}
|
|
218
232
|
const hit = new Vector3()
|
|
219
|
-
if (!raycaster.ray
|
|
233
|
+
if (!intersectSpatialDragPlane(spatialRay ?? raycaster.ray, plane, hit)) return
|
|
220
234
|
const initialAngle = angleOf(hit)
|
|
235
|
+
if (spatialPointerId) pointerTarget.setPointerCapture?.(event.pointerId)
|
|
236
|
+
let altKey = event.nativeEvent.altKey
|
|
221
237
|
|
|
222
238
|
document.body.style.cursor = 'grabbing'
|
|
223
239
|
sfxEmitter.emit('sfx:item-pick')
|
|
@@ -230,15 +246,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
230
246
|
})
|
|
231
247
|
setIsDragging(true)
|
|
232
248
|
|
|
233
|
-
const
|
|
234
|
-
setNDC(e.clientX, e.clientY)
|
|
235
|
-
raycaster.setFromCamera(ndc, camera)
|
|
249
|
+
const applyRay = (ray: Ray, freeRotation = false) => {
|
|
236
250
|
const moveHit = new Vector3()
|
|
237
|
-
if (!
|
|
251
|
+
if (!intersectSpatialDragPlane(ray, plane, moveHit)) return
|
|
238
252
|
let delta = angleOf(moveHit) - initialAngle
|
|
239
253
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
240
254
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
241
|
-
if (!
|
|
255
|
+
if (!freeRotation && isAngleSnapActive())
|
|
256
|
+
delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
|
242
257
|
|
|
243
258
|
// Shared rigid-rotation math (also used by the keyboard group R/T);
|
|
244
259
|
// see `rotateGroupPatches` for the orbit/yaw handedness contract.
|
|
@@ -286,8 +301,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
286
301
|
})
|
|
287
302
|
}
|
|
288
303
|
}
|
|
304
|
+
const onMove = (e: PointerEvent) => {
|
|
305
|
+
setNDC(e.clientX, e.clientY)
|
|
306
|
+
raycaster.setFromCamera(ndc, camera)
|
|
307
|
+
applyRay(raycaster.ray, e.altKey)
|
|
308
|
+
}
|
|
289
309
|
|
|
290
310
|
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
311
|
+
let releaseSpatialCapture: (() => void) | null = null
|
|
291
312
|
const clearLivePreviews = () => {
|
|
292
313
|
const overrides = useLiveNodeOverrides.getState()
|
|
293
314
|
const liveTransforms = useLiveTransforms.getState()
|
|
@@ -303,6 +324,10 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
303
324
|
window.removeEventListener('pointerup', onUp)
|
|
304
325
|
window.removeEventListener('pointercancel', onCancel)
|
|
305
326
|
window.removeEventListener('keydown', onKeyDown, true)
|
|
327
|
+
window.removeEventListener('keyup', onKeyUp, true)
|
|
328
|
+
releaseSpatialCapture?.()
|
|
329
|
+
releaseSpatialCapture = null
|
|
330
|
+
if (spatialPointerId) pointerTarget.releasePointerCapture?.(event.pointerId)
|
|
306
331
|
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
307
332
|
useScene.temporal.getState().resume()
|
|
308
333
|
useViewer.getState().setInputDragging(false)
|
|
@@ -352,12 +377,19 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
352
377
|
// Escape / ⌘Z abort the rotate — capture phase so they win over the global
|
|
353
378
|
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
|
|
354
379
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
380
|
+
if (e.key === 'Alt') {
|
|
381
|
+
altKey = true
|
|
382
|
+
return
|
|
383
|
+
}
|
|
355
384
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
356
385
|
e.preventDefault()
|
|
357
386
|
e.stopPropagation()
|
|
358
387
|
swallowNextClick()
|
|
359
388
|
onCancel()
|
|
360
389
|
}
|
|
390
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
391
|
+
if (e.key === 'Alt') altKey = false
|
|
392
|
+
}
|
|
361
393
|
|
|
362
394
|
dragCleanupRef.current = () => {
|
|
363
395
|
clearLivePreviews()
|
|
@@ -366,10 +398,22 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
366
398
|
for (const id of affectedIds) {
|
|
367
399
|
useLiveTransforms.getState().clear(id)
|
|
368
400
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
401
|
+
if (spatialPointerId && spatialRay) {
|
|
402
|
+
releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, {
|
|
403
|
+
onMove: (ray) => {
|
|
404
|
+
spatialRay.copy(ray)
|
|
405
|
+
applyRay(spatialRay, altKey)
|
|
406
|
+
},
|
|
407
|
+
onRelease: onUp,
|
|
408
|
+
onCancel,
|
|
409
|
+
})
|
|
410
|
+
} else {
|
|
411
|
+
window.addEventListener('pointermove', onMove)
|
|
412
|
+
window.addEventListener('pointerup', onUp)
|
|
413
|
+
window.addEventListener('pointercancel', onCancel)
|
|
414
|
+
}
|
|
372
415
|
window.addEventListener('keydown', onKeyDown, true)
|
|
416
|
+
window.addEventListener('keyup', onKeyUp, true)
|
|
373
417
|
}
|
|
374
418
|
|
|
375
419
|
return createPortal(
|
|
@@ -398,6 +442,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
398
442
|
onPointerDown={activate}
|
|
399
443
|
onPointerEnter={onHoverEnter}
|
|
400
444
|
onPointerLeave={onHoverLeave}
|
|
445
|
+
{...({ pointerEventsOrder: 10 } as Record<string, unknown>)}
|
|
401
446
|
scale={baseScale}
|
|
402
447
|
/>
|
|
403
448
|
<mesh
|
|
@@ -407,6 +452,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
407
452
|
onPointerDown={activate}
|
|
408
453
|
onPointerEnter={onHoverEnter}
|
|
409
454
|
onPointerLeave={onHoverLeave}
|
|
455
|
+
{...({ pointerEventsOrder: 10 } as Record<string, unknown>)}
|
|
410
456
|
renderOrder={1010}
|
|
411
457
|
scale={scale}
|
|
412
458
|
/>
|
|
@@ -12,7 +12,7 @@ import { armGroupMove3d } from './group-move-3d'
|
|
|
12
12
|
import {
|
|
13
13
|
classifyParticipant,
|
|
14
14
|
computeGroupBox,
|
|
15
|
-
|
|
15
|
+
computeGroupPlanBox,
|
|
16
16
|
levelFrame,
|
|
17
17
|
} from './group-transform-shared'
|
|
18
18
|
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
|
|
@@ -24,8 +24,8 @@ const BOX_PAD = 0.06
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* 3D sibling of the 2D dashed group selection box: a dashed wireframe around
|
|
27
|
-
* the multi-selection's transformable participants
|
|
28
|
-
*
|
|
27
|
+
* the multi-selection's transformable participants that doubles as the
|
|
28
|
+
* group's whole-volume drag
|
|
29
29
|
* handle — move cursor across it, press-drag anywhere on it slides the group,
|
|
30
30
|
* a plain click picks it up. Holding a selection modifier passes the press
|
|
31
31
|
* through so members inside can still be toggled. Rides the live drag delta
|
|
@@ -55,22 +55,30 @@ export function GroupSelectionBox3D() {
|
|
|
55
55
|
const box = useMemo(() => {
|
|
56
56
|
void meshEpoch
|
|
57
57
|
if (participantIds.length === 0) return null
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
// Aligned with the building like the 2D box: level-frame footprint, world
|
|
59
|
+
// height span, turned by the level's rotation.
|
|
60
|
+
const world = computeGroupBox(participantIds)
|
|
61
|
+
const { matrix } = levelFrame(levelId)
|
|
62
|
+
const plan = computeGroupPlanBox(participantIds, levelId)
|
|
63
|
+
if (!(world && plan)) return null
|
|
64
|
+
const center = new THREE.Vector3(
|
|
65
|
+
(plan.minX + plan.maxX) / 2,
|
|
66
|
+
0,
|
|
67
|
+
(plan.minZ + plan.maxZ) / 2,
|
|
68
|
+
).applyMatrix4(matrix)
|
|
69
|
+
center.y = (world.min.y + world.max.y) / 2
|
|
70
|
+
const quaternion = new THREE.Quaternion()
|
|
71
|
+
matrix.decompose(new THREE.Vector3(), quaternion, new THREE.Vector3())
|
|
65
72
|
return {
|
|
66
|
-
size: [
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
],
|
|
73
|
+
size: [
|
|
74
|
+
plan.maxX - plan.minX + 2 * BOX_PAD,
|
|
75
|
+
world.max.y - world.min.y + 2 * BOX_PAD,
|
|
76
|
+
plan.maxZ - plan.minZ + 2 * BOX_PAD,
|
|
77
|
+
] as [number, number, number],
|
|
71
78
|
center,
|
|
79
|
+
quaternion,
|
|
72
80
|
}
|
|
73
|
-
}, [participantIds,
|
|
81
|
+
}, [participantIds, levelId, meshEpoch])
|
|
74
82
|
|
|
75
83
|
// Dashed wireframe. Built per box size (rare — selection / commit changes)
|
|
76
84
|
// because LineDashedMaterial measures dashes along the line, so scaling a
|
|
@@ -156,7 +164,7 @@ export function GroupSelectionBox3D() {
|
|
|
156
164
|
]
|
|
157
165
|
|
|
158
166
|
return (
|
|
159
|
-
<group position={position}>
|
|
167
|
+
<group position={position} quaternion={box.quaternion}>
|
|
160
168
|
<primitive object={edges} />
|
|
161
169
|
{/* Invisible whole-volume hit target — the box IS the drag handle. */}
|
|
162
170
|
<mesh
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeDefinition,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
CeilingNode,
|
|
7
|
+
LevelNode,
|
|
8
|
+
nodeRegistry,
|
|
9
|
+
registerNode,
|
|
10
|
+
SlabNode,
|
|
11
|
+
sceneRegistry,
|
|
12
|
+
useScene,
|
|
13
|
+
WallNode,
|
|
14
|
+
} from '@pascal-app/core'
|
|
15
|
+
import { BoxGeometry, Group, Mesh } from 'three'
|
|
3
16
|
import { z } from 'zod'
|
|
4
17
|
import {
|
|
5
18
|
classifyParticipant,
|
|
6
19
|
collectParticipants,
|
|
20
|
+
computeGroupPlanBox,
|
|
21
|
+
groupPlanBounds,
|
|
7
22
|
planBoundsCenter,
|
|
8
23
|
rotateGroupPatches,
|
|
9
24
|
rotateGroupSnapshots,
|
|
@@ -472,3 +487,113 @@ describe('group transform participants', () => {
|
|
|
472
487
|
])
|
|
473
488
|
})
|
|
474
489
|
})
|
|
490
|
+
|
|
491
|
+
describe('group plan bounds', () => {
|
|
492
|
+
const withNodes = (nodes: AnyNode[], run: () => void) => {
|
|
493
|
+
const saved = useScene.getState().nodes
|
|
494
|
+
useScene.setState({ nodes: Object.fromEntries(nodes.map((n) => [n.id, n])) as typeof saved })
|
|
495
|
+
try {
|
|
496
|
+
run()
|
|
497
|
+
} finally {
|
|
498
|
+
useScene.setState({ nodes: saved })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
test('a placed object measures its built mesh in the level frame, or falls back to its anchor', () => {
|
|
503
|
+
const level = new Group()
|
|
504
|
+
level.position.set(10, 0, -4)
|
|
505
|
+
level.rotation.y = Math.PI / 5
|
|
506
|
+
const item = new Mesh(new BoxGeometry(1, 1, 1))
|
|
507
|
+
item.position.set(2, 0.5, 0)
|
|
508
|
+
level.add(item)
|
|
509
|
+
level.updateWorldMatrix(true, true)
|
|
510
|
+
const registry = sceneRegistry.nodes as unknown as Map<string, unknown>
|
|
511
|
+
registry.set('item_plan_bounds_test', item)
|
|
512
|
+
const start = {
|
|
513
|
+
id: 'item_plan_bounds_test' as AnyNodeId,
|
|
514
|
+
kind: 'vec3' as const,
|
|
515
|
+
position: [2, 0, 0] as [number, number, number],
|
|
516
|
+
rotation: [0, 0, 0] as [number, number, number],
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
const frameInv = level.matrixWorld.clone().invert()
|
|
520
|
+
const built = groupPlanBounds([start], frameInv)
|
|
521
|
+
expect(built?.minX).toBeCloseTo(1.5)
|
|
522
|
+
expect(built?.maxX).toBeCloseTo(2.5)
|
|
523
|
+
expect(built?.minZ).toBeCloseTo(-0.5)
|
|
524
|
+
expect(built?.maxZ).toBeCloseTo(0.5)
|
|
525
|
+
// Unbuilt while the 3D scene is paused: the placeholder must not count.
|
|
526
|
+
item.geometry.userData.placeholder = true
|
|
527
|
+
expect(groupPlanBounds([start], frameInv)).toEqual({ minX: 2, minZ: 0, maxX: 2, maxZ: 0 })
|
|
528
|
+
} finally {
|
|
529
|
+
registry.delete('item_plan_bounds_test')
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
test('hugs the selected wall outlines; a connected wall stays outside', () => {
|
|
534
|
+
const level = LevelNode.parse({ children: [] })
|
|
535
|
+
const bottom = WallNode.parse({
|
|
536
|
+
parentId: level.id,
|
|
537
|
+
start: [0, 0],
|
|
538
|
+
end: [2.4, 0],
|
|
539
|
+
thickness: 0.2,
|
|
540
|
+
})
|
|
541
|
+
const side = WallNode.parse({
|
|
542
|
+
parentId: level.id,
|
|
543
|
+
start: [2.4, 0],
|
|
544
|
+
end: [2.4, 5.3],
|
|
545
|
+
thickness: 0.2,
|
|
546
|
+
})
|
|
547
|
+
const neighbour = WallNode.parse({
|
|
548
|
+
parentId: level.id,
|
|
549
|
+
start: [2.4, 5.3],
|
|
550
|
+
end: [8, 5.3],
|
|
551
|
+
thickness: 0.2,
|
|
552
|
+
})
|
|
553
|
+
withNodes([level, bottom, side, neighbour], () => {
|
|
554
|
+
const plan = computeGroupPlanBox([bottom.id, side.id], level.id)
|
|
555
|
+
expect(plan?.minX).toBeCloseTo(0)
|
|
556
|
+
expect(plan?.maxX).toBeCloseTo(2.5)
|
|
557
|
+
expect(plan?.minZ).toBeCloseTo(-0.1)
|
|
558
|
+
expect(plan?.maxZ).toBeLessThan(5.5)
|
|
559
|
+
expect(plan?.maxX).toBeLessThan(8)
|
|
560
|
+
})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('two walls with the room floor and ceiling: the box is the drawn outlines, never the meshes', () => {
|
|
564
|
+
// A user's selection whose box came out 13 m wide in 2D-only view.
|
|
565
|
+
const level = LevelNode.parse({ id: 'level_vvkg048zziz9ktiy', children: [] })
|
|
566
|
+
const room: [number, number][] = [
|
|
567
|
+
[-3.6221682640435353, -9.037533979512114],
|
|
568
|
+
[-6.21504662384804, -8.29671159099654],
|
|
569
|
+
[-5.712152486161543, -6.5365821090938],
|
|
570
|
+
[-5.712152486161543, -5],
|
|
571
|
+
[-8.223564365687542, -5],
|
|
572
|
+
[-8.223564365687542, -6.5365821090938],
|
|
573
|
+
[-10.712152486161543, -6.5365821090938],
|
|
574
|
+
[-11.712152486161543, -8.5365821090938],
|
|
575
|
+
[-4.119274126357038, -10.777404497609373],
|
|
576
|
+
]
|
|
577
|
+
const wall15 = WallNode.parse({
|
|
578
|
+
parentId: level.id,
|
|
579
|
+
start: [-10.712152486161543, -6.5365821090938],
|
|
580
|
+
end: [-11.712152486161543, -8.5365821090938],
|
|
581
|
+
})
|
|
582
|
+
const wall12 = WallNode.parse({
|
|
583
|
+
parentId: level.id,
|
|
584
|
+
start: [-11.712152486161543, -8.5365821090938],
|
|
585
|
+
end: [-6.712152486161543, -10.0365821090938],
|
|
586
|
+
})
|
|
587
|
+
const slab = SlabNode.parse({ parentId: level.id, polygon: room, elevation: 0.05 })
|
|
588
|
+
const ceiling = CeilingNode.parse({ parentId: level.id, polygon: room, height: 2.49 })
|
|
589
|
+
withNodes([level, wall15, wall12, slab, ceiling], () => {
|
|
590
|
+
const plan = computeGroupPlanBox([wall15.id, ceiling.id, slab.id, wall12.id], level.id)!
|
|
591
|
+
expect(plan.minX).toBeLessThan(-11.71)
|
|
592
|
+
expect(plan.minX).toBeGreaterThan(-11.95)
|
|
593
|
+
expect(plan.maxX).toBeCloseTo(-3.62, 1)
|
|
594
|
+
expect(plan.minZ).toBeCloseTo(-10.78, 1)
|
|
595
|
+
expect(plan.maxZ).toBeCloseTo(-5, 1)
|
|
596
|
+
expect(plan.maxX - plan.minX).toBeLessThan(8.4)
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
})
|