diff --git a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts index 83c7eff19c..4cdc76625e 100644 --- a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts @@ -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', ]) diff --git a/packages/core/src/agent-operations/create-roof.ts b/packages/core/src/agent-operations/create-roof.ts index a2c8af71c1..7854836d02 100644 --- a/packages/core/src/agent-operations/create-roof.ts +++ b/packages/core/src/agent-operations/create-roof.ts @@ -6,8 +6,10 @@ import { type AnyNodeId, getActiveRoofHeight, getSegmentSlopeFrame, + type RoofGlazing, RoofNode, RoofSegmentNode, + type RoofSystemType, type RoofType, type WallNode, } from '../schema' @@ -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 @@ -298,6 +305,8 @@ export const createRoof: AgentOperation = (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)) @@ -314,7 +323,18 @@ export const createRoof: AgentOperation = (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, @@ -325,6 +345,8 @@ export const createRoof: AgentOperation = (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 } }) diff --git a/packages/core/src/agent-operations/paint.test.ts b/packages/core/src/agent-operations/paint.test.ts index 92fe8e820c..2be0aed5df 100644 --- a/packages/core/src/agent-operations/paint.test.ts +++ b/packages/core/src/agent-operations/paint.test.ts @@ -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() +}) diff --git a/packages/core/src/agent-operations/paint.ts b/packages/core/src/agent-operations/paint.ts index 7e908fbbc1..4236da7209 100644 --- a/packages/core/src/agent-operations/paint.ts +++ b/packages/core/src/agent-operations/paint.ts @@ -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, @@ -53,6 +54,7 @@ const DEFAULT_ROLES: Record = { fence: ['posts', 'infill', 'base', 'rail'], door: ['panel', 'frame'], window: ['frame'], + 'roof-segment': ['glazing-frame', 'glazing-solid'], block: ['body'], } @@ -212,7 +214,31 @@ export const paint: AgentOperation = (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( @@ -221,7 +247,7 @@ export const paint: AgentOperation = (nodes, input, context) => { { target: id }, ) - if (node.type === 'roof' || node.type === 'roof-segment') { + if (node.type === 'roof' || (node.type === 'roof-segment' && !isGlazedRoofSegment(node))) { const role = (input.role ?? 'top') as RoofRole if (!ROOF_ROLES.includes(role)) refuse('unknown_role', `A roof's roles are ${ROOF_ROLES.join(', ')}.`, { diff --git a/packages/core/src/agent-tools/paint.ts b/packages/core/src/agent-tools/paint.ts index 8537d0c98f..bdd7e93ba2 100644 --- a/packages/core/src/agent-tools/paint.ts +++ b/packages/core/src/agent-tools/paint.ts @@ -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: (find it with search_materials) or scene: 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: (find it with search_materials) or scene: 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.'), diff --git a/packages/core/src/agent-tools/roofs.ts b/packages/core/src/agent-tools/roofs.ts index 7319e71733..578b31ad5b 100644 --- a/packages/core/src/agent-tools/roofs.ts +++ b/packages/core/src/agent-tools/roofs.ts @@ -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.', ), diff --git a/packages/core/src/node-slots/index.ts b/packages/core/src/node-slots/index.ts index 22107d54dd..f5d78a62e9 100644 --- a/packages/core/src/node-slots/index.ts +++ b/packages/core/src/node-slots/index.ts @@ -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, @@ -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' @@ -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': diff --git a/packages/core/src/node-slots/roof.ts b/packages/core/src/node-slots/roof.ts new file mode 100644 index 0000000000..a03a2624ca --- /dev/null +++ b/packages/core/src/node-slots/roof.ts @@ -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, + })) +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 715e3cbd82..509c379b71 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -139,6 +139,7 @@ export { CurtainGrid, CurtainPanelType, CurtainWallConfig, + curtainGridPositions, DEFAULT_CURTAIN_WALL, getCurtainWallConfig, } from './nodes/curtain-wall' @@ -272,6 +273,31 @@ export { } from './nodes/ridge-vent' export type { RoofSupport, RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' +export { + clipGlazedRoofPolygon, + DEFAULT_ROOF_GLAZING, + type GlazedRoofFace, + type GlazedRoofLayout, + type GlazedRoofPiece, + type GlazedRoofPoint, + getBarrelRoofFaces, + getBarrelRoofSolidPieces, + getGlazedRoofFaces, + getGlazedRoofLayout, + getGlazedRoofPanelType, + getGlazedRoofPieceFaces, + getGlazedRoofPieces, + getGlazedRoofPlanLines, + getRoofGlazing, + glazedRoofFacePoint, + glazedRoofSupportsPoint, + insetGlazedRoofPolygon, + isGlazedRoofSegment, + isIndependentRoofSegment, + RoofGlazing, + RoofSystemType, + supportsRoofGlazing, +} from './nodes/roof-glazing' export type { DutchRoofMetrics, RoofSegmentSurfaceMaterialRole, @@ -301,6 +327,7 @@ export { RoofType, roofTypeHasRakes, } from './nodes/roof-segment' +export { getRoofSegmentResizePatch } from './nodes/roof-segment-resize' export type { DutchRoofShapeMetrics, RoofShapeEaveSide, @@ -312,6 +339,7 @@ export { getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getRoofModuleFaces, + getRoofShapeEaveOverhang, getRoofShapeEaveSides, getRoofShapeInsets, getRoofShapeRatios, diff --git a/packages/core/src/schema/nodes/barrel-roof.ts b/packages/core/src/schema/nodes/barrel-roof.ts new file mode 100644 index 0000000000..2b1de73786 --- /dev/null +++ b/packages/core/src/schema/nodes/barrel-roof.ts @@ -0,0 +1,15 @@ +/** Circular segment from the back eave to the front eave. Rise never exceeds a semicircle. */ +export function getBarrelRoofProfile( + span: number, + requestedRise: number, + segments = 12, +): [number, number][] { + const half = Math.max(0.005, span / 2) + const rise = Math.max(0.0001, Math.min(half, requestedRise)) + const radius = (half * half + rise * rise) / (2 * rise) + const angle = 2 * Math.atan(rise / half) + return Array.from({ length: segments + 1 }, (_, i) => { + const theta = -angle + (2 * angle * i) / segments + return [radius * Math.sin(theta), rise - radius + radius * Math.cos(theta)] + }) +} diff --git a/packages/core/src/schema/nodes/curtain-wall.ts b/packages/core/src/schema/nodes/curtain-wall.ts index ac90e0f08e..fa162e0eb0 100644 --- a/packages/core/src/schema/nodes/curtain-wall.ts +++ b/packages/core/src/schema/nodes/curtain-wall.ts @@ -57,3 +57,26 @@ export const DEFAULT_CURTAIN_WALL = CurtainWallConfig.parse({}) export function getCurtainWallConfig(wall: { curtainWall?: CurtainWallConfig }): CurtainWallConfig { return wall.curtainWall ?? DEFAULT_CURTAIN_WALL } + +export function curtainGridPositions(length: number, grid: CurtainGrid): number[] { + if (!Number.isFinite(length) || length <= 0) return [0] + const count = grid.layout === 'count' ? grid.count : Math.ceil(length / grid.spacing) + const bounded = Math.max(1, Math.min(32, count)) + if (grid.layout !== 'fixed-spacing' || count > 32) { + return Array.from({ length: bounded + 1 }, (_, index) => (length * index) / bounded) + } + const whole = Math.floor(length / grid.spacing) + const remainder = length - whole * grid.spacing + const offset = + grid.alignment === 'start' ? 0 : grid.alignment === 'end' ? remainder : remainder / 2 + const positions = [0] + for (let index = 0; index <= whole; index++) { + const value = offset + index * grid.spacing + if (value > 1e-6 && value < length - 1e-6) positions.push(value) + } + positions.push(length) + // A centered remainder can introduce two edge cells. + return positions.length <= 33 + ? positions + : Array.from({ length: 33 }, (_, i) => (length * i) / 32) +} diff --git a/packages/core/src/schema/nodes/gutter-defaults.test.ts b/packages/core/src/schema/nodes/gutter-defaults.test.ts index 975f6cfeb3..4f0a63a0e9 100644 --- a/packages/core/src/schema/nodes/gutter-defaults.test.ts +++ b/packages/core/src/schema/nodes/gutter-defaults.test.ts @@ -26,6 +26,22 @@ describe('createDefaultGuttersForSegment', () => { expect(gutters.every((gutter) => isDefaultGutterNode(gutter, segment.id))).toBe(true) }) + test("a barrel's eave gutters hang where its arch springs; its overhang only lengthens them", () => { + const front = (overhang: number) => + getGutterRunsForSegment( + RoofSegmentNode.parse({ + roofType: 'barrel', + width: 8, + depth: 6, + overhang, + wallHeight: 0.5, + }), + ).find((run) => run.side === '+Z')! + expect(front(0.3).position[2]).toBe(3) + expect(front(1).position).toEqual(front(0.3).position) + expect(front(1).length - front(0.3).length).toBeCloseTo(1.4) + }) + test('spans the full tucked perimeter so four-sided gutters meet at corners', () => { const segment = RoofSegmentNode.parse({ roofType: 'flat', diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index 7d221bc646..8158dff8ae 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -7,7 +7,7 @@ import { normalizeRoofSegmentTrim, type RoofSegmentNode, } from './roof-segment' -import { getRoofShapeEaveSides } from './roof-segment-shape' +import { getRoofShapeEaveOverhang, getRoofShapeEaveSides } from './roof-segment-shape' const MIN_DEFAULT_GUTTER_LENGTH_M = 0.2 const DEFAULT_GUTTER_GENERATOR = 'default-gutter' @@ -145,7 +145,7 @@ export function computeGutterEaveY( const wallHeight = segment.wallHeight ?? 0 if ((segment.roofType ?? 'gable') === 'flat') return wallHeight const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 - return wallHeight - (segment.overhang ?? 0) * Math.tan(pitchRad) + GUTTER_EAVE_TUCK_UP + return wallHeight - getRoofShapeEaveOverhang(segment) * Math.tan(pitchRad) + GUTTER_EAVE_TUCK_UP } function getDefaultGutterSides(segment: RoofSegmentNode): GutterEaveSide[] { @@ -155,7 +155,7 @@ function getDefaultGutterSides(segment: RoofSegmentNode): GutterEaveSide[] { function getGutterEnvelope(segment: RoofSegmentNode) { const halfW = Math.max(0, segment.width) / 2 const halfD = Math.max(0, segment.depth) / 2 - const overhang = Math.max(0, segment.overhang ?? 0) + const overhang = Math.max(0, getRoofShapeEaveOverhang(segment)) // an eave gutter runs out to the rakes, as far as they reach const outerHalfW = Math.max( halfW, diff --git a/packages/core/src/schema/nodes/ridge-vent.ts b/packages/core/src/schema/nodes/ridge-vent.ts index 5b5925f32d..7b7e1c8704 100644 --- a/packages/core/src/schema/nodes/ridge-vent.ts +++ b/packages/core/src/schema/nodes/ridge-vent.ts @@ -80,6 +80,8 @@ export function getRidgeVentLinesForSegment(segment: RoofSegmentNode): RidgeVent }) const { width, depth, minX, maxX, minZ, maxZ } = bounds if ( + segment.roofSystem === 'glazed' || + segment.roofType === 'barrel' || segment.roofType === 'flat' || segment.roofType === 'shed' || segment.roofType === 'conical' diff --git a/packages/core/src/schema/nodes/roof-glazing-config.ts b/packages/core/src/schema/nodes/roof-glazing-config.ts new file mode 100644 index 0000000000..60157e3732 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-glazing-config.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' +import { CurtainGrid, CurtainPanelType } from './curtain-wall' + +export const RoofSystemType = z.enum(['standard', 'glazed']) +export type RoofSystemType = z.infer + +export const RoofGlazing = z.object({ + across: CurtainGrid.prefault({}), + along: CurtainGrid.prefault({}), + frameWidth: z.number().finite().positive().default(0.05), + frameDepth: z.number().finite().positive().default(0.08), + glassThickness: z.number().finite().positive().default(0.008), + frameColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default('#303942'), + glassColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default('#a9d5df'), + glassOpacity: z.number().finite().min(0.05).max(1).default(0.3), + glassRoughness: z.number().finite().min(0).max(1).default(0.12), + glazedEnds: z.boolean().default(true), + panelType: CurtainPanelType.default('glass'), + panels: z + .array( + z.object({ + face: z + .string() + .regex(/^(front|back|slope|end-left|end-right|barrel-([0-9]|[12][0-9]|3[01]))$/), + column: z.number().int().min(0).max(31), + row: z.number().int().min(0).max(31), + type: CurtainPanelType, + }), + ) + .max(5120) + .default([]), +}) +export type RoofGlazing = z.infer +export const DEFAULT_ROOF_GLAZING = RoofGlazing.parse({}) diff --git a/packages/core/src/schema/nodes/roof-glazing.test.ts b/packages/core/src/schema/nodes/roof-glazing.test.ts new file mode 100644 index 0000000000..de91b6a1eb --- /dev/null +++ b/packages/core/src/schema/nodes/roof-glazing.test.ts @@ -0,0 +1,191 @@ +import { afterEach, expect, test } from 'bun:test' +import { + clampRectToRoofWallFace, + type GlazedRoofPiece, + getBarrelRoofSolidPieces, + getGlazedRoofFaces, + getGlazedRoofLayout, + getGlazedRoofPieceFaces, + getGlazedRoofPieces, + getGlazedRoofPlanLines, + getRoofGlazing, + getRoofSegmentSurfaceY, + getRoofSegmentWallFace, + glazedRoofFacePoint, + glazedRoofSupportsPoint, + isGlazedRoofSegment, + RoofSegmentNode, + useScene, +} from '@pascal-app/core' + +test('old roof segments stay standard and glazing survives a JSON round trip', () => { + expect(isGlazedRoofSegment(RoofSegmentNode.parse({ roofType: 'gable' }))).toBe(false) + const node = RoofSegmentNode.parse({ + roofType: 'gable', + roofSystem: 'glazed', + glazing: { + across: { count: 4, layout: 'count' }, + glassColor: '#123456', + panels: [{ face: 'front', column: 1, row: 0, type: 'empty' }], + }, + }) + expect(RoofSegmentNode.parse(JSON.parse(JSON.stringify(node)))).toEqual(node) + expect(isGlazedRoofSegment({ ...node, roofSystem: 'standard' })).toBe(false) + expect(isGlazedRoofSegment({ ...node, roofType: 'hip' })).toBe(false) +}) + +for (const roofType of ['gable', 'shed'] as const) { + test(`${roofType} pane layout measures slope distance and shares the covering datum`, () => { + const node = RoofSegmentNode.parse({ + roofType, + roofSystem: 'glazed', + width: 8, + depth: 6, + pitch: 45, + overhang: 0, + wallThickness: 0, + glazing: { along: { layout: 'maximum-spacing', spacing: 1 } }, + }) + const layout = getGlazedRoofLayout(node).find(({ face }) => face.normal[1] > 0)! + expect(layout.face.length).toBeCloseTo((roofType === 'gable' ? 3 : 6) * Math.SQRT2) + for (let i = 1; i < layout.along.length; i++) + expect(layout.along[i]! - layout.along[i - 1]!).toBeLessThanOrEqual(1) + const point = glazedRoofFacePoint(layout.face, 2, layout.face.length * 0.6) + expect(point[1]).toBeCloseTo(getRoofSegmentSurfaceY(node, point[0], point[2])) + expect(getGlazedRoofFaces(node).filter((face) => face.normal[1] === 0)).toHaveLength(2) + }) +} + +test('empty pane interiors and trimmed corners do not support accessories', () => { + const node = RoofSegmentNode.parse({ + roofType: 'gable', + roofSystem: 'glazed', + width: 4, + depth: 4, + wallThickness: 0, + overhang: 0, + glazing: { + across: { layout: 'count', count: 2 }, + along: { layout: 'count', count: 1 }, + panels: [{ face: 'front', column: 0, row: 0, type: 'empty' }], + }, + }) + expect(glazedRoofSupportsPoint(node, -1, 1)).toBe(false) + expect(glazedRoofSupportsPoint(node, 1, 1)).toBe(true) + expect(glazedRoofSupportsPoint(node, -1, -1)).toBe(true) + const trimmed = RoofSegmentNode.parse({ ...node, trim: { frontRightX: 1, frontRightZ: 1 } }) + expect(glazedRoofSupportsPoint(trimmed, 1.8, 1.8)).toBe(false) + expect(glazedRoofSupportsPoint(trimmed, 0.8, 0.8)).toBe(true) + expect( + getGlazedRoofPlanLines(trimmed).every( + ([a, b]) => a[0] + a[1] <= 3.000001 && b[0] + b[1] <= 3.000001, + ), + ).toBe(true) +}) + +const originalScene = useScene.getState() +afterEach(() => useScene.setState(originalScene)) +test('scene loading fills glazing defaults in sparse saved configurations', () => { + const node = RoofSegmentNode.parse({ roofType: 'gable', roofSystem: 'glazed' }) + const sparse = { ...node, glazing: { glassOpacity: 0.45 } } as RoofSegmentNode + useScene.getState().setScene({ [node.id]: sparse }, [node.id]) + const loaded = useScene.getState().nodes[node.id] as RoofSegmentNode + expect(loaded.glazing?.across.spacing).toBe(1.5) + expect(loaded.glazing?.glassOpacity).toBe(0.45) +}) + +test('barrel span and rise define symmetric arch bays and arched end infill', () => { + const node = RoofSegmentNode.parse({ + roofType: 'barrel', + roofSystem: 'glazed', + depth: 4, + width: 6, + barrelRise: 2, + barrelSegments: 12, + glazing: {}, + overhang: 0, + wallThickness: 0, + }) + const faces = getGlazedRoofFaces(node) + expect(faces.filter((face) => face.id.startsWith('barrel-'))).toHaveLength(12) + expect(faces.filter((face) => face.id.startsWith('end-'))).toHaveLength(2) + expect(getRoofSegmentSurfaceY(node, 0, 0)).toBeCloseTo(node.wallHeight + 2) + expect(getRoofSegmentSurfaceY(node, 0, 2)).toBeCloseTo(node.wallHeight) + expect(getRoofSegmentSurfaceY({ ...node, barrelRise: 20 }, 0, 0)).toBeCloseTo(node.wallHeight + 2) + expect(getRoofSegmentSurfaceY({ ...node, barrelRise: undefined, pitch: 0 }, 0, 0)).toBeCloseTo( + node.wallHeight + 1, + ) + const layout = getGlazedRoofLayout(node)[3]! + const u = (layout.across[0]! + layout.across[1]!) / 2 + const v = layout.face.length / 2 + const [x, , z] = glazedRoofFacePoint(layout.face, u, v) + const empty = { + ...node, + glazing: { + ...RoofSegmentNode.parse({ ...node }).glazing!, + panels: [{ face: 'barrel-3', column: 0, row: 0, type: 'empty' as const }], + }, + } + expect(glazedRoofSupportsPoint(node, x, z)).toBe(true) + expect(glazedRoofSupportsPoint(empty, x, z)).toBe(false) + expect(RoofSegmentNode.parse(JSON.parse(JSON.stringify(empty)))).toEqual(empty) + expect(getGlazedRoofPlanLines(node).length).toBeGreaterThan(12) +}) + +const vertices = (pieces: GlazedRoofPiece[]) => + pieces.flatMap((piece) => getGlazedRoofPieceFaces(piece).flat()) +const extent = (points: { x: number; y: number; z: number }[], axis: 'x' | 'y' | 'z') => [ + Math.min(...points.map((point) => point[axis])), + Math.max(...points.map((point) => point[axis])), +] + +for (const roofType of ['gable', 'shed'] as const) + test(`a glazed ${roofType} stands on walls centred on its footprint, and nothing rises through its covering`, () => { + const node = RoofSegmentNode.parse({ + roofType, + roofSystem: 'glazed', + width: 8, + depth: 6, + pitch: 30, + overhang: 0.3, + wallThickness: 0.2, + wallHeight: 0.5, + }) + const pieces = getGlazedRoofPieces(node) + const walls = vertices(pieces.filter((piece) => piece.face.normal[1] === 0 && piece.slot === 2)) + expect(extent(walls, 'x')).toEqual([-4.1, 4.1].map((x) => expect.closeTo(x))) + expect(extent(walls, 'z')).toEqual([-3.1, 3.1].map((z) => expect.closeTo(z))) + expect(extent(walls, 'y')[0]).toBeCloseTo(0) + // Everything under the covering's top stays below its frame depth: no face shares its plane. + const depth = getRoofGlazing(node).frameDepth + const under = vertices(pieces.filter((piece) => piece.face.normal[1] === 0)) + for (const { x, y, z } of under) + expect(y).toBeLessThanOrEqual(getRoofSegmentSurfaceY(node, x, z) - depth + 1e-6) + + expect( + extent(vertices(getGlazedRoofPieces({ ...node, wallShell: 'omit' })), 'y')[0], + ).toBeGreaterThan(0) + }) + +test('a barrel stands on walls centred on its footprint, as doors and windows place on them', () => { + const node = RoofSegmentNode.parse({ + roofType: 'barrel', + width: 6, + depth: 4, + barrelRise: 1, + wallThickness: 0.2, + wallHeight: 0.5, + }) + const walls = vertices( + getBarrelRoofSolidPieces(node).filter((piece) => piece.face.normal[1] === 0), + ) + expect(extent(walls, 'x')).toEqual([-3.1, 3.1].map((x) => expect.closeTo(x))) + expect(extent(walls, 'z')).toEqual([-2.1, 2.1].map((z) => expect.closeTo(z))) + expect(getRoofSegmentWallFace(node, 'right').length).toBeCloseTo(4.2) + expect(getRoofSegmentWallFace(node, 'front').length).toBeCloseTo(6.2) + // A window fits on the arched end above the wall top. + expect(clampRectToRoofWallFace(getRoofSegmentWallFace(node, 'right'), 2.1, 0.9, 1, 0.6)).toEqual({ + u: 2.1, + v: 0.9, + }) +}) diff --git a/packages/core/src/schema/nodes/roof-glazing.ts b/packages/core/src/schema/nodes/roof-glazing.ts new file mode 100644 index 0000000000..bb3dc6cfef --- /dev/null +++ b/packages/core/src/schema/nodes/roof-glazing.ts @@ -0,0 +1,669 @@ +import { getBarrelRoofProfile } from './barrel-roof' +import { curtainGridPositions } from './curtain-wall' +import { DEFAULT_ROOF_GLAZING, type RoofGlazing } from './roof-glazing-config' +import { + getActiveRoofHeight, + getRoofSegmentVisibleTopBounds, + getSegmentSlopeFrame, + normalizeRoofSegmentTrim, + type RoofSegmentNode, +} from './roof-segment' + +export { DEFAULT_ROOF_GLAZING, RoofGlazing, RoofSystemType } from './roof-glazing-config' +export function supportsRoofGlazing(node: Pick): boolean { + return ( + node.roofType === 'barrel' || + node.roofType === 'gable' || + (node.roofType === 'shed' && !node.arc) + ) +} + +export function isGlazedRoofSegment( + node: Pick, +): boolean { + return node.roofSystem === 'glazed' && supportsRoofGlazing(node) +} + +/** Built from its own pieces rather than the merged roof shell: a glazed segment, or a barrel. */ +export function isIndependentRoofSegment( + node: Pick, +): boolean { + return node.roofType === 'barrel' || isGlazedRoofSegment(node) +} + +export function getRoofGlazing(node: { glazing?: RoofGlazing }): RoofGlazing { + return node.glazing ?? DEFAULT_ROOF_GLAZING +} + +export type GlazedRoofPoint = readonly [number, number, number] +export type GlazedRoofFace = { + id: string + origin: GlazedRoofPoint + u: GlazedRoofPoint + v: GlazedRoofPoint + normal: GlazedRoofPoint + width: number + length: number + polygon: [number, number][] +} +export type GlazedRoofLayout = { + face: GlazedRoofFace + across: number[] + along: number[] +} + +/** Covering-top datum, with frame and pane thickness extending inward. */ +export function getGlazedRoofFaces(node: RoofSegmentNode): GlazedRoofFace[] { + if (!isGlazedRoofSegment(node)) return [] + if (node.roofType === 'barrel') return getBarrelRoofFaces(node) + const { minX, maxX, minZ, maxZ } = getRoofSegmentVisibleTopBounds({ + ...node, + trim: normalizeRoofSegmentTrim({ width: node.width, depth: node.depth }), + }) + const { tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) + const peak = node.wallHeight + getActiveRoofHeight(node) + const face = ( + id: string, + startZ: number, + endZ: number, + startY: number, + direction: number, + ): GlazedRoofFace => { + const length = Math.abs(endZ - startZ) / cosTheta + return { + id, + origin: [minX, startY, startZ], + u: [1, 0, 0], + v: [0, -sinTheta, direction * cosTheta], + normal: [0, cosTheta, direction * sinTheta], + width: maxX - minX, + length, + polygon: [ + [0, 0], + [maxX - minX, 0], + [maxX - minX, length], + [0, length], + ], + } + } + const faces = + node.roofType === 'gable' + ? [face('front', 0, maxZ, peak, 1), face('back', 0, minZ, peak, -1)] + : [face('slope', minZ, maxZ, peak - (minZ + node.depth / 2) * tanTheta, 1)] + if (!getRoofGlazing(node).glazedEnds) return faces + + // Only infill above the wall top; the existing room walls own the enclosure below. + const endLength = node.depth + const rise = getActiveRoofHeight(node) + if (rise <= 1e-6) return faces + const profile: [number, number][] = + node.roofType === 'gable' + ? [ + [0, 0], + [endLength, 0], + [endLength / 2, rise], + ] + : [ + [0, 0], + [endLength, 0], + [0, rise], + ] + const polygon = fitBelowCovering( + node, + profile, + [-node.depth / 2, node.wallHeight], + glazingCoveringDepth(node), + ) + for (const side of [-1, 1]) { + faces.push({ + id: side < 0 ? 'end-left' : 'end-right', + origin: [(side * node.width) / 2, node.wallHeight, -node.depth / 2], + u: [0, 0, 1], + v: [0, 1, 0], + normal: [side, 0, 0], + width: endLength, + length: rise, + polygon, + }) + } + return faces +} + +function clipGlazedRoofHalfPlane( + polygon: [number, number][], + signed: (point: [number, number]) => number, +): [number, number][] { + const result: [number, number][] = [] + for (let i = 0; i < polygon.length; i++) { + const start = polygon[i]!, + end = polygon[(i + 1) % polygon.length]! + const d0 = signed(start), + d1 = signed(end) + if (d0 <= 1e-9) result.push(start) + if (d0 <= 1e-9 !== d1 <= 1e-9) { + const t = d0 / (d0 - d1) + result.push([start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t]) + } + } + return result +} + +/** The covering's top across the span, as (z, y) points, front to back in +z. */ +function coveringTopLine(node: RoofSegmentNode): [number, number][] { + const half = node.depth / 2 + const rise = getActiveRoofHeight(node) + const eave = node.wallHeight + if (node.roofType === 'barrel') + return getBarrelRoofProfile(node.depth, rise, node.barrelSegments ?? 12).map(([z, y]) => [ + z, + y + eave, + ]) + if (node.roofType === 'gable') + return [ + [-half, eave], + [0, eave + rise], + [half, eave], + ] + // A shed falls toward +z. + return [ + [-half, eave + rise], + [half, eave], + ] +} + +/** How deep the glazed covering reaches below its top. */ +function glazingCoveringDepth(node: RoofSegmentNode): number { + const config = getRoofGlazing(node) + return Math.max(config.frameDepth, config.glassThickness) +} + +/** + * Clip a polygon in a plane across the span, (u, v) placed at `origin` = (z, y), to below the + * covering's underside: end infill and walls meet the covering instead of running through it. + */ +function fitBelowCovering( + node: RoofSegmentNode, + polygon: [number, number][], + origin: readonly [number, number], + coveringDepth: number, +): [number, number][] { + const line = coveringTopLine(node) + let result = polygon + for (let i = 1; i < line.length; i++) { + const a = line[i - 1]!, + b = line[i]! + const dy = b[1] - a[1], + dz = b[0] - a[0] + const length = Math.hypot(dy, dz) + const nz = -dy / length, + ny = dz / length + result = clipGlazedRoofHalfPlane( + result, + ([u, v]) => nz * (u + origin[0] - a[0]) + ny * (v + origin[1] - a[1]) + coveringDepth, + ) + } + return result +} + +/** A wall piece: a cross-section in (z, y), run along x from `minX` to `maxX`. */ +function wallPiece( + id: string, + minX: number, + maxX: number, + polygon: [number, number][], + slot: number, +): GlazedRoofPiece { + const zs = polygon.map(([z]) => z) + const ys = polygon.map(([, y]) => y) + return { + face: { + id, + origin: [maxX, 0, 0], + u: [0, 0, 1], + v: [0, 1, 0], + normal: [1, 0, 0], + width: Math.max(...zs) - Math.min(...zs), + length: Math.max(...ys) - Math.min(...ys), + polygon, + }, + polygon, + depth: maxX - minX, + slot, + } +} + +const rect = (minZ: number, maxZ: number, minY: number, maxY: number): [number, number][] => [ + [minZ, minY], + [maxZ, minY], + [maxZ, maxY], + [minZ, maxY], +] + +/** + * The walls under a segment, centred on its footprint like every roof's, up to the wall top; a + * solid barrel's ends rise on to its arch, where a glazed one has its glazing. A gable's or a + * shed's covering reaches past the walls, so they fit below it; a barrel's arch springs from the + * wall top, so only its arched ends do. + */ +function getRoofWallPieces( + node: RoofSegmentNode, + coveringDepth: number, + slot: number, +): GlazedRoofPiece[] { + const t = node.wallThickness + if (node.wallShell === 'omit' || !(t > 0)) return [] + const halfW = node.width / 2, + halfD = node.depth / 2, + wallTop = node.wallHeight + const barrel = node.roofType === 'barrel' + const fit = (polygon: [number, number][]) => + barrel ? polygon : fitBelowCovering(node, polygon, [0, 0], coveringDepth) + const pieces: GlazedRoofPiece[] = [] + for (const side of [-1, 1]) { + const minX = side * halfW - t / 2, + maxX = side * halfW + t / 2 + const id = side < 0 ? 'end-left' : 'end-right' + if (wallTop > 0) + pieces.push( + wallPiece(id, minX, maxX, fit(rect(-halfD - t / 2, halfD + t / 2, 0, wallTop)), slot), + ) + if (barrel && !isGlazedRoofSegment(node)) + pieces.push( + wallPiece( + `${id}-arch`, + minX, + maxX, + fitBelowCovering( + node, + rect(-halfD, halfD, wallTop, wallTop + getActiveRoofHeight(node)), + [0, 0], + coveringDepth, + ), + slot, + ), + ) + } + if (wallTop > 0) + for (const side of [-1, 1]) { + const inner = side * (halfD - t / 2), + outer = side * (halfD + t / 2) + pieces.push( + wallPiece( + side < 0 ? 'back' : 'front', + -halfW + t / 2, + halfW - t / 2, + fit(rect(Math.min(inner, outer), Math.max(inner, outer), 0, wallTop)), + slot, + ), + ) + } + return pieces.filter((piece) => piece.polygon.length >= 3) +} + +/** Circular arch, represented by planar glazing bays. The stored span stays at the eaves. */ +export function getBarrelRoofFaces(node: RoofSegmentNode): GlazedRoofFace[] { + const half = Math.max(0.005, node.depth / 2) + const rise = getActiveRoofHeight(node) + const count = node.barrelSegments ?? 12 + const extension = Math.max(0, node.overhang) + Math.max(0, node.wallThickness) / 2 + const minX = -node.width / 2 - extension + const width = node.width + 2 * extension + const points = getBarrelRoofProfile(node.depth, rise, count).map(([z, y]): [number, number] => [ + z, + y + node.wallHeight, + ]) + const faces: GlazedRoofFace[] = [] + for (let i = 0; i < count; i++) { + const a = points[i]!, + b = points[i + 1]! + const dy = b[1] - a[1], + dz = b[0] - a[0] + const length = Math.hypot(dy, dz) + faces.push({ + id: `barrel-${i}`, + origin: [minX, a[1], a[0]], + u: [1, 0, 0], + v: [0, dy / length, dz / length], + normal: [0, dz / length, -dy / length], + width, + length, + polygon: [ + [0, 0], + [width, 0], + [width, length], + [0, length], + ], + }) + } + if (node.roofSystem !== 'glazed' || getRoofGlazing(node).glazedEnds) { + const profile: [number, number][] = [ + [0, 0], + [node.depth, 0], + ...points + .slice(1, -1) + .reverse() + .map(([z, y]): [number, number] => [z + half, y - node.wallHeight]), + ] + for (const side of [-1, 1]) + faces.push({ + id: side < 0 ? 'end-left' : 'end-right', + origin: [(side * node.width) / 2, node.wallHeight, -half], + u: [0, 0, 1], + v: [0, 1, 0], + normal: [side, 0, 0], + width: node.depth, + length: rise, + polygon: fitBelowCovering( + node, + profile, + [-half, node.wallHeight], + glazingCoveringDepth(node), + ), + }) + } + return faces +} + +export function getBarrelRoofSolidPieces(node: RoofSegmentNode): GlazedRoofPiece[] { + const faces = getBarrelRoofFaces(node) + const pieces: GlazedRoofPiece[] = faces + .filter((face) => face.normal[1] > 0) + .map((face) => ({ + face, + polygon: face.polygon, + depth: node.deckThickness + node.shingleThickness, + slot: 3, + })) + pieces.push(...getRoofWallPieces(node, node.deckThickness + node.shingleThickness, 2)) + return pieces.filter((piece) => piece.depth > 0) +} + +export function getGlazedRoofLayout(node: RoofSegmentNode): GlazedRoofLayout[] { + const config = getRoofGlazing(node) + return getGlazedRoofFaces(node).map((face) => ({ + face, + across: curtainGridPositions(face.width, config.across), + along: curtainGridPositions(face.length, config.along), + })) +} + +export function getGlazedRoofPanelType( + node: RoofSegmentNode, + face: string, + column: number, + row: number, +) { + const config = getRoofGlazing(node) + return ( + config.panels.find( + (panel) => panel.face === face && panel.column === column && panel.row === row, + )?.type ?? config.panelType + ) +} + +export function glazedRoofFacePoint(face: GlazedRoofFace, u: number, v: number): GlazedRoofPoint { + return [ + face.origin[0] + face.u[0] * u + face.v[0] * v, + face.origin[1] + face.u[1] * u + face.v[1] * v, + face.origin[2] + face.u[2] * u + face.v[2] * v, + ] +} + +function trimConstraints(node: RoofSegmentNode): [number, number, number][] { + const trim = normalizeRoofSegmentTrim(node) + const left = -node.width / 2 + trim.left, + right = node.width / 2 - trim.right + const front = node.depth / 2 - trim.front, + back = -node.depth / 2 + trim.back + const constraints: [number, number, number][] = [] + if (trim.left > 0) constraints.push([1, 0, -left]) + if (trim.right > 0) constraints.push([-1, 0, right]) + if (trim.front > 0) constraints.push([0, -1, front]) + if (trim.back > 0) constraints.push([0, 1, -back]) + for (const [x, z, dx, dz, signX, signZ] of [ + [left, front, trim.frontLeftX, trim.frontLeftZ, 1, -1], + [right, front, trim.frontRightX, trim.frontRightZ, -1, -1], + [left, back, trim.backLeftX, trim.backLeftZ, 1, 1], + [right, back, trim.backRightX, trim.backRightZ, -1, 1], + ]) { + if (dx! > 0 && dz! > 0) + constraints.push([signX! / dx!, signZ! / dz!, -1 - (signX! * x!) / dx! - (signZ! * z!) / dz!]) + } + return constraints +} + +export function getGlazedRoofPlanLines( + node: RoofSegmentNode, +): [readonly [number, number], readonly [number, number]][] { + const lines: [readonly [number, number], readonly [number, number]][] = [] + for (const { face, across, along } of getGlazedRoofLayout(node)) { + if (face.normal[1] <= 0) continue + const emit = (start: GlazedRoofPoint, end: GlazedRoofPoint) => { + let lo = 0, + hi = 1 + for (const [a, b, c] of trimConstraints(node)) { + const d0 = a * start[0] + b * start[2] + c + const d1 = a * end[0] + b * end[2] + c + if (d0 < 0 && d1 < 0) return + if (d0 < 0) lo = Math.max(lo, d0 / (d0 - d1)) + if (d1 < 0) hi = Math.min(hi, d0 / (d0 - d1)) + } + if (hi <= lo) return + const at = (t: number): [number, number] => [ + start[0] + (end[0] - start[0]) * t, + start[2] + (end[2] - start[2]) * t, + ] + lines.push([at(lo), at(hi)]) + } + for (const u of across) + emit(glazedRoofFacePoint(face, u, 0), glazedRoofFacePoint(face, u, face.length)) + for (const v of along) + emit(glazedRoofFacePoint(face, 0, v), glazedRoofFacePoint(face, face.width, v)) + } + return lines +} + +export function glazedRoofSupportsPoint(node: RoofSegmentNode, x: number, z: number): boolean { + if (!isGlazedRoofSegment(node)) return false + if (trimConstraints(node).some(([a, b, c]) => a * x + b * z + c < -1e-6)) return false + const config = getRoofGlazing(node) + for (const { face, across, along } of getGlazedRoofLayout(node)) { + if (face.normal[1] <= 0) continue + const u = x - face.origin[0], + v = (z - face.origin[2]) / face.v[2] + if (u < 0 || u > face.width || v < 0 || v > face.length) continue + const column = across.findIndex((position, index) => index > 0 && position >= u) - 1 + const row = along.findIndex((position, index) => index > 0 && position >= v) - 1 + if (column < 0 || row < 0) continue + if (getGlazedRoofPanelType(node, face.id, column, row) !== 'empty') return true + const width = Math.min(config.frameWidth, face.width / 4, face.length / 4) + if ( + across.some((position) => Math.abs(u - position) <= width / 2) || + along.some((position) => Math.abs(v - position) <= width / 2) + ) + return true + } + return false +} + +export function clipGlazedRoofPolygon( + polygon: readonly (readonly [number, number])[], + bounds: { minU: number; maxU: number; minV: number; maxV: number }, +): [number, number][] { + let result = polygon.map(([u, v]): [number, number] => [u, v]) + for (const [axis, value, sign] of [ + [0, bounds.minU, 1], + [0, bounds.maxU, -1], + [1, bounds.minV, 1], + [1, bounds.maxV, -1], + ] as const) { + const input = result + result = [] + for (let i = 0; i < input.length; i++) { + const a = input[i]! + const b = input[(i + 1) % input.length]! + const aInside = (a[axis] - value) * sign >= -1e-9 + const bInside = (b[axis] - value) * sign >= -1e-9 + if (aInside) result.push(a) + if (aInside !== bInside) { + const t = (value - a[axis]) / (b[axis] - a[axis]) + result.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]) + } + } + } + return result +} + +/** Inset a convex pane boundary so diagonal end frames never overlap its glass. */ +export function insetGlazedRoofPolygon( + polygon: [number, number][], + distance: number, +): [number, number][] { + let result = polygon + for (let edge = 0; edge < polygon.length; edge++) { + const a = polygon[edge]!, + b = polygon[(edge + 1) % polygon.length]! + const dx = b[0] - a[0], + dy = b[1] - a[1] + const length = Math.hypot(dx, dy) + if (length < 1e-9) continue + const signed = (point: [number, number]) => + (dx * (point[1] - a[1]) - dy * (point[0] - a[0])) / length - distance + const input = result + result = [] + for (let i = 0; i < input.length; i++) { + const start = input[i]!, + end = input[(i + 1) % input.length]! + const d0 = signed(start), + d1 = signed(end) + if (d0 >= 0) result.push(start) + if (d0 >= 0 !== d1 >= 0) { + const t = d0 / (d0 - d1) + result.push([start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t]) + } + } + } + return result +} + +export type GlazedRoofPiece = { + face: GlazedRoofFace + polygon: [number, number][] + depth: number + slot: number +} + +export function getGlazedRoofPieces(node: RoofSegmentNode): GlazedRoofPiece[] { + const config = getRoofGlazing(node) + const pieces: GlazedRoofPiece[] = [] + const add = (face: GlazedRoofFace, polygon: [number, number][], depth: number, slot: number) => { + const clean = polygon.filter((point, index) => { + const previous = polygon[(index + polygon.length - 1) % polygon.length]! + return Math.hypot(point[0] - previous[0], point[1] - previous[1]) > 1e-8 + }) + let area = 0 + for (let i = 0; i < clean.length; i++) { + const a = clean[i]!, + b = clean[(i + 1) % clean.length]! + area += a[0] * b[1] - b[0] * a[1] + } + if (clean.length >= 3 && Math.abs(area) > 1e-8) + pieces.push({ face, polygon: clean, depth, slot }) + } + for (const { face, across, along } of getGlazedRoofLayout(node)) { + const width = Math.min(config.frameWidth, face.width / 4, face.length / 4) + for (let column = 0; column < across.length - 1; column++) { + for (let row = 0; row < along.length - 1; row++) { + const type = getGlazedRoofPanelType(node, face.id, column, row) + if (type === 'empty') continue + const minU = across[column]!, + maxU = across[column + 1]! + const minV = along[row]!, + maxV = along[row + 1]! + const inset = Math.min(width / 2, (maxU - minU) / 4, (maxV - minV) / 4) + const pane = clipGlazedRoofPolygon(insetGlazedRoofPolygon(face.polygon, width), { + minU: minU + inset, + maxU: maxU - inset, + minV: minV + inset, + maxV: maxV - inset, + }) + add(face, pane, config.glassThickness, type === 'glass' ? 1 : 2) + } + } + for (const u of across.slice(1, -1)) { + add( + face, + clipGlazedRoofPolygon(insetGlazedRoofPolygon(face.polygon, width), { + minU: u - width / 2, + maxU: u + width / 2, + minV: 0, + maxV: face.length, + }), + config.frameDepth, + 0, + ) + } + for (const v of along.slice(1, -1)) { + add( + face, + clipGlazedRoofPolygon(insetGlazedRoofPolygon(face.polygon, width), { + minU: 0, + maxU: face.width, + minV: v - width / 2, + maxV: v + width / 2, + }), + config.frameDepth, + 0, + ) + } + // Nearest-edge partitions form miter joints without overlapping border faces. + const edgeDistances = face.polygon.map((a, i) => { + const b = face.polygon[(i + 1) % face.polygon.length]! + const dx = b[0] - a[0], + dy = b[1] - a[1] + const length = Math.hypot(dx, dy) + return (point: [number, number]) => + length < 1e-9 + ? Number.POSITIVE_INFINITY + : (dx * (point[1] - a[1]) - dy * (point[0] - a[0])) / length + }) + for (const distance of edgeDistances) { + let border = clipGlazedRoofHalfPlane(face.polygon, (point) => distance(point) - width) + for (const other of edgeDistances) + if (other !== distance) + border = clipGlazedRoofHalfPlane(border, (point) => distance(point) - other(point)) + add(face, border, config.frameDepth, 0) + } + } + for (const wall of getRoofWallPieces(node, glazingCoveringDepth(node), 2)) + add(wall.face, wall.polygon, wall.depth, wall.slot) + return pieces +} + +export function getGlazedRoofPieceFaces( + piece: GlazedRoofPiece, +): { x: number; y: number; z: number }[][] { + const { face, depth } = piece + let top = piece.polygon.map(([u, v]) => { + const [x, y, z] = glazedRoofFacePoint(face, u, v) + return { x, y, z } + }) + const cross = [ + face.u[1] * face.v[2] - face.u[2] * face.v[1], + face.u[2] * face.v[0] - face.u[0] * face.v[2], + face.u[0] * face.v[1] - face.u[1] * face.v[0], + ] + if (cross[0]! * face.normal[0] + cross[1]! * face.normal[1] + cross[2]! * face.normal[2] < 0) + top = top.reverse() + const bottom = top.map(({ x, y, z }) => ({ + x: x - face.normal[0] * depth, + y: y - face.normal[1] * depth, + z: z - face.normal[2] * depth, + })) + const faces = [top, [...bottom].reverse()] + for (let i = 0; i < top.length; i++) { + const next = (i + 1) % top.length + faces.push([top[i]!, bottom[i]!, bottom[next]!, top[next]!]) + } + return faces +} diff --git a/packages/core/src/schema/nodes/roof-segment-resize.ts b/packages/core/src/schema/nodes/roof-segment-resize.ts new file mode 100644 index 0000000000..69d3e1bc53 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-resize.ts @@ -0,0 +1,29 @@ +import { + getActiveRoofHeight, + getPitchFromActiveRoofHeight, + type RoofSegmentNode, +} from './roof-segment' + +/** Resize one footprint edge while keeping its opposite edge and the crown height fixed. */ +export function getRoofSegmentResizePatch( + node: RoofSegmentNode, + axis: 'x' | 'z', + side: 1 | -1, + dimension: number, +): Partial { + const initial = axis === 'x' ? node.width : node.depth + const offset = (side * (dimension - initial)) / 2 + const rotation = node.rotation ?? 0 + const position: [number, number, number] = [ + node.position[0] + offset * (axis === 'x' ? Math.cos(rotation) : Math.sin(rotation)), + node.position[1], + node.position[2] + offset * (axis === 'x' ? -Math.sin(rotation) : Math.cos(rotation)), + ] + if (node.roofType === 'conical') return { width: dimension, depth: dimension, position } + if (axis === 'x') return { width: dimension, position } + const rise = getActiveRoofHeight(node) + if (node.roofType === 'barrel') + return { depth: dimension, barrelRise: Math.min(dimension / 2, rise), position } + const pitch = getPitchFromActiveRoofHeight({ ...node, depth: dimension, roofHeight: rise }) + return { depth: dimension, pitch: Math.max(0, Math.min(85, pitch)), position } +} diff --git a/packages/core/src/schema/nodes/roof-segment-shape.ts b/packages/core/src/schema/nodes/roof-segment-shape.ts index ee5b7c065e..b39b3e5fde 100644 --- a/packages/core/src/schema/nodes/roof-segment-shape.ts +++ b/packages/core/src/schema/nodes/roof-segment-shape.ts @@ -1,3 +1,4 @@ +import { getBarrelRoofProfile } from './barrel-roof' import { ROOF_SHAPE_DEFAULTS, type RoofType } from './roof-segment' export type RoofShapeFaceVertex = { @@ -8,12 +9,21 @@ export type RoofShapeFaceVertex = { export type RoofShapeEaveSide = '+X' | '-X' | '+Z' | '-Z' +/** How far the eaves reach past the walls, where gutters hang: a barrel's arch springs from its eaves. */ +export function getRoofShapeEaveOverhang(segment: { + roofType?: RoofType + overhang?: number +}): number { + return segment.roofType === 'barrel' ? 0 : (segment.overhang ?? 0) +} + export function getRoofShapeEaveSides(type: RoofType): RoofShapeEaveSide[] { switch (type) { case 'conical': return [] case 'shed': return ['+Z'] + case 'barrel': case 'gable': case 'gambrel': return ['+Z', '-Z'] @@ -300,6 +310,45 @@ export function getRoofModuleFaces(input: { const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z }) const { iF = 0, iB = 0, iL = 0, iR = 0 } = input.insets + if (input.type === 'barrel' && input.rh > 0) { + const profile = getBarrelRoofProfile(input.d, input.rh, 32) + const left = profile.map(([z, y]) => v(-input.w / 2, input.wh + y, z)) + const right = profile.map(([z, y]) => v(input.w / 2, input.wh + y, z)) + const faces = [ + [ + v(-input.w / 2, input.baseY, -input.d / 2), + v(-input.w / 2, input.baseY, input.d / 2), + ...[...left].reverse(), + ], + [ + v(input.w / 2, input.baseY, input.d / 2), + v(input.w / 2, input.baseY, -input.d / 2), + ...right, + ], + [ + v(-input.w / 2, input.baseY, input.d / 2), + v(input.w / 2, input.baseY, input.d / 2), + right.at(-1)!, + left.at(-1)!, + ], + [ + v(input.w / 2, input.baseY, -input.d / 2), + v(-input.w / 2, input.baseY, -input.d / 2), + left[0]!, + right[0]!, + ], + [ + v(-input.w / 2, input.baseY, -input.d / 2), + v(input.w / 2, input.baseY, -input.d / 2), + v(input.w / 2, input.baseY, input.d / 2), + v(-input.w / 2, input.baseY, input.d / 2), + ], + ] + for (let i = 0; i < profile.length - 1; i++) + faces.push([left[i]!, left[i + 1]!, right[i + 1]!, right[i]!]) + return faces + } + if (input.type === 'conical') { const startAngle = Number.isFinite(input.conicalStartAngle) ? input.conicalStartAngle! : 0 const requestedSweep = Number.isFinite(input.conicalSweepAngle) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts index 21bebe781b..7477c47e62 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -1,4 +1,5 @@ import { getWallFaceOffsets } from '../../systems/wall/wall-frame' +import { getBarrelRoofProfile } from './barrel-roof' import type { RoofSegmentNode } from './roof-segment' import { getDutchRoofMetrics, getSegmentSlopeFrame } from './roof-segment' @@ -50,6 +51,8 @@ type SegmentWallInputs = Pick< Partial< Pick< RoofSegmentNode, + | 'barrelRise' + | 'barrelSegments' | 'gambrelLowerWidthRatio' | 'gambrelLowerHeightRatio' | 'mansardSteepWidthRatio' @@ -75,6 +78,15 @@ type WallVolumeFrame = { function getWallVolumeFrame(node: SegmentWallInputs): WallVolumeFrame { const { activeRh, tanTheta } = getSegmentSlopeFrame(node) + if (node.roofType === 'barrel') + return { + wV: node.width + (node.wallThickness ?? 0.1), + dV: node.depth + (node.wallThickness ?? 0.1), + eaveY: node.wallHeight, + peakY: node.wallHeight + activeRh, + tanTheta, + hasSlope: true, + } const wallThickness = node.wallThickness ?? 0.1 const autoDrop = getWallFaceOffsets({ thickness: wallThickness }).a * tanTheta const wV = Math.max(0.01, node.width + wallThickness) @@ -130,6 +142,23 @@ function buildFaceProfile( if (!hasSlope) return rectProfile(length, eaveY) switch (node.roofType) { + case 'barrel': { + if (!isEnd) return rectProfile(length, node.wallHeight) + const profile = getBarrelRoofProfile( + node.depth, + getSegmentSlopeFrame(node).activeRh, + node.barrelSegments ?? 12, + ) + // The arch springs from the eaves' centre lines, inside the walls' outer faces; the placeable + // region stays convex by leaving out the walls' outer halves above their base corners. + return [ + [0, 0], + [length, 0], + ...profile + .reverse() + .map(([z, y]): [number, number] => [z + length / 2, y + node.wallHeight]), + ] + } case 'gable': { if (!isEnd) return rectProfile(length, eaveY) return [ diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 67beb65a09..5c0408521e 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -3,10 +3,11 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import type { MaterialSchema as MaterialSchemaType } from '../material' import { MaterialSchema } from '../material' +import { getBarrelRoofProfile } from './barrel-roof' +import { RoofGlazing, RoofSystemType } from './roof-glazing' import { RoofType } from './roof-types' export { RoofType } from './roof-types' - export const MIN_ROOF_SEGMENT_TRIM_SPAN = 0.1 const DEFAULT_ROOF_SEGMENT_WIDTH = 8 const DEFAULT_ROOF_SEGMENT_DEPTH = 6 @@ -102,6 +103,11 @@ export const RoofSegmentNode = BaseNode.extend({ rotation: z.number().default(0), // Roof shape type roofType: RoofType.default('gable'), + barrelRise: z.number().finite().positive().optional(), + barrelSegments: z.number().int().min(4).max(32).optional(), + roofSystem: RoofSystemType.optional(), + glazing: RoofGlazing.optional(), + slots: z.record(z.string(), z.string()).optional(), // Footprint dimensions width: z.number().default(8), depth: z.number().default(6), @@ -238,7 +244,9 @@ export const RoofSegmentNode = BaseNode.extend({ Roof segment node - an individual roof module within a roof group. Each segment generates a complete architectural volume (walls + roof). Multiple segments can be combined to form complex roof shapes. - - roofType: hip, gable, shed, gambrel, dutch, mansard, flat, conical + - roofType: hip, gable, shed, gambrel, dutch, mansard, flat, conical, barrel + - barrelRise: arch rise in metres, limited to half the depth/span + - barrelSegments: 4โ€“32 flat arch bays, default 12 - width/depth: footprint dimensions - conicalStartAngle / conicalSweepAngle: angular extent of a conical sector (radians) - conicalFullCircle: temporarily render the complete cone while preserving the sector angles @@ -580,8 +588,17 @@ export type SegmentSlopeFrame = { * silently drifted when a new roof type was added. */ export function getSegmentSlopeFrame( - node: Pick & Partial, + node: Pick & + Partial & + Pick, 'barrelRise' | 'barrelSegments'>, ): SegmentSlopeFrame { + if (node.roofType === 'barrel') { + const run = Math.max(0.005, node.depth / 2) + const rise = Math.min(run, node.barrelRise ?? run / 2) + const tanTheta = rise / run + const cosTheta = 1 / Math.sqrt(1 + tanTheta * tanTheta) + return { run, rise, tanTheta, cosTheta, sinTheta: tanTheta * cosTheta, activeRh: rise } + } const ratios = withRatioDefaults(node) const run = getPrimarySlopeRun(ratios) // `!(pitch > 0)` (not `pitch <= 0`) so a missing/NaN pitch โ€” e.g. a segment @@ -632,6 +649,8 @@ export function getRoofSegmentDeckReach( Partial>, ): { x: number; z: number } { const half = finiteNonNegative(segment.wallThickness) / 2 + // A barrel's arch springs from its eaves, and its ends reach out by the overhang, unsloped. + if (segment.roofType === 'barrel') return { x: half + finiteNonNegative(segment.overhang), z: 0 } const z = half + finiteNonNegative(segment.overhang) * getSegmentSlopeFrame(segment).cosTheta const gable = segment.gableOverhang const raked = roofTypeHasRakes(segment.roofType) && Number.isFinite(gable) @@ -661,7 +680,8 @@ export function getRoofSegmentVisibleTopBounds( const depth = finitePositive(segment.depth, DEFAULT_ROOF_SEGMENT_DEPTH) const trim = normalizeRoofSegmentTrim({ ...segment, width, depth }) const reach = getRoofSegmentDeckReach(segment) - const shingleOverhang = finiteNonNegative(segment.shingleThickness) * sinTheta + const shingleOverhang = + segment.roofSystem === 'glazed' ? 0 : finiteNonNegative(segment.shingleThickness) * sinTheta let xExt = reach.x let frontExt = reach.z @@ -733,6 +753,17 @@ export function getRoofSegmentSurfaceY( ): number { const slopeFrame = getSegmentSlopeFrame(node) const activeRh = slopeFrame.activeRh + if (node.roofType === 'barrel') { + const profile = getBarrelRoofProfile(node.depth, activeRh, node.barrelSegments ?? 12) + const z = Math.max(-node.depth / 2, Math.min(node.depth / 2, localZ)) + for (let i = 1; i < profile.length; i++) { + const a = profile[i - 1]!, + b = profile[i]! + if (z <= b[0] + 1e-8) + return node.wallHeight + a[1] + ((b[1] - a[1]) * (z - a[0])) / (b[0] - a[0]) + } + return node.wallHeight + } const peakY = node.wallHeight + activeRh if (activeRh === 0) return node.wallHeight diff --git a/packages/core/src/schema/nodes/roof-types.ts b/packages/core/src/schema/nodes/roof-types.ts index df34fe4826..c24e25b2d3 100644 --- a/packages/core/src/schema/nodes/roof-types.ts +++ b/packages/core/src/schema/nodes/roof-types.ts @@ -11,6 +11,7 @@ export const RoofType = z.enum([ 'mansard', 'flat', 'conical', + 'barrel', ]) export type RoofType = z.infer diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 6692510d76..6bf654b650 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -79,6 +79,7 @@ type WallMergePlan = { } const DEFAULT_RIDGE_VENT_REFRESH_FIELDS = new Set([ + 'roofSystem', 'roofType', 'width', 'depth', diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index ba97963e7f..afd69ad08b 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -17,6 +17,7 @@ import { } from '../schema/nodes/dormer' import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator' import { LevelNode, normalizeLevelBaseElevation } from '../schema/nodes/level' +import { RoofGlazing } from '../schema/nodes/roof-glazing' import { getPitchFromActiveRoofHeight, type RoofSegmentNode, @@ -699,10 +700,16 @@ function migrateNodes(nodes: Record): { // The migration result is cast, not zod-parsed, so the schema default never // applies on its own โ€” this branch is the only place it lands. if (node.type === 'roof-segment') { + if (node.glazing !== undefined) { + const glazing = RoofGlazing.safeParse(node.glazing) + if (glazing.success) patchedNodes[id] = { ...patchedNodes[id], glazing: glazing.data } + } const currentPitch = (node as { pitch?: unknown }).pitch const hasValidPitch = typeof currentPitch === 'number' && currentPitch > 0 if (!hasValidPitch) { - const { roofHeight, ...rest } = node as RoofSegmentNode & { roofHeight?: unknown } + const { roofHeight, ...rest } = patchedNodes[id] as RoofSegmentNode & { + roofHeight?: unknown + } const width = typeof node.width === 'number' ? node.width : 8 const depth = typeof node.depth === 'number' ? node.depth : 6 const roofType = (typeof node.roofType === 'string' ? node.roofType : 'gable') as RoofType diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 118536c067..d01042154a 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -6,8 +6,6 @@ import { emitter, floorPlatePaintRefusal, type GridEvent, - getEffectiveRoofSurfaceMaterial, - getEffectiveSegmentSurfaceMaterial, getRoofSegmentDeckReach, getRoofSegmentSurfaceY, getSelectableKinds, @@ -30,13 +28,7 @@ import { useRegistryVersion, useScene, } from '@pascal-app/core' -import { - createMaterial, - getRoofMaterialArray, - registerMaterialCacheCleanup, - resolveMaterialRef, - useViewer, -} from '@pascal-app/viewer' +import { getRoofMaterialArray, registerMaterialCacheCleanup, useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three' @@ -64,7 +56,6 @@ import { import { selectionEnabled } from '../../lib/interaction/scope' import { type ActivePaintMaterial, - buildRoofSegmentSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch, hasActivePaintMaterial, resolveActivePaintMaterialFromSelection, @@ -502,47 +493,6 @@ function applyRoofPaintPreview( return previewMeshMaterial(mesh, previewMaterial) } -function applyRoofSegmentPaintPreview( - node: RoofSegmentNode, - parent: RoofNode | null, - role: 'top' | 'edge' | 'wall', - material: ActivePaintMaterial, -): PaintPreviewCleanup | null { - const mesh = getRegisteredMesh(node.id) - if (!mesh) return null - - // Synthesise the segment node as if the paint had committed, then build - // the same 4-slot array the renderer would. Mirrors getRoofMaterialArray - // layout (slot 0 โ† edge, 1 โ† wall, 2 โ† wall, 3 โ† top) so the preview - // material lands on the matching CSG groups. - const previewNode: RoofSegmentNode = { - ...node, - ...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset), - } - const sceneMaterials = useScene.getState().materials - const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => { - const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined - const spec = getEffectiveSegmentSurfaceMaterial(previewNode, r, parentSpec) - const resolved = resolveMaterialRef(spec.materialPreset, sceneMaterials) - if (resolved) return resolved - if (spec.material !== undefined) return createMaterial(spec.material) - return null - } - const edge = resolveSlot('edge') - const wall = resolveSlot('wall') - const top = resolveSlot('top') - if (!(edge || wall || top)) return null - const fallback = parent - ? getRoofMaterialArray(parent, undefined, undefined, undefined, undefined, null, sceneMaterials) - : null - const fb = (n: number) => fallback?.[n] ?? null - // Per-role only, then the parent's themed slot โ€” matches the renderer so the - // preview never bleeds a painted surface onto the segment's other surfaces. - const arr: Material[] = [edge ?? fb(0)!, wall ?? fb(1)!, wall ?? fb(2)!, top ?? fb(3)!] - if (arr.some((m) => !m)) return null - return previewMeshMaterial(mesh, arr) -} - // Chimney + dormer paint dispatch lives on their NodeDefinition's // `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/ // paint.ts). The generic registry-driven arm in this file consults @@ -1109,30 +1059,15 @@ export const SelectionManager = () => { } } - if (node.type === 'roof' || node.type === 'roof-segment') { - const isSegmentHit = node.type === 'roof-segment' - const roofNode = - node.type === 'roof' - ? node - : node.parentId - ? useScene.getState().nodes[node.parentId as AnyNodeId] - : null - if (roofNode?.type !== 'roof') return null - - const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent) - if (isPaintPicking()) - return pickInteraction(isSegmentHit ? node : roofNode, role, event, null) + // A segment paints through its definition's `capabilities.paint`; the merged shell's hit is + // the roof's, and fans to it. + if (node.type === 'roof') { + const role = resolveRoofMaterialTarget(event as RoofEvent) + if (isPaintPicking()) return pickInteraction(node, role, event, null) const compatible = role !== null && paintEnabled - // Painting directly on a segment (only possible in segment edit - // mode, where the per-segment mesh is visible) writes to the - // segment's own role-specific fields. Painting the merged shell - // โ€” or a roof node directly โ€” keeps fanning to the parent roof. - const segmentTarget = isSegmentHit ? (node as RoofSegmentNode) : null return { - key: `${segmentTarget ? 'roof-segment' : 'roof'}:${ - segmentTarget ? segmentTarget.id : roofNode.id - }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, - hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId, + key: `roof:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, + hoveredId: node.id as AnyNodeId, hoverMode: compatible ? (eraser ? 'erase-ready' : 'paint-ready') : 'paint-disabled', // Roof isn't on the slot model (role-specific fields, custom commit), // so it offers only the single surface โ€” but still labels it. @@ -1140,53 +1075,34 @@ export const SelectionManager = () => { compatible && role ? { scopes: ['single'], - slotLabel: slotDisplayLabel(roofNode, role), + slotLabel: slotDisplayLabel(node, role), nodeNoun: 'roof', } : null, apply: compatible && role ? () => { - const sceneState = useScene.getState() - if (segmentTarget) { - sceneState.updateNode( - segmentTarget.id as AnyNodeId, - buildRoofSegmentSurfaceMaterialPatch( - segmentTarget, - role, - paintSpec.material, - paintSpec.materialPreset, - ), - ) - } else { - sceneState.updateNode( - roofNode.id as AnyNodeId, + useScene + .getState() + .updateNode( + node.id as AnyNodeId, buildRoofSurfaceMaterialPatch( - roofNode as RoofNode, + node, role, paintSpec.material, paintSpec.materialPreset, ), ) - } } : null, preview: compatible && role - ? () => - segmentTarget - ? applyRoofSegmentPaintPreview( - segmentTarget, - roofNode as RoofNode, - role, - paintSpec, - ) - : applyRoofPaintPreview(roofNode as RoofNode, role, paintSpec) + ? () => applyRoofPaintPreview(node, role, paintSpec) : () => previewCursor('not-allowed'), } } - // Only `roof` / `roof-segment` reach a legacy paint arm (above) โ€” every + // Only `roof` reaches a legacy paint arm (above) โ€” every // other paintable kind declares `capabilities.paint` and returns from the // registry-driven dispatch at the top of this function. diff --git a/packages/editor/src/components/ui/controls/segmented-control.tsx b/packages/editor/src/components/ui/controls/segmented-control.tsx index 75dd6d6aae..ecbe6bfb8a 100644 --- a/packages/editor/src/components/ui/controls/segmented-control.tsx +++ b/packages/editor/src/components/ui/controls/segmented-control.tsx @@ -5,7 +5,7 @@ import { cn } from '../../../lib/utils' interface SegmentedControlProps { value: T onChange: (value: T) => void - options: { label: React.ReactNode; value: T }[] + options: { label: React.ReactNode; value: T; disabled?: boolean }[] className?: string disabled?: boolean mixed?: boolean @@ -29,6 +29,7 @@ export function SegmentedControl({ > {options.map((option) => { const isSelected = !mixed && value === option.value + const isDisabled = disabled || option.disabled === true return (