editor: building rotation, alt-click single wall, world-grid alignment, tap-to-engage move (#388)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * Snapshot: ridge vent locks to segment ridge during placement, free sliders after Placement tool clamps the cursor to the segment's ridge line for all roof types (gable, hip, shed, gambrel, dutch, mansard; flat rejected). Renderer re-derives Y from the live surface plus a small lift, and treats stored position[1] / position[2] as user offsets so the inspector sliders move the vent after placement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(roof): contribute outer silhouette to alignment-guide candidates Roof has no centred-box footprint (it's the union of its `roof-segment` children) so the capability bridge needs the resolved AABB directly. We build it from the children's corners in roof-local space, then transform to world coords. Roofs only contribute as static candidates — the move-roof tool drives them by origin, so the relocatable-box path never applies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(floorplan): upright text geometry that stays horizontal under scene rotation Floor-plan text labels were rotating with the 90° default scene rotation, making zone names read sideways. Add an `upright` flag to text geometry: when set, the registry layer counter-rotates the label by sceneRotationDeg around its anchor so it reads horizontally on screen. Zone labels opt in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(editor): redesign material-paint cursor badge and gate floor-plan paint mode Paint cursor swaps the inline icon+label chip for a stacked badge — a glowing accent stem pointing down to the hit point with the paint icon above — so the cue reads as "pointer" rather than "tooltip". Switches the accent to indigo. The 2D floor-plan now also shows the paint-icon overlay and routes pointer interactions to the painter when in material-paint mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(editor): 90° rotate action on the floating building menu Adds a Rotate button to the building action menu (and a generic `onRotate` slot to NodeActionMenu). Each click rotates the building 90° CW around its world-bbox center, mirroring the offset compensation `MoveBuildingContent` uses during R/T-rotate drags — so the building spins in place instead of orbiting its (often off-centre) origin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(column,shelf): show cursor sphere during placement preview Matches the placement affordance other floor-placed tools already have so users see the snap point as a discrete dot alongside the ghost mesh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ifc-converter): update next-env types path to .next/dev/types Next.js moved generated route types under `.next/dev/types/` — regenerate the reference so the type-check resolves them again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ridge-vent): track parent segment's live overrides for real-time follow While dragging a roof-segment resize handle (width / depth / pitch / wallHeight / rotation), the new dimensions stream through useLiveNodeOverrides and only flush to the store on release — so the ridge vent was snapping to the new ridge only after the drag ended. Merge segment overrides into the renderer's segment view so the vent rides the resize in real time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(editor): step building rotation in 15° increments Cuts the floating building menu's rotate-step from 90° to 15° so each click nudges the building rather than slamming it to the next cardinal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(editor): world-frame alignment guides and grid snap under building rotation Brings the 2D floor-plan alignment + grid behaviour in line with 3D so guides and snap follow the world XZ grid regardless of how the active building is rotated. Core: adds resolveAlignmentInBuildingWorld + snapWorldXZToBuildingLocal, plus a shared BuildingPose type, so callers can resolve in world space while staying in building-local coords downstream. Editor: introduces packages/editor/src/lib/world-grid-snap.ts as the single entry point for tools that need world-frame alignment / grid snap (resolveAlignmentForActiveBuilding, getActiveBuildingPose, snapWorldXZForActiveBuilding, snapBuildingLocalToWorldGrid). Honours useLiveTransforms and resolves the active building via level.parentId first so the helper stays in sync with the floor-plan panel. Alignment store unification: applyFloorplanAlignment and the registry move overlay now publish guides in world XZ — the same frame the 3D tools already use — so the shared useAlignmentGuides store carries one consistent coordinate system. The 2D layer renders world XZ via the fixed world → SVG transform (a 90° rotation around the building's world position; independent of building rotation), so guides come out parallel to the world-axis-aligned floor-plan grid in every case. Drafting + move sessions across walls, fences, slabs, ceilings, columns, items, shelves, roofs, stairs, zones and elevators are re-routed through the new helpers so cursor placement, grid snap, draft endpoints, and move commits all land on the world XZ grid even when the building has been rotated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(editor): drop building drag-rotate and grid building-XZ chase Two behaviours added recently were causing the world grid to slide around during building edits. Reverts both, keeping the rest of the world-frame alignment work intact. - floating-building-action-menu / node-action-menu: roll the rotate button back to a single click that increments rotation by 15° around the bbox center (state at 3eb02fde). The drag-rotate from b50c0d5a streamed live position updates per frame, which the floor plan and grid both followed. - grid: stop chasing the active building's world XZ each frame (pre-c00a2469 behaviour). The grid stays anchored at world (0, 0) so it doesn't visibly sweep when the building origin moves — during a drag, an R/T rotation in the move tool, or a click rotate that re-anchors the origin to the bbox-pivot. Y still lerps to the active level's floor height. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(editor): consistent building rotation pivot + 2D position sync Several connected fixes to building rotate/move so the 3D mesh, the 2D floor plan, and alignment guides all stay visually consistent across rotations and drags. Rotation-invariant pivot - New `getBuildingLocalBboxCenter` helper walks descendant mesh geometry bounds in the building's LOCAL frame, returning a centroid that doesn't depend on the building's current world rotation. The previous approach derived the pivot from the world AABB at the current rotation, which made the local pivot subtly drift each time the building was rotated. - Floating menu click-rotate and move-tool R/T rotation now seed their pivot from this helper, so successive rotations stay pinned at the same world point. Move-tool stability - R/T rotation always pivots around the current world bbox center, even before the cursor has moved onto the grid (previously it fell back to rotating around the origin in that case). - Commit (`onGridClick`) now writes the mesh's actual on-screen pose instead of recomputing it from the click event's grid coords, removing a one-cell snap on release. Alignment resolver - Tie-break is now perp-first, primary-second. The previous order was reliable when wall faces shared an exact axis-aligned coord (pre-rotation), but post-rotation float deltas meant primary never tied and the resolver could lock onto the far corner of a candidate. Perp-first picks the visually closest point. 2D ↔ 3D sync - The floor-plan SVG scene group now applies a live `translate(...)` derived from `(committedBuildingPosition → live buildingPosition)`, in addition to the existing live rotation. Building-local content slides in lockstep with the 3D mesh during a drag. - The alignment-guide layer projects from world XZ using the COMMITTED building position, so guides stay locked to their world coordinates while the building slides past them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Merge remote-tracking branch 'origin/main' into fix/fir-5-june * feat(wall): alt-click commits a single wall instead of chaining Holding Alt on the click that completes a segment now stops drafting instead of starting a new segment from the just-placed endpoint. Same behaviour wired in both the 3D wall tool and the 2D floor-plan placement hook (which already passed `singleWall: event.altKey` — the 2D handler was ignoring the flag). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(editor): drop 15° rotate from floating building action menu Removes the rotate button on the floating building menu and the `getBuildingLocalBboxCenter` pivot helper that only existed to support it. The selected-group rotate handle remains the way to rotate a building. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ifc-converter): point next-env at .next/types instead of .next/dev/types Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(editor): unify 3D move grip with floating-menu Move via tap-to-engage The translate gizmo on the 3D bounding box now hands the node to the registry move tool (same path as the floating action menu's Move button) instead of running its own plane-drag. One move flow — green bounding box, cursor follow, alignment guides, R/T rotation, click-to-commit — drives both entry points. Adds a `dragBounds` capability on the node registry so kinds whose rendered mesh contains extras (per-level landing assemblies on an elevator, etc.) can declare the box the user thinks of as "the thing being dragged." Elevator and stair opt in; everything else still auto-measures. `MoveRoofTool` now shows the green box for whole-stair and whole-roof moves, and aligns roofs by their footprint corners (previously only stairs did). Slab floor-plan moves clear `autoFromWalls` on commit so space-detection doesn't snap the slab back, via a new `extraCommitFields` hook on the polygon-centroid move target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(editor): 2D alignment guides in building-local frame + world-grid wall snap The 2D floor-plan alignment layer now mounts inside the rotated scene <g> and reads from an editor-local `useAlignmentGuides` store — guides are stored in building-local meters and follow the SVG transform that already carries the rest of the floor-plan geometry. Pill labels are counter-rotated by the scene rotation so they stay upright under building rotation. The 3D pipeline keeps its own world-frame store; the two surfaces no longer share frames. Wall drafting picks up an optional `gridSnap` override so the 2D floor-plan can snap drafts onto the world-XZ grid even when the active building is rotated — without it, a rotated building's drafted wall would chase the local grid and miss the visible lines. `snapPointTo45Degrees` and `snapWallDraftPointDetailed` route through the override when supplied; otherwise the prior local-axis grid snap is unchanged. The floor-plan panel wires `snapBuildingLocalToWorldGrid` through the placement hook and exports `WALL_GRID_STEP` to support it. Also fixes the minor/major grid stroke palette swap so minor lines render with the minor palette and majors with the major palette. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(editor): drop dead onRotate slot, memoize dragBounds snapshot Removes the unreachable `onRotate` prop, `RotateCw` import, and rotate button from `NodeActionMenu` — its only caller was the floating building menu's 15° rotate, which was removed in d65fd5fe. Memoizes `MoveRegistryNodeTool`'s `dragBounds` lookup so it captures the scene snapshot once per node instead of re-reading `useScene.getState().nodes` on every render. Bounds depend only on `node` (locked for the tool's lifetime) and start-time sibling state (elevator shaft height from the level set), so a one-shot snapshot is the right semantics — and it avoids implying a live subscription that isn't there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: biome check --write (import order, formatting, unused import) Auto-fixes from `bun check:fix`: - import-order normalization in editor/nodes/core - unused `snapToGrid` / `useLayoutEffect` imports dropped - minor whitespace/wrapping tweaks No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b620c9d502
commit
555cf06c12
@@ -241,6 +241,12 @@ export type FloorplanGeometry =
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
paintOrder?: 'stroke' | 'fill' | 'normal'
|
||||
/**
|
||||
* When true, the registry layer counter-rotates the label by
|
||||
* `sceneRotationDeg` so it reads horizontally on screen regardless
|
||||
* of the floor-plan's scene rotation (default 90°).
|
||||
*/
|
||||
upright?: boolean
|
||||
}
|
||||
/**
|
||||
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
|
||||
@@ -981,6 +987,22 @@ export type Capabilities = {
|
||||
* `AlignmentFootprintConfig`.
|
||||
*/
|
||||
alignmentFootprint?: AlignmentFootprintConfig
|
||||
/**
|
||||
* Bounds drawn by the 3D drag bounding box during a move. Opt-in: when
|
||||
* omitted, the box auto-measures the rendered mesh, which is correct for
|
||||
* most kinds. Set this when the rendered mesh tree contains extras the
|
||||
* user wouldn't think of as "the thing being dragged" — e.g. an elevator
|
||||
* whose mesh includes per-level landing assemblies, and the user expects
|
||||
* the box to wrap just the shaft they're moving.
|
||||
*
|
||||
* `size`: `[width, height, depth]` in the node's local frame.
|
||||
* `centerY`: optional Y center; defaults to `size[1] / 2` (box sits on
|
||||
* the ground plane). Override when the local origin isn't at the base.
|
||||
*/
|
||||
dragBounds?: (
|
||||
node: AnyNode,
|
||||
nodes?: Readonly<Record<string, AnyNode>>,
|
||||
) => { size: [number, number, number]; centerY?: number }
|
||||
roofAccessory?: RoofAccessoryConfig
|
||||
/**
|
||||
* Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed
|
||||
|
||||
@@ -75,15 +75,108 @@ export type ResolveAlignmentResult = {
|
||||
|
||||
const EMPTY: ResolveAlignmentResult = { guides: [], snap: null }
|
||||
|
||||
/** Forward rotation: local XZ → world XZ for a node whose parent has
|
||||
* position `bx,_,bz` and rotation-Y `rotY` (radians). Matches the
|
||||
* transform used throughout the editor's tools / floor-plan. */
|
||||
function localToWorld(
|
||||
x: number,
|
||||
z: number,
|
||||
bx: number,
|
||||
bz: number,
|
||||
cos: number,
|
||||
sin: number,
|
||||
): { x: number; z: number } {
|
||||
return {
|
||||
x: bx + x * cos + z * sin,
|
||||
z: bz - x * sin + z * cos,
|
||||
}
|
||||
}
|
||||
|
||||
function transformAnchorToWorld(
|
||||
anchor: AlignmentAnchor,
|
||||
bx: number,
|
||||
bz: number,
|
||||
cos: number,
|
||||
sin: number,
|
||||
): AlignmentAnchor {
|
||||
const w = localToWorld(anchor.x, anchor.z, bx, bz, cos, sin)
|
||||
return { nodeId: anchor.nodeId, kind: anchor.kind, x: w.x, z: w.z }
|
||||
}
|
||||
|
||||
export type BuildingPose = {
|
||||
position: readonly [number, number, number]
|
||||
rotationY: number
|
||||
}
|
||||
|
||||
export type ResolveAlignmentInBuildingResult = {
|
||||
/** Guides in WORLD coordinates. Renderers must be in a world-space group. */
|
||||
guides: AlignmentGuide[]
|
||||
/** Snap delta in the BUILDING-LOCAL frame, ready to add to a local position. */
|
||||
snap: { dx: number; dz: number } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve alignment in WORLD space while accepting BUILDING-LOCAL anchors.
|
||||
*
|
||||
* Why this exists: the floor-plan grid lives in world XZ (rendered outside
|
||||
* the rotated scene group), so alignment must follow the same axes —
|
||||
* otherwise rotating a building drags the alignment guides off the visible
|
||||
* grid and onto the rotated wall's local axes (the bug the user hit). The
|
||||
* resolver itself is frame-agnostic; this wrapper just transforms anchors
|
||||
* to world, resolves, then rotates the snap delta back into building-local
|
||||
* so callers can add it to a local position without further math.
|
||||
*
|
||||
* `pose === null` → resolve in the caller's frame as-is (no transform).
|
||||
*/
|
||||
export function resolveAlignmentInBuildingWorld(input: {
|
||||
moving: readonly AlignmentAnchor[]
|
||||
candidates: readonly AlignmentAnchor[]
|
||||
threshold: number
|
||||
pose: BuildingPose | null
|
||||
}): ResolveAlignmentInBuildingResult {
|
||||
const { moving, candidates, threshold, pose } = input
|
||||
if (!pose) {
|
||||
return resolveAlignment({ moving, candidates, threshold })
|
||||
}
|
||||
const cos = Math.cos(pose.rotationY)
|
||||
const sin = Math.sin(pose.rotationY)
|
||||
const bx = pose.position[0]
|
||||
const bz = pose.position[2]
|
||||
const movingWorld = moving.map((a) => transformAnchorToWorld(a, bx, bz, cos, sin))
|
||||
const candidatesWorld = candidates.map((a) => transformAnchorToWorld(a, bx, bz, cos, sin))
|
||||
const result = resolveAlignment({
|
||||
moving: movingWorld,
|
||||
candidates: candidatesWorld,
|
||||
threshold,
|
||||
})
|
||||
if (!result.snap) return { guides: result.guides, snap: null }
|
||||
// World → local rotation (orthogonal matrix → transpose). The inverse of
|
||||
// `localToWorld` above maps (dx_world, dz_world) → (dx_local, dz_local).
|
||||
const dxW = result.snap.dx
|
||||
const dzW = result.snap.dz
|
||||
const dxL = dxW * cos - dzW * sin
|
||||
const dzL = dxW * sin + dzW * cos
|
||||
return { guides: result.guides, snap: { dx: dxL, dz: dzL } }
|
||||
}
|
||||
|
||||
export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignmentResult {
|
||||
const { moving, candidates, threshold } = input
|
||||
if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY
|
||||
|
||||
// Best match per axis: smallest |Δ| on the matched axis (tightest
|
||||
// alignment), then — crucially — tie-break to the candidate anchor NEAREST
|
||||
// on the perpendicular axis. Anchors are real points (corners / endpoints /
|
||||
// midpoints), so the guide always connects to the closest actual point of
|
||||
// the candidate, never a far one that merely shares the same coordinate.
|
||||
// Best match per axis: among all candidate anchors within `threshold` of
|
||||
// the moving anchor on the matched axis, pick the one CLOSEST in the
|
||||
// perpendicular direction — so the guide always connects to the visually
|
||||
// nearest actual point of the candidate. Primary delta only breaks perp
|
||||
// ties.
|
||||
//
|
||||
// Why perp-first: a wall pre-rotation contributes anchors that share an
|
||||
// exact X (vertical wall) or Z (horizontal wall), so primary deltas tie
|
||||
// and perp picks the nearer endpoint. Post-rotation, the same wall's
|
||||
// anchors are at slightly-different world coordinates after a float
|
||||
// rotation — primary deltas differ by tiny amounts and a primary-first
|
||||
// tie-break would lock onto whichever happens to be marginally tighter,
|
||||
// often the far endpoint. Perp-first keeps the "closest point of
|
||||
// reference" behaviour stable through rotation.
|
||||
type Best = {
|
||||
delta: number
|
||||
primary: number
|
||||
@@ -102,13 +195,13 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment
|
||||
const adz = Math.abs(dz)
|
||||
if (
|
||||
adx <= threshold &&
|
||||
(bestX === null || adx < bestX.primary || (adx === bestX.primary && adz < bestX.perp))
|
||||
(bestX === null || adz < bestX.perp || (adz === bestX.perp && adx < bestX.primary))
|
||||
) {
|
||||
bestX = { delta: dx, primary: adx, perp: adz, m, c }
|
||||
}
|
||||
if (
|
||||
adz <= threshold &&
|
||||
(bestZ === null || adz < bestZ.primary || (adz === bestZ.primary && adx < bestZ.perp))
|
||||
(bestZ === null || adx < bestZ.perp || (adx === bestZ.perp && adz < bestZ.primary))
|
||||
) {
|
||||
bestZ = { delta: dz, primary: adz, perp: adx, m, c }
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ export {
|
||||
type AlignmentGuide,
|
||||
type AlignmentGuideAxis,
|
||||
type AnchorKind,
|
||||
type BuildingPose,
|
||||
bboxAnchors,
|
||||
bboxCornerAnchors,
|
||||
type ResolveAlignmentInBuildingResult,
|
||||
type ResolveAlignmentInput,
|
||||
type ResolveAlignmentResult,
|
||||
resolveAlignment,
|
||||
resolveAlignmentInBuildingWorld,
|
||||
} from './alignment'
|
||||
export {
|
||||
collectAlignmentAnchors,
|
||||
@@ -56,4 +59,5 @@ export {
|
||||
snapScalar,
|
||||
snapServices,
|
||||
snapVec3ToGrid,
|
||||
snapWorldXZToBuildingLocal,
|
||||
} from './snap'
|
||||
|
||||
@@ -36,6 +36,53 @@ export function snapVec3ToGrid(point: Vec3, step: number = DEFAULT_GRID_STEP): V
|
||||
return [snapScalar(point[0], step), point[1], snapScalar(point[2], step)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a world XZ point to the grid, then express it in the local frame of
|
||||
* a building positioned at `buildingPosition` with rotation `buildingRotationY`
|
||||
* (radians, around the Y axis). Returns both the snapped world point and its
|
||||
* local-frame equivalent, so callers can render in either frame without
|
||||
* recomputing the rotation.
|
||||
*
|
||||
* Use when a tool needs to keep snapping on the world grid (the grid the
|
||||
* editor renders) even when the active building is rotated. Snapping in the
|
||||
* building's local frame would otherwise chase the rotated axes and miss
|
||||
* the visible grid lines.
|
||||
*/
|
||||
export function snapWorldXZToBuildingLocal(
|
||||
worldX: number,
|
||||
worldZ: number,
|
||||
buildingPosition: Vec3,
|
||||
buildingRotationY: number,
|
||||
step: number = DEFAULT_GRID_STEP,
|
||||
): { world: [number, number]; local: [number, number] } {
|
||||
if (step <= 0) {
|
||||
const dx = worldX - buildingPosition[0]
|
||||
const dz = worldZ - buildingPosition[2]
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
return {
|
||||
world: [worldX, worldZ],
|
||||
local: [dx * cos - dz * sin, dx * sin + dz * cos],
|
||||
}
|
||||
}
|
||||
const snappedWX = Math.round(worldX / step) * step
|
||||
const snappedWZ = Math.round(worldZ / step) * step
|
||||
const dx = snappedWX - buildingPosition[0]
|
||||
const dz = snappedWZ - buildingPosition[2]
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
// The forward (local → world) rotation used in the editor is
|
||||
// wx = bx + lx*cos + lz*sin
|
||||
// wz = bz - lx*sin + lz*cos
|
||||
// so the inverse (orthogonal, so transpose) is
|
||||
// lx = dx*cos - dz*sin
|
||||
// lz = dx*sin + dz*cos
|
||||
return {
|
||||
world: [snappedWX, snappedWZ],
|
||||
local: [dx * cos - dz * sin, dx * sin + dz * cos],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Angle snap ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { memo } from 'react'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import { formatMeasurement } from '../editor/measurement-pill'
|
||||
import { useFloorplanRender } from './floorplan-render-context'
|
||||
|
||||
/**
|
||||
* Figma-style alignment guides for the 2D floor plan.
|
||||
*
|
||||
* Subscribes to `useAlignmentGuides` — populated by
|
||||
* `FloorplanRegistryMoveOverlay` (Path 2) during a generic free-translate
|
||||
* drag. Each guide renders as a red line between the moving and matched
|
||||
* candidate anchors with small `×` end-caps. A distance pill is drawn at
|
||||
* the line's midpoint when the perpendicular gap is non-zero.
|
||||
* Subscribes to the editor-local `useAlignmentGuides` store (separate
|
||||
* from the core store the 3D layer reads). Guides come in
|
||||
* building-local meters, so the layer is mounted INSIDE the rotated
|
||||
* `<g data-floorplan-scene>` — the SVG transform that takes the rest
|
||||
* of the floor-plan geometry from local → screen carries the guide
|
||||
* lines too. Pill labels are counter-rotated by `sceneRotationDeg`
|
||||
* (from `FloorplanRenderProvider`) so they stay upright even when the
|
||||
* scene `<g>` is rotated by building rotation.
|
||||
*
|
||||
* Each guide renders as a red line between the moving and matched
|
||||
* candidate anchors with small `×` end-caps. A distance pill is drawn
|
||||
* at the midpoint when the perpendicular gap is non-zero.
|
||||
*
|
||||
* Stroke widths and handle radii are scaled by `unitsPerPixel` so they
|
||||
* stay a constant size on screen no matter the zoom. Text labels are
|
||||
* counter-rotated by `sceneRotationDeg` so they read upright even when
|
||||
* the building rotation rotates the scene `<g>`.
|
||||
*
|
||||
* Mounted inside the `data-floorplan-scene` group so coordinates match
|
||||
* world meters 1:1 with the rest of the floor plan.
|
||||
* stay a constant size on screen no matter the zoom.
|
||||
*/
|
||||
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
|
||||
const guides = useAlignmentGuides((s) => s.guides)
|
||||
|
||||
@@ -76,6 +76,10 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
||||
return { kind: 'icon', icon: 'mdi:trash-can-outline' }
|
||||
}
|
||||
|
||||
if (mode === 'material-paint') {
|
||||
return { kind: 'asset', iconSrc: '/icons/paint.png' }
|
||||
}
|
||||
|
||||
return null
|
||||
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
||||
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
|
||||
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
||||
|
||||
@@ -464,6 +464,11 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
movingLocalBBox.x + movingLocalBBox.width + dxProposed,
|
||||
movingLocalBBox.y + movingLocalBBox.height + dzProposed,
|
||||
)
|
||||
// Local-frame resolve (anchors come from the building-local
|
||||
// SVG `getBBox()`). Guides land in the editor-local alignment
|
||||
// store, which the 2D FloorplanAlignmentGuideLayer renders
|
||||
// inside the rotated scene <g>. The 3D pipeline uses a
|
||||
// separate store, so frames stay isolated per surface.
|
||||
const result = resolveAlignment({
|
||||
moving: movingAnchors,
|
||||
candidates: candidateAnchors,
|
||||
|
||||
@@ -1596,6 +1596,34 @@ function InteractiveGeometry({
|
||||
</g>
|
||||
)
|
||||
}
|
||||
case 'text': {
|
||||
if (!g.upright) return <FloorplanGeometryRenderer geometry={g} key={keyHint} />
|
||||
// Counter-rotate by the scene rotation so the label reads
|
||||
// horizontally on screen even when the floor-plan view is
|
||||
// rotated (default `sceneRotationDeg` is 90°).
|
||||
return (
|
||||
<g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
|
||||
<text
|
||||
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
||||
fill={g.fill ?? '#171717'}
|
||||
fontFamily={g.fontFamily}
|
||||
fontSize={g.fontSize}
|
||||
fontWeight={g.fontWeight}
|
||||
opacity={g.opacity}
|
||||
paintOrder={g.paintOrder}
|
||||
stroke={g.stroke}
|
||||
strokeLinecap={g.stroke ? 'round' : undefined}
|
||||
strokeLinejoin={g.stroke ? 'round' : undefined}
|
||||
strokeWidth={g.strokeWidth}
|
||||
textAnchor={g.textAnchor ?? 'start'}
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{g.text}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<FloorplanGeometryRenderer
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, CircleGeometry, type Group } from 'three'
|
||||
import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { formatMeasurement } from './measurement-pill'
|
||||
@@ -22,11 +22,13 @@ import { formatMeasurement } from './measurement-pill'
|
||||
* the design reference — so they're real 3D geometry, not screen billboards.
|
||||
* Only the distance pill is screen-space (`<Html>`).
|
||||
*
|
||||
* Guide coordinates are XZ meters in the building-local frame; this layer is
|
||||
* mounted inside ToolManager's building-local group so they render at the
|
||||
* right world position (and line up with the cursor). The whole ribbon is
|
||||
* lifted to the active level's building-local Y each frame so it lies on the
|
||||
* floor being edited — not the building base — when floors are stacked.
|
||||
* Guide coordinates are XZ meters in the WORLD frame — alignment now resolves
|
||||
* on the world axes (via `resolveAlignmentForActiveBuilding`) so the guides
|
||||
* stay parallel to the visible world grid even when the active building is
|
||||
* rotated. This layer is mounted OUTSIDE the building-local ToolManager group
|
||||
* for the same reason. The whole ribbon is lifted to the active level's WORLD
|
||||
* Y each frame so it lies on the floor being edited — not the building base —
|
||||
* when floors are stacked.
|
||||
*/
|
||||
|
||||
const LINE_COLOR = 0x81_8c_f8 // indigo-400 — matches the editor's selection accent (box-select / wall highlights)
|
||||
@@ -58,16 +60,17 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const groupRef = useRef<Group>(null)
|
||||
|
||||
// Guides carry only XZ (building-local plan coords); their Y has to track
|
||||
// the active level so the ground ribbon lies on the floor being edited,
|
||||
// not the building base. Read the level mesh's building-local Y each frame
|
||||
// — the same source `grid.tsx` uses, so the ribbon stays locked to the
|
||||
// grid plane (and lerps with it during a level switch).
|
||||
// Guides carry only XZ in WORLD coords; their Y has to track the active
|
||||
// level's world Y so the ground ribbon lies on the floor being edited,
|
||||
// not the building base. `getWorldPosition` walks the level mesh's
|
||||
// parents (building / site) so it stays correct even if the building has
|
||||
// a Y offset.
|
||||
const worldYWork = useMemo(() => new Vector3(), [])
|
||||
useFrame(() => {
|
||||
const group = groupRef.current
|
||||
if (!group) return
|
||||
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
|
||||
group.position.y = levelMesh ? levelMesh.position.y : 0
|
||||
group.position.y = levelMesh ? levelMesh.getWorldPosition(worldYWork).y : 0
|
||||
})
|
||||
|
||||
if (guides.length === 0) return null
|
||||
|
||||
@@ -77,6 +77,7 @@ import { guideEmitter } from '../../lib/guide-events'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
||||
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
||||
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
|
||||
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
||||
@@ -3243,20 +3244,20 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({
|
||||
<path
|
||||
d={minorGridPath}
|
||||
fill="none"
|
||||
opacity={palette.majorGridOpacity}
|
||||
opacity={palette.minorGridOpacity}
|
||||
shapeRendering="crispEdges"
|
||||
stroke={palette.majorGrid}
|
||||
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
|
||||
stroke={palette.minorGrid}
|
||||
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
<path
|
||||
d={majorGridPath}
|
||||
fill="none"
|
||||
opacity={palette.minorGridOpacity}
|
||||
opacity={palette.majorGridOpacity}
|
||||
shapeRendering="crispEdges"
|
||||
stroke={palette.minorGrid}
|
||||
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
|
||||
stroke={palette.majorGrid}
|
||||
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
@@ -8831,7 +8832,7 @@ export function FloorplanPanel() {
|
||||
)
|
||||
|
||||
const handleWallPlacementPoint = useCallback(
|
||||
(point: WallPlanPoint) => {
|
||||
(point: WallPlanPoint, options?: { singleWall?: boolean }) => {
|
||||
if (!draftStart) {
|
||||
setDraftStart(point)
|
||||
setDraftEnd(point)
|
||||
@@ -8859,6 +8860,16 @@ export function FloorplanPanel() {
|
||||
// clearing it (the previous behaviour caused the 2nd-segment
|
||||
// draft to silently break after click 2).
|
||||
const createdWall = createWallOnCurrentLevel(draftStart, point)
|
||||
|
||||
// Alt commits a single wall: drop the draft so the next click
|
||||
// starts a fresh segment instead of chaining off this endpoint.
|
||||
if (options?.singleWall) {
|
||||
setDraftStart(null)
|
||||
setDraftEnd(null)
|
||||
setCursorPoint(null)
|
||||
return
|
||||
}
|
||||
|
||||
const nextStart: WallPlanPoint = createdWall
|
||||
? [createdWall.end[0], createdWall.end[1]]
|
||||
: point
|
||||
@@ -8934,6 +8945,11 @@ export function FloorplanPanel() {
|
||||
snapWallDraftPoint: snapWallDraftPointMagnetic,
|
||||
toPoint2D,
|
||||
walls,
|
||||
// World-axis grid snap so drafts land on the visible grid even
|
||||
// when the active building is rotated. The helper resolves the
|
||||
// active building's pose internally; this hook stays oblivious to
|
||||
// building rotation / position.
|
||||
worldGridSnap: snapBuildingLocalToWorldGrid,
|
||||
})
|
||||
|
||||
const handleBackgroundClick = useCallback(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MathUtils, type Mesh, PlaneGeometry, Vector2, Vector3 } from 'three'
|
||||
import { MathUtils, type Mesh, PlaneGeometry, Vector2 } from 'three'
|
||||
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useCeilingEvents } from '../../hooks/use-ceiling-events'
|
||||
@@ -143,23 +143,11 @@ export const Grid = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const worldPosScratch = useMemo(() => new Vector3(), [])
|
||||
useFrame((_, delta) => {
|
||||
const { levelId, buildingId } = useViewer.getState().selection
|
||||
// Align the grid's XZ origin to the active building so its visible cell
|
||||
// lines pass through building-local snap points (walls snap in
|
||||
// building-local coords; a building placed at world (0.25, 0.25) would
|
||||
// otherwise leave snapped wall endpoints stranded between grid lines).
|
||||
let targetX = 0
|
||||
let targetZ = 0
|
||||
if (buildingId) {
|
||||
const buildingMesh = sceneRegistry.nodes.get(buildingId as AnyNodeId)
|
||||
if (buildingMesh) {
|
||||
buildingMesh.getWorldPosition(worldPosScratch)
|
||||
targetX = worldPosScratch.x
|
||||
targetZ = worldPosScratch.z
|
||||
}
|
||||
}
|
||||
const { levelId } = useViewer.getState().selection
|
||||
// Grid stays anchored to world XZ (0, 0) — never chases the active
|
||||
// building. The Y origin still lerps to the active level so the grid
|
||||
// sits at floor height when a level is open.
|
||||
let targetY = 0
|
||||
if (levelId) {
|
||||
const levelMesh = sceneRegistry.nodes.get(levelId)
|
||||
@@ -167,22 +155,16 @@ export const Grid = ({
|
||||
targetY = levelMesh.position.y
|
||||
}
|
||||
}
|
||||
const t = 12 * delta
|
||||
gridRef.current.position.x = MathUtils.lerp(gridRef.current.position.x, targetX, t)
|
||||
gridRef.current.position.z = MathUtils.lerp(gridRef.current.position.z, targetZ, t)
|
||||
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, t)
|
||||
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
|
||||
gridRef.current.position.y = newY
|
||||
setGridY(newY)
|
||||
|
||||
// Re-derive the local-frame cursor uniform after the grid's XZ has
|
||||
// lerped this frame, so the reveal ring stays locked under the world
|
||||
// cursor even when the grid origin is mid-transition.
|
||||
// Grid XZ is fixed at world origin, so the local-frame cursor uniform
|
||||
// is just the world cursor (mirrored on Z to match the -π/2 X-rotation
|
||||
// of the plane).
|
||||
const world = lastWorldCursorRef.current
|
||||
if (world) {
|
||||
cursorPositionRef.current.set(
|
||||
world.x - gridRef.current.position.x,
|
||||
-(world.z - gridRef.current.position.z),
|
||||
)
|
||||
cursorPositionRef.current.set(world.x, -world.z)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,15 +8,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
|
||||
@@ -76,7 +68,7 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-
|
||||
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
||||
const DELETE_CURSOR_BADGE_OFFSET_X = 14
|
||||
const DELETE_CURSOR_BADGE_OFFSET_Y = 14
|
||||
const PAINT_CURSOR_BADGE_COLOR = '#f59e0b'
|
||||
const PAINT_CURSOR_BADGE_COLOR = '#818cf8'
|
||||
const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
|
||||
const PAINT_CURSOR_BADGE_OFFSET_X = 14
|
||||
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
|
||||
@@ -536,43 +528,46 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
|
||||
|
||||
function PaintCursorBadge({
|
||||
position,
|
||||
label,
|
||||
disabled,
|
||||
icon,
|
||||
}: {
|
||||
position: { x: number; y: number }
|
||||
label: string
|
||||
disabled: boolean
|
||||
icon: string
|
||||
}) {
|
||||
const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
|
||||
const lineHeight = 18
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-40"
|
||||
style={{
|
||||
left: position.x + PAINT_CURSOR_BADGE_OFFSET_X,
|
||||
top: position.y + PAINT_CURSOR_BADGE_OFFSET_Y,
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-xl border border-white/5 bg-zinc-900/95 px-3 py-2 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
|
||||
className="-translate-x-1/2 -translate-y-full absolute top-0 left-1/2"
|
||||
style={{
|
||||
backgroundColor: accentColor,
|
||||
boxShadow: `0 0 12px ${accentColor}cc`,
|
||||
height: lineHeight,
|
||||
width: 2,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 left-1/2 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
|
||||
style={{
|
||||
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${accentColor}22`,
|
||||
transform: `translate(-50%, calc(-100% - ${lineHeight}px))`,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
||||
color={accentColor}
|
||||
height={16}
|
||||
icon={icon}
|
||||
width={16}
|
||||
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
||||
src="/icons/paint.png"
|
||||
/>
|
||||
<span className="font-medium text-[11px]" style={{ color: accentColor }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -702,7 +697,7 @@ function DeleteCursorLayer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none"
|
||||
className="pointer-events-none z-40"
|
||||
ref={badgeRef}
|
||||
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
|
||||
>
|
||||
@@ -720,15 +715,12 @@ function PaintCursorLayer({
|
||||
}) {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
||||
const activePaintTarget = useEditor((s) => s.activePaintTarget)
|
||||
const badgeRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const active = mode === 'material-paint' && !isVersionPreviewMode
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
if (badgeRef.current) {
|
||||
badgeRef.current.style.display = 'none'
|
||||
}
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
const el = containerRef.current
|
||||
@@ -736,20 +728,16 @@ function PaintCursorLayer({
|
||||
let frame = 0
|
||||
let nextX = 0
|
||||
let nextY = 0
|
||||
const badge = badgeRef.current
|
||||
|
||||
const flushPosition = () => {
|
||||
frame = 0
|
||||
if (!badge) return
|
||||
badge.style.display = 'block'
|
||||
badge.style.transform = `translate(${nextX + PAINT_CURSOR_BADGE_OFFSET_X}px, ${nextY + PAINT_CURSOR_BADGE_OFFSET_Y}px)`
|
||||
setPosition({ x: nextX, y: nextY })
|
||||
}
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const updateFromEvent = (e: PointerEvent) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
nextX = e.clientX - rect.left
|
||||
nextY = e.clientY - rect.top
|
||||
|
||||
if (frame === 0) {
|
||||
frame = window.requestAnimationFrame(flushPosition)
|
||||
}
|
||||
@@ -759,17 +747,19 @@ function PaintCursorLayer({
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = 0
|
||||
}
|
||||
if (badge) {
|
||||
badge.style.display = 'none'
|
||||
}
|
||||
setPosition(null)
|
||||
}
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointermove', updateFromEvent)
|
||||
el.addEventListener('pointerenter', updateFromEvent)
|
||||
el.addEventListener('pointerdown', updateFromEvent)
|
||||
el.addEventListener('pointerleave', onLeave)
|
||||
return () => {
|
||||
if (frame !== 0) {
|
||||
window.cancelAnimationFrame(frame)
|
||||
}
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointermove', updateFromEvent)
|
||||
el.removeEventListener('pointerenter', updateFromEvent)
|
||||
el.removeEventListener('pointerdown', updateFromEvent)
|
||||
el.removeEventListener('pointerleave', onLeave)
|
||||
}
|
||||
}, [active, containerRef])
|
||||
@@ -779,29 +769,15 @@ function PaintCursorLayer({
|
||||
(activePaintMaterial.material !== undefined ||
|
||||
activePaintMaterial.materialPreset !== undefined),
|
||||
)
|
||||
const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
|
||||
const icon = 'mdi:format-color-fill'
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active && badgeRef.current) {
|
||||
badgeRef.current.style.display = 'none'
|
||||
}
|
||||
}, [active])
|
||||
|
||||
if (!active) return null
|
||||
if (!active || !position) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none"
|
||||
ref={badgeRef}
|
||||
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
|
||||
className="pointer-events-none absolute z-40"
|
||||
style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
>
|
||||
<PaintCursorBadge
|
||||
disabled={!hasMaterial}
|
||||
icon={icon}
|
||||
label={label}
|
||||
position={{ x: 0, y: 0 }}
|
||||
/>
|
||||
<PaintCursorBadge disabled={!hasMaterial} position={{ x: 0, y: 0 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { createEditorApi } from '../../lib/editor-api'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { snapToGrid } from '../tools/item/placement-math'
|
||||
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
@@ -1143,9 +1143,6 @@ function ArcArrow({
|
||||
function TranslateArrow({
|
||||
descriptor,
|
||||
node,
|
||||
handleIndex,
|
||||
dragControls,
|
||||
rideObject,
|
||||
}: {
|
||||
descriptor: TranslateHandle<AnyNode>
|
||||
node: AnyNode
|
||||
@@ -1154,7 +1151,6 @@ function TranslateArrow({
|
||||
rideObject: Object3D
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const { camera } = useThree()
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const baseScale = zoom * ARROW_SCALE
|
||||
@@ -1166,67 +1162,17 @@ function TranslateArrow({
|
||||
// local +Z). Its cross icon stands up into that plane (tilt about X).
|
||||
const isWallPlane = descriptor.plane === 'node-normal'
|
||||
|
||||
const activate = useHandleDrag({
|
||||
kind: 'drag',
|
||||
cursor,
|
||||
dragControls,
|
||||
handleIndex,
|
||||
node,
|
||||
rideObject,
|
||||
setIsDragging,
|
||||
onStart: ({ event, initialNode, intersectPlane, rideObject: dragRideObject, sceneApi }) => {
|
||||
const worldOrigin = new Vector3().setFromMatrixPosition(dragRideObject.matrixWorld)
|
||||
const planeNormal = isWallPlane
|
||||
? new Vector3().setFromMatrixColumn(dragRideObject.matrixWorld, 2).normalize()
|
||||
: new Vector3(0, 1, 0)
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
|
||||
const parent = dragRideObject.parent
|
||||
const parentInverse = new Matrix4()
|
||||
if (parent) {
|
||||
parent.updateMatrixWorld()
|
||||
parentInverse.copy(parent.matrixWorld).invert()
|
||||
}
|
||||
|
||||
const hitWorld = new Vector3()
|
||||
if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) {
|
||||
return null
|
||||
}
|
||||
const startLocal = hitWorld.clone().applyMatrix4(parentInverse)
|
||||
const initialPos = (initialNode as { position?: readonly [number, number, number] })
|
||||
.position ?? [0, 0, 0]
|
||||
|
||||
return {
|
||||
markDirty: false,
|
||||
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
||||
const hit = new Vector3()
|
||||
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
||||
const curLocal = hit.applyMatrix4(parentInverse)
|
||||
const newPos: [number, number, number] = [initialPos[0], initialPos[1], initialPos[2]]
|
||||
newPos[0] += curLocal.x - startLocal.x
|
||||
if (isWallPlane) {
|
||||
newPos[1] += curLocal.y - startLocal.y
|
||||
} else {
|
||||
newPos[2] += curLocal.z - startLocal.z
|
||||
}
|
||||
|
||||
const extents = descriptor.snapExtents?.(initialNode as never, sceneApi)
|
||||
if (extents) {
|
||||
newPos[0] = snapToGrid(newPos[0], extents[0])
|
||||
if (isWallPlane) {
|
||||
newPos[1] = snapToGrid(newPos[1], extents[1])
|
||||
} else {
|
||||
newPos[2] = snapToGrid(newPos[2], extents[1])
|
||||
}
|
||||
}
|
||||
return descriptor.apply(initialNode as never, newPos, sceneApi) as Partial<AnyNode>
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Suppress the unused `isDragging` lint — it only drives the React re-render
|
||||
// that keeps hover/drag cursor state in sync.
|
||||
void isDragging
|
||||
// Same function as the floating action menu's Move button
|
||||
// (`floating-action-menu.tsx` → `handleMove`): arm the registry move tool,
|
||||
// which owns the cursor follow, grid + alignment snap, green guide overlay,
|
||||
// and click-to-commit. Routes both entry points through one path so the
|
||||
// 3D translate gizmo and the floating Move button behave identically.
|
||||
const activate = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useEditor.getState().setMovingNode(node as never)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
|
||||
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
|
||||
// it lies in the item-local XY plane (= the wall face).
|
||||
|
||||
@@ -5,7 +5,6 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
|
||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import {
|
||||
snapPointToGrid as snapWallPointToGrid,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
@@ -41,7 +40,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
) => boolean
|
||||
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
|
||||
handleSlabPlacementPoint: (point: WallPlanPoint) => void
|
||||
handleWallPlacementPoint: (point: WallPlanPoint) => void
|
||||
handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void
|
||||
handleZonePlacementPoint: (point: WallPlanPoint) => void
|
||||
isCeilingBuildActive: boolean
|
||||
isCeilingItemPlacementActive: boolean
|
||||
@@ -65,6 +64,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
start?: WallPlanPoint
|
||||
angleSnap?: boolean
|
||||
step?: number
|
||||
gridSnap?: (point: WallPlanPoint) => WallPlanPoint
|
||||
}) => WallPlanPoint
|
||||
snapPolygonDraftPoint: (args: {
|
||||
point: WallPlanPoint
|
||||
@@ -73,6 +73,13 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
}) => WallPlanPoint
|
||||
toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
|
||||
walls: WallNode[]
|
||||
/**
|
||||
* Snap a building-local plan point to the world XZ grid at `step`.
|
||||
* Injected so the hook doesn't have to know the building's rotation
|
||||
* or position — used by wall / fence branches that snap at variable
|
||||
* step (Shift = fine).
|
||||
*/
|
||||
worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint
|
||||
}
|
||||
|
||||
export function useFloorplanBackgroundPlacement({
|
||||
@@ -111,6 +118,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
snapPolygonDraftPoint,
|
||||
toPoint2D,
|
||||
walls,
|
||||
worldGridSnap,
|
||||
}: UseFloorplanBackgroundPlacementArgs) {
|
||||
const handleBackgroundPlacementClick = useCallback(
|
||||
(
|
||||
@@ -177,16 +185,17 @@ export function useFloorplanBackgroundPlacement({
|
||||
if (isFenceBuildActive) {
|
||||
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
|
||||
// Figma alignment — endpoint snap wins (same precedence as move).
|
||||
// `gridSnap` keeps the snap on the world XZ grid even when the
|
||||
// building is rotated.
|
||||
const fenceStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
const fenceSnapped = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
fences,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
gridSnap: (p) => worldGridSnap(p, fenceStep),
|
||||
})
|
||||
const fenceGridBase = snapWallPointToGrid(
|
||||
planPoint,
|
||||
shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
|
||||
)
|
||||
const fenceGridBase = worldGridSnap(planPoint, fenceStep)
|
||||
const fenceLocked =
|
||||
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
||||
const snappedPoint = fenceLocked
|
||||
@@ -249,22 +258,23 @@ export function useFloorplanBackgroundPlacement({
|
||||
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then
|
||||
// Figma alignment — endpoint/join snap wins (same precedence as the
|
||||
// move-preview branch), so committing onto a corner still works.
|
||||
// `gridSnap` keeps the snap on the world XZ grid even when the
|
||||
// building is rotated.
|
||||
const wallStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
const wallSnapped = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
gridSnap: (p) => worldGridSnap(p, wallStep),
|
||||
})
|
||||
const wallGridBase = snapWallPointToGrid(
|
||||
planPoint,
|
||||
shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
|
||||
)
|
||||
const wallGridBase = worldGridSnap(planPoint, wallStep)
|
||||
const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
|
||||
const snappedPoint = wallLocked
|
||||
? wallSnapped
|
||||
: alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey })
|
||||
: alignFloorplanDraftPoint(wallSnapped, { bypass: false })
|
||||
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
handleWallPlacementPoint(snappedPoint)
|
||||
handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -324,6 +334,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
toPoint2D,
|
||||
walls,
|
||||
handleWallPlacementPoint,
|
||||
worldGridSnap,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ export function useFloorplanSceneData({
|
||||
|
||||
return {
|
||||
buildingPosition,
|
||||
committedBuildingPosition,
|
||||
buildingRotationY,
|
||||
currentBuildingId,
|
||||
ceilings,
|
||||
|
||||
@@ -134,14 +134,23 @@ export function snapFenceDraftPoint(args: {
|
||||
ignoreFenceIds?: string[]
|
||||
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
|
||||
step?: number
|
||||
/**
|
||||
* Optional grid-snap function. When provided, replaces the default
|
||||
* local-axis snap — lets the 2D floor-plan keep snapping to the
|
||||
* world XZ grid even when the building is rotated. Wall / fence
|
||||
* endpoint snap precedence is preserved.
|
||||
*/
|
||||
gridSnap?: (point: FencePlanPoint) => FencePlanPoint
|
||||
}): FencePlanPoint {
|
||||
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step } = args
|
||||
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step, gridSnap } = args
|
||||
const gridStep = step ?? getSegmentGridStep()
|
||||
const angleStep = getWallAngleSnapStep(gridStep)
|
||||
const basePoint =
|
||||
start && angleSnap
|
||||
? snapPointTo45Degrees(start, point, gridStep, angleStep)
|
||||
: snapPointToGrid(point, gridStep)
|
||||
? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap)
|
||||
: gridSnap
|
||||
? gridSnap(point)
|
||||
: snapPointToGrid(point, gridStep)
|
||||
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
||||
|
||||
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
|
||||
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
@@ -99,10 +100,15 @@ export const floorStrategy = {
|
||||
const [dimX, , dimZ] = dims
|
||||
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||
// event.localPosition is building-local; the coordinator cursor group is inside the
|
||||
// building-local ToolManager group, so local coords are correct for both data and visuals.
|
||||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||
// Snap on the world XZ grid (the grid the editor renders) so the
|
||||
// item edges land on the visible grid even when the active building
|
||||
// is rotated; then project the world point back into building-local
|
||||
// for storage. Without this, a rotated building drags placement off
|
||||
// the world grid.
|
||||
const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
||||
const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
||||
const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0)
|
||||
const [x, z] = local
|
||||
const y = ctx.gridPosition.y
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getScaledDimensions,
|
||||
type ItemEvent,
|
||||
movingFootprintAnchors,
|
||||
resolveAlignment,
|
||||
resolveLevelId,
|
||||
type ShelfEvent,
|
||||
sceneRegistry,
|
||||
@@ -42,6 +41,7 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
import {
|
||||
@@ -700,7 +700,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
draft.id,
|
||||
useViewer.getState().selection.levelId,
|
||||
)
|
||||
const ar = resolveAlignment({
|
||||
const ar = resolveAlignmentForActiveBuilding({
|
||||
moving: movingFootprintAnchors(
|
||||
draft as unknown as AnyNode,
|
||||
result.gridPosition[0],
|
||||
|
||||
@@ -28,6 +28,7 @@ import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placemen
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { DragBoundingBox } from '../shared/drag-bounding-box'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
|
||||
import { PlacementBox } from '../shared/placement-box'
|
||||
@@ -561,6 +562,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
useAbsoluteCursorPlacement,
|
||||
])
|
||||
|
||||
// Snapshot the scene once at drag-start — bounds depend on `node` (locked
|
||||
// for the lifetime of this tool) and any sibling state the kind reads. If a
|
||||
// future kind needs live sibling state mid-drag, switch to a subscribed
|
||||
// selector; for v1 (elevator shaft height from level set) start-time is
|
||||
// correct and avoids subscribing the whole `nodes` map.
|
||||
const dragBounds = useMemo(
|
||||
() =>
|
||||
nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ??
|
||||
null,
|
||||
[node],
|
||||
)
|
||||
|
||||
if (!previewVisible) return null
|
||||
|
||||
if (boxDimensions) {
|
||||
@@ -574,5 +587,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
return <CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
|
||||
return (
|
||||
<>
|
||||
<CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
|
||||
<DragBoundingBox
|
||||
centerY={dragBounds?.centerY}
|
||||
nodeId={node.id}
|
||||
position={cursorPosition}
|
||||
rotationY={cursorRotationY}
|
||||
size={dragBounds?.size}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type LevelNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
snapScalar,
|
||||
useScene,
|
||||
@@ -20,6 +19,10 @@ import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'thre
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
resolveAlignmentForActiveBuilding,
|
||||
snapWorldXZForActiveBuilding,
|
||||
} from '../../../lib/world-grid-snap'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
@@ -196,7 +199,7 @@ export const RoofTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return [gridX, gridZ]
|
||||
}
|
||||
const ar = resolveAlignment({
|
||||
const ar = resolveAlignmentForActiveBuilding({
|
||||
moving: [{ nodeId: '__roof-draft__', kind: 'corner', x: rawX, z: rawZ }],
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
@@ -237,9 +240,16 @@ export const RoofTool: React.FC = () => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to drag every roof corner off the visible grid.
|
||||
const snapped = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
snapToActiveGrid(event.localPosition[0]),
|
||||
snapToActiveGrid(event.localPosition[2]),
|
||||
snapped[0],
|
||||
snapped[1],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
@@ -275,9 +285,16 @@ export const RoofTool: React.FC = () => {
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to drag every roof corner off the visible grid.
|
||||
const snapped = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
snapToActiveGrid(event.localPosition[0]),
|
||||
snapToActiveGrid(event.localPosition[2]),
|
||||
snapped[0],
|
||||
snapped[1],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
|
||||
@@ -64,6 +64,15 @@ interface DragBoundingBoxProps {
|
||||
rotationY?: number
|
||||
/** Declared `[width, height, depth]`, used until/if the mesh can't be measured. */
|
||||
fallbackSize?: [number, number, number]
|
||||
/**
|
||||
* Hard override for the box extents — wins over both mesh measurement and
|
||||
* `fallbackSize`. Use when the rendered mesh contains extras the user
|
||||
* wouldn't read as "the thing being dragged" (e.g. an elevator whose mesh
|
||||
* includes per-level landings outside the shaft footprint).
|
||||
*/
|
||||
size?: [number, number, number]
|
||||
/** Y center of the box in the node's local frame. Defaults to `size[1] / 2`. */
|
||||
centerY?: number
|
||||
color?: number
|
||||
}
|
||||
|
||||
@@ -80,15 +89,20 @@ export function DragBoundingBox({
|
||||
position,
|
||||
rotationY = 0,
|
||||
fallbackSize = [0, 0, 0],
|
||||
size,
|
||||
centerY,
|
||||
color = DEFAULT_COLOR,
|
||||
}: DragBoundingBoxProps) {
|
||||
const measured = useMemo(() => {
|
||||
if (size) return null
|
||||
const obj = sceneRegistry.nodes.get(nodeId)
|
||||
return obj ? measureLocalBounds(obj) : null
|
||||
}, [nodeId])
|
||||
}, [nodeId, size])
|
||||
|
||||
const [w, h, d] = measured?.size ?? fallbackSize
|
||||
const [cx, cy, cz] = measured?.center ?? [0, fallbackSize[1] / 2, 0]
|
||||
const [w, h, d] = size ?? measured?.size ?? fallbackSize
|
||||
const [cx, cy, cz] = size
|
||||
? [0, centerY ?? size[1] / 2, 0]
|
||||
: (measured?.center ?? [0, fallbackSize[1] / 2, 0])
|
||||
const minY = cy - h / 2
|
||||
|
||||
const edgeGeometry = useMemo(() => {
|
||||
|
||||
@@ -68,17 +68,24 @@ export function snapPointTo45Degrees(
|
||||
cursor: WallPlanPoint,
|
||||
step = WALL_GRID_STEP,
|
||||
angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
|
||||
/**
|
||||
* Optional grid-snap callback. Lets the caller route the final
|
||||
* snap through a world-XZ grid (or any other axis system) instead
|
||||
* of the local-axis grid `snapPointToGrid` uses. When omitted,
|
||||
* falls back to the local-axis snap at `step`.
|
||||
*/
|
||||
gridSnap?: (point: WallPlanPoint) => WallPlanPoint,
|
||||
): WallPlanPoint {
|
||||
const dx = cursor[0] - start[0]
|
||||
const dz = cursor[1] - start[1]
|
||||
const angle = Math.atan2(dz, dx)
|
||||
const snappedAngle = Math.round(angle / angleStep) * angleStep
|
||||
const distance = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
return snapPointToGrid(
|
||||
[start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
|
||||
step,
|
||||
)
|
||||
const point: WallPlanPoint = [
|
||||
start[0] + Math.cos(snappedAngle) * distance,
|
||||
start[1] + Math.sin(snappedAngle) * distance,
|
||||
]
|
||||
return gridSnap ? gridSnap(point) : snapPointToGrid(point, step)
|
||||
}
|
||||
|
||||
export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
|
||||
@@ -331,6 +338,13 @@ type SnapWallDraftArgs = {
|
||||
* keep the prior behaviour.
|
||||
*/
|
||||
magnetic?: boolean
|
||||
/**
|
||||
* Optional grid-snap override. Lets the caller route grid snapping
|
||||
* through a world-XZ aligned snap (so a rotated building's draft
|
||||
* lands on the visible grid). When omitted, falls back to the
|
||||
* local-axis grid at `step`.
|
||||
*/
|
||||
gridSnap?: (point: WallPlanPoint) => WallPlanPoint
|
||||
}
|
||||
|
||||
export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
|
||||
@@ -342,6 +356,7 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
||||
ignoreWallIds,
|
||||
step: overrideStep,
|
||||
magnetic = true,
|
||||
gridSnap,
|
||||
} = args
|
||||
|
||||
// Discrete special points (corner / midpoint / crossing) are taken from the
|
||||
@@ -356,8 +371,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
||||
const angleStep = getWallAngleSnapStep(step)
|
||||
const basePoint =
|
||||
start && angleSnap
|
||||
? snapPointTo45Degrees(start, point, step, angleStep)
|
||||
: snapPointToGrid(point, step)
|
||||
? snapPointTo45Degrees(start, point, step, angleStep, gridSnap)
|
||||
: gridSnap
|
||||
? gridSnap(point)
|
||||
: snapPointToGrid(point, step)
|
||||
|
||||
if (magnetic) {
|
||||
const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from './../../../lib/constants'
|
||||
import { sfxEmitter } from './../../../lib/sfx-bus'
|
||||
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
@@ -178,9 +179,13 @@ export const ZoneTool: React.FC = () => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// Snap to 0.5 grid
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to pull the snap off the visible grid lines.
|
||||
const [gridX, gridZ] = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
0.5,
|
||||
).local
|
||||
cursorPosition = [gridX, gridZ]
|
||||
levelYRef.current = event.localPosition[1]
|
||||
|
||||
@@ -209,8 +214,11 @@ export const ZoneTool: React.FC = () => {
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const [gridX, gridZ] = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
0.5,
|
||||
).local
|
||||
let clickPoint: [number, number] = [gridX, gridZ]
|
||||
|
||||
// Snap to axis from last point
|
||||
|
||||
@@ -104,6 +104,7 @@ export {
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPointDetailed,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
type WallDraftSnapKind,
|
||||
type WallDraftSnapResult,
|
||||
type WallPlanPoint,
|
||||
@@ -237,6 +238,12 @@ export {
|
||||
// nodes` so they don't need their own copy / their own tailwind-merge
|
||||
// dependency.
|
||||
export { cn } from './lib/utils'
|
||||
export {
|
||||
getActiveBuildingPose,
|
||||
resolveAlignmentForActiveBuilding,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapWorldXZForActiveBuilding,
|
||||
} from './lib/world-grid-snap'
|
||||
export { default as useAlignmentGuides } from './store/use-alignment-guides'
|
||||
export { default as useAudio } from './store/use-audio'
|
||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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 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 || 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() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline rotation the floor-plan view applies on top of the building
|
||||
* rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx —
|
||||
* the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`.
|
||||
*/
|
||||
const FLOORPLAN_VIEW_ROTATION_RAD = Math.PI / 2
|
||||
|
||||
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 || 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 || 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
|
||||
}
|
||||
@@ -137,21 +137,20 @@ function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
|
||||
|
||||
function elevatorMoveHandle(): HandleDescriptor<ElevatorNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
// Tap-to-engage: hand the elevator to its move tool (same path the
|
||||
// floating action menu's Move button takes via `setMovingNode`) so the
|
||||
// 3D grip and the floating-UI button share one move flow — green
|
||||
// bounding box, alignment guides, R/T rotation, click-to-commit.
|
||||
kind: 'tap-action',
|
||||
shape: 'move-cross',
|
||||
cursor: 'move',
|
||||
onActivate: (node, _scene, editor) => editor.engageMove(node),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const { halfZ } = elevatorOuterHalfExtents(n)
|
||||
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
|
||||
},
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: (n) => {
|
||||
const { halfX, halfZ } = elevatorOuterHalfExtents(n)
|
||||
const dimX = Math.max(halfX * 2, MIN_ELEVATOR_DIM)
|
||||
const dimZ = Math.max(halfZ * 2, MIN_ELEVATOR_DIM)
|
||||
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
|
||||
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +204,23 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
|
||||
rotation: [0, e.rotation ?? 0, 0],
|
||||
}
|
||||
},
|
||||
// Drag box wraps just the OUTER SHAFT × full shaft height — same footprint
|
||||
// alignment uses, same height the rendered shell occupies. Without this
|
||||
// override, `DragBoundingBox` would measure the whole mesh tree (per-level
|
||||
// landing assemblies, cab interior, buttons) and the box would feel
|
||||
// vertically off-centre when the elevator's lowest served level isn't the
|
||||
// building origin.
|
||||
dragBounds: (node, nodes) => {
|
||||
const e = node as ElevatorNodeType
|
||||
const { halfX, halfZ } = elevatorOuterHalfExtents(e)
|
||||
const { shaftBaseY, totalHeight } = resolveElevatorLevels(e, nodes ?? {})
|
||||
const cabHeight = Math.max(e.cabHeight, 1.4)
|
||||
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
|
||||
return {
|
||||
size: [halfX * 2, shaftHeight, halfZ * 2],
|
||||
centerY: shaftBaseY + shaftHeight / 2,
|
||||
}
|
||||
},
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
@@ -16,10 +16,12 @@ import {
|
||||
type FencePlanPoint,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapFenceDraftPoint,
|
||||
snapScalarToGrid,
|
||||
useAlignmentGuides,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
@@ -159,12 +161,14 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
// Endpoint move = grid snap only; the 45°-from-start angle
|
||||
// snap is draft-only. Shift switches to the fine grid step for
|
||||
// precision, matching the 3D fence endpoint action.
|
||||
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
const snapped = snapFenceDraftPoint({
|
||||
point: planPoint as FencePlanPoint,
|
||||
walls: nextWalls,
|
||||
fences: nextFences,
|
||||
ignoreFenceIds: [node.id],
|
||||
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep) as FencePlanPoint,
|
||||
})
|
||||
// Figma-style alignment on the dragged endpoint — snaps it onto
|
||||
// another object's edge / wall face and publishes a guide, matching
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getActiveRoofHeight,
|
||||
RidgeVentNode as RidgeVentSchema,
|
||||
type RoofSegmentNode,
|
||||
useScene,
|
||||
@@ -217,11 +216,8 @@ export default function RidgeVentPanel() {
|
||||
/>
|
||||
<SliderControl
|
||||
label="Y"
|
||||
max={Math.max(
|
||||
(segment?.wallHeight ?? 3) + (segment ? getActiveRoofHeight(segment) : 3) + 2,
|
||||
(node.position[1] ?? 0) + 0.1,
|
||||
)}
|
||||
min={Math.min(0, (node.position[1] ?? 0) - 0.5)}
|
||||
max={2}
|
||||
min={-2}
|
||||
onChange={(v) =>
|
||||
handleUpdate({
|
||||
position: [node.position[0] ?? 0, v, node.position[2] ?? 0],
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap'
|
||||
import { getSurfaceY } from '../shared/roof-surface'
|
||||
import { buildRidgeVentGeometry } from './geometry'
|
||||
|
||||
@@ -66,11 +67,26 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
|
||||
? ({ ...storeNode, ...overrides } as RidgeVentNode)
|
||||
: storeNode
|
||||
|
||||
const segment = useScene((state) =>
|
||||
const segmentStore = useScene((state) =>
|
||||
node.roofSegmentId
|
||||
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
// Subscribe to the segment's live overrides too — when the user drags a
|
||||
// segment handle (width / depth / wallHeight / pitch / rotation), the
|
||||
// dimensions stream through `useLiveNodeOverrides` and don't hit the
|
||||
// store until release. Merging them lets the ridge ride the segment in
|
||||
// real time instead of snapping into place on commit.
|
||||
const segmentOverrides = useLiveNodeOverrides((s) =>
|
||||
node.roofSegmentId
|
||||
? (s.get(node.roofSegmentId as AnyNodeId) as Partial<RoofSegmentNode> | undefined)
|
||||
: undefined,
|
||||
)
|
||||
const segment: RoofSegmentNode | undefined = segmentStore
|
||||
? segmentOverrides
|
||||
? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode)
|
||||
: segmentStore
|
||||
: undefined
|
||||
|
||||
const geometry = useMemo(
|
||||
() => buildRidgeVentGeometry(node),
|
||||
@@ -109,18 +125,26 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
|
||||
const segPos = segment.position ?? [0, 0, 0]
|
||||
const segRotY = segment.rotation ?? 0
|
||||
|
||||
// Seat the vent on the ridge by DERIVING its Y from the segment's current
|
||||
// surface rather than the stored `position[1]`. The ridge height comes from
|
||||
// the segment's pitch (`getActiveRoofHeight`), so when the roof is lowered
|
||||
// the segment updates, this renderer re-runs, and the vent rides the ridge
|
||||
// down automatically — no stale floating cap. X/Z stay as authored (the vent
|
||||
// straddles the ridge line at localZ≈0).
|
||||
const ridgeY = getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment)
|
||||
// Lock the BASE position to the ridge so the vent always starts on the
|
||||
// slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS
|
||||
// off that base (Y above ridge lift, Z away from ridge centerline). So
|
||||
// after placement the inspector's Y / Z sliders nudge the vent off the
|
||||
// locked ridge without losing the slope-tracking base. X is the position
|
||||
// along the ridge — the snap re-clamps it to the segment's ridge span.
|
||||
const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0)
|
||||
const ridgeX = snap ? snap.localX : (node.position[0] ?? 0)
|
||||
const baseZ = snap ? snap.localZ : 0
|
||||
const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT
|
||||
// Clamp legacy stored Y (absolute peak height from earlier versions) so the
|
||||
// vent doesn't fly off when the field was an absolute Y instead of offset.
|
||||
const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0))
|
||||
const ridgeY = baseY + yOffset
|
||||
const ridgeZ = baseZ + (node.position[2] ?? 0)
|
||||
|
||||
return (
|
||||
<group position={segPos} rotation-y={segRotY}>
|
||||
<group
|
||||
position={[node.position[0] ?? 0, ridgeY, node.position[2] ?? 0]}
|
||||
position={[ridgeX, ridgeY, ridgeZ]}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation ?? 0}
|
||||
visible={node.visible}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRidgeSnap } from '../shared/ridge-snap'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { ridgeVentDefinition } from './definition'
|
||||
import RidgeVentPreview from './preview'
|
||||
@@ -65,12 +66,18 @@ const RidgeVentTool = () => {
|
||||
)
|
||||
if (!hit) return
|
||||
|
||||
// Snap the cursor to the ridge by zeroing localZ via the
|
||||
// segment's local frame, then convert back through the building.
|
||||
// Project the cursor onto the segment's ridge line (clamped to the
|
||||
// segment's ridge span). The preview then moves ALONG the ridge as the
|
||||
// cursor moves — never off it. Flat segments have no ridge: hide.
|
||||
const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
|
||||
if (!snap) {
|
||||
setPreviewPos(null)
|
||||
return
|
||||
}
|
||||
const segObj = sceneRegistry.nodes.get(hit.segment.id)
|
||||
let ridgeWorld: [number, number, number]
|
||||
if (segObj) {
|
||||
const ridgeLocal = new THREE.Vector3(hit.localX, hit.localY, 0)
|
||||
const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ)
|
||||
segObj.updateWorldMatrix(true, false)
|
||||
ridgeLocal.applyMatrix4(segObj.matrixWorld)
|
||||
ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z]
|
||||
@@ -99,14 +106,15 @@ const RidgeVentTool = () => {
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
|
||||
if (!snap) return
|
||||
const state = useScene.getState()
|
||||
|
||||
const vent = RidgeVentNode.parse({
|
||||
...ridgeVentDefinition.defaults(),
|
||||
name: 'Ridge Vent',
|
||||
roofSegmentId: hit.segment.id,
|
||||
// Snap Z to 0 — ridge vents straddle the ridge line.
|
||||
position: [hit.localX, hit.localY, 0],
|
||||
position: [snap.localX, 0, snap.localZ],
|
||||
rotation: 0,
|
||||
})
|
||||
state.createNode(vent, hit.segment.id as AnyNodeId)
|
||||
|
||||
@@ -108,6 +108,51 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
// Contribute a plan AABB to the alignment-guide candidate pool so a roof
|
||||
// (and any moving sibling) snaps against the roof's outer silhouette.
|
||||
// Roof has no centred-box footprint — it's the union of its
|
||||
// `roof-segment` children — so we hand the bridge a resolved `aabb`
|
||||
// directly. The roof moves by its origin via `move-roof-tool`, so it
|
||||
// only ever contributes static candidates; the relocatable-box path
|
||||
// never needs to apply to roofs.
|
||||
alignmentFootprint: (node, nodes) => {
|
||||
const roof = node as RoofNodeType
|
||||
if (!nodes) return null
|
||||
const cos = Math.cos(roof.rotation ?? 0)
|
||||
const sin = Math.sin(roof.rotation ?? 0)
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxZ = Number.NEGATIVE_INFINITY
|
||||
let any = false
|
||||
for (const childId of roof.children ?? []) {
|
||||
const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
||||
if (segment?.type !== 'roof-segment') continue
|
||||
const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2
|
||||
const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2
|
||||
const sCos = Math.cos(segment.rotation ?? 0)
|
||||
const sSin = Math.sin(segment.rotation ?? 0)
|
||||
for (const [cx, cz] of [
|
||||
[-halfWidth, -halfDepth],
|
||||
[halfWidth, -halfDepth],
|
||||
[halfWidth, halfDepth],
|
||||
[-halfWidth, halfDepth],
|
||||
] as const) {
|
||||
// Segment corner → roof-local.
|
||||
const rx = segment.position[0] + cx * sCos + cz * sSin
|
||||
const rz = segment.position[2] - cx * sSin + cz * sCos
|
||||
// Roof-local → world (apply roof rotation, then position).
|
||||
const wx = roof.position[0] + rx * cos + rz * sin
|
||||
const wz = roof.position[2] - rx * sin + rz * cos
|
||||
if (wx < minX) minX = wx
|
||||
if (wx > maxX) maxX = wx
|
||||
if (wz < minZ) minZ = wz
|
||||
if (wz > maxZ) maxZ = wz
|
||||
any = true
|
||||
}
|
||||
}
|
||||
return any ? { shape: 'aabb', minX, minZ, maxX, maxZ } : null
|
||||
},
|
||||
},
|
||||
|
||||
// Bespoke free-floating move (drag-to-place with R/T rotation and
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
CursorSphere,
|
||||
commitFreshPlacementSubtree,
|
||||
DragBoundingBox,
|
||||
getFloorStackPreviewPosition,
|
||||
resolvePlanarCursorPosition,
|
||||
snapFenceDraftPoint,
|
||||
@@ -56,6 +57,7 @@ export const MoveRoofTool: React.FC<{
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
|
||||
const [previewRotation, setPreviewRotation] = useState<number>(movingNode.rotation as number)
|
||||
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
||||
const obj = sceneRegistry.nodes.get(movingNode.id)
|
||||
if (obj) {
|
||||
@@ -187,8 +189,8 @@ export const MoveRoofTool: React.FC<{
|
||||
|
||||
// Alignment for top-level stair / roof only. Segments live in parent-local
|
||||
// space (a different frame from the building-local candidate pool / guide
|
||||
// layer), so we leave them on the plain grid+corner snap. Stairs align by
|
||||
// their footprint edges; roofs keep the origin-point behavior.
|
||||
// layer), so we leave them on the plain grid+corner snap. Both stair and
|
||||
// roof align by their footprint bounding-box corners.
|
||||
const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof'
|
||||
const alignmentCandidates = alignTopLevel
|
||||
? collectAlignmentAnchors(
|
||||
@@ -203,7 +205,7 @@ export const MoveRoofTool: React.FC<{
|
||||
return [lx, lz]
|
||||
}
|
||||
const moving =
|
||||
movingNode.type === 'stair'
|
||||
movingNode.type === 'stair' || movingNode.type === 'roof'
|
||||
? movingAlignmentAnchors(movingNode, useScene.getState().nodes, lx, lz, pendingRotation)
|
||||
: []
|
||||
const ar = resolveAlignment({
|
||||
@@ -418,6 +420,7 @@ export const MoveRoofTool: React.FC<{
|
||||
triggerSFX('sfx:item-rotate')
|
||||
|
||||
pendingRotation += rotationDelta
|
||||
setPreviewRotation(pendingRotation)
|
||||
|
||||
// Directly update the Three.js mesh — no store update during drag
|
||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||
@@ -479,9 +482,21 @@ export const MoveRoofTool: React.FC<{
|
||||
}
|
||||
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
|
||||
|
||||
// Green footprint box during whole-stair / whole-roof moves. Skipped for
|
||||
// segments — their cursor lives in parent-local space and the bounding box
|
||||
// would render in the wrong frame.
|
||||
const showBoundingBox = movingNode.type === 'stair' || movingNode.type === 'roof'
|
||||
|
||||
return (
|
||||
<group visible={cursorVisible}>
|
||||
<CursorSphere position={cursorWorldPos} showTooltip={false} />
|
||||
{showBoundingBox && (
|
||||
<DragBoundingBox
|
||||
nodeId={movingNode.id}
|
||||
position={cursorWorldPos}
|
||||
rotationY={previewRotation}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,8 +67,15 @@ export function createPolygonCentroidMoveTarget(args: {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
/** 3D mesh Y the kind's system parks the group at on rebuild. */
|
||||
meshY: number
|
||||
/**
|
||||
* Extra fields merged into the commit payload. Use for kind-specific flags
|
||||
* that a manual drag should clear — e.g. slab `autoFromWalls: false`, so the
|
||||
* space-detection sync stops re-deriving the polygon from walls and
|
||||
* snapping the slab back to its original position.
|
||||
*/
|
||||
extraCommitData?: Record<string, unknown>
|
||||
}): FloorplanMoveTargetSession {
|
||||
const { node, nodes, meshY } = args
|
||||
const { node, nodes, meshY, extraCommitData } = args
|
||||
const id = node.id as AnyNodeId
|
||||
const typeGuard = node.type
|
||||
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
@@ -129,12 +136,19 @@ export function createPolygonCentroidMoveTarget(args: {
|
||||
// so the React render and the kind's geometry rebuild land in the same
|
||||
// paint (no original-position blink). Only write `holes` for kinds that
|
||||
// have them (zone has none).
|
||||
const data: { polygon: Array<[number, number]>; holes?: Array<Array<[number, number]>> } = {
|
||||
const data: {
|
||||
polygon: Array<[number, number]>
|
||||
holes?: Array<Array<[number, number]>>
|
||||
[key: string]: unknown
|
||||
} = {
|
||||
polygon: translatePolygon(originalPolygon, dx, dz),
|
||||
}
|
||||
if (hasHoles) {
|
||||
data.holes = originalHoles.map((h) => translatePolygon(h, dx, dz))
|
||||
}
|
||||
if (extraCommitData) {
|
||||
Object.assign(data, extraCommitData)
|
||||
}
|
||||
useScene.getState().updateNodes([{ id, data }])
|
||||
useScene.getState().markDirty(id)
|
||||
useLiveTransforms.getState().clear(id)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { RoofSegmentNode } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Shared ridge-line snap math for ridge-vent placement + move tools.
|
||||
*
|
||||
* Ridge vents must sit centered on the segment's ridge — off-ridge the
|
||||
* cap's far half dips into the higher part of the slope ("goes inside"
|
||||
* the roof). So the placement tools clamp the cursor onto the ridge:
|
||||
* closest-point projection along the segment's local X axis, with the X
|
||||
* span clipped to where a real ridge actually exists for that roof type.
|
||||
*
|
||||
* Per roof type (the segment's ridge runs along the segment's local X):
|
||||
* - gable / gambrel / dutch / mansard: ridge spans the full width.
|
||||
* - hip: ridge is shortened by the hipped ends — spans width − depth.
|
||||
* A square hip (width ≤ depth) collapses to a single apex point.
|
||||
* - shed: no true ridge — snap to the high eave (z = -depth/2).
|
||||
* - flat: no ridge at all → return null.
|
||||
*/
|
||||
|
||||
// Standard lift above the analytical slope surface so the cap reads as
|
||||
// sitting on the shingle course rather than clipping into it. Shared
|
||||
// with the renderer so live ridge-Y derivation matches placement.
|
||||
export const RIDGE_LIFT = 0.12
|
||||
|
||||
export type RidgeSnap = {
|
||||
/** Segment-local X of the snapped ridge position. */
|
||||
localX: number
|
||||
/** Segment-local Z of the snapped ridge position (0 for peaked roofs). */
|
||||
localZ: number
|
||||
}
|
||||
|
||||
export function resolveRidgeSnap(
|
||||
segment: RoofSegmentNode,
|
||||
cursorLocalX: number,
|
||||
_cursorLocalZ: number,
|
||||
): RidgeSnap | null {
|
||||
const roofType = segment.roofType ?? 'gable'
|
||||
if (roofType === 'flat') return null
|
||||
|
||||
const halfW = (segment.width ?? 0) / 2
|
||||
const halfD = (segment.depth ?? 0) / 2
|
||||
|
||||
const ridgeZ = roofType === 'shed' ? -halfD : 0
|
||||
const ridgeHalfLength = roofType === 'hip' ? Math.max(0, halfW - halfD) : halfW
|
||||
const localX = Math.max(-ridgeHalfLength, Math.min(ridgeHalfLength, cursorLocalX))
|
||||
|
||||
return { localX, localZ: ridgeZ }
|
||||
}
|
||||
@@ -11,4 +11,12 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
|
||||
* `meshY = 0`: `GeometrySystem` parks the slab group at y=0 on rebuild.
|
||||
*/
|
||||
export const slabFloorplanMoveTarget: FloorplanMoveTarget<SlabNode> = ({ node, nodes }) =>
|
||||
createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
|
||||
createPolygonCentroidMoveTarget({
|
||||
node,
|
||||
nodes,
|
||||
meshY: 0,
|
||||
// A user-dragged slab is a manual edit. Clear `autoFromWalls` so the
|
||||
// space-detection sync doesn't recompute the polygon from the wall loop
|
||||
// and snap the slab back to its original position.
|
||||
extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined,
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
type SlabNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
@@ -17,7 +16,10 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
getSegmentGridStep,
|
||||
markToolCancelConsumed,
|
||||
resolveAlignmentForActiveBuilding,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
@@ -164,10 +166,12 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (isFloorplanSourcedEvent(event)) return
|
||||
const gridStep = getSegmentGridStep()
|
||||
const [localX, localZ] = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
|
||||
})
|
||||
|
||||
if (
|
||||
@@ -189,7 +193,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
// publish a guide. Alt bypasses.
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
const result = resolveAlignmentForActiveBuilding({
|
||||
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
|
||||
@@ -352,7 +352,14 @@ function stairRotateHandle(): HandleDescriptor<StairNodeType> {
|
||||
|
||||
function stairMoveHandle(): HandleDescriptor<StairNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
// Tap-to-engage: hand the stair to its `MoveRoofTool` (same path the
|
||||
// floating action menu's Move button takes via `setMovingNode`) so the
|
||||
// 3D grip and the floating-UI button share one move flow — green
|
||||
// bounding box, alignment guides, R/T rotation, click-to-commit.
|
||||
kind: 'tap-action',
|
||||
shape: 'move-cross',
|
||||
cursor: 'move',
|
||||
onActivate: (node, _scene, editor) => editor.engageMove(node),
|
||||
placement: {
|
||||
// Low to the floor at the front edge (matches the item move grip) so it
|
||||
// reads as a floor-move grip and stays clear of the body resize / rotate
|
||||
@@ -362,14 +369,6 @@ function stairMoveHandle(): HandleDescriptor<StairNodeType> {
|
||||
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + STAIR_MOVE_FRONT_OFFSET]
|
||||
},
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: (n, sceneApi) => {
|
||||
const bounds = readStairMoveBounds(n, sceneApi)
|
||||
const dimX = Math.max(bounds.maxX - bounds.minX, MIN_CURVED_WIDTH)
|
||||
const dimZ = Math.max(bounds.maxZ - bounds.minZ, MIN_CURVED_WIDTH)
|
||||
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
|
||||
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CursorSphere,
|
||||
getSegmentGridStep,
|
||||
markToolCancelConsumed,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapScalarToGrid,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
@@ -85,12 +86,14 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const snapStep = getSegmentGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
const localZ = shiftPressedRef.current
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
// Snap the cursor on the WORLD XZ grid (still in building-local
|
||||
// coords for the rest of the math) so a rotated building doesn't
|
||||
// pull the curve handle off the visible grid lines.
|
||||
const [snappedLocalX, snappedLocalZ] = shiftPressedRef.current
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep)
|
||||
const localX = snappedLocalX
|
||||
const localZ = snappedLocalZ
|
||||
|
||||
const offsetFromMidpoint = -(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
alignFloorplanDraftPoint,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapScalarToGrid,
|
||||
snapWallDraftPoint,
|
||||
useAlignmentGuides,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -111,8 +113,11 @@ export const wallCurveAffordance: FloorplanAffordance<WallNode> = {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapStep = getSegmentGridStep()
|
||||
const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep)
|
||||
const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep)
|
||||
// World-grid snap so a rotated building doesn't drag the curve
|
||||
// handle off the visible grid.
|
||||
const [x, y] = modifiers.shiftKey
|
||||
? [planPoint[0], planPoint[1]]
|
||||
: snapBuildingLocalToWorldGrid([planPoint[0], planPoint[1]], snapStep)
|
||||
|
||||
// Signed projection of (snappedPoint - chord midpoint) onto the
|
||||
// chord normal. Legacy negates because the SVG y-axis flips
|
||||
@@ -186,11 +191,13 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
||||
// the angle snap is for initial draft only. Shift switches to
|
||||
// the fine grid step for precision, matching the 3D
|
||||
// `MoveWallEndpointTool`.
|
||||
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
const snapped = snapWallDraftPoint({
|
||||
point: planPoint as WallPlanPoint,
|
||||
walls,
|
||||
ignoreWallIds: [node.id],
|
||||
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep),
|
||||
})
|
||||
// Figma-style alignment on the dragged corner — snaps it onto another
|
||||
// object's edge / wall face and publishes a guide. The dragged wall
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
markToolCancelConsumed,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapScalarToGrid,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
@@ -470,9 +471,21 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const perpDelta = snappedProj - originalProj
|
||||
deltaX = axis[0] * perpDelta
|
||||
deltaZ = axis[1] * perpDelta
|
||||
} else if (shiftPressedRef.current) {
|
||||
deltaX = rawDeltaX
|
||||
deltaZ = rawDeltaZ
|
||||
} else {
|
||||
deltaX = shiftPressedRef.current ? rawDeltaX : snapScalarToGrid(rawDeltaX, snapStep)
|
||||
deltaZ = shiftPressedRef.current ? rawDeltaZ : snapScalarToGrid(rawDeltaZ, snapStep)
|
||||
// Snap the resulting wall center to the WORLD XZ grid (projected
|
||||
// back into building-local), then express the result as a delta
|
||||
// from the original centre. Without this, a rotated building
|
||||
// dragged the wall along its local axes instead of world ones.
|
||||
const targetLocal: [number, number] = [
|
||||
originalCenter[0] + rawDeltaX,
|
||||
originalCenter[1] + rawDeltaZ,
|
||||
]
|
||||
const snappedLocal = snapBuildingLocalToWorldGrid(targetLocal, snapStep)
|
||||
deltaX = snappedLocal[0] - originalCenter[0]
|
||||
deltaZ = snappedLocal[1] - originalCenter[1]
|
||||
}
|
||||
|
||||
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
|
||||
|
||||
@@ -686,6 +686,13 @@ export const WallTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
|
||||
// Alt commits a single wall — stop drafting instead of chaining
|
||||
// so the next click starts a fresh start point.
|
||||
if (event.nativeEvent?.altKey === true) {
|
||||
stopDrafting()
|
||||
return
|
||||
}
|
||||
|
||||
const nextStart = createdWall.end
|
||||
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
|
||||
@@ -105,6 +105,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
|
||||
textAnchor: 'middle',
|
||||
dominantBaseline: 'central',
|
||||
opacity: showSelectedChrome ? 1 : 0.92,
|
||||
upright: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user