diff --git a/packages/nodes/src/item/floorplan-move.ts b/packages/nodes/src/item/floorplan-move.ts new file mode 100644 index 00000000..2a8aad5c --- /dev/null +++ b/packages/nodes/src/item/floorplan-move.ts @@ -0,0 +1,256 @@ +import { + type AnyNode, + type AnyNodeId, + type CeilingNode, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + getScaledDimensions, + type ItemNode, + useScene, +} from '@pascal-app/core' +import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import { findClosestWallInPlan } from '../shared/wall-attach-target' + +/** + * 2D floor-plan move handler for item. Branches on `asset.attachTo`: + * + * - `'wall'` / `'wall-side'`: pointer snaps to nearest wall (same + * math as door / window via `findClosestWallInPlan`). Position + * local-X is snapped to 0.5m grid; the wall-local Y carries over + * from the source position (2D has no vertical signal). + * - `'ceiling'`: pointer is point-in-polygon-tested against every + * ceiling on the level. If hit, the item reparents to that + * ceiling at the snapped local plan position. + * - undefined (floor): pointer is point-in-polygon-tested against + * every slab on the level. If hit, the item reparents to that + * slab; otherwise it stays parented to the level (free-floating) + * at the snapped plan position. + * + * Skipped vs the 3D `MoveItemContent` for now: attachTo *transitions* + * (drop a wall lamp on a ceiling and have it switch to ceiling-attach). + * The 3D path remains canonical for that — 2D only re-anchors within + * the item's current attach family. + */ + +const GRID_STEP = 0.5 + +export const itemFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const attachTo = node.asset.attachTo + const startLevelId: AnyNodeId | null = (() => { + // Walk to the owning level depending on the item's current parent: + // - wall / ceiling parent → parent.parentId is the level + // - level parent (floor items) → parent.id IS the level + // - item / shelf parent → walk up until we hit a level + // Without the `parent.type === 'level'` short-circuit, floor items + // (whose immediate parent is the level itself) get `level.parentId`, + // which is the *building* — `findContainingSurface` would then + // iterate the building's children (levels, not slabs) and the + // fallback `parentId: startLevelId` would reparent the item to the + // building. The item drops out of the level→children DFS the floor + // plan walks and disappears mid-drag. + const nodes = useScene.getState().nodes + let current = nodes[node.parentId as AnyNodeId] + while (current) { + if (current.type === 'level') return current.id as AnyNodeId + if (!current.parentId) return null + current = nodes[current.parentId as AnyNodeId] + } + return null + })() + + if (attachTo === 'wall' || attachTo === 'wall-side') { + return buildWallItemSession(node, startLevelId) + } + if (attachTo === 'ceiling') { + return buildSurfaceItemSession(node, startLevelId, 'ceiling') + } + return buildFloorItemSession(node, startLevelId) +} + +function buildWallItemSession( + node: ItemNode, + startLevelId: AnyNodeId | null, +): FloorplanMoveTargetSession { + // Wall items use the same local-X snap pipeline as doors / windows. + // local-Y carries over from the source item's position (2D can't + // express vertical movement). + const startLocalY = node.position[1] + + return { + affectedIds: [node.id as AnyNodeId], + apply({ planPoint, modifiers }) { + const nodes = useScene.getState().nodes + 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) + const halfW = width / 2 + const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX)) + + useScene.getState().updateNodes([ + { + id: node.id as AnyNodeId, + data: { + position: [clampedX, startLocalY, 0], + rotation: [0, hit.itemRotation, 0], + side: hit.side, + parentId: hit.wall.id, + }, + }, + ]) + }, + canCommit() { + const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined + return !!live && live.type === 'item' && !!live.parentId + }, + } +} + +/** + * Floor items live as level children — the slab is *not* a parent (slabs + * have no `children` field; only ceilings and the level itself do). + * Reparenting a floor item to a slab corrupts the parent-children + * bookkeeping and the item drops out of the level→children DFS the + * floor-plan layer walks → the polygon stops rendering mid-drag. + * + * For the 2D move we just translate `position` in level-local coords and + * leave the parent as the level (matching the 3D `detachItemSurfaceToFloor` + * in `use-placement-coordinator.tsx`). Snap to the 0.5m grid unless the + * user holds Shift. + */ +function buildFloorItemSession( + node: ItemNode, + startLevelId: AnyNodeId | null, +): FloorplanMoveTargetSession { + return { + affectedIds: [node.id as AnyNodeId], + apply({ planPoint, modifiers }) { + const snapped: WallPlanPoint = modifiers.shiftKey + ? ([planPoint[0], planPoint[1]] as WallPlanPoint) + : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + + const sourceY = node.position[1] + const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]] + + useScene.getState().updateNodes([ + { + id: node.id as AnyNodeId, + data: { + position: nextPosition, + // Keep parent as the level we resolved at session-start. If + // somehow it's null (e.g. orphaned item), fall back to the + // existing parent so we don't write `null` and detach. + parentId: startLevelId ?? node.parentId, + }, + }, + ]) + }, + canCommit() { + const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined + return !!live && live.type === 'item' + }, + } +} + +/** + * Ceiling items reparent to whichever ceiling polygon contains the + * pointer. Ceilings carry a `children` field on their schema so the + * parent-children bookkeeping in `updateNodes` works correctly when the + * item moves between ceilings. If the cursor drifts off every ceiling, + * the original parent is preserved (no detach back to the level — there + * is no canonical "free-floating ceiling item"). + */ +function buildSurfaceItemSession( + node: ItemNode, + startLevelId: AnyNodeId | null, + targetKind: 'ceiling', +): FloorplanMoveTargetSession { + return { + affectedIds: [node.id as AnyNodeId], + apply({ planPoint, modifiers }) { + const nodes = useScene.getState().nodes + const snapped: WallPlanPoint = modifiers.shiftKey + ? ([planPoint[0], planPoint[1]] as WallPlanPoint) + : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + + const surface = findContainingSurface(snapped, nodes, startLevelId, targetKind) + + const sourceY = node.position[1] + const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]] + + useScene.getState().updateNodes([ + { + id: node.id as AnyNodeId, + data: { + position: nextPosition, + parentId: surface ? surface.id : node.parentId, + }, + }, + ]) + }, + canCommit() { + const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined + return !!live && live.type === 'item' + }, + } +} + +/** + * Walk every ceiling under the level and return the first one whose + * polygon contains the pointer. Holes are honoured — a point inside a + * hole counts as not inside the surface. Slabs are intentionally NOT a + * valid target: floor items are parented to the level, not the slab, + * because slabs don't carry a `children` field on their schema. + */ +function findContainingSurface( + point: readonly [number, number], + nodes: Record, + parentLevelId: AnyNodeId | null, + targetKind: 'ceiling', +): CeilingNode | null { + if (!parentLevelId) return null + const level = nodes[parentLevelId] + const childIds = (level as unknown as { children?: AnyNodeId[] })?.children + if (!Array.isArray(childIds)) return null + + for (const childId of childIds) { + const node = nodes[childId] + if (!node || node.type !== targetKind) continue + const surface = node as CeilingNode + const polygon = surface.polygon + if (!polygon || polygon.length < 3) continue + if (!pointInRing(point, polygon)) continue + const holes = surface.holes ?? [] + let inHole = false + for (const hole of holes) { + if (hole.length >= 3 && pointInRing(point, hole)) { + inHole = true + break + } + } + if (!inHole) return surface + } + return null +} + +/** Standard ray-cast point-in-polygon. Treats edges as inside. */ +function pointInRing( + point: readonly [number, number], + ring: ReadonlyArray, +): boolean { + let inside = false + const [px, py] = point + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const ax = ring[i]![0] + const ay = ring[i]![1] + const bx = ring[j]![0] + const by = ring[j]![1] + const intersects = ay > py !== by > py && px < ((bx - ax) * (py - ay)) / (by - ay) + ax + if (intersects) inside = !inside + } + return inside +}