Skip to content

core: move old roofs to the roof level, by hand or by agent - #1047

Merged
Aymericr merged 4 commits into
mainfrom
feedback/roof-level-move
Oct 13, 2026
Merged

Aymericr merged 4 commits into
mainfrom
feedback/roof-level-move

Conversation

@Aymericr

@Aymericr Aymericr commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Rebuilt on main after #1039 (and #1044) landed. It is now four commits on editor main f9bcb0c5 (#1052, the editor commit hosted main pins): 0b3a6c4d, the move, then d4460ebd, 4e440fa3 and b7b7ec60, three fixes from Bugbot's review (below). Everything #1039 already had is gone from the diff: its roof-level rule (planRoofLevel, coveredStoreyId, roofsOver, servesAsRoofLevel) stays exactly as main has it. This PR adds only the move, by hand and by agent.

A roof written onto the storey whose walls carry it can be moved to the level the roof rule gives it, without changing how it looks. Existing projects need this: roofs built before the roof level, and by tools that ignored it, still cover their storey's plan in solo and exploded level views.

  • services/roof-move.ts (new):
    • isRoofOnStoreyWalls(nodes, roofId) names a roof held by the storey whose wall tops it bears on, within 0.15 m. A porch roof on its beam bears lower, so it is not one. A lower wing's roof on the storey above bears on the walls below, so it is not one either.
    • planRoofsOffStorey(nodes, storeyId) moves only those roofs. Each goes to planRoofLevel's level: the roof level over the top storey (created or declared in the same changes), otherwise the storey above. A roof level it creates is as tall as the roofs rise over its floor, the eaves' lift included, as create_roof makes one. Each roof group drops by the storey's offset, so it stands where it stood. A roof that follows walls switches to a fixed height where the walls on its new level would lift it, or where the follow would not see the storey's walls (a support level or an empty level between them). A second move leaves a lower wing's roof that the first move put on the storey above.
    • roofsKeptOverStorey(before, after): a roof group above the storey it covers (coveredStoreyId), at a fixed height (support level: moved roofs, the agents' and the auto roof's), keeps its height over that storey when the storey, or a support level or empty level between them, is made taller or shorter. A level put in, taken out or reordered between them carries the roof with its level, as Editing foundations: roofs on the building's roof level, add_fence shared, corner-window editing and review fixes #1039 decided for "+ level". The roof elevation sync applies it without an undo step of its own, as it already does for a roof that follows walls. A roof standing on its own level's walls is left alone.
  • roof-level.ts: planRoofLevel returns storeyOffset (the storey's floor from the target level's floor), for the roof writers that measure heights from the storey. roofLevelAbove reads where planRoofLevel would put a roof without planning a level, so the panel can ask on every store change. coveredStoreyId and servesAsRoofLevel are exported for the plugins.
  • "Move to the roof level" on a roof's panel, in 2D and 3D.
    • It shows only for a roof on its own storey's walls, and says why: "On First Floor with its walls, so it hides that floor's plan in solo and exploded views". When several roofs move, it adds "The floor's 2 roofs move together".
    • Under an upper storey the button reads "Move to Second Floor".
    • The move is one undo step, and the selection follows the roof to its new level. The button is filled (bg-foreground/[0.08], rounded-full).
  • move_roofs_to_roof_level: the panel's move for agents. One contract (agent-tools/roofs.ts), one core operation (agent-operations/move-roofs.ts), with its case table. It is registered on the MCP with its annotation policy (destructive: it re-parents existing roofs), and listed in the README's tool table and the agent guide. Refusals: level_not_found, no_roof_on_walls.
  • verify_scene's roof_mixed_in_storey keeps main's reports (a roof on a storey that covers it, with walls, rooms or a stair there). It also reports a roof standing on its storey's own walls that main read as a lower wing's roof, and for those it names move_roofs_to_roof_level, the panel action, and "never delete and rebuild them". A porch roof on its beam is still reported, without the move. A roof level holding a plugin's roof planes is no longer reported as roof_level_no_roof.
  • systems/roof/roof-elevation.ts splits out the footprint test it already ran (wallsUnderRoof, roofWallTop), so the move reads the same walls the elevation sync does. main's rule that a lower wing's roof follows the walls below only is kept.
  • wiki/architecture/vertical-model.md (Roof levels) describes the move.

Resolved against main: storeyCoveredBy, the bearing-based coverage reader of the earlier branch, is dropped for main's coveredStoreyId. The roof-level tests are main's, with storeyOffset added and the move's cases appended. The MCP guide test lists roof_mixed_in_storey as a code the guide may name.

How to test

  1. Make a one-storey house whose roof is on the storey, as older projects have it. For example: four walls 2.7 m tall on a 2.5 m storey, and a hip roof parented to that storey with support: 'walls' at y = 2.7.
  2. Select the roof and open its panel. A Level section says it hides the floor's plan and offers Move to the roof level.
  3. Click it. A level named Roof appears above the storey and is selected. The roof looks the same: same eave height, still "Follows walls".
  4. Select the storey: its plan shows with no roof over it. In Preview, Exploded lifts the roof off the plan, and Solo on the storey shows the plan alone.
  5. Undo: the roof is back on the storey and the Roof level is gone, in one step.
  6. A two-storey house: a large First Floor (16 × 10 m) under a small Second Floor (8 × 10 m over its west half), with Architect-style hip roofs on each storey at its wall tops.
    • Select the wing's roof. The panel reads Move to Second Floor. Click it: the roof is on Second Floor at y = −2.7 m, its eave still at 2.7 m, and its panel has no Level section.
    • Select Second Floor's roof: Move to the roof level moves only it.
  7. Make First Floor taller (10 ft, its walls still 2.7 m). The wing's roof stays on the 2.7 m walls (y = −3.048 m). Undo puts both back.
  8. Repeat steps 2–5 in the 2D view.
  9. Agents: move_roofs_to_roof_level { levelId } does what the button does. A second call answers no_roof_on_walls.

Ran on b7b7ec60, from the hosted root (pascalorg/private-editor#913's worktree):

  • bunx turbo run check-types --filter='./editor/packages/*' (9/9), Biome on the changed files.
  • bun test editor/packages/core editor/packages/mcp/src editor/packages/nodes/src/roof: 5369 pass, 0 fail. On 0b3a6c4d, with editor/packages/editor/src/components/ui/command-palette added: 5366 pass.
  • The hosted side: agent-surface-parity, agent-tool-table and hosted-mcp-tool-effects pass with the tool on both surfaces.

In the browser, in the hosted app (pascalorg/private-editor#913), on 0292ac09: that is this commit before its rebase onto #1052, which touches only the command palette.

  • Steps 1–5 and 8, in 3D and 2D, dark and light, on a house with two hip roofs on First Floor (one following walls, one at a fixed height). The button moved both to a new Roof level at the same world heights. Preview's Building view was pixel-identical before and after. Exploded lifted both roofs, First Floor selected showed its plan, and one undo put both roofs back and removed the Roof level.
  • Step 9's first call, from a real Architect · Low chat turn on a fresh copy of that house: one move_roofs_to_roof_level step ("Moved roofs to the roof level") gave the same result, and undo restored it.
  • Steps 6–7 (two storeys) and the second call were not re-run in the browser. The core tests cover them.

Checklist

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

Review

Two reviews of the earlier branch and its hosted half (pascalorg/private-editor#913): an adversarial review, and GPT-6 Astra.

Fixed here:

  • A wall-following roof moved over a level between kept a follow that could not see its storey (Bugbot on 4e440fa3, medium). The follow reads only the roof level's walls and those directly under it, so the roof rose with the roof level when the storey grew. Fixed in b7b7ec60: such a roof keeps its height, and roofsKeptOverStorey keeps it on the storey's walls. The new case fails on 4e440fa3.
  • A roof level made for the move was short of the roofs (Bugbot on d4460ebd, medium). It was sized by the roofs' rise alone, so with walls taller than their storey the roofs stood above it. Fixed in 4e440fa3: it is as tall as the roofs rise over its floor, as create_roof makes one. pascalorg/private-editor#913 sizes the hosted writers' roof level the same way.
  • A moved roof rose off its walls over a support level or an empty level (Bugbot on 0b3a6c4d, medium). roofsKeptOverStorey took the level directly under the roof level as the storey it covers. With a support level or a level still to be built between them, it kept nothing, so a fixed-height roof standing on the storey's walls rose when the storey grew. Fixed in d4460ebd, which reads the storey from coveredStoreyId. With levels between, it keeps only a roof bearing on that storey's walls, so a roof that "+ level" lifted stays on top of the new level. The new tests fail on 0b3a6c4d.
  • The move counted roofs the rule itself had placed (adversarial, high). A lower wing's roof moved onto the storey above was offered again, and a second click took it two levels above its walls. Only roofs on their own storey's walls are offered and moved. Tests cover a second move and a lower roof on the storey above.
  • No agent counterpart to the move (both reviews, high). move_roofs_to_roof_level is the lossless move, and verify_scene names it.
  • A fixed-height roof group above its storey floated when the storey grew (both reviews). The roof elevation sync keeps it over its storey.
  • The button read as plain text; the panel planned a roof level on every store change (adversarial, low). The button is filled; the label comes from roofLevelAbove.
  • A plane-only roof level was reported roofless (Astra).

Not changed, with the reason:

🤖 Generated with Claude Code


Note

Medium Risk
Re-parents roof nodes and can create or declare roof levels; scene graph and elevation sync behavior change for migrated roofs, though geometry is intended to stay fixed.

Overview
Adds a lossless migration path for legacy roofs parented to occupied storeys: new move_roofs_to_roof_level agent tool and roof panel "Move to the roof level" both run planRoofsOffStorey, re-parenting only roofs that bear on that storey's wall tops (porch beams and lower-wing roofs on the storey above are excluded) onto the target from planRoofLevel, preserving world position, ids, and segments.

roof-move.ts implements detection (isRoofOnStoreyWalls), the move plan (including storeyOffset on roof-level plans), and roofsKeptOverStorey so roofs at fixed height above a storey stay aligned when storey or intermediate level heights change; the roof elevation sync applies that on level edits.

verify_scene refines roof_mixed_in_storey (names the move for on-wall roofs), stops roof_level_no_roof when plugin roof planes exist, and treats lower-storey roofs on the floor above as quiet. Docs, MCP registration, fixtures, and extensive tests accompany the change.

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

@Aymericr Aymericr changed the title core: move a storey's roofs to the roof level, from the roof's panel core: move old roofs to the roof level, by hand or by agent Oct 13, 2026
@Aymericr
Aymericr force-pushed the feedback/roof-level-move branch from 7dc3c14 to 0292ac0 Compare October 13, 2026 20:29
Projects built before the roof level, and roofs a tool wrote onto the storey whose walls carry them,
keep hiding that storey's plan in solo and exploded views. Rebuilt on main after #1039 landed: its
roof-level rule (planRoofLevel, coveredStoreyId, roofsOver) stays as it is; this adds the move.

- services/roof-move.ts: isRoofOnStoreyWalls (a roof bearing on the wall tops of the storey holding
  it; a porch roof on its beam and a lower wing's roof on the storey above are not), and
  planRoofsOffStorey, which moves those roofs to planRoofLevel's level in one set of changes, each
  container dropped by the storey's offset so it stands where it stood.
- planRoofLevel returns that storeyOffset, for the plugins that write roofs measured from the
  storey's floor; roofLevelAbove reads its target without planning a level.
- The roof's panel offers "Move to the roof level" ("Move to <storey>" under an upper storey), in
  2D and 3D, as one undo step.
- move_roofs_to_roof_level: the same move for agents, one contract and one core operation, on the
  MCP with its annotation policy, in the README's tool table and the agent guide.
- verify_scene's roof_mixed_in_storey keeps main's reports and names the move for the roofs it
  takes off.
- A roof above the storey it covers at a height of its own keeps its height over that storey when
  the storey is made taller or shorter (roofsKeptOverStorey, in the roof elevation sync).
- A roof level holding a plugin's roof planes is not reported roofless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Aymericr
Aymericr force-pushed the feedback/roof-level-move branch from 0292ac0 to 0b3a6c4 Compare October 13, 2026 21:30

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

Stale Bugbot comment from a previous run.

Comment thread packages/core/src/services/roof-move.ts
… still to be built

roofsKeptOverStorey took the level directly under the roof's level as the storey the roof covers.
With a support level or an empty level between them, which is where planRoofLevel puts the roof
level, it found no storey there. A fixed-height roof standing on the storey's walls then rose off
them when the storey, or the level between, was made taller (Bugbot on #1047).

- The storey is coveredStoreyId's, the one the move itself reads.
- A level put in, taken out or reordered between them still carries the roof with its level, as
  #1039 decided for "+ level". Over levels between, only a roof bearing on the storey's walls is
  kept on them, so a roof that "+ level" lifted stays on top of the new level when it grows.

Co-Authored-By: Claude Opus 5.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.

Stale Bugbot comment from a previous run.

Comment thread packages/core/src/services/roof-move.ts Outdated
…r its floor

planRoofsOffStorey sized a new roof level by the roofs' rise over their eaves alone. Walls taller
than their storey seat the eaves above the new level's floor, so the roofs stood above its height
(Bugbot on #1047). The level now takes the roofs' highest point over its floor, the eaves' lift
included, as create_roof sizes one, and the plan's create says the same.

Co-Authored-By: Claude Opus 5.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 4e440fa. Configure here.

Comment thread packages/core/src/services/roof-move.ts
planRoofsOffStorey kept a moved roof on `walls` follow when the follow seated it where it stood.
Over a support level or a level still to be built, the follow reads only the roof level's walls and
the ones directly under it, finds none, and answers the roof's own height, so the roof kept a
follow that could not see its storey. When the storey grew, it rose with the roof level off the
walls (Bugbot on #1047). Such a roof now keeps its height (`support` level), which
roofsKeptOverStorey keeps on the storey's walls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Aymericr
Aymericr merged commit 85e79e7 into main Oct 13, 2026
5 checks passed
@Aymericr
Aymericr deleted the feedback/roof-level-move branch October 13, 2026 22:55
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.

1 participant