From 3e2aee1136d337e00a636d6e20fcb09fb9ef4475 Mon Sep 17 00:00:00 2001 From: wolf10drc Date: Mon, 3 Aug 2026 02:56:31 +0400 Subject: [PATCH 1/6] mcp: layout clearance for doors and item overlaps Prevent furnish_room from placing furniture in door keep-outs or on other items. Add rotation-aware footprints, smart lateral/inset re-place, and report remaining issues from verify_scene and check_collisions. --- packages/mcp/src/resources/agent-guide.ts | 2 + packages/mcp/src/tools/check-collisions.ts | 62 ++--- packages/mcp/src/tools/door-clearance.test.ts | 125 ++++++++++ packages/mcp/src/tools/door-clearance.ts | 219 +++++++++++++++++ .../mcp/src/tools/layout-clearance.test.ts | 141 +++++++++++ packages/mcp/src/tools/layout-clearance.ts | 228 ++++++++++++++++++ packages/mcp/src/tools/room-tools.test.ts | 121 ++++++++++ packages/mcp/src/tools/room-tools.ts | 134 ++++++---- packages/mcp/src/tools/scene-query.test.ts | 115 +++++++++ packages/mcp/src/tools/scene-query.ts | 6 + 10 files changed, 1072 insertions(+), 81 deletions(-) create mode 100644 packages/mcp/src/tools/door-clearance.test.ts create mode 100644 packages/mcp/src/tools/door-clearance.ts create mode 100644 packages/mcp/src/tools/layout-clearance.test.ts create mode 100644 packages/mcp/src/tools/layout-clearance.ts 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.ts b/packages/mcp/src/tools/check-collisions.ts index f29a804340..665dc54920 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,38 @@ 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)) } + const found = findItemItemCollisions({ + nodes: scoped, + levelId: levelId as string | undefined, + }) + 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..471baf00ca --- /dev/null +++ b/packages/mcp/src/tools/door-clearance.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test' +import { + collectDoorKeepouts, + doorKeepoutFromWall, + findBlockedDoors, + itemBlocksDoorKeepout, + itemPlanAabb, + keepoutForPolygonEdge, +} from './door-clearance' +import type { AnyNode } from '@pascal-app/core/schema' + +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 item( + id: string, + position: [number, number, number], + dimensions: [number, number, number], + name = 'Item', +) { + return { + object: 'node' as const, + id, + type: 'item' as const, + parentId: 'level_1', + visible: true, + metadata: {}, + name, + position, + rotation: [0, 0, 0] as [number, number, number], + asset: { + id: 'x', + name, + category: 'furniture', + thumbnail: '', + src: '', + 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) + }) +}) diff --git a/packages/mcp/src/tools/door-clearance.ts b/packages/mcp/src/tools/door-clearance.ts new file mode 100644 index 0000000000..97002af533 --- /dev/null +++ b/packages/mcp/src/tools/door-clearance.ts @@ -0,0 +1,219 @@ +/** + * 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). + */ + +import type { AnyNode, WallNode } from '@pascal-app/core/schema' +import { wallLength, type Vec2 } from './geometry' + +export type PlanAabb = { + minX: number + maxX: number + minZ: number + maxZ: number +} + +export type DoorKeepout = { + doorId: string + wallId: string + /** World-space AABB on both sides of the wall opening. */ + aabb: PlanAabb + width: number + localX: 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 + +function aabbsOverlap(a: PlanAabb, b: PlanAabb, gap = 0): boolean { + return ( + a.maxX - gap > b.minX && + a.minX + gap < b.maxX && + a.maxZ - gap > b.minZ && + a.minZ + gap < b.maxZ + ) +} + +/** + * Axis-aligned item footprint in plan (x/z), matching furnish_room rotation handling. + */ +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, + } +} + +/** + * 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: Pick, + door: Pick & { type?: string }, + options?: { clearDepth?: number; sidePad?: number }, +): 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 + // Perpendicular in plan (rotate tangent 90°): (dx,dz) -> (-dz, dx) + 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, + 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 }, +): 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 || wall.type !== 'wall') continue + const keepout = doorKeepoutFromWall(wall, node, options) + 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 + itemName?: string + message: string +} + +export function findBlockedDoors(args: { + nodes: Iterable + clearDepth?: number + sidePad?: number +}): BlockedDoorIssue[] { + const nodes = [...args.nodes] + const keepouts = collectDoorKeepouts(nodes, { + clearDepth: args.clearDepth, + sidePad: args.sidePad, + }) + if (keepouts.length === 0) return [] + + const issues: BlockedDoorIssue[] = [] + for (const node of nodes) { + if (node.type !== 'item') continue + const dims = node.asset?.dimensions as number[] | undefined + const rotY = Array.isArray(node.rotation) ? (node.rotation[1] ?? 0) : 0 + const aabb = itemPlanAabb(node.position as number[], dims, rotY) + for (const keepout of keepouts) { + 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, + 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 +} + +export function aabbFromPlan(a: PlanAabb): PlanAabb { + return a +} + +export { aabbsOverlap } 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..cfd2b7acf7 --- /dev/null +++ b/packages/mcp/src/tools/layout-clearance.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'bun:test' +import { + classifyPlacement, + findItemItemCollisions, + findValidPlacement, + generatePlacementCandidates, + itemPlanAabb, + layoutIssuesFromScene, +} from './layout-clearance' +import type { AnyNode } from '@pascal-app/core/schema' + +function item( + id: string, + position: [number, number, number], + dimensions: [number, number, number], + name = 'Item', + rotY = 0, +) { + return { + object: 'node' as const, + id, + type: 'item' as const, + parentId: 'level_1', + visible: true, + metadata: {}, + name, + position, + rotation: [0, rotY, 0] as [number, number, number], + asset: { + id: 'x', + name, + category: 'furniture', + thumbnail: '', + src: '', + 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 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([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) + }) +}) diff --git a/packages/mcp/src/tools/layout-clearance.ts b/packages/mcp/src/tools/layout-clearance.ts new file mode 100644 index 0000000000..6d7e22f3f3 --- /dev/null +++ b/packages/mcp/src/tools/layout-clearance.ts @@ -0,0 +1,228 @@ +/** + * Shared plan-layout clearance for MCP tools. + * + * - Door keep-outs (re-exports / wraps door-clearance) + * - Item–item AABB overlap (rotation-aware) + * - Placement candidate search when primary pose is blocked + * + * Used by furnish_room, verify_scene, and check_collisions. + */ + +import type { AnyNode } from '@pascal-app/core/schema' +import { + aabbsOverlap, + collectDoorKeepouts, + findBlockedDoors, + itemPlanAabb, + type PlanAabb, +} from './door-clearance' + +export { + aabbsOverlap, + collectDoorKeepouts, + findBlockedDoors, + itemPlanAabb, + type PlanAabb, +} from './door-clearance' + +/** Minimum gap (m) between item footprints (soft buffer). */ +export const DEFAULT_ITEM_GAP = 0.08 + +export type OccupiedFootprint = { + id: string + name?: string + aabb: PlanAabb +} + +export type ItemCollision = { + aId: string + bId: string + aName?: string + bName?: string + kind: 'item-aabb' + message: string +} + +export function nodeItemAabb(node: AnyNode): PlanAabb | null { + if (node.type !== 'item') return null + const dims = node.asset?.dimensions as number[] | undefined + const rotY = Array.isArray(node.rotation) ? (node.rotation[1] ?? 0) : 0 + const pos = node.position as number[] + return itemPlanAabb(pos, dims, rotY) +} + +export function collectOccupiedFootprints( + nodes: Iterable, + options?: { levelId?: string; excludeIds?: Set; floorOnly?: boolean }, +): OccupiedFootprint[] { + const out: OccupiedFootprint[] = [] + for (const node of nodes) { + 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 + } + if (options?.levelId && node.parentId && node.parentId !== options.levelId) { + // Floor packing only uses level-parented items (not wall children). + if (options.floorOnly) 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, + 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 (!aabbsOverlap(a.aabb, b.aabb, gap)) continue + collisions.push({ + aId: a.id, + bId: b.id, + aName: a.name, + bName: b.name, + 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' +} + +/** + * Generate alternate poses around a primary placement (lateral + inset nudges). + * Used when the first pose hits a door or another item. + */ +export function generatePlacementCandidates( + primary: PlacementCandidate, + options?: { + lateralsM?: number[] + insetsM?: number[] + /** Unit vector "into room" for inset (away from back wall). */ + inward?: { x: number; z: number } + /** Unit vector along the furniture wall. */ + 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, + }) + let lastReason: PlacementRejectReason = 'overlaps_item' + for (const c of candidates) { + 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 } + lastReason = reason + } + return { candidate: null, reason: lastReason } +} + +export function layoutIssuesFromScene(nodes: Iterable): string[] { + const list = [...nodes] + const issues: string[] = [] + for (const b of findBlockedDoors({ nodes: list })) { + issues.push(b.message) + } + for (const c of findItemItemCollisions({ nodes: list })) { + issues.push(c.message) + } + return issues +} diff --git a/packages/mcp/src/tools/room-tools.test.ts b/packages/mcp/src/tools/room-tools.test.ts index 05dc5f709e..09da63c750 100644 --- a/packages/mcp/src/tools/room-tools.test.ts +++ b/packages/mcp/src/tools/room-tools.test.ts @@ -223,4 +223,125 @@ 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..2f36166082 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -13,8 +13,15 @@ import { z } from 'zod' import type { SceneOperations } from '../operations' import { findCatalogItem, searchCatalogItems } from './asset-catalog' import { ErrorCode, throwMcpError } from './errors' +import { keepoutForPolygonEdge } from './door-clearance' +import { + collectDoorKeepouts, + collectOccupiedFootprints, + findValidPlacement, + itemPlanAabb, + type PlanAabb, +} from './layout-clearance' import { - pointInBoundsWithPadding, polygonArea, polygonBounds, type Vec2, @@ -127,8 +134,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 +234,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 +290,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) { @@ -554,7 +565,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 +573,81 @@ 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()) + // Prefer real doors already on the level; fall back to a planned keep-out on the door wall edge. + const existingKeepouts = collectDoorKeepouts(allNodes) + const doorKeepoutAabbs: PlanAabb[] = existingKeepouts.map((k) => k.aabb) + if (doorKeepoutAabbs.length === 0) { + const planned = keepoutForPolygonEdge(points, resolvedDoorWallIndex, { t: 0.5, width: 0.9 }) + if (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..4e3ced53ec 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -10,6 +10,7 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { computeWallSlabSupport } from '@pascal-app/core/spatial-grid' import { z } from 'zod' import type { SceneOperations } from '../operations' +import { layoutIssuesFromScene } from './layout-clearance' import { distance2D, pointInPolygon, @@ -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, From 9f1db7d2bb84d2f2bee42452fbc481d2a55d7951 Mon Sep 17 00:00:00 2001 From: wolf10drc Date: Mon, 3 Aug 2026 03:29:06 +0400 Subject: [PATCH 2/6] mcp: fix layout clearance review findings and add error log Scope doors/items by level, keep planned room entrances when other doors exist, treat gap as minimum free space, honor item scale, and document pitfalls in docs/layout-clearance-error-log.md. --- .../mcp/docs/layout-clearance-error-log.md | 58 ++++++++++++ packages/mcp/src/tools/door-clearance.test.ts | 77 ++++++++++++++- packages/mcp/src/tools/door-clearance.ts | 94 ++++++++++++++++--- .../mcp/src/tools/layout-clearance.test.ts | 44 ++++++++- packages/mcp/src/tools/layout-clearance.ts | 79 +++++++++++----- packages/mcp/src/tools/room-tools.ts | 20 ++-- 6 files changed, 320 insertions(+), 52 deletions(-) create mode 100644 packages/mcp/docs/layout-clearance-error-log.md 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..5de267f055 --- /dev/null +++ b/packages/mcp/docs/layout-clearance-error-log.md @@ -0,0 +1,58 @@ +# 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. + +## 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/tools/door-clearance.test.ts b/packages/mcp/src/tools/door-clearance.test.ts index 471baf00ca..b099623a60 100644 --- a/packages/mcp/src/tools/door-clearance.test.ts +++ b/packages/mcp/src/tools/door-clearance.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from 'bun:test' import { + aabbsOverlap, collectDoorKeepouts, doorKeepoutFromWall, findBlockedDoors, itemBlocksDoorKeepout, + itemNodePlanAabb, itemPlanAabb, + keepoutCoversPlanned, keepoutForPolygonEdge, } from './door-clearance' import type { AnyNode } from '@pascal-app/core/schema' @@ -40,28 +43,43 @@ function door(id: string, wallId: string, localX: number, width = 0.8) { } } +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: 'level_1', + parentId, visible: true, metadata: {}, name, position, rotation: [0, 0, 0] as [number, number, number], + scale, asset: { id: 'x', name, category: 'furniture', thumbnail: '', - src: '', + src: 'asset://x', dimensions, }, } @@ -122,4 +140,59 @@ describe('door-clearance', () => { 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) + }) }) diff --git a/packages/mcp/src/tools/door-clearance.ts b/packages/mcp/src/tools/door-clearance.ts index 97002af533..a630579057 100644 --- a/packages/mcp/src/tools/door-clearance.ts +++ b/packages/mcp/src/tools/door-clearance.ts @@ -3,9 +3,12 @@ * * 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, WallNode } from '@pascal-app/core/schema' +import { getScaledDimensions } from '@pascal-app/core/schema' import { wallLength, type Vec2 } from './geometry' export type PlanAabb = { @@ -18,6 +21,7 @@ export type PlanAabb = { export type DoorKeepout = { doorId: string wallId: string + levelId: string | null /** World-space AABB on both sides of the wall opening. */ aabb: PlanAabb width: number @@ -29,17 +33,44 @@ 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 -function aabbsOverlap(a: PlanAabb, b: PlanAabb, gap = 0): boolean { +/** + * 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 - gap > b.minX && - a.minX + gap < b.maxX && - a.maxZ - gap > b.minZ && - a.minZ + gap < b.maxZ + a.maxX + g > b.minX && + a.minX - g < b.maxX && + a.maxZ + g > b.minZ && + a.minZ - g < b.maxZ ) } /** - * Axis-aligned item footprint in plan (x/z), matching furnish_room rotation handling. + * 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[], @@ -61,6 +92,14 @@ export function itemPlanAabb( } } +/** 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. @@ -68,7 +107,7 @@ export function itemPlanAabb( export function doorKeepoutFromWall( wall: Pick, door: Pick & { type?: string }, - options?: { clearDepth?: number; sidePad?: number }, + 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 @@ -82,7 +121,6 @@ export function doorKeepoutFromWall( const [ex, ez] = wall.end const dx = (ex - sx) / length const dz = (ez - sz) / length - // Perpendicular in plan (rotate tangent 90°): (dx,dz) -> (-dz, dx) const nx = -dz const nz = dx @@ -101,6 +139,7 @@ export function doorKeepoutFromWall( return { doorId: door.id, wallId: wall.id, + levelId: options?.levelId ?? null, width, localX, aabb: { @@ -114,7 +153,7 @@ export function doorKeepoutFromWall( export function collectDoorKeepouts( nodes: Iterable, - options?: { clearDepth?: number; sidePad?: number }, + options?: { clearDepth?: number; sidePad?: number; levelId?: string }, ): DoorKeepout[] { const byId = new Map() for (const node of nodes) byId.set(node.id, node) @@ -126,7 +165,13 @@ export function collectDoorKeepouts( if (!wallId) continue const wall = byId.get(wallId) if (!wall || wall.type !== 'wall') continue - const keepout = doorKeepoutFromWall(wall, node, options) + 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 @@ -140,6 +185,7 @@ export type BlockedDoorIssue = { doorId: string wallId: string itemId: string + levelId?: string | null itemName?: string message: string } @@ -148,27 +194,38 @@ 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 dims = node.asset?.dimensions as number[] | undefined - const rotY = Array.isArray(node.rotation) ? (node.rotation[1] ?? 0) : 0 - const aabb = itemPlanAabb(node.position as number[], dims, rotY) + 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})`, }) @@ -212,8 +269,15 @@ export function keepoutForPolygonEdge( 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). + */ +export function keepoutCoversPlanned(existing: PlanAabb, planned: PlanAabb): boolean { + // Centers roughly align / boxes overlap substantially. + return aabbsOverlap(existing, planned, 0) +} + export function aabbFromPlan(a: PlanAabb): PlanAabb { return a } - -export { aabbsOverlap } diff --git a/packages/mcp/src/tools/layout-clearance.test.ts b/packages/mcp/src/tools/layout-clearance.test.ts index cfd2b7acf7..6da2ec640d 100644 --- a/packages/mcp/src/tools/layout-clearance.test.ts +++ b/packages/mcp/src/tools/layout-clearance.test.ts @@ -15,23 +15,26 @@ function item( 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: 'level_1', + parentId, visible: true, metadata: {}, name, position, rotation: [0, rotY, 0] as [number, number, number], + scale, asset: { id: 'x', name, category: 'furniture', thumbnail: '', - src: '', + src: 'asset://x', dimensions, }, } @@ -106,6 +109,15 @@ describe('layout-clearance', () => { }) 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', @@ -134,8 +146,34 @@ describe('layout-clearance', () => { 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([wall, door, toilet, a, b] as unknown as AnyNode[]) + 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) + }) }) diff --git a/packages/mcp/src/tools/layout-clearance.ts b/packages/mcp/src/tools/layout-clearance.ts index 6d7e22f3f3..b98f6fe0ee 100644 --- a/packages/mcp/src/tools/layout-clearance.ts +++ b/packages/mcp/src/tools/layout-clearance.ts @@ -1,11 +1,11 @@ /** * Shared plan-layout clearance for MCP tools. * - * - Door keep-outs (re-exports / wraps door-clearance) - * - Item–item AABB overlap (rotation-aware) + * - Door keep-outs (level-scoped) + * - Item–item AABB overlap (rotation + scale aware) * - Placement candidate search when primary pose is blocked * - * Used by furnish_room, verify_scene, and check_collisions. + * See docs/layout-clearance-error-log.md for regression checklist. */ import type { AnyNode } from '@pascal-app/core/schema' @@ -13,7 +13,9 @@ import { aabbsOverlap, collectDoorKeepouts, findBlockedDoors, + itemNodePlanAabb, itemPlanAabb, + resolveNodeLevelId, type PlanAabb, } from './door-clearance' @@ -21,16 +23,19 @@ export { aabbsOverlap, collectDoorKeepouts, findBlockedDoors, + itemNodePlanAabb, itemPlanAabb, + resolveNodeLevelId, type PlanAabb, } from './door-clearance' -/** Minimum gap (m) between item footprints (soft buffer). */ +/** 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 } @@ -39,24 +44,23 @@ export type ItemCollision = { bId: string aName?: string bName?: string + levelId?: string | null kind: 'item-aabb' message: string } export function nodeItemAabb(node: AnyNode): PlanAabb | null { - if (node.type !== 'item') return null - const dims = node.asset?.dimensions as number[] | undefined - const rotY = Array.isArray(node.rotation) ? (node.rotation[1] ?? 0) : 0 - const pos = node.position as number[] - return itemPlanAabb(pos, dims, rotY) + 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] as const)) const out: OccupiedFootprint[] = [] - for (const node of nodes) { + for (const node of list) { if (node.type !== 'item') continue if (options?.excludeIds?.has(node.id)) continue const attach = node.asset?.attachTo @@ -66,9 +70,13 @@ export function collectOccupiedFootprints( ) { continue } - if (options?.levelId && node.parentId && node.parentId !== options.levelId) { - // Floor packing only uses level-parented items (not wall children). - if (options.floorOnly) 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 @@ -76,6 +84,7 @@ export function collectOccupiedFootprints( out.push({ id: node.id, name: typeof name === 'string' ? name : undefined, + levelId, aabb, }) } @@ -94,12 +103,14 @@ export function findItemItemCollisions(args: { 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})`, }) @@ -152,18 +163,12 @@ export function classifyPlacement(args: { return 'ok' } -/** - * Generate alternate poses around a primary placement (lateral + inset nudges). - * Used when the first pose hits a door or another item. - */ export function generatePlacementCandidates( primary: PlacementCandidate, options?: { lateralsM?: number[] insetsM?: number[] - /** Unit vector "into room" for inset (away from back wall). */ inward?: { x: number; z: number } - /** Unit vector along the furniture wall. */ along?: { x: number; z: number } }, ): PlacementCandidate[] { @@ -194,7 +199,9 @@ export function findValidPlacement(args: { 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 } { +}): + | { candidate: PlacementCandidate; reason: PlacementRejectReason } + | { candidate: null; reason: PlacementRejectReason } { const candidates = generatePlacementCandidates(args.primary, { along: args.along, inward: args.inward, @@ -215,14 +222,34 @@ export function findValidPlacement(args: { return { candidate: null, reason: lastReason } } +/** + * Collect layout issues, scoped per level so stacked floors do not false-positive. + */ export function layoutIssuesFromScene(nodes: Iterable): string[] { const list = [...nodes] - const issues: string[] = [] - for (const b of findBlockedDoors({ nodes: list })) { - issues.push(b.message) + 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) } - for (const c of findItemItemCollisions({ nodes: list })) { - issues.push(c.message) + // 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) } - return issues + + 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.ts b/packages/mcp/src/tools/room-tools.ts index 2f36166082..2d96984333 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -13,7 +13,7 @@ import { z } from 'zod' import type { SceneOperations } from '../operations' import { findCatalogItem, searchCatalogItems } from './asset-catalog' import { ErrorCode, throwMcpError } from './errors' -import { keepoutForPolygonEdge } from './door-clearance' +import { keepoutCoversPlanned, keepoutForPolygonEdge } from './door-clearance' import { collectDoorKeepouts, collectOccupiedFootprints, @@ -579,12 +579,20 @@ export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): const items: AnyNode[] = [] const allNodes = Object.values(bridge.getNodes()) - // Prefer real doors already on the level; fall back to a planned keep-out on the door wall edge. - const existingKeepouts = collectDoorKeepouts(allNodes) + // 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) - if (doorKeepoutAabbs.length === 0) { - const planned = keepoutForPolygonEdge(points, resolvedDoorWallIndex, { t: 0.5, width: 0.9 }) - if (planned) doorKeepoutAabbs.push(planned) + // 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. From b6622394bc5a3fcac69f8094f9efb70f973b1d83 Mon Sep 17 00:00:00 2001 From: wolf10drc Date: Mon, 3 Aug 2026 15:41:28 +0400 Subject: [PATCH 3/6] mcp: tighten planned keep-out coverage and skip reasons Require planned center + 50% area overlap before treating a door keep-out as covering a room entrance. Report primary pose reject reasons so furnish skips cite door/overlap instead of last-nudge outside_bounds. --- .../mcp/docs/layout-clearance-error-log.md | 15 ++++++++++ packages/mcp/src/tools/door-clearance.test.ts | 21 ++++++++++++++ packages/mcp/src/tools/door-clearance.ts | 28 +++++++++++++++++-- .../mcp/src/tools/layout-clearance.test.ts | 15 ++++++++++ packages/mcp/src/tools/layout-clearance.ts | 24 +++++++++++++--- 5 files changed, 97 insertions(+), 6 deletions(-) diff --git a/packages/mcp/docs/layout-clearance-error-log.md b/packages/mcp/docs/layout-clearance-error-log.md index 5de267f055..833e9deed3 100644 --- a/packages/mcp/docs/layout-clearance-error-log.md +++ b/packages/mcp/docs/layout-clearance-error-log.md @@ -44,6 +44,21 @@ Agents should treat skip reasons and verify issues as actionable, not ignore the **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 diff --git a/packages/mcp/src/tools/door-clearance.test.ts b/packages/mcp/src/tools/door-clearance.test.ts index b099623a60..dc9e6b6145 100644 --- a/packages/mcp/src/tools/door-clearance.test.ts +++ b/packages/mcp/src/tools/door-clearance.test.ts @@ -195,4 +195,25 @@ describe('door-clearance', () => { 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 index a630579057..9a1a040cdc 100644 --- a/packages/mcp/src/tools/door-clearance.ts +++ b/packages/mcp/src/tools/door-clearance.ts @@ -272,10 +272,34 @@ export function keepoutForPolygonEdge( /** * 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 { - // Centers roughly align / boxes overlap substantially. - return aabbsOverlap(existing, planned, 0) + 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 { diff --git a/packages/mcp/src/tools/layout-clearance.test.ts b/packages/mcp/src/tools/layout-clearance.test.ts index 6da2ec640d..bf0a45bd19 100644 --- a/packages/mcp/src/tools/layout-clearance.test.ts +++ b/packages/mcp/src/tools/layout-clearance.test.ts @@ -176,4 +176,19 @@ describe('layout-clearance', () => { 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 index b98f6fe0ee..7f9fa86aa1 100644 --- a/packages/mcp/src/tools/layout-clearance.ts +++ b/packages/mcp/src/tools/layout-clearance.ts @@ -206,8 +206,19 @@ export function findValidPlacement(args: { along: args.along, inward: args.inward, }) - let lastReason: PlacementRejectReason = 'overlaps_item' - for (const c of candidates) { + + // 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({ @@ -217,9 +228,14 @@ export function findValidPlacement(args: { roomBounds: args.roomBounds, }) if (reason === 'ok') return { candidate: c, reason } - lastReason = 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: lastReason } + return { candidate: null, reason: primaryReason ?? bestFailReason } } /** From d623d923f4fa998ee027ad50ae47f2fbab7f2ce0 Mon Sep 17 00:00:00 2001 From: wolf10drc Date: Mon, 3 Aug 2026 16:02:17 +0400 Subject: [PATCH 4/6] mcp: give living tv-stand door-wall nudge axes TV sits on the door wall and hits keep-outs; pass along/inward so findValidPlacement can inset into the room instead of world-axis nudges. --- packages/mcp/src/tools/room-tools.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 2d96984333..0e4c63f8cc 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -340,14 +340,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 } From 85a04ae6ccb2f781fd300ae1a73d5e13cb060ad5 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 4 Aug 2026 17:07:37 -0400 Subject: [PATCH 5/6] mcp: fix the quality gate on layout clearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate failures, the second hidden behind the first: - Biome import ordering across 7 files (`bun run check:fix`), plus the `useOptionalChain` warning in `collectDoorKeepouts`. - Three type errors in the clearance helpers, which CI never reported because the lint step failed first and short-circuited the job. The type errors were both real signature problems, not noise: - `Pick` cannot work — `position` and `width` exist on only some members of the `AnyNode` union, so `Pick` rejects the keys outright. Replaced with an explicit `DoorOpeningLike`. - `Pick` brands the id as `wall_${string}`, but `keepoutForPolygonEdge` intentionally passes a synthetic `edge-N` segment for room edges that have no wall node yet. Replaced with `WallSegmentLike`, which is the shape these helpers actually accept. - `new Map(list.map((n) => [n.id, n] as const))` infers the branded `AnyNodeId` key type, so `resolveNodeLevelId(node.id, byId)` failed on a plain `string`. Annotated as `Map`. Gates: `bun run check` clean, `check-types` 9/9, core 917 / mcp 321 / nodes 939 tests pass, 0 fail. Co-Authored-By: Claude Opus 5 --- packages/mcp/src/tools/door-clearance.test.ts | 2 +- packages/mcp/src/tools/door-clearance.ts | 51 ++++++++++--------- .../mcp/src/tools/layout-clearance.test.ts | 11 +--- packages/mcp/src/tools/layout-clearance.ts | 7 ++- packages/mcp/src/tools/room-tools.test.ts | 4 +- packages/mcp/src/tools/room-tools.ts | 10 +--- packages/mcp/src/tools/scene-query.ts | 2 +- 7 files changed, 40 insertions(+), 47 deletions(-) diff --git a/packages/mcp/src/tools/door-clearance.test.ts b/packages/mcp/src/tools/door-clearance.test.ts index dc9e6b6145..0c1ec3ba21 100644 --- a/packages/mcp/src/tools/door-clearance.test.ts +++ b/packages/mcp/src/tools/door-clearance.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core/schema' import { aabbsOverlap, collectDoorKeepouts, @@ -10,7 +11,6 @@ import { keepoutCoversPlanned, keepoutForPolygonEdge, } from './door-clearance' -import type { AnyNode } from '@pascal-app/core/schema' function wall(id: string, start: [number, number], end: [number, number]) { return { diff --git a/packages/mcp/src/tools/door-clearance.ts b/packages/mcp/src/tools/door-clearance.ts index 9a1a040cdc..d138666bf9 100644 --- a/packages/mcp/src/tools/door-clearance.ts +++ b/packages/mcp/src/tools/door-clearance.ts @@ -7,9 +7,9 @@ * See docs/layout-clearance-error-log.md for pitfalls (levels, gap sign, scale). */ -import type { AnyNode, WallNode } from '@pascal-app/core/schema' +import type { AnyNode } from '@pascal-app/core/schema' import { getScaledDimensions } from '@pascal-app/core/schema' -import { wallLength, type Vec2 } from './geometry' +import { type Vec2, wallLength } from './geometry' export type PlanAabb = { minX: number @@ -28,6 +28,25 @@ export type DoorKeepout = { 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. */ @@ -40,21 +59,13 @@ export const DEFAULT_DOOR_SIDE_PAD = 0.05 */ 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 - ) + 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 { +export function resolveNodeLevelId(nodeId: string, byId: Map): string | null { let current: AnyNode | undefined = byId.get(nodeId) const seen = new Set() while (current) { @@ -105,8 +116,8 @@ export function itemNodePlanAabb(node: AnyNode): PlanAabb | null { * on both faces so either swing side is protected. */ export function doorKeepoutFromWall( - wall: Pick, - door: Pick & { type?: string }, + wall: WallSegmentLike, + door: DoorOpeningLike, options?: { clearDepth?: number; sidePad?: number; levelId?: string | null }, ): DoorKeepout | null { const clearDepth = options?.clearDepth ?? DEFAULT_DOOR_CLEAR_DEPTH @@ -164,7 +175,7 @@ export function collectDoorKeepouts( const wallId = node.wallId ?? node.parentId if (!wallId) continue const wall = byId.get(wallId) - if (!wall || wall.type !== 'wall') continue + 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, { @@ -282,10 +293,7 @@ export function keepoutCoversPlanned(existing: PlanAabb, planned: PlanAabb): boo 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 + 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). @@ -295,10 +303,7 @@ export function keepoutCoversPlanned(existing: PlanAabb, planned: PlanAabb): boo 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), - ) + const plannedArea = Math.max(1e-9, (planned.maxX - planned.minX) * (planned.maxZ - planned.minZ)) return inter / plannedArea >= 0.5 } diff --git a/packages/mcp/src/tools/layout-clearance.test.ts b/packages/mcp/src/tools/layout-clearance.test.ts index bf0a45bd19..21762d9d60 100644 --- a/packages/mcp/src/tools/layout-clearance.test.ts +++ b/packages/mcp/src/tools/layout-clearance.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core/schema' import { classifyPlacement, findItemItemCollisions, @@ -7,7 +8,6 @@ import { itemPlanAabb, layoutIssuesFromScene, } from './layout-clearance' -import type { AnyNode } from '@pascal-app/core/schema' function item( id: string, @@ -146,14 +146,7 @@ describe('layout-clearance', () => { 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[]) + 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) }) diff --git a/packages/mcp/src/tools/layout-clearance.ts b/packages/mcp/src/tools/layout-clearance.ts index 7f9fa86aa1..9e1c8d42dc 100644 --- a/packages/mcp/src/tools/layout-clearance.ts +++ b/packages/mcp/src/tools/layout-clearance.ts @@ -11,12 +11,11 @@ import type { AnyNode } from '@pascal-app/core/schema' import { aabbsOverlap, - collectDoorKeepouts, findBlockedDoors, itemNodePlanAabb, itemPlanAabb, - resolveNodeLevelId, type PlanAabb, + resolveNodeLevelId, } from './door-clearance' export { @@ -25,8 +24,8 @@ export { findBlockedDoors, itemNodePlanAabb, itemPlanAabb, - resolveNodeLevelId, type PlanAabb, + resolveNodeLevelId, } from './door-clearance' /** Minimum free space (m) required between item footprints. */ @@ -58,7 +57,7 @@ export function collectOccupiedFootprints( options?: { levelId?: string; excludeIds?: Set; floorOnly?: boolean }, ): OccupiedFootprint[] { const list = [...nodes] - const byId = new Map(list.map((n) => [n.id, n] as const)) + const byId = new Map(list.map((n) => [n.id, n])) const out: OccupiedFootprint[] = [] for (const node of list) { if (node.type !== 'item') continue diff --git a/packages/mcp/src/tools/room-tools.test.ts b/packages/mcp/src/tools/room-tools.test.ts index 09da63c750..56f07bf650 100644 --- a/packages/mcp/src/tools/room-tools.test.ts +++ b/packages/mcp/src/tools/room-tools.test.ts @@ -338,7 +338,9 @@ describe('room tools', () => { 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')) + 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 0e4c63f8cc..dad82c9733 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -12,8 +12,9 @@ import { import { z } from 'zod' import type { SceneOperations } from '../operations' import { findCatalogItem, searchCatalogItems } from './asset-catalog' -import { ErrorCode, throwMcpError } from './errors' import { keepoutCoversPlanned, keepoutForPolygonEdge } from './door-clearance' +import { ErrorCode, throwMcpError } from './errors' +import { polygonArea, polygonBounds, type Vec2, wallLength, wallLocalXFromT } from './geometry' import { collectDoorKeepouts, collectOccupiedFootprints, @@ -21,13 +22,6 @@ import { itemPlanAabb, type PlanAabb, } from './layout-clearance' -import { - polygonArea, - polygonBounds, - type Vec2, - wallLength, - wallLocalXFromT, -} from './geometry' import { publishLiveSceneSnapshot } from './live-sync' import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema } from './schemas' diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 4e3ced53ec..014f1ef002 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -10,7 +10,6 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { computeWallSlabSupport } from '@pascal-app/core/spatial-grid' import { z } from 'zod' import type { SceneOperations } from '../operations' -import { layoutIssuesFromScene } from './layout-clearance' import { distance2D, pointInPolygon, @@ -19,6 +18,7 @@ import { type Vec2, wallLength, } from './geometry' +import { layoutIssuesFromScene } from './layout-clearance' import { NodeIdSchema } from './schemas' export const levelScopedInput = { From d87f1fd194a725616891c023d9f56011887cfa5b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 4 Aug 2026 17:10:14 -0400 Subject: [PATCH 6/6] mcp: keep check_collisions reporting real overlap, not proximity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidating check_collisions onto findItemItemCollisions also handed it that helper's DEFAULT_ITEM_GAP of 8cm, which is not the same question. The gap exists for furnish_room: when *placing* a new item you want breathing room around it, so "is this spot free" means "free plus 8cm." check_collisions answers a different question about an existing scene — "do these footprints actually intersect" — and an 8cm gap makes it report furniture merely standing next to other furniture as a collision. Two 1m items 7cm apart came back as overlapping. Passes gap: 0 explicitly and adds the tight regression test the suite was missing; the existing "do not overlap" case placed its items 20m apart, so nothing caught the change. Co-Authored-By: Claude Opus 5 --- .../mcp/src/tools/check-collisions.test.ts | 20 +++++++++++++++++++ packages/mcp/src/tools/check-collisions.ts | 5 +++++ 2 files changed, 25 insertions(+) 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 665dc54920..d4d4ce14a1 100644 --- a/packages/mcp/src/tools/check-collisions.ts +++ b/packages/mcp/src/tools/check-collisions.ts @@ -41,9 +41,14 @@ export function registerCheckCollisions(server: McpServer, bridge: SceneOperatio 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,