Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/mcp/docs/layout-clearance-error-log.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions packages/mcp/src/resources/agent-guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
20 changes: 20 additions & 0 deletions packages/mcp/src/tools/check-collisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
67 changes: 27 additions & 40 deletions packages/mcp/src/tools/check-collisions.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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) }],
Expand Down
Loading
Loading