@pascal-app/editor 1.0.0-beta.5 → 1.0.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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- 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 +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- 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/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 +78 -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 +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -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 +295 -239
- 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-action-menu.tsx +12 -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 +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- 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/ceiling/ceiling-system.tsx +3 -1
- 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/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -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 +304 -171
- 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 +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -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 +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- 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 +56 -17
- 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 +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- 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/tools/zone/zone-tool.tsx +13 -4
- 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 +63 -13
- 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/metric-control.tsx +29 -13
- 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 +44 -21
- 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 +103 -10
- 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 +266 -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 +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- 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 +502 -31
- 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 +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- 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/shelf-tree-node.tsx +3 -8
- 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 +20 -7
- 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/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- 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 +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- 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 +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -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/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/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 +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- 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 +5 -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/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- 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-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- 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/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/node-action-movement.ts +21 -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 +455 -0
- package/src/lib/portable-export.ts +1042 -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 +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -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 +38 -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-clipboard.ts +1 -1
- 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/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/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/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- 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-handle-group.ts +4 -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/store/use-send-to-app.ts +21 -0
- 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,111 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { type AnyNode, createSceneApi, type LinearResizeHandle, useScene } from '@pascal-app/core'
|
|
3
|
+
import { Euler, Vector3 } from 'three'
|
|
4
|
+
import { createArrowHandleGeometry } from './handle-arrow'
|
|
5
|
+
import {
|
|
6
|
+
computeFreezeOffset,
|
|
7
|
+
resolveLinearHandlePosition,
|
|
8
|
+
resolveLinearHandleRotation,
|
|
9
|
+
} from './handle-placement'
|
|
10
|
+
|
|
11
|
+
const scene = createSceneApi(useScene)
|
|
12
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
13
|
+
test(`${axis} clearance preserves sufficient offsets and keeps even hovered arrow tails clear at different scales`, () => {
|
|
14
|
+
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
|
15
|
+
const geometry = createArrowHandleGeometry(true)
|
|
16
|
+
geometry.computeBoundingBox()
|
|
17
|
+
const tail = geometry.boundingBox!.min.x * 1.12
|
|
18
|
+
for (const edge of [0.06, 1.2, -0.4]) {
|
|
19
|
+
const position: [number, number, number] = [0, 0, 0]
|
|
20
|
+
position[index] = edge + 0.15
|
|
21
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
22
|
+
kind: 'linear-resize',
|
|
23
|
+
axis,
|
|
24
|
+
anchor: 'center',
|
|
25
|
+
currentValue: () => 1,
|
|
26
|
+
apply: () => null,
|
|
27
|
+
placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
|
|
28
|
+
}
|
|
29
|
+
for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
|
|
30
|
+
const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
|
|
31
|
+
expect(resolved[index] + tail * scale - edge).toBeGreaterThan(0.1 * scale)
|
|
32
|
+
if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
|
|
33
|
+
resolved.forEach((value, i) => {
|
|
34
|
+
if (i !== index) expect(value).toBe(position[i]!)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
delete descriptor.placement.clearance
|
|
38
|
+
expect(resolveLinearHandlePosition(descriptor, null, scene, 3)).toBe(position)
|
|
39
|
+
}
|
|
40
|
+
geometry.dispose()
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('array rotations keep sibling handles finite during rotation and resize', () => {
|
|
45
|
+
const initial = { position: [1, 2, 3], rotation: [0, Math.PI / 2, 0] } as unknown as AnyNode
|
|
46
|
+
const rotated = { ...initial, rotation: [0, Math.PI, 0] } as unknown as AnyNode
|
|
47
|
+
expect(computeFreezeOffset(rotated, initial)).toEqual([0, 0, 0])
|
|
48
|
+
const moved = { ...initial, position: [2, 4, 3] } as unknown as AnyNode
|
|
49
|
+
const offset = computeFreezeOffset(moved, initial)
|
|
50
|
+
expect(offset[0]).toBeCloseTo(0)
|
|
51
|
+
expect(offset[1]).toBe(2)
|
|
52
|
+
expect(offset[2]).toBeCloseTo(1)
|
|
53
|
+
expect(
|
|
54
|
+
computeFreezeOffset(moved, { ...initial, rotation: Math.PI / 2 } as unknown as AnyNode),
|
|
55
|
+
).toEqual(offset)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
59
|
+
test(`${axis} negative direction keeps hovered arrow above its lower-edge clearance`, () => {
|
|
60
|
+
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
|
61
|
+
const geometry = createArrowHandleGeometry(true)
|
|
62
|
+
geometry.computeBoundingBox()
|
|
63
|
+
const tail = -geometry.boundingBox!.min.x * 1.12
|
|
64
|
+
for (const edge of [-2, 0, 2]) {
|
|
65
|
+
const position: [number, number, number] = [0, 0, 0]
|
|
66
|
+
position[index] = edge - 0.15
|
|
67
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
68
|
+
kind: 'linear-resize',
|
|
69
|
+
axis,
|
|
70
|
+
anchor: 'max',
|
|
71
|
+
direction: -1,
|
|
72
|
+
currentValue: () => 1,
|
|
73
|
+
apply: () => null,
|
|
74
|
+
placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
|
|
75
|
+
}
|
|
76
|
+
for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
|
|
77
|
+
const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
|
|
78
|
+
expect(edge - (resolved[index] + tail * scale)).toBeGreaterThan(0.1 * scale)
|
|
79
|
+
if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
|
|
80
|
+
const pointing = new Vector3(1, 0, 0)
|
|
81
|
+
.applyEuler(new Euler(...resolveLinearHandleRotation(descriptor, resolved)))
|
|
82
|
+
.applyAxisAngle(new Vector3(0, 1, 0), axis === 'z' ? -Math.PI / 2 : 0)
|
|
83
|
+
expect(pointing.getComponent(index)).toBeCloseTo(-1)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
geometry.dispose()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('unspecified direction preserves vertical position heuristic and wall-facing blade', () => {
|
|
91
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
92
|
+
kind: 'linear-resize',
|
|
93
|
+
axis: 'y',
|
|
94
|
+
anchor: 'min',
|
|
95
|
+
currentValue: () => 1,
|
|
96
|
+
apply: () => null,
|
|
97
|
+
placement: { position: () => [0, 0, 0] },
|
|
98
|
+
}
|
|
99
|
+
expect(resolveLinearHandleRotation(descriptor, [0, -1, 0])).toEqual([
|
|
100
|
+
0,
|
|
101
|
+
Math.PI / 2,
|
|
102
|
+
-Math.PI / 2,
|
|
103
|
+
])
|
|
104
|
+
expect(resolveLinearHandleRotation(descriptor, [0, 1, 0])).toEqual([0, Math.PI / 2, Math.PI / 2])
|
|
105
|
+
expect(
|
|
106
|
+
resolveLinearHandleRotation({ ...descriptor, axis: 'x', faceNormal: true }, [0, 0, 0]),
|
|
107
|
+
).toEqual([Math.PI / 2, 0, 0])
|
|
108
|
+
expect(
|
|
109
|
+
resolveLinearHandleRotation({ ...descriptor, axis: 'z', faceNormal: true }, [0, 0, 0]),
|
|
110
|
+
).toEqual([0, 0, 0])
|
|
111
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AnyNode, LinearResizeHandle, RadialResizeHandle, SceneApi } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function resolveLinearHandlePosition<N>(
|
|
4
|
+
descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
|
|
5
|
+
node: N,
|
|
6
|
+
scene: SceneApi,
|
|
7
|
+
baseScale: number,
|
|
8
|
+
): readonly [number, number, number] {
|
|
9
|
+
const position = descriptor.placement.position(node, scene)
|
|
10
|
+
const clearance = descriptor.kind === 'linear-resize' ? descriptor.placement.clearance : undefined
|
|
11
|
+
if (!clearance) return position
|
|
12
|
+
const index = descriptor.axis === 'x' ? 0 : descriptor.axis === 'y' ? 1 : 2
|
|
13
|
+
const result: [number, number, number] = [...position]
|
|
14
|
+
const direction = descriptor.kind === 'linear-resize' ? (descriptor.direction ?? 1) : 1
|
|
15
|
+
const edge = clearance.edge(node, scene) + direction * clearance.distance * baseScale
|
|
16
|
+
result[index] = direction === -1 ? Math.min(result[index], edge) : Math.max(result[index], edge)
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveLinearHandleRotation<N>(
|
|
21
|
+
descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
|
|
22
|
+
position: readonly [number, number, number],
|
|
23
|
+
): [number, number, number] {
|
|
24
|
+
const direction = descriptor.kind === 'linear-resize' ? descriptor.direction : undefined
|
|
25
|
+
if (descriptor.axis === 'y') {
|
|
26
|
+
const sign = direction ?? (position[1] < 0 ? -1 : 1)
|
|
27
|
+
return [0, Math.PI / 2, (sign * Math.PI) / 2]
|
|
28
|
+
}
|
|
29
|
+
const faceNormal =
|
|
30
|
+
descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal
|
|
31
|
+
return [faceNormal ? Math.PI / 2 : 0, 0, direction === -1 ? Math.PI : 0]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Offset, in node-local frame, that compensates for `position` drift on
|
|
35
|
+
// the mesh during an asymmetric resize. Width/length L+R recompute
|
|
36
|
+
// `position` so the anchored edge stays world-fixed — the renderer
|
|
37
|
+
// follows that override, the ride object moves, and every arrow under
|
|
38
|
+
// it would drift along with the mesh center. Subtracting this offset
|
|
39
|
+
// from a non-active arrow's local placement undoes that drift so it
|
|
40
|
+
// stays at its pre-drag world position.
|
|
41
|
+
//
|
|
42
|
+
// Rotation drags don't change `position`, so the offset collapses to
|
|
43
|
+
// zero and non-active arrows naturally rotate with the mesh — which is
|
|
44
|
+
// the desired behaviour (the whole rig rotates as a unit).
|
|
45
|
+
export function computeFreezeOffset(
|
|
46
|
+
liveNode: AnyNode,
|
|
47
|
+
preDragNode: AnyNode,
|
|
48
|
+
): [number, number, number] {
|
|
49
|
+
// Not every node in the union carries a `position` field (sites are the
|
|
50
|
+
// notable holdout — they don't have handles anyway, but TypeScript still
|
|
51
|
+
// requires us to discriminate). Guarded access keeps the freeze logic
|
|
52
|
+
// safe for the few node kinds that lack the field.
|
|
53
|
+
const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
|
|
54
|
+
const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
|
|
55
|
+
0, 0, 0,
|
|
56
|
+
]
|
|
57
|
+
const deltaWorldX = liveP[0] - preP[0]
|
|
58
|
+
const deltaWorldY = liveP[1] - preP[1]
|
|
59
|
+
const deltaWorldZ = liveP[2] - preP[2]
|
|
60
|
+
const rotation = (preDragNode as { rotation?: number | readonly number[] }).rotation
|
|
61
|
+
const rotY = typeof rotation === 'number' ? rotation : (rotation?.[1] ?? 0)
|
|
62
|
+
// World → node-local for Y-axis rotation by rotY (THREE.Object3D
|
|
63
|
+
// rotation-y convention): inverse is rotation by -rotY around +Y.
|
|
64
|
+
const cosR = Math.cos(rotY)
|
|
65
|
+
const sinR = Math.sin(rotY)
|
|
66
|
+
const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
|
|
67
|
+
const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
|
|
68
|
+
return [deltaLocalX, deltaWorldY, deltaLocalZ]
|
|
69
|
+
}
|