item: fix 2D move — floor items stay parented to the level
Two related bugs caused floor items to disappear mid-2D-drag: 1. `startLevelId` did `parent.parentId` unconditionally for non-wall parents — so for a floor item parented directly to the level (the canonical convention from `use-placement-coordinator`), it returned `level.parentId = building.id`. `findContainingSurface` then iterated the building's children (levels, not slabs) and the fallback `parentId: startLevelId` reparented the item to the building. Now we walk the parent chain until we hit a `level` and short-circuit on `parent.type === 'level'`. 2. `buildSurfaceItemSession` reparented floor items to a slab when the cursor was over one. Slabs don't carry a `children` field on their schema (only ceilings + level do), so `updateNodesAction`'s reparent logic operated on `undefined.children` — the item dropped out of the level→children DFS the floor-plan layer walks, and the polygon stopped rendering mid-drag. Split into `buildFloorItemSession` (always parents to the level, just updates position) and a ceiling-only `buildSurfaceItemSession`. `findContainingSurface` narrows to `'ceiling'` as the only valid target. Matches the 3D `detachItemSurfaceToFloor` convention: floor items live as level children, not slab children. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0dee7747d2
commit
0540692bd5
@@ -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<ItemNode> = ({ 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<AnyNodeId, AnyNode>,
|
||||
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<readonly [number, number]>,
|
||||
): 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
|
||||
}
|
||||
Reference in New Issue
Block a user