@pascal-app/editor 1.0.4 → 1.0.5
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/README.md +106 -0
- package/package.json +39 -37
- package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
- package/src/components/editor/bake-thumbnail.tsx +6 -15
- package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
- package/src/components/editor/construction-reveal-audio.tsx +40 -0
- package/src/components/editor/custom-camera-controls.tsx +81 -8
- package/src/components/editor/editor-layout-mobile.tsx +1 -1
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/export-manager.tsx +14 -19
- package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
- package/src/components/editor/first-person/build-collider-world.ts +22 -5
- package/src/components/editor/first-person/ground-holes.test.ts +128 -0
- package/src/components/editor/first-person/ground-holes.ts +130 -0
- package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
- package/src/components/editor/first-person/mechanism-targets.ts +52 -0
- package/src/components/editor/first-person-controls.tsx +19 -11
- package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +72 -23
- package/src/components/editor/follow-pascal.test.tsx +912 -0
- package/src/components/editor/follow-pascal.tsx +354 -0
- package/src/components/editor/group-actions.ts +2 -0
- package/src/components/editor/group-move-3d.ts +2 -0
- package/src/components/editor/group-rotate-handle.tsx +2 -0
- package/src/components/editor/group-transform-shared.test.ts +460 -0
- package/src/components/editor/index.tsx +166 -79
- package/src/components/editor/riser-diagram-panel.tsx +14 -7
- package/src/components/editor/room-highlight.tsx +13 -5
- package/src/components/editor/room-labels.test.ts +621 -128
- package/src/components/editor/room-labels.tsx +1090 -190
- package/src/components/editor/selection-manager.tsx +38 -117
- package/src/components/editor/slab-hole-highlights.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
- package/src/components/editor/snapshot-capture.test.ts +69 -58
- package/src/components/editor/snapshot-capture.ts +7 -1
- package/src/components/editor/thumbnail-generator.test.ts +350 -4
- package/src/components/editor/thumbnail-generator.tsx +128 -54
- package/src/components/editor/use-floorplan-background-placement.ts +3 -1
- package/src/components/editor/views/plugins-view.tsx +1 -8
- package/src/components/editor/views/view-bar.test.tsx +54 -0
- package/src/components/editor/views/view-bar.tsx +140 -17
- package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
- package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
- package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
- package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
- package/src/components/point-ask/bubble.tsx +538 -0
- package/src/components/point-ask/hover-chip.tsx +91 -0
- package/src/components/point-ask/mode-pill.tsx +92 -0
- package/src/components/point-ask/optional-motion.ts +22 -0
- package/src/components/point-ask/outline-corners.tsx +93 -0
- package/src/components/point-ask/pins.tsx +711 -0
- package/src/components/point-ask/point-ask-layer.tsx +83 -0
- package/src/components/point-ask/point-ask-styles.ts +105 -0
- package/src/components/point-ask/region-marquee-styles.ts +15 -0
- package/src/components/point-ask/region-marquee.tsx +64 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
- package/src/components/systems/roof/roof-edit-system.tsx +1 -0
- package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
- package/src/components/tools/distribution/connection-compatibility.ts +43 -0
- package/src/components/tools/distribution/distribution-public.ts +9 -0
- package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
- package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
- package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
- package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
- package/src/components/tools/distribution/draw-alignment.ts +35 -0
- package/src/components/tools/distribution/ports.test.ts +56 -0
- package/src/components/tools/distribution/ports.ts +378 -0
- package/src/components/tools/distribution/run-cursor.test.ts +135 -0
- package/src/components/tools/distribution/run-cursor.ts +36 -0
- package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
- package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
- package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
- package/src/components/tools/distribution/run-port-snap.ts +25 -0
- package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
- package/src/components/tools/distribution/run-screen-direction.ts +30 -0
- package/src/components/tools/elevator/elevator-tool.tsx +3 -6
- package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
- package/src/components/tools/item/placement-history.test.tsx +38 -0
- package/src/components/tools/item/placement-strategies.test.ts +46 -1
- package/src/components/tools/item/placement-strategies.ts +3 -6
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
- package/src/components/tools/item/use-draft-node.test.tsx +94 -94
- package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
- package/src/components/tools/registry/item-surface-move.test.ts +62 -63
- package/src/components/tools/registry/item-surface-move.ts +2 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
- package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
- package/src/components/tools/tool-manager.tsx +14 -5
- package/src/components/tools/wall/wall-drafting.test.ts +170 -57
- package/src/components/ui/action-menu/action-button.tsx +6 -1
- package/src/components/ui/action-menu/camera-actions.tsx +7 -3
- package/src/components/ui/action-menu/control-modes.tsx +13 -7
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
- package/src/components/ui/action-menu/index.tsx +56 -33
- package/src/components/ui/action-menu/measurement-control.tsx +11 -9
- package/src/components/ui/action-menu/placement.ts +20 -0
- package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
- package/src/components/ui/action-menu/view-toggles.tsx +47 -42
- package/src/components/ui/build-panel/build-panel.tsx +10 -3
- package/src/components/ui/command-palette/editor-commands.tsx +3 -12
- package/src/components/ui/command-palette/index.tsx +45 -15
- package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
- package/src/components/ui/controls/action-button.tsx +5 -2
- package/src/components/ui/controls/metric-control.tsx +1 -1
- package/src/components/ui/controls/segmented-control.tsx +21 -8
- package/src/components/ui/controls/select-control.tsx +1 -1
- package/src/components/ui/controls/slider-control.tsx +1 -1
- package/src/components/ui/controls/toggle-control.tsx +3 -3
- package/src/components/ui/controls/tool-options-panel.tsx +1 -1
- package/src/components/ui/floating-level-selector.tsx +150 -45
- package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
- package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
- package/src/components/ui/helpers/helper-manager.tsx +7 -1
- package/src/components/ui/helpers/item-helper.tsx +9 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
- package/src/components/ui/panels/panel-wrapper.tsx +53 -27
- package/src/components/ui/panels/parametric-field-control.tsx +2 -2
- package/src/components/ui/panels/parametric-inspector.tsx +3 -3
- package/src/components/ui/panels/room-panel.tsx +65 -0
- package/src/components/ui/primitives/badge.tsx +8 -0
- package/src/components/ui/primitives/sidebar.tsx +1 -1
- package/src/components/ui/right-stack.tsx +21 -2
- package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +3 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
- package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
- package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/tab-bar.tsx +4 -4
- package/src/components/viewer/viewer-controls-bar.tsx +18 -6
- package/src/components/viewer/viewer-rooms.tsx +16 -0
- package/src/hooks/use-auto-save.integration.test.tsx +96 -38
- package/src/hooks/use-auto-save.ts +26 -1
- package/src/hooks/use-ceiling-events.test.ts +123 -118
- package/src/hooks/use-grid-events.test.ts +105 -105
- package/src/hooks/use-keyboard.test.ts +65 -16
- package/src/hooks/use-keyboard.ts +29 -12
- package/src/hooks/use-placement-notice.test.ts +24 -1
- package/src/hooks/use-placement-notice.ts +37 -6
- package/src/index.tsx +71 -24
- package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
- package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
- package/src/lib/add-storey.test.ts +177 -0
- package/src/lib/add-storey.ts +59 -0
- package/src/lib/below-grade.test.tsx +171 -0
- package/src/lib/below-grade.ts +133 -0
- package/src/lib/camera-facing.test.ts +24 -0
- package/src/lib/camera-facing.ts +24 -0
- package/src/lib/direct-manipulation.ts +2 -0
- package/src/lib/download-project-file.ts +17 -0
- package/src/lib/editor-level-display.ts +9 -2
- package/src/lib/editor-views.ts +35 -1
- package/src/lib/export-texture-utils.ts +2 -4
- package/src/lib/floor-foundation-support.test.ts +1 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
- package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
- package/src/lib/floorplan/sheet-export.test.ts +24 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
- package/src/lib/floorplan/site-plan/coverage.ts +2 -4
- package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
- package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
- package/src/lib/floorplan/site-plan/geometry.ts +1 -416
- package/src/lib/floorplan/site-plan/index.ts +0 -16
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
- package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
- package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
- package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
- package/src/lib/follow-pascal-exterior.test.ts +171 -0
- package/src/lib/follow-pascal-exterior.ts +39 -0
- package/src/lib/follow-pascal-presentation.test.ts +203 -0
- package/src/lib/follow-pascal-presentation.ts +129 -0
- package/src/lib/follow-pascal-sight.ts +84 -0
- package/src/lib/follow-pascal-targets.test.ts +406 -0
- package/src/lib/follow-pascal-targets.ts +198 -0
- package/src/lib/follow-pascal.test.ts +350 -0
- package/src/lib/follow-pascal.ts +284 -0
- package/src/lib/fresh-planar-placement.test.ts +112 -0
- package/src/lib/fresh-planar-placement.ts +57 -3
- package/src/lib/geometry-script/author.ts +6 -1
- package/src/lib/geometry-script/client.ts +8 -1
- package/src/lib/geometry-script/finish-scripts.ts +127 -0
- package/src/lib/geometry-script/protocol.ts +1 -0
- package/src/lib/glb-export.test.ts +187 -162
- package/src/lib/glb-export.ts +4 -1
- package/src/lib/history.test.ts +12 -7
- package/src/lib/interaction/scope.ts +4 -0
- package/src/lib/level-selection.test.ts +57 -0
- package/src/lib/level-selection.ts +16 -1
- package/src/lib/lot/clean-ring.test.ts +2 -1
- package/src/lib/lot/drop-in.ts +9 -24
- package/src/lib/lot/lot-patch.test.ts +1 -1
- package/src/lib/lot/lot-patch.ts +2 -2
- package/src/lib/measurement-kind.test.ts +6 -8
- package/src/lib/measurements.test.ts +23 -0
- package/src/lib/orbit-floor-clearance.test.ts +36 -0
- package/src/lib/orbit-floor-clearance.ts +48 -0
- package/src/lib/paint-pass-through.test.ts +11 -0
- package/src/lib/paint-pass-through.ts +4 -2
- package/src/lib/paint-scope.ts +1 -0
- package/src/lib/parametric-node-update.ts +16 -7
- package/src/lib/plugin-chat.ts +15 -0
- package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
- package/src/lib/point-ask/__fixtures__/house.ts +81 -0
- package/src/lib/point-ask/ask-created.test.ts +195 -0
- package/src/lib/point-ask/ask-flow.test.ts +380 -0
- package/src/lib/point-ask/build-context.test.ts +173 -0
- package/src/lib/point-ask/build-context.ts +94 -0
- package/src/lib/point-ask/capture-crop.ts +129 -0
- package/src/lib/point-ask/choreography.ts +69 -0
- package/src/lib/point-ask/compare.test.ts +145 -0
- package/src/lib/point-ask/compare.ts +73 -0
- package/src/lib/point-ask/describe-target.test.ts +165 -0
- package/src/lib/point-ask/describe-target.ts +234 -0
- package/src/lib/point-ask/dictation.ts +79 -0
- package/src/lib/point-ask/edge-marker.test.ts +61 -0
- package/src/lib/point-ask/edge-marker.ts +27 -0
- package/src/lib/point-ask/frame-loop.ts +25 -0
- package/src/lib/point-ask/history-marks.test.ts +94 -0
- package/src/lib/point-ask/history-marks.ts +39 -0
- package/src/lib/point-ask/host-api.ts +32 -0
- package/src/lib/point-ask/outline-box.test.ts +30 -0
- package/src/lib/point-ask/outline-box.ts +11 -0
- package/src/lib/point-ask/overlay-metrics.ts +6 -0
- package/src/lib/point-ask/pick.test.ts +220 -0
- package/src/lib/point-ask/pick.ts +121 -0
- package/src/lib/point-ask/pin-machine.test.ts +494 -0
- package/src/lib/point-ask/pin-machine.ts +217 -0
- package/src/lib/point-ask/placement.test.ts +203 -0
- package/src/lib/point-ask/placement.ts +141 -0
- package/src/lib/point-ask/pointer-tracker.ts +34 -0
- package/src/lib/point-ask/projector.ts +83 -0
- package/src/lib/point-ask/region.test.ts +157 -0
- package/src/lib/point-ask/region.ts +136 -0
- package/src/lib/point-ask/retry.test.ts +217 -0
- package/src/lib/point-ask/retry.ts +37 -0
- package/src/lib/point-ask/scene-commit.test.ts +312 -0
- package/src/lib/point-ask/scene-commit.ts +32 -0
- package/src/lib/point-ask/session.ts +1391 -0
- package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
- package/src/lib/point-ask/spring-keyframes.ts +38 -0
- package/src/lib/point-ask/spring.test.ts +63 -0
- package/src/lib/point-ask/spring.ts +107 -0
- package/src/lib/point-ask/suggestions.test.ts +80 -0
- package/src/lib/point-ask/suggestions.ts +125 -0
- package/src/lib/point-ask/target-scope.test.ts +622 -0
- package/src/lib/point-ask/target-scope.ts +338 -0
- package/src/lib/point-ask/types.ts +48 -0
- package/src/lib/portable-export.test.ts +34 -1
- package/src/lib/portable-export.ts +119 -53
- package/src/lib/print-feature-thickness.ts +10 -1
- package/src/lib/print-roof-solids.test.ts +39 -1
- package/src/lib/print-roof-solids.ts +43 -10
- package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
- package/src/lib/room-assembly-overlay.ts +2 -3
- package/src/lib/room-selection.test.ts +45 -1
- package/src/lib/scene-clipboard.test.ts +246 -1
- package/src/lib/scene-clipboard.ts +44 -14
- package/src/lib/selection-routing.ts +1 -1
- package/src/lib/sfx-bus.ts +13 -0
- package/src/lib/sfx-player.test.ts +70 -9
- package/src/lib/sfx-player.ts +6 -0
- package/src/lib/stair-levels.test.ts +25 -0
- package/src/lib/stair-levels.ts +22 -3
- package/src/lib/terrace-draft.ts +2 -1
- package/src/lib/viewer-selection.ts +22 -11
- package/src/lib/wall-placement-pointer.test.ts +42 -0
- package/src/lib/wall-placement-pointer.ts +57 -0
- package/src/lib/window-changes.ts +35 -0
- package/src/store/use-audio.test.ts +146 -0
- package/src/store/use-audio.tsx +127 -3
- package/src/store/use-construction-reveal.test.ts +173 -0
- package/src/store/use-construction-reveal.ts +145 -0
- package/src/store/use-corner-snap-hint.test.ts +24 -0
- package/src/store/use-corner-snap-hint.ts +47 -0
- package/src/store/use-follow-pascal.test.ts +62 -0
- package/src/store/use-follow-pascal.ts +53 -0
- package/src/store/use-point-ask.ts +247 -0
- package/src/store/use-stair-build-preview.ts +25 -5
- package/src/store/view-layout.test.ts +27 -1
- package/src/store/view-layout.ts +4 -1
- package/src/test-utils/render-current-markup.tsx +16 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
- package/src/components/tools/stair/stair-click-guard.ts +0 -72
- package/src/components/tools/stair/stair-defaults.ts +0 -15
- package/src/components/tools/stair/stair-tool.tsx +0 -682
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { alignFloorplanDraftPoint, useAlignmentGuides } from '@pascal-app/editor'
|
|
4
|
+
|
|
5
|
+
type Vec3 = [number, number, number]
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Layer Figma-style alignment guides onto a draw-tool cursor point so HVAC /
|
|
9
|
+
* DWV runs and equipment line up with each other (and every other node) while
|
|
10
|
+
* being drawn — the same feedback walls get.
|
|
11
|
+
*
|
|
12
|
+
* Treats the point as a single corner anchor, gathers candidates from the live
|
|
13
|
+
* scene (every kind contributes via `nodeAlignmentAnchors`), publishes the
|
|
14
|
+
* guides to `useAlignmentGuides` (rendered in BOTH the 2D floor plan and the
|
|
15
|
+
* 3D view), and returns the point with the snap applied. Y is preserved — only
|
|
16
|
+
* XZ is aligned.
|
|
17
|
+
*
|
|
18
|
+
* - `applySnap: false` publishes the guide passively without pulling the point
|
|
19
|
+
* off a constrained ray (e.g. an angle-locked run continuation).
|
|
20
|
+
* - `bypass: true` clears guides and returns the point untouched (Alt, or when
|
|
21
|
+
* a stronger port / run-body snap already won).
|
|
22
|
+
*/
|
|
23
|
+
export function alignDrawPoint(point: Vec3, opts: { applySnap: boolean; bypass?: boolean }): Vec3 {
|
|
24
|
+
if (opts.bypass) {
|
|
25
|
+
useAlignmentGuides.getState().clear()
|
|
26
|
+
return point
|
|
27
|
+
}
|
|
28
|
+
const [x, z] = alignFloorplanDraftPoint([point[0], point[2]], { applySnap: opts.applySnap })
|
|
29
|
+
return [x, point[1], z]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Drop any alignment guides this tool published (cancel / commit / unmount). */
|
|
33
|
+
export function clearDrawAlignment(): void {
|
|
34
|
+
useAlignmentGuides.getState().clear()
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { RunSurfaceTarget } from './distribution-run-contract'
|
|
3
|
+
import { findNearestPort3D, findNearestPortXZ, type ScenePort } from './ports'
|
|
4
|
+
|
|
5
|
+
const ports: ScenePort[] = [
|
|
6
|
+
{
|
|
7
|
+
nodeId: 'duct-segment_a',
|
|
8
|
+
id: 'floor',
|
|
9
|
+
position: [0, 0, 0],
|
|
10
|
+
diameter: 0.1,
|
|
11
|
+
direction: [1, 0, 0],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
nodeId: 'duct-segment_b',
|
|
15
|
+
id: 'wall',
|
|
16
|
+
position: [0.4, 2, 0],
|
|
17
|
+
diameter: 0.1,
|
|
18
|
+
direction: [1, 0, 0],
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
describe('distribution port distance metrics', () => {
|
|
23
|
+
test('wall drafting uses true 3D distance', () => {
|
|
24
|
+
expect(findNearestPort3D([0.4, 1.8, 0], ports, 0.5)?.id).toBe('wall')
|
|
25
|
+
expect(findNearestPort3D([0, 1.8, 0], ports, 0.1)).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('floor drafting retains the legacy XZ metric', () => {
|
|
29
|
+
expect(findNearestPortXZ([0, 1.8, 0], ports, 0.1)?.id).toBe('floor')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('wall drafting rejects a nearby port on the wrong plane', () => {
|
|
33
|
+
const wall: RunSurfaceTarget = {
|
|
34
|
+
kind: 'wall',
|
|
35
|
+
levelId: 'level_1',
|
|
36
|
+
hostId: 'wall_1',
|
|
37
|
+
side: 'front',
|
|
38
|
+
frame: {
|
|
39
|
+
origin: [0, 0, 0],
|
|
40
|
+
normal: [0, 0, 1],
|
|
41
|
+
tangent: [1, 0, 0],
|
|
42
|
+
bitangent: [0, 1, 0],
|
|
43
|
+
},
|
|
44
|
+
bounds: { minU: 0, maxU: 10, minV: 0, maxV: 3 },
|
|
45
|
+
}
|
|
46
|
+
const offPlane: ScenePort = {
|
|
47
|
+
nodeId: 'duct-segment_c',
|
|
48
|
+
id: 'off-plane',
|
|
49
|
+
position: [0, 1.8, 0.25],
|
|
50
|
+
diameter: 0.1,
|
|
51
|
+
direction: [1, 0, 0],
|
|
52
|
+
}
|
|
53
|
+
expect(findNearestPort3D([0, 1.8, 0], [offPlane], 0.1, wall)).toBeNull()
|
|
54
|
+
expect(findNearestPort3D([0.4, 2, 0], ports, 0.1, wall)?.id).toBe('wall')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
findLevelAncestorId,
|
|
5
|
+
type NodePort,
|
|
6
|
+
nodeRegistry,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import type { RunSurfaceTarget } from './distribution-run-contract'
|
|
10
|
+
|
|
11
|
+
/** A port plus the scene node that owns it. */
|
|
12
|
+
export type ScenePort = NodePort & { nodeId: AnyNodeId }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Filter narrowing which ports a tool will snap to.
|
|
16
|
+
* - `excludeNodeId` skips the node currently being drawn/placed so a
|
|
17
|
+
* tool doesn't snap to its own preview.
|
|
18
|
+
* - `systems` keeps only ports on the listed distribution loops — duct
|
|
19
|
+
* tools pass the air loops so they ignore refrigerant service ports;
|
|
20
|
+
* the lineset tool passes `'refrigerant'` so it ignores duct collars.
|
|
21
|
+
* A port with no `system` matches any filter.
|
|
22
|
+
*/
|
|
23
|
+
export type PortFilter = {
|
|
24
|
+
levelId?: AnyNodeId
|
|
25
|
+
excludeNodeId?: AnyNodeId
|
|
26
|
+
systems?: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Gather every typed port in the scene by asking each node's registered
|
|
31
|
+
* `def.ports`. Positions are level-local meters (the kind applies its own
|
|
32
|
+
* transform inside `def.ports`).
|
|
33
|
+
*/
|
|
34
|
+
export function collectScenePorts(
|
|
35
|
+
filter: PortFilter = {},
|
|
36
|
+
sceneNodes?: Readonly<Record<AnyNodeId, AnyNode>>,
|
|
37
|
+
): ScenePort[] {
|
|
38
|
+
const { excludeNodeId, systems, levelId } = filter
|
|
39
|
+
const nodes = sceneNodes ?? useScene.getState().nodes
|
|
40
|
+
const result: ScenePort[] = []
|
|
41
|
+
for (const node of Object.values(nodes)) {
|
|
42
|
+
if (
|
|
43
|
+
!node ||
|
|
44
|
+
node.visible === false ||
|
|
45
|
+
node.id === excludeNodeId ||
|
|
46
|
+
(levelId && findLevelAncestorId(node.id, nodes) !== levelId)
|
|
47
|
+
)
|
|
48
|
+
continue
|
|
49
|
+
const ports = nodeRegistry.get(node.type)?.ports?.(node)
|
|
50
|
+
if (!ports) continue
|
|
51
|
+
for (const port of ports) {
|
|
52
|
+
if (systems && port.system !== undefined && !systems.includes(port.system)) continue
|
|
53
|
+
result.push({ ...port, nodeId: node.id })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Nearest port within `radius` of `point` on the XZ plane. Y is ignored —
|
|
61
|
+
* grid events ride the floor plane while ports usually hang at duct
|
|
62
|
+
* height, so a vertical-distance check would make elevated ports
|
|
63
|
+
* unreachable. The snap adopts the port's full 3D position.
|
|
64
|
+
*/
|
|
65
|
+
export function findNearestPortXZ(
|
|
66
|
+
point: readonly [number, number, number],
|
|
67
|
+
ports: ScenePort[],
|
|
68
|
+
radius: number,
|
|
69
|
+
): ScenePort | null {
|
|
70
|
+
let best: ScenePort | null = null
|
|
71
|
+
let bestDistSq = radius * radius
|
|
72
|
+
for (const port of ports) {
|
|
73
|
+
const dx = port.position[0] - point[0]
|
|
74
|
+
const dz = port.position[2] - point[2]
|
|
75
|
+
const distSq = dx * dx + dz * dz
|
|
76
|
+
if (distSq <= bestDistSq) {
|
|
77
|
+
bestDistSq = distSq
|
|
78
|
+
best = port
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return best
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Nearest port using true 3D distance. Use this while drafting on a wall. */
|
|
85
|
+
export function findNearestPort3D(
|
|
86
|
+
point: readonly [number, number, number],
|
|
87
|
+
ports: ScenePort[],
|
|
88
|
+
radius: number,
|
|
89
|
+
surface?: RunSurfaceTarget | null,
|
|
90
|
+
): ScenePort | null {
|
|
91
|
+
let best: ScenePort | null = null
|
|
92
|
+
let bestDistSq = radius * radius
|
|
93
|
+
for (const port of ports) {
|
|
94
|
+
if (surface?.kind === 'wall') {
|
|
95
|
+
const planeDistance =
|
|
96
|
+
(port.position[0] - surface.frame.origin[0]) * surface.frame.normal[0] +
|
|
97
|
+
(port.position[1] - surface.frame.origin[1]) * surface.frame.normal[1] +
|
|
98
|
+
(port.position[2] - surface.frame.origin[2]) * surface.frame.normal[2]
|
|
99
|
+
if (Math.abs(planeDistance) > radius) continue
|
|
100
|
+
}
|
|
101
|
+
const dx = port.position[0] - point[0]
|
|
102
|
+
const dy = port.position[1] - point[1]
|
|
103
|
+
const dz = port.position[2] - point[2]
|
|
104
|
+
const distSq = dx * dx + dy * dy + dz * dz
|
|
105
|
+
if (distSq <= bestDistSq) {
|
|
106
|
+
bestDistSq = distSq
|
|
107
|
+
best = port
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return best
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Run-body hits ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** Closest-point hit on a run's centerline (not its end ports). */
|
|
116
|
+
export type RunBodyHit = {
|
|
117
|
+
nodeId: AnyNodeId
|
|
118
|
+
/** Polyline segment hit — between `path[segmentIndex]` and `path[segmentIndex + 1]`. */
|
|
119
|
+
segmentIndex: number
|
|
120
|
+
/** Closest point on the centerline, level-local meters (Y interpolated). */
|
|
121
|
+
point: [number, number, number]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Nearest point on any run CENTERLINE of the given kinds within `radius` of `point`
|
|
126
|
+
* on the XZ plane — how a branch taps the side of a trunk. Same XZ-only
|
|
127
|
+
* distance convention as `findNearestPortXZ` (grid events ride the floor,
|
|
128
|
+
* runs hang above it); the hit adopts the centerline's full 3D
|
|
129
|
+
* position. Vertical risers project to a point in XZ and are skipped —
|
|
130
|
+
* tapping those isn't meaningful.
|
|
131
|
+
*/
|
|
132
|
+
export function findNearestRunBodyXZ(
|
|
133
|
+
point: readonly [number, number, number],
|
|
134
|
+
radius: number,
|
|
135
|
+
filter: { excludeNodeId?: AnyNodeId; kinds: readonly string[]; levelId?: AnyNodeId },
|
|
136
|
+
sceneNodes?: Readonly<Record<AnyNodeId, AnyNode>>,
|
|
137
|
+
): RunBodyHit | null {
|
|
138
|
+
const { kinds } = filter
|
|
139
|
+
const nodes = sceneNodes ?? useScene.getState().nodes
|
|
140
|
+
let best: RunBodyHit | null = null
|
|
141
|
+
let bestDistSq = radius * radius
|
|
142
|
+
for (const node of Object.values(nodes)) {
|
|
143
|
+
if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue
|
|
144
|
+
if (filter.levelId && findLevelAncestorId(node.id, nodes) !== filter.levelId) continue
|
|
145
|
+
const path = (node as { path?: Array<readonly [number, number, number]> }).path
|
|
146
|
+
if (!path) continue
|
|
147
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
148
|
+
const a = path[i]!
|
|
149
|
+
const b = path[i + 1]!
|
|
150
|
+
const abx = b[0] - a[0]
|
|
151
|
+
const abz = b[2] - a[2]
|
|
152
|
+
const lenSq = abx * abx + abz * abz
|
|
153
|
+
if (lenSq < 1e-8) continue // vertical riser — no XZ extent
|
|
154
|
+
const t = Math.min(
|
|
155
|
+
1,
|
|
156
|
+
Math.max(0, ((point[0] - a[0]) * abx + (point[2] - a[2]) * abz) / lenSq),
|
|
157
|
+
)
|
|
158
|
+
const cx = a[0] + abx * t
|
|
159
|
+
const cz = a[2] + abz * t
|
|
160
|
+
const dx = point[0] - cx
|
|
161
|
+
const dz = point[2] - cz
|
|
162
|
+
const distSq = dx * dx + dz * dz
|
|
163
|
+
if (distSq <= bestDistSq) {
|
|
164
|
+
bestDistSq = distSq
|
|
165
|
+
best = {
|
|
166
|
+
nodeId: node.id,
|
|
167
|
+
segmentIndex: i,
|
|
168
|
+
point: [cx, a[1] + (b[1] - a[1]) * t, cz],
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return best
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Nearest run centerline point using true 3D distance, including risers. */
|
|
177
|
+
export function findNearestRunBody3D(
|
|
178
|
+
point: readonly [number, number, number],
|
|
179
|
+
radius: number,
|
|
180
|
+
filter: { excludeNodeId?: AnyNodeId; kinds: readonly string[]; levelId?: AnyNodeId },
|
|
181
|
+
surface?: RunSurfaceTarget | null,
|
|
182
|
+
sceneNodes?: Readonly<Record<AnyNodeId, AnyNode>>,
|
|
183
|
+
): RunBodyHit | null {
|
|
184
|
+
const { kinds } = filter
|
|
185
|
+
const nodes = sceneNodes ?? useScene.getState().nodes
|
|
186
|
+
let best: RunBodyHit | null = null
|
|
187
|
+
let bestDistSq = radius * radius
|
|
188
|
+
for (const node of Object.values(nodes)) {
|
|
189
|
+
if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue
|
|
190
|
+
if (filter.levelId && findLevelAncestorId(node.id, nodes) !== filter.levelId) continue
|
|
191
|
+
if (node.visible === false) continue
|
|
192
|
+
const path = (node as { path?: Array<readonly [number, number, number]> }).path
|
|
193
|
+
if (!path) continue
|
|
194
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
195
|
+
const a = path[i]!
|
|
196
|
+
const b = path[i + 1]!
|
|
197
|
+
if (surface?.kind === 'wall') {
|
|
198
|
+
const aPlane =
|
|
199
|
+
(a[0] - surface.frame.origin[0]) * surface.frame.normal[0] +
|
|
200
|
+
(a[1] - surface.frame.origin[1]) * surface.frame.normal[1] +
|
|
201
|
+
(a[2] - surface.frame.origin[2]) * surface.frame.normal[2]
|
|
202
|
+
const bPlane =
|
|
203
|
+
(b[0] - surface.frame.origin[0]) * surface.frame.normal[0] +
|
|
204
|
+
(b[1] - surface.frame.origin[1]) * surface.frame.normal[1] +
|
|
205
|
+
(b[2] - surface.frame.origin[2]) * surface.frame.normal[2]
|
|
206
|
+
if (Math.abs(aPlane) > radius && Math.abs(bPlane) > radius) continue
|
|
207
|
+
}
|
|
208
|
+
const abx = b[0] - a[0]
|
|
209
|
+
const aby = b[1] - a[1]
|
|
210
|
+
const abz = b[2] - a[2]
|
|
211
|
+
const lenSq = abx * abx + aby * aby + abz * abz
|
|
212
|
+
if (lenSq < 1e-8) continue
|
|
213
|
+
const t = Math.min(
|
|
214
|
+
1,
|
|
215
|
+
Math.max(
|
|
216
|
+
0,
|
|
217
|
+
((point[0] - a[0]) * abx + (point[1] - a[1]) * aby + (point[2] - a[2]) * abz) / lenSq,
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
const hit: [number, number, number] = [a[0] + abx * t, a[1] + aby * t, a[2] + abz * t]
|
|
221
|
+
const dx = point[0] - hit[0]
|
|
222
|
+
const dy = point[1] - hit[1]
|
|
223
|
+
const dz = point[2] - hit[2]
|
|
224
|
+
const distSq = dx * dx + dy * dy + dz * dz
|
|
225
|
+
if (distSq <= bestDistSq) {
|
|
226
|
+
bestDistSq = distSq
|
|
227
|
+
best = { nodeId: node.id, segmentIndex: i, point: hit }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return best
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Where a drawn segment `start`→`end` crosses straight THROUGH an
|
|
236
|
+
* existing run's centerline in XZ — the four-way (cross) case, as
|
|
237
|
+
* opposed to ending ON a run (the tee case). The crossing must be
|
|
238
|
+
* INTERIOR to both: strictly between the drawn segment's ends (so the
|
|
239
|
+
* run truly passes through, not just touches at a tip — those are tee
|
|
240
|
+
* taps) and strictly inside the hit trunk segment, clear of its joints
|
|
241
|
+
* by `endMargin` meters so the run legs have room. The hit's `point`
|
|
242
|
+
* adopts the trunk centerline's interpolated 3D position (the drawn run
|
|
243
|
+
* snaps onto the trunk's height). Returns the nearest such crossing, or
|
|
244
|
+
* null. Vertical risers (no XZ extent) are skipped, same as the body
|
|
245
|
+
* query.
|
|
246
|
+
*/
|
|
247
|
+
export function findRunBodyCrossingXZ(
|
|
248
|
+
start: readonly [number, number, number],
|
|
249
|
+
end: readonly [number, number, number],
|
|
250
|
+
endMargin: number,
|
|
251
|
+
filter: { excludeNodeId?: AnyNodeId; kinds: readonly string[] },
|
|
252
|
+
): RunBodyHit | null {
|
|
253
|
+
const { kinds } = filter
|
|
254
|
+
const { nodes } = useScene.getState()
|
|
255
|
+
const dx = end[0] - start[0]
|
|
256
|
+
const dz = end[2] - start[2]
|
|
257
|
+
const drawnLenSq = dx * dx + dz * dz
|
|
258
|
+
if (drawnLenSq < 1e-8) return null
|
|
259
|
+
const drawnLen = Math.sqrt(drawnLenSq)
|
|
260
|
+
// Interior margins as a fraction of each segment's length.
|
|
261
|
+
const drawnPad = Math.min(0.45, endMargin / drawnLen)
|
|
262
|
+
let best: RunBodyHit | null = null
|
|
263
|
+
let bestScore = Number.POSITIVE_INFINITY
|
|
264
|
+
for (const node of Object.values(nodes)) {
|
|
265
|
+
if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue
|
|
266
|
+
const path = (node as { path?: Array<readonly [number, number, number]> }).path
|
|
267
|
+
if (!path) continue
|
|
268
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
269
|
+
const a = path[i]!
|
|
270
|
+
const b = path[i + 1]!
|
|
271
|
+
const ex = b[0] - a[0]
|
|
272
|
+
const ez = b[2] - a[2]
|
|
273
|
+
const runLenSq = ex * ex + ez * ez
|
|
274
|
+
if (runLenSq < 1e-8) continue // vertical riser — no XZ extent
|
|
275
|
+
// Solve start + s·d = a + t·e in XZ. denom is the 2D cross of the
|
|
276
|
+
// two directions; ~0 means parallel (no single crossing).
|
|
277
|
+
const denom = dx * ez - dz * ex
|
|
278
|
+
if (Math.abs(denom) < 1e-9) continue
|
|
279
|
+
const wx = a[0] - start[0]
|
|
280
|
+
const wz = a[2] - start[2]
|
|
281
|
+
const s = (wx * ez - wz * ex) / denom
|
|
282
|
+
const t = (wx * dz - wz * dx) / denom
|
|
283
|
+
const runLen = Math.sqrt(runLenSq)
|
|
284
|
+
const runPad = Math.min(0.45, endMargin / runLen)
|
|
285
|
+
// Strictly interior to both segments, clear of the trunk's joints.
|
|
286
|
+
if (s <= drawnPad || s >= 1 - drawnPad) continue
|
|
287
|
+
if (t <= runPad || t >= 1 - runPad) continue
|
|
288
|
+
// Prefer the crossing nearest the drawn start (first run hit).
|
|
289
|
+
if (s < bestScore) {
|
|
290
|
+
bestScore = s
|
|
291
|
+
best = {
|
|
292
|
+
nodeId: node.id,
|
|
293
|
+
segmentIndex: i,
|
|
294
|
+
point: [a[0] + ex * t, a[1] + (b[1] - a[1]) * t, a[2] + ez * t],
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return best
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Surface-local crossing for wall drafting. Intersects projected U/V lines
|
|
303
|
+
* and ignores runs that are not coplanar with the selected wall. */
|
|
304
|
+
export function findRunBodyCrossingSurface(
|
|
305
|
+
start: readonly [number, number, number],
|
|
306
|
+
end: readonly [number, number, number],
|
|
307
|
+
endMargin: number,
|
|
308
|
+
surface: RunSurfaceTarget,
|
|
309
|
+
filter: { excludeNodeId?: AnyNodeId; kinds: readonly string[] },
|
|
310
|
+
): RunBodyHit | null {
|
|
311
|
+
const project = (p: readonly [number, number, number]): [number, number, number] => {
|
|
312
|
+
const d: [number, number, number] = [
|
|
313
|
+
p[0] - surface.frame.origin[0],
|
|
314
|
+
p[1] - surface.frame.origin[1],
|
|
315
|
+
p[2] - surface.frame.origin[2],
|
|
316
|
+
]
|
|
317
|
+
return [
|
|
318
|
+
d[0] * surface.frame.tangent[0] +
|
|
319
|
+
d[1] * surface.frame.tangent[1] +
|
|
320
|
+
d[2] * surface.frame.tangent[2],
|
|
321
|
+
d[0] * surface.frame.bitangent[0] +
|
|
322
|
+
d[1] * surface.frame.bitangent[1] +
|
|
323
|
+
d[2] * surface.frame.bitangent[2],
|
|
324
|
+
d[0] * surface.frame.normal[0] +
|
|
325
|
+
d[1] * surface.frame.normal[1] +
|
|
326
|
+
d[2] * surface.frame.normal[2],
|
|
327
|
+
]
|
|
328
|
+
}
|
|
329
|
+
const s0 = project(start),
|
|
330
|
+
s1 = project(end)
|
|
331
|
+
const dx = s1[0] - s0[0],
|
|
332
|
+
dy = s1[1] - s0[1]
|
|
333
|
+
const drawnLen = Math.hypot(dx, dy)
|
|
334
|
+
if (drawnLen < 1e-8) return null
|
|
335
|
+
const drawnPad = Math.min(0.45, endMargin / drawnLen)
|
|
336
|
+
const { kinds } = filter
|
|
337
|
+
const { nodes } = useScene.getState()
|
|
338
|
+
let best: RunBodyHit | null = null
|
|
339
|
+
let bestScore = Number.POSITIVE_INFINITY
|
|
340
|
+
for (const node of Object.values(nodes)) {
|
|
341
|
+
if (
|
|
342
|
+
!node ||
|
|
343
|
+
node.visible === false ||
|
|
344
|
+
node.parentId !== surface.levelId ||
|
|
345
|
+
!kinds.includes(node.type) ||
|
|
346
|
+
node.id === filter.excludeNodeId
|
|
347
|
+
)
|
|
348
|
+
continue
|
|
349
|
+
const path = (node as { path?: Array<readonly [number, number, number]> }).path
|
|
350
|
+
if (!path) continue
|
|
351
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
352
|
+
const a = path[i]!,
|
|
353
|
+
b = path[i + 1]!,
|
|
354
|
+
pa = project(a),
|
|
355
|
+
pb = project(b)
|
|
356
|
+
const ex = pb[0] - pa[0],
|
|
357
|
+
ey = pb[1] - pa[1],
|
|
358
|
+
runLen = Math.hypot(ex, ey)
|
|
359
|
+
const denom = dx * ey - dy * ex
|
|
360
|
+
if (runLen < 1e-8 || Math.abs(denom) < 1e-9) continue
|
|
361
|
+
const wx = pa[0] - s0[0],
|
|
362
|
+
wy = pa[1] - s0[1]
|
|
363
|
+
const s = (wx * ey - wy * ex) / denom,
|
|
364
|
+
t = (wx * dy - wy * dx) / denom
|
|
365
|
+
if (Math.abs(pa[2] + t * (pb[2] - pa[2]) - (s0[2] + s * (s1[2] - s0[2]))) > 0.01) continue
|
|
366
|
+
const runPad = Math.min(0.45, endMargin / runLen)
|
|
367
|
+
if (s <= drawnPad || s >= 1 - drawnPad || t <= runPad || t >= 1 - runPad || s >= bestScore)
|
|
368
|
+
continue
|
|
369
|
+
bestScore = s
|
|
370
|
+
best = {
|
|
371
|
+
nodeId: node.id,
|
|
372
|
+
segmentIndex: i,
|
|
373
|
+
point: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t],
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return best
|
|
378
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNodeId } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
createRunSurfaceFrame,
|
|
5
|
+
isSameRunWallSurface,
|
|
6
|
+
shouldLockRunWallSurface,
|
|
7
|
+
} from './distribution-run-tool'
|
|
8
|
+
import { intersectRunPlane, resolveRunCursorPlane } from './run-cursor'
|
|
9
|
+
|
|
10
|
+
describe('surface-first run cursor', () => {
|
|
11
|
+
test('matches only the wall host and face that started the draft', () => {
|
|
12
|
+
const target = {
|
|
13
|
+
kind: 'wall' as const,
|
|
14
|
+
levelId: 'level-1' as AnyNodeId,
|
|
15
|
+
hostId: 'wall-1' as AnyNodeId,
|
|
16
|
+
side: 'front' as const,
|
|
17
|
+
frame: createRunSurfaceFrame([0, 0, 0], [0, 0, 1]),
|
|
18
|
+
bounds: { minU: -1, maxU: 1, minV: -1, maxV: 1 },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
expect(
|
|
22
|
+
isSameRunWallSurface(target, {
|
|
23
|
+
kind: 'wall',
|
|
24
|
+
hostId: target.hostId,
|
|
25
|
+
face: 'side',
|
|
26
|
+
side: 'front',
|
|
27
|
+
}),
|
|
28
|
+
).toBe(true)
|
|
29
|
+
expect(
|
|
30
|
+
isSameRunWallSurface(target, {
|
|
31
|
+
kind: 'wall',
|
|
32
|
+
hostId: target.hostId,
|
|
33
|
+
face: 'side',
|
|
34
|
+
side: 'back',
|
|
35
|
+
}),
|
|
36
|
+
).toBe(false)
|
|
37
|
+
expect(
|
|
38
|
+
isSameRunWallSurface(target, {
|
|
39
|
+
kind: 'wall',
|
|
40
|
+
hostId: 'wall-2',
|
|
41
|
+
face: 'side',
|
|
42
|
+
side: 'front',
|
|
43
|
+
}),
|
|
44
|
+
).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('locks a wall only when the start is not snapped to a body or port', () => {
|
|
48
|
+
const target = {
|
|
49
|
+
kind: 'wall' as const,
|
|
50
|
+
levelId: 'level-1' as AnyNodeId,
|
|
51
|
+
hostId: 'wall-1' as AnyNodeId,
|
|
52
|
+
side: 'front' as const,
|
|
53
|
+
frame: createRunSurfaceFrame([0, 0, 0], [0, 0, 1]),
|
|
54
|
+
bounds: { minU: 0, maxU: 1, minV: 0, maxV: 1 },
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
expect(shouldLockRunWallSurface(target, { port: null, body: null })).toBe(true)
|
|
58
|
+
expect(shouldLockRunWallSurface(target, { port: {} as never, body: null })).toBe(false)
|
|
59
|
+
expect(shouldLockRunWallSurface(target, { port: null, body: {} as never })).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('reacquires either ceiling face from free space with duct or pipe clearance', () => {
|
|
63
|
+
for (const side of [-1, 1]) {
|
|
64
|
+
for (const clearance of [0.0254, 0.1016]) {
|
|
65
|
+
const result = resolveRunCursorPlane({
|
|
66
|
+
hit: {
|
|
67
|
+
point: [2, 3, 4],
|
|
68
|
+
frame: createRunSurfaceFrame([0, 3, 0], [0, side, 0]),
|
|
69
|
+
},
|
|
70
|
+
working: createRunSurfaceFrame([0, 1, 0]),
|
|
71
|
+
fallback: [2, 1, 4],
|
|
72
|
+
clearance,
|
|
73
|
+
})
|
|
74
|
+
expect(result.point[1]).toBeCloseTo(3 + side * clearance)
|
|
75
|
+
expect(result.frame.origin[1]).toBeCloseTo(result.point[1])
|
|
76
|
+
expect(result.frame.normal).toEqual([0, side, 0])
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('wall hit wins over a ground fallback and applies clearance once', () => {
|
|
82
|
+
const frame = createRunSurfaceFrame([0, 0, 2], [0, 0, 1])
|
|
83
|
+
const result = resolveRunCursorPlane({
|
|
84
|
+
hit: { point: [1, 1.5, 2], frame },
|
|
85
|
+
working: null,
|
|
86
|
+
fallback: [9, 0, 9],
|
|
87
|
+
clearance: 0.0508,
|
|
88
|
+
})
|
|
89
|
+
expect(result.point).toEqual([1, 1.5, 2.0508])
|
|
90
|
+
})
|
|
91
|
+
test('empty space continues on the wall plane along the actual mouse ray', () => {
|
|
92
|
+
const frame = createRunSurfaceFrame([0, 0, 2], [0, 0, 1])
|
|
93
|
+
const result = resolveRunCursorPlane({
|
|
94
|
+
hit: null,
|
|
95
|
+
working: frame,
|
|
96
|
+
ray: { origin: [0, 4, 6], direction: [1, -0.5, -1] },
|
|
97
|
+
fallback: [0, 0, 0],
|
|
98
|
+
clearance: 0.1,
|
|
99
|
+
})
|
|
100
|
+
expect(result.point).toEqual([4, 2, 2])
|
|
101
|
+
})
|
|
102
|
+
test('ceiling target replaces wall plane and offsets downward', () => {
|
|
103
|
+
const result = resolveRunCursorPlane({
|
|
104
|
+
hit: { point: [2, 3, 4], frame: createRunSurfaceFrame([0, 3, 0], [0, -1, 0]) },
|
|
105
|
+
working: createRunSurfaceFrame([0, 0, 4], [0, 0, 1]),
|
|
106
|
+
fallback: [0, 0, 0],
|
|
107
|
+
clearance: 0.1,
|
|
108
|
+
})
|
|
109
|
+
expect(result.point).toEqual([2, 2.9, 4])
|
|
110
|
+
})
|
|
111
|
+
test('parallel and behind-camera intersections preserve the last point', () => {
|
|
112
|
+
const working = createRunSurfaceFrame([0, 0, 2], [0, 0, 1])
|
|
113
|
+
expect(intersectRunPlane({ origin: [0, 0, 0], direction: [0, 0, -1] }, working)).toBeNull()
|
|
114
|
+
expect(
|
|
115
|
+
resolveRunCursorPlane({
|
|
116
|
+
hit: null,
|
|
117
|
+
working,
|
|
118
|
+
ray: { origin: [0, 0, 0], direction: [1, 0, 0] },
|
|
119
|
+
fallback: [1, 2, 2],
|
|
120
|
+
clearance: 0,
|
|
121
|
+
}).point,
|
|
122
|
+
).toEqual([1, 2, 2])
|
|
123
|
+
})
|
|
124
|
+
test('floor and sloped planes use their own normal for clearance', () => {
|
|
125
|
+
const frame = createRunSurfaceFrame([0, 2, 0], [0, Math.SQRT1_2, Math.SQRT1_2])
|
|
126
|
+
const result = resolveRunCursorPlane({
|
|
127
|
+
hit: { point: [0, 2, 0], frame },
|
|
128
|
+
working: null,
|
|
129
|
+
fallback: [0, 0, 0],
|
|
130
|
+
clearance: 0.1,
|
|
131
|
+
})
|
|
132
|
+
expect(result.point[1]).toBeCloseTo(2 + Math.SQRT1_2 * 0.1)
|
|
133
|
+
expect(result.point[2]).toBeCloseTo(Math.SQRT1_2 * 0.1)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RunPoint, RunSurfaceFrame } from './distribution-run-contract'
|
|
2
|
+
|
|
3
|
+
export function intersectRunPlane(
|
|
4
|
+
ray: { origin: RunPoint; direction: RunPoint },
|
|
5
|
+
frame: RunSurfaceFrame,
|
|
6
|
+
): RunPoint | null {
|
|
7
|
+
const denominator = ray.direction.reduce((sum, value, i) => sum + value * frame.normal[i]!, 0)
|
|
8
|
+
if (Math.abs(denominator) < 1e-5) return null
|
|
9
|
+
const distance =
|
|
10
|
+
frame.origin.reduce((sum, value, i) => sum + (value - ray.origin[i]!) * frame.normal[i]!, 0) /
|
|
11
|
+
denominator
|
|
12
|
+
if (distance < 0 || !Number.isFinite(distance)) return null
|
|
13
|
+
return ray.origin.map((value, i) => value + distance * ray.direction[i]!) as RunPoint
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveRunCursorPlane({
|
|
17
|
+
hit,
|
|
18
|
+
working,
|
|
19
|
+
ray,
|
|
20
|
+
fallback,
|
|
21
|
+
clearance,
|
|
22
|
+
}: {
|
|
23
|
+
hit: { point: RunPoint; frame: RunSurfaceFrame } | null
|
|
24
|
+
working: RunSurfaceFrame | null
|
|
25
|
+
ray?: { origin: RunPoint; direction: RunPoint }
|
|
26
|
+
fallback: RunPoint
|
|
27
|
+
clearance: number
|
|
28
|
+
}): { point: RunPoint; frame: RunSurfaceFrame } {
|
|
29
|
+
if (hit) {
|
|
30
|
+
const offset = (point: RunPoint): RunPoint =>
|
|
31
|
+
point.map((value, i) => value + hit.frame.normal[i]! * clearance) as RunPoint
|
|
32
|
+
return { point: offset(hit.point), frame: { ...hit.frame, origin: offset(hit.frame.origin) } }
|
|
33
|
+
}
|
|
34
|
+
if (!working) throw new Error('A cursor requires a surface or a working plane')
|
|
35
|
+
return { point: ray ? (intersectRunPlane(ray, working) ?? fallback) : fallback, frame: working }
|
|
36
|
+
}
|