From 0b3a6c4d690e5ccf40ea1a323674673110f62525 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 16:11:56 -0400 Subject: [PATCH 1/4] core: move old roofs to the roof level, by hand or by agent 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 " 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 --- .../agent-operations/__fixtures__/cases.ts | 3 +- .../__fixtures__/roof-cases.ts | 95 ++++++ .../__fixtures__/verify-scene-cases.ts | 70 +++++ packages/core/src/agent-operations/index.ts | 3 + .../core/src/agent-operations/move-roofs.ts | 36 +++ .../core/src/agent-operations/verify-scene.ts | 24 +- packages/core/src/agent-tools/index.ts | 3 +- packages/core/src/agent-tools/roofs.ts | 10 + packages/core/src/services/index.ts | 9 + packages/core/src/services/roof-level.test.ts | 283 +++++++++++++++++- packages/core/src/services/roof-level.ts | 88 ++++-- packages/core/src/services/roof-move.ts | 171 +++++++++++ .../roof/roof-elevation-system.test.ts | 16 + .../systems/roof/roof-elevation-system.tsx | 12 +- .../core/src/systems/roof/roof-elevation.ts | 187 +++++++----- packages/mcp/README.md | 1 + packages/mcp/src/resources/agent-guide.ts | 2 +- packages/mcp/src/resources/resources.test.ts | 2 + .../src/tools/read-tool-annotations.test.ts | 1 + packages/mcp/src/tools/shared-tools.ts | 6 + packages/nodes/src/roof/panel.tsx | 58 ++++ .../tool-annotation-justifications.json | 13 + scripts/openai-tool-annotation-policy.ts | 1 + wiki/architecture/vertical-model.md | 20 +- 24 files changed, 998 insertions(+), 116 deletions(-) create mode 100644 packages/core/src/agent-operations/move-roofs.ts create mode 100644 packages/core/src/services/roof-move.ts diff --git a/packages/core/src/agent-operations/__fixtures__/cases.ts b/packages/core/src/agent-operations/__fixtures__/cases.ts index 3d85f3787d..79dd32fcef 100644 --- a/packages/core/src/agent-operations/__fixtures__/cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/cases.ts @@ -20,7 +20,7 @@ import { import { CREATE_ROOM_CASES, FURNISH_ROOM_CASES } from './build-room-cases' import { PLACE_ITEMS_CASES } from './place-items-cases' import { FIND_BY_TYPE_CASES } from './find-by-type-cases' -import { CREATE_ROOF_CASES } from './roof-cases' +import { CREATE_ROOF_CASES, MOVE_ROOFS_CASES } from './roof-cases' import { ROOM_CASES } from './room-cases' import { SEARCH_ASSETS_CASES } from './search-assets-cases' import { CORNER_WINDOW_CASES } from './corner-window-cases' @@ -908,6 +908,7 @@ export const AGENT_TOOL_CASES: readonly AgentToolCase[] = [ ...ADD_WALL_CASES, ...ADD_FENCE_CASES, ...CREATE_ROOF_CASES, + ...MOVE_ROOFS_CASES, ...ADD_LEVEL_CASES, ...CREATE_STAIR_CASES, ...PLACE_ITEMS_CASES, diff --git a/packages/core/src/agent-operations/__fixtures__/roof-cases.ts b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts index 5a2ece11eb..1579ba0e02 100644 --- a/packages/core/src/agent-operations/__fixtures__/roof-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts @@ -340,3 +340,98 @@ export const CREATE_ROOF_CASES: AgentToolCase[] = [ expect: { refusal: 'no_rooms', mentions: ['width and depth'] }, }, ] + +/** `scene` with a roof over `levelId`'s 8 × 6 m room on that level, its segment bearing at `y`. */ +function roofedOn( + scene: () => SceneGraph, + levelId: string, + y: number, + support: 'walls' | 'level' = 'walls', +) { + return (): SceneGraph => { + const nodes = { ...(scene().nodes as Record) } + const segment = RoofSegmentNode.parse({ + id: 'rseg_low', + parentId: 'roof_low', + width: 8, + depth: 6, + wallHeight: 0, + }) + const roof = RoofNode.parse({ + id: 'roof_low', + parentId: levelId, + position: [4, y, 3], + support: { kind: support }, + children: [segment.id], + }) + const level = nodes[levelId] as LevelNode + return graph( + ...Object.values(nodes).filter((node) => node.id !== levelId), + { ...level, children: [...level.children, roof.id] }, + roof, + segment, + ) + } +} + +/** + * `move_roofs_to_roof_level`: a project built before the rule holds its roofs on the storey whose + * walls carry them; the roof's panel moves them by hand, and this tool for agents, standing where + * they stood. + */ +export const MOVE_ROOFS_CASES: AgentToolCase[] = [ + { + name: 'a bungalow roof on its walls goes up to a new Roof level, its eave where it was', + tool: 'move_roofs_to_roof_level', + scene: roofedOn(house(), 'level_g', 2.5), + input: { levelId: 'level_g' }, + expect: { + result: { roofIds: ['roof_low'] }, + check: (result, nodes) => { + const roof = nodes.roof_low as RoofNode + const level = nodes[result.roofLevelId as string] as LevelNode | undefined + return [ + ...(result.createdRoofLevelId === result.roofLevelId ? [] : ['no Roof level was made']), + ...(level?.level === 1 && level.metadata.role === 'roof' + ? [] + : ['the roof level is not declared right above the storey']), + ...(roof.parentId === result.roofLevelId ? [] : [`the roof is on ${roof.parentId}`]), + // the roof level's floor is the storey's 2.5 m up: the eave stays on the 2.5 m walls + ...(Math.abs(roof.position[1]) < 1e-6 ? [] : [`the eave moved: y ${roof.position[1]}`]), + ...(roof.children.includes('rseg_low') ? [] : ['the segment was not kept']), + ] + }, + }, + }, + { + name: 'a lower storey’s roof goes onto the storey above it', + tool: 'move_roofs_to_roof_level', + scene: roofedOn(house({ storeys: 2 }), 'level_g', 2.5, 'level'), + input: { levelId: 'level_g' }, + expect: { + result: { roofIds: ['roof_low'], roofLevelId: 'level_1', createdRoofLevelId: null }, + after: { roof_low: { parentId: 'level_1', position: [4, 0, 3] } }, + }, + }, + { + name: 'a roof already on the roof level leaves nothing to move', + tool: 'move_roofs_to_roof_level', + scene: house({ above: 'roof' }), + input: { levelId: 'level_g' }, + expect: { refusal: 'no_roof_on_walls', mentions: ['level_g'] }, + }, + { + name: 'a porch roof on its beam, under the walls’ tops, stays on its storey', + tool: 'move_roofs_to_roof_level', + scene: roofedOn(house(), 'level_g', 2.1, 'level'), + input: { levelId: 'level_g' }, + expect: { refusal: 'no_roof_on_walls' }, + }, + { + name: 'a level that does not exist is refused', + tool: 'move_roofs_to_roof_level', + scene: house(), + input: { levelId: 'level_x' }, + expect: { refusal: 'level_not_found' }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts b/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts index 425ebadf06..293e881109 100644 --- a/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts @@ -7,6 +7,7 @@ import { ItemNode, LevelNode, RoofNode, + RoofSegmentNode, SlabNode, StairNode, StairSegmentNode, @@ -85,6 +86,17 @@ function room(levelId: string, tag: string): AnyNode[] { ] } +/** A roof over the 4 × 3 m room, held by `levelId`, its segment bearing `y` above that floor. */ +const roofOver = (levelId: string, y: number): AnyNode[] => [ + RoofNode.parse({ + id: 'roof_low', + parentId: levelId, + position: [2, y, 1.5], + children: ['rseg_low'], + }), + RoofSegmentNode.parse({ id: 'rseg_low', parentId: 'roof_low', width: 4, depth: 3, wallHeight: 0 }), +] + const STAIR_HOLE: [number, number][] = [ [1.5, 0.2], [2.5, 0.2], @@ -273,6 +285,24 @@ export const VERIFY_SCENE_CASES: AgentToolCase[] = [ ), { contains: ['roof_level_no_roof'] }, ), + verify( + "a roof level holding a plugin's roof planes has its roof", + () => + scene( + building(), + level('level_0', 0), + level('level_roof', 1, { metadata: { role: 'roof' } }), + ...room('level_0', 'ground'), + { + object: 'node', + id: 'roofplane_south', + type: 'procedural:roof-plane', + parentId: 'level_roof', + visible: true, + } as unknown as AnyNode, + ), + { lacks: ['roof_level_no_roof'] }, + ), verify( 'a roof level below a storey of its building is reported', () => @@ -327,12 +357,52 @@ export const VERIFY_SCENE_CASES: AgentToolCase[] = [ mentions: ['dedicated roof level'], }, ), + verify( + 'a roof on the walls of the storey holding it is reported, naming the move', + () => + scene( + building(), + level('level_0', 0), + ...room('level_0', 'ground'), + ...roofOver('level_0', 2.5), + ), + { + contains: ['roof_mixed_in_storey'], + mentions: ['dedicated roof level', 'move_roofs_to_roof_level', 'Move to the roof level'], + }, + ), verify( "a lower wing's roof at the upper storey's floor, where the Roof tool puts it, is quiet", () => twoStoreys(...stairOn('level_0'), RoofNode.parse({ id: 'roof_porch', parentId: 'level_1' })), { lacks: ['roof_mixed_in_storey'] }, ), + verify( + "a lower storey's roof on the storey above it, bearing on the lower walls, is quiet", + () => + scene( + building(), + level('level_0', 0), + level('level_1', 1), + ...room('level_0', 'ground'), + ...room('level_1', 'upper'), + ...stairOn('level_0'), + // the upper storey's floor is 2.8 m up; the ground floor's walls top out at 2.5 m + ...roofOver('level_1', -0.3), + ), + { lacks: ['roof_mixed_in_storey'] }, + ), + verify( + 'a porch roof on its beam, under the walls’ tops, is still reported on its storey', + () => + scene( + building(), + level('level_0', 0), + ...room('level_0', 'ground'), + ...roofOver('level_0', 2.1), + ), + { contains: ['roof_mixed_in_storey'], mentions: ['dedicated roof level'] }, + ), verify( 'a wall taller than its storey in a multi-storey building is reported', () => diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index 97d11722e6..67bab4cbbd 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -16,6 +16,7 @@ import { getNode } from './get-node' import { getLevelSummary, getWalls, getZones } from './level-reads' import { listLevels } from './list-levels' import { mergeWindowsOperation } from './merge-windows' +import { moveRoofsToRoofLevel } from './move-roofs' import { paint } from './paint' import { placeInRoom } from './place-in-room' import { placeItems } from './place-items' @@ -56,6 +57,7 @@ export * from './list-levels' export * from './material-preset' export * from './material-refs' export * from './merge-windows' +export * from './move-roofs' export * from './node-patch' export * from './paint' export * from './place-items' @@ -94,6 +96,7 @@ const SINGLE_OPERATIONS = { create_stair: createStair, add_column: addColumn, create_roof: createRoof, + move_roofs_to_roof_level: moveRoofsToRoofLevel, place_items: placeItems, delete_node: deleteNode, find_by_type: findByType, diff --git a/packages/core/src/agent-operations/move-roofs.ts b/packages/core/src/agent-operations/move-roofs.ts new file mode 100644 index 0000000000..756a1e668c --- /dev/null +++ b/packages/core/src/agent-operations/move-roofs.ts @@ -0,0 +1,36 @@ +import { refuse } from '../agent-tools/refusal' +import { getLevelDisplayName } from '../lib/level-name' +import type { LevelNode } from '../schema' +import { planRoofsOffStorey } from '../services/roof-move' +import { requireLevel } from './level-target' +import type { AgentOperation } from './types' + +/** + * `move_roofs_to_roof_level`: the roof panel's "Move to the roof level" for agents. The roofs a + * storey holds on its own walls go to the level the rule gives them, standing where they stood. + */ +export const moveRoofsToRoofLevel: AgentOperation<{ levelId: string }> = (nodes, input) => { + const storey = requireLevel(nodes, input.levelId) + const move = planRoofsOffStorey(nodes, storey.id) + const name = `${getLevelDisplayName(storey)} (${storey.id})` + if (!move) + refuse( + 'no_roof_on_walls', + `${name} holds no roof standing on its walls, so none hides its plan: a porch roof on its beam and a lower wing's roof on the storey above already sit where they belong.`, + { levelId: storey.id }, + ) + const roofIds = move.changes.update + .filter(({ id }) => nodes[id]?.type === 'roof') + .map(({ id }) => id) + const created = move.plan.kind === 'roof' ? move.plan.create : null + const target = (created ?? nodes[move.plan.levelId]) as LevelNode + return { + result: { + roofIds, + roofLevelId: move.plan.levelId, + createdRoofLevelId: created?.id ?? null, + message: `Moved ${roofIds.length} roof${roofIds.length === 1 ? '' : 's'} off ${name} onto ${getLevelDisplayName(target)} (${target.id}), standing where ${roofIds.length === 1 ? 'it' : 'they'} stood.`, + }, + changes: move.changes, + } +} diff --git a/packages/core/src/agent-operations/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index 323f0815ed..3d9122a68e 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -4,8 +4,9 @@ import { wallSupportForNodes } from '../lib/opening-floor-datum' import { detectOpenWallEnds, type OpenWallEnd } from '../lib/room-graph' import { siteBrief } from '../lib/site-placement' import { nodeRegistry } from '../registry' -import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema' +import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema, type LevelNode } from '../schema' import { roofsOver } from '../services/roof-level' +import { isRoofOnStoreyWalls } from '../services/roof-move' import { getStoredLevelHeight, getWallCoveringSlabs, getWallTop } from '../services/storey' import { computeSegmentTransforms, rotateXZ } from '../systems/stair/stair-footprint' import { resolveStairTotalRise } from '../systems/stair/stair-rise-query' @@ -298,7 +299,8 @@ export const verifyScene: AgentOperation = ( }) } if (level.role === 'roof') { - if (content.roofs === 0) + // Roof geometry a plugin draws (roof planes) is no `roof` node, and is still the roof. + if (onLevel(level.levelId).length === 0) report( 'roof_level_no_roof', `${levelName} is a roof support level but has no roof geometry; add or move roof geometry there rather than deleting the support level to satisfy story count`, @@ -330,15 +332,23 @@ export const verifyScene: AgentOperation = ( report('zones_no_ceilings', `${levelName} has zones but no ceilings`) if (content.walls > 0 && content.doors === 0) report('walls_no_doors', `${levelName} has walls but no doors`) - // A roof on a storey that covers it (written on its own walls' level); a lower wing's roof - // here covers the storey below, where the rule puts it. + // A roof on a storey that covers it (written on its own walls' level), or one standing on the + // walls of the storey holding it; a lower wing's roof here covers the storey below, where the + // rule puts it. Those on its walls are the ones move_roofs_to_roof_level takes off. + const held = (nodes[level.levelId] as LevelNode).children + const onWalls = held.filter((id) => isRoofOnStoreyWalls(nodes, id)) if ( - roofsOver(nodes, level.levelId).some((roof) => roof.parentId === level.levelId) && - (content.walls > 0 || content.zones > 0 || content.stairs > 0) + onWalls.length > 0 || + (roofsOver(nodes, level.levelId).some((roof) => roof.parentId === level.levelId) && + (content.walls > 0 || content.zones > 0 || content.stairs > 0)) ) report( 'roof_mixed_in_storey', - `${levelName} mixes roof geometry with occupied-level content; place roofs on a dedicated roof level for solo/exploded level views`, + `${levelName} mixes roof geometry with occupied-level content; place roofs on a dedicated roof level for solo/exploded level views${ + onWalls.length > 0 + ? `. On its own walls: ${onWalls.join(', ')}; move_roofs_to_roof_level with levelId ${level.levelId} moves them there as they stand (never delete and rebuild them); by hand, "Move to the roof level" on the roof's panel` + : '' + }`, ) } diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 4a39735454..0f688608f6 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -24,7 +24,7 @@ import { deleteNodeTool, getNodeTool } from './nodes' import { paintTool } from './paint' import { placeInRoomTool } from './place-in-room' import { placeItemsTool } from './place-items' -import { createRoofTool } from './roofs' +import { createRoofTool, moveRoofsToRoofLevelTool } from './roofs' import { ROOM_TOOL_CONTRACTS } from './room-structure' import { searchMaterialsTool } from './search-materials' import { getSiteTool, placeBuildingTool } from './site' @@ -94,6 +94,7 @@ export const AGENT_TOOL_CONTRACTS = [ placeItemsTool, createStairTool, createRoofTool, + moveRoofsToRoofLevelTool, deleteNodeTool, addObjectTool, getSourceTool, diff --git a/packages/core/src/agent-tools/roofs.ts b/packages/core/src/agent-tools/roofs.ts index dfa2b62597..a2d13557a0 100644 --- a/packages/core/src/agent-tools/roofs.ts +++ b/packages/core/src/agent-tools/roofs.ts @@ -60,3 +60,13 @@ export const createRoofTool = { name: z.string().max(120).optional().describe('What the user would call it (default "Roof").'), }, } + +export const moveRoofsToRoofLevelTool = { + name: 'move_roofs_to_roof_level', + title: 'Move roofs to the roof level', + description: + "Move the roofs a storey holds on its own walls to the level the roof rule gives them, as a roof's panel does by hand (Move to the roof level): over the top storey, the building's roof level (made, named Roof, when there is none); under an upper storey, that storey. Each roof stands exactly where it stood, its segments, finishes and id kept: only the level holding it changes, so exploded and solo level views show the storey's plan with no roof over it. For a project built before roofs had their own level, when verify_scene reports roof_mixed_in_storey. Refused with a code: level_not_found, no_roof_on_walls (the storey holds no roof standing on its walls; a porch roof on its beam and a lower wing's roof on the storey above already sit where they belong). Returns the roofs moved (roofIds), the level they went to (roofLevelId) and the roof level made for them (createdRoofLevelId).", + input: { + levelId: NodeId.describe('The storey whose roofs move, by an id list_levels returned.'), + }, +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 3a4bead15d..a19af34044 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -92,12 +92,21 @@ export { type RiserMarker, } from './riser-diagram' export { + coveredStoreyId, isRoofLevel, planRoofLevel, type RoofLevelPlan, + roofLevelAbove, roofsOver, + servesAsRoofLevel, storeyInsertIndex, } from './roof-level' +export { + isRoofOnStoreyWalls, + planRoofsOffStorey, + type RoofMove, + roofsKeptOverStorey, +} from './roof-move' export { SHELF_BOARD_INSET, shelfBoardDimensions, shelfRowBoardDimensions } from './shelf-board' export { DEFAULT_ANGLE_STEP, diff --git a/packages/core/src/services/roof-level.test.ts b/packages/core/src/services/roof-level.test.ts index bb007b730e..4d0a628238 100644 --- a/packages/core/src/services/roof-level.test.ts +++ b/packages/core/src/services/roof-level.test.ts @@ -1,6 +1,26 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, BuildingNode, LevelNode, RoofNode, WallNode, ZoneNode } from '../schema' -import { isRoofLevel, planRoofLevel, roofsOver, storeyInsertIndex } from './roof-level' +import { applySceneChanges } from '../agent-operations/apply-changes' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + LevelNode, + RoofNode, + RoofSegmentNode, + WallNode, + ZoneNode, +} from '../schema' +import { resolveRoofElevation } from '../systems/roof/roof-elevation' +import { + coveredStoreyId, + isRoofLevel, + planRoofLevel, + roofLevelAbove, + roofsOver, + storeyInsertIndex, +} from './roof-level' +import { isRoofOnStoreyWalls, planRoofsOffStorey } from './roof-move' +import { getLevelElevations } from './storey' // One rule for every roof writer (a roof made on the ground floor's level blocks the exploded // view): a roof lives on the level directly above the storey whose walls carry it, and over the top @@ -45,6 +65,67 @@ function house(...stack: { id: string; content: Content; metadata?: Record = {} + const rect = (prefix: string, levelId: string, corners: [number, number][]) => + corners.map((start, i) => + WallNode.parse({ + id: `${prefix}${i}`, + parentId: levelId, + start, + end: corners[(i + 1) % corners.length], + height: 2.7, + }), + ) + const walls = { + level_g: rect('wall_g', 'level_g', [ + [0, 0], + [12, 0], + [12, 8], + [0, 8], + ]), + level_1: rect('wall_u', 'level_1', [ + [0, 0], + [6, 0], + [6, 8], + [0, 8], + ]), + } + nodes.building_h = BuildingNode.parse({ id: 'building_h', children: ['level_g', 'level_1'] }) + for (const [index, levelId] of (['level_g', 'level_1'] as const).entries()) { + const held = roofs.filter(([, on]) => on === levelId).map(([id]) => id) + nodes[levelId] = LevelNode.parse({ + id: levelId, + parentId: 'building_h', + level: index, + height: 2.5, + children: [...walls[levelId].map((wall) => wall.id), ...held], + }) + for (const wall of walls[levelId]) nodes[wall.id] = wall + } + for (const [id, levelId, x, y, width] of roofs) { + const segment = RoofSegmentNode.parse({ + id: `rseg_${id}`, + parentId: id, + width, + depth: 8, + wallHeight: 0, + }) + nodes[id] = RoofNode.parse({ + id, + parentId: levelId, + position: [x, y, 4], + children: [segment.id], + }) + nodes[segment.id] = segment + } + return nodes +} + describe('planRoofLevel', () => { test('over a bungalow, a new level named Roof on top, declared for the roof', () => { const plan = planRoofLevel(house({ id: 'level_g', content: 'walls' }), 'level_g', 3) @@ -53,6 +134,8 @@ describe('planRoofLevel', () => { expect(plan.create).toMatchObject({ name: 'Roof', level: 1, height: 3, parentId: 'building_h' }) expect(isRoofLevel(plan.create!)).toBe(true) expect(plan.levelId).toBe(plan.create!.id) + // the new level stands on the storey: its floor is the storey's height up + expect(plan.storeyOffset).toBe(-2.5) }) test('over the top of two storeys, the roof level goes above the upper one', () => { @@ -65,7 +148,11 @@ describe('planRoofLevel', () => { test('over a storey with a storey above it, that storey: a lower roof covers part of it', () => { const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_1', content: 'walls' }) - expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ kind: 'storey', levelId: 'level_1' }) + expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ + kind: 'storey', + levelId: 'level_1', + storeyOffset: -2.5, + }) }) test('the declared roof level above is reused as it is', () => { @@ -76,6 +163,7 @@ describe('planRoofLevel', () => { expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ kind: 'roof', levelId: 'level_r', + storeyOffset: -2.5, create: null, update: [], }) @@ -106,7 +194,11 @@ describe('planRoofLevel', () => { { id: 'level_e', content: 'empty' }, { id: 'level_2', content: 'walls' }, ) - expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ kind: 'storey', levelId: 'level_e' }) + expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ + kind: 'storey', + levelId: 'level_e', + storeyOffset: -2.5, + }) }) test('a support level holding a roof stays a support: the roof level goes above it', () => { @@ -131,6 +223,7 @@ describe('planRoofLevel', () => { expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ kind: 'roof', levelId: 'level_r', + storeyOffset: -2.5, create: null, update: [], }) @@ -203,3 +296,185 @@ describe('storeyInsertIndex', () => { expect(storeyInsertIndex(house(), 'building_h')).toEqual({ floorIndex: 0, shift: [] }) }) }) + +describe('roofLevelAbove', () => { + test('reads where planRoofLevel puts a roof, planning no level', () => { + const bungalow = house({ id: 'level_g', content: 'walls' }) + expect(roofLevelAbove(bungalow, 'level_g')).toEqual({ kind: 'roof', level: null }) + const two = house({ id: 'level_g', content: 'walls' }, { id: 'level_1', content: 'walls' }) + expect(roofLevelAbove(two, 'level_g')).toMatchObject({ + kind: 'storey', + level: { id: 'level_1' }, + }) + expect(roofLevelAbove(two, 'level_1')).toEqual({ kind: 'roof', level: null }) + const roofed = house({ id: 'level_g', content: 'walls' }, { id: 'level_r', content: 'roof' }) + expect(roofLevelAbove(roofed, 'level_g')).toMatchObject({ + kind: 'roof', + level: { id: 'level_r' }, + }) + }) +}) + +describe('isRoofOnStoreyWalls', () => { + test("a lower wing's roof on the storey above, on the lower walls' tops, covers the lower storey", () => { + // the upper floor stands 2.5 m up; the ground floor's walls top out 0.2 m over it + const nodes = wingHouse([['roof_wing', 'level_1', 9, 0.2, 6]]) + expect(coveredStoreyId(nodes, nodes.roof_wing as RoofNode)).toBe('level_g') + expect(roofsOver(nodes, 'level_g').map((roof) => roof.id)).toEqual(['roof_wing']) + expect(roofsOver(nodes, 'level_1')).toEqual([]) + expect(isRoofOnStoreyWalls(nodes, 'roof_wing')).toBe(false) + }) + + test('a roof on the walls of the storey holding it stands on its walls', () => { + const nodes = wingHouse([['roof_up', 'level_1', 3, 2.7, 6]]) + expect(coveredStoreyId(nodes, nodes.roof_up as RoofNode)).toBe('level_1') + expect(isRoofOnStoreyWalls(nodes, 'roof_up')).toBe(true) + }) + + test("a porch roof on its beam, under the walls' tops, does not", () => { + expect( + isRoofOnStoreyWalls(wingHouse([['roof_porch', 'level_g', 9, 2.3, 6]]), 'roof_porch'), + ).toBe(false) + }) + + test('a roof on the roof level, or with no segments, does not', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_r', content: 'roof' }) + expect(isRoofOnStoreyWalls(nodes, 'roof_level_r')).toBe(false) + expect(isRoofOnStoreyWalls(house({ id: 'level_c', content: 'roof' }), 'roof_level_c')).toBe( + false, + ) + }) +}) + +// Projects built before the rule hold their roofs on the ground floor: the roof's panel and +// move_roofs_to_roof_level move them to the roof level, standing exactly where they stood. +describe('planRoofsOffStorey', () => { + /** An 8 × 6 m room of walls 2.7 m tall on 2.5 m storeys, its gable roof `support` on `on`. */ + function roofedHouse( + on: string, + support: 'walls' | 'level', + stack: { id: string; content: Content; metadata?: Record }[] = [ + { id: 'level_g', content: 'walls' }, + ], + ) { + const nodes = house(...stack) + const corners: [number, number][] = [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ] + const ground = nodes.level_g as LevelNode + const walls = corners.map((start, i) => + WallNode.parse({ + id: `wall_g${i}`, + parentId: 'level_g', + start, + end: corners[(i + 1) % 4], + height: 2.7, + }), + ) + const segment = RoofSegmentNode.parse({ id: 'rseg_main', width: 8, depth: 6, wallHeight: 0 }) + const roof = RoofNode.parse({ + id: 'roof_main', + parentId: on, + position: [4, 2.7, 3], + support: { kind: support }, + children: [segment.id], + }) + nodes.level_g = { ...ground, children: walls.map((wall) => wall.id) } + for (const node of [...walls, roof, { ...segment, parentId: roof.id }]) nodes[node.id] = node + const level = nodes[on] as LevelNode + nodes[on] = { ...level, children: [...level.children, roof.id] } + return nodes + } + + const worldEave = (nodes: Record, roofId: string) => { + const roof = nodes[roofId] as RoofNode + const base = getLevelElevations(nodes as Record).get(roof.parentId!)?.baseY + return (base ?? 0) + roof.position[1] + } + + test('a bungalow roof on its walls’ level goes up to a new roof level, the eave where it was', () => { + const nodes = roofedHouse('level_g', 'walls') + const move = planRoofsOffStorey(nodes, 'level_g') + expect(move?.plan.kind).toBe('roof') + if (move?.plan.kind !== 'roof') return + expect(move.plan.create).toMatchObject({ name: 'Roof', level: 1 }) + const moved = applySceneChanges(nodes, move.changes) + const roof = moved.roof_main as RoofNode + expect(roof.parentId).toBe(move.plan.levelId) + expect((moved[move.plan.levelId] as LevelNode).children).toEqual(['roof_main']) + expect((moved.level_g as LevelNode).children).not.toContain('roof_main') + expect(worldEave(moved, 'roof_main')).toBeCloseTo(2.7, 6) + // still following the walls, and they seat it where it stands + expect(roof.support.kind).toBe('walls') + expect(resolveRoofElevation(roof, moved)).toBeCloseTo(roof.position[1], 6) + }) + + test('a roof at a height of its own keeps it, measured from the roof level', () => { + const nodes = roofedHouse('level_g', 'level') + const move = planRoofsOffStorey(nodes, 'level_g')! + const moved = applySceneChanges(nodes, move.changes) + expect((moved.roof_main as RoofNode).support.kind).toBe('level') + expect(worldEave(moved, 'roof_main')).toBeCloseTo(2.7, 6) + }) + + test('the roof level already there takes it', () => { + const nodes = roofedHouse('level_g', 'walls', [ + { id: 'level_g', content: 'walls' }, + { id: 'level_r', content: 'roof', metadata: { role: 'roof' } }, + ]) + const move = planRoofsOffStorey(nodes, 'level_g')! + expect(move.plan).toMatchObject({ kind: 'roof', levelId: 'level_r', create: null }) + expect(move.changes.create).toEqual([]) + }) + + test('under an upper storey, onto it, standing where it stood', () => { + const nodes = roofedHouse('level_g', 'walls', [ + { id: 'level_g', content: 'walls' }, + { id: 'level_1', content: 'walls' }, + ]) + const move = planRoofsOffStorey(nodes, 'level_g')! + expect(move.plan).toMatchObject({ kind: 'storey', levelId: 'level_1' }) + const moved = applySceneChanges(nodes, move.changes) + expect(moved.roof_main?.parentId).toBe('level_1') + expect(worldEave(moved, 'roof_main')).toBeCloseTo(2.7, 6) + expect(resolveRoofElevation(moved.roof_main as RoofNode, moved)).toBeCloseTo( + (moved.roof_main as RoofNode).position[1], + 6, + ) + }) + + test("a second move leaves the wing roof the first put on the storey above: it is the lower storey's", () => { + const nodes = wingHouse([ + ['roof_wing', 'level_g', 9, 2.7, 6], + ['roof_up', 'level_1', 3, 2.7, 6], + ]) + const first = planRoofsOffStorey(nodes, 'level_g')! + expect(first.plan).toMatchObject({ kind: 'storey', levelId: 'level_1' }) + const once = applySceneChanges(nodes, first.changes) + expect(once.roof_wing?.parentId).toBe('level_1') + expect(coveredStoreyId(once, once.roof_wing as RoofNode)).toBe('level_g') + const second = planRoofsOffStorey(once, 'level_1')! + expect(second.plan.kind).toBe('roof') + expect(second.changes.update.map(({ id }) => id)).not.toContain('roof_wing') + const twice = applySceneChanges(once, second.changes) + expect(twice.roof_wing?.parentId).toBe('level_1') + expect(twice.roof_up?.parentId).toBe(second.plan.levelId) + expect(roofsOver(twice, 'level_g').map((roof) => roof.id)).toEqual(['roof_wing']) + expect(roofsOver(twice, 'level_1').map((roof) => roof.id)).toEqual(['roof_up']) + }) + + test("a porch roof on its beam, under the walls' tops, is not moved", () => { + expect( + planRoofsOffStorey(wingHouse([['roof_porch', 'level_g', 9, 2.3, 6]]), 'level_g'), + ).toBeNull() + }) + + test('nothing to move on a storey without roofs, or on the roof level', () => { + expect(planRoofsOffStorey(house({ id: 'level_g', content: 'walls' }), 'level_g')).toBeNull() + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_r', content: 'roof' }) + expect(planRoofsOffStorey(nodes, 'level_r')).toBeNull() + }) +}) diff --git a/packages/core/src/services/roof-level.ts b/packages/core/src/services/roof-level.ts index 0fad2a845d..608969ad83 100644 --- a/packages/core/src/services/roof-level.ts +++ b/packages/core/src/services/roof-level.ts @@ -70,20 +70,35 @@ function stackOf(nodes: Nodes, levelId: string, side: 'below' | 'above'): LevelN export type RoofLevelPlan = /** A storey stands above: it takes a roof over part of the storey below. */ - | { kind: 'storey'; levelId: string } - /** The building's roof level, `create`d first when it has none, `update`d when undeclared. */ - | { - kind: 'roof' - levelId: string - create: LevelNode | null - update: { id: string; data: Partial }[] - } + ( + | { kind: 'storey'; levelId: string } + /** The building's roof level, `create`d first when it has none, `update`d when undeclared. */ + | { + kind: 'roof' + levelId: string + create: LevelNode | null + update: { id: string; data: Partial }[] + } + ) & { + /** + * The storey's floor from the target level's, once the plan is written (m, negative): a roof + * measured from the storey's floor sits its container this high on the target level. + */ + storeyOffset: number + } + +/** How far `storeyId`'s floor sits from `levelId`'s (m): negative, the storey being below. */ +function storeyOffsetTo(nodes: Nodes, storeyId: string, levelId: string): number { + const elevations = elevationsOf(nodes) + return (elevations.get(storeyId)?.baseY ?? 0) - (elevations.get(levelId)?.baseY ?? 0) +} /** A level reused as the roof level, declared when it was not. */ -function roofLevelPlan(level: LevelNode): RoofLevelPlan { +function roofLevelPlan(nodes: Nodes, storeyId: string, level: LevelNode): RoofLevelPlan { return { kind: 'roof', levelId: level.id, + storeyOffset: storeyOffsetTo(nodes, storeyId, level.id), create: null, update: isRoofLevel(level) ? [] @@ -104,27 +119,50 @@ function roofLevelPlan(level: LevelNode): RoofLevelPlan { } /** - * Where a roof carried by `storeyId`'s walls lives. With a storey built above (anything on it), - * the storey directly above takes the roof over part of the one below: a porch, a lower wing. - * Else the building's roof level, above every level still to be built and every support level: - * the roof level there; the empty level directly above when it is the only one, declared; else a - * new "Roof" level `roofHeight` tall (the roof's rise over its eaves) on top. Pure: the caller - * writes the plan in the same step as the roof. + * The level that takes the roofs over `storeyId`'s walls, as the scene stands. With a storey built + * above (anything on it), the storey directly above takes the roof over part of the one below: a + * porch, a lower wing. Else the building's roof level, above every level still to be built and + * every support level: the roof level there; the empty level directly above when it is the only + * one; else none yet (`level` null). Reads only, so a panel can ask on every store change. */ -export function planRoofLevel(nodes: Nodes, storeyId: string, roofHeight: number): RoofLevelPlan { - const storey = nodes[storeyId] - if (storey?.type !== 'level') throw Error(`${storeyId} is not a level`) +export function roofLevelAbove( + nodes: Nodes, + storeyId: string, +): { kind: 'storey'; level: LevelNode } | { kind: 'roof'; level: LevelNode | null } { const above = stackOf(nodes, storeyId, 'above') const isPlain = (level: LevelNode) => !servesAsRoofLevel(nodes, level) && !isSupportLevel(level) // The roof stands on the storey directly above, an empty one still to be built included. if (above.some((level) => isPlain(level) && level.children.length > 0)) - return { kind: 'storey', levelId: above.find(isPlain)!.id } + return { kind: 'storey', level: above.find(isPlain)! } const top = above.filter((level) => !servesAsRoofLevel(nodes, level)).at(-1) const roofLevel = above.find( (level) => servesAsRoofLevel(nodes, level) && (!top || level.level > top.level), ) - if (roofLevel) return roofLevelPlan(roofLevel) - if (above.length === 1 && isPlain(above[0]!)) return roofLevelPlan(above[0]!) + if (roofLevel) return { kind: 'roof', level: roofLevel } + if (above.length === 1 && isPlain(above[0]!)) return { kind: 'roof', level: above[0]! } + return { kind: 'roof', level: null } +} + +/** + * Where a roof carried by `storeyId`'s walls lives (`roofLevelAbove`): the storey directly above, + * or the building's roof level, declared if it was not, else a new "Roof" level `roofHeight` tall + * (the roof's rise over its eaves) on top. Pure: the caller writes the plan in the same step as the + * roof. + */ +export function planRoofLevel(nodes: Nodes, storeyId: string, roofHeight: number): RoofLevelPlan { + const storey = nodes[storeyId] + if (storey?.type !== 'level') throw Error(`${storeyId} is not a level`) + const target = roofLevelAbove(nodes, storeyId) + if (target.kind === 'storey') + return { + kind: 'storey', + levelId: target.level.id, + storeyOffset: storeyOffsetTo(nodes, storeyId, target.level.id), + } + if (target.level) return roofLevelPlan(nodes, storeyId, target.level) + const top = stackOf(nodes, storeyId, 'above') + .filter((level) => !servesAsRoofLevel(nodes, level)) + .at(-1) const level = LevelNode.parse({ name: 'Roof', parentId: storey.parentId, @@ -133,7 +171,13 @@ export function planRoofLevel(nodes: Nodes, storeyId: string, roofHeight: number children: [], metadata: { role: 'roof' }, }) - return { kind: 'roof', levelId: level.id, create: level, update: [] } + return { + kind: 'roof', + levelId: level.id, + storeyOffset: storeyOffsetTo({ ...nodes, [level.id]: level }, storeyId, level.id), + create: level, + update: [], + } } /** diff --git a/packages/core/src/services/roof-move.ts b/packages/core/src/services/roof-move.ts new file mode 100644 index 0000000000..24437ac56f --- /dev/null +++ b/packages/core/src/services/roof-move.ts @@ -0,0 +1,171 @@ +import { applySceneChanges } from '../agent-operations/apply-changes' +import type { SceneChanges } from '../agent-operations/types' +import { + type AnyNode, + type AnyNodeId, + getActiveRoofHeight, + type RoofNode, + type RoofSegmentNode, +} from '../schema' +import { resolveRoofElevation, roofWallTop } from '../systems/roof/roof-elevation' +import { coveredStoreyId, planRoofLevel, type RoofLevelPlan, servesAsRoofLevel } from './roof-level' +import { getLevelBelow, getLevelElevations } from './storey' + +/** + * Roofs written onto the storey whose walls carry them, before the roof level (or by a tool that + * ignored it), and their move to the level the rule gives them (`planRoofLevel`). + */ + +type Nodes = Readonly> + +/** + * Where a roof's segments bear, from the floor of the level holding it (m): the lowest of them; + * null with none. + */ +function roofBearing(nodes: Nodes, roof: RoofNode): number | null { + const heights = roof.children + .map((id) => nodes[id]) + .filter((child): child is RoofSegmentNode => child?.type === 'roof-segment') + .map((segment) => roof.position[1] + segment.position[1]) + return heights.length ? Math.min(...heights) : null +} + +/** How far from a storey's wall tops a roof bears and still stands on them; a porch's beam is lower. */ +const ON_WALLS = 0.15 + +/** Whether a roof bears on the tops of `levelId`'s walls under it. */ +function bearsOn(nodes: Nodes, roof: RoofNode, levelId: string): boolean { + const bearing = roofBearing(nodes, roof) + const top = roofWallTop(roof, nodes, levelId) + return bearing !== null && top !== null && Math.abs(bearing - top) <= ON_WALLS +} + +/** + * A roof held by the storey whose walls it stands on (written there before the rule, or by a tool + * that ignored it): it hides that storey's plan in solo and exploded views, and + * `planRoofsOffStorey` moves it. A roof over the storey below (a lower wing's, where the rule puts + * it), or bearing on something else (a porch's beam, its posts), is not one. + */ +export function isRoofOnStoreyWalls(nodes: Nodes, roofId: string): boolean { + const roof = nodes[roofId] + const level = roof?.type === 'roof' ? nodes[roof.parentId ?? ''] : undefined + if (roof?.type !== 'roof' || level?.type !== 'level' || servesAsRoofLevel(nodes, level)) + return false + return bearsOn(nodes, roof, level.id) +} + +export type RoofMove = { + plan: RoofLevelPlan + changes: Required> +} + +/** + * The repair for roofs written onto the storey whose walls carry them (`isRoofOnStoreyWalls`): + * each goes to the level the rule gives it, its container dropped by the storey's offset so it + * stands where it stood. A roof that follows walls keeps following where the walls it finds there + * seat it at the same height; where they would lift it, it keeps its height instead. Null when the + * storey holds no such roof. Pure: one set of changes, so one undo step. + */ +export function planRoofsOffStorey(nodes: Nodes, storeyId: string): RoofMove | null { + const storey = nodes[storeyId] + if (storey?.type !== 'level') return null + const roofs = storey.children + .map((id) => nodes[id]) + .filter( + (child): child is RoofNode => child?.type === 'roof' && isRoofOnStoreyWalls(nodes, child.id), + ) + if (!roofs.length) return null + const rise = Math.max( + 0, + ...roofs + .flatMap((roof) => roof.children.map((id) => nodes[id])) + .filter((child): child is RoofSegmentNode => child?.type === 'roof-segment') + .map((segment) => (segment.wallHeight ?? 0) + getActiveRoofHeight(segment)), + ) + const plan = planRoofLevel(nodes, storeyId, rise) + // Updates land before creates (in the store too): a new level lists its roofs from the start. + const create = + plan.kind === 'roof' && plan.create + ? [ + { + node: { ...plan.create, children: roofs.map((roof) => roof.id) } as AnyNode, + parentId: plan.create.parentId ?? undefined, + }, + ] + : [] + const moves = roofs.map((roof) => { + const [x, y, z] = roof.position + const data: Partial = { + parentId: plan.levelId, + position: [x, y + plan.storeyOffset, z], + } + return { id: roof.id, data } + }) + const update: { id: string; data: Partial }[] = [ + ...(plan.kind === 'roof' ? plan.update : []), + ...moves, + ] + const moved = applySceneChanges(nodes, { create, update }) + for (const { id, data } of moves) { + const roof = moved[id] as RoofNode + if ( + roof.support?.kind === 'walls' && + Math.abs(resolveRoofElevation(roof, moved) - roof.position[1]) > 1e-4 + ) + data.support = { kind: 'level' } + } + return { plan, changes: { create, update } } +} + +/** + * Whether a roof stands over the storey under the level holding it: on the roof level over it + * (`coveredStoreyId`), or on the storey above bearing on its walls and not on its own level's (a + * lower wing's roof, where the rule puts it). + */ +function standsOver(nodes: Nodes, roof: RoofNode, storeyId: string): boolean { + const level = nodes[roof.parentId ?? ''] + if (level?.type !== 'level' || coveredStoreyId(nodes, roof) !== storeyId) return false + return ( + servesAsRoofLevel(nodes, level) || + (bearsOn(nodes, roof, storeyId) && !bearsOn(nodes, roof, level.id)) + ) +} + +/** + * A roof on the level above the storey it covers, at a height of its own (`support` level, as the + * agents' and the auto roof's are, measured from that storey's floor), keeps its height over that + * floor when its level rises or drops over the storey (the storey made taller or shorter), as it + * did when it lived on the storey. The updates that keep it there from `before` to `after`; a roof + * moved in between, or whose level stands over another storey now, is left where it is. + */ +export function roofsKeptOverStorey( + before: Nodes, + after: Nodes, +): { id: AnyNodeId; data: Pick }[] { + const was = getLevelElevations(before as Record) + const now = getLevelElevations(after as Record) + const rise = (elevations: typeof now, levelId: string, storeyId: string) => + (elevations.get(levelId)?.baseY ?? 0) - (elevations.get(storeyId)?.baseY ?? 0) + const updates: { id: AnyNodeId; data: Pick }[] = [] + for (const roof of Object.values(after)) { + if (roof.type !== 'roof' || roof.support?.kind !== 'level' || !roof.parentId) continue + const prior = before[roof.id] + if ( + prior?.type !== 'roof' || + prior.parentId !== roof.parentId || + prior.position[1] !== roof.position[1] + ) + continue + const storeyId = getLevelBelow(roof.parentId, after as Record)?.id + if ( + !storeyId || + getLevelBelow(roof.parentId, before as Record)?.id !== storeyId + ) + continue + const drift = rise(now, roof.parentId, storeyId) - rise(was, roof.parentId, storeyId) + if (Math.abs(drift) < 1e-6 || !standsOver(before, prior, storeyId)) continue + const [x, y, z] = roof.position + updates.push({ id: roof.id, data: { position: [x, y - drift, z] } }) + } + return updates +} diff --git a/packages/core/src/systems/roof/roof-elevation-system.test.ts b/packages/core/src/systems/roof/roof-elevation-system.test.ts index cf7f9f2f74..fae9c30233 100644 --- a/packages/core/src/systems/roof/roof-elevation-system.test.ts +++ b/packages/core/src/systems/roof/roof-elevation-system.test.ts @@ -154,6 +154,22 @@ describe('RoofElevationSystem', () => { expect(currentRoof(roof.id).support).toEqual({ kind: 'level' }) }) + test('a roof above its storey at a height of its own keeps its height over that storey as the storey grows', async () => { + const { level, roof, manual } = setup() + await Promise.resolve() + useScene.getState().updateNode(roof.id, { support: { kind: 'level' }, position: [2, 0, 1] }) + await Promise.resolve() + // the walls stay 3 m tall: the roof stays on them, 0.5 m under the risen roof level's floor + useScene.getState().updateNode(level.id, { height: 3.5 }) + await Promise.resolve() + expect(currentRoof(roof.id).position).toEqual([2, -0.5, 1]) + expect(currentRoof(manual.id).position).toEqual([6, 7, 8]) + useScene.temporal.getState().undo() + await Promise.resolve() + expect((useScene.getState().nodes[level.id] as LevelNode).height).toBe(3) + expect(currentRoof(roof.id).position).toEqual([2, 0, 1]) + }) + test('undo and redo restore custom/follow mode and Y together', async () => { const { roof } = setup() await Promise.resolve() diff --git a/packages/core/src/systems/roof/roof-elevation-system.tsx b/packages/core/src/systems/roof/roof-elevation-system.tsx index e5b9cd7ead..9edd951530 100644 --- a/packages/core/src/systems/roof/roof-elevation-system.tsx +++ b/packages/core/src/systems/roof/roof-elevation-system.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import type { AnyNode, AnyNodeId } from '../../schema' +import { roofsKeptOverStorey } from '../../services/roof-move' import { getSceneHistoryPauseDepth, notifySceneCommit, @@ -34,17 +35,22 @@ export function initializeRoofElevationSync(): () => void { let disposed = false let queued = false let syncing = false + // The nodes before this pass's first change: a roof above its storey keeps its height over it. + let changedFrom: Readonly> | null = null - const schedule = () => { + const schedule = (previous: Readonly> | null = null) => { if (queued) return queued = true + changedFrom = previous // Wall bases use the spatial grid, whose listener must finish before this pass. queueMicrotask(() => { queued = false if (disposed) return const { nodes, updateNodes, readOnly } = useScene.getState() if (readOnly) return - const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + const updates: Array<{ id: AnyNodeId; data: Partial }> = changedFrom + ? roofsKeptOverStorey(changedFrom, nodes) + : [] for (const node of Object.values(nodes)) { if (node.type !== 'roof' || node.support?.kind !== 'walls') continue const elevation = resolveRoofElevation(node, nodes) @@ -86,7 +92,7 @@ export function initializeRoofElevationSync(): () => void { const next = state.nodes[id as AnyNodeId] const prev = previous.nodes[id as AnyNodeId] if (next !== prev && (isElevationRelevant(next) || isElevationRelevant(prev))) { - schedule() + schedule(previous.nodes) return } } diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts index a171d6a1b1..24cc1d8df4 100644 --- a/packages/core/src/systems/roof/roof-elevation.ts +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -26,6 +26,89 @@ export function resolveRoofWallTopElevation( ) } +/** + * The walls among `wallIds` a roof stands on: those under its segments' footprints, closed room or + * not (a room missing a wall, an L-shaped room whose centre falls outside, or a redrawn enclosure + * all still hold the roof up); a cone's, its round wall. Null for a roof with no segments yet. + */ +function wallsUnderRoof( + roof: RoofNode, + nodes: Readonly>, + wallIds: readonly string[], +): string[] | null { + const segments = roof.children + .map((id) => nodes[id]) + .filter((node): node is RoofSegmentNode => node?.type === 'roof-segment') + if (segments.length === 0) return null + const cos = Math.cos(roof.rotation) + const sin = Math.sin(roof.rotation) + const toLevel = (x: number, z: number): [number, number] => [ + roof.position[0] + x * cos + z * sin, + roof.position[2] - x * sin + z * cos, + ] + const conicalSegments = segments.filter((segment) => segment.roofType === 'conical') + if (conicalSegments.length) + return wallIds.filter((id) => { + const wall = nodes[id] + if (wall?.type !== 'wall') return false + const arc = getWallArcData(wall) + if (!arc) return false + return conicalSegments.some((segment) => { + const [centerX, centerZ] = toLevel(segment.position[0], segment.position[2]) + return ( + Math.hypot(arc.center.x - centerX, arc.center.y - centerZ) <= 1e-4 && + Math.abs(arc.radius - segment.width / 2) <= 1e-4 + ) + }) + }) + // The band test is curve- and thickness-aware and boundary-inclusive, so perimeter walls on the + // footprint edge count. + const footprints = segments.map((segment) => { + const c = Math.cos(segment.rotation) + const s = Math.sin(segment.rotation) + const halfW = segment.width / 2 + const halfD = segment.depth / 2 + const corners: Array<[number, number]> = [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] + return corners.map(([x, z]) => + toLevel(segment.position[0] + x * c + z * s, segment.position[2] - x * s + z * c), + ) + }) + return wallIds.filter((id) => { + const wall = nodes[id] + return ( + wall?.type === 'wall' && + footprints.some((polygon) => wallOverlapsSlabFootprint(wall, polygon)) + ) + }) +} + +/** The tallest of these walls' tops, from `targetLevelId`'s floor; undefined for none. */ +function highestWallTop( + targetLevelId: string, + wallIds: readonly string[], + nodes: Readonly>, + elevations: ReturnType, +): number | undefined { + let highest: number | undefined + for (const id of wallIds) { + const wall = nodes[id] + if (wall?.type !== 'wall') continue + const top = resolveRoofWallTopElevation( + targetLevelId as LevelNode['id'], + wall, + nodes, + elevations, + ) + highest = highest === undefined ? top : Math.max(highest, top) + } + return highest +} + export function resolveRoofElevation( roof: RoofNode, nodes: Readonly>, @@ -48,80 +131,36 @@ export function resolveRoofElevation( .map((id) => (id ? nodes[id] : undefined)) .filter((node): node is LevelNode => node?.type === 'level') .flatMap((level) => level.children) - - const conicalSegments = roof.children - .map((id) => nodes[id]) - .filter( - (node): node is RoofSegmentNode => - node?.type === 'roof-segment' && node.roofType === 'conical', - ) - const cos = Math.cos(roof.rotation) - const sin = Math.sin(roof.rotation) - const toLevel = (x: number, z: number): [number, number] => [ - roof.position[0] + x * cos + z * sin, - roof.position[2] - x * sin + z * cos, - ] - // Walls under the footprint, closed room or not: a room missing a wall, an - // L-shaped room whose centre falls outside, or a redrawn enclosure all still - // hold the roof up. The band test is curve- and thickness-aware and - // boundary-inclusive, so perimeter walls on the footprint edge count. - const footprints = roof.children - .map((id) => nodes[id]) - .filter((node): node is RoofSegmentNode => node?.type === 'roof-segment') - .map((segment) => { - const c = Math.cos(segment.rotation) - const s = Math.sin(segment.rotation) - const halfW = segment.width / 2 - const halfD = segment.depth / 2 - const corners: Array<[number, number]> = [ - [-halfW, -halfD], - [halfW, -halfD], - [halfW, halfD], - [-halfW, halfD], - ] - return corners.map(([x, z]) => - toLevel(segment.position[0] + x * c + z * s, segment.position[2] - x * s + z * c), + const wallIds = + wallsUnderRoof(roof, nodes, candidateWallIds) ?? + supportLevelIds + .map((id) => + id + ? resolveRoomRoofFootprintOnLevel(id as LevelNode['id'], nodes, [ + roof.position[0], + roof.position[2], + ]) + : null, ) - }) - const wallIds = conicalSegments.length - ? candidateWallIds.filter((id) => { - const wall = nodes[id] - if (wall?.type !== 'wall') return false - const arc = getWallArcData(wall) - if (!arc) return false - return conicalSegments.some((segment) => { - const [centerX, centerZ] = toLevel(segment.position[0], segment.position[2]) - return ( - Math.hypot(arc.center.x - centerX, arc.center.y - centerZ) <= 1e-4 && - Math.abs(arc.radius - segment.width / 2) <= 1e-4 - ) - }) - }) - : footprints.length - ? candidateWallIds.filter((id) => { - const wall = nodes[id] - return ( - wall?.type === 'wall' && - footprints.some((polygon) => wallOverlapsSlabFootprint(wall, polygon)) - ) - }) - : (supportLevelIds - .map((id) => - id - ? resolveRoomRoofFootprintOnLevel(id as LevelNode['id'], nodes, [ - roof.position[0], - roof.position[2], - ]) - : null, - ) - .find((target) => target !== null)?.wallIds ?? []) + .find((target) => target !== null)?.wallIds ?? + [] + return highestWallTop(levelId, wallIds, nodes, elevations) ?? roof.position[1] +} - let highest: number | undefined - for (const id of wallIds) { - const wall = nodes[id] - if (wall?.type !== 'wall') continue - const top = resolveRoofWallTopElevation(levelId as LevelNode['id'], wall, nodes, elevations) - highest = highest === undefined ? top : Math.max(highest, top) - } - return highest ?? roof.position[1] +/** + * The tallest top of `levelId`'s walls a roof stands on, from the floor of the level holding the + * roof (m); null when it stands on none of them, or has no segments to stand with. + */ +export function roofWallTop( + roof: RoofNode, + nodes: Readonly>, + levelId: string, +): number | null { + const level = nodes[levelId] + if (level?.type !== 'level') return null + const wallIds = wallsUnderRoof(roof, nodes, level.children) + if (!wallIds) return null + return ( + highestWallTop(resolveLevelId(roof, nodes), wallIds, nodes, getLevelElevations(nodes)) ?? null + ) } diff --git a/packages/mcp/README.md b/packages/mcp/README.md index e0e4b2a8bb..eaa67a6236 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -363,6 +363,7 @@ an optional `height` and uses the same default. | `measure_stair` | Read actual risers, going at the walking line, slope, uniformity, arrival, headroom against floors/ceilings/stair bodies, and design-target diagnostics. | `{ stairId, available?: { width, length } }` | `{ measurements, layouts }` | | `fit_stair` | Fit uniform risers or replace the chain with a straight/L/U preset with width-sized landings or quarter-turn winders in one undoable edit. | `{ stairId, fitRun?, targets?, layout?, turn?, width?, landingDepth?, turningStrategy?, innerGap?, walkingLineOffset?, division? }` | `{ stairId, measurements }` | | `create_roof` | Cap the top storey with a roof over its footprint (its walls' outline, else its floor plates, else its rooms), one segment per rectangle, or over a rectangle of your own (`width`, `depth`, `center`). The roof goes on the building's roof level above the storey (reused, the empty level directly above when it is the only one, else made and named Roof) and stands on the storey's wall tops; the hand roof tool follows the same rule. | `{ levelId? (or level), roofType?, pitch?, overhang?, wallHeight?, width?, depth?, center?, materialPreset?, fasciaHeight?, fasciaMaterialPreset?, name? }` | `{ roofId, roofLevelId, createdRoofLevelId, coveredLevelId, segmentIds, roofType, achieved }`; refusals `storey_above`, `roof_exists`, `building_required` (several buildings, no level named), `storey_without_building`, `conflicting_level`, `no_levels`, `no_rooms`, `rectangle_incomplete`, `unknown_material` (the roof's or the fascia's finish) | +| `move_roofs_to_roof_level` | Move the roofs a storey holds on its own walls to the level the roof rule gives them (the building's roof level over the top storey, made when there is none; the storey above a lower one), as a roof's panel does by hand. Each roof stands where it stood, segments, finishes and id kept. For projects built before the roof level, when `verify_scene` reports `roof_mixed_in_storey`. | `{ levelId }` | `{ roofIds, roofLevelId, createdRoofLevelId, achieved }`; refusals `level_not_found`, `no_roof_on_walls` | | `create_room` | Create a room from a polygon: one wall per edge (an edge a wall already runs along reuses it) and the room zone; its doors and windows can be declared in the same call by edge (`wallIndex`) and `t`, each placed with `add_door`'s and `add_window`'s rules or listed in `skippedOpenings`. The floor plate and ceiling are derived, never authored. `outdoor: true` makes a terrace: separators (the editor's Separator: a room boundary with no wall) where no wall runs, no walls of its own, no ceiling. Where every edge of the polygon already runs along a wall (nothing to build) and the polygon fits the room those walls make, the call names that room instead of stacking a second zone on it, and `zoneId` is that room's. If that room has a name of its own (not a number the editor gave it: any name but the editor's "Room N", whoever gave it) the call is refused (`room_named_by_person`, worded with the room's name): pass `rename: true` **only when the person asked for the rename; otherwise ask them**. A room is never renamed unasked, and the same name over it says there is nothing to create. A terrace (`outdoor: true`) drawn over a fully walled courtyard names it outdoor. The refusal is agent-only: the editor's own room tool behaves as before (a person sees and names rooms directly). | `{ name, polygon, levelId?, color?, wallHeight?, wallThickness?, outdoor?, rename?, doors?, windows? }` | `{ zoneId, slabId, ceilingId, wallIds, reusedWalls, areaSqMeters, doorIds, windowIds, skippedOpenings? }`; refusals `cannot_enclose`, `outdoor_room_overlap`, `room_named_by_person` | | `add_door` | Add a door to a straight wall at `t` (0..1 along it) with the editor's placement rules, or a passage with no leaf (`openingKind: "opening"`); it slides to stay on the wall and reports `clamped`. `doorType`, `style` and the outline (`openingShape`) match a reference, `code` builds what they cannot, and `nodeId` rebuilds a scripted door. | `{ wallId, t?, width?, height?, openingKind?, hingesSide?, swingDirection?, doorType?, style?, openingShape?, code?, nodeId?, force? }` | `{ doorId, wallId, localX, t, clamped, achieved }`; refusals `wall_not_found`, `not_a_wall`, `curved_wall`, `wall_too_short`, `opening_overlap` (unless `force`) | | `add_window` | Add a window as `add_door` places a door, centred vertically on the wall unless `sillHeight` (its bottom edge above the floor) is given; `windowType`, `columns` / `rows`, `style` (a Fixed window's panes; refused on another type, `style_needs_fixed_window`) and the outline match a reference. | `{ wallId, t?, width?, height?, sillHeight?, windowType?, columns?, rows?, style?, openingShape?, code?, nodeId?, force? }` | `{ windowId, wallId, localX, t, clamped, sillHeight, achieved }`; the same refusals as `add_door` | diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index a2b48d7015..ca6bad0619 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -49,7 +49,7 @@ export const AGENT_GUIDE = [ '- 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.', - "- Use `create_roof` for roofs, once the storeys and their exterior walls stand. A building's roof goes on its roof level, the level directly above the top storey (named Roof, made when there is none), so exploded and solo level views lift it off the plan; a roof level is not an occupied story. `add_level` puts a new storey under it. A storey takes one roof: `roof_exists` names the one there.", + "- Use `create_roof` for roofs, once the storeys and their exterior walls stand. A building's roof goes on its roof level, the level directly above the top storey (named Roof, made when there is none), so exploded and solo level views lift it off the plan; a roof level is not an occupied story. `add_level` puts a new storey under it. A storey takes one roof: `roof_exists` names the one there. A project built before the roof level holds its roof on the storey (`verify_scene`'s `roof_mixed_in_storey`): `move_roofs_to_roof_level` moves it there as it stands; never delete and rebuild it.", '', '## Stairs', '', diff --git a/packages/mcp/src/resources/resources.test.ts b/packages/mcp/src/resources/resources.test.ts index 8ee5ceb4a0..5ee0763608 100644 --- a/packages/mcp/src/resources/resources.test.ts +++ b/packages/mcp/src/resources/resources.test.ts @@ -236,6 +236,8 @@ describe('pascal://agent-guide', () => { 'editor_tab_required', 'invalid_input', 'roof_exists', + // verify_scene's issue type, which names move_roofs_to_roof_level + 'roof_mixed_in_storey', 'room_named_by_person', 'scene_wipe_blocked', ] diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index 8a792d33c2..fe82177370 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -115,6 +115,7 @@ const TOOL_POLICIES = [ 'lock_outside_faces', 'merge_windows', 'merge_zones', + 'move_roofs_to_roof_level', 'paint', 'delete_scene', 'get_project_status', diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index c74661d271..be07fe041f 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -33,6 +33,7 @@ import { listLevelsTool, measureStairTool, mergeWindowsTool, + moveRoofsToRoofLevelTool, paintTool, placeBuildingTool, placeInRoomTool, @@ -293,6 +294,11 @@ const SHARED_TOOLS: SharedTool[] = [ operation: AGENT_OPERATIONS.create_roof, annotations: ADDITIVE_TOOL_ANNOTATIONS, }, + { + contract: moveRoofsToRoofLevelTool, + operation: AGENT_OPERATIONS.move_roofs_to_roof_level, + annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, + }, { contract: placeItemsTool, operation: AGENT_OPERATIONS.place_items, diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 0a50330f45..75a974830b 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -7,9 +7,14 @@ import { type ChimneyNode, type DormerNode, type GutterNode, + getLevelDisplayName, + isRoofOnStoreyWalls, + type LevelNode, + planRoofsOffStorey, type RidgeVentNode, type RoofNode, type RoofSegmentNode, + roofLevelAbove, type SkylightNode, type SolarPanelNode, type TurbineVentNode, @@ -148,6 +153,40 @@ export default function RoofPanel() { }), ) + // A roof on the walls of the storey holding it (the agents' and the editor's roofs before the + // roof level) hides that storey's plan in solo and exploded views: offer to move it where it goes. + const levelMove = useScene( + useShallow((s) => { + if (!node || !isRoofOnStoreyWalls(s.nodes, node.id)) return null + const level = s.nodes[node.parentId as AnyNodeId] as LevelNode + const above = roofLevelAbove(s.nodes, level.id) + return { + storey: getLevelDisplayName(level), + label: + above.kind === 'roof' + ? 'Move to the roof level' + : `Move to ${getLevelDisplayName(above.level)}`, + roofs: level.children.filter((id) => isRoofOnStoreyWalls(s.nodes, id)).length, + } + }), + ) + + const handleMoveOffStorey = useCallback(() => { + if (!node?.parentId) return + const move = planRoofsOffStorey(useScene.getState().nodes, node.parentId) + if (!move) return + triggerSFX('sfx:item-place') + useScene.getState().applyNodeChanges({ + create: move.changes.create.map((op) => ({ + node: op.node, + parentId: op.parentId as AnyNodeId | undefined, + })), + update: move.changes.update.map(({ id, data }) => ({ id: id as AnyNodeId, data })), + }) + // The storey in view hides the levels above it: the selection follows the roof up. + setSelection({ levelId: move.plan.levelId as LevelNode['id'], selectedIds: [node.id] }) + }, [node, setSelection]) + const handleSelectElement = useCallback( (id: string) => { setSelection({ selectedIds: [id as AnyNode['id']] }) @@ -243,6 +282,25 @@ export default function RoofPanel() { title={node.name || 'Roof'} width={300} > + {levelMove && ( + +
+

+ On {levelMove.storey} with its walls, so it hides that floor's plan in solo and + exploded views. + {levelMove.roofs > 1 && ` The floor's ${levelMove.roofs} roofs move together.`} +

+ +
+
+ )} +
{segments.map((seg, i) => ( diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index 0e200f4d3a..fe7608cfd2 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -747,6 +747,19 @@ "openWorldHint": "Uses only the connected scene; no public internet access." } }, + { + "name": "move_roofs_to_roof_level", + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It is not read-only because it moves a storey's roofs onto the building's roof level, or the storey above, in the connected Pascal scene.", + "destructiveHint": "It changes the level that holds existing roof nodes and their heights there, and can declare an existing empty level the roof level.", + "openWorldHint": "It operates on the connected Pascal scene without accessing the public internet." + } + }, { "name": "move_zone", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index 42c482afe4..aff47be91b 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -72,6 +72,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { merge_windows: policy(false, true, false), merge_zones: policy(false, true, false), move_zone: policy(false, true, false), + move_roofs_to_roof_level: policy(false, true, false), paint: policy(false, true, false), photo_to_scene: policy(false, true, true), place_building: policy(false, true, false), diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 3a4d081655..d2c1617903 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -236,11 +236,25 @@ its floor (a porch's or a lower wing's roof, where the rule puts it), else the s roof written on its own walls' level). `roofsOver` reads it, so a porch roof on the upper storey does not stop `create_roof` capping that storey; a lower wing's roof that follows walls follows the walls below only, not the upper storey's along its edge; and `verify_scene` reports a roof mixed -into a storey (`roof_mixed_in_storey`) only for a roof covering its own storey, and a roof level -below a storey, an empty one still to be built included (`roof_level_not_on_top`). Existing scenes -are not migrated. Unpainted gables on a roof level take the cladding of the storey's walls below +into a storey (`roof_mixed_in_storey`) only for a roof covering its own storey or standing on its +walls (below), and a roof level below a storey, an empty one still to be built included +(`roof_level_not_on_top`). Existing scenes are not migrated. Unpainted gables on a roof level take the cladding of the storey's walls below (`levelWallCladdingRef`). +A roof held by the storey whose wall tops it bears on (within 0.15 m: a porch roof on its beam +bears lower; a lower wing's roof on the storey above bears on the walls below) is a roof the rule +moves (`isRoofOnStoreyWalls`, in `services/roof-move.ts`); `verify_scene`'s `roof_mixed_in_storey` +names the move for it. Such a roof offers "Move to the roof level" on its panel (in 2D and 3D; +"Move to " under an upper storey, as `roofLevelAbove` reads it), and agents have +`move_roofs_to_roof_level`: both run `planRoofsOffStorey`, which moves the storey's roofs on its +walls to the level the rule gives them in one undo step, each container dropped by +`planRoofLevel`'s `storeyOffset` so it stands where it stood, a roof following walls turning custom +where the walls there would lift it. A roof above the storey it covers at a height of its own +(`support` level, as moved roofs, the agents' and the auto roof's are, measured from that storey's +floor) keeps its height over that floor when the storey's height changes: the roof elevation sync +moves it (`roofsKeptOverStorey`) without an undo step of its own, as it does a roof following +walls. + ## Stacked mezzanine rooms A mezzanine is a room zone with `floor.support: 'open'`, an explicit From d4460ebd08e738f8be4b3a35e0577ad13cd2a42f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 17:53:18 -0400 Subject: [PATCH 2/4] core: a moved roof keeps to its walls over a support level or a level 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 --- packages/core/src/services/roof-level.test.ts | 145 ++++++++++++------ packages/core/src/services/roof-move.ts | 47 ++++-- 2 files changed, 135 insertions(+), 57 deletions(-) diff --git a/packages/core/src/services/roof-level.test.ts b/packages/core/src/services/roof-level.test.ts index 4d0a628238..5d933092a3 100644 --- a/packages/core/src/services/roof-level.test.ts +++ b/packages/core/src/services/roof-level.test.ts @@ -19,7 +19,7 @@ import { roofsOver, storeyInsertIndex, } from './roof-level' -import { isRoofOnStoreyWalls, planRoofsOffStorey } from './roof-move' +import { isRoofOnStoreyWalls, planRoofsOffStorey, roofsKeptOverStorey } from './roof-move' import { getLevelElevations } from './storey' // One rule for every roof writer (a roof made on the ground floor's level blocks the exploded @@ -348,53 +348,53 @@ describe('isRoofOnStoreyWalls', () => { // Projects built before the rule hold their roofs on the ground floor: the roof's panel and // move_roofs_to_roof_level move them to the roof level, standing exactly where they stood. -describe('planRoofsOffStorey', () => { - /** An 8 × 6 m room of walls 2.7 m tall on 2.5 m storeys, its gable roof `support` on `on`. */ - function roofedHouse( - on: string, - support: 'walls' | 'level', - stack: { id: string; content: Content; metadata?: Record }[] = [ - { id: 'level_g', content: 'walls' }, - ], - ) { - const nodes = house(...stack) - const corners: [number, number][] = [ - [0, 0], - [8, 0], - [8, 6], - [0, 6], - ] - const ground = nodes.level_g as LevelNode - const walls = corners.map((start, i) => - WallNode.parse({ - id: `wall_g${i}`, - parentId: 'level_g', - start, - end: corners[(i + 1) % 4], - height: 2.7, - }), - ) - const segment = RoofSegmentNode.parse({ id: 'rseg_main', width: 8, depth: 6, wallHeight: 0 }) - const roof = RoofNode.parse({ - id: 'roof_main', - parentId: on, - position: [4, 2.7, 3], - support: { kind: support }, - children: [segment.id], - }) - nodes.level_g = { ...ground, children: walls.map((wall) => wall.id) } - for (const node of [...walls, roof, { ...segment, parentId: roof.id }]) nodes[node.id] = node - const level = nodes[on] as LevelNode - nodes[on] = { ...level, children: [...level.children, roof.id] } - return nodes - } +/** An 8 × 6 m room of walls 2.7 m tall on 2.5 m storeys, its gable roof `support` on `on`. */ +function roofedHouse( + on: string, + support: 'walls' | 'level', + stack: { id: string; content: Content; metadata?: Record }[] = [ + { id: 'level_g', content: 'walls' }, + ], +) { + const nodes = house(...stack) + const corners: [number, number][] = [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ] + const ground = nodes.level_g as LevelNode + const walls = corners.map((start, i) => + WallNode.parse({ + id: `wall_g${i}`, + parentId: 'level_g', + start, + end: corners[(i + 1) % 4], + height: 2.7, + }), + ) + const segment = RoofSegmentNode.parse({ id: 'rseg_main', width: 8, depth: 6, wallHeight: 0 }) + const roof = RoofNode.parse({ + id: 'roof_main', + parentId: on, + position: [4, 2.7, 3], + support: { kind: support }, + children: [segment.id], + }) + nodes.level_g = { ...ground, children: walls.map((wall) => wall.id) } + for (const node of [...walls, roof, { ...segment, parentId: roof.id }]) nodes[node.id] = node + const level = nodes[on] as LevelNode + nodes[on] = { ...level, children: [...level.children, roof.id] } + return nodes +} - const worldEave = (nodes: Record, roofId: string) => { - const roof = nodes[roofId] as RoofNode - const base = getLevelElevations(nodes as Record).get(roof.parentId!)?.baseY - return (base ?? 0) + roof.position[1] - } +const worldEave = (nodes: Record, roofId: string) => { + const roof = nodes[roofId] as RoofNode + const base = getLevelElevations(nodes as Record).get(roof.parentId!)?.baseY + return (base ?? 0) + roof.position[1] +} +describe('planRoofsOffStorey', () => { test('a bungalow roof on its walls’ level goes up to a new roof level, the eave where it was', () => { const nodes = roofedHouse('level_g', 'walls') const move = planRoofsOffStorey(nodes, 'level_g') @@ -478,3 +478,56 @@ describe('planRoofsOffStorey', () => { expect(planRoofsOffStorey(nodes, 'level_r')).toBeNull() }) }) + +describe('roofsKeptOverStorey', () => { + const resized = (nodes: Record, levelId: string, height: number) => ({ + ...nodes, + [levelId]: { ...(nodes[levelId] as LevelNode), height }, + }) + const kept = (before: Record, after: Record) => + applySceneChanges(after, { update: roofsKeptOverStorey(before, after) }) + /** The bungalow's roof, at a height of its own, moved off its storey under `above`. */ + function movedUnder( + above: { id: string; content: Content; metadata?: Record }[], + ) { + const nodes = roofedHouse('level_g', 'level', [{ id: 'level_g', content: 'walls' }, ...above]) + return applySceneChanges(nodes, planRoofsOffStorey(nodes, 'level_g')!.changes) + } + + test('a moved roof stays on its walls as the storey under its roof level grows', () => { + const moved = movedUnder([{ id: 'level_r', content: 'empty', metadata: { role: 'roof' } }]) + expect(worldEave(kept(moved, resized(moved, 'level_g', 3)), 'roof_main')).toBeCloseTo(2.7, 6) + }) + + test('with a support level between, it stays on the walls of the storey it covers', () => { + const moved = movedUnder([{ id: 'level_s', content: 'empty', metadata: { role: 'support' } }]) + expect(worldEave(moved, 'roof_main')).toBeCloseTo(2.7, 6) + expect(worldEave(kept(moved, resized(moved, 'level_g', 3)), 'roof_main')).toBeCloseTo(2.7, 6) + expect(worldEave(kept(moved, resized(moved, 'level_s', 3)), 'roof_main')).toBeCloseTo(2.7, 6) + }) + + test('with a level still to be built between, it stays on the walls as either grows', () => { + const moved = movedUnder([ + { id: 'level_1', content: 'empty' }, + { id: 'level_r', content: 'empty', metadata: { role: 'roof' } }, + ]) + expect(moved.roof_main?.parentId).toBe('level_r') + expect(worldEave(kept(moved, resized(moved, 'level_g', 3)), 'roof_main')).toBeCloseTo(2.7, 6) + expect(worldEave(kept(moved, resized(moved, 'level_1', 3)), 'roof_main')).toBeCloseTo(2.7, 6) + }) + + test('a storey put in under the roof level lifts the roof with it, and it stays on top of it', () => { + const moved = movedUnder([{ id: 'level_r', content: 'empty', metadata: { role: 'roof' } }]) + const building = moved.building_h as BuildingNode + // "+ level": the new storey takes the roof level's place and the roof level moves up one + const added: Record = { + ...moved, + building_h: { ...building, children: [...building.children, 'level_1'] }, + level_r: { ...(moved.level_r as LevelNode), level: 2 }, + level_1: LevelNode.parse({ id: 'level_1', parentId: 'building_h', level: 1, height: 2.5 }), + } + expect(roofsKeptOverStorey(moved, added)).toEqual([]) + expect(worldEave(added, 'roof_main')).toBeCloseTo(5.2, 6) + expect(roofsKeptOverStorey(added, resized(added, 'level_1', 3))).toEqual([]) + }) +}) diff --git a/packages/core/src/services/roof-move.ts b/packages/core/src/services/roof-move.ts index 24437ac56f..c891babed7 100644 --- a/packages/core/src/services/roof-move.ts +++ b/packages/core/src/services/roof-move.ts @@ -9,7 +9,7 @@ import { } from '../schema' import { resolveRoofElevation, roofWallTop } from '../systems/roof/roof-elevation' import { coveredStoreyId, planRoofLevel, type RoofLevelPlan, servesAsRoofLevel } from './roof-level' -import { getLevelBelow, getLevelElevations } from './storey' +import { getLevelElevations, type LevelElevation } from './storey' /** * Roofs written onto the storey whose walls carry them, before the roof level (or by a tool that @@ -131,12 +131,34 @@ function standsOver(nodes: Nodes, roof: RoofNode, storeyId: string): boolean { ) } +/** The levels between a storey and a level above it in their building's stack, bottom up. */ +function levelsBetween( + elevations: Map, + storeyId: string, + levelId: string, +): string { + const lower = elevations.get(storeyId) + const upper = elevations.get(levelId) + if (!lower || !upper) return '' + return [...elevations] + .filter( + ([, entry]) => + entry.buildingId === upper.buildingId && + entry.ordinal > lower.ordinal && + entry.ordinal < upper.ordinal, + ) + .sort(([, a], [, b]) => a.ordinal - b.ordinal) + .map(([id]) => id) + .join() +} + /** - * A roof on the level above the storey it covers, at a height of its own (`support` level, as the - * agents' and the auto roof's are, measured from that storey's floor), keeps its height over that - * floor when its level rises or drops over the storey (the storey made taller or shorter), as it - * did when it lived on the storey. The updates that keep it there from `before` to `after`; a roof - * moved in between, or whose level stands over another storey now, is left where it is. + * A roof on a level above the storey it covers (`coveredStoreyId`), at a height of its own + * (`support` level, as the agents' and the auto roof's are, measured from that storey's floor), + * keeps its height over that floor when its level rises or drops over the storey (the storey, or a + * support level or a level still to be built between them, made taller or shorter), as it did when + * it lived on the storey. The updates that keep it there from `before` to `after`; a roof moved in + * between, or whose level stands over another storey or other levels now, is left where it is. */ export function roofsKeptOverStorey( before: Nodes, @@ -156,14 +178,17 @@ export function roofsKeptOverStorey( prior.position[1] !== roof.position[1] ) continue - const storeyId = getLevelBelow(roof.parentId, after as Record)?.id - if ( - !storeyId || - getLevelBelow(roof.parentId, before as Record)?.id !== storeyId - ) + const storeyId = coveredStoreyId(after, roof) + if (!storeyId || storeyId === roof.parentId || coveredStoreyId(before, prior) !== storeyId) continue + // A level put in, taken out or reordered between them: the roof moves with its level (#1039). + const between = levelsBetween(now, storeyId, roof.parentId) + if (between !== levelsBetween(was, storeyId, roof.parentId)) continue const drift = rise(now, roof.parentId, storeyId) - rise(was, roof.parentId, storeyId) if (Math.abs(drift) < 1e-6 || !standsOver(before, prior, storeyId)) continue + // Over levels between, only a roof on the storey's walls stays on them: one a new level lifted + // off them stays on top of that level. + if (between && !bearsOn(before, prior, storeyId)) continue const [x, y, z] = roof.position updates.push({ id: roof.id, data: { position: [x, y - drift, z] } }) } From 4e440fa3267b6b9247fa61dade4808dad9b65a86 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 18:23:44 -0400 Subject: [PATCH 3/4] core: a roof level made for the move is as tall as the roofs rise over 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 --- packages/core/src/services/roof-level.test.ts | 8 +++ packages/core/src/services/roof-move.ts | 50 ++++++++++++------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/core/src/services/roof-level.test.ts b/packages/core/src/services/roof-level.test.ts index 5d933092a3..db9eb0aff4 100644 --- a/packages/core/src/services/roof-level.test.ts +++ b/packages/core/src/services/roof-level.test.ts @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, BuildingNode, + getActiveRoofHeight, LevelNode, RoofNode, RoofSegmentNode, @@ -410,6 +411,13 @@ describe('planRoofsOffStorey', () => { // still following the walls, and they seat it where it stands expect(roof.support.kind).toBe('walls') expect(resolveRoofElevation(roof, moved)).toBeCloseTo(roof.position[1], 6) + // the new level is as tall as the roof rises over its floor: the 0.2 m the walls stand above + // the storey, then the roof's own rise, as create_roof sizes one + const rise = getActiveRoofHeight(moved.rseg_main as RoofSegmentNode) + expect((moved[move.plan.levelId] as LevelNode).height).toBeCloseTo(0.2 + rise, 6) + expect(move.plan.create).toMatchObject({ + height: (moved[move.plan.levelId] as LevelNode).height, + }) }) test('a roof at a height of its own keeps it, measured from the roof level', () => { diff --git a/packages/core/src/services/roof-move.ts b/packages/core/src/services/roof-move.ts index c891babed7..06e15c52f1 100644 --- a/packages/core/src/services/roof-move.ts +++ b/packages/core/src/services/roof-move.ts @@ -75,29 +75,45 @@ export function planRoofsOffStorey(nodes: Nodes, storeyId: string): RoofMove | n (child): child is RoofNode => child?.type === 'roof' && isRoofOnStoreyWalls(nodes, child.id), ) if (!roofs.length) return null - const rise = Math.max( - 0, - ...roofs - .flatMap((roof) => roof.children.map((id) => nodes[id])) + const segmentsOf = (roof: RoofNode) => + roof.children + .map((id) => nodes[id]) .filter((child): child is RoofSegmentNode => child?.type === 'roof-segment') - .map((segment) => (segment.wallHeight ?? 0) + getActiveRoofHeight(segment)), - ) - const plan = planRoofLevel(nodes, storeyId, rise) - // Updates land before creates (in the store too): a new level lists its roofs from the start. + const segmentRise = (segment: RoofSegmentNode) => + (segment.wallHeight ?? 0) + getActiveRoofHeight(segment) + const rise = Math.max(0, ...roofs.flatMap(segmentsOf).map(segmentRise)) + const planned = planRoofLevel(nodes, storeyId, rise) + const seats = roofs.map((roof) => roof.position[1] + planned.storeyOffset) + // A roof level made for them is as tall as they rise over its floor, the eaves' lift over the + // storey included, as create_roof makes one; it lists its roofs from the start (updates land + // before creates, in the store too). + const plan: RoofLevelPlan = + planned.kind === 'roof' && planned.create + ? { + ...planned, + create: { + ...planned.create, + children: roofs.map((roof) => roof.id), + height: Math.max( + 0.2, + ...roofs.flatMap((roof, index) => + segmentsOf(roof).map( + (segment) => (seats[index] ?? 0) + segment.position[1] + segmentRise(segment), + ), + ), + ), + }, + } + : planned const create = plan.kind === 'roof' && plan.create - ? [ - { - node: { ...plan.create, children: roofs.map((roof) => roof.id) } as AnyNode, - parentId: plan.create.parentId ?? undefined, - }, - ] + ? [{ node: plan.create as AnyNode, parentId: plan.create.parentId ?? undefined }] : [] - const moves = roofs.map((roof) => { - const [x, y, z] = roof.position + const moves = roofs.map((roof, index) => { + const [x, , z] = roof.position const data: Partial = { parentId: plan.levelId, - position: [x, y + plan.storeyOffset, z], + position: [x, seats[index] ?? roof.position[1], z], } return { id: roof.id, data } }) From b7b7ec6088fcfce103f797957d6b656bc9545e3c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 18:38:28 -0400 Subject: [PATCH 4/4] core: a wall-following roof moved over a level between keeps its height 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 --- packages/core/src/services/roof-level.test.ts | 20 +++++++++++++++++++ packages/core/src/services/roof-move.ts | 13 ++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/core/src/services/roof-level.test.ts b/packages/core/src/services/roof-level.test.ts index db9eb0aff4..ee0fc4c908 100644 --- a/packages/core/src/services/roof-level.test.ts +++ b/packages/core/src/services/roof-level.test.ts @@ -524,6 +524,26 @@ describe('roofsKeptOverStorey', () => { expect(worldEave(kept(moved, resized(moved, 'level_1', 3)), 'roof_main')).toBeCloseTo(2.7, 6) }) + test('a roof that followed walls, moved over a level between, keeps its height and its walls', () => { + for (const between of [ + [{ id: 'level_s', content: 'empty' as const, metadata: { role: 'support' } }], + [ + { id: 'level_1', content: 'empty' as const }, + { id: 'level_r', content: 'empty' as const, metadata: { role: 'roof' } }, + ], + ]) { + const nodes = roofedHouse('level_g', 'walls', [ + { id: 'level_g', content: 'walls' }, + ...between, + ]) + const moved = applySceneChanges(nodes, planRoofsOffStorey(nodes, 'level_g')!.changes) + // the follow reads only the roof level's walls and those directly under it: not the storey's + expect((moved.roof_main as RoofNode).support.kind).toBe('level') + expect(worldEave(moved, 'roof_main')).toBeCloseTo(2.7, 6) + expect(worldEave(kept(moved, resized(moved, 'level_g', 3)), 'roof_main')).toBeCloseTo(2.7, 6) + } + }) + test('a storey put in under the roof level lifts the roof with it, and it stays on top of it', () => { const moved = movedUnder([{ id: 'level_r', content: 'empty', metadata: { role: 'roof' } }]) const building = moved.building_h as BuildingNode diff --git a/packages/core/src/services/roof-move.ts b/packages/core/src/services/roof-move.ts index 06e15c52f1..93a1015756 100644 --- a/packages/core/src/services/roof-move.ts +++ b/packages/core/src/services/roof-move.ts @@ -9,7 +9,7 @@ import { } from '../schema' import { resolveRoofElevation, roofWallTop } from '../systems/roof/roof-elevation' import { coveredStoreyId, planRoofLevel, type RoofLevelPlan, servesAsRoofLevel } from './roof-level' -import { getLevelElevations, type LevelElevation } from './storey' +import { getLevelBelow, getLevelElevations, type LevelElevation } from './storey' /** * Roofs written onto the storey whose walls carry them, before the roof level (or by a tool that @@ -63,8 +63,9 @@ export type RoofMove = { * The repair for roofs written onto the storey whose walls carry them (`isRoofOnStoreyWalls`): * each goes to the level the rule gives it, its container dropped by the storey's offset so it * stands where it stood. A roof that follows walls keeps following where the walls it finds there - * seat it at the same height; where they would lift it, it keeps its height instead. Null when the - * storey holds no such roof. Pure: one set of changes, so one undo step. + * seat it at the same height; where they would lift it, or it would not find the storey's, it keeps + * its height instead. Null when the storey holds no such roof. Pure: one set of changes, so one + * undo step. */ export function planRoofsOffStorey(nodes: Nodes, storeyId: string): RoofMove | null { const storey = nodes[storeyId] @@ -122,11 +123,15 @@ export function planRoofsOffStorey(nodes: Nodes, storeyId: string): RoofMove | n ...moves, ] const moved = applySceneChanges(nodes, { create, update }) + // The follow reads the walls of the level holding a roof and of the one directly under it: over + // a support level or a level still to be built it would not see the storey's. + const followSeesStorey = + getLevelBelow(plan.levelId, moved as Record)?.id === storeyId for (const { id, data } of moves) { const roof = moved[id] as RoofNode if ( roof.support?.kind === 'walls' && - Math.abs(resolveRoofElevation(roof, moved) - roof.position[1]) > 1e-4 + (!followSeesStorey || Math.abs(resolveRoofElevation(roof, moved) - roof.position[1]) > 1e-4) ) data.support = { kind: 'level' } }