diff --git a/packages/mcp/docs/layout-clearance-error-log.md b/packages/mcp/docs/layout-clearance-error-log.md new file mode 100644 index 0000000000..833e9deed3 --- /dev/null +++ b/packages/mcp/docs/layout-clearance-error-log.md @@ -0,0 +1,73 @@ +# Layout clearance — error & reporting log + +Permanent checklist so door / item layout bugs do not regress. +Use when changing `door-clearance.ts`, `layout-clearance.ts`, `furnish_room`, `verify_scene`, or `check_collisions`. + +## Reporting codes / messages + +| Source | Message / skip reason | Meaning | +|---|---|---| +| `furnish_room` skip | `blocks door clearance` | Pose hits door keep-out (real or planned) | +| `furnish_room` skip | `overlaps another item` | Pose hits another floor item (gap required) | +| `furnish_room` skip | `outside room bounds` | Pose leaves room polygon bounds | +| `verify_scene` | `Door … is blocked by item …` | Existing item in door keep-out | +| `verify_scene` / `check_collisions` | `Items overlap: A and B` | Item–item footprint conflict | +| `check_collisions` | `kind: item-aabb` | Same as overlap, structured | + +Agents should treat skip reasons and verify issues as actionable, not ignore them. + +## Known pitfalls (found in review) — do not reintroduce + +### L1 — Multi-level false positives +**Bug:** Plan X/Z only; upstairs furniture blocked downstairs doors. +**Rule:** Every door/item comparison must share the same **level id** (walk `parentId` via `resolveNodeLevelId`). +**Test:** Keepouts / collisions with two levels, same plan footprint, must not cross. + +### L2 — Planned entrance keep-out skipped when other doors exist +**Bug:** Planned keep-out only if `existingKeepouts.length === 0` globally. +**Rule:** For `furnish_room`, always add planned keep-out for **this room’s** `doorWallIndex` unless an existing keep-out already covers that edge (`keepoutCoversPlanned`). +**Test:** Level has door in room A; furnish room B without a door → still protects B’s door wall. + +### L3 — Gap sign inverted +**Bug:** `a.maxX - gap > b.minX` required deeper penetration for larger gap. +**Rule:** `gap` = **minimum free space**. Overlap if boxes expanded by gap still intersect: +`a.maxX + gap > b.minX && a.minX - gap < b.maxX` (same for Z). +**Test:** Two items 0.05 m apart with `gap = 0.08` must report collision. + +### L4 — Item scale ignored +**Bug:** Used raw `asset.dimensions` instead of `getScaledDimensions`. +**Rule:** Scene items always use `itemNodePlanAabb` / `getScaledDimensions`. Catalog placements (not yet scaled) use asset dimensions. +**Test:** Scaled item collides when scaled footprint overlaps. + +### L5 — Client navigation does not re-apply light preview (apps/editor) +**Bug:** `useEffect([])` only on mount; same-route query change ignored. +**Rule:** Depend on search-string / `useSearchParams` for light preview shading. +**Test:** Manual or unit: change `?disable=postFx` without remount → solid shading applies. + +### L6 — Planned keep-out false coverage +**Bug:** Any AABB overlap treated as “entrance already covered,” so a nearby door suppress this room’s planned keep-out. +**Rule:** `keepoutCoversPlanned` requires planned **center inside** existing keep-out and ≥50% planned area intersection. +**Test:** Adjacent keep-out that only glances planned must not cover; centered same-opening keep-out must cover. + +### L7 — Misleading placement skip reason +**Bug:** `findValidPlacement` reported the last candidate’s reason (often `outside_bounds`). +**Rule:** On total failure, report the **primary** pose reject reason (prefer door/overlap over bounds). +**Test:** Primary hits door, all nudges OOB → skip reason is door clearance. + +### L8 — Light preview stuck after flag removal (apps/editor) +**Bug:** Effect only sets solid when flags present; never restores when query cleared. +**Rule:** When light-preview flags absent, restore default shading (e.g. `rendered`). +**Test:** Navigate on → solid; navigate off → rendered (or app default). + +## Pre-merge checklist + +- [ ] Level-scoped door + item tests green +- [ ] Planned keep-out with sibling-room doors green +- [ ] Gap semantics unit test green +- [ ] Scaled dimensions unit test green +- [ ] `bun test` for door/layout/room/scene-query/check-collisions + +## Related PRs + +- #569 — MCP layout clearance +- #570 — Light preview / editor redirect diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index 03adabbb9d..6fa5dec066 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -35,6 +35,8 @@ export const AGENT_GUIDE = [ '- Prefer semantic tools over raw graph patches.', '- Do not hand-write node graphs unless no semantic tool exists.', '- For rooms, use `create_room` -> `add_door` -> `add_window` -> `furnish_room`.', + '- `furnish_room` skips or nudges poses that block door clear zones or overlap other items; `verify_scene` and `check_collisions` report remaining issues.', + '- Between adjacent rooms, prefer one shared wall (or only cut openings that line up). Leave ~0.65 m clear on both sides of each door; do not stack furniture footprints.', '- For complete homes, create exterior shell, interior rooms, openings, roof, furniture, then landscaping.', '- For doors/windows, use `t` or `position` from 0 to 1 along the wall unless a tool explicitly says otherwise.', '- X/Z are floor-plan axes and Y is vertical; dimensions are meters.', diff --git a/packages/mcp/src/tools/check-collisions.test.ts b/packages/mcp/src/tools/check-collisions.test.ts index 1a159b4aab..3ff7527c24 100644 --- a/packages/mcp/src/tools/check-collisions.test.ts +++ b/packages/mcp/src/tools/check-collisions.test.ts @@ -76,6 +76,26 @@ describe('check_collisions', () => { expect(parsed.collisions.length).toBe(0) }) + // This tool reports *actual* overlap, not "too close together". It shares + // findItemItemCollisions with furnish_room, which defaults to an 8cm spacing + // gap; if that default ever leaks in here, neighbouring furniture starts + // getting reported as colliding. 7cm apart must stay clean. + test('items standing close but not overlapping are not collisions', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + // a spans x [0, 1], b spans x [1.07, 2.07] — 7cm of clear air between them. + const a = makeItem([0.5, 0, 0]) + const b = makeItem([1.57, 0, 0]) + bridge.createNode(a, level.id) + bridge.createNode(b, level.id) + + const result = await client.callTool({ + name: 'check_collisions', + arguments: {}, + }) + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.collisions.length).toBe(0) + }) + test('scopes to levelId', async () => { const result = await client.callTool({ name: 'check_collisions', diff --git a/packages/mcp/src/tools/check-collisions.ts b/packages/mcp/src/tools/check-collisions.ts index f29a804340..d4d4ce14a1 100644 --- a/packages/mcp/src/tools/check-collisions.ts +++ b/packages/mcp/src/tools/check-collisions.ts @@ -1,8 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { AnyNodeId, ItemNode } from '@pascal-app/core/schema' -import { getScaledDimensions } from '@pascal-app/core/schema' +import type { AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneOperations } from '../operations' +import { findItemItemCollisions } from './layout-clearance' import { NodeIdSchema } from './schemas' export const checkCollisionsInput = { @@ -19,56 +19,43 @@ export const checkCollisionsOutput = { ), } -type AABB = { minX: number; maxX: number; minZ: number; maxZ: number } - -function itemAabb(item: ItemNode): AABB { - const [x, , z] = item.position - const [w, , d] = getScaledDimensions(item) - const halfW = w / 2 - const halfD = d / 2 - return { - minX: x - halfW, - maxX: x + halfW, - minZ: z - halfD, - maxZ: z + halfD, - } -} - -function aabbOverlap(a: AABB, b: AABB): boolean { - return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ -} - export function registerCheckCollisions(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'check_collisions', { title: 'Check collisions', description: - 'Detect overlapping item footprints via an axis-aligned 2D bounding-box test. Optionally scoped to a single level.', + 'Detect overlapping item footprints via a rotation-aware plan AABB test. Optionally scoped to a single level (items parented to that level).', inputSchema: checkCollisionsInput, outputSchema: checkCollisionsOutput, }, async ({ levelId }) => { - const filter: { type: 'item'; levelId?: AnyNodeId } = { type: 'item' } - if (levelId) filter.levelId = levelId as AnyNodeId - const items = bridge.findNodes(filter) as ItemNode[] - - const boxes = items.map((i) => ({ item: i, aabb: itemAabb(i) })) - const collisions: { aId: string; bId: string; kind: string }[] = [] - for (let i = 0; i < boxes.length; i++) { - for (let j = i + 1; j < boxes.length; j++) { - const a = boxes[i]! - const b = boxes[j]! - if (aabbOverlap(a.aabb, b.aabb)) { - collisions.push({ - aId: a.item.id as string, - bId: b.item.id as string, - kind: 'item-aabb', - }) - } - } + const nodes = Object.values(bridge.getNodes()) + let scoped = nodes + if (levelId) { + const levelItems = new Set( + bridge + .findNodes({ type: 'item', levelId: levelId as AnyNodeId }) + .map((n) => n.id as string), + ) + scoped = nodes.filter((n) => n.type !== 'item' || levelItems.has(n.id)) } + // gap: 0 keeps this tool's contract — it reports *actual* overlap. + // findItemItemCollisions defaults to DEFAULT_ITEM_GAP (8cm), which is the + // breathing room furnish_room wants when placing new items; applied here + // it would report items merely standing close together as colliding. + const found = findItemItemCollisions({ + nodes: scoped, + levelId: levelId as string | undefined, + gap: 0, + }) + const collisions = found.map((c) => ({ + aId: c.aId, + bId: c.bId, + kind: c.kind, + })) + const payload = { collisions } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/door-clearance.test.ts b/packages/mcp/src/tools/door-clearance.test.ts new file mode 100644 index 0000000000..0c1ec3ba21 --- /dev/null +++ b/packages/mcp/src/tools/door-clearance.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core/schema' +import { + aabbsOverlap, + collectDoorKeepouts, + doorKeepoutFromWall, + findBlockedDoors, + itemBlocksDoorKeepout, + itemNodePlanAabb, + itemPlanAabb, + keepoutCoversPlanned, + keepoutForPolygonEdge, +} from './door-clearance' + +function wall(id: string, start: [number, number], end: [number, number]) { + return { + object: 'node' as const, + id, + type: 'wall' as const, + parentId: 'level_1', + visible: true, + metadata: {}, + start, + end, + height: 2.5, + thickness: 0.15, + children: [] as string[], + } +} + +function door(id: string, wallId: string, localX: number, width = 0.8) { + return { + object: 'node' as const, + id, + type: 'door' as const, + parentId: wallId, + wallId, + visible: true, + metadata: {}, + position: [localX, 1.05, 0] as [number, number, number], + width, + height: 2.1, + } +} + +function level(id: string) { + return { + object: 'node' as const, + id, + type: 'level' as const, + parentId: 'building_1', + visible: true, + metadata: {}, + children: [] as string[], + } +} + +function item( + id: string, + position: [number, number, number], + dimensions: [number, number, number], + name = 'Item', + parentId = 'level_1', + scale: [number, number, number] = [1, 1, 1], +) { + return { + object: 'node' as const, + id, + type: 'item' as const, + parentId, + visible: true, + metadata: {}, + name, + position, + rotation: [0, 0, 0] as [number, number, number], + scale, + asset: { + id: 'x', + name, + category: 'furniture', + thumbnail: '', + src: 'asset://x', + dimensions, + }, + } +} + +describe('door-clearance', () => { + test('doorKeepoutFromWall covers both sides of a horizontal wall door', () => { + const w = wall('wall_1', [0, 2.5], [5.5, 2.5]) + const d = door('door_1', 'wall_1', 1.375, 0.8) + const keepout = doorKeepoutFromWall(w, d, { clearDepth: 0.65, sidePad: 0.05 }) + expect(keepout).not.toBeNull() + // Door center world ≈ (1.375, 2.5); keep-out extends ±0.65 in Z + expect(keepout!.aabb.minZ).toBeLessThan(2.5 - 0.6) + expect(keepout!.aabb.maxZ).toBeGreaterThan(2.5 + 0.6) + expect(keepout!.aabb.minX).toBeLessThan(1.375) + expect(keepout!.aabb.maxX).toBeGreaterThan(1.375) + }) + + test('item in clear zone blocks door; item outside does not', () => { + const w = wall('wall_1', [0, 2.5], [5.5, 2.5]) + const d = door('door_1', 'wall_1', 1.375, 0.8) + const keepout = doorKeepoutFromWall(w, d)! + const toilet = itemPlanAabb([0.7, 0, 1.95], [1, 0.9, 1], 0) + const farBed = itemPlanAabb([2.75, 0, 4.7], [2, 0.8, 2.5], 0) + expect(itemBlocksDoorKeepout(toilet, keepout)).toBe(true) + expect(itemBlocksDoorKeepout(farBed, keepout)).toBe(false) + }) + + test('findBlockedDoors reports furniture in keep-out', () => { + const nodes = [ + wall('wall_1', [0, 2.5], [5.5, 2.5]), + door('door_bath', 'wall_1', 1.375, 0.8), + item('item_toilet', [0.7, 0, 1.95], [1, 0.9, 1], 'Toilet'), + item('item_bed', [2.75, 0, 4.7], [2, 0.8, 2.5], 'Double Bed'), + ] as unknown as AnyNode[] + + const issues = findBlockedDoors({ nodes }) + expect(issues.some((i) => i.itemId === 'item_toilet')).toBe(true) + expect(issues.some((i) => i.itemId === 'item_bed')).toBe(false) + expect(issues[0]?.message).toContain('blocked') + }) + + test('collectDoorKeepouts skips doors without a wall parent', () => { + const nodes = [door('orphan', 'missing_wall', 1)] as unknown as AnyNode[] + expect(collectDoorKeepouts(nodes)).toEqual([]) + }) + + test('keepoutForPolygonEdge plans clearance on room edge', () => { + const poly: [number, number][] = [ + [0, 0], + [2.75, 0], + [2.75, 2.5], + [0, 2.5], + ] + // edge 2 is north wall [2.75,2.5] -> [0,2.5] + const aabb = keepoutForPolygonEdge(poly, 2, { t: 0.5, width: 0.8, clearDepth: 0.85 }) + expect(aabb).not.toBeNull() + expect(aabb!.minZ).toBeLessThan(2.5) + expect(aabb!.maxZ).toBeGreaterThan(2.5) + }) + + test('aabbsOverlap: gap is minimum free space (not penetration depth)', () => { + // Two 1x1 boxes centered 1.05 m apart on X → 0.05 m free gap between faces + const a = { minX: 0, maxX: 1, minZ: 0, maxZ: 1 } + const b = { minX: 1.05, maxX: 2.05, minZ: 0, maxZ: 1 } + expect(aabbsOverlap(a, b, 0)).toBe(false) + // Require 0.08 m free → must report conflict + expect(aabbsOverlap(a, b, 0.08)).toBe(true) + // Far apart + const c = { minX: 3, maxX: 4, minZ: 0, maxZ: 1 } + expect(aabbsOverlap(a, c, 0.08)).toBe(false) + }) + + test('findBlockedDoors does not cross stacked levels (L1)', () => { + const nodes = [ + level('level_1'), + level('level_2'), + { ...wall('wall_L1', [0, 2.5], [5.5, 2.5]), parentId: 'level_1' }, + { ...wall('wall_L2', [0, 2.5], [5.5, 2.5]), parentId: 'level_2' }, + door('door_L1', 'wall_L1', 1.375, 0.8), + door('door_L2', 'wall_L2', 1.375, 0.8), + // Toilet on L2 in same plan slot as L1 door + item('toilet_L2', [0.7, 0, 1.95], [1, 0.9, 1], 'Toilet', 'level_2'), + ] as unknown as AnyNode[] + + const l1 = findBlockedDoors({ nodes, levelId: 'level_1' }) + expect(l1.some((i) => i.itemId === 'toilet_L2')).toBe(false) + const l2 = findBlockedDoors({ nodes, levelId: 'level_2' }) + expect(l2.some((i) => i.itemId === 'toilet_L2')).toBe(true) + }) + + test('itemNodePlanAabb respects scale (L4)', () => { + const n = item('big', [0, 0, 0], [1, 1, 1], 'Big', 'level_1', [2, 1, 2]) + const aabb = itemNodePlanAabb(n as unknown as AnyNode)! + // width 2, depth 2 → half 1 + expect(aabb.maxX - aabb.minX).toBeCloseTo(2, 5) + expect(aabb.maxZ - aabb.minZ).toBeCloseTo(2, 5) + }) + + test('keepoutCoversPlanned detects covered entrance edge (L2 helper)', () => { + const planned = keepoutForPolygonEdge( + [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + 0, + { t: 0.5, width: 0.9 }, + )! + const real = doorKeepoutFromWall(wall('w', [0, 0], [5, 0]), door('d', 'w', 2.5, 0.9))! + expect(keepoutCoversPlanned(real.aabb, planned)).toBe(true) + const elsewhere = { minX: 10, maxX: 11, minZ: 10, maxZ: 11 } + expect(keepoutCoversPlanned(elsewhere, planned)).toBe(false) + }) + + test('keepoutCoversPlanned rejects glancing nearby door (L6)', () => { + const planned = keepoutForPolygonEdge( + [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + 0, + { t: 0.5, width: 0.9 }, + )! + // Nearby keep-out that only barely overlaps planned AABB (not same opening center) + const glancing = { + minX: planned.maxX - 0.05, + maxX: planned.maxX + 1, + minZ: planned.minZ, + maxZ: planned.maxZ, + } + expect(keepoutCoversPlanned(glancing, planned)).toBe(false) + }) +}) diff --git a/packages/mcp/src/tools/door-clearance.ts b/packages/mcp/src/tools/door-clearance.ts new file mode 100644 index 0000000000..d138666bf9 --- /dev/null +++ b/packages/mcp/src/tools/door-clearance.ts @@ -0,0 +1,312 @@ +/** + * Door access keep-outs for MCP layout tools. + * + * Furniture that overlaps a door clear zone is reported as blocking the door. + * Used by furnish_room (skip placements) and verify_scene (layout issues). + * + * See docs/layout-clearance-error-log.md for pitfalls (levels, gap sign, scale). + */ + +import type { AnyNode } from '@pascal-app/core/schema' +import { getScaledDimensions } from '@pascal-app/core/schema' +import { type Vec2, wallLength } from './geometry' + +export type PlanAabb = { + minX: number + maxX: number + minZ: number + maxZ: number +} + +export type DoorKeepout = { + doorId: string + wallId: string + levelId: string | null + /** World-space AABB on both sides of the wall opening. */ + aabb: PlanAabb + width: number + localX: number +} + +/** + * Wall shape these helpers accept. Deliberately structural rather than + * `Pick`: `keepoutForPolygonEdge` feeds in synthetic `edge-N` + * segments for room edges that do not have a wall node yet, so the id cannot + * be the branded `wall_${string}`. + */ +export type WallSegmentLike = { id: string; start: Vec2; end: Vec2 } + +/** + * Door shape these helpers accept — a real `DoorNode` or a planned opening. + * `Pick` does not work: those keys exist on + * only some members of the `AnyNode` union, so `Pick` rejects them. + */ +export type DoorOpeningLike = { + id: string + position?: readonly number[] + width?: number +} + +/** Plan depth (m) cleared on each side of the wall face through the opening. */ +export const DEFAULT_DOOR_CLEAR_DEPTH = 0.65 +/** Extra half-width (m) beyond the door leaf along the wall. */ +export const DEFAULT_DOOR_SIDE_PAD = 0.05 + +/** + * True when A and B come closer than `gap` meters (including penetration). + * `gap` is the **minimum free space required** between boxes: + * expand each box by gap/2, then test intersection. + */ +export function aabbsOverlap(a: PlanAabb, b: PlanAabb, gap = 0): boolean { + const g = gap + return a.maxX + g > b.minX && a.minX - g < b.maxX && a.maxZ + g > b.minZ && a.minZ - g < b.maxZ +} + +/** + * Walk parentId chain to the enclosing level id (pure; no bridge required). + */ +export function resolveNodeLevelId(nodeId: string, byId: Map): string | null { + let current: AnyNode | undefined = byId.get(nodeId) + const seen = new Set() + while (current) { + if (seen.has(current.id)) return null + seen.add(current.id) + if (current.type === 'level') return current.id + const parentId = current.parentId + if (!parentId) return null + current = byId.get(parentId) + } + return null +} + +/** + * Axis-aligned item footprint in plan (x/z), rotation-aware. + * Prefer scaled dimensions when the node is available. + */ +export function itemPlanAabb( + position: [number, number, number] | number[], + dimensions: [number, number, number] | number[] | undefined, + rotationYRad = 0, +): PlanAabb { + const x = position[0] ?? 0 + const z = position[2] ?? 0 + const [w = 1, , d = 1] = dimensions ?? [1, 1, 1] + const cos = Math.abs(Math.cos(rotationYRad)) + const sin = Math.abs(Math.sin(rotationYRad)) + const halfW = (w * cos + d * sin) / 2 + const halfD = (w * sin + d * cos) / 2 + return { + minX: x - halfW, + maxX: x + halfW, + minZ: z - halfD, + maxZ: z + halfD, + } +} + +/** Footprint for a scene item node (uses getScaledDimensions). */ +export function itemNodePlanAabb(node: AnyNode): PlanAabb | null { + if (node.type !== 'item') return null + const [w, , d] = getScaledDimensions(node) + const rotY = Array.isArray(node.rotation) ? (node.rotation[1] ?? 0) : 0 + return itemPlanAabb(node.position as number[], [w, 0, d], rotY) +} + +/** + * Build a rectangular keep-out around a wall door, extruded perpendicular to the wall + * on both faces so either swing side is protected. + */ +export function doorKeepoutFromWall( + wall: WallSegmentLike, + door: DoorOpeningLike, + options?: { clearDepth?: number; sidePad?: number; levelId?: string | null }, +): DoorKeepout | null { + const clearDepth = options?.clearDepth ?? DEFAULT_DOOR_CLEAR_DEPTH + const sidePad = options?.sidePad ?? DEFAULT_DOOR_SIDE_PAD + const length = wallLength(wall) + if (length <= 1e-6) return null + + const width = typeof door.width === 'number' && door.width > 0 ? door.width : 0.9 + const localX = Array.isArray(door.position) ? (door.position[0] ?? length / 2) : length / 2 + + const [sx, sz] = wall.start + const [ex, ez] = wall.end + const dx = (ex - sx) / length + const dz = (ez - sz) / length + const nx = -dz + const nz = dx + + const half = width / 2 + sidePad + const corners: Vec2[] = [] + for (const along of [localX - half, localX + half]) { + const cx = sx + dx * along + const cz = sz + dz * along + for (const side of [-clearDepth, clearDepth]) { + corners.push([cx + nx * side, cz + nz * side]) + } + } + + const xs = corners.map((c) => c[0]) + const zs = corners.map((c) => c[1]) + return { + doorId: door.id, + wallId: wall.id, + levelId: options?.levelId ?? null, + width, + localX, + aabb: { + minX: Math.min(...xs), + maxX: Math.max(...xs), + minZ: Math.min(...zs), + maxZ: Math.max(...zs), + }, + } +} + +export function collectDoorKeepouts( + nodes: Iterable, + options?: { clearDepth?: number; sidePad?: number; levelId?: string }, +): DoorKeepout[] { + const byId = new Map() + for (const node of nodes) byId.set(node.id, node) + + const keepouts: DoorKeepout[] = [] + for (const node of byId.values()) { + if (node.type !== 'door') continue + const wallId = node.wallId ?? node.parentId + if (!wallId) continue + const wall = byId.get(wallId) + if (wall?.type !== 'wall') continue + const levelId = resolveNodeLevelId(wall.id, byId) ?? resolveNodeLevelId(node.id, byId) + if (options?.levelId && levelId !== options.levelId) continue + const keepout = doorKeepoutFromWall(wall, node, { + clearDepth: options?.clearDepth, + sidePad: options?.sidePad, + levelId, + }) + if (keepout) keepouts.push(keepout) + } + return keepouts +} + +export function itemBlocksDoorKeepout(itemAabb: PlanAabb, keepout: DoorKeepout): boolean { + return aabbsOverlap(itemAabb, keepout.aabb, 0.02) +} + +export type BlockedDoorIssue = { + doorId: string + wallId: string + itemId: string + levelId?: string | null + itemName?: string + message: string +} + +export function findBlockedDoors(args: { + nodes: Iterable + clearDepth?: number + sidePad?: number + /** When set, only doors and items on this level are considered. */ + levelId?: string +}): BlockedDoorIssue[] { + const nodes = [...args.nodes] + const byId = new Map(nodes.map((n) => [n.id, n] as const)) + const keepouts = collectDoorKeepouts(nodes, { + clearDepth: args.clearDepth, + sidePad: args.sidePad, + levelId: args.levelId, + }) + if (keepouts.length === 0) return [] + + const issues: BlockedDoorIssue[] = [] + for (const node of nodes) { + if (node.type !== 'item') continue + const itemLevel = resolveNodeLevelId(node.id, byId) + if (args.levelId && itemLevel !== args.levelId) continue + + const aabb = itemNodePlanAabb(node) + if (!aabb) continue + + for (const keepout of keepouts) { + // Same-level only (multi-story safety). + if (keepout.levelId && itemLevel && keepout.levelId !== itemLevel) continue + if (args.levelId && keepout.levelId && keepout.levelId !== args.levelId) continue + if (!itemBlocksDoorKeepout(aabb, keepout)) continue + const itemName = node.name ?? node.asset?.name ?? node.id + issues.push({ + doorId: keepout.doorId, + wallId: keepout.wallId, + itemId: node.id, + levelId: itemLevel, + itemName: typeof itemName === 'string' ? itemName : undefined, + message: `Door ${keepout.doorId} on wall ${keepout.wallId} is blocked by item ${itemName} (${node.id})`, + }) + } + } + return issues +} + +/** + * Synthetic keep-outs for a room polygon edge that will host a door (before doors exist). + * Used by furnish_room when doorWallIndex is known. + */ +export function keepoutForPolygonEdge( + polygon: Vec2[], + edgeIndex: number, + options?: { t?: number; width?: number; clearDepth?: number; sidePad?: number }, +): PlanAabb | null { + if (polygon.length < 3) return null + const i = ((edgeIndex % polygon.length) + polygon.length) % polygon.length + const start = polygon[i]! + const end = polygon[(i + 1) % polygon.length]! + const wall = { + id: `edge-${i}`, + start, + end, + } + const length = wallLength(wall) + if (length <= 1e-6) return null + const width = options?.width ?? 0.9 + const t = options?.t ?? 0.5 + const localX = Math.min(Math.max(t * length, width / 2), length - width / 2) + const keepout = doorKeepoutFromWall( + wall, + { + id: `planned-door-${i}`, + position: [localX, 1.05, 0], + width, + }, + { clearDepth: options?.clearDepth, sidePad: options?.sidePad }, + ) + return keepout?.aabb ?? null +} + +/** + * Whether an existing keep-out already covers this room-edge planned zone + * (so we do not double-count a real door on that edge). + * + * Requires the planned keep-out **center** to lie inside the existing keep-out, + * and the existing box to cover a large fraction of the planned area. + * Plain AABB overlap is not enough (nearby hallway doors must not suppress + * this room's entrance keep-out). + */ +export function keepoutCoversPlanned(existing: PlanAabb, planned: PlanAabb): boolean { + const cx = (planned.minX + planned.maxX) / 2 + const cz = (planned.minZ + planned.maxZ) / 2 + const centerInside = + cx >= existing.minX && cx <= existing.maxX && cz >= existing.minZ && cz <= existing.maxZ + if (!centerInside) return false + + // Intersection area / planned area must be substantial (same opening, not a glancing touch). + const ix0 = Math.max(existing.minX, planned.minX) + const ix1 = Math.min(existing.maxX, planned.maxX) + const iz0 = Math.max(existing.minZ, planned.minZ) + const iz1 = Math.min(existing.maxZ, planned.maxZ) + if (ix1 <= ix0 || iz1 <= iz0) return false + const inter = (ix1 - ix0) * (iz1 - iz0) + const plannedArea = Math.max(1e-9, (planned.maxX - planned.minX) * (planned.maxZ - planned.minZ)) + return inter / plannedArea >= 0.5 +} + +export function aabbFromPlan(a: PlanAabb): PlanAabb { + return a +} diff --git a/packages/mcp/src/tools/layout-clearance.test.ts b/packages/mcp/src/tools/layout-clearance.test.ts new file mode 100644 index 0000000000..21762d9d60 --- /dev/null +++ b/packages/mcp/src/tools/layout-clearance.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core/schema' +import { + classifyPlacement, + findItemItemCollisions, + findValidPlacement, + generatePlacementCandidates, + itemPlanAabb, + layoutIssuesFromScene, +} from './layout-clearance' + +function item( + id: string, + position: [number, number, number], + dimensions: [number, number, number], + name = 'Item', + rotY = 0, + parentId = 'level_1', + scale: [number, number, number] = [1, 1, 1], +) { + return { + object: 'node' as const, + id, + type: 'item' as const, + parentId, + visible: true, + metadata: {}, + name, + position, + rotation: [0, rotY, 0] as [number, number, number], + scale, + asset: { + id: 'x', + name, + category: 'furniture', + thumbnail: '', + src: 'asset://x', + dimensions, + }, + } +} + +describe('layout-clearance', () => { + test('findItemItemCollisions detects rotated footprint overlap', () => { + // Two 2x1 footprints; second rotated 90° so it extends along X + const a = item('a', [0, 0, 0], [2, 1, 1], 'A', 0) + const b = item('b', [0.9, 0, 0], [2, 1, 1], 'B', Math.PI / 2) + const hits = findItemItemCollisions({ nodes: [a, b] as unknown as AnyNode[] }) + expect(hits.length).toBe(1) + expect(hits[0]!.message).toContain('overlap') + }) + + test('findItemItemCollisions ignores separated items', () => { + const a = item('a', [0, 0, 0], [1, 1, 1], 'A') + const b = item('b', [3, 0, 0], [1, 1, 1], 'B') + expect(findItemItemCollisions({ nodes: [a, b] as unknown as AnyNode[] })).toEqual([]) + }) + + test('classifyPlacement flags door keep-out and item overlap', () => { + const aabb = itemPlanAabb([1, 0, 1], [1, 1, 1], 0) + expect( + classifyPlacement({ + aabb, + doorKeepouts: [{ minX: 0.5, maxX: 1.5, minZ: 0.5, maxZ: 1.5 }], + occupied: [], + }), + ).toBe('blocks_door_clearance') + expect( + classifyPlacement({ + aabb, + doorKeepouts: [], + occupied: [{ minX: 0.5, maxX: 1.5, minZ: 0.5, maxZ: 1.5 }], + }), + ).toBe('overlaps_item') + expect( + classifyPlacement({ + aabb, + doorKeepouts: [], + occupied: [{ minX: 5, maxX: 6, minZ: 5, maxZ: 6 }], + }), + ).toBe('ok') + }) + + test('findValidPlacement nudges off an overlapping neighbor', () => { + const primary = { x: 2, z: 2, rotationDeg: 0 } + // Blocker sits on primary + const occupied = [itemPlanAabb([2, 0, 2], [1.2, 1, 1.2], 0)] + const found = findValidPlacement({ + primary, + dimensions: [1, 1, 1], + doorKeepouts: [], + occupied, + roomBounds: { minX: 0, maxX: 6, minZ: 0, maxZ: 6 }, + along: { x: 1, z: 0 }, + inward: { x: 0, z: 1 }, + }) + expect(found.candidate).not.toBeNull() + expect(found.candidate!.x !== 2 || found.candidate!.z !== 2).toBe(true) + }) + + test('generatePlacementCandidates includes primary and offsets', () => { + const c = generatePlacementCandidates( + { x: 0, z: 0, rotationDeg: 0 }, + { lateralsM: [0, 1], insetsM: [0, 0.5], along: { x: 1, z: 0 }, inward: { x: 0, z: 1 } }, + ) + expect(c.some((p) => p.x === 0 && p.z === 0)).toBe(true) + expect(c.some((p) => p.x === 1 && p.z === 0)).toBe(true) + expect(c.some((p) => p.x === 0 && p.z === 0.5)).toBe(true) + }) + + test('layoutIssuesFromScene merges door blocks and item overlaps', () => { + const level = { + object: 'node' as const, + id: 'level_1', + type: 'level' as const, + parentId: null, + visible: true, + metadata: {}, + children: [] as string[], + } + const wall = { + object: 'node' as const, + id: 'wall_1', + type: 'wall' as const, + parentId: 'level_1', + visible: true, + metadata: {}, + start: [0, 2.5] as [number, number], + end: [5.5, 2.5] as [number, number], + height: 2.5, + thickness: 0.15, + children: [] as string[], + } + const door = { + object: 'node' as const, + id: 'door_1', + type: 'door' as const, + parentId: 'wall_1', + wallId: 'wall_1', + visible: true, + metadata: {}, + position: [1.375, 1.05, 0] as [number, number, number], + width: 0.8, + height: 2.1, + } + const toilet = item('t', [0.7, 0, 1.95], [1, 0.9, 1], 'Toilet') + const a = item('a', [3, 0, 4], [1.5, 1, 1.5], 'A') + const b = item('b', [3.2, 0, 4.1], [1.5, 1, 1.5], 'B') + const issues = layoutIssuesFromScene([level, wall, door, toilet, a, b] as unknown as AnyNode[]) + expect(issues.some((m) => m.includes('blocked'))).toBe(true) + expect(issues.some((m) => m.includes('overlap'))).toBe(true) + }) + + test('item gap of 0.08 m flags near-touching items (L3)', () => { + // centers 1.05 m apart, each half-width 0.5 → 0.05 m free space + const a = item('a', [0, 0, 0], [1, 1, 1], 'A') + const b = item('b', [1.05, 0, 0], [1, 1, 1], 'B') + const hits = findItemItemCollisions({ + nodes: [a, b] as unknown as AnyNode[], + gap: 0.08, + }) + expect(hits.length).toBe(1) + }) + + test('scaled items collide via getScaledDimensions (L4)', () => { + // base 1x1, scale 3 → large footprint at origin overlaps neighbor at 1.5 + const a = item('a', [0, 0, 0], [1, 1, 1], 'A', 0, 'level_1', [3, 1, 3]) + const b = item('b', [1.5, 0, 0], [1, 1, 1], 'B') + const hits = findItemItemCollisions({ nodes: [a, b] as unknown as AnyNode[] }) + expect(hits.length).toBe(1) + }) + + test('findValidPlacement reports primary door failure not last OOB (L7)', () => { + // Tiny room so lateral nudges go out of bounds; primary sits in door keep-out. + const found = findValidPlacement({ + primary: { x: 1, z: 1, rotationDeg: 0 }, + dimensions: [1, 1, 1], + doorKeepouts: [{ minX: 0, maxX: 2, minZ: 0, maxZ: 2 }], + occupied: [], + roomBounds: { minX: 0, maxX: 2.2, minZ: 0, maxZ: 2.2 }, + along: { x: 1, z: 0 }, + inward: { x: 0, z: 1 }, + }) + expect(found.candidate).toBeNull() + expect(found.reason).toBe('blocks_door_clearance') + }) +}) diff --git a/packages/mcp/src/tools/layout-clearance.ts b/packages/mcp/src/tools/layout-clearance.ts new file mode 100644 index 0000000000..9e1c8d42dc --- /dev/null +++ b/packages/mcp/src/tools/layout-clearance.ts @@ -0,0 +1,270 @@ +/** + * Shared plan-layout clearance for MCP tools. + * + * - Door keep-outs (level-scoped) + * - Item–item AABB overlap (rotation + scale aware) + * - Placement candidate search when primary pose is blocked + * + * See docs/layout-clearance-error-log.md for regression checklist. + */ + +import type { AnyNode } from '@pascal-app/core/schema' +import { + aabbsOverlap, + findBlockedDoors, + itemNodePlanAabb, + itemPlanAabb, + type PlanAabb, + resolveNodeLevelId, +} from './door-clearance' + +export { + aabbsOverlap, + collectDoorKeepouts, + findBlockedDoors, + itemNodePlanAabb, + itemPlanAabb, + type PlanAabb, + resolveNodeLevelId, +} from './door-clearance' + +/** Minimum free space (m) required between item footprints. */ +export const DEFAULT_ITEM_GAP = 0.08 + +export type OccupiedFootprint = { + id: string + name?: string + levelId?: string | null + aabb: PlanAabb +} + +export type ItemCollision = { + aId: string + bId: string + aName?: string + bName?: string + levelId?: string | null + kind: 'item-aabb' + message: string +} + +export function nodeItemAabb(node: AnyNode): PlanAabb | null { + return itemNodePlanAabb(node) +} + +export function collectOccupiedFootprints( + nodes: Iterable, + options?: { levelId?: string; excludeIds?: Set; floorOnly?: boolean }, +): OccupiedFootprint[] { + const list = [...nodes] + const byId = new Map(list.map((n) => [n.id, n])) + const out: OccupiedFootprint[] = [] + for (const node of list) { + if (node.type !== 'item') continue + if (options?.excludeIds?.has(node.id)) continue + const attach = node.asset?.attachTo + if ( + options?.floorOnly && + (attach === 'wall' || attach === 'wall-side' || attach === 'ceiling') + ) { + continue + } + const levelId = resolveNodeLevelId(node.id, byId) + if (options?.levelId && levelId !== options.levelId) continue + // Floor packing: prefer level-parented items (not wall-hosted children). + if (options?.floorOnly && node.parentId && levelId && node.parentId !== levelId) { + if (attach === 'wall' || attach === 'wall-side' || attach === 'ceiling') continue + // Wall-parented items without attachTo still skipped for floor packing. + if (byId.get(node.parentId)?.type === 'wall') continue + } + const aabb = nodeItemAabb(node) + if (!aabb) continue + const name = node.name ?? node.asset?.name + out.push({ + id: node.id, + name: typeof name === 'string' ? name : undefined, + levelId, + aabb, + }) + } + return out +} + +export function findItemItemCollisions(args: { + nodes: Iterable + levelId?: string + gap?: number +}): ItemCollision[] { + const gap = args.gap ?? DEFAULT_ITEM_GAP + const footprints = collectOccupiedFootprints(args.nodes, { levelId: args.levelId }) + const collisions: ItemCollision[] = [] + for (let i = 0; i < footprints.length; i++) { + for (let j = i + 1; j < footprints.length; j++) { + const a = footprints[i]! + const b = footprints[j]! + if (a.levelId && b.levelId && a.levelId !== b.levelId) continue + if (!aabbsOverlap(a.aabb, b.aabb, gap)) continue + collisions.push({ + aId: a.id, + bId: b.id, + aName: a.name, + bName: b.name, + levelId: a.levelId ?? b.levelId, + kind: 'item-aabb', + message: `Items overlap: ${a.name ?? a.id} (${a.id}) and ${b.name ?? b.id} (${b.id})`, + }) + } + } + return collisions +} + +export type PlacementCandidate = { + x: number + z: number + rotationDeg: number +} + +export type PlacementRejectReason = + | 'outside_bounds' + | 'blocks_door_clearance' + | 'overlaps_item' + | 'ok' + +export function classifyPlacement(args: { + aabb: PlanAabb + doorKeepouts: PlanAabb[] + occupied: PlanAabb[] + roomBounds?: { minX: number; maxX: number; minZ: number; maxZ: number } + padding?: number + itemGap?: number + doorGap?: number +}): PlacementRejectReason { + const padding = args.padding ?? 0.05 + const itemGap = args.itemGap ?? DEFAULT_ITEM_GAP + const doorGap = args.doorGap ?? 0.02 + if (args.roomBounds) { + const b = args.roomBounds + if ( + args.aabb.minX < b.minX + padding || + args.aabb.maxX > b.maxX - padding || + args.aabb.minZ < b.minZ + padding || + args.aabb.maxZ > b.maxZ - padding + ) { + return 'outside_bounds' + } + } + if (args.doorKeepouts.some((k) => aabbsOverlap(args.aabb, k, doorGap))) { + return 'blocks_door_clearance' + } + if (args.occupied.some((o) => aabbsOverlap(args.aabb, o, itemGap))) { + return 'overlaps_item' + } + return 'ok' +} + +export function generatePlacementCandidates( + primary: PlacementCandidate, + options?: { + lateralsM?: number[] + insetsM?: number[] + inward?: { x: number; z: number } + along?: { x: number; z: number } + }, +): PlacementCandidate[] { + const laterals = options?.lateralsM ?? [0, -0.4, 0.4, -0.8, 0.8, -1.2, 1.2] + const insets = options?.insetsM ?? [0, 0.25, 0.5, 0.75] + const along = options?.along ?? { x: 1, z: 0 } + const inward = options?.inward ?? { x: 0, z: 1 } + const out: PlacementCandidate[] = [] + const seen = new Set() + for (const lat of laterals) { + for (const inset of insets) { + const x = primary.x + along.x * lat + inward.x * inset + const z = primary.z + along.z * lat + inward.z * inset + const key = `${x.toFixed(3)},${z.toFixed(3)},${primary.rotationDeg}` + if (seen.has(key)) continue + seen.add(key) + out.push({ x, z, rotationDeg: primary.rotationDeg }) + } + } + return out +} + +export function findValidPlacement(args: { + primary: PlacementCandidate + dimensions: [number, number, number] | number[] | undefined + doorKeepouts: PlanAabb[] + occupied: PlanAabb[] + roomBounds?: { minX: number; maxX: number; minZ: number; maxZ: number } + along?: { x: number; z: number } + inward?: { x: number; z: number } +}): + | { candidate: PlacementCandidate; reason: PlacementRejectReason } + | { candidate: null; reason: PlacementRejectReason } { + const candidates = generatePlacementCandidates(args.primary, { + along: args.along, + inward: args.inward, + }) + + // Prefer reporting why the **primary** pose failed (door/overlap), not the + // last lateral/inset candidate (often outside_bounds after large nudges). + let primaryReason: PlacementRejectReason | null = null + const reasonPriority: PlacementRejectReason[] = [ + 'blocks_door_clearance', + 'overlaps_item', + 'outside_bounds', + ] + let bestFailReason: PlacementRejectReason = 'overlaps_item' + + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]! + const rot = (c.rotationDeg * Math.PI) / 180 + const aabb = itemPlanAabb([c.x, 0, c.z], args.dimensions, rot) + const reason = classifyPlacement({ + aabb, + doorKeepouts: args.doorKeepouts, + occupied: args.occupied, + roomBounds: args.roomBounds, + }) + if (reason === 'ok') return { candidate: c, reason } + if (i === 0) primaryReason = reason + const prevRank = reasonPriority.indexOf(bestFailReason) + const nextRank = reasonPriority.indexOf(reason) + if (nextRank >= 0 && (prevRank < 0 || nextRank < prevRank)) { + bestFailReason = reason + } + } + return { candidate: null, reason: primaryReason ?? bestFailReason } +} + +/** + * Collect layout issues, scoped per level so stacked floors do not false-positive. + */ +export function layoutIssuesFromScene(nodes: Iterable): string[] { + const list = [...nodes] + const byId = new Map(list.map((n) => [n.id, n] as const)) + const levelIds = new Set() + for (const n of list) { + if (n.type === 'level') levelIds.add(n.id) + } + // Also collect levels referenced by walls/items (in case filter missed) + for (const n of list) { + const lid = resolveNodeLevelId(n.id, byId) + if (lid) levelIds.add(lid) + } + + const issues: string[] = [] + const levels = levelIds.size > 0 ? [...levelIds] : [undefined] + + for (const levelId of levels) { + for (const b of findBlockedDoors({ nodes: list, levelId })) { + issues.push(b.message) + } + for (const c of findItemItemCollisions({ nodes: list, levelId })) { + issues.push(c.message) + } + } + + // Deduplicate (node may appear under multiple walks) + return [...new Set(issues)] +} diff --git a/packages/mcp/src/tools/room-tools.test.ts b/packages/mcp/src/tools/room-tools.test.ts index 05dc5f709e..56f07bf650 100644 --- a/packages/mcp/src/tools/room-tools.test.ts +++ b/packages/mcp/src/tools/room-tools.test.ts @@ -223,4 +223,127 @@ describe('room tools', () => { } expect(bridge.validateScene().valid).toBe(true) }) + + test('furnish_room never leaves items blocking doors after bathroom layout', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const roomResult = await client.callTool({ + name: 'create_room', + arguments: { + levelId: level.id, + name: 'Bath', + polygon: [ + [0, 0], + [2.75, 0], + [2.75, 2.5], + [0, 2.5], + ], + }, + }) + const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text) + // Door on north wall (index 2) — same geometry as the blocked master-suite bath. + await client.callTool({ + name: 'add_door', + arguments: { wallId: room.wallIds[2], t: 0.5, width: 0.8 }, + }) + + const furnish = await client.callTool({ + name: 'furnish_room', + arguments: { + zoneId: room.zoneId, + roomType: 'bathroom', + doorWallIndex: 0, + }, + }) + expect(furnish.isError).toBeFalsy() + const parsed = JSON.parse((furnish.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.placed + parsed.skipped.length).toBeGreaterThan(0) + + const { findBlockedDoors } = await import('./door-clearance') + const blocked = findBlockedDoors({ nodes: Object.values(bridge.getNodes()) }) + expect(blocked).toEqual([]) + // If the heuristic wanted a fixture in the clear zone, it must be skipped explicitly. + for (const reason of parsed.skipped as string[]) { + expect(typeof reason).toBe('string') + } + }) + + test('furnish_room does not stack items on each other (overlap smart skip/nudge)', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + // Large bedroom so multiple placements exist; still must end with zero item–item overlaps. + const roomResult = await client.callTool({ + name: 'create_room', + arguments: { + levelId: level.id, + name: 'Bedroom', + polygon: [ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ], + }, + }) + const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text) + await client.callTool({ + name: 'add_door', + arguments: { wallId: room.wallIds[0], t: 0.5, width: 0.9 }, + }) + const furnish = await client.callTool({ + name: 'furnish_room', + arguments: { zoneId: room.zoneId, roomType: 'bedroom', doorWallIndex: 0 }, + }) + expect(furnish.isError).toBeFalsy() + const { findItemItemCollisions, findBlockedDoors } = await import('./layout-clearance') + const nodes = Object.values(bridge.getNodes()) + expect(findItemItemCollisions({ nodes })).toEqual([]) + expect(findBlockedDoors({ nodes })).toEqual([]) + }) + + test('furnish_room records door-clearance skips when a door sits on the furniture wall', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + // Large bedroom so bed placement is near the "back" wall (edge opposite doorWallIndex). + const roomResult = await client.callTool({ + name: 'create_room', + arguments: { + levelId: level.id, + name: 'Bedroom', + polygon: [ + [0, 0], + [5.5, 0], + [5.5, 4], + [0, 4], + ], + }, + }) + const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text) + // doorWallIndex default 0 → back wall is edge 2 (north). Put a door there so bed on back wall is blocked. + await client.callTool({ + name: 'add_door', + arguments: { wallId: room.wallIds[2], t: 0.5, width: 0.9 }, + }) + + const furnish = await client.callTool({ + name: 'furnish_room', + arguments: { + zoneId: room.zoneId, + roomType: 'bedroom', + doorWallIndex: 0, + }, + }) + expect(furnish.isError).toBeFalsy() + const parsed = JSON.parse((furnish.content as Array<{ type: string; text: string }>)[0]!.text) + const { findBlockedDoors } = await import('./door-clearance') + expect(findBlockedDoors({ nodes: Object.values(bridge.getNodes()) })).toEqual([]) + // Bed is placed against the back wall where the door is; expect clearance skip or empty bed. + const bedPlaced = Object.values(bridge.getNodes()).some( + (n) => n.type === 'item' && (n.name === 'Double Bed' || n.name === 'Single Bed'), + ) + const doorSkips = (parsed.skipped as string[]).filter((s) => + s.includes('blocks door clearance'), + ) + expect(bedPlaced || doorSkips.length > 0).toBe(true) + if (bedPlaced) { + expect(doorSkips.length).toBe(0) + } + }) }) diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 2a36fdc102..dad82c9733 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -12,15 +12,16 @@ import { import { z } from 'zod' import type { SceneOperations } from '../operations' import { findCatalogItem, searchCatalogItems } from './asset-catalog' +import { keepoutCoversPlanned, keepoutForPolygonEdge } from './door-clearance' import { ErrorCode, throwMcpError } from './errors' +import { polygonArea, polygonBounds, type Vec2, wallLength, wallLocalXFromT } from './geometry' import { - pointInBoundsWithPadding, - polygonArea, - polygonBounds, - type Vec2, - wallLength, - wallLocalXFromT, -} from './geometry' + collectDoorKeepouts, + collectOccupiedFootprints, + findValidPlacement, + itemPlanAabb, + type PlanAabb, +} from './layout-clearance' import { publishLiveSceneSnapshot } from './live-sync' import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema } from './schemas' @@ -127,8 +128,15 @@ export const furnishRoomOutput = { skipped: z.array(z.string()), } -type Footprint = { minX: number; maxX: number; minZ: number; maxZ: number } -type Placement = { assetId: string; x: number; z: number; rotationDeg?: number } +type Placement = { + assetId: string + x: number + z: number + rotationDeg?: number + /** Optional axes for smart re-place (along wall / into room). */ + along?: { x: number; z: number } + inward?: { x: number; z: number } +} function textResult>(payload: T) { return { @@ -220,23 +228,6 @@ function makeItemAsset(asset: AssetInput) { } } -function itemFootprint(asset: AssetInput, x: number, z: number, rotationDeg = 0): Footprint { - const [w = 1, , d = 1] = asset.dimensions ?? [1, 1, 1] - const rot = (rotationDeg * Math.PI) / 180 - const cos = Math.abs(Math.cos(rot)) - const sin = Math.abs(Math.sin(rot)) - const halfW = (w * cos + d * sin) / 2 - const halfD = (w * sin + d * cos) / 2 - return { minX: x - halfW, maxX: x + halfW, minZ: z - halfD, maxZ: z + halfD } -} - -function footprintsOverlap(a: Footprint, b: Footprint): boolean { - const gap = 0.08 - return ( - a.maxX - gap > b.minX && a.minX + gap < b.maxX && a.maxZ - gap > b.minZ && a.minZ + gap < b.maxZ - ) -} - function buildRoomPlacements( roomType: (typeof ROOM_TYPES)[number], polygon: Vec2[], @@ -293,11 +284,25 @@ function buildRoomPlacements( const addBack = (assetId: string, inset: number, lateral = 0, rotationDeg = facingRot) => { const [x, z] = backPos(inset, lateral) - placements.push({ assetId, x, z, rotationDeg }) + placements.push({ + assetId, + x, + z, + rotationDeg, + along: { x: ax, z: az }, + inward: { x: inX, z: inZ }, + }) } const addSide = (assetId: string, inset: number, lateral = 0, rotationDeg = sideRot) => { const [x, z] = sidePos(inset, lateral) - placements.push({ assetId, x, z, rotationDeg }) + placements.push({ + assetId, + x, + z, + rotationDeg, + along: { x: sax, z: saz }, + inward: { x: snX, z: snZ }, + }) } switch (roomType) { @@ -329,14 +334,23 @@ function buildRoomPlacements( addBack('sofa', 0.9) addBack('coffee-table', 2.1) addSide('livingroom-chair', 0.85, -sideAlongLen * 0.18) + // TV faces the sofa from the door wall: use door-wall axes so smart + // re-place nudges into the room (along wall / inward), not world X/Z. const doorIdx = doorWallIndex % n const doorStart = polygon[doorIdx]! const doorEnd = polygon[(doorIdx + 1) % n]! + const doorMidX = (doorStart[0] + doorEnd[0]) / 2 + const doorMidZ = (doorStart[1] + doorEnd[1]) / 2 + // Door-wall inward is opposite of "back wall" inward (into room from door). + const doorInX = -inX + const doorInZ = -inZ placements.push({ assetId: 'tv-stand', - x: (doorStart[0] + doorEnd[0]) / 2 - inX * 0.35, - z: (doorStart[1] + doorEnd[1]) / 2 - inZ * 0.35, + x: doorMidX + doorInX * 0.35, + z: doorMidZ + doorInZ * 0.35, rotationDeg: facingRot + 180, + along: { x: ax, z: az }, + inward: { x: doorInX, z: doorInZ }, }) break } @@ -554,7 +568,7 @@ export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): { title: 'Furnish room', description: - 'Place realistic furniture for a room type using levelId + polygon, or infer both from zoneId. Parent floor items to the level so they render and validate.', + 'Place furniture for a room type (levelId+polygon or zoneId). Skips or nudges poses that block door clear zones or overlap existing items (rotation-aware). Parent floor items to the level.', inputSchema: furnishRoomInput, outputSchema: furnishRoomOutput, }, @@ -562,40 +576,89 @@ export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): const room = inferRoomGeometry(bridge, levelId, polygon as Vec2[] | undefined, zoneId) assertLevel(bridge, room.levelId) const points = room.polygon - const { placements, bounds } = buildRoomPlacements(roomType, points, doorWallIndex ?? 0) - const footprints: Footprint[] = [] + const resolvedDoorWallIndex = doorWallIndex ?? 0 + const { placements, bounds } = buildRoomPlacements(roomType, points, resolvedDoorWallIndex) const skipped: string[] = [] const items: AnyNode[] = [] + const allNodes = Object.values(bridge.getNodes()) + // Doors on THIS level only (stacked floors must not interact in plan). + const existingKeepouts = collectDoorKeepouts(allNodes, { levelId: room.levelId }) + const doorKeepoutAabbs: PlanAabb[] = existingKeepouts.map((k) => k.aabb) + // Always protect this room's door-wall edge when no keep-out already covers it + // (other rooms may already have doors elsewhere on the same level). + const planned = keepoutForPolygonEdge(points, resolvedDoorWallIndex, { + t: 0.5, + width: 0.9, + }) + if ( + planned && + !doorKeepoutAabbs.some((existing) => keepoutCoversPlanned(existing, planned)) + ) { + doorKeepoutAabbs.push(planned) + } + + // Existing floor items on this level + footprints we place in this batch. + const occupied: PlanAabb[] = collectOccupiedFootprints(allNodes, { + levelId: room.levelId, + floorOnly: true, + }).map((f) => f.aabb) + + const roomBounds = { + minX: bounds.minX, + maxX: bounds.maxX, + minZ: bounds.minZ, + maxZ: bounds.maxZ, + } + for (const placement of placements) { const asset = findCatalogItem(placement.assetId) if (!asset) { skipped.push(`${placement.assetId}: asset not found`) continue } - const fp = itemFootprint(asset, placement.x, placement.z, placement.rotationDeg ?? 0) - const padding = 0.05 - if ( - !( - pointInBoundsWithPadding(fp.minX, fp.minZ, bounds, -padding) && - pointInBoundsWithPadding(fp.maxX, fp.maxZ, bounds, -padding) - ) - ) { - skipped.push(`${asset.id}: outside room bounds`) - continue + + const primary = { + x: placement.x, + z: placement.z, + rotationDeg: placement.rotationDeg ?? 0, } - if (footprints.some((existing) => footprintsOverlap(fp, existing))) { - skipped.push(`${asset.id}: overlaps another item`) + const resolved = findValidPlacement({ + primary, + dimensions: asset.dimensions, + doorKeepouts: doorKeepoutAabbs, + occupied, + roomBounds, + along: placement.along, + inward: placement.inward, + }) + + if (!resolved.candidate) { + const reason = + resolved.reason === 'blocks_door_clearance' + ? 'blocks door clearance' + : resolved.reason === 'outside_bounds' + ? 'outside room bounds' + : 'overlaps another item' + skipped.push(`${asset.id}: ${reason}`) continue } - footprints.push(fp) + + const { x, z, rotationDeg } = resolved.candidate + const rotRad = (rotationDeg * Math.PI) / 180 + const planAabb = itemPlanAabb([x, 0, z], asset.dimensions, rotRad) + occupied.push(planAabb) items.push( ItemNode.parse({ name: asset.name, - position: [placement.x, 0, placement.z], - rotation: [0, ((placement.rotationDeg ?? 0) * Math.PI) / 180, 0], + position: [x, 0, z], + rotation: [0, rotRad, 0], asset: makeItemAsset(asset), - metadata: { mcpTool: 'furnish_room', roomType }, + metadata: { + mcpTool: 'furnish_room', + roomType, + ...(x !== primary.x || z !== primary.z ? { placementAdjusted: true } : {}), + }, }), ) } diff --git a/packages/mcp/src/tools/scene-query.test.ts b/packages/mcp/src/tools/scene-query.test.ts index f84365cc3e..24af0348a2 100644 --- a/packages/mcp/src/tools/scene-query.test.ts +++ b/packages/mcp/src/tools/scene-query.test.ts @@ -5,6 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { CeilingNode, DoorNode, + ItemNode, LevelNode, RoofNode, SlabNode, @@ -83,6 +84,120 @@ describe('scene query tools', () => { expect(parsed.issues.join('\n')).toContain('walls but no zones') }) + test('verify_scene reports item–item footprint overlaps', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + bridge.createNode( + ItemNode.parse({ + name: 'Closet A', + position: [2, 0, 2], + rotation: [0, 0, 0], + asset: { + id: 'closet', + name: 'Closet', + category: 'furniture', + thumbnail: '/items/closet/thumbnail.webp', + src: '/items/closet/model.glb', + dimensions: [2, 2.5, 1], + }, + }), + level.id, + ) + bridge.createNode( + ItemNode.parse({ + name: 'Closet B', + position: [2.3, 0, 2.1], + rotation: [0, 0, 0], + asset: { + id: 'closet', + name: 'Closet', + category: 'furniture', + thumbnail: '/items/closet/thumbnail.webp', + src: '/items/closet/model.glb', + dimensions: [2, 2.5, 1], + }, + }), + level.id, + ) + + const result = await client.callTool({ name: 'verify_scene', arguments: {} }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.hasIssues).toBe(true) + expect(parsed.issues.join('\n')).toMatch(/overlap/i) + }) + + test('verify_scene reports furniture blocking door clearance', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const wall = WallNode.parse({ start: [0, 2.5], end: [5.5, 2.5], height: 2.5 }) + bridge.createNode(wall, level.id) + const door = DoorNode.parse({ + wallId: wall.id, + position: [1.375, 1.05, 0], + width: 0.8, + height: 2.1, + }) + bridge.createNode(door, wall.id) + // Toilet sitting on the south side of the door clear zone (same failure as master suite). + bridge.createNode( + ItemNode.parse({ + name: 'Toilet', + position: [0.7, 0, 1.95], + rotation: [0, 0, 0], + asset: { + id: 'toilet', + name: 'Toilet', + category: 'bathroom', + thumbnail: '/items/toilet/thumbnail.webp', + src: '/items/toilet/model.glb', + dimensions: [1, 0.9, 1], + }, + }), + level.id, + ) + // Also give a zone+slab so verify does not only complain about missing rooms. + bridge.createNode( + ZoneNode.parse({ + name: 'Bath', + polygon: [ + [0, 0], + [2.75, 0], + [2.75, 2.5], + [0, 2.5], + ], + }), + level.id, + ) + bridge.createNode( + SlabNode.parse({ + polygon: [ + [0, 0], + [2.75, 0], + [2.75, 2.5], + [0, 2.5], + ], + }), + level.id, + ) + bridge.createNode( + CeilingNode.parse({ + polygon: [ + [0, 0], + [2.75, 0], + [2.75, 2.5], + [0, 2.5], + ], + }), + level.id, + ) + + const result = await client.callTool({ name: 'verify_scene', arguments: {} }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.hasIssues).toBe(true) + expect(parsed.issues.join('\n')).toMatch(/blocked by item/i) + expect(parsed.issues.join('\n')).toContain(door.id) + }) + test('verify_scene separates occupied stories from dedicated roof levels', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index b3eac2f4a3..014f1ef002 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -18,6 +18,7 @@ import { type Vec2, wallLength, } from './geometry' +import { layoutIssuesFromScene } from './layout-clearance' import { NodeIdSchema } from './schemas' export const levelScopedInput = { @@ -831,6 +832,11 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): } } + // Door keep-outs + item–item footprint overlaps (rotation-aware). + for (const layoutIssue of layoutIssuesFromScene(Object.values(bridge.getNodes()))) { + issues.push(layoutIssue) + } + const payload = { ok: true, valid: validation.valid,