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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/agent-operations/__fixtures__/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/agent-operations/__fixtures__/roof-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AnyNode>) }
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' },
},
]
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ItemNode,
LevelNode,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
StairSegmentNode,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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',
() =>
Expand Down Expand Up @@ -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',
() =>
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agent-operations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/agent-operations/move-roofs.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
24 changes: 17 additions & 7 deletions packages/core/src/agent-operations/verify-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -298,7 +299,8 @@ export const verifyScene: AgentOperation<VerifySceneInput | undefined> = (
})
}
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`,
Expand Down Expand Up @@ -330,15 +332,23 @@ export const verifyScene: AgentOperation<VerifySceneInput | undefined> = (
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`
: ''
}`,
)
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/agent-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -94,6 +94,7 @@ export const AGENT_TOOL_CONTRACTS = [
placeItemsTool,
createStairTool,
createRoofTool,
moveRoofsToRoofLevelTool,
deleteNodeTool,
addObjectTool,
getSourceTool,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/agent-tools/roofs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
},
}
9 changes: 9 additions & 0 deletions packages/core/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading