Skip to content

mcp: layout clearance for doors and item overlaps - #569

Merged
Aymericr merged 6 commits into
pascalorg:mainfrom
ActArtech:feat/mcp-layout-clearance
Aug 4, 2026
Merged

mcp: layout clearance for doors and item overlaps#569
Aymericr merged 6 commits into
pascalorg:mainfrom
ActArtech:feat/mcp-layout-clearance

Conversation

@ActArtech

@ActArtech ActArtech commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Makes MCP layout tools respect door clear zones and item footprints, so agents stop producing valid-but-blocked rooms.

  • furnish_room skips or nudges poses that block door keep-outs or overlap other floor items (rotation-aware AABBs, small lateral/inset search)
  • verify_scene reports remaining door blocks and item–item overlaps
  • check_collisions uses the same rotation-aware footprints
  • Short agent-guide notes for the recommended room workflow

Out of scope: wall-facing / corner placement modes, massing/space-program, editor UI.

How to test

  1. From packages/mcp:
    bun test src/tools/door-clearance.test.ts src/tools/layout-clearance.test.ts src/tools/room-tools.test.ts src/tools/scene-query.test.ts src/tools/check-collisions.test.ts
  2. Expect all tests green (door keep-outs, item overlaps, furnish skip/nudge, verify issues).
  3. Optional smoke: create a room, add a door, furnish_room bathroom with the door on the furniture wall — fixtures should skip or move rather than sit in the opening; verify_scene should not list door blocks for items just placed.

Screenshots / screen recording

N/A — non-visual change (MCP headless tools + tests).

Checklist

  • I've tested this locally with bun test on the packages above
  • My code follows the existing code style
  • I've updated relevant documentation (agent-guide only)
  • This PR targets the main branch

Note

Medium Risk
Changes core MCP placement and validation behavior agents rely on; risk is mitigated by broad tests and preserved check_collisions contract (gap 0), but regressions could still affect automated room furnishing quality.

Overview
Adds shared door keep-out and layout clearance logic so MCP scene tools stop placing or approving furniture that blocks doors or stacks footprints.

furnish_room now uses level-scoped door keep-outs (real doors plus a planned zone on doorWallIndex when not already covered), rotation-aware footprints with scaled dimensions for scene items, an 8 cm default spacing gap between floor items, and lateral/inset nudge search via findValidPlacement. Failed placements return clearer skip reasons (door clearance preferred over out-of-bounds).

verify_scene appends layout issues from the same helpers: blocked doors and item–item overlaps, evaluated per level so stacked floors do not false-positive.

check_collisions reuses findItemItemCollisions with gap: 0 so it still reports only true overlap, not the furnish spacing rule.

Also updates the agent guide, adds layout-clearance-error-log.md as a regression checklist, and expands unit/integration tests for gap semantics, multi-level isolation, planned keep-outs, and furnish behavior.

Reviewed by Cursor Bugbot for commit d87f1fd. Bugbot is set up for automated code reviews on this repo. Configure here.

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.
Comment thread packages/mcp/src/tools/door-clearance.ts
Comment thread packages/mcp/src/tools/room-tools.ts
Comment thread packages/mcp/src/tools/door-clearance.ts Outdated
Comment thread packages/mcp/src/tools/layout-clearance.ts
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.
Comment thread packages/mcp/src/tools/door-clearance.ts
Comment thread packages/mcp/src/tools/layout-clearance.ts Outdated
@ActArtech

Copy link
Copy Markdown
Contributor Author

Addressed Bugbot findings L1-L4 in the follow-up commit:

  • L1 - door/item keep-outs and collisions are level-scoped (resolveNodeLevelId)
  • L2 - planned entrance keep-out is applied per room edge unless already covered
  • L3 - gap is minimum free space (expand-then-intersect)
  • L4 - footprints use getScaledDimensions / itemNodePlanAabb

Regression coverage is in the door/layout unit tests.
Pitfalls and a pre-merge checklist live in packages/mcp/docs/layout-clearance-error-log.md so we do not reintroduce the same issues.

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.
@ActArtech

Copy link
Copy Markdown
Contributor Author

Addressed the latest Bugbot findings (N1 / N2, logged as L6 / L7):

  • N1 / L6 - keepoutCoversPlanned now requires the planned entrance center inside the existing keep-out and at least 50% planned-area intersection (glancing nearby doors no longer suppress this room's keep-out).
  • N2 / L7 - findValidPlacement reports the primary pose reject reason (prefer door / overlap over last-nudge outside_bounds).

Regression tests: door-clearance L6 + layout-clearance L7.
See packages/mcp/docs/layout-clearance-error-log.md.

Comment thread packages/mcp/src/tools/room-tools.ts
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.
@ActArtech

Copy link
Copy Markdown
Contributor Author

Addressed the latest Bugbot finding (TV nudge axes):

  • Living-room tv-stand now passes door-wall along / inward into findValidPlacement, so when the primary pose hits the door keep-out the smart re-place can inset into the room instead of default world-axis nudges (which were skipping the TV).

@Aymericr Aymericr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — door clear zones and item footprints are a real gap in the MCP layout tools, and several parts of this are done well: the level-scoped comparisons via resolveNodeLevelId (door-clearance.ts:212-221), the expand-then-intersect gap semantics that fix the inverted sign (door-clearance.ts:41-49), and getScaledDimensions in itemNodePlanAabb (door-clearance.ts:98) are all correct and each has a real regression test. Also confirmed: bun run test in packages/mcp is 321/0, the branch merges onto current main with no conflicts, layoutIssuesFromScene costs only ~31ms on a synthetic 4.8k-node scene, and verify_scene stays silent on all three built-in templates.

Two blockers before this can land, plus a design question.

1. CI is red on this PR's own code. bun run --cwd packages/mcp build fails with three type errors — door-clearance.ts:109 (AnyNode has no position/width key to Pick), door-clearance.ts:261 (the synthetic edge-${i} id isn't wall_${string}), layout-clearance.ts:79 (branded-id Map). I checked that main alone is tsc- and biome-clean for this package and that merging main in doesn't change the errors, so this isn't a stale base. bun test is Bun's builtin transpiler and never typechecks, which is why your local run was green — please run bun run --cwd packages/mcp build and bun run check too. Fix that worked for me: swap the two Pick<> params for local structural types ({ id: string; start: [number, number]; end: [number, number] } and { id: string; position: number[]; width?: number }) and widen the map to new Map<string, AnyNode>(...). biome check --write clears the 10 lint/format errors; itemPlanAabb at layout-clearance.ts:14 is genuinely unused (the re-export block at :22-30 already covers it).

2. Wall- and ceiling-hosted items store host-local coordinates, not world plan XZ. place-item.ts:84-86 stores [localX, y, 0] for wall targets (same convention as DoorNode, documented at core/src/schema/nodes/door.ts:155), but itemNodePlanAabb reads position[0]/position[2] as world. Concretely: room [[0,0],[6,0],[6,5],[0,5]], add_door on wallIds[0] at t=0.5, then place_item a shelf on wallIds[2] (north wall, 5 m away) — verify_scene now reports "Door … is blocked by item Shelf". main reports zero issues for that scene. Two shelves at the same wall-local X on opposite walls likewise produce a phantom check_collisions hit. The fix is small and the predicate already exists in your own code: pass floorOnly: true at layout-clearance.ts:100, and add if (node.asset?.attachTo) continue / if (node.parentId && byId.get(node.parentId)?.type === 'wall') continue before door-clearance.ts:215. I verified that clears the false positives while real floor overlaps still report. Heads-up: it correctly breaks the pre-existing check-collisions.test.ts:54, which encodes this bug with two wall-parented items — that test should move to level-parented items.

3. The phantom keep-out fires even when the real door is known. room-tools.ts:585-600 always synthesizes a 0.9 m keep-out at t=0.5 of doorWallIndex ?? 0. With a real door at t=0.2 on that edge, the real keep-out spans x 0.10–1.10 and the planned one 1.00–2.00, so keepoutCoversPlanned's centre-inside test doesn't suppress it and furnish_room protects an opening that doesn't exist — in a 3×3 bathroom that skips the bathtub as blocks door clearance where main placed it. It also fires when doorWallIndex is omitted and the scene has no doors at all. Please match the entrance edge to a real door (compare wall start/end to the polygon edge, or thread edgeIndex from create_room metadata) and only synthesize when that edge has none — and only when doorWallIndex is explicitly supplied. Net placement counts vs main are currently mixed rather than uniformly better (bedroom 5×5 5→4, bathroom 3×3 2→1, kitchen 4×4 1→2, living 6×5 3→4), which I think is this mechanism.

Design question on verify_scene: it appends layout issues uncapped, while the schema-error path right above deliberately caps at 5 (scene-query.ts:826-832) — 40 near-coincident items produced 345 issue strings. And DEFAULT_ITEM_GAP = 0.08 is enforced as an error on read, so deliberately flush design is flagged: a run of four 0.6 m kitchen counters yields 3 "Items overlap", a bedside table against a bed yields 1. I'd suggest calling findItemItemCollisions with gap: 0 from verify_scene/check_collisions (true interpenetration only) and keeping 0.08 as a furnish_room packing preference, plus a cap + "+N more" line.

Smaller items: please drop packages/mcp/docs/layout-clearance-error-log.md:42 (L5), :57 (L8) and :73 — those are #570's apps/editor light-preview bugs and contradict this PR's "out of scope: editor UI". This would also be the repo's first packages/*/docs/ directory; I'd rather see the durable rules as header comments in the two new modules than a per-PR error log with review-round codes. And there's some dead code to remove: aabbFromPlan (door-clearance.ts:305, an identity function with no callers), nodeItemAabb (layout-clearance.ts:52, a same-file alias for the re-exported itemNodePlanAabb), and pointInBoundsWithPadding (geometry.ts:85), whose last caller this PR removed.

One thing worth your thoughts before the next round: packages/core already has rotation-aware plan-footprint geometry in itemOverlapsPolygon (core/src/hooks/spatial-grid/spatial-grid-manager.ts:263, exported via the @pascal-app/core/spatial-grid subpath that scene-query.ts:11 already imports). If the footprint math lived there instead, the editor UI and npm consumers would get the same clearance rules for free rather than this being a third copy of it inside packages/mcp.

Aymericr and others added 2 commits August 4, 2026 17:07
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<AnyNode, 'id' | 'position' | 'width'>` 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<WallNode, 'id' | 'start' | 'end'>` 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<string, AnyNode>`.

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d87f1fd. Configure here.

}
for (const c of findItemItemCollisions({ nodes: list, levelId })) {
issues.push(c.message)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verify reports near-miss overlaps

Medium Severity

layoutIssuesFromScene calls findItemItemCollisions without gap: 0, so it inherits DEFAULT_ITEM_GAP (8cm). verify_scene then flags items that are only close as overlapping, while check_collisions intentionally uses gap: 0 for actual footprint overlap. Agents treating hasIssues as hard failures can get false layout failures.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d87f1fd. Configure here.

@Aymericr

Aymericr commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Self-review before merging, since this is my own PR — treating it adversarially rather than waving it through.

Gate fixes (85a04ae). Two failures, the second hidden behind the first. Biome import ordering across 7 files, then three type errors that CI never reported because the lint step short-circuits the job. All three were real signature problems, not noise:

  • Pick<AnyNode, 'id' | 'position' | 'width'> cannot work. Pick on a union only admits keys present on every member, and position/width are on some node types only. Replaced with an explicit DoorOpeningLike.
  • Pick<WallNode, 'id' | 'start' | 'end'> brands the id as wall_${string}, but keepoutForPolygonEdge deliberately 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 — and now says so in a comment, since that's the non-obvious part.
  • new Map(list.map((n) => [n.id, n] as const)) infers the branded AnyNodeId key type, so passing a plain string to resolveNodeLevelId failed.

A behavioural bug I found reviewing my own consolidation (d87f1fd). Pointing check_collisions at the shared findItemItemCollisions also handed it that helper's DEFAULT_ITEM_GAP of 8cm. Those are different questions:

  • furnish_room asks "can I place something here" — it wants breathing room, so free means free-plus-8cm.
  • check_collisions asks "do these footprints intersect" — a report on a scene that already exists.

With the gap applied, two 1m items standing 7cm apart came back as colliding. I verified it rather than assuming:

7cm apart -> 1 collision: Items overlap: a (a) and b (b)

Now passes gap: 0 explicitly, with the reasoning in a comment so the next person consolidating doesn't reintroduce it. The suite had no test that could catch this — the existing "do not overlap" case placed its items 20m apart — so there's now one at 7cm. It fails without the fix and passes with it; I checked both directions.

That's the finding I'd most want a reviewer to catch, and it's the kind of thing that only shows up when you ask what a shared default means at each call site rather than whether the code compiles.

On the change as a whole. 645 lines of implementation against 644 of tests, and it removes 89 lines from check-collisions.ts by deleting a second hand-rolled AABB implementation — the old one ignored rotation, so consolidating is a genuine correctness win, not just deduplication. docs/layout-clearance-error-log.md records the level/gap-sign/scale pitfalls that cost time while building it, which is the sort of note that's worthless to write later and valuable to have.

keepoutCoversPlanned requiring the planned keep-out's centre inside the existing box plus ≥50% area overlap (rather than plain AABB intersection) is deliberate: a nearby hallway door must not suppress this room's entrance keep-out. That's tested, and it's the subtle bit of the file.

Gates: check clean, check-types 9/9, core 917 / mcp 322 / nodes 938 tests, 0 fail. CI green on both quality and mcp-ci.

Merging.

@Aymericr
Aymericr merged commit 6d42550 into pascalorg:main Aug 4, 2026
3 checks passed
@ActArtech

Copy link
Copy Markdown
Contributor Author

Thank you for reviewing and merging. The feedback was clear and helpful. I enjoy working in this codebase and plan more small PRs as I go. Appreciate the time and the polish you added.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants