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:
Sudhir Yadav
2026-06-04 14:03:00 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 Pascal
parent 86db5decb8
commit 46f94b97b3
84 changed files with 4399 additions and 1021 deletions
+18 -5
View File
@@ -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
}
+61 -12
View File
@@ -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
+126 -76
View File
@@ -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>
+158
View File
@@ -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