@pascal-app/editor 0.9.2 → 1.0.0-beta.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 +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -3,11 +3,13 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
bboxCornerAnchors,
|
|
5
5
|
collectAlignmentAnchors,
|
|
6
|
+
emitter,
|
|
6
7
|
pauseSceneHistory,
|
|
7
8
|
pauseSpaceDetection,
|
|
8
9
|
resolveAlignment,
|
|
9
10
|
resumeSceneHistory,
|
|
10
11
|
resumeSpaceDetection,
|
|
12
|
+
type SceneMaterialId,
|
|
11
13
|
useLiveNodeOverrides,
|
|
12
14
|
useLiveTransforms,
|
|
13
15
|
useScene,
|
|
@@ -16,11 +18,18 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
16
18
|
import { Plane, Vector2, Vector3 } from 'three'
|
|
17
19
|
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
18
20
|
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
+
import {
|
|
22
|
+
copySelectedNodesToEditorClipboard,
|
|
23
|
+
duplicateNodesToLevel,
|
|
24
|
+
getEditorClipboardSnapshot,
|
|
25
|
+
pasteSystemEditorClipboardToLevel,
|
|
26
|
+
} from '../../lib/scene-clipboard'
|
|
27
|
+
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
|
21
28
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
29
|
+
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
|
22
30
|
import useEditor, {
|
|
23
31
|
isAlignmentGuideActive,
|
|
32
|
+
isBrushMode,
|
|
24
33
|
isGridSnapActive,
|
|
25
34
|
isMagneticSnapActive,
|
|
26
35
|
} from '../../store/use-editor'
|
|
@@ -79,7 +88,7 @@ export function canGroupPickUp(): boolean {
|
|
|
79
88
|
* and drag them along with the copies.
|
|
80
89
|
*/
|
|
81
90
|
export function startGroupPickUp(
|
|
82
|
-
opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
|
|
91
|
+
opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
|
|
83
92
|
): boolean {
|
|
84
93
|
const { selectedIds, levelId } = useViewer.getState().selection
|
|
85
94
|
const participantIds = groupParticipantIds()
|
|
@@ -202,16 +211,18 @@ export function startGroupPickUp(
|
|
|
202
211
|
const applyMove = (e: PointerEvent) => {
|
|
203
212
|
const plan = resolvePlanPoint(e)
|
|
204
213
|
if (!plan) return
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
if (!startPlan) {
|
|
214
|
+
// Ordinary moves are delta-relative so the group never teleports. A
|
|
215
|
+
// pasted selection instead arrives centered under the cursor.
|
|
216
|
+
if (!opts.positionAtCursor && !startPlan) {
|
|
208
217
|
startPlan = plan
|
|
209
218
|
return
|
|
210
219
|
}
|
|
211
220
|
const step = useEditor.getState().gridSnapStep
|
|
212
221
|
const snap = isGridSnapActive() && step > 0
|
|
213
|
-
|
|
214
|
-
|
|
222
|
+
const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
|
|
223
|
+
const rawDz = opts.positionAtCursor ? plan[1] - restCenter[1] : plan[1] - startPlan![1]
|
|
224
|
+
let dx = snap ? Math.round(rawDx / step) * step : rawDx
|
|
225
|
+
let dz = snap ? Math.round(rawDz / step) * step : rawDz
|
|
215
226
|
|
|
216
227
|
if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
|
|
217
228
|
const result = resolveAlignment({
|
|
@@ -361,6 +372,7 @@ export function startGroupPickUp(
|
|
|
361
372
|
// Only a press over a tracked surface commits; a click on side panels or
|
|
362
373
|
// the toolbar keeps the pick-up alive.
|
|
363
374
|
if (!resolvePlanPoint(e)) return
|
|
375
|
+
if (opts.positionAtCursor && !lastDelta) applyMove(e)
|
|
364
376
|
e.preventDefault()
|
|
365
377
|
e.stopPropagation()
|
|
366
378
|
commitPointerId = e.pointerId
|
|
@@ -380,6 +392,17 @@ export function startGroupPickUp(
|
|
|
380
392
|
|
|
381
393
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
382
394
|
const key = e.key.toLowerCase()
|
|
395
|
+
if ((e.metaKey || e.ctrlKey) && (key === 'c' || key === 'v' || key === 'x')) {
|
|
396
|
+
// A clipboard chord replaces the current carry. Let the global keyboard
|
|
397
|
+
// arm receive the same event after this cancellation. Capture C/X first:
|
|
398
|
+
// pasted or duplicated carries delete their transient selection while
|
|
399
|
+
// cancelling, so the global arm would otherwise see nothing.
|
|
400
|
+
if (key === 'c' || key === 'x') {
|
|
401
|
+
copySelectedNodesToEditorClipboard()
|
|
402
|
+
}
|
|
403
|
+
cancel()
|
|
404
|
+
return
|
|
405
|
+
}
|
|
383
406
|
if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
|
384
407
|
e.preventDefault()
|
|
385
408
|
e.stopPropagation()
|
|
@@ -437,6 +460,112 @@ export function duplicateSelectionAndPickUp(): boolean {
|
|
|
437
460
|
return true
|
|
438
461
|
}
|
|
439
462
|
|
|
463
|
+
function sceneReferencesMaterial(materialId: string) {
|
|
464
|
+
const reference = `scene:${materialId}`
|
|
465
|
+
const containsReference = (value: unknown): boolean => {
|
|
466
|
+
if (value === reference) return true
|
|
467
|
+
if (Array.isArray(value)) return value.some(containsReference)
|
|
468
|
+
if (value && typeof value === 'object') {
|
|
469
|
+
return Object.values(value).some(containsReference)
|
|
470
|
+
}
|
|
471
|
+
return false
|
|
472
|
+
}
|
|
473
|
+
return Object.values(useScene.getState().nodes).some(containsReference)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function removeUnusedPasteMaterials(materialIds: SceneMaterialId[]) {
|
|
477
|
+
for (const materialId of materialIds) {
|
|
478
|
+
if (!sceneReferencesMaterial(materialId)) {
|
|
479
|
+
useScene.getState().removeSceneMaterial(materialId)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Paste the Pascal scene payload from the browser clipboard onto the active
|
|
486
|
+
* level, then carry the clones under the cursor until click-to-place. Escape
|
|
487
|
+
* removes the uncommitted clones and any scene materials imported with them.
|
|
488
|
+
*/
|
|
489
|
+
export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promise<boolean> {
|
|
490
|
+
const activeScope = useInteractionScope.getState().scope
|
|
491
|
+
if (activeScope.kind === 'placing' || activeScope.kind === 'moving') {
|
|
492
|
+
emitter.emit('tool:cancel')
|
|
493
|
+
}
|
|
494
|
+
// Paint and sculpt hold their scope for the whole *mode*, so a pick-up would
|
|
495
|
+
// `begin({kind: 'handle-drag'})` over it — scopes are single-owner, so the brush
|
|
496
|
+
// would stay armed and painting while the editor believed a group move was
|
|
497
|
+
// running, and the mode's own release would then end someone else's scope.
|
|
498
|
+
// ⌘V is reachable from either mode (it reads the clipboard, not the selection,
|
|
499
|
+
// which is why clearing the selection on mode entry does not cover it), so drop
|
|
500
|
+
// the brush first: carrying clones is the newer intent, and unlike the cancels
|
|
501
|
+
// above there is nothing to abandon.
|
|
502
|
+
if (isBrushMode(useEditor.getState().mode)) useEditor.getState().setMode('select')
|
|
503
|
+
|
|
504
|
+
const result = await pasteSystemEditorClipboardToLevel(targetLevelId)
|
|
505
|
+
if (!result || result.pastedIds.length === 0) return false
|
|
506
|
+
|
|
507
|
+
const discardPaste = () => {
|
|
508
|
+
useScene.getState().deleteNodes(result.pastedIds)
|
|
509
|
+
removeUnusedPasteMaterials(result.createdMaterialIds)
|
|
510
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
511
|
+
}
|
|
512
|
+
if (result.pastedIds.length === 1) {
|
|
513
|
+
const rootId = result.pastedIds[0]!
|
|
514
|
+
const root = useScene.getState().nodes[rootId]
|
|
515
|
+
if (root?.type === 'door' || root?.type === 'window') {
|
|
516
|
+
const metadata =
|
|
517
|
+
root.metadata && typeof root.metadata === 'object' && !Array.isArray(root.metadata)
|
|
518
|
+
? (root.metadata as Record<string, unknown>)
|
|
519
|
+
: {}
|
|
520
|
+
const draft = { ...root, metadata: { ...metadata, isNew: true } }
|
|
521
|
+
useScene.getState().updateNode(rootId, { metadata: draft.metadata })
|
|
522
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
523
|
+
const unsubscribe = useInteractionScope.subscribe((state, previous) => {
|
|
524
|
+
const previousOwnsDraft =
|
|
525
|
+
(previous.scope.kind === 'placing' || previous.scope.kind === 'moving') &&
|
|
526
|
+
previous.scope.nodeId === rootId
|
|
527
|
+
const currentOwnsDraft =
|
|
528
|
+
(state.scope.kind === 'placing' || state.scope.kind === 'moving') &&
|
|
529
|
+
state.scope.nodeId === rootId
|
|
530
|
+
if (!previousOwnsDraft || currentOwnsDraft) return
|
|
531
|
+
unsubscribe()
|
|
532
|
+
removeUnusedPasteMaterials(result.createdMaterialIds)
|
|
533
|
+
})
|
|
534
|
+
useEditor.getState().setMovingNode(draft)
|
|
535
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
536
|
+
return true
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const started = startGroupPickUp({
|
|
541
|
+
positionAtCursor: true,
|
|
542
|
+
scopeToSelection: true,
|
|
543
|
+
onCancel: discardPaste,
|
|
544
|
+
})
|
|
545
|
+
if (!started) sfxEmitter.emit('sfx:item-place')
|
|
546
|
+
return true
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Cut uses the same cross-tab clipboard payload as Copy, then removes exactly
|
|
551
|
+
* the copied roots. Promoted subtree selections (such as all modules in one
|
|
552
|
+
* cabinet run) therefore remove the same root that Paste will recreate.
|
|
553
|
+
*/
|
|
554
|
+
export function cutSelectionToEditorClipboard(): boolean {
|
|
555
|
+
if (!copySelectedNodesToEditorClipboard()) return false
|
|
556
|
+
const payload = getEditorClipboardSnapshot()
|
|
557
|
+
if (!payload || payload.rootIds.length === 0) return false
|
|
558
|
+
|
|
559
|
+
if (payload.rootIds.length === 1) {
|
|
560
|
+
emitDeleteSFX(useScene.getState().nodes[payload.rootIds[0]!]?.type)
|
|
561
|
+
} else {
|
|
562
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
563
|
+
}
|
|
564
|
+
useScene.getState().deleteNodes(payload.rootIds)
|
|
565
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
566
|
+
return true
|
|
567
|
+
}
|
|
568
|
+
|
|
440
569
|
/**
|
|
441
570
|
* Delete every selected node — same semantics as the keyboard Delete arm,
|
|
442
571
|
* including the accidental-bulk-delete confirm.
|
|
@@ -444,14 +573,25 @@ export function duplicateSelectionAndPickUp(): boolean {
|
|
|
444
573
|
export function deleteSelection(): boolean {
|
|
445
574
|
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
446
575
|
if (selectedIds.length === 0) return false
|
|
576
|
+
|
|
577
|
+
const commitDelete = () => {
|
|
578
|
+
if (selectedIds.length === 1) {
|
|
579
|
+
emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
|
|
580
|
+
} else {
|
|
581
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
582
|
+
}
|
|
583
|
+
useScene.getState().deleteNodes(selectedIds)
|
|
584
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
585
|
+
}
|
|
586
|
+
|
|
447
587
|
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
588
|
+
useDeleteConfirmation.getState().requestConfirmation({
|
|
589
|
+
count: selectedIds.length,
|
|
590
|
+
onConfirm: commitDelete,
|
|
591
|
+
})
|
|
592
|
+
return true
|
|
452
593
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
useViewer.getState().setSelection({ selectedIds: [] })
|
|
594
|
+
|
|
595
|
+
commitDelete()
|
|
456
596
|
return true
|
|
457
597
|
}
|
|
@@ -60,6 +60,7 @@ export function GroupFloatingActionMenu() {
|
|
|
60
60
|
// memo keyed on selection + nodes is enough, no per-frame box traversal.
|
|
61
61
|
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
62
62
|
const anchor = useMemo(() => {
|
|
63
|
+
void meshEpoch
|
|
63
64
|
if (participantIds.length === 0) return null
|
|
64
65
|
const fullIds = expandToComponent(participantIds, nodes, levelId)
|
|
65
66
|
const box = computeGroupBox(fullIds)
|
|
@@ -69,7 +70,6 @@ export function GroupFloatingActionMenu() {
|
|
|
69
70
|
box.max.y + MENU_Y_OFFSET,
|
|
70
71
|
(box.min.z + box.max.z) / 2,
|
|
71
72
|
)
|
|
72
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
73
73
|
}, [participantIds, nodes, levelId, meshEpoch])
|
|
74
74
|
|
|
75
75
|
useFrame((state) => {
|
|
@@ -132,6 +132,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
132
132
|
// - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
|
|
133
133
|
// - `corner` = front-right bbox corner at mid-height → where the gizmo sits
|
|
134
134
|
const rest = useMemo(() => {
|
|
135
|
+
void meshEpoch
|
|
135
136
|
const box = computeGroupBox(ids)
|
|
136
137
|
if (!box) return null
|
|
137
138
|
const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
|
|
@@ -141,7 +142,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
|
|
|
141
142
|
box.max.z + CORNER_OFFSET,
|
|
142
143
|
)
|
|
143
144
|
return { pivot, corner }
|
|
144
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
145
145
|
}, [ids, meshEpoch])
|
|
146
146
|
|
|
147
147
|
if (!rest) return null
|
|
@@ -53,6 +53,7 @@ export function GroupSelectionBox3D() {
|
|
|
53
53
|
// Re-measure once the meshes settle after a scene change (undo included).
|
|
54
54
|
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
55
55
|
const box = useMemo(() => {
|
|
56
|
+
void meshEpoch
|
|
56
57
|
if (participantIds.length === 0) return null
|
|
57
58
|
const fullIds = expandToComponent(participantIds, nodes, levelId)
|
|
58
59
|
const world = computeGroupBox(fullIds)
|
|
@@ -69,7 +70,6 @@ export function GroupSelectionBox3D() {
|
|
|
69
70
|
],
|
|
70
71
|
center,
|
|
71
72
|
}
|
|
72
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
73
73
|
}, [participantIds, nodes, levelId, meshEpoch])
|
|
74
74
|
|
|
75
75
|
// Dashed wireframe. Built per box size (rare — selection / commit changes)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
runAsSingleSceneHistoryStep,
|
|
6
|
+
useScene,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
import { commitHandleDragPatch } from './handle-drag-history'
|
|
9
|
+
|
|
10
|
+
type RafFn = (callback: (time: number) => void) => number
|
|
11
|
+
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
|
|
12
|
+
callback(0)
|
|
13
|
+
return 0
|
|
14
|
+
}
|
|
15
|
+
;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
|
|
16
|
+
|
|
17
|
+
const NODE_ID = 'shelf_handle-drag-history' as AnyNodeId
|
|
18
|
+
const COMPANION_NODE_ID = 'shelf_handle-drag-history-companion' as AnyNodeId
|
|
19
|
+
|
|
20
|
+
function shelf(depth: number, id = NODE_ID): AnyNode {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
type: 'shelf',
|
|
24
|
+
object: 'node',
|
|
25
|
+
parentId: null,
|
|
26
|
+
visible: true,
|
|
27
|
+
metadata: {},
|
|
28
|
+
children: [],
|
|
29
|
+
position: [0, 0, 0],
|
|
30
|
+
rotation: [0, 0, 0],
|
|
31
|
+
width: 1,
|
|
32
|
+
depth,
|
|
33
|
+
thickness: 0.04,
|
|
34
|
+
height: 0.9,
|
|
35
|
+
style: 'wall-shelf',
|
|
36
|
+
rows: 1,
|
|
37
|
+
columns: 1,
|
|
38
|
+
withBack: false,
|
|
39
|
+
withSides: true,
|
|
40
|
+
withBottom: false,
|
|
41
|
+
bracketStyle: 'minimal',
|
|
42
|
+
} as AnyNode
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('commitHandleDragPatch', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
|
|
48
|
+
useScene.temporal.getState().clear()
|
|
49
|
+
useScene.temporal.getState().resume()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('records the final patch as one undo step after preview history is resumed', () => {
|
|
53
|
+
useScene.getState().createNode(shelf(0.3))
|
|
54
|
+
const pastCount = useScene.temporal.getState().pastStates.length
|
|
55
|
+
useScene.temporal.getState().pause()
|
|
56
|
+
|
|
57
|
+
commitHandleDragPatch({
|
|
58
|
+
patch: { depth: 0.7 },
|
|
59
|
+
resumeHistory: () => useScene.temporal.getState().resume(),
|
|
60
|
+
runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
|
|
61
|
+
commit: (patch) => useScene.getState().updateNode(NODE_ID, patch),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(pastCount + 1)
|
|
65
|
+
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
|
|
66
|
+
|
|
67
|
+
useScene.temporal.getState().undo()
|
|
68
|
+
expect(useScene.getState().nodes[NODE_ID]).toBeDefined()
|
|
69
|
+
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('records a composite multi-node commit as one undo step', () => {
|
|
73
|
+
useScene.getState().createNode(shelf(0.3))
|
|
74
|
+
useScene.getState().createNode(shelf(0.4, COMPANION_NODE_ID))
|
|
75
|
+
useScene.temporal.getState().clear()
|
|
76
|
+
useScene.temporal.getState().pause()
|
|
77
|
+
|
|
78
|
+
commitHandleDragPatch({
|
|
79
|
+
patch: { selectedDepth: 0.7, companionDepth: 0.2 },
|
|
80
|
+
resumeHistory: () => useScene.temporal.getState().resume(),
|
|
81
|
+
runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
|
|
82
|
+
commit: ({ selectedDepth, companionDepth }) => {
|
|
83
|
+
useScene.getState().updateNode(NODE_ID, { depth: selectedDepth })
|
|
84
|
+
useScene.getState().updateNode(COMPANION_NODE_ID, { depth: companionDepth })
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
89
|
+
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
|
|
90
|
+
expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.2)
|
|
91
|
+
|
|
92
|
+
useScene.temporal.getState().undo()
|
|
93
|
+
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
|
|
94
|
+
expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.4)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function commitHandleDragPatch<T>({
|
|
2
|
+
commit,
|
|
3
|
+
patch,
|
|
4
|
+
resumeHistory,
|
|
5
|
+
runAsSingleHistoryStep,
|
|
6
|
+
}: {
|
|
7
|
+
commit: (patch: T) => void
|
|
8
|
+
patch: T
|
|
9
|
+
resumeHistory: () => void
|
|
10
|
+
runAsSingleHistoryStep: (run: () => void) => void
|
|
11
|
+
}) {
|
|
12
|
+
resumeHistory()
|
|
13
|
+
runAsSingleHistoryStep(() => commit(patch))
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import type { AnyNodeId } from '@pascal-app/core'
|
|
3
|
+
import { replacePreviewOverrideIds } from './preview-overrides'
|
|
4
|
+
|
|
5
|
+
const FIRST_ID = 'cabinet_first' as AnyNodeId
|
|
6
|
+
const SECOND_ID = 'cabinet_second' as AnyNodeId
|
|
7
|
+
const THIRD_ID = 'cabinet_third' as AnyNodeId
|
|
8
|
+
|
|
9
|
+
describe('replacePreviewOverrideIds', () => {
|
|
10
|
+
test('clears companion overrides that leave the active preview', () => {
|
|
11
|
+
const clear = mock(() => {})
|
|
12
|
+
|
|
13
|
+
const nextIds = replacePreviewOverrideIds(
|
|
14
|
+
new Set([FIRST_ID, SECOND_ID]),
|
|
15
|
+
[
|
|
16
|
+
[SECOND_ID, { width: 0.7 }],
|
|
17
|
+
[THIRD_ID, { width: 0.5 }],
|
|
18
|
+
],
|
|
19
|
+
clear,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(clear).toHaveBeenCalledTimes(1)
|
|
23
|
+
expect(clear).toHaveBeenCalledWith(FIRST_ID)
|
|
24
|
+
expect(nextIds).toEqual(new Set([SECOND_ID, THIRD_ID]))
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function replacePreviewOverrideIds(
|
|
4
|
+
activeIds: ReadonlySet<AnyNodeId>,
|
|
5
|
+
entries: ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>,
|
|
6
|
+
clear: (id: AnyNodeId) => void,
|
|
7
|
+
): Set<AnyNodeId> {
|
|
8
|
+
const nextIds = new Set(entries.map(([id]) => id))
|
|
9
|
+
for (const id of activeIds) {
|
|
10
|
+
if (!nextIds.has(id)) clear(id)
|
|
11
|
+
}
|
|
12
|
+
return nextIds
|
|
13
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { resolveResizeSnapValue } from './resize-snap'
|
|
3
|
+
|
|
4
|
+
describe('resolveResizeSnapValue', () => {
|
|
5
|
+
it('applies only magnetic snapping in lines mode', () => {
|
|
6
|
+
const magneticSnap = mock(() => 0.6)
|
|
7
|
+
|
|
8
|
+
expect(
|
|
9
|
+
resolveResizeSnapValue({
|
|
10
|
+
rawValue: 0.59,
|
|
11
|
+
gridSnapEnabled: true,
|
|
12
|
+
gridSnapActive: false,
|
|
13
|
+
gridSnapStep: 0.1,
|
|
14
|
+
magneticSnapActive: true,
|
|
15
|
+
magneticSnap,
|
|
16
|
+
}),
|
|
17
|
+
).toBe(0.6)
|
|
18
|
+
expect(magneticSnap).toHaveBeenCalledWith(0.59)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('applies only grid snapping in grid mode', () => {
|
|
22
|
+
const magneticSnap = mock(() => 0.6)
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
resolveResizeSnapValue({
|
|
26
|
+
rawValue: 0.56,
|
|
27
|
+
gridSnapEnabled: true,
|
|
28
|
+
gridSnapActive: true,
|
|
29
|
+
gridSnapStep: 0.1,
|
|
30
|
+
magneticSnapActive: false,
|
|
31
|
+
magneticSnap,
|
|
32
|
+
}),
|
|
33
|
+
).toBeCloseTo(0.6)
|
|
34
|
+
expect(magneticSnap).not.toHaveBeenCalled()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('keeps the raw value in off mode', () => {
|
|
38
|
+
const magneticSnap = mock(() => 0.6)
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
resolveResizeSnapValue({
|
|
42
|
+
rawValue: 0.56,
|
|
43
|
+
gridSnapEnabled: true,
|
|
44
|
+
gridSnapActive: false,
|
|
45
|
+
gridSnapStep: 0.1,
|
|
46
|
+
magneticSnapActive: false,
|
|
47
|
+
magneticSnap,
|
|
48
|
+
}),
|
|
49
|
+
).toBe(0.56)
|
|
50
|
+
expect(magneticSnap).not.toHaveBeenCalled()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { snapScalar } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function resolveResizeSnapValue({
|
|
4
|
+
rawValue,
|
|
5
|
+
gridSnapEnabled,
|
|
6
|
+
gridSnapActive,
|
|
7
|
+
gridSnapStep,
|
|
8
|
+
magneticSnapActive,
|
|
9
|
+
magneticSnap,
|
|
10
|
+
}: {
|
|
11
|
+
rawValue: number
|
|
12
|
+
gridSnapEnabled: boolean
|
|
13
|
+
gridSnapActive: boolean
|
|
14
|
+
gridSnapStep: number
|
|
15
|
+
magneticSnapActive: boolean
|
|
16
|
+
magneticSnap?: (value: number) => number
|
|
17
|
+
}): number {
|
|
18
|
+
const gridValue =
|
|
19
|
+
gridSnapEnabled && gridSnapActive && gridSnapStep > 0
|
|
20
|
+
? snapScalar(rawValue, gridSnapStep)
|
|
21
|
+
: rawValue
|
|
22
|
+
return magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue
|
|
23
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type AnyNodeId,
|
|
6
6
|
type Cursor,
|
|
7
7
|
createSceneApi,
|
|
8
|
+
runAsSingleSceneHistoryStep,
|
|
8
9
|
useLiveNodeOverrides,
|
|
9
10
|
useScene,
|
|
10
11
|
} from '@pascal-app/core'
|
|
@@ -15,6 +16,7 @@ import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3
|
|
|
15
16
|
import { isHistoryShortcut } from '../../../lib/history'
|
|
16
17
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
17
18
|
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
|
19
|
+
import { commitHandleDragPatch } from './handle-drag-history'
|
|
18
20
|
|
|
19
21
|
export type HandleDragControls = {
|
|
20
22
|
onStart: (index: number, snapshot: AnyNode) => void
|
|
@@ -174,6 +176,13 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
174
176
|
session.onBegin?.()
|
|
175
177
|
|
|
176
178
|
let lastPatch: Partial<AnyNode> | null = null
|
|
179
|
+
let historyPaused = true
|
|
180
|
+
|
|
181
|
+
const resumeHistory = () => {
|
|
182
|
+
if (!historyPaused) return
|
|
183
|
+
historyPaused = false
|
|
184
|
+
useScene.temporal.getState().resume()
|
|
185
|
+
}
|
|
177
186
|
|
|
178
187
|
const onMove = (moveEvent: PointerEvent) => {
|
|
179
188
|
const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane })
|
|
@@ -193,7 +202,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
193
202
|
if (document.body.style.cursor === cursor) {
|
|
194
203
|
document.body.style.cursor = ''
|
|
195
204
|
}
|
|
196
|
-
|
|
205
|
+
resumeHistory()
|
|
197
206
|
useViewer.getState().setInputDragging(false)
|
|
198
207
|
setIsDragging(false)
|
|
199
208
|
session.onEnd?.()
|
|
@@ -212,11 +221,12 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
212
221
|
swallowNextClick()
|
|
213
222
|
sfxEmitter.emit('sfx:item-place')
|
|
214
223
|
if (lastPatch) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
commitHandleDragPatch({
|
|
225
|
+
patch: lastPatch,
|
|
226
|
+
resumeHistory,
|
|
227
|
+
runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
|
|
228
|
+
commit: session.commit ?? ((patch) => sceneApi.update(overrideId, patch)),
|
|
229
|
+
})
|
|
220
230
|
}
|
|
221
231
|
clearOverride()
|
|
222
232
|
cleanup()
|
|
@@ -237,7 +247,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
237
247
|
onCancel()
|
|
238
248
|
}
|
|
239
249
|
|
|
240
|
-
dragCleanupRef.current =
|
|
250
|
+
dragCleanupRef.current = onCancel
|
|
241
251
|
window.addEventListener('pointermove', onMove)
|
|
242
252
|
window.addEventListener('pointerup', onUp)
|
|
243
253
|
window.addEventListener('pointercancel', onCancel)
|