* feat(editor): preset-system polish — paint panel, slim action bar, icon rail - Export `MaterialPaintPanel` so embedders host the paint material picker in their own panel (community docks it in the Build sidebar) instead of the bottom action bar. - ActionMenu: drop the build / material-paint / furnish modes and the structure-tools palette row + paint tray (the host's Build sidebar owns building now). Reduce `structure-tools` to the shared `tools` lookup still used by cursor/floorplan indicators; remove the orphaned `useContextualTools`. - MaterialPicker: swatches wrap into a fluid `auto-fill` grid that fills width. - IconRail (tab-bar): bigger icons, grayscale-when-idle, Radix tooltip, w-14 rail; sync `RAIL_WIDTH` to 56. - Inspector footer: hand the host `footer` to kind-owned custom panels via `InspectorFooterContext` so the save button renders without per-kind wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: biome format pass Whole-repo formatter normalization (line wrap/unwrap only, no logic changes) surfaced by the format-on-edit hook against prior drift. Kept separate from the feature commit so the preset-system diff stays reviewable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
type FloorplanMoveTarget,
|
|
type FloorplanMoveTargetSession,
|
|
getPerpendicularWallMoveAxis,
|
|
getPlannedLinkedWallUpdates,
|
|
planWallMoveJunctions,
|
|
useLiveNodeOverrides,
|
|
useScene,
|
|
type WallNode,
|
|
type WallPlanPoint,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
getFloorplanWallThickness,
|
|
getSegmentGridStep,
|
|
isSegmentLongEnough,
|
|
snapScalarToGrid,
|
|
useWallMoveGhosts,
|
|
type WallMoveGhostBridge,
|
|
} from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import {
|
|
buildBridgeWallCreates,
|
|
buildBridgeWallPreviews,
|
|
getLinkedWallSnapshots,
|
|
getWallsAfterUpdates,
|
|
type LinkedWallSnapshot,
|
|
stripWallIsNewMetadata,
|
|
} from './move-shared'
|
|
|
|
/**
|
|
* 2D floor-plan move handler for wall.
|
|
*
|
|
* Mirrors the 3D `MoveWallTool` junction-plan behavior so dragging a
|
|
* wall in the floor plan produces the same scene topology as dragging
|
|
* it in 3D: linked corners cascade, same-direction collapsed walls
|
|
* delete, off-axis branches stay rectilinear with a new bridge wall
|
|
* inserted between the original and new corner.
|
|
*
|
|
* Tick (`apply`) — publishes per-frame `{ start, end }` overrides to
|
|
* `useLiveNodeOverrides` for the moved wall + every linked wall.
|
|
* `useScene` stays at the pre-drag values throughout the drag; the
|
|
* `WallSystem` and the 2D floor-plan layer fold the overrides in when
|
|
* reading endpoints, so the visual preview updates without churning
|
|
* zustand. Bridge creates and wall deletes are still deferred to
|
|
* commit so the live preview doesn't churn the scene graph either.
|
|
*
|
|
* Commit (`commit`) — recomputes the plan at the final cursor position
|
|
* and emits one atomic `applyNodeChanges` covering the moved walls,
|
|
* the bridge wall creates, and the collapsed wall deletes — so a
|
|
* single Ctrl-Z rolls the entire operation back. Clears the live
|
|
* overrides after the write lands so the system reads from the new
|
|
* committed scene state.
|
|
*
|
|
* Auto-slab live preview and ghost bridge SVG previews — visible in
|
|
* the 3D tool — are deliberately deferred. Slab polygons re-derive on
|
|
* commit through the normal scene reactions; bridges appear at commit
|
|
* time. Follow-up work to surface them mid-drag is tracked separately.
|
|
*/
|
|
export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node }) => {
|
|
const wallId = node.id as AnyNodeId
|
|
const originalStart: WallPlanPoint = [node.start[0], node.start[1]]
|
|
const originalEnd: WallPlanPoint = [node.end[0], node.end[1]]
|
|
const originalCenter: WallPlanPoint = [
|
|
(originalStart[0] + originalEnd[0]) / 2,
|
|
(originalStart[1] + originalEnd[1]) / 2,
|
|
]
|
|
// Perpendicular (wall normal) axis — same lock the 3D `MoveWallTool`
|
|
// uses, so a 2D drag slides the wall sideways across its own
|
|
// direction rather than free-floating in XZ. `null` only for
|
|
// degenerate zero-length walls; we fall through to free motion in
|
|
// that case (the wall is going to be deleted anyway).
|
|
const moveAxis = getPerpendicularWallMoveAxis(originalStart, originalEnd)
|
|
const isNew = !!(node.metadata as { isNew?: unknown } | null)?.isNew
|
|
|
|
const linkedOriginals: LinkedWallSnapshot[] = isNew
|
|
? []
|
|
: getLinkedWallSnapshots({
|
|
wallId: node.id,
|
|
wallParentId: node.parentId ?? null,
|
|
originalStart,
|
|
originalEnd,
|
|
})
|
|
|
|
// Anchor lazily at the first cursor sample (the click point) and
|
|
// measure cursor movement from there. The wall doesn't shift on
|
|
// click — `rawDelta` is zero on the first apply, so the snapped
|
|
// centre projection equals `originalProj` (already grid-aligned for
|
|
// walls placed via the draft tool). As the cursor moves, the wall
|
|
// translates along its normal by the cursor's perpendicular delta,
|
|
// snapped to absolute grid lines.
|
|
//
|
|
// Using `originalCenter` as the anchor instead would pin the wall's
|
|
// centre to the cursor — but the floor-plan drag icon lives inside
|
|
// a floating menu whose hit-area is offset from the exact wall
|
|
// centre, so the first `planPoint` would never equal `originalCenter`
|
|
// and the wall would snap to a different grid cell the instant the
|
|
// drag began, even before the cursor moved.
|
|
let rawAnchor: WallPlanPoint | null = null
|
|
let lastDelta: WallPlanPoint = [0, 0]
|
|
let lastNextStart: WallPlanPoint = originalStart
|
|
let lastNextEnd: WallPlanPoint = originalEnd
|
|
|
|
const session: FloorplanMoveTargetSession = {
|
|
affectedIds: [wallId, ...linkedOriginals.map((w) => w.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]
|
|
const step = getSegmentGridStep()
|
|
|
|
// Axis-lock the move to the wall's perpendicular (normal). Project
|
|
// the original centre + raw cursor delta onto the axis, snap the
|
|
// absolute projection to a grid multiple, then translate the wall
|
|
// by `axis * perpDelta`. Matches `MoveWallTool` so 2D and 3D drag
|
|
// produce identical wall topology. Shift bypasses snap.
|
|
let dx: number
|
|
let dz: number
|
|
if (moveAxis) {
|
|
const originalProj = originalCenter[0] * moveAxis[0] + originalCenter[1] * moveAxis[1]
|
|
const rawProj = originalProj + rawDx * moveAxis[0] + rawDz * moveAxis[1]
|
|
const snappedProj = modifiers.shiftKey ? rawProj : snapScalarToGrid(rawProj, step)
|
|
const perpDelta = snappedProj - originalProj
|
|
dx = moveAxis[0] * perpDelta
|
|
dz = moveAxis[1] * perpDelta
|
|
} else {
|
|
dx = modifiers.shiftKey ? rawDx : snapScalarToGrid(rawDx, step)
|
|
dz = modifiers.shiftKey ? rawDz : snapScalarToGrid(rawDz, step)
|
|
}
|
|
|
|
if (dx === lastDelta[0] && dz === lastDelta[1]) return
|
|
lastDelta = [dx, dz]
|
|
|
|
const nextStart: WallPlanPoint = [originalStart[0] + dx, originalStart[1] + dz]
|
|
const nextEnd: WallPlanPoint = [originalEnd[0] + dx, originalEnd[1] + dz]
|
|
lastNextStart = nextStart
|
|
lastNextEnd = nextEnd
|
|
|
|
const plan = planWallMoveJunctions(
|
|
linkedOriginals,
|
|
originalStart,
|
|
originalEnd,
|
|
nextStart,
|
|
nextEnd,
|
|
)
|
|
const plannedUpdates = getPlannedLinkedWallUpdates(
|
|
plan,
|
|
originalStart,
|
|
originalEnd,
|
|
nextStart,
|
|
nextEnd,
|
|
)
|
|
const plannedById = new Map(plannedUpdates.map((entry) => [entry.id, entry]))
|
|
|
|
// Walls in the plan that would collapse to ≈zero-length get held
|
|
// at their pre-drag positions during the live preview — actually
|
|
// deleting them is deferred to commit so a single drag tick
|
|
// doesn't churn the scene graph with create / delete pairs.
|
|
const collapsedIds = new Set([
|
|
...plannedUpdates
|
|
.filter((entry) => !isSegmentLongEnough(entry.start, entry.end))
|
|
.map((entry) => entry.id),
|
|
...plan.wallsToDelete.map((wall) => wall.id),
|
|
])
|
|
|
|
const linkedUpdates = linkedOriginals.map((wall) => {
|
|
if (collapsedIds.has(wall.id)) {
|
|
return { id: wall.id as AnyNodeId, start: wall.start, end: wall.end }
|
|
}
|
|
const planned = plannedById.get(wall.id)
|
|
if (planned) {
|
|
return { id: wall.id as AnyNodeId, start: planned.start, end: planned.end }
|
|
}
|
|
return { id: wall.id as AnyNodeId, start: wall.start, end: wall.end }
|
|
})
|
|
|
|
// Publish endpoint overrides instead of writing to `useScene`.
|
|
// `WallSystem` + the 2D layer + the sidebar panel all consult
|
|
// `useLiveNodeOverrides` first, so the visual + numeric preview
|
|
// updates each frame while zustand stays at the pre-drag values
|
|
// until the user commits. Batched into a single zustand
|
|
// notification — otherwise each per-wall `.set` would re-render
|
|
// every override subscriber once per linked wall per tick.
|
|
useLiveNodeOverrides
|
|
.getState()
|
|
.setMany([
|
|
[wallId, { start: nextStart, end: nextEnd }],
|
|
...linkedUpdates.map(
|
|
(upd) =>
|
|
[upd.id, { start: upd.start, end: upd.end }] as [string, Record<string, unknown>],
|
|
),
|
|
])
|
|
|
|
// Surface bridge-wall previews so the floor-plan SVG layer can
|
|
// render dashed outlines of what `commit()` will insert. Mirrors
|
|
// the 3D `MoveWallTool`'s `ghostWallPreviews` mesh layer. The
|
|
// `existingWalls` snapshot needs the post-drag layout so duplicate
|
|
// detection works; we synthesise it from scene state + the
|
|
// overrides we just published (zustand is still on baseline).
|
|
const previewSceneWalls = getWallsAfterUpdates(useScene.getState().nodes, [
|
|
{ id: wallId, data: { start: nextStart, end: nextEnd } },
|
|
...linkedUpdates.map((u) => ({ id: u.id, data: { start: u.start, end: u.end } })),
|
|
]).filter((wall) => !collapsedIds.has(wall.id))
|
|
const bridgePreviews = buildBridgeWallPreviews({
|
|
bridgePlans: plan.bridgePlans,
|
|
nextStart,
|
|
nextEnd,
|
|
existingWalls: previewSceneWalls,
|
|
})
|
|
const ghostBridges: WallMoveGhostBridge[] = bridgePreviews.map(({ ghost, wall }) => ({
|
|
id: ghost.id,
|
|
start: ghost.start,
|
|
end: ghost.end,
|
|
color: ghost.color,
|
|
thickness: getFloorplanWallThickness(wall),
|
|
}))
|
|
useWallMoveGhosts.getState().setBridges(ghostBridges)
|
|
|
|
// `WallSystem` only runs its rebuild pass when `dirtyNodes` is
|
|
// non-empty. We're not writing to scene any more, but we still
|
|
// need the system to pick up the live override changes — so
|
|
// mark dirty here. The rebuild reads the effective endpoints
|
|
// (override-merged) so the mesh follows the cursor.
|
|
const sceneState = useScene.getState()
|
|
sceneState.markDirty(wallId)
|
|
for (const upd of linkedUpdates) {
|
|
sceneState.markDirty(upd.id)
|
|
}
|
|
},
|
|
|
|
canCommit() {
|
|
const live = useScene.getState().nodes[wallId] as WallNode | undefined
|
|
if (!live || live.type !== 'wall') return false
|
|
const [dx, dz] = lastDelta
|
|
return dx !== 0 || dz !== 0
|
|
},
|
|
|
|
commit() {
|
|
const sceneState = useScene.getState()
|
|
const liveWall = sceneState.nodes[wallId] as WallNode | undefined
|
|
if (!liveWall || liveWall.type !== 'wall') {
|
|
// Bail without leaving stale overrides behind.
|
|
const overrides = useLiveNodeOverrides.getState()
|
|
overrides.clear(wallId)
|
|
for (const wall of linkedOriginals) overrides.clear(wall.id as AnyNodeId)
|
|
return
|
|
}
|
|
|
|
const plan = planWallMoveJunctions(
|
|
linkedOriginals,
|
|
originalStart,
|
|
originalEnd,
|
|
lastNextStart,
|
|
lastNextEnd,
|
|
)
|
|
const linkedWallUpdates = getPlannedLinkedWallUpdates(
|
|
plan,
|
|
originalStart,
|
|
originalEnd,
|
|
lastNextStart,
|
|
lastNextEnd,
|
|
)
|
|
const collapsedLinkedWallIds = new Set([
|
|
...linkedWallUpdates
|
|
.filter((entry) => !isSegmentLongEnough(entry.start, entry.end))
|
|
.map((entry) => entry.id as AnyNodeId),
|
|
...plan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
|
])
|
|
|
|
const movingWallUpdate = {
|
|
id: wallId,
|
|
data: isNew
|
|
? {
|
|
start: lastNextStart,
|
|
end: lastNextEnd,
|
|
metadata: stripWallIsNewMetadata(node.metadata),
|
|
}
|
|
: { start: lastNextStart, end: lastNextEnd },
|
|
}
|
|
|
|
const commitUpdates: Array<{ id: AnyNodeId; data: Partial<WallNode> }> = [
|
|
movingWallUpdate,
|
|
...linkedWallUpdates
|
|
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
|
|
.map((entry) => ({
|
|
id: entry.id as AnyNodeId,
|
|
data: { start: entry.start, end: entry.end } as Partial<WallNode>,
|
|
})),
|
|
]
|
|
|
|
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
|
|
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
|
|
)
|
|
const bridgeCreates = buildBridgeWallCreates({
|
|
bridgePlans: plan.bridgePlans,
|
|
nextStart: lastNextStart,
|
|
nextEnd: lastNextEnd,
|
|
existingWalls,
|
|
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
|
|
})
|
|
|
|
sceneState.applyNodeChanges({
|
|
update: commitUpdates as Array<{ id: AnyNodeId; data: Partial<AnyNode> }>,
|
|
create: bridgeCreates,
|
|
delete: Array.from(collapsedLinkedWallIds),
|
|
})
|
|
|
|
// Drop the live overrides now that the committed scene state
|
|
// matches them — leaving them around would keep the system
|
|
// double-merging the same values and would shadow any genuine
|
|
// post-commit edits to start/end. Cleared synchronously so the
|
|
// next frame reads the new scene state directly.
|
|
const overrides = useLiveNodeOverrides.getState()
|
|
overrides.clear(wallId)
|
|
for (const wall of linkedOriginals) overrides.clear(wall.id as AnyNodeId)
|
|
|
|
// Swap ghosts → real walls: the bridges we just created render
|
|
// through the registry layer, so the dashed previews aren't
|
|
// needed any more. Clearing here (instead of waiting for the
|
|
// overlay's unmount cleanup) avoids a one-frame flash where
|
|
// both ghosts and real bridges paint at the new position.
|
|
useWallMoveGhosts.getState().clear()
|
|
|
|
useViewer.getState().setSelection({ selectedIds: [wallId] })
|
|
},
|
|
}
|
|
return session
|
|
}
|