From 8c8c0d108bb22de0ad1d07c90030d7a2d69be553 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 13 Oct 2026 17:48:51 +0530 Subject: [PATCH 1/4] feat(roof): add glazed construction and barrel roofs Add framing, glazing, barrel arch controls, floor-plan grids, painting, print solids, and MCP authoring while preserving existing scenes. Resolve overlapping barrel corner faces and polish the glazing inspector with shape-aware availability, unit display, and individual panel overrides. --- packages/core/src/schema/index.ts | 25 + packages/core/src/schema/nodes/barrel-roof.ts | 15 + .../core/src/schema/nodes/curtain-wall.ts | 23 + packages/core/src/schema/nodes/ridge-vent.ts | 2 + .../src/schema/nodes/roof-glazing.test.ts | 126 ++++ .../core/src/schema/nodes/roof-glazing.ts | 611 ++++++++++++++++++ .../src/schema/nodes/roof-segment-shape.ts | 41 ++ .../src/schema/nodes/roof-segment-walls.ts | 27 + .../core/src/schema/nodes/roof-segment.ts | 44 +- .../core/src/store/actions/node-actions.ts | 1 + packages/core/src/store/use-scene.ts | 9 +- .../ui/controls/segmented-control.tsx | 8 +- packages/editor/src/index.tsx | 2 + .../editor/src/lib/print-feature-thickness.ts | 11 +- .../editor/src/lib/print-roof-solids.test.ts | 25 +- packages/editor/src/lib/print-roof-solids.ts | 30 +- .../mcp/src/tools/construction-tools.test.ts | 59 ++ packages/mcp/src/tools/construction-tools.ts | 35 +- packages/nodes/src/index.ts | 3 + .../nodes/src/roof-segment/definition.test.ts | 26 +- packages/nodes/src/roof-segment/definition.ts | 14 +- packages/nodes/src/roof-segment/floorplan.ts | 1 + packages/nodes/src/roof-segment/paint.ts | 115 ++++ packages/nodes/src/roof-segment/panel.tsx | 101 +-- packages/nodes/src/roof-segment/renderer.tsx | 50 +- packages/nodes/src/roof/barrel-panel.tsx | 67 ++ packages/nodes/src/roof/floorplan.ts | 13 +- .../nodes/src/roof/glazed-geometry.test.ts | 277 ++++++++ packages/nodes/src/roof/glazed-geometry.ts | 168 +++++ packages/nodes/src/roof/glazed-materials.ts | 42 ++ .../nodes/src/roof/glazed-renderer.test.tsx | 130 ++++ packages/nodes/src/roof/glazing-controls.tsx | 211 ++++++ .../nodes/src/roof/glazing-panel-editor.tsx | 122 ++++ packages/nodes/src/roof/glazing-panel.tsx | 189 ++++++ packages/nodes/src/roof/glazing-preview.ts | 53 ++ packages/nodes/src/roof/panel.tsx | 2 + packages/nodes/src/roof/renderer.tsx | 15 +- packages/nodes/src/roof/system.tsx | 5 +- packages/nodes/src/roof/tool.tsx | 7 + packages/nodes/src/shared/roof-segment-hit.ts | 5 +- packages/nodes/src/shared/roof-surface.ts | 28 + .../nodes/src/wall/curtain-wall-layout.ts | 30 +- packages/viewer/src/index.ts | 3 + .../viewer/src/systems/roof/roof-system.tsx | 138 +++- wiki/glazed-roofs.md | 56 ++ wiki/research/glazed-roof-forms.md | 109 ++++ 46 files changed, 2957 insertions(+), 117 deletions(-) create mode 100644 packages/core/src/schema/nodes/barrel-roof.ts create mode 100644 packages/core/src/schema/nodes/roof-glazing.test.ts create mode 100644 packages/core/src/schema/nodes/roof-glazing.ts create mode 100644 packages/nodes/src/roof-segment/paint.ts create mode 100644 packages/nodes/src/roof/barrel-panel.tsx create mode 100644 packages/nodes/src/roof/glazed-geometry.test.ts create mode 100644 packages/nodes/src/roof/glazed-geometry.ts create mode 100644 packages/nodes/src/roof/glazed-materials.ts create mode 100644 packages/nodes/src/roof/glazed-renderer.test.tsx create mode 100644 packages/nodes/src/roof/glazing-controls.tsx create mode 100644 packages/nodes/src/roof/glazing-panel-editor.tsx create mode 100644 packages/nodes/src/roof/glazing-panel.tsx create mode 100644 packages/nodes/src/roof/glazing-preview.ts create mode 100644 wiki/glazed-roofs.md create mode 100644 wiki/research/glazed-roof-forms.md diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c3defeb1bc..ff830822e1 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' @@ -271,6 +272,30 @@ 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, + RoofGlazing, + RoofSystemType, + supportsRoofGlazing, +} from './nodes/roof-glazing' export type { DutchRoofMetrics, RoofSegmentSurfaceMaterialRole, 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/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.test.ts b/packages/core/src/schema/nodes/roof-glazing.test.ts new file mode 100644 index 0000000000..186bfdc00a --- /dev/null +++ b/packages/core/src/schema/nodes/roof-glazing.test.ts @@ -0,0 +1,126 @@ +import { afterEach, expect, test } from 'bun:test' +import { + getGlazedRoofFaces, + getGlazedRoofLayout, + getGlazedRoofPlanLines, + getRoofSegmentSurfaceY, + 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) +}) 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..d74ac8a569 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-glazing.ts @@ -0,0 +1,611 @@ +import { z } from 'zod' +import { getBarrelRoofProfile } from './barrel-roof' +import { CurtainGrid, CurtainPanelType, curtainGridPositions } from './curtain-wall' +import { + getActiveRoofHeight, + getRoofSegmentVisibleTopBounds, + getSegmentSlopeFrame, + normalizeRoofSegmentTrim, + type RoofSegmentNode, +} from './roof-segment' + +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({}) + +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) +} + +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], + ] + 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: profile, + }) + } + 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 +} + +/** End infill fits below the inward thickness of the arch covering. */ +function fitBarrelEndInfill( + node: RoofSegmentNode, + polygon: [number, number][], + coveringDepth: number, +): [number, number][] { + const profile = getBarrelRoofProfile( + node.depth, + getActiveRoofHeight(node), + node.barrelSegments ?? 12, + ) + let result = polygon + for (let i = 1; i < profile.length; i++) { + const a = profile[i - 1]!, + b = profile[i]! + const dy = b[1] - a[1], + dz = b[0] - a[0] + const length = Math.hypot(dy, dz) + const nx = -dy / length, + ny = dz / length + const signed = ([u, v]: [number, number]) => + nx * (u - node.depth / 2 - a[0]) + ny * (v - a[1]) + coveringDepth + result = clipGlazedRoofHalfPlane(result, signed) + } + return result +} + +/** 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: fitBarrelEndInfill( + node, + profile, + Math.max(getRoofGlazing(node).frameDepth, getRoofGlazing(node).glassThickness), + ), + }) + } + 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, + })) + if (node.wallShell !== 'omit' && node.wallThickness > 0) { + const rise = getActiveRoofHeight(node) + const profile = getBarrelRoofProfile(node.depth, rise, node.barrelSegments ?? 12) + for (const side of [-1, 1]) { + const face: GlazedRoofFace = { + id: side < 0 ? 'end-left' : 'end-right', + origin: [(side * node.width) / 2, 0, -node.depth / 2], + u: [0, 0, 1], + v: [0, 1, 0], + normal: [side, 0, 0], + width: node.depth, + length: node.wallHeight + rise, + polygon: [ + [0, 0], + [node.depth, 0], + ...[...profile] + .reverse() + .map(([z, y]): [number, number] => [z + node.depth / 2, y + node.wallHeight]), + ], + } + face.polygon = fitBarrelEndInfill( + node, + face.polygon.map(([u, v]): [number, number] => [u, v - node.wallHeight]), + node.deckThickness + node.shingleThickness, + ).map(([u, v]) => [u, v + node.wallHeight]) + pieces.push({ face, polygon: face.polygon, depth: node.wallThickness, slot: 2 }) + } + if (node.wallHeight > 0) + for (const side of [-1, 1]) { + const face: GlazedRoofFace = { + id: side < 0 ? 'back' : 'front', + origin: [-node.width / 2 + node.wallThickness, 0, (side * node.depth) / 2], + u: [1, 0, 0], + v: [0, 1, 0], + normal: [0, 0, side], + width: Math.max(0, node.width - 2 * node.wallThickness), + length: node.wallHeight, + polygon: [ + [0, 0], + [Math.max(0, node.width - 2 * node.wallThickness), 0], + [Math.max(0, node.width - 2 * node.wallThickness), node.wallHeight], + [0, node.wallHeight], + ], + } + pieces.push({ face, polygon: face.polygon, depth: node.wallThickness, slot: 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) + } + } + 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-shape.ts b/packages/core/src/schema/nodes/roof-segment-shape.ts index ee5b7c065e..6f610638ca 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 = { @@ -14,6 +15,7 @@ export function getRoofShapeEaveSides(type: RoofType): RoofShapeEaveSide[] { return [] case 'shed': return ['+Z'] + case 'barrel': case 'gable': case 'gambrel': return ['+Z', '-Z'] @@ -300,6 +302,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..0737f3ef70 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, + dV: node.depth, + 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,21 @@ 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, + ) + 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 9ea6f5b14d..c32cfb2d8e 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -3,6 +3,8 @@ 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' export const RoofType = z.enum([ 'hip', @@ -13,6 +15,7 @@ export const RoofType = z.enum([ 'mansard', 'flat', 'conical', + 'barrel', ]) export type RoofType = z.infer @@ -112,6 +115,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 +246,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 @@ -577,8 +587,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 @@ -621,13 +640,15 @@ export function getRoofSegmentVisibleTopBounds( const width = finitePositive(segment.width, DEFAULT_ROOF_SEGMENT_WIDTH) const depth = finitePositive(segment.depth, DEFAULT_ROOF_SEGMENT_DEPTH) const trim = normalizeRoofSegmentTrim({ ...segment, width, depth }) - const horizontalOverhang = finiteNonNegative(segment.overhang) * cosTheta + const horizontalOverhang = + finiteNonNegative(segment.overhang) * (segment.roofType === 'barrel' ? 1 : cosTheta) const deckExt = finiteNonNegative(segment.wallThickness) / 2 + horizontalOverhang - const shingleOverhang = finiteNonNegative(segment.shingleThickness) * sinTheta + const shingleOverhang = + segment.roofSystem === 'glazed' ? 0 : finiteNonNegative(segment.shingleThickness) * sinTheta let xExt = deckExt - let frontExt = deckExt - let backExt = deckExt + let frontExt = segment.roofType === 'barrel' ? 0 : deckExt + let backExt = segment.roofType === 'barrel' ? 0 : deckExt if ( segment.roofType === 'hip' || @@ -695,6 +716,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/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index ee7d0d90f7..ca9b23891d 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 94a3493603..202b204fc3 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, @@ -698,10 +699,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/ui/controls/segmented-control.tsx b/packages/editor/src/components/ui/controls/segmented-control.tsx index 773afeb872..626cb4a4d2 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 (