Fix pass over 2D reference-image (guide) handling plus two adjacent repairs: - North is now world −Z (FLOORPLAN_VIEW_ROTATION_DEG 90 → 0, mirrored in world-grid-snap + mcp README; floorplan-panel de-dupes its local copy). A rotation-0 north-up scan reads upright when the compass says aligned, and "align north" maps to camera azimuth 0 instead of a 90° jump. - Guide resize cursor: custom double-arrow cursor aimed at the dragged corner in screen space — accounts for image aspect (was atan2(±1,±1), square-only), the guide's own rotation, and the floorplan view rotation the overlay group renders inside (was ignored entirely). - R/T rotate a selected reference (guide/scan) in ±45° steps; references live in selectedReferenceId, not the viewer selection, so both arms get the reference-first branch the Delete arm already had. Locked guides skip. - 2D-only mode hides the Top View button (drives the display:none 3D camera); orbit stays — it spins the synced floorplan view. - Set Scale UX: locked guides stay clickable (calibration auto-lock made them unselectable until reload); starting the flow from 3D switches to 2D; the length input pre-fills the drawn length in the pre-selected unit (imperial pre-filled meters labeled feet); Set Scale flips into Cancel while the flow runs (mirrored via referenceScaleActiveGuideId); Hide/ Clear Scale only render once calibrated; Escape cancels (global arm + dialog); Clear Scale and Replace Image drop the calibration auto-lock; discoverability: corner-hint row + panel nudge for uncalibrated guides. - healSceneNodes: strip child refs whose child's parentId names another parent (stale reparent leftovers rendered a window twice — duplicate React keys in 2D, doubled hosted geometry in 3D) and same-array dupes. - Editor accepts onLoaderChange so hosts can measure open-to-interactive time (community wires it to a PostHog timing event). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
232 lines
9.1 KiB
TypeScript
232 lines
9.1 KiB
TypeScript
/**
|
||
* World-grid snap for tools that consume `grid:move` / `grid:click`.
|
||
*
|
||
* Tools historically snapped on `event.localPosition` (the cursor in the
|
||
* active building's local frame). After the floor-plan grid was pulled
|
||
* out of the rotated scene group, snapping has to follow the WORLD XZ
|
||
* grid — otherwise a rotated building drags every placement off the
|
||
* visible grid lines. This helper resolves the active building's pose
|
||
* and projects the world snap back into local coords for storage.
|
||
*/
|
||
import {
|
||
type AlignmentAnchor,
|
||
type AlignmentGuide,
|
||
type AnyNodeId,
|
||
type BuildingPose,
|
||
type ResolveAlignmentInBuildingResult,
|
||
resolveAlignmentInBuildingWorld,
|
||
snapWorldXZToBuildingLocal,
|
||
useLiveTransforms,
|
||
useScene,
|
||
} from '@pascal-app/core'
|
||
import { useViewer } from '@pascal-app/viewer'
|
||
|
||
/**
|
||
* Look up the active building's pose, or null when we're at the site root.
|
||
* Used by tools that need to resolve alignment in world coords.
|
||
*
|
||
* Falls back through the active level's `parentId` because the 2D floor-plan
|
||
* often runs with `selection.buildingId === null` while still operating
|
||
* inside a specific building (the user is editing a level, not the building
|
||
* shell itself). Without the fallback, alignment in the floor plan saw a
|
||
* `buildingRotY === 0` pose and emitted world-axis guides — which appeared
|
||
* diagonal once the building was rotated.
|
||
*
|
||
* Honours `useLiveTransforms` overrides: while the building is being moved
|
||
* or rotated, the floor-plan scene <g> is driven by the live transform
|
||
* (see `use-floorplan-scene-data.ts`), so alignment has to read the same
|
||
* pose or the rotated anchors fall out of sync with the SVG transform and
|
||
* guides drift off the visible grid mid-drag (and through any post-drag
|
||
* frame where the live override is still set).
|
||
*/
|
||
export function getActiveBuildingPose(): BuildingPose | null {
|
||
const sel = useViewer.getState().selection
|
||
const nodes = useScene.getState().nodes
|
||
// Match `use-floorplan-scene-data.ts`: prefer the active level's
|
||
// owning building, fall back to selection.buildingId. If the user
|
||
// has a stale building selected from another scope while editing a
|
||
// different level, the level path is the authoritative one.
|
||
let buildingId: AnyNodeId | null = null
|
||
if (sel.levelId) {
|
||
const level = nodes[sel.levelId]
|
||
if (level && level.type === 'level' && level.parentId) {
|
||
buildingId = level.parentId as AnyNodeId
|
||
}
|
||
}
|
||
if (!buildingId) buildingId = sel.buildingId ?? null
|
||
const building = buildingId ? nodes[buildingId] : null
|
||
if (building?.type !== 'building') return null
|
||
const live = useLiveTransforms.getState().transforms.get(buildingId as string)
|
||
return {
|
||
position: live?.position ?? building.position,
|
||
rotationY: live?.rotation ?? building.rotation[1] ?? 0,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve Figma-style alignment for tools whose anchors are in the active
|
||
* building's local frame, but where alignment must run on the WORLD axes
|
||
* (the frame the user sees the grid in). Wraps `resolveAlignmentInBuildingWorld`
|
||
* with the active building lookup so callers don't repeat the boilerplate.
|
||
*
|
||
* Returns:
|
||
* - `guides` in WORLD coords (renderer must live in a world-space group),
|
||
* - `snap` in BUILDING-LOCAL coords (ready to add to a local position).
|
||
*/
|
||
export function resolveAlignmentForActiveBuilding(args: {
|
||
moving: readonly AlignmentAnchor[]
|
||
candidates: readonly AlignmentAnchor[]
|
||
threshold: number
|
||
}): ResolveAlignmentInBuildingResult {
|
||
return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
|
||
}
|
||
|
||
function worldXZToPoseLocal(x: number, z: number, pose: BuildingPose | null): [number, number] {
|
||
if (!pose) return [x, z]
|
||
const cos = Math.cos(pose.rotationY)
|
||
const sin = Math.sin(pose.rotationY)
|
||
const dx = x - pose.position[0]
|
||
const dz = z - pose.position[2]
|
||
return [dx * cos - dz * sin, dx * sin + dz * cos]
|
||
}
|
||
|
||
/**
|
||
* Project WORLD-frame alignment guides into the active building's LOCAL frame.
|
||
*
|
||
* The 3D alignment layer is still mounted inside the building-local tool group,
|
||
* so tools that resolve alignment on the world axes (item placement, slab move)
|
||
* need their guides converted before publishing to `useAlignmentGuides`.
|
||
*/
|
||
export function projectAlignmentGuidesWorldToActiveBuildingLocal(
|
||
guides: readonly AlignmentGuide[],
|
||
): AlignmentGuide[] {
|
||
const pose = getActiveBuildingPose()
|
||
return guides.map((guide) => {
|
||
const [fromX, fromZ] = worldXZToPoseLocal(guide.from.x, guide.from.z, pose)
|
||
const [toX, toZ] = worldXZToPoseLocal(guide.to.x, guide.to.z, pose)
|
||
return {
|
||
...guide,
|
||
from: { x: fromX, z: fromZ },
|
||
to: { x: toX, z: toZ },
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Baseline rotation the floor-plan view applies on top of the building
|
||
* rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 0` in lib/floorplan/geometry.ts —
|
||
* the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`.
|
||
*/
|
||
const FLOORPLAN_VIEW_ROTATION_RAD = 0
|
||
|
||
function rotateAnchorsBy(
|
||
anchors: readonly AlignmentAnchor[],
|
||
cos: number,
|
||
sin: number,
|
||
): AlignmentAnchor[] {
|
||
return anchors.map((a) => ({
|
||
nodeId: a.nodeId,
|
||
kind: a.kind,
|
||
x: a.x * cos - a.z * sin,
|
||
z: a.x * sin + a.z * cos,
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* Resolve alignment in the 2D floor-plan view frame — the frame the user
|
||
* sees the (always axis-aligned) grid lines in, regardless of how the
|
||
* building has been rotated. Use this from EVERY 2D floor-plan path so
|
||
* alignment guides stay parallel to the visible grid.
|
||
*
|
||
* Why it differs from `resolveAlignmentForActiveBuilding`: the 3D viewport
|
||
* shows the world XZ grid, so world-frame alignment matches the visible
|
||
* grid there. The 2D floor plan, however, rotates the scene `<g>` by
|
||
* `floorplanSceneRotationDeg = FVR − buildingRot` and renders the grid
|
||
* OUTSIDE that rotated group — so the visible axes are
|
||
* `R(FVR − buildingRot) · local`. World-frame alignment would land on
|
||
* world axes, which appear diagonal in this view; view-frame alignment
|
||
* lands on the SVG axes the user actually reads.
|
||
*
|
||
* Returns guides in view-frame coords (correct input for the floor-plan
|
||
* alignment-guide layer, which is mounted outside the rotated scene
|
||
* group) and a snap delta projected back into building-local (so callers
|
||
* can add it to a local position as-is).
|
||
*/
|
||
export function resolveAlignmentForFloorplanView(args: {
|
||
moving: readonly AlignmentAnchor[]
|
||
candidates: readonly AlignmentAnchor[]
|
||
threshold: number
|
||
}): ResolveAlignmentInBuildingResult {
|
||
const pose = getActiveBuildingPose()
|
||
const buildingRotY = pose?.rotationY ?? 0
|
||
const rot = FLOORPLAN_VIEW_ROTATION_RAD - buildingRotY
|
||
const cos = Math.cos(rot)
|
||
const sin = Math.sin(rot)
|
||
const result = resolveAlignmentInBuildingWorld({
|
||
moving: rotateAnchorsBy(args.moving, cos, sin),
|
||
candidates: rotateAnchorsBy(args.candidates, cos, sin),
|
||
threshold: args.threshold,
|
||
// Pose `null` keeps `resolveAlignmentInBuildingWorld` in its no-op
|
||
// path: it just runs `resolveAlignment` on the already-rotated
|
||
// anchors and returns the snap delta in the same view frame.
|
||
pose: null,
|
||
})
|
||
if (!result.snap) return result
|
||
// View-frame delta → local-frame delta (transpose of R(rot)).
|
||
const { dx, dz } = result.snap
|
||
return {
|
||
guides: result.guides,
|
||
snap: { dx: dx * cos + dz * sin, dz: -dx * sin + dz * cos },
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Snap a world XZ position to the grid, then express it in the active
|
||
* building's local frame. When no building is active, world == local.
|
||
*/
|
||
export function snapWorldXZForActiveBuilding(
|
||
worldX: number,
|
||
worldZ: number,
|
||
step: number,
|
||
): { world: [number, number]; local: [number, number] } {
|
||
const buildingId = useViewer.getState().selection.buildingId
|
||
const building = buildingId ? useScene.getState().nodes[buildingId] : null
|
||
if (building?.type !== 'building') {
|
||
if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
|
||
const sx = Math.round(worldX / step) * step
|
||
const sz = Math.round(worldZ / step) * step
|
||
return { world: [sx, sz], local: [sx, sz] }
|
||
}
|
||
return snapWorldXZToBuildingLocal(
|
||
worldX,
|
||
worldZ,
|
||
building.position,
|
||
building.rotation[1] ?? 0,
|
||
step,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Snap a building-local plan point so the resulting position sits on the
|
||
* world XZ grid. The returned point is still in building-local coords —
|
||
* useful as a `gridSnap` callback for snapWallDraftPoint / snapFenceDraftPoint
|
||
* etc., which operate entirely in the local frame.
|
||
*/
|
||
export function snapBuildingLocalToWorldGrid(
|
||
local: readonly [number, number],
|
||
step: number,
|
||
): [number, number] {
|
||
const buildingId = useViewer.getState().selection.buildingId
|
||
const building = buildingId ? useScene.getState().nodes[buildingId] : null
|
||
if (building?.type !== 'building') {
|
||
if (step <= 0) return [local[0], local[1]]
|
||
return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
|
||
}
|
||
const rotY = building.rotation[1] ?? 0
|
||
const cos = Math.cos(rotY)
|
||
const sin = Math.sin(rotY)
|
||
const worldX = building.position[0] + local[0] * cos + local[1] * sin
|
||
const worldZ = building.position[2] - local[0] * sin + local[1] * cos
|
||
return snapWorldXZToBuildingLocal(worldX, worldZ, building.position, rotY, step).local
|
||
}
|