ai-chatbot/packages/nodes/src/window/floorplan-move.ts
2026-06-08 01:07:53 -04:00

138 lines
4.7 KiB
TypeScript

import {
type AnyNodeId,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './window-math'
/**
* 2D floor-plan move handler for window. Same shape as door (see
* `nodes/src/door/floorplan-move.ts`) — pointer in plan space → snap
* to nearest wall → project onto wall axis → snap local-X to 0.5m →
* clamp inside wall bounds → commit.
*
* Window-specific: local Y (vertical position on the wall) is preserved
* from the source node — we don't try to reposition the sill from a 2D
* pointer (there's no Y signal in plan view). The 3D move tool handles
* vertical motion; the 2D move is a horizontal-only re-anchor.
*/
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
const startLevelId = (() => {
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
const originalWall = node.parentId
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
: undefined
const resolveCursor = createFloorplanCursorResolver({
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: [node.position[0], 0],
metadata: node.metadata,
})
// Preserve the source window's local Y — 2D move doesn't have a way
// to express vertical motion, so we keep whatever vertical position
// the window had when the move started.
const startLocalY = node.position[1]
// Track the last successful placement so `commit()` can write it
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
let lastValid: {
position: [number, number, number]
rotation: [number, number, number]
side: WindowNode['side']
parentId: string
wallId: string
} | null = null
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return
// 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,
startLocalY,
node.width,
node.height,
)
lastValid = {
position: [clampedX, clampedY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.side,
parentId: hit.wall.id,
wallId: hit.wall.id,
}
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: lastValid,
},
])
},
canCommit() {
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
if (!live || live.type !== 'window') return false
const overlapping = hasWallChildOverlap(
live.parentId as string,
live.position[0],
live.position[1],
live.width,
live.height,
live.id,
)
return !overlapping
},
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 — that works most of the
// time but produces an empty diff (and silent revert) when the
// committed move lands on the same `parentId` with identical data.
// See `doorFloorplanMoveTarget.commit` for the original fix.
if (!lastValid) return
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: lastValid,
},
])
},
}
return session
}