@pascal-app/editor 1.0.0-beta.5 → 1.0.0
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +13 -7
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +42 -51
- package/src/components/editor/floorplan-panel.tsx +76 -12
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +140 -21
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/selection-manager.tsx +104 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +284 -237
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +135 -2
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +9 -12
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +22 -5
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +129 -62
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +82 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +1 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -387,12 +387,11 @@ export function rotateGroupSnapshots(
|
|
|
387
387
|
return { starts: rotatedStarts, links: rotatedLinks }
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number }
|
|
391
|
+
|
|
390
392
|
// Level-frame XZ extents of the participant DATA — the mesh-free sibling of
|
|
391
|
-
// `computeGroupBox`, used when meshes aren't mounted yet
|
|
392
|
-
|
|
393
|
-
export function participantExtents(
|
|
394
|
-
starts: ParticipantStart[],
|
|
395
|
-
): { minX: number; minZ: number; maxX: number; maxZ: number } | null {
|
|
393
|
+
// `computeGroupBox`, used when meshes aren't mounted yet.
|
|
394
|
+
function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null {
|
|
396
395
|
let minX = Number.POSITIVE_INFINITY
|
|
397
396
|
let minZ = Number.POSITIVE_INFINITY
|
|
398
397
|
let maxX = Number.NEGATIVE_INFINITY
|
|
@@ -419,8 +418,73 @@ export function participantExtents(
|
|
|
419
418
|
return { minX, minZ, maxX, maxZ }
|
|
420
419
|
}
|
|
421
420
|
|
|
421
|
+
// The one footprint every group transform measures itself against: the
|
|
422
|
+
// selection's mounted meshes (world box, converted into the level frame) with
|
|
423
|
+
// the participant DATA extents as the fallback when the meshes aren't up yet
|
|
424
|
+
// (Duplicate picks up its clones a frame before their renderers mount). Anchor
|
|
425
|
+
// points alone sit metres inside a wide selection's real footprint, so a
|
|
426
|
+
// gesture that pivots on the data extents orbits a different point than the
|
|
427
|
+
// idle keyboard rotate and the rotate gizmos, which both use the mesh box.
|
|
428
|
+
export function groupPlanBounds(
|
|
429
|
+
box: Box3 | null,
|
|
430
|
+
starts: ParticipantStart[],
|
|
431
|
+
frameInv: Matrix4,
|
|
432
|
+
): GroupPlanBounds | null {
|
|
433
|
+
if (!box) return participantExtents(starts)
|
|
434
|
+
const min = box.min.clone().applyMatrix4(frameInv)
|
|
435
|
+
const max = box.max.clone().applyMatrix4(frameInv)
|
|
436
|
+
return {
|
|
437
|
+
minX: Math.min(min.x, max.x),
|
|
438
|
+
minZ: Math.min(min.z, max.z),
|
|
439
|
+
maxX: Math.max(min.x, max.x),
|
|
440
|
+
maxZ: Math.max(min.z, max.z),
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [
|
|
445
|
+
(b.minX + b.maxX) / 2,
|
|
446
|
+
(b.minZ + b.maxZ) / 2,
|
|
447
|
+
]
|
|
448
|
+
|
|
449
|
+
// Re-seed the footprint after a mid-gesture rotation by orbiting the box
|
|
450
|
+
// corners and re-fitting an axis-aligned box. Re-measuring the rotated DATA
|
|
451
|
+
// extents instead would slide the centre off the pivot the snapshots turned
|
|
452
|
+
// around, dragging the alignment anchors away from the group under the cursor.
|
|
453
|
+
export function rotatePlanBounds(
|
|
454
|
+
b: GroupPlanBounds,
|
|
455
|
+
center: { x: number; z: number },
|
|
456
|
+
delta: number,
|
|
457
|
+
): GroupPlanBounds {
|
|
458
|
+
const cos = Math.cos(delta)
|
|
459
|
+
const sin = Math.sin(delta)
|
|
460
|
+
let minX = Number.POSITIVE_INFINITY
|
|
461
|
+
let minZ = Number.POSITIVE_INFINITY
|
|
462
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
463
|
+
let maxZ = Number.NEGATIVE_INFINITY
|
|
464
|
+
const corners: Vec2[] = [
|
|
465
|
+
[b.minX, b.minZ],
|
|
466
|
+
[b.maxX, b.minZ],
|
|
467
|
+
[b.maxX, b.maxZ],
|
|
468
|
+
[b.minX, b.maxZ],
|
|
469
|
+
]
|
|
470
|
+
for (const [x, z] of corners) {
|
|
471
|
+
const dx = x - center.x
|
|
472
|
+
const dz = z - center.z
|
|
473
|
+
const rx = center.x + dx * cos - dz * sin
|
|
474
|
+
const rz = center.z + dx * sin + dz * cos
|
|
475
|
+
minX = Math.min(minX, rx)
|
|
476
|
+
minZ = Math.min(minZ, rz)
|
|
477
|
+
maxX = Math.max(maxX, rx)
|
|
478
|
+
maxZ = Math.max(maxZ, rz)
|
|
479
|
+
}
|
|
480
|
+
return { minX, minZ, maxX, maxZ }
|
|
481
|
+
}
|
|
482
|
+
|
|
422
483
|
// Rigid group slide: shift every participant (and each linked neighbour's
|
|
423
|
-
// shared endpoint) by the same level-frame XZ delta. Y
|
|
484
|
+
// shared endpoint) by the same level-frame XZ delta. Y is untouched; the
|
|
485
|
+
// snapshot's rotation rides along because a mid-gesture R/T turns the
|
|
486
|
+
// SNAPSHOTS — dropping it here would republish (and commit) the pre-rotation
|
|
487
|
+
// facing, orbiting the layout while every member keeps its old bearing.
|
|
424
488
|
export function translateGroupPatches(
|
|
425
489
|
starts: ParticipantStart[],
|
|
426
490
|
links: LinkedNeighbor[],
|
|
@@ -437,7 +501,10 @@ export function translateGroupPatches(
|
|
|
437
501
|
if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift))
|
|
438
502
|
patches.push([s.id, patch])
|
|
439
503
|
} else {
|
|
440
|
-
|
|
504
|
+
const position: Vec3 = [s.position[0] + dx, s.position[1], s.position[2] + dz]
|
|
505
|
+
const rotation =
|
|
506
|
+
s.kind === 'vec3' ? ([s.rotation[0], s.rotation[1], s.rotation[2]] as Vec3) : s.rotation
|
|
507
|
+
patches.push([s.id, { position, rotation }])
|
|
441
508
|
}
|
|
442
509
|
}
|
|
443
510
|
for (const l of links) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { _roots, act, createRoot, extend } from '@react-three/fiber'
|
|
3
|
+
import { createElement } from 'react'
|
|
4
|
+
import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3, type WebGLRenderer } from 'three'
|
|
5
|
+
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
6
|
+
import useEditor from '../../../store/use-editor'
|
|
7
|
+
import { hitAreaRaycast, InvisibleHandleHitArea } from './handle-arrow'
|
|
8
|
+
|
|
9
|
+
extend({ Mesh })
|
|
10
|
+
|
|
11
|
+
test('an occluded handle does not sort ahead of a nearer scene body', () => {
|
|
12
|
+
const geometry = new BoxGeometry(0.5, 0.5, 0.5)
|
|
13
|
+
const material = new MeshBasicMaterial()
|
|
14
|
+
const body = new Mesh(geometry, material)
|
|
15
|
+
body.position.z = 1
|
|
16
|
+
body.updateMatrixWorld()
|
|
17
|
+
const handle = new Mesh(geometry, material)
|
|
18
|
+
handle.position.z = 2
|
|
19
|
+
handle.raycast = hitAreaRaycast
|
|
20
|
+
handle.updateMatrixWorld()
|
|
21
|
+
|
|
22
|
+
const raycaster = new Raycaster(new Vector3(0, 0, 0), new Vector3(0, 0, 1))
|
|
23
|
+
const hits = raycaster.intersectObjects([body, handle], false)
|
|
24
|
+
|
|
25
|
+
expect(hits[0]?.object).toBe(body)
|
|
26
|
+
expect(hits.find((hit) => hit.object === handle)?.distance).toBeGreaterThan(
|
|
27
|
+
hits.find((hit) => hit.object === body)?.distance ?? Number.POSITIVE_INFINITY,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
geometry.dispose()
|
|
31
|
+
material.dispose()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('mounted handles disable synchronously during placement drags and restore after release', async () => {
|
|
35
|
+
const previousDragMode = useEditor.getState().placementDragMode
|
|
36
|
+
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
37
|
+
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
|
|
38
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = true
|
|
39
|
+
const canvas = new EventTarget() as HTMLCanvasElement
|
|
40
|
+
const root = createRoot(canvas)
|
|
41
|
+
const geometry = new BoxGeometry(1, 1, 1)
|
|
42
|
+
const material = new MeshBasicNodeMaterial()
|
|
43
|
+
try {
|
|
44
|
+
useEditor.setState({ placementDragMode: true })
|
|
45
|
+
await root.configure({
|
|
46
|
+
gl: { render() {}, setSize() {}, setPixelRatio() {} } as unknown as WebGLRenderer,
|
|
47
|
+
frameloop: 'never',
|
|
48
|
+
dpr: 1,
|
|
49
|
+
size: { width: 100, height: 100, top: 0, left: 0 },
|
|
50
|
+
})
|
|
51
|
+
const render = (scale: number) =>
|
|
52
|
+
createElement(InvisibleHandleHitArea, {
|
|
53
|
+
geometry,
|
|
54
|
+
material,
|
|
55
|
+
scale,
|
|
56
|
+
onPointerDown() {},
|
|
57
|
+
onPointerEnter() {},
|
|
58
|
+
onPointerLeave() {},
|
|
59
|
+
})
|
|
60
|
+
await act(async () => root.render(render(1)))
|
|
61
|
+
const scene = _roots.get(canvas)!.store.getState().scene
|
|
62
|
+
const handle = scene.children[0] as Mesh
|
|
63
|
+
scene.updateMatrixWorld(true)
|
|
64
|
+
const raycaster = new Raycaster(new Vector3(0, 0, 2), new Vector3(0, 0, -1))
|
|
65
|
+
raycaster.layers.enableAll()
|
|
66
|
+
expect(raycaster.intersectObject(handle)).toHaveLength(0)
|
|
67
|
+
useEditor.setState({ placementDragMode: false })
|
|
68
|
+
expect(handle.raycast).toBe(hitAreaRaycast)
|
|
69
|
+
expect(raycaster.intersectObject(handle).length).toBeGreaterThan(0)
|
|
70
|
+
useEditor.setState({ placementDragMode: true })
|
|
71
|
+
expect(raycaster.intersectObject(handle)).toHaveLength(0)
|
|
72
|
+
await act(async () => root.render(render(2)))
|
|
73
|
+
expect(raycaster.intersectObject(handle)).toHaveLength(0)
|
|
74
|
+
await act(async () => root.render(null))
|
|
75
|
+
const detachedRaycast = handle.raycast
|
|
76
|
+
useEditor.setState({ placementDragMode: false })
|
|
77
|
+
expect(handle.raycast).toBe(detachedRaycast)
|
|
78
|
+
} finally {
|
|
79
|
+
await act(async () => root.render(null))
|
|
80
|
+
_roots.delete(canvas)
|
|
81
|
+
geometry.dispose()
|
|
82
|
+
material.dispose()
|
|
83
|
+
useEditor.setState({ placementDragMode: previousDragMode })
|
|
84
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
|
|
85
|
+
}
|
|
86
|
+
})
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
22
22
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
23
23
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
24
|
+
import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation'
|
|
24
25
|
import useEditor from '../../../store/use-editor'
|
|
25
26
|
|
|
26
27
|
// While a press-drag move is in flight (`placementDragMode`), the move tool
|
|
@@ -29,7 +30,7 @@ import useEditor from '../../../store/use-editor'
|
|
|
29
30
|
// (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
|
|
30
31
|
// Make every handle hit area inert for the duration; the indicator mesh still
|
|
31
32
|
// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
|
|
32
|
-
function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
|
|
33
|
+
export function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
|
|
33
34
|
if (useEditor.getState().placementDragMode) return
|
|
34
35
|
Mesh.prototype.raycast.call(this, raycaster, intersects)
|
|
35
36
|
}
|
|
@@ -66,6 +67,12 @@ const MOVE_CROSS_DEPTH = 0.06
|
|
|
66
67
|
const MOVE_CROSS_BEVEL_THICKNESS = 0.018
|
|
67
68
|
const MOVE_CROSS_BEVEL_SIZE = 0.012
|
|
68
69
|
const MOVE_CROSS_BEVEL_SEGMENTS = 6
|
|
70
|
+
const PLUS_HALF_LENGTH = 0.18
|
|
71
|
+
const PLUS_HALF_WIDTH = 0.045
|
|
72
|
+
const PLUS_DEPTH = 0.06
|
|
73
|
+
const PLUS_BEVEL_THICKNESS = 0.018
|
|
74
|
+
const PLUS_BEVEL_SIZE = 0.012
|
|
75
|
+
const PLUS_BEVEL_SEGMENTS = 6
|
|
69
76
|
const ROTATE_HANDLE_RADIUS = 0.2
|
|
70
77
|
const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
|
|
71
78
|
const ROTATE_RIBBON_HALF_WIDTH = 0.02
|
|
@@ -73,7 +80,13 @@ const ROTATE_HEAD_HALF_WIDTH = 0.045
|
|
|
73
80
|
const TRACKER_CUBE_SIZE = 0.16
|
|
74
81
|
export const CORNER_HEX_RADIUS = 0.11
|
|
75
82
|
|
|
76
|
-
export type HandleArrowShape =
|
|
83
|
+
export type HandleArrowShape =
|
|
84
|
+
| 'chevron'
|
|
85
|
+
| 'cross'
|
|
86
|
+
| 'plus'
|
|
87
|
+
| 'curved-arrow'
|
|
88
|
+
| 'tracker'
|
|
89
|
+
| 'corner-picker'
|
|
77
90
|
export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
|
|
78
91
|
|
|
79
92
|
export type HandleArrowPlacement = {
|
|
@@ -304,6 +317,54 @@ function createMoveCrossHitAreaGeometry() {
|
|
|
304
317
|
return merged
|
|
305
318
|
}
|
|
306
319
|
|
|
320
|
+
function createPlusHandleGeometry() {
|
|
321
|
+
const shape = new Shape()
|
|
322
|
+
shape.moveTo(-PLUS_HALF_WIDTH, PLUS_HALF_LENGTH)
|
|
323
|
+
shape.lineTo(PLUS_HALF_WIDTH, PLUS_HALF_LENGTH)
|
|
324
|
+
shape.lineTo(PLUS_HALF_WIDTH, PLUS_HALF_WIDTH)
|
|
325
|
+
shape.lineTo(PLUS_HALF_LENGTH, PLUS_HALF_WIDTH)
|
|
326
|
+
shape.lineTo(PLUS_HALF_LENGTH, -PLUS_HALF_WIDTH)
|
|
327
|
+
shape.lineTo(PLUS_HALF_WIDTH, -PLUS_HALF_WIDTH)
|
|
328
|
+
shape.lineTo(PLUS_HALF_WIDTH, -PLUS_HALF_LENGTH)
|
|
329
|
+
shape.lineTo(-PLUS_HALF_WIDTH, -PLUS_HALF_LENGTH)
|
|
330
|
+
shape.lineTo(-PLUS_HALF_WIDTH, -PLUS_HALF_WIDTH)
|
|
331
|
+
shape.lineTo(-PLUS_HALF_LENGTH, -PLUS_HALF_WIDTH)
|
|
332
|
+
shape.lineTo(-PLUS_HALF_LENGTH, PLUS_HALF_WIDTH)
|
|
333
|
+
shape.lineTo(-PLUS_HALF_WIDTH, PLUS_HALF_WIDTH)
|
|
334
|
+
shape.closePath()
|
|
335
|
+
const geometry = new ExtrudeGeometry(shape, {
|
|
336
|
+
depth: PLUS_DEPTH,
|
|
337
|
+
bevelEnabled: true,
|
|
338
|
+
bevelThickness: PLUS_BEVEL_THICKNESS,
|
|
339
|
+
bevelSize: PLUS_BEVEL_SIZE,
|
|
340
|
+
bevelOffset: 0,
|
|
341
|
+
bevelSegments: PLUS_BEVEL_SEGMENTS,
|
|
342
|
+
curveSegments: 8,
|
|
343
|
+
steps: 1,
|
|
344
|
+
})
|
|
345
|
+
geometry.translate(0, 0, -PLUS_DEPTH / 2)
|
|
346
|
+
geometry.computeVertexNormals()
|
|
347
|
+
geometry.computeBoundingSphere()
|
|
348
|
+
return geometry
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function createPlusHitAreaGeometry() {
|
|
352
|
+
const length = (PLUS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
|
|
353
|
+
const width = (PLUS_HALF_WIDTH + HIT_AREA_MARGIN) * 2
|
|
354
|
+
const horizontal = new BoxGeometry(length, width, HIT_AREA_THICKNESS)
|
|
355
|
+
const vertical = new BoxGeometry(width, length, HIT_AREA_THICKNESS)
|
|
356
|
+
const merged = mergeGeometries([horizontal, vertical], false)
|
|
357
|
+
if (!merged) {
|
|
358
|
+
vertical.dispose()
|
|
359
|
+
horizontal.computeBoundingSphere()
|
|
360
|
+
return horizontal
|
|
361
|
+
}
|
|
362
|
+
horizontal.dispose()
|
|
363
|
+
vertical.dispose()
|
|
364
|
+
merged.computeBoundingSphere()
|
|
365
|
+
return merged
|
|
366
|
+
}
|
|
367
|
+
|
|
307
368
|
export function createRotateArrowHitAreaGeometry() {
|
|
308
369
|
const halfSweep = ROTATE_HANDLE_HALF_SWEEP + HIT_AREA_MARGIN / ROTATE_HANDLE_RADIUS
|
|
309
370
|
const geometry = new TorusGeometry(
|
|
@@ -345,6 +406,7 @@ const CORNER_DISC_ROUND_SEGMENTS = 32
|
|
|
345
406
|
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round = false) {
|
|
346
407
|
if (shape === 'chevron') return createArrowHandleGeometry(thin)
|
|
347
408
|
if (shape === 'cross') return createMoveCrossHandleGeometry()
|
|
409
|
+
if (shape === 'plus') return createPlusHandleGeometry()
|
|
348
410
|
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
|
|
349
411
|
if (shape === 'tracker') {
|
|
350
412
|
const geometry = new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE)
|
|
@@ -362,6 +424,7 @@ function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round
|
|
|
362
424
|
function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
|
|
363
425
|
if (shape === 'chevron') return createArrowHitAreaGeometry()
|
|
364
426
|
if (shape === 'cross') return createMoveCrossHitAreaGeometry()
|
|
427
|
+
if (shape === 'plus') return createPlusHitAreaGeometry()
|
|
365
428
|
if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
|
|
366
429
|
if (shape === 'tracker') return createTrackerHitAreaGeometry()
|
|
367
430
|
const geometry = new CircleGeometry(
|
|
@@ -373,7 +436,29 @@ function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
|
|
|
373
436
|
}
|
|
374
437
|
|
|
375
438
|
let sharedHitAreaMaterial: MeshBasicNodeMaterial | null = null
|
|
376
|
-
|
|
439
|
+
const sharedHandleGeometries = new Map<string, BufferGeometry>()
|
|
440
|
+
const sharedHandleHitGeometries = new Map<string, BufferGeometry>()
|
|
441
|
+
const sharedHandleMaterials = new Map<string, MeshBasicNodeMaterial>()
|
|
442
|
+
|
|
443
|
+
function sharedHandleGeometry(shape: HandleArrowShape, thin: boolean, round: boolean) {
|
|
444
|
+
const key = `${shape}:${thin}:${round}`
|
|
445
|
+
let geometry = sharedHandleGeometries.get(key)
|
|
446
|
+
if (!geometry) {
|
|
447
|
+
geometry = createHandleArrowGeometry(shape, thin, round)
|
|
448
|
+
sharedHandleGeometries.set(key, geometry)
|
|
449
|
+
}
|
|
450
|
+
return geometry
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function sharedHandleHitGeometry(shape: HandleArrowShape, round: boolean) {
|
|
454
|
+
const key = `${shape}:${round}`
|
|
455
|
+
let geometry = sharedHandleHitGeometries.get(key)
|
|
456
|
+
if (!geometry) {
|
|
457
|
+
geometry = createHandleArrowHitGeometry(shape, round)
|
|
458
|
+
sharedHandleHitGeometries.set(key, geometry)
|
|
459
|
+
}
|
|
460
|
+
return geometry
|
|
461
|
+
}
|
|
377
462
|
|
|
378
463
|
function createInvisibleHitAreaMaterial() {
|
|
379
464
|
return new MeshBasicNodeMaterial({
|
|
@@ -388,23 +473,8 @@ function createInvisibleHitAreaMaterial() {
|
|
|
388
473
|
}
|
|
389
474
|
|
|
390
475
|
export function useInvisibleHitAreaMaterial(): MeshBasicNodeMaterial {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial()
|
|
394
|
-
materialRef.current = sharedHitAreaMaterial
|
|
395
|
-
}
|
|
396
|
-
useEffect(() => {
|
|
397
|
-
sharedHitAreaMaterialRefs += 1
|
|
398
|
-
return () => {
|
|
399
|
-
sharedHitAreaMaterialRefs -= 1
|
|
400
|
-
if (sharedHitAreaMaterialRefs <= 0 && sharedHitAreaMaterial) {
|
|
401
|
-
sharedHitAreaMaterial.dispose()
|
|
402
|
-
sharedHitAreaMaterial = null
|
|
403
|
-
sharedHitAreaMaterialRefs = 0
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}, [])
|
|
407
|
-
return materialRef.current
|
|
476
|
+
sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial()
|
|
477
|
+
return sharedHitAreaMaterial
|
|
408
478
|
}
|
|
409
479
|
|
|
410
480
|
export function InvisibleHandleHitArea({
|
|
@@ -434,6 +504,7 @@ export function InvisibleHandleHitArea({
|
|
|
434
504
|
raycast={hitAreaRaycast}
|
|
435
505
|
renderOrder={HIT_AREA_RENDER_ORDER}
|
|
436
506
|
scale={scale}
|
|
507
|
+
userData={{ [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true }}
|
|
437
508
|
/>
|
|
438
509
|
)
|
|
439
510
|
}
|
|
@@ -461,19 +532,21 @@ export function useArrowMaterial(): MeshBasicNodeMaterial {
|
|
|
461
532
|
)
|
|
462
533
|
}
|
|
463
534
|
|
|
464
|
-
function useHandleArrowMaterial(shape: HandleArrowShape): MeshBasicNodeMaterial {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
535
|
+
function useHandleArrowMaterial(shape: HandleArrowShape, hover: boolean): MeshBasicNodeMaterial {
|
|
536
|
+
const key = `${shape}:${hover}`
|
|
537
|
+
let material = sharedHandleMaterials.get(key)
|
|
538
|
+
if (!material) {
|
|
539
|
+
material = new MeshBasicNodeMaterial({
|
|
540
|
+
color: new Color(hover ? ARROW_HOVER_COLOR : ARROW_COLOR),
|
|
541
|
+
side: DoubleSide,
|
|
542
|
+
transparent: true,
|
|
543
|
+
opacity: shape === 'corner-picker' ? 0.95 : 1,
|
|
544
|
+
depthTest: false,
|
|
545
|
+
depthWrite: shape !== 'corner-picker',
|
|
546
|
+
})
|
|
547
|
+
sharedHandleMaterials.set(key, material)
|
|
548
|
+
}
|
|
549
|
+
return material
|
|
477
550
|
}
|
|
478
551
|
|
|
479
552
|
function indicatorRenderOrder(shape: HandleArrowShape) {
|
|
@@ -497,15 +570,9 @@ export function HandleArrow({
|
|
|
497
570
|
round = false,
|
|
498
571
|
}: HandleArrowProps) {
|
|
499
572
|
const visualShape = normalizeHandleArrowShape(shape, cursor)
|
|
500
|
-
const geometry =
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
)
|
|
504
|
-
const hitGeometry = useMemo(
|
|
505
|
-
() => createHandleArrowHitGeometry(visualShape, round),
|
|
506
|
-
[visualShape, round],
|
|
507
|
-
)
|
|
508
|
-
const indicatorMaterial = useHandleArrowMaterial(visualShape)
|
|
573
|
+
const geometry = sharedHandleGeometry(visualShape, thin, round)
|
|
574
|
+
const hitGeometry = sharedHandleHitGeometry(visualShape, round)
|
|
575
|
+
const indicatorMaterial = useHandleArrowMaterial(visualShape, hover)
|
|
509
576
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
510
577
|
const rootRef = useRef<Group>(null)
|
|
511
578
|
const rotation: [number, number, number] = placement.rotation
|
|
@@ -517,9 +584,6 @@ export function HandleArrow({
|
|
|
517
584
|
const scale = (hover ? hoverScale : 1) * placement.baseScale
|
|
518
585
|
const hitScale = visualShape === 'corner-picker' ? scale : placement.baseScale
|
|
519
586
|
|
|
520
|
-
useEffect(() => {
|
|
521
|
-
indicatorMaterial.color.set(hover ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
|
522
|
-
}, [indicatorMaterial, hover])
|
|
523
587
|
useEffect(() => {
|
|
524
588
|
const hideForCapture = () => {
|
|
525
589
|
if (rootRef.current) rootRef.current.visible = false
|
|
@@ -534,9 +598,6 @@ export function HandleArrow({
|
|
|
534
598
|
emitter.off('thumbnail:after-capture', restoreAfterCapture)
|
|
535
599
|
}
|
|
536
600
|
}, [])
|
|
537
|
-
useEffect(() => () => geometry.dispose(), [geometry])
|
|
538
|
-
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
539
|
-
useEffect(() => () => indicatorMaterial.dispose(), [indicatorMaterial])
|
|
540
601
|
|
|
541
602
|
const handleEnter: PointerHandler = (event) => {
|
|
542
603
|
event.stopPropagation()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type HandleDragModifiers,
|
|
5
|
+
type LinearResizeHandle,
|
|
6
|
+
type SceneApi,
|
|
7
|
+
useLiveNodeOverrides,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { replacePreviewOverrideIds } from './preview-overrides'
|
|
11
|
+
|
|
12
|
+
export function createLinearResizeDragBinding({
|
|
13
|
+
descriptor,
|
|
14
|
+
initialNode,
|
|
15
|
+
nodeId,
|
|
16
|
+
sceneApi,
|
|
17
|
+
initialModifiers,
|
|
18
|
+
}: {
|
|
19
|
+
descriptor: LinearResizeHandle<AnyNode>
|
|
20
|
+
initialNode: AnyNode
|
|
21
|
+
nodeId: AnyNodeId
|
|
22
|
+
sceneApi: SceneApi
|
|
23
|
+
initialModifiers: HandleDragModifiers
|
|
24
|
+
}) {
|
|
25
|
+
const overrideId = descriptor.overrideTarget?.(initialNode, sceneApi) ?? nodeId
|
|
26
|
+
let lastModifiers = initialModifiers
|
|
27
|
+
let previewOverrideIds = new Set<AnyNodeId>()
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
overrideId,
|
|
31
|
+
commit: descriptor.commit
|
|
32
|
+
? (patch: Partial<AnyNode>) =>
|
|
33
|
+
descriptor.commit?.(initialNode, patch, sceneApi, lastModifiers)
|
|
34
|
+
: undefined,
|
|
35
|
+
apply(next: number, modifiers: HandleDragModifiers): Partial<AnyNode> {
|
|
36
|
+
lastModifiers = modifiers
|
|
37
|
+
const patch = descriptor.apply(initialNode, next, sceneApi, modifiers) as Partial<AnyNode>
|
|
38
|
+
const previewEntries = descriptor.previewOverrides?.(initialNode, next, sceneApi, modifiers)
|
|
39
|
+
if (!previewEntries) return patch
|
|
40
|
+
|
|
41
|
+
previewOverrideIds = replacePreviewOverrideIds(
|
|
42
|
+
previewOverrideIds,
|
|
43
|
+
previewEntries,
|
|
44
|
+
(previewId) => {
|
|
45
|
+
useLiveNodeOverrides.getState().clear(previewId)
|
|
46
|
+
useScene.getState().markDirty(previewId)
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
useLiveNodeOverrides
|
|
50
|
+
.getState()
|
|
51
|
+
.setMany(
|
|
52
|
+
previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record<string, unknown>]),
|
|
53
|
+
)
|
|
54
|
+
for (const [previewId] of previewEntries) {
|
|
55
|
+
useScene.getState().markDirty(previewId)
|
|
56
|
+
}
|
|
57
|
+
return patch
|
|
58
|
+
},
|
|
59
|
+
clearPreview(): void {
|
|
60
|
+
for (const previewId of previewOverrideIds) {
|
|
61
|
+
useLiveNodeOverrides.getState().clear(previewId)
|
|
62
|
+
useScene.getState().markDirty(previewId)
|
|
63
|
+
}
|
|
64
|
+
previewOverrideIds = new Set()
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -49,4 +49,50 @@ describe('resolveResizeSnapValue', () => {
|
|
|
49
49
|
).toBe(0.56)
|
|
50
50
|
expect(magneticSnap).not.toHaveBeenCalled()
|
|
51
51
|
})
|
|
52
|
+
|
|
53
|
+
it('applies a structural connection snap independently of the active mode', () => {
|
|
54
|
+
const connectionSnap = mock(() => 0.6)
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
resolveResizeSnapValue({
|
|
58
|
+
rawValue: 0.59,
|
|
59
|
+
gridSnapEnabled: false,
|
|
60
|
+
gridSnapActive: false,
|
|
61
|
+
gridSnapStep: 0.1,
|
|
62
|
+
magneticSnapActive: false,
|
|
63
|
+
connectionSnap,
|
|
64
|
+
}),
|
|
65
|
+
).toBe(0.6)
|
|
66
|
+
expect(connectionSnap).toHaveBeenCalledWith(0.59)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('bypasses a structural connection snap while force-moving', () => {
|
|
70
|
+
const connectionSnap = mock(() => 0.6)
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
resolveResizeSnapValue({
|
|
74
|
+
rawValue: 0.59,
|
|
75
|
+
gridSnapEnabled: false,
|
|
76
|
+
gridSnapActive: false,
|
|
77
|
+
gridSnapStep: 0.1,
|
|
78
|
+
magneticSnapActive: false,
|
|
79
|
+
connectionSnapActive: false,
|
|
80
|
+
connectionSnap,
|
|
81
|
+
}),
|
|
82
|
+
).toBe(0.59)
|
|
83
|
+
expect(connectionSnap).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('keeps the last valid value when pointer projection is non-finite', () => {
|
|
87
|
+
expect(
|
|
88
|
+
resolveResizeSnapValue({
|
|
89
|
+
rawValue: Number.NaN,
|
|
90
|
+
fallbackValue: 12.3,
|
|
91
|
+
gridSnapEnabled: false,
|
|
92
|
+
gridSnapActive: false,
|
|
93
|
+
gridSnapStep: 0.5,
|
|
94
|
+
magneticSnapActive: false,
|
|
95
|
+
}),
|
|
96
|
+
).toBe(12.3)
|
|
97
|
+
})
|
|
52
98
|
})
|
|
@@ -2,22 +2,31 @@ import { snapScalar } from '@pascal-app/core'
|
|
|
2
2
|
|
|
3
3
|
export function resolveResizeSnapValue({
|
|
4
4
|
rawValue,
|
|
5
|
+
fallbackValue = rawValue,
|
|
5
6
|
gridSnapEnabled,
|
|
6
7
|
gridSnapActive,
|
|
7
8
|
gridSnapStep,
|
|
8
9
|
magneticSnapActive,
|
|
9
10
|
magneticSnap,
|
|
11
|
+
connectionSnapActive = true,
|
|
12
|
+
connectionSnap,
|
|
10
13
|
}: {
|
|
11
14
|
rawValue: number
|
|
15
|
+
fallbackValue?: number
|
|
12
16
|
gridSnapEnabled: boolean
|
|
13
17
|
gridSnapActive: boolean
|
|
14
18
|
gridSnapStep: number
|
|
15
19
|
magneticSnapActive: boolean
|
|
16
20
|
magneticSnap?: (value: number) => number
|
|
21
|
+
connectionSnapActive?: boolean
|
|
22
|
+
connectionSnap?: (value: number) => number
|
|
17
23
|
}): number {
|
|
24
|
+
if (!Number.isFinite(rawValue)) return fallbackValue
|
|
18
25
|
const gridValue =
|
|
19
26
|
gridSnapEnabled && gridSnapActive && gridSnapStep > 0
|
|
20
27
|
? snapScalar(rawValue, gridSnapStep)
|
|
21
28
|
: rawValue
|
|
22
|
-
|
|
29
|
+
const modeValue = magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue
|
|
30
|
+
const resolved = connectionSnapActive && connectionSnap ? connectionSnap(modeValue) : modeValue
|
|
31
|
+
return Number.isFinite(resolved) ? resolved : fallbackValue
|
|
23
32
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type AnyNodeId,
|
|
6
6
|
type Cursor,
|
|
7
7
|
createSceneApi,
|
|
8
|
+
type HandleDragModifiers,
|
|
8
9
|
runAsSingleSceneHistoryStep,
|
|
9
10
|
useLiveNodeOverrides,
|
|
10
11
|
useScene,
|
|
@@ -46,6 +47,7 @@ export type HandleDragStartContext = {
|
|
|
46
47
|
|
|
47
48
|
export type HandleDragMoveContext = {
|
|
48
49
|
event: PointerEvent
|
|
50
|
+
modifiers: HandleDragModifiers
|
|
49
51
|
getPointerRay: GetPointerRay
|
|
50
52
|
intersectPlane: IntersectPlane
|
|
51
53
|
}
|
|
@@ -177,6 +179,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
177
179
|
|
|
178
180
|
let lastPatch: Partial<AnyNode> | null = null
|
|
179
181
|
let historyPaused = true
|
|
182
|
+
let altKey = event.nativeEvent.altKey
|
|
180
183
|
|
|
181
184
|
const resumeHistory = () => {
|
|
182
185
|
if (!historyPaused) return
|
|
@@ -185,7 +188,12 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
const onMove = (moveEvent: PointerEvent) => {
|
|
188
|
-
const patch = session.move({
|
|
191
|
+
const patch = session.move({
|
|
192
|
+
event: moveEvent,
|
|
193
|
+
modifiers: { altKey },
|
|
194
|
+
getPointerRay,
|
|
195
|
+
intersectPlane,
|
|
196
|
+
})
|
|
189
197
|
if (!patch) return
|
|
190
198
|
lastPatch = patch
|
|
191
199
|
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
|
|
@@ -199,6 +207,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
199
207
|
window.removeEventListener('pointerup', onUp)
|
|
200
208
|
window.removeEventListener('pointercancel', onCancel)
|
|
201
209
|
window.removeEventListener('keydown', onKeyDown, true)
|
|
210
|
+
window.removeEventListener('keyup', onKeyUp, true)
|
|
202
211
|
if (document.body.style.cursor === cursor) {
|
|
203
212
|
document.body.style.cursor = ''
|
|
204
213
|
}
|
|
@@ -240,17 +249,25 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
240
249
|
// Escape / ⌘Z abort the drag — capture phase so they win over the global
|
|
241
250
|
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
|
|
242
251
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
252
|
+
if (e.key === 'Alt') {
|
|
253
|
+
altKey = true
|
|
254
|
+
return
|
|
255
|
+
}
|
|
243
256
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
244
257
|
e.preventDefault()
|
|
245
258
|
e.stopPropagation()
|
|
246
259
|
swallowNextClick()
|
|
247
260
|
onCancel()
|
|
248
261
|
}
|
|
262
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
263
|
+
if (e.key === 'Alt') altKey = false
|
|
264
|
+
}
|
|
249
265
|
|
|
250
266
|
dragCleanupRef.current = onCancel
|
|
251
267
|
window.addEventListener('pointermove', onMove)
|
|
252
268
|
window.addEventListener('pointerup', onUp)
|
|
253
269
|
window.addEventListener('pointercancel', onCancel)
|
|
254
270
|
window.addEventListener('keydown', onKeyDown, true)
|
|
271
|
+
window.addEventListener('keyup', onKeyUp, true)
|
|
255
272
|
}
|
|
256
273
|
}
|