118 lines
4.3 KiB
TypeScript
118 lines
4.3 KiB
TypeScript
import {
|
|
type AlignmentAnchor,
|
|
type AlignmentGuide,
|
|
collectAlignmentAnchors,
|
|
resolveAlignment,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { useAlignmentGuides } from '@pascal-app/editor'
|
|
|
|
/**
|
|
* Fixed Figma-style alignment threshold (meters) for floor-plan placement /
|
|
* move — parity with the 3D tools' `ALIGNMENT_THRESHOLD_M`. Pure-2D drafting
|
|
* can pass a zoom-scaled `threshold` instead so the magnetic pull stays
|
|
* constant in screen pixels across zoom levels.
|
|
*/
|
|
export const FLOORPLAN_ALIGNMENT_THRESHOLD_M = 0.08
|
|
|
|
export type FloorplanAlignmentResult = {
|
|
/** Plan point with the alignment snap delta applied (grid snap should
|
|
* already be baked into the input point). */
|
|
point: [number, number]
|
|
snapped: boolean
|
|
guides: AlignmentGuide[]
|
|
}
|
|
|
|
/**
|
|
* Layer Figma-style alignment on top of an already grid-snapped plan point,
|
|
* shared by the 2D move sessions (`*FloorplanMoveTarget`) and the structural
|
|
* drafting branches in `floorplan-panel`.
|
|
*
|
|
* Publishes guides to the `useAlignmentGuides` store as a side effect — set
|
|
* on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
|
|
* renders them. Returns the adjusted point. When `bypass` is true (Alt for
|
|
* alignment-only bypass, or Shift for the full guided-constraint bypass) the
|
|
* point is returned unchanged and guides are cleared.
|
|
*
|
|
* `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
|
|
* the scene is stable during a single drag, so re-collecting per pointer-move
|
|
* is wasted work.
|
|
*/
|
|
export function applyFloorplanAlignment(
|
|
point: readonly [number, number],
|
|
movingAnchors: AlignmentAnchor[],
|
|
candidates: AlignmentAnchor[],
|
|
opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number },
|
|
): FloorplanAlignmentResult {
|
|
if (opts?.bypass) {
|
|
useAlignmentGuides.getState().clear()
|
|
return { point: [point[0], point[1]], snapped: false, guides: [] }
|
|
}
|
|
|
|
const result = resolveAlignment({
|
|
moving: movingAnchors,
|
|
candidates,
|
|
threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M,
|
|
})
|
|
|
|
useAlignmentGuides.getState().set(result.guides)
|
|
|
|
if (!result.snap || opts?.applySnap === false) {
|
|
return { point: [point[0], point[1]], snapped: false, guides: result.guides }
|
|
}
|
|
return {
|
|
point: [point[0] + result.snap.dx, point[1] + result.snap.dz],
|
|
snapped: true,
|
|
guides: result.guides,
|
|
}
|
|
}
|
|
|
|
/** Synthetic node id for the in-progress structural-draft vertex. Never
|
|
* collides with a real scene node, so `collectAlignmentAnchors` excludes
|
|
* nothing real. */
|
|
export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
|
|
|
|
/**
|
|
* Align a single grid-snapped structural-draft vertex (wall / fence / polygon
|
|
* / roof endpoint) against every other node's anchors (incl. wall faces from
|
|
* the wall-face anchor work). Treats the vertex as one corner anchor, gathers
|
|
* candidates from the live scene, publishes guides (cleared on `bypass`), and
|
|
* returns the possibly-snapped point.
|
|
*
|
|
* Used by BOTH the move-preview branch and the click-commit handler so the
|
|
* committed vertex lands exactly where the preview showed it. Caller owns the
|
|
* per-kind precedence: existing-wall endpoint/join snap can still win, while
|
|
* angle-locked segments can pass `applySnap: false` to publish passive guide
|
|
* feedback without pulling the endpoint off its constrained ray.
|
|
*
|
|
* `excludeIds` drops those nodes' anchors from the candidate pool — used when
|
|
* dragging a wall / fence endpoint so the moving endpoint doesn't try to
|
|
* align to its own (and its linked siblings') geometry that moves with it.
|
|
*/
|
|
export function alignFloorplanDraftPoint(
|
|
point: readonly [number, number],
|
|
opts?: {
|
|
applySnap?: boolean
|
|
bypass?: boolean
|
|
threshold?: number
|
|
excludeIds?: readonly string[]
|
|
},
|
|
): [number, number] {
|
|
if (opts?.bypass) {
|
|
useAlignmentGuides.getState().clear()
|
|
return [point[0], point[1]]
|
|
}
|
|
let candidates = collectAlignmentAnchors(useScene.getState().nodes, FLOORPLAN_DRAFT_ALIGN_ID)
|
|
if (opts?.excludeIds?.length) {
|
|
const excluded = new Set(opts.excludeIds)
|
|
candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId))
|
|
}
|
|
const { point: snapped } = applyFloorplanAlignment(
|
|
point,
|
|
[{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
|
|
candidates,
|
|
{ applySnap: opts?.applySnap, threshold: opts?.threshold },
|
|
)
|
|
return snapped
|
|
}
|