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
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ export const NOT_LENGTHS = new Set([
'paint.material.properties.roughness',
'paint.material.properties.metalness',
'paint.material.properties.opacity',
'create_roof.glazing.glassOpacity',
'create_roof.glazing.glassRoughness',
'paint.material.texture.scale',
'generate_studio_media.settings.durationSeconds',
])
22 changes: 22 additions & 0 deletions packages/core/src/agent-operations/create-roof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
type AnyNodeId,
getActiveRoofHeight,
getSegmentSlopeFrame,
type RoofGlazing,
RoofNode,
RoofSegmentNode,
type RoofSystemType,
type RoofType,
type WallNode,
} from '../schema'
Expand All @@ -27,6 +29,11 @@ type Level = AnyNode & { type: 'level' }
type CreateRoofInput = {
levelId?: string
level?: string
roofSystem?: RoofSystemType
glazing?: RoofGlazing
barrelRise?: number
barrelSegments?: number
wallThickness?: number
roofType?: RoofType
pitch?: number
overhang?: number
Expand Down Expand Up @@ -298,6 +305,8 @@ export const createRoof: AgentOperation<CreateRoofInput> = (nodes, input, contex
toWorld = footprint.toWorld
}
const roofType = input.roofType ?? chosenType
if (input.roofSystem === 'glazed' && !['gable', 'shed', 'barrel'].includes(roofType))
refuse('unsupported_glazing', 'Glazed roofs support gable, straight shed, and barrel shapes.')

const minX = Math.min(...rectangles.map((r) => r.minX))
const maxX = Math.max(...rectangles.map((r) => r.maxX))
Expand All @@ -314,7 +323,18 @@ export const createRoof: AgentOperation<CreateRoofInput> = (nodes, input, contex
roofType === 'conical'
? { width: Math.max(span.width, span.depth), depth: Math.max(span.width, span.depth) }
: span
if (
roofType === 'barrel' &&
input.barrelRise !== undefined &&
input.barrelRise > size.depth / 2
)
refuse('invalid_barrel_rise', 'Barrel rise must not exceed half the span.')
const segment = RoofSegmentNode.parse({
roofSystem: input.roofSystem,
glazing: input.glazing,
barrelRise: input.barrelRise,
barrelSegments: input.barrelSegments,
...(input.wallThickness !== undefined && { wallThickness: input.wallThickness }),
...size,
roofType,
wallHeight,
Expand All @@ -325,6 +345,8 @@ export const createRoof: AgentOperation<CreateRoofInput> = (nodes, input, contex
})
// The overhang is asked as the eaves' reach past the walls, in plan, as a builder says it and
// Architect's roofs take it; the segment stores it along the slope.
// A barrel's overhang is at its ends, already in plan.
if (roofType === 'barrel') return segment
return { ...segment, overhang: segment.overhang / getSegmentSlopeFrame(segment).cosTheta }
})

Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/agent-operations/paint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,54 @@ describe('a roof', () => {
expect((after.rseg_paint as RoofSegmentNode).wallMaterialPreset).toBeUndefined()
})
})

for (const role of [undefined, 'glazing-glass', 'glazing-solid'] as const)
test(`glazed roof paint ${role ?? 'defaults'} writes visible slots on its segments`, () => {
const segment = RoofSegmentNode.parse({ roofType: 'barrel', roofSystem: 'glazed' })
const roof = RoofNode.parse({ children: [segment.id] })
const nodes = { [roof.id]: roof, [segment.id]: { ...segment, parentId: roof.id } }
const outcome = paint(nodes, { targets: [roof.id], role, material: 'library:preset-softwhite' })
const result = applySceneChanges(nodes, outcome.changes)
expect((result[segment.id] as RoofSegmentNode).slots).toEqual(
role
? { [role]: 'library:preset-softwhite' }
: {
'glazing-frame': 'library:preset-softwhite',
'glazing-solid': 'library:preset-softwhite',
},
)
expect(result[roof.id]).toEqual(roof)
expect(() => paint(nodes, { targets: [segment.id], role: 'top', color: '#ff0000' })).toThrow()
})

test('a roof of glazed and standard segments paints each the roles it has', () => {
const glazed = RoofSegmentNode.parse({ roofType: 'gable', roofSystem: 'glazed' })
const standard = RoofSegmentNode.parse({ roofType: 'gable' })
const roof = RoofNode.parse({ children: [glazed.id, standard.id] })
const nodes = {
[roof.id]: roof,
[glazed.id]: { ...glazed, parentId: roof.id },
[standard.id]: { ...standard, parentId: roof.id },
}
const painted = (role?: string) =>
applySceneChanges(
nodes,
paint(nodes, { targets: [roof.id], role, material: 'library:preset-softwhite' }).changes,
)
const top = painted('top')
expect((top[roof.id] as RoofNode).topMaterialPreset).toBe('library:preset-softwhite')
expect((top[glazed.id] as RoofSegmentNode).slots).toBeUndefined()
const frame = painted('glazing-frame')
expect((frame[glazed.id] as RoofSegmentNode).slots).toEqual({
'glazing-frame': 'library:preset-softwhite',
})
expect(frame[roof.id]).toEqual(roof)
const both = painted()
expect((both[roof.id] as RoofNode).topMaterialPreset).toBe('library:preset-softwhite')
expect((both[standard.id] as RoofSegmentNode).topMaterialPreset).toBeUndefined()
expect(Object.keys((both[glazed.id] as RoofSegmentNode).slots ?? {})).toEqual([
'glazing-frame',
'glazing-solid',
])
expect(() => painted('rafter')).toThrow()
})
32 changes: 29 additions & 3 deletions packages/core/src/agent-operations/paint.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { refuse } from '../agent-tools/refusal'
import type { MaterialSurface } from '../material-kinds'
import { nodeSlotDeclarations } from '../node-slots'
import { GLAZED_ROOF_SLOTS, nodeSlotDeclarations } from '../node-slots'
import { nearestLibraryColorRef } from '../procedural-items/library-colors'
import {
type AnyNode,
getEffectiveSegmentSurfaceMaterial,
isGlazedRoofSegment,
type MaterialSchema,
type RoofNode,
type RoofSegmentNode,
Expand Down Expand Up @@ -53,6 +54,7 @@ const DEFAULT_ROLES: Record<string, readonly string[]> = {
fence: ['posts', 'infill', 'base', 'rail'],
door: ['panel', 'frame'],
window: ['frame'],
'roof-segment': ['glazing-frame', 'glazing-solid'],
block: ['body'],
}

Expand Down Expand Up @@ -212,7 +214,31 @@ export const paint: AgentOperation<PaintInput> = (nodes, input, context) => {
const throughRoof: string[] = []
const modelRoles: string[] = []

for (const id of input.targets) {
// A roof with glazed segments: its roles go on the roof for its standard segments, its glazing's
// on each glazed segment; a role only one kind has paints that kind.
const targets = [
...new Set(
input.targets.flatMap((id) => {
const node = nodes[id]
if (node?.type !== 'roof') return [id]
const segments = node.children
.map((childId) => nodes[childId])
.filter((child): child is RoofSegmentNode => child?.type === 'roof-segment')
const glazed = segments.filter(isGlazedRoofSegment).map((segment) => segment.id)
if (!glazed.length) return [id]
const standard = glazed.length < segments.length
const roles = [...(standard ? ROOF_ROLES : []), ...GLAZED_ROOF_SLOTS]
if (input.role !== undefined && !roles.includes(input.role as never))
refuse('unknown_role', `Roof ${id}'s roles are ${roles.join(', ')}.`, {
target: id,
roles,
})
const roofRole = input.role === undefined || ROOF_ROLES.includes(input.role as RoofRole)
return [...(standard && roofRole ? [id] : []), ...(roofRole && input.role ? [] : glazed)]
}),
),
]
for (const id of targets) {
const node = current(id)
if (!node)
refuse(
Expand All @@ -221,7 +247,7 @@ export const paint: AgentOperation<PaintInput> = (nodes, input, context) => {
{ target: id },
)

if (node.type === 'roof' || node.type === 'roof-segment') {
if (node.type === 'roof' || (node.type === 'roof-segment' && !isGlazedRoofSegment(node))) {
Comment thread
cursor[bot] marked this conversation as resolved.
const role = (input.role ?? 'top') as RoofRole
if (!ROOF_ROLES.includes(role))
refuse('unknown_role', `A roof's roles are ${ROOF_ROLES.join(', ')}.`, {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/agent-tools/paint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const paintTool = {
name: 'paint',
title: 'Paint',
description:
"Give walls, slabs, ceilings, roofs, columns, fences, doors, windows, stairs, blocks, cabinets, shelves, items and the other built parts a finish in one call, as the editor's Paint does. Give one finish: color (#rrggbb, painted with the nearest of the library's flat colours, said with how near); material, a library:<id> (find it with search_materials) or scene:<id> ref, or a native material {properties: {color, roughness, metalness}} for an exact finish, kept as a scene material (materialName names a new one) and reused when the scene holds an equal one; or erase: true, back to the surface's default. role picks the surface: a wall's face a or b, or exterior and interior (the faces turned out of its rooms or into them, else by its stored sides; a partition is interior on both; a wall whose sides are unknown, or with rooms along only part of a face, is refused: name its face, a or b), its trims aSkirting, bSkirting, aCrown, bCrown, aChairRail, bChairRail, a curtain wall's curtain-frame, curtain-glass, curtain-solid; a slab's surface, side, edge, riser, underside or foundation; a ceiling's surface; a roof's top (the covering), edge (the gable ends and the trim band), wall (the soffit and the deck edge, and the fascia and rake boards unless they have their own) or fascia (the fascia and rake boards, a fascia band); a column's shaft, base, capital or frame; a fence's posts, infill, base or rail; a door's panel, frame, glass or hardware; a window's frame or glass; a stair's treads, body or railing; a block's body; a cabinet's front, carcass, countertop, plinth, hardware or glass; another part's own slots, which a refusal names; an item's surfaces by the names its model gives them (its painted ones are in get_node's slots), which paint cannot check. Without a role: both faces of a wall, a slab's or a ceiling's surface, a roof's top, a column's shaft, a whole fence, a door's panel and frame, a window's frame, a block's body, a part's one surface. A roof shows one finish per surface: a segment's finish goes on its roof, and a roof's on all of its segments. A wall face is painted whole, over the wall finish of the room it faces; its painted parts keep their own, named in the note. A ceiling shows its finish's colour, not its texture. The result says the finish written, and the scene material when there is one. A target that is not in the scene, a role it lacks or an unknown material refuses the whole call: nothing is painted.",
"Give walls, slabs, ceilings, roofs, columns, fences, doors, windows, stairs, blocks, cabinets, shelves, items and the other built parts a finish in one call, as the editor's Paint does. Give one finish: color (#rrggbb, painted with the nearest of the library's flat colours, said with how near); material, a library:<id> (find it with search_materials) or scene:<id> ref, or a native material {properties: {color, roughness, metalness}} for an exact finish, kept as a scene material (materialName names a new one) and reused when the scene holds an equal one; or erase: true, back to the surface's default. role picks the surface: a wall's face a or b, or exterior and interior (the faces turned out of its rooms or into them, else by its stored sides; a partition is interior on both; a wall whose sides are unknown, or with rooms along only part of a face, is refused: name its face, a or b), its trims aSkirting, bSkirting, aCrown, bCrown, aChairRail, bChairRail, a curtain wall's curtain-frame, curtain-glass, curtain-solid; a slab's surface, side, edge, riser, underside or foundation; a ceiling's surface; a roof's top (the covering), edge (the gable ends and the trim band), wall (the soffit and the deck edge, and the fascia and rake boards unless they have their own) or fascia (the fascia and rake boards, a fascia band); a glazed roof's glazing-frame, glazing-glass or glazing-solid; a column's shaft, base, capital or frame; a fence's posts, infill, base or rail; a door's panel, frame, glass or hardware; a window's frame or glass; a stair's treads, body or railing; a block's body; a cabinet's front, carcass, countertop, plinth, hardware or glass; another part's own slots, which a refusal names; an item's surfaces by the names its model gives them (its painted ones are in get_node's slots), which paint cannot check. Without a role: both faces of a wall, a slab's or a ceiling's surface, a roof's top, a glazed roof's frame and solid panels, a column's shaft, a whole fence, a door's panel and frame, a window's frame, a block's body, a part's one surface. A roof shows one finish per surface: a segment's finish goes on its roof, and a roof's on all of its segments. A glazed segment holds its own glazing finishes: a roof's glazing roles go on its glazed segments, its other roles on the roof, and without a role both. A wall face is painted whole, over the wall finish of the room it faces; its painted parts keep their own, named in the note. A ceiling shows its finish's colour, not its texture. The result says the finish written, and the scene material when there is one. A target that is not in the scene, a role it lacks or an unknown material refuses the whole call: nothing is painted.",
input: {
targets: z.array(NodeId).min(1).max(200).describe('The nodes to paint.'),
role: z.string().min(1).optional().describe('The surface: see the description.'),
Expand Down
41 changes: 40 additions & 1 deletion packages/core/src/agent-tools/roofs.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,53 @@
import { z } from 'zod'
import { CurtainGrid } from '../schema/nodes/curtain-wall'
import { RoofGlazing, RoofSystemType } from '../schema/nodes/roof-glazing-config'
import { RoofType } from '../schema/nodes/roof-types'
import { measurement, roofPitch } from './measurement'
import { NodeId } from './node-id'

const glazingGrid = CurtainGrid.partial().extend({
spacing: measurement('length', 'm', {
min: 0.2,
max: 100,
description: 'Bar spacing, for the maximum-spacing and fixed-spacing layouts.',
}).optional(),
})

/** A roof's glazing as the node stores it, its lengths read in any unit. */
const RoofGlazingInput = RoofGlazing.partial().extend({
across: glazingGrid.optional(),
along: glazingGrid.optional(),
frameWidth: measurement('length', 'm', {
positive: true,
description: 'Frame bar width.',
}).optional(),
frameDepth: measurement('length', 'm', {
positive: true,
description: 'Frame bar depth.',
}).optional(),
glassThickness: measurement('length', 'm', {
positive: true,
description: 'Glass pane thickness.',
}).optional(),
})

export const createRoofTool = {
name: 'create_roof',
title: 'Create roof',
description:
"Cap a storey with a roof over its footprint (its walls' outline, else its floor plates, else its rooms), swept in the building's own axes into rectangles, one roof segment each. Without a roofType: a gable for one rectangle, hips for a few wings, flat for a large block. The roof goes on the building's roof level above the top storey (the empty level directly above when it is the only one; made, named Roof, when there is none), and stands on the storey's wall tops, so exploded and solo level views lift it off the plan. Call it once the storeys and their exterior walls stand. Refused with a code: storey_above (another storey stands above: a roof caps the top one; a lower wing's roof is drawn with the Roof tool), building_required (no level named on a site of several buildings: pass a level of the one to roof), roof_exists (the storey already has a roof: edit it, or delete_node it first when the person asked for a new one), no_rooms (no rooms or closed walls on any level of the building: pass width and depth to roof a rectangle of your own).",
"Cap a storey with a roof over its footprint (its walls' outline, else its floor plates, else its rooms), swept in the building's own axes into rectangles, one roof segment each. Without a roofType: a gable for one rectangle, hips for a few wings, flat for a large block. The roof goes on the building's roof level above the top storey (the empty level directly above when it is the only one; made, named Roof, when there is none), and stands on the storey's wall tops, so exploded and solo level views lift it off the plan. Glazed construction supports gable, straight shed and barrel. Barrel depth is the span; barrelRise is at most half the span. glazing controls frame, glass, grids and panels. Paint glazing-frame, glazing-glass or glazing-solid. Call it once the storeys and their exterior walls stand. Refused with a code: storey_above (another storey stands above: a roof caps the top one; a lower wing's roof is drawn with the Roof tool), building_required (no level named on a site of several buildings: pass a level of the one to roof), roof_exists (the storey already has a roof: edit it, or delete_node it first when the person asked for a new one), no_rooms (no rooms or closed walls on any level of the building: pass width and depth to roof a rectangle of your own).",
input: {
roofSystem: RoofSystemType.default('standard'),
glazing: RoofGlazingInput.optional(),
barrelRise: measurement('length', 'm', {
positive: true,
description: 'Barrel arch rise, at most half each segment span.',
}).optional(),
barrelSegments: z.number().int().min(4).max(32).optional(),
wallThickness: measurement('length', 'm', {
positive: true,
description: 'Roof wall thickness.',
}).optional(),
levelId: NodeId.optional().describe(
'The storey to cover, by an id list_levels returned. Default: the top storey with rooms. A level without rooms stands for the highest storey of its building that has some.',
),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/node-slots/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { elevatorSlots } from './elevator'
import { fenceSlots } from './fence'
import { leanToSlots } from './lean-to-extension'
import { proceduralItemSlots } from './procedural-item'
import { roofSegmentSlots } from './roof'
import {
boxVentSlots,
cupolaSlots,
Expand All @@ -34,6 +35,7 @@ export * from './elevator'
export * from './fence'
export * from './lean-to-extension'
export * from './procedural-item'
export * from './roof'
export * from './roof-accessories'
export * from './shelf'
export * from './slab'
Expand Down Expand Up @@ -85,6 +87,8 @@ export function nodeSlotDeclarations(node: AnyNode): SlotDeclaration[] | null {
return leanToSlots()
case 'procedural-item':
return proceduralItemSlots(node)
case 'roof-segment':
return roofSegmentSlots(node)
case 'shelf':
return shelfSlots(node)
case 'slab':
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/node-slots/roof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { SlotDeclaration } from '../registry/types'
import { getRoofGlazing, isGlazedRoofSegment } from '../schema/nodes/roof-glazing'
import type { RoofSegmentNode } from '../schema/nodes/roof-segment'
export const GLAZED_ROOF_SLOTS = ['glazing-frame', 'glazing-glass', 'glazing-solid'] as const
export function roofSegmentSlots(node: RoofSegmentNode): SlotDeclaration[] {
if (!isGlazedRoofSegment(node)) return []
const config = getRoofGlazing(node)
return GLAZED_ROOF_SLOTS.map((slotId, index) => ({
slotId,
label: ['Frame', 'Glass', 'Solid panels'][index]!,
default: index === 1 ? config.glassColor : config.frameColor,
}))
}
Loading
Loading