editor: alignment guides + floor-plan move/placement parity (#372)
* 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 * feat(editor): 3D alignment guides for item/wall/fence move + placement Bring Figma-style alignment guides into the 3D editor, reusing the shared pure resolver (`resolveAlignment`) and ephemeral guide store (`useAlignmentGuides`) that previously only drove the 2D floor plan. Core: - `alignment-anchors.ts`: node→anchor adapters (footprint AABBs, corner anchors, wall/fence segment anchors) + `refineGuidesToGap` so a guide's line and distance read to the candidate's nearest edge, not the far side. - `bboxCornerAnchors` + corner-only footprint anchors so alignment locks to item edges, never centrelines. - `resolvePointSnap` (point-coincidence variant; kept for future use). Editor: - `Alignment3DGuideLayer`: dashed ribbon + flat floor dots + distance pill, in the project's indigo accent, mounted inside ToolManager's building-local group so guides render in the cursor's frame. - Producers wired in the item move tool, item placement coordinator, and the wall + fence endpoint tools; walls and fences cross-align. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): align guides snap to nearest real anchor, drop bbox re-span The 3D alignment guide could place its end dot in empty space: a diagonal wall (or any rotated / non-rectangular object) has bounding-box corners that don't lie on the object, and `refineGuidesToGap` re-spanned the guide to exactly those AABB edges — so the dot floated "along the coordinate" rather than on the item. - `resolveAlignment` now tie-breaks to the candidate anchor NEAREST on the perpendicular axis (after the tightest axis match). Anchors are real points (corners / endpoints / midpoints), so the guide always connects to the closest actual point — which also yields the facing-edge gap distance. - All four producers (item move, item placement, wall + fence endpoints) now publish the raw resolver guides; the AABB nearest-edge re-span is gone. - Removed the now-dead `refineGuidesToGap` and `resolvePointSnap` helpers (and their tests / exports). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): group move handle + shared group-transform core, move-tool polish Add a group-move gizmo alongside the existing group-rotate handle, both driven by a new shared `group-transform-shared` module (participant classification, group-box + corner math, connected wall/fence component expansion so attached structure transforms rigidly as one piece). - core: refactor alignment-anchors collection + tests, extend handle registry - editor: group-move-handle, group-transform-shared; rotate handle reuses them; node-arrow-handles gains click-swallow guard; box-select + placement tweaks - nodes: move-tool updates across ceiling/column/slab/roof/registry; door math and panel adjustments; item definition cleanup - nodes(fence): play `sfx:grid-snap` ticker on endpoint move, matching the wall endpoint tool (fixes missing audio feedback on fence side drag) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): keep autosave alive across page unload The autosave debounces writes by 1s and relies on a `beforeunload` flush for anything still pending. That flush fired a plain `fetch` PUT, which the browser cancels the instant the page unloads — so refreshing right after an edit (e.g. painting a roof material) silently dropped the change and the reload showed the last persisted scene. Thread a `{ keepalive }` option through the save callback and set it on the unload flush so the request survives the unload. Also listen for `pagehide` (fires where `beforeunload` does not, e.g. mobile Safari / bfcache) and clear the dirty flag up front so the two listeners don't double-send. Normal debounced saves omit `keepalive` (its 64KB body cap only constrains the best-effort unload flush, not regular saves). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): paint eraser + reset-all, drop roof cross-role bleed Material paint gains an eraser (clear a surface back to its default) and a "Reset all" action that defaults every painted surface on a node — for a roof that includes each child segment — via a generic `buildResetSurfaceMaterialUpdates` that nulls catch-all and role-specific material fields without per-kind knowledge. Also stop a single painted roof surface from bleeding onto the others: `getEffectiveRoofSurfaceMaterial`, `getRoofMaterialArray`, and the segment renderer no longer cross-fall-back between top/edge/wall. An unset role resolves only to the legacy catch-all (back-compat) or the theme default, so painting just the shingle, trim, or soffit stays on that surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): alignment guides + drag bounding box across tools Extend the Figma-style 3D alignment guides to the placement and move tools for columns, elevators, roofs, stairs, ceilings, slabs, fences, walls, doors, and windows: each collects alignment anchors from the scene, resolves a snap within the shared threshold, and drives the `useAlignmentGuides` overlay. Wall openings (doors/windows) only snap along their host wall via the new `wall-opening-alignment` helper. Add a shared `DragBoundingBox` overlay (exported from the editor barrel) that renders the dragged object's bounds during a move, wired into the column move tool alongside the alignment snap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): axis-stable resize-arrow drag plane + slimmer gizmo handles Build the linear resize-arrow's drag plane so it always contains the handle's axis (view direction minus its along-axis component) instead of a plane that merely faces the camera. The old camera-facing normal collapsed when the axis pointed toward the viewer — screen motion barely changed the axis component, so the resize crawled or stopped tracking the cursor. Also slim the extruded arrow/handle geometry (shared by the node arrows, wall side handles, and polygon editor) for a lighter gizmo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): fill-block wall opening highlight + selected frameless openings Draw the selected-wall opening highlight as a translucent block filling the cutout volume (front-side culled) instead of a single vertical pane, so it reads as an occupied slot from any angle — including a top-down floorplan view where an edge-on pane was invisible. Also highlight a directly-selected frameless opening (a `door` with openingKind `'opening'`), which otherwise renders no geometry of its own, and reflect live drag overrides via `useLiveNodeOverrides`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viewer): double-side slab hole side-walls Build the slab in 3D rather than via ExtrudeGeometry so each hole-wall quad is emitted twice with opposite winding. The slab material is forced to FrontSide (DoubleSide poisons the MRT scene pass), under which ExtrudeGeometry's single-sided hole walls get back-face culled and you see straight through the cut. The doubled quads keep the cut's inner thickness visible from any angle without z-fighting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): box-select building-scoped nodes like elevators Building-scoped selectable nodes (e.g. elevators) are children of the building, not the active level, so the level walk never reached them. Also walk the level's building children and box-test any registry- selectable kind by its rendered bounds, matching the column/stair/shelf path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): shelf placement alignment + column placement ghost Shelf placement now snaps to Figma-style alignment guides by its footprint edges (layered on grid snap, Alt bypasses), matching the existing 3D move tool. Guides refresh after each drop and clear on teardown. Column placement migrates to the registry `def.tool` path so it can render a translucent column ghost at the cursor (like the shelf build tool) instead of a bare cursor sphere — the editor package can't import the column geometry, so the tool now lives in packages/nodes: - extract `ColumnBody` from the renderer and add a `ColumnPreview` (cloned translucent material, raycast disabled, origin-positioned) - new `column/tool.tsx` registry placement tool with the same footprint-edge alignment as shelf / column move - wire `def.tool` + tool hints; drop the now-unreachable legacy editor-side `ColumnTool` and its dead file Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): floor-plan alignment, pivot moves & placement ghosts Bring the 3D editor's Figma-style alignment experience to the 2D floor plan across every node kind, fix pivot semantics on move, and add 2D placement ghosts. Alignment - Wall anchors now include ±thickness/2 face corners so columns/items/etc. snap flush to wall faces (fixes pillar↔wall); shared by 2D and 3D. - Shared apply-alignment helper (applyFloorplanAlignment / alignFloorplanDraftPoint, with excludeIds) used by move sessions, structural drafting (wall/fence/slab/zone/ceiling/roof), and wall/fence endpoint drags. - Door/window/wall-item moves get along-wall edge-to-edge snapping. - Generic free-translate move path aligns by edges (corner anchors). Pivot moves (2D) - Polygon kinds (slab/ceiling/zone) move by centroid→cursor via a shared polygon-centroid mover; stair moves by origin→cursor; matching 3D. - Shelf/column move targets write position directly (single source of truth) so the 3D group no longer sticks on commit. Placement ghosts (2D) - usePlacementPreview store + FloorplanPlacementPreviewLayer render a kind's def.floorplan footprint following the cursor; wired for column and elevator. Fixes - Elevator placement no longer deselects the active floor plan (preserve levelId through setSelection's hierarchy guard). - Guides clear on every commit/cancel/unmount path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(editor): address architecture review for floor-plan work - Move usePlacementPreview store from core to editor: placement ghosts are an editor/tool concern the read-only viewer never needs. Rewire the column tool (via the @pascal-app/editor public surface) and the editor-internal elevator tool + preview layer (relative imports). - FloorplanPlacementPreviewLayer: read scene lazily in ctx.resolve instead of bulk-reading the nodes map during render. - wiki/architecture/tools.md: refresh the stale useLiveTransforms-per-kind note to reflect item/shelf/column (world-plan) + slab/ceiling/zone (delta). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(nodes): recreate window draft on wall:move when null after placement After a successful click-to-place, the click handler deletes the transient draft and relies on the wall-rebuild → R3F pointer-enter cascade to create a fresh draft for the next placement. If that cascade doesn't fire synchronously (e.g. async geometry rebuild) the next wall:move receives a null draftRef and bails — requiring leave/re-enter to place again. Fix: in onWallMove, when draftRef.current is null but we're hovering a valid wall, recreate the draft immediately (same WindowNode.parse + createNode path as onWallEnter). This is idempotent: if wall:enter does fire first, destroyDraft() in onWallEnter cleans up cleanly. Preserves parity with door multi-place behaviour, matching #367's intent. * fix(nodes): recreate door draft on wall:move when null after placement Mirror of the window fix one commit back: after click-to-place the DoorTool deletes its transient draft and relies on the wall-rebuild \u2192 R3F pointer-enter cascade to spawn a fresh draft for the next placement. When that cascade doesn't fire synchronously, the next wall:move sees a null draftRef and bails \u2014 forcing a leave/re-enter to place again. Recreate the draft in onWallMove when null and over a valid wall on the current level. Idempotent with onWallEnter (destroyDraft cleans up if both fire). --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Pascal <open@pascal.app>
This commit is contained in:
co-authored by
Claude Opus 4.6
Pascal
parent
86db5decb8
commit
46f94b97b3
@@ -1,88 +1,14 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
import type { CeilingNode, FloorplanMoveTarget } from '@pascal-app/core'
|
||||
import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for ceiling — mirrors the 3D `MoveCeilingTool`
|
||||
* live-drag pattern. See the equivalent module in `slab/floorplan-move.ts`
|
||||
* for the full rationale; the only ceiling-specific detail is the
|
||||
* preserved Y offset (`CeilingSystem` positions the mesh at `height − 0.01`
|
||||
* on rebuild, so the direct `mesh.position.y` mirrors that to avoid a
|
||||
* vertical teleport when the React group position is reconciled).
|
||||
* 2D floor-plan move handler for ceiling. Delegates to the shared polygon
|
||||
* centroid-pivot mover (same pivot semantics as slab / items). See
|
||||
* `shared/polygon-centroid-move.ts` for the rationale.
|
||||
*
|
||||
* `meshY = height − 0.01`: `CeilingSystem` parks the ceiling group at that Y
|
||||
* on rebuild, so mirroring it during the drag avoids a vertical teleport in
|
||||
* split view.
|
||||
*/
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
function translatePolygon(
|
||||
polygon: ReadonlyArray<readonly [number, number]>,
|
||||
dx: number,
|
||||
dz: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
|
||||
}
|
||||
|
||||
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node }) => {
|
||||
const ceilingId = node.id as AnyNodeId
|
||||
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
const originalHoles = (node.holes ?? []).map((hole) =>
|
||||
hole.map(([x, z]) => [x, z] as [number, number]),
|
||||
)
|
||||
const height = node.height ?? 2.5
|
||||
let anchor: [number, number] | null = null
|
||||
let lastDelta: [number, number] = [0, 0]
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [ceilingId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
|
||||
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
|
||||
if (!anchor) {
|
||||
anchor = [snapped[0], snapped[1]]
|
||||
return
|
||||
}
|
||||
const dx = snapped[0] - anchor[0]
|
||||
const dz = snapped[1] - anchor[1]
|
||||
lastDelta = [dx, dz]
|
||||
useLiveTransforms.getState().set(ceilingId, {
|
||||
position: [dx, 0, dz],
|
||||
rotation: 0,
|
||||
})
|
||||
const mesh = sceneRegistry.nodes.get(ceilingId) as THREE.Object3D | undefined
|
||||
// Preserve ceiling height — `CeilingSystem` sets `mesh.position.y =
|
||||
// height − 0.01` on each rebuild; mirror that during the drag so
|
||||
// the mesh stays at ceiling height (not collapsed to y=0).
|
||||
if (mesh) mesh.position.set(dx, height - 0.01, dz)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[ceilingId] as CeilingNode | undefined
|
||||
if (!live || live.type !== 'ceiling') return false
|
||||
const [dx, dz] = lastDelta
|
||||
if (dx === 0 && dz === 0) return false
|
||||
// Sync commit sequence — see `slab/floorplan-move.ts` for the
|
||||
// full ordering rationale (scene write → direct markDirty →
|
||||
// useLiveTransforms.clear, all sync in this handler so React
|
||||
// render + CeilingSystem rebuild land in the same paint).
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: ceilingId,
|
||||
data: {
|
||||
polygon: translatePolygon(originalPolygon, dx, dz),
|
||||
holes: originalHoles.map((h) => translatePolygon(h, dx, dz)),
|
||||
},
|
||||
},
|
||||
])
|
||||
useScene.getState().markDirty(ceilingId)
|
||||
useLiveTransforms.getState().clear(ceilingId)
|
||||
return true
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node, nodes }) =>
|
||||
createPolygonCentroidMoveTarget({ node, nodes, meshY: (node.height ?? 2.5) - 0.01 })
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -32,6 +36,9 @@ function snap(value: number) {
|
||||
return Math.round(value * 2) / 2
|
||||
}
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
@@ -104,6 +111,10 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
const height = heightRef.current
|
||||
const ceilingId = node.id
|
||||
|
||||
// Alignment candidates — every other alignable object's anchors,
|
||||
// gathered once (the scene graph is stable during the drag).
|
||||
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, ceilingId)
|
||||
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (deltaX: number, deltaZ: number) => {
|
||||
@@ -146,7 +157,29 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
applyPreview(localX - anchor[0], localZ - anchor[1])
|
||||
let deltaX = localX - anchor[0]
|
||||
let deltaZ = localZ - anchor[1]
|
||||
|
||||
// Figma-style alignment snap: align the ceiling's translated polygon
|
||||
// vertices to other objects' anchors; fold the snap into the delta and
|
||||
// publish a guide. Alt bypasses.
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
deltaX += result.snap.dx
|
||||
deltaZ += result.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(result.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
applyPreview(deltaX, deltaZ)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
@@ -167,6 +200,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
useScene.getState().markDirty(ceilingId as AnyNodeId)
|
||||
}
|
||||
useLiveTransforms.getState().clear(ceilingId)
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [ceilingId] })
|
||||
@@ -176,6 +210,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
|
||||
const onCancel = () => {
|
||||
clearPreview()
|
||||
useAlignmentGuides.getState().clear()
|
||||
useViewer.getState().setSelection({ selectedIds: [ceilingId] })
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
@@ -186,6 +221,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (!wasCommitted) {
|
||||
clearPreview()
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
EDITOR_LAYER,
|
||||
@@ -25,6 +33,8 @@ import { CeilingNode } from './schema'
|
||||
|
||||
const CEILING_HEIGHT = 2.52
|
||||
const GRID_OFFSET = 0.02
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function calculateSnapPoint(
|
||||
lastPoint: [number, number],
|
||||
@@ -83,6 +93,11 @@ export const CeilingTool: React.FC = () => {
|
||||
// draw isn't built with a stale preset's parameters. Unmount-only.
|
||||
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
|
||||
|
||||
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
|
||||
// on every cursor move (cursorPosition is in its deps), so clearing guides
|
||||
// in its cleanup would wipe the guide the instant after each move sets it.
|
||||
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
|
||||
|
||||
const verticalGeo = useMemo(
|
||||
() =>
|
||||
new BufferGeometry().setFromPoints([
|
||||
@@ -100,20 +115,60 @@ export const CeilingTool: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object. The
|
||||
// ceiling's own in-progress vertices are intentionally excluded (no
|
||||
// self-alignment while drawing).
|
||||
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
// Snap the drafted vertex onto another object's nearest real anchor and
|
||||
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
|
||||
// point: resolving against the grid point would only ever catch anchors
|
||||
// that happen to sit on a grid line, so off-grid items (furniture, angled
|
||||
// walls) would never surface a guide. The matched axis locks exactly to the
|
||||
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
|
||||
// bypasses.
|
||||
const alignPoint = (
|
||||
fallback: [number, number],
|
||||
raw: [number, number],
|
||||
bypass: boolean,
|
||||
): [number, number] => {
|
||||
if (bypass || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return fallback
|
||||
}
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId: '__ceiling-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (ar.guides.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return fallback
|
||||
}
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
let [x, z] = fallback
|
||||
for (const guide of ar.guides) {
|
||||
if (guide.axis === 'x') x = guide.coord
|
||||
else z = guide.coord
|
||||
}
|
||||
return [x, z]
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
const gridX = Math.round(rawPoint[0] * 2) / 2
|
||||
const gridZ = Math.round(rawPoint[1] * 2) / 2
|
||||
const gridPosition: [number, number] = [gridX, gridZ]
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
|
||||
const gridY = event.localPosition[1] + GRID_OFFSET
|
||||
const lastPoint = points[points.length - 1]
|
||||
const displayPoint =
|
||||
const orthoPoint =
|
||||
shiftPressed.current || !lastPoint
|
||||
? gridPosition
|
||||
: calculateSnapPoint(lastPoint, gridPosition)
|
||||
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
@@ -144,6 +199,7 @@ export const CeilingTool: React.FC = () => {
|
||||
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
setPoints([...points, clickPoint])
|
||||
}
|
||||
@@ -155,12 +211,14 @@ export const CeilingTool: React.FC = () => {
|
||||
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (points.length > 0) markToolCancelConsumed()
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { buildColumnFloorplan } from './floorplan'
|
||||
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
|
||||
import { columnFloorplanMoveTarget } from './floorplan-move'
|
||||
import { columnParametrics } from './parametrics'
|
||||
import { ColumnNode } from './schema'
|
||||
|
||||
@@ -327,16 +328,28 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
affordanceTools: {
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
// Registry-driven placement tool — renders a translucent `ColumnPreview`
|
||||
// ghost at the cursor (mirroring the shelf build tool) instead of the
|
||||
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
|
||||
// registry-first path mounts this and skips the legacy `<ColumnTool>`.
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place column' },
|
||||
{ key: 'Alt', label: 'No snap' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
floorplan: buildColumnFloorplan,
|
||||
// 2D body move routes through this kind-specific target so the column
|
||||
// aligns by its footprint *edges* (and snaps flush to wall faces) instead
|
||||
// of the overlay's generic free-translate path, which aligned by bbox
|
||||
// centre and gathered candidates from SVG bounding boxes only. Mirrors the
|
||||
// shelf move target.
|
||||
floorplanMoveTarget: columnFloorplanMoveTarget,
|
||||
// 2D drag affordances — `column-resize` handles every dimension arrow
|
||||
// the floor-plan builder emits per cross-section / support style (the
|
||||
// payload's `dim` field discriminates radius / uniform / width / depth
|
||||
// / brace-width / brace-depth / spreads). `column-rotate` powers the
|
||||
// corner rotate-arrow. Body move continues to flow through the
|
||||
// orange move-handle dot via the registry overlay's generic
|
||||
// free-translate path — columns don't need a kind-specific
|
||||
// `floorplanMoveTarget` since they have no linked-cascade
|
||||
// requirements like wall.
|
||||
// corner rotate-arrow.
|
||||
floorplanAffordances: {
|
||||
'column-resize': columnResizeAffordance,
|
||||
'column-rotate': columnRotateAffordance,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ColumnNode,
|
||||
collectAlignmentAnchors,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
movingFootprintAnchors,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
snapPointToGrid,
|
||||
triggerSFX,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for column — mirrors `itemFloorplanMoveTarget`:
|
||||
* each pointermove writes the absolute world-plan position straight to
|
||||
* `useScene` (history paused by the overlay). The 2D SVG and the 3D group
|
||||
* transform both read `node.position` reactively, so they stay in lockstep;
|
||||
* the overlay's snapshot-diff makes the drag one undoable step. `canCommit`
|
||||
* only validates.
|
||||
*
|
||||
* Columns previously fell through to the overlay's generic free-translate
|
||||
* path, which aligned a column by its bbox *centre* and gathered candidates
|
||||
* from SVG bounding boxes only (missing wall faces / diagonal walls). Routing
|
||||
* through a kind-specific target gives column the same footprint-edge
|
||||
* alignment as shelf / item — including snapping flush to wall faces (the
|
||||
* pillar↔wall case this whole feature targets).
|
||||
*
|
||||
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern; for a
|
||||
* `position`-field kind that leaves the 3D group stuck at the old spot on
|
||||
* commit (nothing reconciles it off the cleared live transform, since the
|
||||
* geometry doesn't rebuild on a position-only change). See the shelf handler
|
||||
* for the full rationale.
|
||||
*
|
||||
* Column stores rotation as a scalar (not a tuple); position is `[x, y, z]`.
|
||||
*/
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ node, nodes }) => {
|
||||
const columnId = node.id as AnyNodeId
|
||||
const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
|
||||
const rotationY = node.rotation ?? 0
|
||||
let lastPosition: [number, number, number] = originalPosition
|
||||
let lastSnapKey: string | null = null
|
||||
|
||||
// Alignment candidates gathered once — scene is stable during the drag.
|
||||
const candidates = collectAlignmentAnchors(nodes, columnId)
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [columnId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const gridSnapped: WallPlanPoint = modifiers.shiftKey
|
||||
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
|
||||
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
|
||||
// Figma-style alignment layered on the grid snap (Alt bypasses).
|
||||
const { point: snapped } = applyFloorplanAlignment(
|
||||
gridSnapped,
|
||||
movingFootprintAnchors(
|
||||
node as unknown as AnyNode,
|
||||
gridSnapped[0],
|
||||
gridSnapped[1],
|
||||
rotationY,
|
||||
),
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
lastPosition = next
|
||||
|
||||
const snapKey = `${snapped[0]},${snapped[1]}`
|
||||
if (snapKey !== lastSnapKey) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
}
|
||||
// Single source of truth — write the absolute position straight to the
|
||||
// scene (history paused by the overlay). 2D SVG and 3D group transform
|
||||
// both follow `node.position` reactively, so they can't diverge.
|
||||
useScene.getState().updateNodes([{ id: columnId, data: { position: next } }])
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[columnId] as ColumnNode | undefined
|
||||
if (!live || live.type !== 'column') return false
|
||||
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
@@ -4,13 +4,23 @@ import {
|
||||
type AnyNodeId,
|
||||
type ColumnNode,
|
||||
ColumnNode as ColumnNodeSchema,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
movingFootprintAnchors,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
CursorSphere,
|
||||
DragBoundingBox,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
@@ -37,8 +47,12 @@ const snapToGridStep = (value: number) => {
|
||||
/** 90° steps, matching the GLB item / shelf placement rotation. */
|
||||
const ROTATION_STEP = Math.PI / 2
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||
const [previewRotation, setPreviewRotation] = useState<number>(node.rotation)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
@@ -61,9 +75,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
// Alignment candidates — every other alignable object's anchors, gathered
|
||||
// once (the scene graph is stable during the imperative drag).
|
||||
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, node.id)
|
||||
|
||||
const applyPreview = (position: [number, number, number]) => {
|
||||
lastPosition = position
|
||||
setPreviewPosition(position)
|
||||
setPreviewRotation(rotationY)
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position,
|
||||
rotation: rotationY,
|
||||
@@ -77,11 +96,29 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
hasMoved = true
|
||||
applyPreview([
|
||||
snapToGridStep(event.localPosition[0]),
|
||||
0,
|
||||
snapToGridStep(event.localPosition[2]),
|
||||
])
|
||||
let x = snapToGridStep(event.localPosition[0])
|
||||
let z = snapToGridStep(event.localPosition[2])
|
||||
|
||||
// Figma-style alignment snap on top of grid snap; Alt bypasses. The
|
||||
// guide connects to the candidate's nearest real anchor (resolver
|
||||
// tie-break), so the dot always sits on an actual point.
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(node, x, z, rotationY),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
x += result.snap.dx
|
||||
z += result.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(result.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
applyPreview([x, 0, z])
|
||||
}
|
||||
|
||||
// R / T rotate the dragged column about Y in 90° steps (matches the move
|
||||
@@ -99,11 +136,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!hasMoved) return
|
||||
const position: [number, number, number] = [
|
||||
snapToGridStep(event.localPosition[0]),
|
||||
0,
|
||||
snapToGridStep(event.localPosition[2]),
|
||||
]
|
||||
useAlignmentGuides.getState().clear()
|
||||
// Commit at the last previewed position so the alignment snap (which
|
||||
// may pull off-grid) is preserved, rather than re-snapping the raw
|
||||
// click to the grid.
|
||||
const position: [number, number, number] = [...lastPosition]
|
||||
const nodeId = (node as { id?: ColumnNode['id'] }).id
|
||||
|
||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||
@@ -134,6 +171,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(node.position[0], node.position[1], node.position[2])
|
||||
@@ -155,6 +193,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (!committed) {
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
@@ -166,7 +205,17 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
||||
return (
|
||||
<>
|
||||
<CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
||||
<DragBoundingBox
|
||||
fallbackSize={[node.width, node.height, node.depth]}
|
||||
nodeId={node.id}
|
||||
position={previewPosition}
|
||||
rotationY={previewRotation}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MoveColumnTool
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { createContext, useContext, useMemo, useRef } from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
|
||||
|
||||
const ColumnMaterialContext = createContext<Material>(baseMaterial())
|
||||
@@ -2076,6 +2076,130 @@ function Capital({ node, y, height }: { node: ColumnNode; y: number; height: num
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The column's geometry tree — either a fabricated support frame or the
|
||||
* classical base / shaft / capital stack. Extracted from `ColumnRenderer`
|
||||
* so the translucent placement ghost (`ColumnPreview`) renders the exact
|
||||
* same shape without the registry registration, pointer handlers, or
|
||||
* live-transform wiring the real renderer layers on. Material and edge
|
||||
* softness arrive through context, so each caller controls appearance by
|
||||
* wrapping this in its own providers.
|
||||
*/
|
||||
function ColumnBody({ node }: { node: ColumnNode }) {
|
||||
const shaftLayout = useMemo(() => {
|
||||
const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4)
|
||||
const capitalHeight =
|
||||
node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4)
|
||||
const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight)
|
||||
return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight }
|
||||
}, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height])
|
||||
|
||||
return node.supportStyle === 'a-frame' ? (
|
||||
<AFrameSupport node={node} />
|
||||
) : node.supportStyle === 'y-frame' ? (
|
||||
<YFrameSupport node={node} />
|
||||
) : node.supportStyle === 'v-frame' ? (
|
||||
<VFrameSupport node={node} />
|
||||
) : node.supportStyle === 'x-brace' ? (
|
||||
<XBraceSupport node={node} />
|
||||
) : node.supportStyle === 'k-brace' ? (
|
||||
<KBraceSupport node={node} />
|
||||
) : node.supportStyle === 'single-strut' ? (
|
||||
<SingleStrutSupport node={node} />
|
||||
) : node.supportStyle === 'tripod' ? (
|
||||
<TripodSupport node={node} />
|
||||
) : node.supportStyle === 'trestle' ? (
|
||||
<TrestleSupport node={node} />
|
||||
) : node.supportStyle === 'portal-frame' ? (
|
||||
<PortalFrameSupport node={node} />
|
||||
) : node.supportStyle === 'box-frame' ? (
|
||||
<BoxFrameSupport node={node} />
|
||||
) : (
|
||||
<>
|
||||
<Base height={shaftLayout.baseHeight} node={node} />
|
||||
<BaseCarvings height={shaftLayout.baseHeight} node={node} />
|
||||
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
|
||||
<Rings node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
|
||||
<LatheBands node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
|
||||
<Flutes node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
|
||||
<LowerCarvedBand
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<DravidianShaftPanels
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<SpiralRibs node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
|
||||
<Capital
|
||||
height={shaftLayout.capitalHeight}
|
||||
node={node}
|
||||
y={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
||||
/>
|
||||
<CapitalCarvings
|
||||
capitalHeight={shaftLayout.capitalHeight}
|
||||
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
||||
node={node}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translucent, non-interactive ghost of a column — the placement tool's
|
||||
* cursor preview, mirroring `ShelfPreview`. Builds the same geometry tree
|
||||
* as the real renderer via `<ColumnBody>` but:
|
||||
* - clones the material and makes it transparent (cloning is required:
|
||||
* `createColumnMaterial` can hand back a shared/cached instance, and
|
||||
* mutating it would turn every committed column see-through);
|
||||
* - disables raycast on every mesh so the ghost doesn't intercept the
|
||||
* placement cursor ray (which would stall `grid:move`);
|
||||
* - renders at the local origin so the caller's cursor group positions it.
|
||||
*/
|
||||
export const ColumnPreview = ({ node }: { node: ColumnNode }) => {
|
||||
const shading = useViewer((state) => state.shading)
|
||||
const textures = useViewer((state) => state.textures)
|
||||
const colorPreset = useViewer((state) => state.colorPreset)
|
||||
const groupRef = useRef<Group>(null)
|
||||
|
||||
const material = useMemo(() => {
|
||||
const ghost = createColumnMaterial({
|
||||
material: node.material,
|
||||
materialPreset: node.materialPreset,
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
}).clone()
|
||||
ghost.transparent = true
|
||||
ghost.opacity = 0.5
|
||||
ghost.depthWrite = false
|
||||
return ghost
|
||||
}, [shading, textures, colorPreset, node.material, node.materialPreset])
|
||||
|
||||
useEffect(() => () => material.dispose(), [material])
|
||||
|
||||
// Strip pointer events off the freshly-built meshes every render — the
|
||||
// geometry tree rebuilds when the ghost's dimensions change, so a one-shot
|
||||
// effect wouldn't cover later meshes.
|
||||
useEffect(() => {
|
||||
groupRef.current?.traverse((obj) => {
|
||||
;(obj as unknown as { raycast: () => void }).raycast = () => {}
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<ColumnMaterialContext.Provider value={material}>
|
||||
<ColumnEdgeSoftnessContext.Provider value={node.edgeSoftness ?? 0.025}>
|
||||
<group ref={groupRef}>
|
||||
<ColumnBody node={node} />
|
||||
</group>
|
||||
</ColumnEdgeSoftnessContext.Provider>
|
||||
</ColumnMaterialContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
// Merge any live drag override so width / depth / radius / height
|
||||
@@ -2115,14 +2239,6 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
|
||||
|
||||
useRegistry(node.id, node.type, ref)
|
||||
|
||||
const shaftLayout = useMemo(() => {
|
||||
const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4)
|
||||
const capitalHeight =
|
||||
node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4)
|
||||
const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight)
|
||||
return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight }
|
||||
}, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height])
|
||||
|
||||
return (
|
||||
<ColumnMaterialContext.Provider value={material}>
|
||||
<ColumnEdgeSoftnessContext.Provider value={node.edgeSoftness ?? 0.025}>
|
||||
@@ -2133,73 +2249,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
{node.supportStyle === 'a-frame' ? (
|
||||
<AFrameSupport node={node} />
|
||||
) : node.supportStyle === 'y-frame' ? (
|
||||
<YFrameSupport node={node} />
|
||||
) : node.supportStyle === 'v-frame' ? (
|
||||
<VFrameSupport node={node} />
|
||||
) : node.supportStyle === 'x-brace' ? (
|
||||
<XBraceSupport node={node} />
|
||||
) : node.supportStyle === 'k-brace' ? (
|
||||
<KBraceSupport node={node} />
|
||||
) : node.supportStyle === 'single-strut' ? (
|
||||
<SingleStrutSupport node={node} />
|
||||
) : node.supportStyle === 'tripod' ? (
|
||||
<TripodSupport node={node} />
|
||||
) : node.supportStyle === 'trestle' ? (
|
||||
<TrestleSupport node={node} />
|
||||
) : node.supportStyle === 'portal-frame' ? (
|
||||
<PortalFrameSupport node={node} />
|
||||
) : node.supportStyle === 'box-frame' ? (
|
||||
<BoxFrameSupport node={node} />
|
||||
) : (
|
||||
<>
|
||||
<Base height={shaftLayout.baseHeight} node={node} />
|
||||
<BaseCarvings height={shaftLayout.baseHeight} node={node} />
|
||||
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
|
||||
<Rings
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<LatheBands
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<Flutes
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<LowerCarvedBand
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<DravidianShaftPanels
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<SpiralRibs
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<Capital
|
||||
height={shaftLayout.capitalHeight}
|
||||
node={node}
|
||||
y={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
||||
/>
|
||||
<CapitalCarvings
|
||||
capitalHeight={shaftLayout.capitalHeight}
|
||||
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
||||
node={node}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ColumnBody node={node} />
|
||||
</group>
|
||||
</ColumnEdgeSoftnessContext.Provider>
|
||||
</ColumnMaterialContext.Provider>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
COLUMN_PRESETS,
|
||||
ColumnNode,
|
||||
type ColumnPresetId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
movingFootprintAnchors,
|
||||
resolveAlignment,
|
||||
snapPointToGrid,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { ColumnPreview } from './renderer'
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools and
|
||||
* the shelf placement tool. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
|
||||
|
||||
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
|
||||
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
||||
return ColumnNode.parse({
|
||||
name: label,
|
||||
position,
|
||||
rotation: 0,
|
||||
...preset,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry-driven column placement tool. Mirrors the shelf build tool:
|
||||
* a translucent `ColumnPreview` ghost follows the cursor (the piece the
|
||||
* legacy editor-side `ColumnTool` lacked — it only showed a sphere), grid
|
||||
* snap is layered with Figma-style alignment, and a `grid:click` commits.
|
||||
*
|
||||
* Lives in `packages/nodes` (not the editor) specifically so it can import
|
||||
* the column geometry for the ghost — the editor package can't depend on
|
||||
* `nodes`. Wired via `def.tool`, so `ToolManager`'s registry-first path
|
||||
* mounts it and the legacy `<ColumnTool>` branch no longer fires.
|
||||
*/
|
||||
const ColumnTool = () => {
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const previousSnapRef = useRef<[number, number] | null>(null)
|
||||
|
||||
// Default-preset column for the placement ghost — matches exactly what the
|
||||
// commit creates (`basicPillar`), so the preview is faithful.
|
||||
const previewNode = useMemo(() => createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [0, 0, 0]), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
previousSnapRef.current = null
|
||||
|
||||
// Alignment candidates — anchors of every other alignable object, gathered
|
||||
// here and refreshed after each placement so a just-placed column becomes a
|
||||
// target for the next one. `previewNode.id` never collides with a scene
|
||||
// node, so nothing real is excluded.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||||
|
||||
// Figma-style alignment snap layered on top of grid snap: when the
|
||||
// preview column's footprint edge lines up (on X or Z) with another
|
||||
// object's edge, snap there and publish a guide. Alt bypasses.
|
||||
let ax = sx
|
||||
let az = sz
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
ax += result.snap.dx
|
||||
az += result.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(result.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
cursorRef.current?.position.set(ax, event.localPosition[1], az)
|
||||
|
||||
// Publish a transient, positioned preview node for the 2D floor-plan
|
||||
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
|
||||
// placement-preview layer renders this node's footprint at the snapped,
|
||||
// aligned cursor so users see the pillar before they click.
|
||||
usePlacementPreview.getState().set({ ...previewNode, position: [ax, 0, az] })
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
previousSnapRef.current = [ax, az]
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||||
let ax = sx
|
||||
let az = sz
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
ax += result.snap.dx
|
||||
az += result.snap.dz
|
||||
}
|
||||
}
|
||||
|
||||
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ax, 0, az])
|
||||
useScene.getState().createNode(column, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [column.id] })
|
||||
triggerSFX('sfx:structure-build')
|
||||
// The placed column is now a valid alignment target for the next one;
|
||||
// refresh the candidate pool and drop the guide from this drop. The
|
||||
// 2D ghost re-publishes on the next move.
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
usePlacementPreview.getState().clear()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
useAlignmentGuides.getState().clear()
|
||||
usePlacementPreview.getState().clear()
|
||||
}
|
||||
}, [activeLevelId, previewNode])
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group ref={cursorRef}>
|
||||
<ColumnPreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColumnTool
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
NodeDefinition,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { scaleHandleHeight } from './door-math'
|
||||
import { buildDoorFloorplan } from './floorplan'
|
||||
import { doorWidthAffordance } from './floorplan-affordances'
|
||||
import { doorFloorplanMoveTarget } from './floorplan-move'
|
||||
@@ -85,9 +86,12 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
|
||||
currentValue: (n) => n.height,
|
||||
apply: (initial, newHeight) => {
|
||||
const bottom = initial.position[1] - initial.height / 2
|
||||
// Scale the handle so it tracks the door instead of staying glued to a
|
||||
// fixed floor height (shared with the panel's Height slider).
|
||||
return {
|
||||
height: newHeight,
|
||||
position: [initial.position[0], bottom + newHeight / 2, initial.position[2]],
|
||||
handleHeight: scaleHandleHeight(initial.handleHeight, initial.height, newHeight),
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Keep the door handle at the same relative height when the door is resized:
|
||||
* scale it by the height ratio, then clamp to the panel's slider bounds
|
||||
* [0.5, height - 0.1] so it never lands outside the (possibly shrunk) door.
|
||||
* Used by both the height-resize arrow and the panel's Height slider so the
|
||||
* handle tracks the door whichever way it's resized.
|
||||
*/
|
||||
export function scaleHandleHeight(
|
||||
handleHeight: number,
|
||||
oldHeight: number,
|
||||
newHeight: number,
|
||||
): number {
|
||||
const ratio = oldHeight > 0 ? newHeight / oldHeight : 1
|
||||
return Math.min(Math.max(handleHeight * ratio, 0.5), Math.max(0.5, newHeight - 0.1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
import { findClosestWallInPlan } from '../shared/wall-attach-target'
|
||||
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
|
||||
import { clampToWall, hasWallChildOverlap } from './door-math'
|
||||
|
||||
/**
|
||||
@@ -55,8 +55,20 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
|
||||
if (!hit) return // pointer off any wall — keep door at last valid position
|
||||
|
||||
// Snap the wall-local X to 0.5m grid (Shift bypasses).
|
||||
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
|
||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||
// openings / wall ends); it competes with — and wins over — the 0.5m
|
||||
// grid snap. Falls back to the grid snap when nothing aligns. Alt
|
||||
// bypasses; Shift drops the grid snap for fine positioning.
|
||||
const neighborX = modifiers.altKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width: node.width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
||||
|
||||
lastValid = {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
DoorNode,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
@@ -23,6 +24,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
@@ -94,8 +96,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||
// moving door is excluded so it never aligns to itself).
|
||||
const alignmentCandidates = collectAlignmentAnchors(
|
||||
useScene.getState().nodes,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
@@ -131,7 +141,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingDoorNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
@@ -190,7 +206,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingDoorNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
@@ -255,7 +277,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const { side, itemRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingDoorNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
@@ -395,6 +423,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { scaleHandleHeight } from './door-math'
|
||||
|
||||
const doorTypeOptions = [
|
||||
{ label: 'Hinged', value: 'hinged', available: true },
|
||||
@@ -716,7 +717,13 @@ export default function DoorPanel() {
|
||||
max={4}
|
||||
min={1.0}
|
||||
onChange={(v) =>
|
||||
handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
|
||||
handleUpdate({
|
||||
height: v,
|
||||
position: [node.position[0], v / 2, node.position[2]],
|
||||
// Keep the handle at the same relative height as the door resizes,
|
||||
// matching the height-resize arrow.
|
||||
handleHeight: scaleHandleHeight(node.handleHeight, node.height, v),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
DoorNode,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
@@ -14,13 +16,13 @@ import {
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
@@ -68,8 +70,13 @@ const DoorTool: React.FC = () => {
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each placement. A door aligns by the plan position of its centre.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
@@ -100,9 +107,15 @@ const DoorTool: React.FC = () => {
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const width = 0.9
|
||||
const height = 2.1
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
@@ -147,12 +160,38 @@ const DoorTool: React.FC = () => {
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const width = draftRef.current?.width ?? 0.9
|
||||
const height = draftRef.current?.height ?? 2.1
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
// Draft may be null after a successful placement (the click handler
|
||||
// deletes it and relies on the wall rebuild → pointer-enter cascade to
|
||||
// recreate it). Recreate it here on the first subsequent move so the
|
||||
// preview is ready for the next click without requiring a leave/enter.
|
||||
if (!draftRef.current) {
|
||||
const levelId = getLevelId()
|
||||
if (levelId && event.node.parentId === levelId) {
|
||||
const node = DoorNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
}
|
||||
|
||||
if (draftRef.current) {
|
||||
// Update the scene store on every move so the 2D floor plan
|
||||
// stays in sync (it re-renders from `node.position`). Only
|
||||
@@ -212,7 +251,13 @@ const DoorTool: React.FC = () => {
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: draftRef.current.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
@@ -279,6 +324,8 @@ const DoorTool: React.FC = () => {
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:item-place')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -302,6 +349,7 @@ const DoorTool: React.FC = () => {
|
||||
return () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
useAlignmentGuides.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
type DragAction,
|
||||
type FenceNode,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -43,6 +47,10 @@ import {
|
||||
|
||||
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the wall / item
|
||||
* tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean {
|
||||
return (
|
||||
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
|
||||
@@ -67,6 +75,9 @@ export type MoveFenceEndpointCtx = {
|
||||
linkedOriginals: LinkedFenceSnapshot[]
|
||||
levelWalls: WallNode[]
|
||||
levelFences: FenceNode[]
|
||||
/** Alignment anchors (endpoints + midpoints) of every OTHER wall / fence on
|
||||
* the level (building-local), feeding the resolver. */
|
||||
alignCandidates: AlignmentAnchor[]
|
||||
}
|
||||
|
||||
export type MoveFenceEndpointDraft = {
|
||||
@@ -132,6 +143,10 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
else if (node.type === 'fence') levelFences.push(node)
|
||||
}
|
||||
|
||||
// Alignment targets — anchors of every other alignable object (walls,
|
||||
// fences, items, slabs, ceilings, columns).
|
||||
const alignCandidates = collectAlignmentAnchors(useScene.getState().nodes, fence.id)
|
||||
|
||||
return {
|
||||
fenceId: fence.id as AnyNodeId,
|
||||
endpoint,
|
||||
@@ -143,6 +158,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
linkedOriginals: snapshotLinked(fence.id, parentId, originalMovingPoint),
|
||||
levelWalls,
|
||||
levelFences,
|
||||
alignCandidates,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -158,14 +174,32 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
ignoreFenceIds: [ctx.fenceId as string],
|
||||
step: modifiers.shift ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
const nextStart = ctx.endpoint === 'start' ? snapped : ctx.fixedPoint
|
||||
const nextEnd = ctx.endpoint === 'end' ? snapped : ctx.fixedPoint
|
||||
|
||||
// Figma-style alignment: nudge the dragged endpoint onto another wall /
|
||||
// fence endpoint or midpoint axis when within threshold, and publish a
|
||||
// guide. The resolver connects to the NEAREST real anchor, so the dot
|
||||
// always sits on an actual point. Alt is reserved for detach.
|
||||
let aligned = snapped
|
||||
if (ctx.alignCandidates.length > 0) {
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
|
||||
candidates: ctx.alignCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (ar.snap) {
|
||||
aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz]
|
||||
}
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
}
|
||||
|
||||
const nextStart = ctx.endpoint === 'start' ? aligned : ctx.fixedPoint
|
||||
const nextEnd = ctx.endpoint === 'end' ? aligned : ctx.fixedPoint
|
||||
const detached = modifiers.alt
|
||||
const linkedUpdates = detached
|
||||
? []
|
||||
: linkedCascade(ctx.linkedOriginals, ctx.originalMovingPoint, snapped)
|
||||
: linkedCascade(ctx.linkedOriginals, ctx.originalMovingPoint, aligned)
|
||||
return {
|
||||
movingPoint: snapped,
|
||||
movingPoint: aligned,
|
||||
start: nextStart,
|
||||
end: nextEnd,
|
||||
linkedUpdates,
|
||||
@@ -190,6 +224,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
},
|
||||
|
||||
commit: (draft, ctx, scene) => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
// Min-length rejection still matters — too-short fence is invalid
|
||||
// and should bounce back via the cancel path (snapshot restore).
|
||||
// But the "no-change" rejection is removed: see
|
||||
@@ -220,7 +255,8 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
},
|
||||
|
||||
cancel: (_ctx, _scene) => {
|
||||
// No-op — createDragSession.cancel() calls scene.restoreAll()
|
||||
useAlignmentGuides.getState().clear()
|
||||
// No-op otherwise — createDragSession.cancel() calls scene.restoreAll()
|
||||
// which puts every touched node back via the snapshot.
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
useAlignmentGuides,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
alignFloorplanDraftPoint,
|
||||
type FencePlanPoint,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
@@ -164,15 +166,23 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
ignoreFenceIds: [node.id],
|
||||
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
const nextStart = endpoint === 'start' ? snapped : fixedPoint
|
||||
const nextEnd = endpoint === 'end' ? snapped : fixedPoint
|
||||
// Figma-style alignment on the dragged endpoint — snaps it onto
|
||||
// another object's edge / wall face and publishes a guide, matching
|
||||
// the 3D fence endpoint action. The dragged fence and its linked
|
||||
// siblings (which cascade with the endpoint) are excluded from the
|
||||
// candidate pool. Alt is reserved for detach here, NOT bypass.
|
||||
const aligned = alignFloorplanDraftPoint(snapped, {
|
||||
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
|
||||
}) as FencePlanPoint
|
||||
const nextStart = endpoint === 'start' ? aligned : fixedPoint
|
||||
const nextEnd = endpoint === 'end' ? aligned : fixedPoint
|
||||
|
||||
const linkedUpdates = modifiers.altKey
|
||||
? []
|
||||
: linkedOriginals.map((l) => ({
|
||||
id: l.id,
|
||||
start: pointsNearlyEqual(l.start, originalMovingPoint) ? snapped : l.start,
|
||||
end: pointsNearlyEqual(l.end, originalMovingPoint) ? snapped : l.end,
|
||||
start: pointsNearlyEqual(l.start, originalMovingPoint) ? aligned : l.start,
|
||||
end: pointsNearlyEqual(l.end, originalMovingPoint) ? aligned : l.end,
|
||||
}))
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
@@ -184,6 +194,9 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
// Pointer-up always runs canCommit — drop the alignment guide here
|
||||
// so it doesn't linger after a commit / reject.
|
||||
useAlignmentGuides.getState().clear()
|
||||
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
|
||||
return (
|
||||
!!finalFence &&
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
type FenceNode,
|
||||
getWallCurveLength,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
type FencePlanPoint,
|
||||
@@ -15,7 +21,7 @@ import {
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { moveFenceEndpointDragAction } from './actions/move-endpoint'
|
||||
|
||||
/**
|
||||
@@ -119,6 +125,19 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
const liveEnd = liveFence?.end ?? target.fence.end
|
||||
const movingPoint = endpoint === 'start' ? liveStart : liveEnd
|
||||
|
||||
// Ticker SFX on each grid-snap step, mirroring the wall endpoint tool.
|
||||
// The action snaps the point before writing to the scene, so `movingPoint`
|
||||
// only changes in discrete grid steps — the right cadence for the click.
|
||||
// First tick just seeds the ref (no sound on mount).
|
||||
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = previousGridPosRef.current
|
||||
if (prev && (prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1])) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = movingPoint
|
||||
}, [movingPoint])
|
||||
|
||||
// Neighbour segments at the parent level — computed once at mount.
|
||||
const parentId = target.fence.parentId ?? null
|
||||
const neighbourSegments = useMemo(() => {
|
||||
@@ -153,6 +172,10 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
],
|
||||
)
|
||||
|
||||
// Safety net: drop any alignment guides if the tool unmounts without the
|
||||
// action's commit / cancel running (e.g. abrupt teardown).
|
||||
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
|
||||
|
||||
// Window-level keystate for the detach badge — independent of grid
|
||||
// event modifiers so the badge can toggle without a pointer move.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
import {
|
||||
calculateLevelMiters,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
getWallMiterBoundaryPoints,
|
||||
type LevelNode,
|
||||
type Point2D,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
@@ -35,6 +38,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
|
||||
|
||||
const FENCE_PREVIEW_HEIGHT = 1.8
|
||||
const FENCE_PREVIEW_THICKNESS = 0.08
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
// HUD label heights are measured from the top of the preview bar, so they
|
||||
// track whatever height a seeded preset draws at (`previewHeight`).
|
||||
const DRAFT_LABEL_Y_OFFSET = 0.22
|
||||
@@ -463,10 +468,34 @@ export const FenceTool: React.FC = () => {
|
||||
useEffect(() => {
|
||||
let previousFenceEnd: FencePlanPoint | null = null
|
||||
|
||||
// Alignment candidates — anchors of every alignable object. Refreshed
|
||||
// after each segment commits (the new fence becomes a candidate too).
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
const refreshAlignmentCandidates = () => {
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
}
|
||||
|
||||
// Align the drafted point onto another object's nearest real anchor and
|
||||
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
|
||||
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
||||
if (bypass || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return point
|
||||
}
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId: '__fence-draft__', kind: 'corner', x: point[0], z: point[1] }],
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
|
||||
}
|
||||
|
||||
const stopDrafting = () => {
|
||||
buildingState.current = 0
|
||||
previewRef.current.visible = false
|
||||
setDraftMeasurement(null)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
@@ -476,14 +505,13 @@ export const FenceTool: React.FC = () => {
|
||||
// Default = active grid step; Shift switches to the fine step
|
||||
// (0.05m). No 45° angle snap — see `wall/tool.tsx` for rationale.
|
||||
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = snapFenceDraftPoint({
|
||||
point: localPoint,
|
||||
walls,
|
||||
fences,
|
||||
step,
|
||||
})
|
||||
const snappedLocal = alignPoint(
|
||||
snapFenceDraftPoint({ point: localPoint, walls, fences, step }),
|
||||
bypassAlign,
|
||||
)
|
||||
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
||||
cursorRef.current.position.copy(endingPoint.current)
|
||||
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
||||
@@ -513,7 +541,10 @@ export const FenceTool: React.FC = () => {
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences, step })
|
||||
const snappedPoint = alignPoint(
|
||||
snapFenceDraftPoint({ point: localPoint, walls, fences, step }),
|
||||
bypassAlign,
|
||||
)
|
||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
@@ -528,26 +559,23 @@ export const FenceTool: React.FC = () => {
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = snapFenceDraftPoint({
|
||||
point: localClick,
|
||||
walls,
|
||||
fences,
|
||||
step: clickStep,
|
||||
})
|
||||
const snappedStart = alignPoint(
|
||||
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }),
|
||||
bypassAlign,
|
||||
)
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
previewRef.current.visible = true
|
||||
setDraftMeasurement(null)
|
||||
} else {
|
||||
const snappedEnd = snapFenceDraftPoint({
|
||||
point: localClick,
|
||||
walls,
|
||||
fences,
|
||||
step: clickStep,
|
||||
})
|
||||
const snappedEnd = alignPoint(
|
||||
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }),
|
||||
bypassAlign,
|
||||
)
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
const dz = snappedEnd[1] - startingPoint.current.z
|
||||
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||
@@ -557,6 +585,11 @@ export const FenceTool: React.FC = () => {
|
||||
)
|
||||
if (!createdFence) return
|
||||
|
||||
// The new segment is now a real node — make it an alignment target
|
||||
// for the next segment, and drop the just-shown guide.
|
||||
refreshAlignmentCandidates()
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
const nextStart = createdFence.end
|
||||
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
@@ -594,6 +627,7 @@ export const FenceTool: React.FC = () => {
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ import { itemFloorplanMoveTarget } from './floorplan-move'
|
||||
import { itemParametrics } from './parametrics'
|
||||
import { ItemNode } from './schema'
|
||||
|
||||
// Gizmo sits just past the front-right footprint corner; the guide ring
|
||||
// The two floor gizmos flank the item at mid-height so they never overlap,
|
||||
// even on small items: move sits past the left edge, rotate past the right
|
||||
// edge, both floated the same distance in front of the item. Mirrors the
|
||||
// wall-item layout below (WALL_SIDE_OFFSET / WALL_GIZMO_LIFT). The guide ring
|
||||
// traces a circle slightly outside the footprint's bounding circle.
|
||||
const ROTATE_CORNER_OFFSET = 0.25
|
||||
const GIZMO_SIDE_OFFSET = 0.3
|
||||
const GIZMO_FRONT_OFFSET = 0.3
|
||||
const ROTATE_RING_OFFSET = 0.06
|
||||
// How far past the item's front edge the move cross floats.
|
||||
const MOVE_FRONT_OFFSET = 0.35
|
||||
|
||||
// Whole-item rotation handle — the two-headed curved arrow. `arc-resize`
|
||||
// does the angular drag math (raycasts a horizontal plane at the gizmo's
|
||||
@@ -35,12 +37,12 @@ function itemRotateHandle(): HandleDescriptor<ItemNodeType> {
|
||||
return { rotation: [rx, ry - delta, rz] }
|
||||
},
|
||||
placement: {
|
||||
// Front-right corner of the footprint at mid-height. The registered
|
||||
// item mesh carries position + rotation only (scale lives on an
|
||||
// inner mesh), so the scaled footprint maps straight to world.
|
||||
// Past the item's right edge at mid-height, floated in front. The
|
||||
// registered item mesh carries position + rotation only (scale lives on
|
||||
// an inner mesh), so the scaled footprint maps straight to world.
|
||||
position: (n) => {
|
||||
const [w, h, d] = getScaledDimensions(n)
|
||||
return [w / 2, h / 2, d / 2 + ROTATE_CORNER_OFFSET]
|
||||
return [w / 2 + GIZMO_SIDE_OFFSET, h / 2, d / 2 + GIZMO_FRONT_OFFSET]
|
||||
},
|
||||
// Fixed −45° tilt leans the curve toward the item's front face.
|
||||
rotationY: () => -Math.PI / 4,
|
||||
@@ -56,27 +58,24 @@ function itemRotateHandle(): HandleDescriptor<ItemNodeType> {
|
||||
}
|
||||
}
|
||||
|
||||
// Free ground-plane move gizmo — the 4-way cross just outside the front edge.
|
||||
// Press-drag-release slides the item across the floor (live preview, commit
|
||||
// on release). `snapExtents` aligns the item's edges to the grid the same
|
||||
// way placement does, swapping width / depth at 90° turns.
|
||||
// The 4-way move cross, just past the item's left edge. Press-drag hands the
|
||||
// item to its placement coordinator (showing the bounding box, dimension labels
|
||||
// and grid-snap ticker) and commits on release — press-drag-release motion with
|
||||
// the full placement feedback.
|
||||
function itemMoveHandle(): HandleDescriptor<ItemNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
kind: 'tap-action',
|
||||
shape: 'move-cross',
|
||||
cursor: 'move',
|
||||
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
|
||||
placement: {
|
||||
// Sit just outside the item's front edge (centred in X, clear of the
|
||||
// model), low to the floor so it reads as a floor-move grip.
|
||||
// Past the item's left edge at mid-height, mirroring the rotate grip on
|
||||
// the right so the two never overlap on small items.
|
||||
position: (n) => {
|
||||
const [, , d] = getScaledDimensions(n)
|
||||
return [0, 0.02, d / 2 + MOVE_FRONT_OFFSET]
|
||||
const [w, h, d] = getScaledDimensions(n)
|
||||
return [-(w / 2 + GIZMO_SIDE_OFFSET), h / 2, d / 2 + GIZMO_FRONT_OFFSET]
|
||||
},
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: (n) => {
|
||||
const [dimX, , dimZ] = getScaledDimensions(n)
|
||||
const swap = Math.abs(Math.sin(n.rotation[1] ?? 0)) > 0.9
|
||||
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,27 +112,25 @@ function itemWallRotateHandle(): HandleDescriptor<ItemNodeType> {
|
||||
}
|
||||
}
|
||||
|
||||
// Slide the item across the wall face — constrained to the wall plane (along
|
||||
// the wall + up/down), depth pinned. Sits just past the item's left edge.
|
||||
// Move cross past the item's left edge on the wall face. Tap to hand the item
|
||||
// to its placement coordinator (`engageMove`) — same feedback as the floating
|
||||
// move button, and the coordinator handles the wall ↔ floor ↔ ceiling
|
||||
// transitions the generic translate drag couldn't. `plane: 'node-normal'`
|
||||
// stands the cross up against the wall face.
|
||||
function itemWallMoveHandle(): HandleDescriptor<ItemNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
kind: 'tap-action',
|
||||
shape: 'move-cross',
|
||||
plane: 'node-normal',
|
||||
portal: 'grandparent',
|
||||
cursor: 'move',
|
||||
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const [w] = getScaledDimensions(n)
|
||||
return [-(w / 2 + WALL_SIDE_OFFSET), 0, WALL_GIZMO_LIFT]
|
||||
},
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: (n) => {
|
||||
const [dimX, dimY] = getScaledDimensions(n)
|
||||
// A 90° roll about the normal swaps the item's along-wall + vertical
|
||||
// footprint.
|
||||
const swap = Math.abs(Math.sin(n.rotation[2] ?? 0)) > 0.9
|
||||
return [swap ? dimY : dimX, swap ? dimX : dimY]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
collectAlignmentAnchors,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
movingFootprintAnchors,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import { findClosestWallInPlan } from '../shared/wall-attach-target'
|
||||
import { applyFloorplanAlignment, snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for item. Branches on `asset.attachTo`:
|
||||
@@ -34,7 +36,7 @@ import { findClosestWallInPlan } from '../shared/wall-attach-target'
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node }) => {
|
||||
export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node, nodes }) => {
|
||||
const attachTo = node.asset.attachTo
|
||||
const startLevelId: AnyNodeId | null = (() => {
|
||||
// Walk to the owning level depending on the item's current parent:
|
||||
@@ -64,7 +66,7 @@ export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node })
|
||||
if (attachTo === 'ceiling') {
|
||||
return buildSurfaceItemSession(node, startLevelId, 'ceiling')
|
||||
}
|
||||
return buildFloorItemSession(node, startLevelId)
|
||||
return buildFloorItemSession(node, startLevelId, nodes)
|
||||
}
|
||||
|
||||
function buildWallItemSession(
|
||||
@@ -83,11 +85,24 @@ function buildWallItemSession(
|
||||
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
|
||||
if (!hit) return
|
||||
|
||||
const snappedLocalX = modifiers.shiftKey
|
||||
? hit.localX
|
||||
: Math.round(hit.localX / GRID_STEP) * GRID_STEP
|
||||
|
||||
const [width] = getScaledDimensions(node)
|
||||
|
||||
// Figma-style along-wall alignment (edge-to-edge with other openings /
|
||||
// wall items / wall ends), winning over the 0.5m grid snap; falls back
|
||||
// to grid when nothing aligns. Alt bypasses; Shift drops the grid snap.
|
||||
const neighborX = modifiers.altKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX =
|
||||
neighborX ??
|
||||
(modifiers.shiftKey ? hit.localX : Math.round(hit.localX / GRID_STEP) * GRID_STEP)
|
||||
|
||||
const halfW = width / 2
|
||||
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
|
||||
|
||||
@@ -125,13 +140,29 @@ function buildWallItemSession(
|
||||
function buildFloorItemSession(
|
||||
node: ItemNode,
|
||||
startLevelId: AnyNodeId | null,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): FloorplanMoveTargetSession {
|
||||
const rotationY = node.rotation[1] ?? 0
|
||||
// Alignment candidates gathered once — scene is stable during the drag.
|
||||
const candidates = collectAlignmentAnchors(nodes, node.id)
|
||||
return {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
const gridSnapped: WallPlanPoint = modifiers.shiftKey
|
||||
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
|
||||
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
|
||||
// Figma-style alignment layered on the grid snap (Alt bypasses).
|
||||
const { point: snapped } = applyFloorplanAlignment(
|
||||
gridSnapped,
|
||||
movingFootprintAnchors(
|
||||
node as unknown as AnyNode,
|
||||
gridSnapped[0],
|
||||
gridSnapped[1],
|
||||
rotationY,
|
||||
),
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey },
|
||||
)
|
||||
|
||||
const sourceY = node.position[1]
|
||||
const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]]
|
||||
|
||||
@@ -88,13 +88,10 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
|
||||
// Some slots have explicit materials; fill the rest from the themed array so
|
||||
// an untextured slot still picks up the scene-theme role colour, not blank white.
|
||||
// Per-role only, then the themed parent slot — no cross-role fallback, so
|
||||
// painting one segment surface never bleeds onto its other surfaces.
|
||||
const slot = (i: number) => themedArray?.[i] ?? new THREE.MeshStandardMaterial()
|
||||
return [
|
||||
edge ?? wall ?? top ?? slot(0),
|
||||
wall ?? edge ?? top ?? slot(1),
|
||||
wall ?? edge ?? top ?? slot(2),
|
||||
top ?? wall ?? edge ?? slot(3),
|
||||
] as THREE.Material[]
|
||||
return [edge ?? slot(0), wall ?? slot(1), wall ?? slot(2), top ?? slot(3)] as THREE.Material[]
|
||||
}, [
|
||||
node.material,
|
||||
node.materialPreset,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
resolveAlignment,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -25,6 +28,9 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
export const MoveRoofTool: React.FC<{
|
||||
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
|
||||
}> = ({ node: movingNode }) => {
|
||||
@@ -158,6 +164,29 @@ export const MoveRoofTool: React.FC<{
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
|
||||
// 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. The moving node
|
||||
// is aligned by its ORIGIN point (how this tool positions it), snapped to
|
||||
// any other alignable object's anchors.
|
||||
const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof'
|
||||
const alignmentCandidates = alignTopLevel
|
||||
? collectAlignmentAnchors(useScene.getState().nodes, movingNode.id)
|
||||
: []
|
||||
const alignLocalPoint = (lx: number, lz: number, bypass: boolean): [number, number] => {
|
||||
if (!alignTopLevel || bypass || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return [lx, lz]
|
||||
}
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }],
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
return ar.snap ? [lx + ar.snap.dx, lz + ar.snap.dz] : [lx, lz]
|
||||
}
|
||||
|
||||
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
|
||||
if (buildingObj) {
|
||||
const worldPoint = buildingObj.localToWorld(
|
||||
@@ -213,7 +242,15 @@ export const MoveRoofTool: React.FC<{
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
||||
// Layer alignment snap on top (top-level stair/roof). Recompute the
|
||||
// world point from the aligned building-local point so it stays correct
|
||||
// under building rotation.
|
||||
const [lx, lz] = alignLocalPoint(
|
||||
snappedLocal[0],
|
||||
snappedLocal[1],
|
||||
event.nativeEvent?.altKey === true,
|
||||
)
|
||||
const [gridX, , gridZ] = localToWorldPoint([lx, lz], y)
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
@@ -223,7 +260,6 @@ export const MoveRoofTool: React.FC<{
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
const [lx, lz] = snappedLocal
|
||||
setCursorWorldPos([lx, event.localPosition[1], lz])
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
||||
@@ -249,11 +285,16 @@ export const MoveRoofTool: React.FC<{
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
||||
const [lx, lz] = snappedLocal
|
||||
const [lx, lz] = alignLocalPoint(
|
||||
snappedLocal[0],
|
||||
snappedLocal[1],
|
||||
event.nativeEvent?.altKey === true,
|
||||
)
|
||||
const [gridX, , gridZ] = localToWorldPoint([lx, lz], y)
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
||||
|
||||
useAlignmentGuides.getState().clear()
|
||||
wasCommitted = true
|
||||
|
||||
// The store still holds the original values (we didn't update during drag).
|
||||
@@ -286,6 +327,7 @@ export const MoveRoofTool: React.FC<{
|
||||
const onCancel = () => {
|
||||
wasCancelled = true
|
||||
useLiveTransforms.getState().clear(movingNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingNode.id)
|
||||
} else {
|
||||
@@ -340,8 +382,9 @@ export const MoveRoofTool: React.FC<{
|
||||
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
|
||||
if (mergedRoofMesh) mergedRoofMesh.visible = true
|
||||
|
||||
// Clear ephemeral live transform
|
||||
// Clear ephemeral live transform + any alignment guides
|
||||
useLiveTransforms.getState().clear(movingNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
// Skip restore when the 2D floor-plan overlay claimed teardown
|
||||
// ownership — same contract `FloorplanRegistryMoveOverlay` uses to
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
type FloorplanMoveTargetSession,
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* Shared 2D floor-plan move for polygon-based kinds (slab / ceiling / zone).
|
||||
*
|
||||
* **Pivot semantics.** The move uses the polygon's **centroid** as the pivot:
|
||||
* the centroid snaps to the (grid-snapped, then Figma-aligned) cursor — the
|
||||
* same way a regular item's origin snaps to the cursor in both 3D and 2D.
|
||||
* This replaces the old grab-relative delta ("drag from wherever you first
|
||||
* touched"), so polygon kinds move consistently with every other item.
|
||||
*
|
||||
* **Why a delta in `useLiveTransforms`** (see `wiki/architecture/tools.md`):
|
||||
* polygon kinds carry their position in their vertices, not a `position`
|
||||
* field. The live preview translates the rendered `<group>` by the delta
|
||||
* (`ParametricNodeRenderer` consumes `useLiveTransforms.position` as the
|
||||
* group position) so the SVG follows the EXACT snapped result with no CSG
|
||||
* rebuild per tick. On commit we write the translated polygon once. Because
|
||||
* the live delta and the committed polygon are derived from the same
|
||||
* `lastDelta`, the visual and the commit always agree.
|
||||
*
|
||||
* `meshY` mirrors the value the kind's system sets on rebuild (slab: 0;
|
||||
* ceiling: `height − 0.01`) so the 3D mesh doesn't teleport vertically in a
|
||||
* split view during the drag.
|
||||
*/
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
/** Figma-style alignment threshold (meters) — parity with the 3D move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function translatePolygon(
|
||||
polygon: ReadonlyArray<readonly [number, number]>,
|
||||
dx: number,
|
||||
dz: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
|
||||
}
|
||||
|
||||
/** Average of the polygon's vertices — matches the 3D `MoveSlabTool` pivot. */
|
||||
function polygonCentroid(polygon: ReadonlyArray<readonly [number, number]>): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let sumX = 0
|
||||
let sumZ = 0
|
||||
for (const [x, z] of polygon) {
|
||||
sumX += x
|
||||
sumZ += z
|
||||
}
|
||||
return [sumX / polygon.length, sumZ / polygon.length]
|
||||
}
|
||||
|
||||
export function createPolygonCentroidMoveTarget(args: {
|
||||
node: {
|
||||
id: string
|
||||
type: string
|
||||
polygon: Array<[number, number]>
|
||||
holes?: Array<Array<[number, number]>>
|
||||
}
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
/** 3D mesh Y the kind's system parks the group at on rebuild. */
|
||||
meshY: number
|
||||
}): FloorplanMoveTargetSession {
|
||||
const { node, nodes, meshY } = args
|
||||
const id = node.id as AnyNodeId
|
||||
const typeGuard = node.type
|
||||
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
const hasHoles = Array.isArray(node.holes)
|
||||
const originalHoles = (node.holes ?? []).map((hole) =>
|
||||
hole.map(([x, z]) => [x, z] as [number, number]),
|
||||
)
|
||||
const originalCenter = polygonCentroid(originalPolygon)
|
||||
// Alignment candidates gathered once — the scene is stable during the drag.
|
||||
const candidates = collectAlignmentAnchors(nodes, id)
|
||||
let lastDelta: [number, number] = [0, 0]
|
||||
|
||||
return {
|
||||
affectedIds: [id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Centroid → snapped cursor. Grid-snap the target centroid (Shift
|
||||
// drops the grid snap), then layer Figma alignment on the translated
|
||||
// polygon's vertices and fold its snap into the delta. Alt bypasses.
|
||||
const target: WallPlanPoint = modifiers.shiftKey
|
||||
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
|
||||
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
|
||||
let dx = target[0] - originalCenter[0]
|
||||
let dz = target[1] - originalCenter[1]
|
||||
|
||||
if (!modifiers.altKey && candidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: polygonAnchors(id, translatePolygon(originalPolygon, dx, dz)),
|
||||
candidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
dx += result.snap.dx
|
||||
dz += result.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(result.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
lastDelta = [dx, dz]
|
||||
// Live-drag exception: write the delta to BOTH `useLiveTransforms`
|
||||
// (React source of truth) and the mesh (direct Three.js) so they don't
|
||||
// fight per frame.
|
||||
useLiveTransforms.getState().set(id, { position: [dx, 0, dz], rotation: 0 })
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
|
||||
if (mesh) mesh.position.set(dx, meshY, dz)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[id] as { type?: string } | undefined
|
||||
if (!live || live.type !== typeGuard) return false
|
||||
const [dx, dz] = lastDelta
|
||||
if (dx === 0 && dz === 0) return false
|
||||
// Sync commit: scene write → direct markDirty → clear live transform,
|
||||
// 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]>> } = {
|
||||
polygon: translatePolygon(originalPolygon, dx, dz),
|
||||
}
|
||||
if (hasHoles) {
|
||||
data.holes = originalHoles.map((h) => translatePolygon(h, dx, dz))
|
||||
}
|
||||
useScene.getState().updateNodes([{ id, data }])
|
||||
useScene.getState().markDirty(id)
|
||||
useLiveTransforms.getState().clear(id)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { type AnyNode, type AnyNodeId, isCurvedWall, type WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
isCurvedWall,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Shared helpers for the kinds whose 2D move snaps onto a wall in plan
|
||||
@@ -127,3 +134,83 @@ export function findClosestWallInPlan(
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/** Figma-style along-wall alignment threshold (meters) — parity with the
|
||||
* XZ placement / move threshold. */
|
||||
const ALONG_WALL_ALIGN_THRESHOLD_M = 0.08
|
||||
|
||||
/** The along-wall span of a wall-hosted node (door / window / wall item):
|
||||
* its centre `localX` and half-width. `null` for kinds with no along-wall
|
||||
* footprint. */
|
||||
function wallAttachmentSpan(node: AnyNode): { center: number; half: number } | null {
|
||||
if (node.type === 'door' || node.type === 'window') {
|
||||
const n = node as { position: [number, number, number]; width: number }
|
||||
return { center: n.position[0], half: n.width / 2 }
|
||||
}
|
||||
if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
const attachTo = item.asset.attachTo
|
||||
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
||||
const [w] = getScaledDimensions(item)
|
||||
return { center: item.position[0], half: w / 2 }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma-style alignment for a wall-hosted opening / item, along the wall
|
||||
* axis. Snaps the moving node's edges (or centre) to other attachments'
|
||||
* edges/centres on the same wall, plus the wall ends. Edge-to-edge first,
|
||||
* so two doors line up flush.
|
||||
*
|
||||
* Returns the adjusted `localX` when a neighbour stop is within threshold,
|
||||
* or `null` when nothing aligns — callers treat `null` as "no alignment,
|
||||
* fall back to the grid snap". This lets along-wall alignment COMPETE with
|
||||
* the 0.5m grid (openings have arbitrary widths rarely on the grid, so
|
||||
* layering on top of the grid snap would almost never trigger).
|
||||
*
|
||||
* Snap-only for v1 — no guide is published (the floor-plan guide layer
|
||||
* renders XZ guides; an along-wall guide on a diagonal wall needs extra
|
||||
* projection work, deferred).
|
||||
*/
|
||||
export function snapLocalXToNeighbors(args: {
|
||||
wall: WallNode
|
||||
localX: number
|
||||
width: number
|
||||
selfId: AnyNodeId
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
threshold?: number
|
||||
}): number | null {
|
||||
const { wall, localX, width, selfId, nodes, threshold = ALONG_WALL_ALIGN_THRESHOLD_M } = args
|
||||
const half = width / 2
|
||||
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
|
||||
// Candidate stops along the wall: both ends + every other attachment's
|
||||
// edges and centre.
|
||||
const candidateStops: number[] = [0, wallLength]
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!node || node.id === selfId) continue
|
||||
if ((node as { parentId?: string }).parentId !== wall.id) continue
|
||||
const span = wallAttachmentSpan(node)
|
||||
if (!span) continue
|
||||
candidateStops.push(span.center - span.half, span.center, span.center + span.half)
|
||||
}
|
||||
|
||||
// Moving stops: our two edges (edge-to-edge alignment) + centre.
|
||||
const movingStops = [localX - half, localX, localX + half]
|
||||
|
||||
let bestDelta: number | null = null
|
||||
let bestAbs = threshold
|
||||
for (const ms of movingStops) {
|
||||
for (const cs of candidateStops) {
|
||||
const d = cs - ms
|
||||
const ad = Math.abs(d)
|
||||
if (ad <= bestAbs && (bestDelta === null || ad < bestAbs)) {
|
||||
bestAbs = ad
|
||||
bestDelta = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestDelta === null ? null : localX + bestDelta
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08
|
||||
/**
|
||||
* A wall opening (door / window) can only slide ALONG its host wall, so it can
|
||||
* only satisfy an x- or z-guide when the wall runs along that axis. Below this
|
||||
* |component| (≈ wall within 60° of the axis) the along-wall move needed to
|
||||
* reach the guide blows up, so we skip it rather than jump the opening across
|
||||
* the wall.
|
||||
*/
|
||||
const MIN_AXIS_COMPONENT = 0.5
|
||||
|
||||
/**
|
||||
* Resolve a wall opening's along-wall position with Figma-style alignment to
|
||||
* other objects, publishing the matching guide as a side effect.
|
||||
*
|
||||
* The probe is the RAW cursor position on the wall (not the 0.5m snap) so
|
||||
* off-grid anchors are caught; we then keep only the guide on an axis the wall
|
||||
* runs along and map it to the along-wall coordinate that lands the opening on
|
||||
* it. Falls back to the half-metre snap when nothing aligns, and clears the
|
||||
* guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
|
||||
* given `width`). `bypass` (Alt) disables alignment.
|
||||
*/
|
||||
export function resolveWallSlideAlignment(args: {
|
||||
wallNode: WallNode
|
||||
rawLocalX: number
|
||||
width: number
|
||||
candidates: readonly AlignmentAnchor[]
|
||||
bypass: boolean
|
||||
}): number {
|
||||
const { wallNode, rawLocalX, width, candidates, bypass } = args
|
||||
const base = snapToHalf(rawLocalX)
|
||||
if (bypass || candidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return base
|
||||
}
|
||||
|
||||
const dx = wallNode.end[0] - wallNode.start[0]
|
||||
const dz = wallNode.end[1] - wallNode.start[1]
|
||||
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
||||
if (wallLength < 1e-6) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return base
|
||||
}
|
||||
const cos = dx / wallLength
|
||||
const sin = dz / wallLength
|
||||
const clampX = (localX: number) => Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
||||
|
||||
const probe = resolveAlignment({
|
||||
moving: [
|
||||
{
|
||||
nodeId: '__wall-opening-draft__',
|
||||
kind: 'corner',
|
||||
x: wallNode.start[0] + rawLocalX * cos,
|
||||
z: wallNode.start[1] + rawLocalX * sin,
|
||||
},
|
||||
],
|
||||
candidates,
|
||||
threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
|
||||
// Keep only a guide on an axis the wall runs along, mapped to the along-wall
|
||||
// position that satisfies it; pick the nearest such.
|
||||
let bestLocalX: number | null = null
|
||||
let bestDelta = Number.POSITIVE_INFINITY
|
||||
for (const guide of probe.guides) {
|
||||
const denom = guide.axis === 'x' ? cos : sin
|
||||
if (Math.abs(denom) < MIN_AXIS_COMPONENT) continue
|
||||
const origin = guide.axis === 'x' ? wallNode.start[0] : wallNode.start[1]
|
||||
const targetLocalX = (guide.coord - origin) / denom
|
||||
const delta = Math.abs(targetLocalX - rawLocalX)
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta
|
||||
bestLocalX = targetLocalX
|
||||
}
|
||||
}
|
||||
if (bestLocalX === null) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return base
|
||||
}
|
||||
|
||||
const clampedX = clampX(bestLocalX)
|
||||
// Re-resolve from where the opening actually lands (post-clamp) so the
|
||||
// published guide connects to the opening, not the raw cursor.
|
||||
const published = resolveAlignment({
|
||||
moving: [
|
||||
{
|
||||
nodeId: '__wall-opening-draft__',
|
||||
kind: 'corner',
|
||||
x: wallNode.start[0] + clampedX * cos,
|
||||
z: wallNode.start[1] + clampedX * sin,
|
||||
},
|
||||
],
|
||||
candidates,
|
||||
threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (published.guides.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
useAlignmentGuides.getState().set(published.guides)
|
||||
}
|
||||
return clampedX
|
||||
}
|
||||
@@ -1,51 +1,76 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
movingFootprintAnchors,
|
||||
type ShelfNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapPointToGrid, triggerSFX, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
snapPointToGrid,
|
||||
triggerSFX,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for shelf — behaves like items in the
|
||||
* floor-plan move flow:
|
||||
* 2D floor-plan move handler for shelf — mirrors `itemFloorplanMoveTarget`,
|
||||
* because shelf is a `position`-field kind (it carries its location in
|
||||
* `node.position`, not in polygon vertices):
|
||||
*
|
||||
* - Each pointermove writes the absolute world-plan target position
|
||||
* to `useLiveTransforms` (so the 2D layer's `effectiveNode` override
|
||||
* re-renders the SVG at the new position) AND mutates the
|
||||
* registered mesh's `position` directly (so the 3D view mirrors the
|
||||
* drag in real time).
|
||||
* - On commit, `canCommit` writes the final position to `scene` as a
|
||||
* single tracked update — the dispatcher's snapshot-diff captures
|
||||
* it as one undoable step.
|
||||
* - On any non-commit unmount (escape, abnormal teardown) the
|
||||
* dispatcher clears `useLiveTransforms` for affectedIds, so the 3D
|
||||
* visual snaps back to the reverted scene state.
|
||||
* - Each pointermove writes the absolute world-plan position straight
|
||||
* to `useScene` (history is paused by the overlay). This is the single
|
||||
* source of truth: the 2D `FloorplanRegistryLayer` and the 3D
|
||||
* `ParametricNodeRenderer` group transform both follow it reactively,
|
||||
* so 2D and 3D can never diverge.
|
||||
* - On commit, the overlay's snapshot-diff reverts to baseline, resumes
|
||||
* history, and re-applies the final position as one undoable step.
|
||||
* `canCommit` only validates.
|
||||
*
|
||||
* Unlike `slab` / `ceiling`, this writes the **absolute** position (the
|
||||
* shelf carries its location in `node.position`, not in polygon
|
||||
* vertices). The 2D layer's override branch for `shelf` mirrors `item`'s
|
||||
* world-plan handling.
|
||||
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern that
|
||||
* `slab` / `ceiling` use. That works for polygon kinds because their commit
|
||||
* rebuilds geometry (the vertices change), which forces the 3D group to
|
||||
* reconcile. Shelf's `geometryKey` excludes `position`, so its commit
|
||||
* `markDirty` is a no-op and nothing reconciled the 3D group off the cleared
|
||||
* live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the
|
||||
* scene directly removes that second source of truth entirely.
|
||||
*/
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node }) => {
|
||||
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => {
|
||||
const shelfId = node.id as AnyNodeId
|
||||
const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
|
||||
const originalRotationY = node.rotation[1] ?? 0
|
||||
let lastPosition: [number, number, number] = originalPosition
|
||||
let lastSnapKey: string | null = null
|
||||
|
||||
// Alignment candidates — corner/edge/segment anchors of every OTHER node
|
||||
// (incl. wall faces). Gathered once: the scene is stable during the drag
|
||||
// (only the shelf moves), so re-collecting per tick is wasted work.
|
||||
const candidates = collectAlignmentAnchors(nodes, shelfId)
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
const gridSnapped: WallPlanPoint = modifiers.shiftKey
|
||||
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
|
||||
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
|
||||
// Figma-style alignment layered on the grid snap — the shelf footprint
|
||||
// edges snap to neighbours / wall faces and a guide is published. Alt
|
||||
// bypasses (matches placement tools' "No snap").
|
||||
const { point: snapped } = applyFloorplanAlignment(
|
||||
gridSnapped,
|
||||
movingFootprintAnchors(
|
||||
node as unknown as AnyNode,
|
||||
gridSnapped[0],
|
||||
gridSnapped[1],
|
||||
originalRotationY,
|
||||
),
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
lastPosition = next
|
||||
|
||||
@@ -57,44 +82,22 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
}
|
||||
// Live preview — same shape items use. `useLiveTransforms.position`
|
||||
// holds world-plan coords (level-local); the 2D `FloorplanRegistryLayer`
|
||||
// override for `shelf` reads this and re-renders the SVG entry.
|
||||
useLiveTransforms.getState().set(shelfId, {
|
||||
position: next,
|
||||
rotation: originalRotationY,
|
||||
})
|
||||
// Mirror to the 3D mesh so split-view follows the cursor without
|
||||
// touching scene state per tick (no CSG, no React re-render of
|
||||
// geometry — same imperative live-drag pattern as the 3D
|
||||
// `MoveRegistryNodeTool`).
|
||||
const mesh = sceneRegistry.nodes.get(shelfId) as THREE.Object3D | undefined
|
||||
if (mesh) mesh.position.set(next[0], next[1], next[2])
|
||||
// Single source of truth — write the absolute position straight to
|
||||
// the scene (history is paused by the overlay). Both the 2D SVG and
|
||||
// the 3D group transform read `node.position` reactively, so they
|
||||
// stay in lockstep. The overlay's snapshot-diff turns the whole drag
|
||||
// into one undoable step on commit.
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: shelfId,
|
||||
data: { position: next },
|
||||
},
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined
|
||||
if (!live || live.type !== 'shelf') return false
|
||||
if (lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) {
|
||||
return false
|
||||
}
|
||||
// Side-effect commit — write final position. The dispatcher's
|
||||
// snapshot-diff right after `canCommit` returns picks this up as
|
||||
// the single tracked change for undo. `useLiveTransforms` is
|
||||
// cleared in the dispatcher's commit path (and in our
|
||||
// abnormal-unmount cleanup) so the 3D view reconciles to the
|
||||
// committed scene position on the next render.
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: shelfId,
|
||||
data: { position: lastPosition },
|
||||
},
|
||||
])
|
||||
// The shelf's geometry doesn't depend on `position` (it's the
|
||||
// group's transform, not the build inputs), but we mark dirty so
|
||||
// any sibling-aware system that does watch position re-runs.
|
||||
useScene.getState().markDirty(shelfId)
|
||||
useLiveTransforms.getState().clear(shelfId)
|
||||
return true
|
||||
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
|
||||
},
|
||||
}
|
||||
return session
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
collectAlignmentAnchors,
|
||||
type EventSuffix,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
movingFootprintAnchors,
|
||||
type NodeEvent,
|
||||
resolveAlignment,
|
||||
ShelfNode,
|
||||
sceneRegistry,
|
||||
snapPointToGrid,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
@@ -21,6 +25,11 @@ import ShelfPreview from './preview'
|
||||
const worldVector = new Vector3()
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools and
|
||||
* the 2D floor-plan overlay. 8 cm gives a magnetic pull layered on top of the
|
||||
* grid snap without fighting it. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
/**
|
||||
* Click-trigger kinds: when the user clicks ANY of these during shelf
|
||||
* placement, we commit at the latest cursor position. R3F's pointer
|
||||
@@ -107,15 +116,47 @@ const ShelfTool = () => {
|
||||
*/
|
||||
const lastCursorRef: { current: [number, number, number] | null } = { current: null }
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (items,
|
||||
// walls, fences, slabs, ceilings, columns, other shelves). Gathered once
|
||||
// here and refreshed after each placement so a just-placed shelf becomes a
|
||||
// target for the next one. `previewNode.id` never collides with a scene
|
||||
// node, so nothing real is excluded.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||||
cursorRef.current?.position.set(sx, event.localPosition[1], sz)
|
||||
lastCursorRef.current = [sx, event.localPosition[1], sz]
|
||||
|
||||
// Figma-style alignment snap layered on top of grid snap: when the
|
||||
// preview shelf's footprint edge lines up (on X or Z) with another
|
||||
// object's edge, snap there and publish a guide. The probe uses the
|
||||
// shelf's footprint corners at the proposed grid position so it aligns
|
||||
// by its edges, not its centre — matching `MoveRegistryNodeTool`. Alt
|
||||
// bypasses.
|
||||
let ax = sx
|
||||
let az = sz
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
ax += result.snap.dx
|
||||
az += result.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(result.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
cursorRef.current?.position.set(ax, event.localPosition[1], az)
|
||||
lastCursorRef.current = [ax, event.localPosition[1], az]
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
previousSnapRef.current = [sx, sz]
|
||||
previousSnapRef.current = [ax, az]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +175,10 @@ const ShelfTool = () => {
|
||||
useScene.getState().createNode(shelf, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
|
||||
triggerSFX('sfx:structure-build')
|
||||
// The placed shelf is now a valid alignment target for the next one;
|
||||
// refresh the candidate pool and drop the guide from this drop.
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
const native = (event as { nativeEvent?: unknown }).nativeEvent
|
||||
if (
|
||||
@@ -162,8 +207,11 @@ const ShelfTool = () => {
|
||||
const key = `${kind}:click` as ClickKey
|
||||
emitter.off(key, commitAtCursor as never)
|
||||
}
|
||||
// Drop any alignment guide left over when the tool deactivates (kind
|
||||
// switch, Esc, unmount) so it doesn't linger over the canvas.
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}, [activeLevelId])
|
||||
}, [activeLevelId, previewNode])
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
|
||||
@@ -1,131 +1,14 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
type SlabNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
import type { FloorplanMoveTarget, SlabNode } from '@pascal-app/core'
|
||||
import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for slab — mirrors the 3D `MoveSlabTool`
|
||||
* live-drag pattern so the visual stays smooth in split view.
|
||||
* 2D floor-plan move handler for slab. Delegates to the shared polygon
|
||||
* centroid-pivot mover: the slab's centroid snaps to the (grid-snapped,
|
||||
* Figma-aligned) cursor — the same pivot semantics as a regular item's
|
||||
* origin — instead of the old grab-relative delta. See
|
||||
* `shared/polygon-centroid-move.ts` for the live-drag / commit rationale.
|
||||
*
|
||||
* **Why not write the polygon every tick?** Per-tick `scene.update` on
|
||||
* `polygon` triggers a CSG geometry rebuild in `GeometrySystem` every
|
||||
* frame. Even with a synchronous `markDirty`, the rebuild dispose/add
|
||||
* pair flickers in the 3D viewer and the slab visibly catches up to
|
||||
* the cursor one frame late — the same regression `commit f4ea07e` was
|
||||
* fixed for in the 3D mover. The fix there: don't touch `scene` during
|
||||
* the drag at all. Translate the rendered `<group>` via the live-drag
|
||||
* exception (`mesh.position` + `useLiveTransforms.position = delta`).
|
||||
* On commit, write the polygon once.
|
||||
*
|
||||
* **Delta semantics** (see `wiki/architecture/tools.md` — "useLiveTransforms
|
||||
* contract is per-kind, not generic"): polygon-based kinds carry their
|
||||
* "position" in the polygon vertices, not a node.position field. The
|
||||
* `useLiveTransforms.position` must be a translation **delta**
|
||||
* (`[Δx, 0, Δz]`), which `ParametricNodeRenderer` consumes as the group
|
||||
* position. Visual = group.position + group.children-in-original-coords
|
||||
* = (delta) + (original polygon vertices) = translated, with no
|
||||
* geometry rebuild.
|
||||
*
|
||||
* **Commit path**: `canCommit` is the only side-effectful write to
|
||||
* `scene`. The dispatcher captured snapshots before the first apply,
|
||||
* so its snapshot-diff after `canCommit` returns will see one update
|
||||
* (the translated polygon) and run the single-undo dance against it.
|
||||
* `MoveSlabTool`'s cleanup (fires when `setMovingNode(null)` runs after
|
||||
* the commit) handles the `useLiveTransforms.clear` + the React-render
|
||||
* that resets `group.position` to (0,0,0) — by then `GeometrySystem`
|
||||
* has rebuilt with the new polygon, so the visual lands at the same
|
||||
* world position with no teleport.
|
||||
* `meshY = 0`: `GeometrySystem` parks the slab group at y=0 on rebuild.
|
||||
*/
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
function translatePolygon(
|
||||
polygon: ReadonlyArray<readonly [number, number]>,
|
||||
dx: number,
|
||||
dz: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
|
||||
}
|
||||
|
||||
export const slabFloorplanMoveTarget: FloorplanMoveTarget<SlabNode> = ({ node }) => {
|
||||
const slabId = node.id as AnyNodeId
|
||||
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
const originalHoles = (node.holes ?? []).map((hole) =>
|
||||
hole.map(([x, z]) => [x, z] as [number, number]),
|
||||
)
|
||||
let anchor: [number, number] | null = null
|
||||
let lastDelta: [number, number] = [0, 0]
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [slabId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
|
||||
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
|
||||
if (!anchor) {
|
||||
anchor = [snapped[0], snapped[1]]
|
||||
return
|
||||
}
|
||||
const dx = snapped[0] - anchor[0]
|
||||
const dz = snapped[1] - anchor[1]
|
||||
lastDelta = [dx, dz]
|
||||
// Live-drag exception (wiki/architecture/tools.md): write the
|
||||
// delta to BOTH `mesh.position` (direct Three.js mutation) and
|
||||
// `useLiveTransforms.position` (React-bound source of truth).
|
||||
// They MUST match — `ParametricNodeRenderer` re-renders on every
|
||||
// useLiveTransforms change and reconciles `<group position={...}>`,
|
||||
// so a divergence makes the two writes fight every frame.
|
||||
useLiveTransforms.getState().set(slabId, {
|
||||
position: [dx, 0, dz],
|
||||
rotation: 0,
|
||||
})
|
||||
const mesh = sceneRegistry.nodes.get(slabId) as THREE.Object3D | undefined
|
||||
if (mesh) mesh.position.set(dx, 0, dz)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[slabId] as SlabNode | undefined
|
||||
if (!live || live.type !== 'slab') return false
|
||||
const [dx, dz] = lastDelta
|
||||
if (dx === 0 && dz === 0) return false
|
||||
// Side-effect commit sequence — mirrors `MoveSlabTool.onGridClick`
|
||||
// so the React render that clears `group.position` (via the
|
||||
// useLiveTransforms.clear below) and the `GeometrySystem` rebuild
|
||||
// (via the sync `markDirty`) land in the same paint cycle. Order
|
||||
// matters:
|
||||
// 1. Write the translated polygon to `scene`. The dispatcher's
|
||||
// snapshot-diff right after `canCommit` returns will pick
|
||||
// this up as the single tracked change for undo.
|
||||
// 2. `markDirty` directly — bypasses the rAF-deferred batch in
|
||||
// `updateNodesAction`, so `GeometrySystem` sees the dirty
|
||||
// flag synchronously and can rebuild this frame (without
|
||||
// this the rebuild slides into the next frame and the slab
|
||||
// visually pops to its original position for one paint).
|
||||
// 3. Clear `useLiveTransforms` — `ParametricNodeRenderer` then
|
||||
// re-renders `<group position={[0,0,0]}>` instead of the
|
||||
// live delta. Without the rebuild from step 2 also landing
|
||||
// this frame, the group would render at (0,0,0) over the
|
||||
// *unrebuilt* (still-original) geometry → original-position
|
||||
// blink. With step 2 in place, the rebuild and the React
|
||||
// render commit together → smooth.
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: slabId,
|
||||
data: {
|
||||
polygon: translatePolygon(originalPolygon, dx, dz),
|
||||
holes: originalHoles.map((h) => translatePolygon(h, dx, dz)),
|
||||
},
|
||||
},
|
||||
])
|
||||
useScene.getState().markDirty(slabId)
|
||||
useLiveTransforms.getState().clear(slabId)
|
||||
return true
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
export const slabFloorplanMoveTarget: FloorplanMoveTarget<SlabNode> = ({ node, nodes }) =>
|
||||
createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
type SlabNode,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -40,6 +44,9 @@ import type * as THREE from 'three'
|
||||
* nothing for zundo to record. The single `scene.update` on commit
|
||||
* becomes the single undo step naturally.
|
||||
*/
|
||||
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
@@ -125,6 +132,10 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is FenceNode => child?.type === 'fence')
|
||||
|
||||
// Alignment candidates — every other alignable object's anchors,
|
||||
// gathered once (the scene graph is stable during the drag).
|
||||
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, slabId)
|
||||
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (deltaX: number, deltaZ: number) => {
|
||||
@@ -170,7 +181,29 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
applyPreview(localX - anchor[0], localZ - anchor[1])
|
||||
let deltaX = localX - anchor[0]
|
||||
let deltaZ = localZ - anchor[1]
|
||||
|
||||
// Figma-style alignment snap: align the slab's translated polygon
|
||||
// vertices to other objects' anchors; fold the snap into the delta and
|
||||
// publish a guide. Alt bypasses.
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
deltaX += result.snap.dx
|
||||
deltaZ += result.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(result.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
applyPreview(deltaX, deltaZ)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
@@ -197,6 +230,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
// GeometrySystem rebuild zeros it on the next frame, by which
|
||||
// point the new geometry is in place — visual stays smooth.
|
||||
useLiveTransforms.getState().clear(slabId)
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [slabId] })
|
||||
@@ -208,6 +242,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
// No scene state to roll back — we never wrote anything. Just
|
||||
// restore the mesh visual.
|
||||
clearPreview()
|
||||
useAlignmentGuides.getState().clear()
|
||||
useViewer.getState().setSelection({ selectedIds: [slabId] })
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
@@ -218,6 +253,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (!wasCommitted) {
|
||||
clearPreview()
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
EDITOR_LAYER,
|
||||
@@ -26,6 +34,8 @@ import { SlabNode } from './schema'
|
||||
*/
|
||||
|
||||
const Y_OFFSET = 0.02
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function calculateSnapPoint(
|
||||
lastPoint: [number, number],
|
||||
@@ -80,21 +90,66 @@ export const SlabTool: React.FC = () => {
|
||||
// isn't built with a stale preset's parameters. Unmount-only.
|
||||
useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), [])
|
||||
|
||||
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
|
||||
// on every cursor move (cursorPosition is in its deps), so clearing guides
|
||||
// in its cleanup would wipe the guide the instant after each move sets it.
|
||||
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object. The
|
||||
// slab's own in-progress vertices are intentionally excluded (no
|
||||
// self-alignment while drawing).
|
||||
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
// Snap the drafted vertex onto another object's nearest real anchor and
|
||||
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
|
||||
// point: resolving against the grid point would only ever catch anchors
|
||||
// that happen to sit on a grid line, so off-grid items (furniture, angled
|
||||
// walls) would never surface a guide. The matched axis locks exactly to the
|
||||
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
|
||||
// bypasses.
|
||||
const alignPoint = (
|
||||
fallback: [number, number],
|
||||
raw: [number, number],
|
||||
bypass: boolean,
|
||||
): [number, number] => {
|
||||
if (bypass || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return fallback
|
||||
}
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId: '__slab-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (ar.guides.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return fallback
|
||||
}
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
let [x, z] = fallback
|
||||
for (const guide of ar.guides) {
|
||||
if (guide.axis === 'x') x = guide.coord
|
||||
else z = guide.coord
|
||||
}
|
||||
return [x, z]
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
const gridX = Math.round(rawPoint[0] * 2) / 2
|
||||
const gridZ = Math.round(rawPoint[1] * 2) / 2
|
||||
const gridPosition: [number, number] = [gridX, gridZ]
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
const lastPoint = points[points.length - 1]
|
||||
const displayPoint =
|
||||
const orthoPoint =
|
||||
shiftPressed.current || !lastPoint
|
||||
? gridPosition
|
||||
: calculateSnapPoint(lastPoint, gridPosition)
|
||||
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
@@ -121,6 +176,7 @@ export const SlabTool: React.FC = () => {
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
setPoints([...points, clickPoint])
|
||||
}
|
||||
@@ -132,12 +188,14 @@ export const SlabTool: React.FC = () => {
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (points.length > 0) markToolCancelConsumed()
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -1,83 +1,56 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
type StairNode,
|
||||
snapScalar,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getSegmentGridStep } from '@pascal-app/editor'
|
||||
import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for stair — kicks in when the user clicks
|
||||
* "Move" on the stair action menu and the floor-plan view is active.
|
||||
* 2D floor-plan move handler for stair.
|
||||
*
|
||||
* **Delta-based motion, anchored on first pointermove.** The first
|
||||
* `apply` only captures `rawAnchor` — the cursor's pointer position the
|
||||
* instant the move begins — and skips writing to the scene. Subsequent
|
||||
* applies translate the stair's original position by the cursor's raw
|
||||
* delta and then snap the *absolute* result to the 0.5 m grid.
|
||||
* **Pivot semantics.** The stair's ORIGIN (its `position`) follows the
|
||||
* snapped cursor — the same pivot the 3D move tool (`shared/move-roof-tool`)
|
||||
* uses: it positions the stair by its origin at the grid-snapped, aligned
|
||||
* cursor, NOT by the grab offset under the mouse. This replaces the old
|
||||
* grab-relative delta so dragging in 2D tracks the same point as 3D.
|
||||
*
|
||||
* Anchoring matters because the action-menu Move button portals to
|
||||
* `document.body`, so the move starts with the cursor wherever the menu
|
||||
* sits (often nowhere near the stair). The previous "position = snapped
|
||||
* cursor" implementation made the stair teleport to the menu's screen
|
||||
* position on the very first pointermove, which is the "drag doesn't
|
||||
* happen properly" symptom. Mirrors the same anchor pattern wall's
|
||||
* `floorplan-move.ts` uses.
|
||||
* Figma alignment is layered on the origin point (single anchor), matching
|
||||
* `move-roof-tool`'s "align by origin" behaviour; Alt bypasses. Guides are
|
||||
* cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
|
||||
*
|
||||
* Snapping the absolute position (rather than the delta) keeps the
|
||||
* stair on the same 0.5 m grid the 3D StairTool placement and 3D
|
||||
* MoveRegistryNodeTool use — so dragging in 2D lands at the same
|
||||
* grid intersections you'd hit dragging in 3D.
|
||||
*
|
||||
* Routing through `floorplanMoveTarget` (instead of the overlay's
|
||||
* generic Path 2 translate) fixes two latent bugs the generic path
|
||||
* has for stair:
|
||||
*
|
||||
* 1. Path 2's `onPointerUp` bails when `event.target.closest(
|
||||
* '[data-floorplan-scene]')` fails — which happens when the
|
||||
* pointer-up lands on empty grid background. Path 1 uses the
|
||||
* overlay's bounding-rect check, which accepts any pointer
|
||||
* inside the SVG viewport.
|
||||
* 2. Path 2 commits via a single `updateNode` call that has no
|
||||
* "self-owned commit" hook, so the overlay's diff path can
|
||||
* silently revert when the final state matches the snapshot.
|
||||
* The `commit()` below mirrors door's pattern: take ownership
|
||||
* of the atomic write so the deterministic
|
||||
* revert → resume → `session.commit()` path runs.
|
||||
* The position is written straight to scene each tick (the stair has a real
|
||||
* `position` field, unlike polygon kinds) and re-applied atomically via
|
||||
* `commit()` so the overlay's deterministic revert → resume → commit path
|
||||
* records a single undo step (same pattern door / window use).
|
||||
*/
|
||||
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node }) => {
|
||||
// Capture the stair's original position once — apply() reads these
|
||||
// every tick instead of re-querying scene state (which would
|
||||
// double-apply our own writes).
|
||||
const originalX = node.position[0]
|
||||
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node, nodes }) => {
|
||||
const startY = node.position[1]
|
||||
const originalZ = node.position[2]
|
||||
|
||||
let rawAnchor: [number, number] | null = null
|
||||
// Alignment candidates gathered once — the scene is stable during the drag.
|
||||
const candidates = collectAlignmentAnchors(nodes, node.id)
|
||||
let lastValid: { position: [number, number, number] } | null = null
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
if (!rawAnchor) {
|
||||
rawAnchor = [planPoint[0], planPoint[1]]
|
||||
return
|
||||
}
|
||||
const rawDx = planPoint[0] - rawAnchor[0]
|
||||
const rawDz = planPoint[1] - rawAnchor[1]
|
||||
// Snap the absolute new position to the editor's current grid
|
||||
// step (the same one the cursor / draft snap to — driven by
|
||||
// `useEditor.gridSnapStep`). Hardcoding 0.5 here caused the stair
|
||||
// to snap to half-metre cells even when the user had set the grid
|
||||
// to a finer step like 0.1, so the cursor and the stair SVG
|
||||
// landed at different grid points. Shift bypasses snap entirely.
|
||||
// Snap the origin to the editor's current grid step (driven by
|
||||
// `useEditor.gridSnapStep`). Shift bypasses the grid snap.
|
||||
const step = getSegmentGridStep()
|
||||
const rawX = originalX + rawDx
|
||||
const rawZ = originalZ + rawDz
|
||||
const sx = modifiers.shiftKey ? rawX : snapScalar(rawX, step)
|
||||
const sz = modifiers.shiftKey ? rawZ : snapScalar(rawZ, step)
|
||||
const gx = modifiers.shiftKey ? planPoint[0] : snapScalar(planPoint[0], step)
|
||||
const gz = modifiers.shiftKey ? planPoint[1] : snapScalar(planPoint[1], step)
|
||||
// Figma alignment on the origin point (Alt bypasses), matching the 3D
|
||||
// move tool. Publishes guides via `useAlignmentGuides`.
|
||||
const { point: aligned } = applyFloorplanAlignment(
|
||||
[gx, gz],
|
||||
[{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey },
|
||||
)
|
||||
const sx = aligned[0]
|
||||
const sz = aligned[1]
|
||||
|
||||
if (lastValid && lastValid.position[0] === sx && lastValid.position[2] === sz) return
|
||||
lastValid = { position: [sx, startY, sz] }
|
||||
@@ -91,12 +64,8 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node
|
||||
},
|
||||
commit() {
|
||||
// Own the atomic write so the overlay takes the deterministic
|
||||
// commit-path (revert → resume → session.commit()). The dispatcher's
|
||||
// diff path would otherwise re-derive the final state by comparing
|
||||
// the post-apply scene to the snapshot — which produces an empty
|
||||
// diff (and silent revert) when the committed move happens to have
|
||||
// identical key/value pairs to the snapshot. Owning commit removes
|
||||
// that foot-gun. Same pattern door / window use.
|
||||
// commit-path (revert → resume → session.commit()). Same pattern
|
||||
// door / window use.
|
||||
if (!lastValid) return
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
|
||||
},
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
useAlignmentGuides,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
alignFloorplanDraftPoint,
|
||||
getSegmentGridStep,
|
||||
isSegmentLongEnough,
|
||||
snapScalarToGrid,
|
||||
@@ -190,9 +192,16 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
||||
ignoreWallIds: [node.id],
|
||||
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
// Figma-style alignment on the dragged corner — snaps it onto another
|
||||
// object's edge / wall face and publishes a guide. The dragged wall
|
||||
// and its linked siblings (which cascade with the corner) are excluded
|
||||
// from the candidate pool. Alt is reserved for detach, NOT bypass.
|
||||
const aligned = alignFloorplanDraftPoint(snapped, {
|
||||
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
|
||||
}) as WallPlanPoint
|
||||
|
||||
const primaryStart: WallPlanPoint = endpoint === 'start' ? snapped : fixedPoint
|
||||
const primaryEnd: WallPlanPoint = endpoint === 'end' ? snapped : fixedPoint
|
||||
const primaryStart: WallPlanPoint = endpoint === 'start' ? aligned : fixedPoint
|
||||
const primaryEnd: WallPlanPoint = endpoint === 'end' ? aligned : fixedPoint
|
||||
|
||||
// ALT detaches: the linked walls keep their original endpoints,
|
||||
// and only the dragged wall moves.
|
||||
@@ -229,6 +238,9 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
||||
}
|
||||
},
|
||||
canCommit() {
|
||||
// Pointer-up always runs canCommit — drop the alignment guide here
|
||||
// so it doesn't linger after a commit / reject.
|
||||
useAlignmentGuides.getState().clear()
|
||||
// The dragged wall must still be long enough at the preview
|
||||
// length — checked against `lastPrimary*`, not scene, because
|
||||
// scene holds baseline values until commit().
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
pauseSceneHistory,
|
||||
resolveAlignment,
|
||||
resumeSceneHistory,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -43,6 +46,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
* `wall/definition.ts`. Editor state trigger is
|
||||
* `useEditor.movingWallEndpoint`.
|
||||
*/
|
||||
/** Figma-style alignment-snap threshold (meters), matching the item move /
|
||||
* placement tools. 8 cm gives a magnetic pull without fighting grid snap. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
@@ -207,6 +214,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
|
||||
)
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (walls,
|
||||
// fences, items, slabs, ceilings, columns), gathered once (the set is
|
||||
// stable during the drag). Coords are building-local, the same frame as
|
||||
// the cursor and the 3D guide layer, so the published guide lines up.
|
||||
const wallAlignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, nodeId)
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
@@ -285,20 +298,40 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined,
|
||||
})
|
||||
|
||||
// Figma-style alignment: nudge the dragged endpoint onto another wall /
|
||||
// fence endpoint or midpoint axis when within threshold, and publish a
|
||||
// guide. The resolver connects to the NEAREST real anchor of the
|
||||
// candidate, so the dot always sits on an actual point (endpoint /
|
||||
// midpoint), never an empty-space bbox corner. Layered on top of the
|
||||
// grid + corner snap above; Alt is reserved for corner-detach here.
|
||||
let alignedPoint = snappedPoint
|
||||
if (wallAlignmentCandidates.length > 0) {
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
|
||||
candidates: wallAlignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (ar.snap) {
|
||||
alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz]
|
||||
}
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
}
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(snappedPoint[0] !== previousGridPosRef.current[0] ||
|
||||
snappedPoint[1] !== previousGridPosRef.current[1])
|
||||
(alignedPoint[0] !== previousGridPosRef.current[0] ||
|
||||
alignedPoint[1] !== previousGridPosRef.current[1])
|
||||
) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = snappedPoint
|
||||
previousGridPosRef.current = alignedPoint
|
||||
hasDraggedRef.current = true
|
||||
|
||||
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
||||
applyPreview(alignedPoint, event.nativeEvent.altKey)
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
// Press-release without drag: dismiss the tool without committing.
|
||||
if (!hasDraggedRef.current) {
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -345,6 +378,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
@@ -390,6 +424,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
|
||||
return () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal(false)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
calculateLevelMiters,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getWallMiterBoundaryPoints,
|
||||
type LevelNode,
|
||||
type Point2D,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
@@ -45,6 +48,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
|
||||
*/
|
||||
const WALL_HEIGHT = 2.5
|
||||
const DRAFT_WALL_THICKNESS = 0.1
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
// HUD label heights are measured from the top of the preview bar, so they
|
||||
// track whatever height a seeded preset draws at (`previewHeight`).
|
||||
const DRAFT_LABEL_Y_OFFSET = 0.22
|
||||
@@ -429,12 +434,36 @@ export const WallTool: React.FC = () => {
|
||||
let gridPosition: WallPlanPoint = [0, 0]
|
||||
let previousWallEnd: [number, number] | null = null
|
||||
|
||||
// Alignment candidates — anchors of every alignable object. Refreshed
|
||||
// after each segment commits (the new wall becomes a candidate too).
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
const refreshAlignmentCandidates = () => {
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
}
|
||||
|
||||
// Align the drafted point onto another object's nearest real anchor and
|
||||
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
|
||||
const alignPoint = (point: WallPlanPoint, bypass: boolean): WallPlanPoint => {
|
||||
if (bypass || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return point
|
||||
}
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId: '__wall-draft__', kind: 'corner', x: point[0], z: point[1] }],
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
|
||||
}
|
||||
|
||||
const stopDrafting = () => {
|
||||
buildingState.current = 0
|
||||
if (wallPreviewRef.current) {
|
||||
wallPreviewRef.current.visible = false
|
||||
}
|
||||
setDraftMeasurement(null)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
@@ -448,14 +477,11 @@ export const WallTool: React.FC = () => {
|
||||
// walls fall out of grid snap naturally when the start sits on
|
||||
// a grid intersection.
|
||||
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
gridPosition = snapWallDraftPoint({ point: localPoint, walls, step })
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
gridPosition = alignPoint(snapWallDraftPoint({ point: localPoint, walls, step }), bypassAlign)
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = snapWallDraftPoint({
|
||||
point: localPoint,
|
||||
walls,
|
||||
step,
|
||||
})
|
||||
const snappedLocal = gridPosition
|
||||
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
||||
cursorRef.current.position.copy(endingPoint.current)
|
||||
|
||||
@@ -501,9 +527,13 @@ export const WallTool: React.FC = () => {
|
||||
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
|
||||
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = snapWallDraftPoint({ point: localClick, walls, step: clickStep })
|
||||
const snappedStart = alignPoint(
|
||||
snapWallDraftPoint({ point: localClick, walls, step: clickStep }),
|
||||
bypassAlign,
|
||||
)
|
||||
gridPosition = snappedStart
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
@@ -517,11 +547,10 @@ export const WallTool: React.FC = () => {
|
||||
// `onGridMove` writes a real BoxGeometry skips that frame.
|
||||
setDraftMeasurement(null)
|
||||
} else if (buildingState.current === 1) {
|
||||
const snappedEnd = snapWallDraftPoint({
|
||||
point: localClick,
|
||||
walls,
|
||||
step: clickStep,
|
||||
})
|
||||
const snappedEnd = alignPoint(
|
||||
snapWallDraftPoint({ point: localClick, walls, step: clickStep }),
|
||||
bypassAlign,
|
||||
)
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
const dz = snappedEnd[1] - startingPoint.current.z
|
||||
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||
@@ -532,6 +561,11 @@ export const WallTool: React.FC = () => {
|
||||
)
|
||||
if (!createdWall) return
|
||||
|
||||
// The new segment is now a real node — make it an alignment target
|
||||
// for the next segment, and drop the just-shown guide.
|
||||
refreshAlignmentCandidates()
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
const nextStart = createdWall.end
|
||||
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
@@ -574,6 +608,7 @@ export const WallTool: React.FC = () => {
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
import { findClosestWallInPlan } from '../shared/wall-attach-target'
|
||||
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
|
||||
import { clampToWall, hasWallChildOverlap } from './window-math'
|
||||
|
||||
/**
|
||||
@@ -49,7 +49,19 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
|
||||
if (!hit) return
|
||||
|
||||
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
|
||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||
// openings / wall ends), winning over the 0.5m grid snap; falls back
|
||||
// to grid when nothing aligns. Alt bypasses; Shift drops the grid snap.
|
||||
const neighborX = modifiers.altKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width: node.width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
hit.wall,
|
||||
snappedLocalX,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
@@ -23,6 +25,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
@@ -113,8 +116,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||
// moving window is excluded so it never aligns to itself). Along-wall only;
|
||||
// the floor-plane guides don't cover sill height.
|
||||
const alignmentCandidates = collectAlignmentAnchors(
|
||||
useScene.getState().nodes,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
@@ -141,7 +153,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
@@ -205,7 +223,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
@@ -274,7 +298,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
@@ -427,6 +457,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
WindowNode,
|
||||
@@ -21,6 +23,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
// Shared edge material — reuse across renders, just toggle color
|
||||
@@ -70,8 +73,14 @@ const WindowTool: React.FC = () => {
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each placement. A window aligns by the plan position of its centre
|
||||
// (along-wall only; the floor-plane guides don't cover sill height).
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
@@ -103,11 +112,16 @@ const WindowTool: React.FC = () => {
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
|
||||
const width = 1.5
|
||||
const height = 1.5
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
@@ -153,14 +167,39 @@ const WindowTool: React.FC = () => {
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
|
||||
const width = draftRef.current?.width ?? 1.5
|
||||
const height = draftRef.current?.height ?? 1.5
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
// Draft may be null after a successful placement (the click handler
|
||||
// deletes it and relies on the wall rebuild → pointer-enter cascade to
|
||||
// recreate it). Recreate it here on the first subsequent move so the
|
||||
// preview is ready for the next click without requiring a leave/enter.
|
||||
if (!draftRef.current) {
|
||||
const levelId = getLevelId()
|
||||
if (levelId && event.node.parentId === levelId) {
|
||||
const node = WindowNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
}
|
||||
|
||||
if (draftRef.current) {
|
||||
// Update the scene store on every move so the 2D floor plan
|
||||
// stays in sync (it re-renders from `node.position`). Only
|
||||
@@ -221,7 +260,13 @@ const WindowTool: React.FC = () => {
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: draftRef.current.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
@@ -287,6 +332,8 @@ const WindowTool: React.FC = () => {
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:item-place')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -310,6 +357,7 @@ const WindowTool: React.FC = () => {
|
||||
return () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
useAlignmentGuides.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
zoneMoveEdgeAffordance,
|
||||
zoneMoveVertexAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { zoneFloorplanMoveTarget } from './floorplan-move'
|
||||
import { zoneParametrics } from './parametrics'
|
||||
import { ZoneNode } from './schema'
|
||||
|
||||
@@ -46,6 +47,11 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
|
||||
priority: 4,
|
||||
},
|
||||
floorplan: buildZoneFloorplan,
|
||||
// 2D body move — centroid-pivot polygon mover (same as slab / ceiling).
|
||||
// Without this, zone fell through to the overlay's generic free-translate
|
||||
// path, which committed a `position` field zone has no schema for, so the
|
||||
// polygon never actually moved on drop.
|
||||
floorplanMoveTarget: zoneFloorplanMoveTarget,
|
||||
// Polygon editor when selected — same three operations slabs / ceilings
|
||||
// expose. The shared factories key off `node.polygon`, optional
|
||||
// `node.holes` (absent on zones). See `floorplan-affordances.ts`.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { FloorplanMoveTarget, ZoneNode } from '@pascal-app/core'
|
||||
import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for zone. Delegates to the shared polygon
|
||||
* centroid-pivot mover — the zone's centroid snaps to the (grid-snapped,
|
||||
* Figma-aligned) cursor. Zone has no `holes`, which the helper handles.
|
||||
*
|
||||
* Previously zone had no move target and fell through to the overlay's
|
||||
* generic free-translate path, which committed a `position` field zone
|
||||
* doesn't have (the polygon never moved on commit). Routing through this
|
||||
* polygon mover translates the actual vertices. `meshY = 0`.
|
||||
*/
|
||||
export const zoneFloorplanMoveTarget: FloorplanMoveTarget<ZoneNode> = ({ node, nodes }) =>
|
||||
createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
|
||||
Reference in New Issue
Block a user