From 59ffe0c71924fb458512a79ee585d0815720b535 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 11:44:33 +0530 Subject: [PATCH 01/21] feat(walls): add curtain wall support - Add curtain wall schema, grid layouts, panels, and openings - Render curtain walls in 3D and floor plans with material painting - Add editor controls, previews, and coverage tests --- packages/core/src/schema/index.ts | 11 + .../src/schema/nodes/curtain-wall.test.ts | 128 +++++ .../core/src/schema/nodes/curtain-wall.ts | 265 +++++++++++ packages/core/src/schema/nodes/wall.ts | 3 + .../components/ui/controls/slider-control.tsx | 107 +++-- packages/nodes/src/door/panel.tsx | 16 - .../nodes/src/wall/curtain-frame-material.tsx | 93 ++++ .../src/wall/curtain-wall-floorplan.test.ts | 84 ++++ .../nodes/src/wall/curtain-wall-floorplan.ts | 107 +++++ .../nodes/src/wall/curtain-wall-paint.test.ts | 69 +++ .../nodes/src/wall/curtain-wall-panel.tsx | 445 ++++++++++++++++++ packages/nodes/src/wall/definition.test.ts | 4 +- packages/nodes/src/wall/definition.ts | 7 +- packages/nodes/src/wall/floorplan.ts | 16 +- packages/nodes/src/wall/paint.ts | 43 +- packages/nodes/src/wall/panel.tsx | 149 ++++-- .../nodes/src/wall/property-preview.test.ts | 75 +++ packages/nodes/src/wall/property-preview.ts | 34 ++ packages/nodes/src/wall/renderer.tsx | 29 +- packages/nodes/src/wall/slots.ts | 17 +- packages/nodes/src/wall/wall-batch-system.tsx | 2 + packages/nodes/src/window/panel.tsx | 154 +++--- .../nodes/src/window/property-preview.test.ts | 28 ++ packages/nodes/src/window/property-preview.ts | 40 ++ packages/viewer/src/index.ts | 1 + .../wall/curtain-opening-frame.test.ts | 118 +++++ .../src/systems/wall/curtain-opening-frame.ts | 74 +++ .../wall/curtain-wall-door-resize.test.ts | 65 +++ .../wall/curtain-wall-geometry.test.ts | 120 +++++ .../src/systems/wall/curtain-wall-geometry.ts | 183 +++++++ .../wall/curtain-wall-materials.test.ts | 72 +++ .../systems/wall/curtain-wall-materials.ts | 51 ++ .../wall/curtain-wall-piece-geometry.test.ts | 26 + .../wall/curtain-wall-piece-geometry.ts | 63 +++ .../systems/wall/curtain-wall-preview.test.ts | 60 +++ .../systems/wall/curtain-wall-shadow.test.ts | 37 ++ .../src/systems/wall/curtain-wall-shadow.ts | 20 + .../wall/curtain-wall-window-resize.test.ts | 78 +++ .../src/systems/wall/wall-cutout-cache.ts | 20 +- .../viewer/src/systems/wall/wall-materials.ts | 60 ++- .../viewer/src/systems/wall/wall-system.tsx | 18 +- wiki/curtain-walls.md | 38 ++ 42 files changed, 2801 insertions(+), 229 deletions(-) create mode 100644 packages/core/src/schema/nodes/curtain-wall.test.ts create mode 100644 packages/core/src/schema/nodes/curtain-wall.ts create mode 100644 packages/nodes/src/wall/curtain-frame-material.tsx create mode 100644 packages/nodes/src/wall/curtain-wall-floorplan.test.ts create mode 100644 packages/nodes/src/wall/curtain-wall-floorplan.ts create mode 100644 packages/nodes/src/wall/curtain-wall-paint.test.ts create mode 100644 packages/nodes/src/wall/curtain-wall-panel.tsx create mode 100644 packages/nodes/src/wall/property-preview.test.ts create mode 100644 packages/nodes/src/wall/property-preview.ts create mode 100644 packages/nodes/src/window/property-preview.test.ts create mode 100644 packages/nodes/src/window/property-preview.ts create mode 100644 packages/viewer/src/systems/wall/curtain-opening-frame.test.ts create mode 100644 packages/viewer/src/systems/wall/curtain-opening-frame.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-door-resize.test.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-geometry.test.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-geometry.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-materials.test.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-materials.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-piece-geometry.test.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-preview.test.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-shadow.test.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-shadow.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-window-resize.test.ts create mode 100644 wiki/curtain-walls.md diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 3edfbea636..4154730c71 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -108,6 +108,17 @@ export { setConstructionDimensionDrawingSuppressedSegments, } from './nodes/construction-dimension' export { CupolaMaterialRole, CupolaNode } from './nodes/cupola' +export { + buildCurtainWallLayout, + CurtainGrid, + CurtainPanelType, + CurtainWallConfig, + type CurtainWallPiece, + curtainGridPositions, + curtainPanelType, + DEFAULT_CURTAIN_WALL, + getCurtainWallConfig, +} from './nodes/curtain-wall' export { DoorNode, DoorSegment, diff --git a/packages/core/src/schema/nodes/curtain-wall.test.ts b/packages/core/src/schema/nodes/curtain-wall.test.ts new file mode 100644 index 0000000000..44b2ad3a6f --- /dev/null +++ b/packages/core/src/schema/nodes/curtain-wall.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'bun:test' +import { + buildCurtainWallLayout, + CurtainGrid, + CurtainWallConfig, + curtainGridPositions, + curtainPanelType, +} from './curtain-wall' +import { WallNode } from './wall' + +describe('curtain walls', () => { + test('old walls remain standard and switching preserves both configurations', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], skirting: { enabled: true } }) + expect(wall.wallType ?? 'standard').toBe('standard') + const curtain = WallNode.parse({ + ...wall, + wallType: 'curtain', + curtainWall: { construction: 'unitized' }, + }) + const restored = WallNode.parse({ ...curtain, wallType: 'standard' }) + expect(restored.skirting).toEqual(wall.skirting) + expect(restored.curtainWall?.construction).toBe('unitized') + expect(restored.id).toBe(wall.id) + expect(restored.type).toBe('wall') + }) + + test('grid rules preserve requested spacing and alignment', () => { + expect(curtainGridPositions(6, CurtainGrid.parse({ layout: 'count', count: 3 }))).toEqual([ + 0, 2, 4, 6, + ]) + expect( + curtainGridPositions(5, CurtainGrid.parse({ layout: 'maximum-spacing', spacing: 2 })), + ).toEqual([0, 5 / 3, 10 / 3, 5]) + expect( + curtainGridPositions( + 5, + CurtainGrid.parse({ layout: 'fixed-spacing', spacing: 2, alignment: 'start' }), + ), + ).toEqual([0, 2, 4, 5]) + expect( + curtainGridPositions( + 5, + CurtainGrid.parse({ layout: 'fixed-spacing', spacing: 2, alignment: 'center' }), + ), + ).toEqual([0, 0.5, 2.5, 4.5, 5]) + expect( + curtainGridPositions( + 5, + CurtainGrid.parse({ layout: 'fixed-spacing', spacing: 2, alignment: 'end' }), + ), + ).toEqual([0, 1, 3, 5]) + expect(curtainGridPositions(0, CurtainGrid.parse({}))).toEqual([0]) + expect(curtainGridPositions(1000, CurtainGrid.parse({ spacing: 0.2 })).length).toBe(33) + }) + + test('panel overrides take precedence over spandrels and defaults', () => { + const config = CurtainWallConfig.parse({ + spandrel: 'bottom', + panels: [{ column: 0, row: 0, type: 'empty' }], + }) + expect(curtainPanelType(config, 0, 0, 2)).toBe('empty') + expect(curtainPanelType(config, 1, 0, 2)).toBe('solid') + expect(curtainPanelType(config, 1, 1, 2)).toBe('glass') + }) + + test('structural glazing exposes glass in front of recessed framing', () => { + const pieces = buildCurtainWallLayout( + 6, + 3, + 0.15, + CurtainWallConfig.parse({ framing: 'structural-glazing' }), + ) + const frames = pieces.filter((piece) => piece.role === 'frame') + const panes = pieces.filter((piece) => piece.role === 'glass') + expect(frames.length).toBeGreaterThan(0) + expect(panes.length).toBe(8) + expect(Math.max(...frames.map((piece) => piece.front))).toBeLessThanOrEqual( + Math.min(...panes.map((piece) => piece.back)), + ) + }) + + test('tiny cells remain positive and invalid dimensions are rejected', () => { + const config = CurtainWallConfig.parse({ + columns: { layout: 'count', count: 32 }, + rows: { layout: 'count', count: 32 }, + mullionWidth: 0.3, + }) + for (const piece of buildCurtainWallLayout(0.1, 0.1, 0.05, config)) { + expect(piece.right).toBeGreaterThan(piece.left) + expect(piece.top).toBeGreaterThan(piece.bottom) + expect(piece.front).toBeGreaterThan(piece.back) + } + for (const input of [ + { mullionWidth: -1 }, + { glassOpacity: 2 }, + { columns: { count: 33 } }, + { glassThickness: Infinity }, + ]) + expect(CurtainWallConfig.safeParse(input).success).toBe(false) + }) +}) + +test('entrance frames adapt to door size, position, support elevation, and removal', () => { + const config = CurtainWallConfig.parse({}) + const base = buildCurtainWallLayout(6, 3, 0.15, config) + const door = { + type: 'door' as const, + position: [1.5, 1.05, 0] as [number, number, number], + width: 0.9, + height: 2.1, + openingShape: 'rectangle' as const, + } + const at = (pieces: typeof base, x: number, y: number) => + pieces.filter((piece) => x > piece.left && x < piece.right && y > piece.bottom && y < piece.top) + const first = buildCurtainWallLayout(6, 3, 0.15, config, [door]) + expect(at(first, 1.5, 0.02)).toHaveLength(0) + expect(at(first, 1.025, 1).map((piece) => piece.role)).toEqual(['frame']) + expect(at(first, 1.25, 2.125).map((piece) => piece.role)).toEqual(['frame']) + const moved = buildCurtainWallLayout(6, 3, 0.15, config, [ + { ...door, position: [4, 1.2, 0], width: 1.8, height: 2.4 }, + ]) + expect(at(moved, 1.25, 1).map((piece) => piece.role)).toEqual(['glass']) + expect(at(moved, 4, 1)).toHaveLength(0) + expect(at(moved, 3.075, 1).map((piece) => piece.role)).toEqual(['frame']) + expect(buildCurtainWallLayout(6, 3, 0.15, config, [])).toEqual(base) + const supported = buildCurtainWallLayout(6, 3.5, 0.15, config, [door], -0.5) + expect(at(supported, 1.25, 2.625).map((piece) => piece.role)).toEqual(['frame']) +}) diff --git a/packages/core/src/schema/nodes/curtain-wall.ts b/packages/core/src/schema/nodes/curtain-wall.ts new file mode 100644 index 0000000000..bb6fa20234 --- /dev/null +++ b/packages/core/src/schema/nodes/curtain-wall.ts @@ -0,0 +1,265 @@ +import { z } from 'zod' +import type { DoorNode } from './door' + +export const CurtainGrid = z.object({ + layout: z.enum(['count', 'maximum-spacing', 'fixed-spacing']).default('maximum-spacing'), + count: z.number().int().min(1).max(32).default(3), + spacing: z.number().finite().min(0.2).max(100).default(1.5), + alignment: z.enum(['start', 'center', 'end']).default('center'), +}) +export type CurtainGrid = z.infer + +export const CurtainPanelType = z.enum(['glass', 'solid', 'empty']) +export type CurtainPanelType = z.infer + +export const CurtainWallConfig = z.object({ + construction: z.enum(['stick', 'unitized']).default('stick'), + framing: z + .enum(['capped', 'vertical-caps', 'horizontal-caps', 'structural-glazing']) + .default('capped'), + columns: CurtainGrid.prefault({}), + rows: CurtainGrid.prefault({ layout: 'count', count: 2 }), + mullionWidth: z.number().finite().min(0.01).max(0.3).default(0.05), + transomWidth: z.number().finite().min(0.01).max(0.3).default(0.05), + perimeterWidth: z.number().finite().min(0.01).max(0.3).default(0.05), + jointWidth: z.number().finite().min(0.002).max(0.05).default(0.01), + glassThickness: z.number().finite().min(0.004).max(0.08).default(0.024), + panelType: CurtainPanelType.default('glass'), + spandrel: z.enum(['none', 'bottom', 'top']).default('none'), + frameColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default('#303942'), + glassColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default('#a9d5df'), + solidColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default('#53616d'), + glassOpacity: z.number().finite().min(0.05).max(1).default(0.3), + glassRoughness: z.number().finite().min(0).max(1).default(0.12), + panels: z + .array( + z.object({ + column: z.number().int().min(0).max(31), + row: z.number().int().min(0).max(31), + type: CurtainPanelType, + }), + ) + .max(1024) + .default([]), +}) +export type CurtainWallConfig = z.infer + +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) +} + +export type CurtainWallPiece = { + left: number + right: number + bottom: number + top: number + front: number + back: number + role: 'frame' | 'glass' | 'solid' +} + +export function curtainPanelType( + config: CurtainWallConfig, + column: number, + row: number, + rowCount: number, +): CurtainPanelType { + const override = config.panels.find((panel) => panel.column === column && panel.row === row) + if (override) return override.type + if ( + (config.spandrel === 'bottom' && row === 0) || + (config.spandrel === 'top' && row === rowCount - 1) + ) + return 'solid' + return config.panelType +} + +export function buildCurtainWallLayout( + length: number, + height: number, + depth: number, + config: CurtainWallConfig, + openings: readonly (Pick & { + type: 'door' | 'window' + })[] = [], + baseElevation = 0, +): CurtainWallPiece[] { + if (length <= 1e-6 || height <= 1e-6 || depth <= 0) return [] + const xs = curtainGridPositions(length, config.columns) + const ys = curtainGridPositions(height, config.rows) + const pieces: CurtainWallPiece[] = [] + const panelDepth = Math.min(config.glassThickness, depth * 0.45) + const panelFront = depth / 2 - Math.min(0.005, depth * 0.05) + const panelBack = panelFront - panelDepth + const add = ( + left: number, + right: number, + bottom: number, + top: number, + front: number, + role: CurtainWallPiece['role'], + back = -depth / 2, + ) => { + if (right - left > 1e-6 && top - bottom > 1e-6 && front - back > 1e-6) + pieces.push({ left, right, bottom, top, front, back, role }) + } + const verticalCap = config.framing === 'capped' || config.framing === 'vertical-caps' + const horizontalCap = config.framing === 'capped' || config.framing === 'horizontal-caps' + for (let column = 0; column < xs.length - 1; column++) { + for (let row = 0; row < ys.length - 1; row++) { + const x0 = xs[column]!, + x1 = xs[column + 1]!, + y0 = ys[row]!, + y1 = ys[row + 1]! + const gap = + config.construction === 'unitized' + ? Math.min(config.jointWidth / 2, (x1 - x0) / 10, (y1 - y0) / 10) + : 0 + const left = x0 + (column === 0 ? 0 : gap), + right = x1 - (column === xs.length - 2 ? 0 : gap) + const bottom = y0 + (row === 0 ? 0 : gap), + top = y1 - (row === ys.length - 2 ? 0 : gap) + const lw = Math.min( + column === 0 ? config.perimeterWidth : config.mullionWidth / 2, + (right - left) * 0.4, + ) + const rw = Math.min( + column === xs.length - 2 ? config.perimeterWidth : config.mullionWidth / 2, + (right - left) * 0.4, + ) + const bw = Math.min( + row === 0 ? config.perimeterWidth : config.transomWidth / 2, + (top - bottom) * 0.4, + ) + const tw = Math.min( + row === ys.length - 2 ? config.perimeterWidth : config.transomWidth / 2, + (top - bottom) * 0.4, + ) + add(left, left + lw, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') + add(right - rw, right, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') + add( + left + lw, + right - rw, + bottom, + bottom + bw, + horizontalCap ? depth / 2 : panelBack, + 'frame', + ) + add(left + lw, right - rw, top - tw, top, horizontalCap ? depth / 2 : panelBack, 'frame') + const type = curtainPanelType(config, column, row, ys.length - 1) + if (type === 'empty') continue + const joint = Math.min(config.jointWidth / 2, (right - left) * 0.1, (top - bottom) * 0.1) + add( + left + (verticalCap ? lw : joint), + right - (verticalCap ? rw : joint), + bottom + (horizontalCap ? bw : joint), + top - (horizontalCap ? tw : joint), + panelFront, + type, + panelBack, + ) + } + } + let fitted = pieces + for (const opening of openings) { + if (opening.openingShape !== 'rectangle') continue + const left = opening.position[0] - opening.width / 2 + const right = opening.position[0] + opening.width / 2 + const bottom = opening.position[1] - opening.height / 2 - baseElevation + const top = bottom + opening.height + if (right <= 0 || left >= length || top <= 0 || bottom >= height) continue + const outerLeft = Math.max(0, left - config.perimeterWidth) + const outerRight = Math.min(length, right + config.perimeterWidth) + const outerBottom = Math.max( + 0, + bottom - (opening.type === 'window' ? config.perimeterWidth : 0), + ) + const outerTop = Math.min(height, top + config.perimeterWidth) + // Replace intersecting infill and grid members with a continuous entrance subframe. + fitted = fitted.flatMap((piece) => { + const x0 = Math.max(piece.left, outerLeft) + const x1 = Math.min(piece.right, outerRight) + const y0 = Math.max(piece.bottom, outerBottom) + const y1 = Math.min(piece.top, outerTop) + if (x1 <= x0 || y1 <= y0) return [piece] + return [ + { ...piece, right: x0 }, + { ...piece, left: x1 }, + { ...piece, left: x0, right: x1, top: y0 }, + { ...piece, left: x0, right: x1, bottom: y1 }, + ].filter((part) => part.right - part.left > 1e-6 && part.top - part.bottom > 1e-6) + }) + const frame = { front: depth / 2, back: -depth / 2, role: 'frame' as const } + fitted.push( + ...[ + ...(opening.type === 'window' + ? [ + { + ...frame, + left: Math.max(0, left), + right: Math.min(length, right), + bottom: outerBottom, + top: Math.min(height, bottom), + }, + ] + : []), + { + ...frame, + left: outerLeft, + right: Math.min(length, left), + bottom: outerBottom, + top: outerTop, + }, + { + ...frame, + left: Math.max(0, right), + right: outerRight, + bottom: outerBottom, + top: outerTop, + }, + { + ...frame, + left: Math.max(0, left), + right: Math.min(length, right), + bottom: Math.max(0, top), + top: outerTop, + }, + ].filter((part) => part.right - part.left > 1e-6 && part.top - part.bottom > 1e-6), + ) + } + return fitted +} diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ce2258e2d3..0bc02dc812 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -2,6 +2,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +import { CurtainWallConfig } from './curtain-wall' import { DoorNode } from './door' import { ItemNode } from './item' import { LeanToExtensionNode } from './lean-to-extension' @@ -131,6 +132,8 @@ export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), + wallType: z.enum(['standard', 'curtain']).optional(), + curtainWall: CurtainWallConfig.optional(), children: z .array( z.union([ diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index e89b84f17f..90cea697bf 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -11,6 +11,8 @@ interface SliderControlProps { value: number onChange: (value: number) => void onCommit?: (value: number) => void + onCancel?: () => void + previewWhileTyping?: boolean min?: number max?: number precision?: number @@ -54,6 +56,8 @@ export function SliderControl({ value, onChange, onCommit, + onCancel, + previewWhileTyping = false, min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, precision: storedPrecision = 0, @@ -87,6 +91,9 @@ export function SliderControl({ anchorValue: number stepMultiplier: number } | null>(null) + const cancelRef = useRef(onCancel) + cancelRef.current = onCancel + const editingRef = useRef(false) const labelRef = useRef(null) const shown = dragDisplay ?? value const valueRef = useRef(shown) @@ -217,45 +224,74 @@ export function SliderControl({ [onChange, onCommit, restoreOnCommit], ) + const cancelEdit = useCallback(() => { + const drag = dragRef.current + dragRef.current = null + editingRef.current = false + if (drag) { + if (!onCancel) onChange(drag.originValue) + useScene.temporal.getState().resume() + } + onCancel?.() + setIsDragging(false) + setIsEditing(false) + setDragDisplay(null) + }, [onCancel, onChange]) + + useEffect( + () => () => { + if (dragRef.current) useScene.temporal.getState().resume() + if (dragRef.current || editingRef.current) cancelRef.current?.() + }, + [], + ) + + useEffect(() => { + if (!isDragging) return + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape') cancelEdit() + } + window.addEventListener('keydown', escape) + return () => window.removeEventListener('keydown', escape) + }, [isDragging, cancelEdit]) + const handleValueClick = useCallback(() => { + editingRef.current = true setIsEditing(true) setInputValue(toDisplay(value).toFixed(precision)) }, [value, precision, toDisplay]) + const parseInputValue = useCallback( + (text: string) => { + const spec = lingoUnitSpec(unit) + const parsed = spec + ? parseMeasurement(text, spec, { + bareUnit: parseUnit ?? spec.unitId, + system: isImperial ? 'us' : 'metric', + }) + : null + const bare = Number(text) + const stored = parsed ?? (text.trim() && Number.isFinite(bare) ? toStored(bare) : null) + return stored === null + ? null + : clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision)))) + }, + [unit, parseUnit, isImperial, toStored, clamp, toDisplay, precision], + ) + const submitValue = useCallback(() => { - const spec = lingoUnitSpec(unit) - let stored = spec - ? parseMeasurement(inputValue, spec, { - bareUnit: parseUnit ?? spec.unitId, - system: isImperial ? 'us' : 'metric', - }) - : null - if (stored === null) { - // Fallback: a bare number typed in the DISPLAY unit → convert to stored. - const numValue = Number.parseFloat(inputValue) - stored = Number.isFinite(numValue) ? toStored(numValue) : null - } - if (stored === null) { + if (!editingRef.current) return + editingRef.current = false + const nextValue = parseInputValue(inputValue) + if (nextValue === null) { + onCancel?.() setInputValue(toDisplay(value).toFixed(precision)) } else { - const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision)))) onChange(nextValue) onCommit?.(nextValue) } setIsEditing(false) - }, [ - inputValue, - unit, - isImperial, - parseUnit, - onChange, - onCommit, - clamp, - precision, - value, - toDisplay, - toStored, - ]) + }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, precision]) const spec = lingoUnitSpec(unit) const hint = @@ -274,8 +310,7 @@ export function SliderControl({ if (e.key === 'Enter') { submitValue() } else if (e.key === 'Escape') { - setInputValue(toDisplay(value).toFixed(precision)) - setIsEditing(false) + cancelEdit() } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() const direction = e.key === 'ArrowUp' ? 1 : -1 @@ -285,7 +320,7 @@ export function SliderControl({ setInputValue(toDisplay(newV).toFixed(precision)) } }, - [submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay], + [submitValue, cancelEdit, value, precision, step, applyDisplayDelta, onChange, toDisplay], ) const displayValue = toDisplay(shown) @@ -309,6 +344,10 @@ export function SliderControl({ onPointerDown={handleLabelPointerDown} onPointerMove={handleLabelPointerMove} onPointerUp={handleLabelPointerUp} + onPointerCancel={cancelEdit} + onLostPointerCapture={() => { + if (dragRef.current) cancelEdit() + }} ref={labelRef} > {/* Grip dots — 2×3 grid */} @@ -340,7 +379,13 @@ export function SliderControl({ autoFocus className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30" onBlur={submitValue} - onChange={(e) => setInputValue(e.target.value)} + onChange={(e) => { + setInputValue(e.target.value) + if (previewWhileTyping) { + const next = parseInputValue(e.target.value) + if (next !== null) onChange(next) + } + }} onKeyDown={handleInputKeyDown} type="text" value={inputValue} diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index eb7a1e597e..7a560b34a7 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -16,7 +16,6 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' -import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' import { scaleHandleHeight } from './door-math' const doorTypeOptions = [ @@ -584,21 +583,6 @@ export default function DoorPanel() { )} - - - - void }) { + const brush = useEditor((state) => state.activePaintMaterial) + const materials = useScene((state) => state.materials) + const readOnly = useScene((state) => state.readOnly) + useLibraryMaterialsVersion() + const ref = node.slots?.['curtain-frame'] + const parsed = parseMaterialRef(ref) + const catalog = parsed?.kind === 'library' ? getCatalogMaterialById(parsed.id) : undefined + const custom = parsed?.kind === 'scene' ? materials[parsed.id as SceneMaterialId] : undefined + const name = catalog?.label ?? custom?.name ?? (ref ? 'Unavailable material' : 'Frame color') + const color = + catalog?.previewColor ?? + (custom ? resolveMaterial(custom.material).color : getCurtainWallConfig(node).frameColor) + const canApply = Boolean(brush?.material || brush?.materialPreset) + const apply = (reset = false) => { + onCommit() + const current = useScene.getState().nodes[node.id] + if (current?.type !== 'wall') return + wallPaint.commit?.({ + node: current, + role: 'curtain-frame', + material: reset ? undefined : brush?.material, + materialPreset: reset ? undefined : brush?.materialPreset, + }) + } + return ( +
+
Frame material
+
+ {catalog?.previewThumbnailUrl ? ( + + ) : ( + + )} + + {name} + +
+
+ + + {ref && ( + + )} +
+

+ Choose a paint material, then click the frame in 3D or apply it here. +

+
+ ) +} diff --git a/packages/nodes/src/wall/curtain-wall-floorplan.test.ts b/packages/nodes/src/wall/curtain-wall-floorplan.test.ts new file mode 100644 index 0000000000..6f35ff9ea8 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-floorplan.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from 'bun:test' +import { DoorNode, WallNode, WindowNode } from '@pascal-app/core' +import { buildCurtainWallFloorplan } from './curtain-wall-floorplan' +import { buildWallFloorplan } from './floorplan' + +test('curtain walls keep selection controls and show separate glass and frame shapes in plan', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], height: 3, wallType: 'curtain' }) + const geometry = buildWallFloorplan(wall, { + children: [], + parent: null, + siblings: [wall], + resolve: () => undefined, + viewState: { + selected: true, + highlighted: false, + hovered: false, + moving: false, + unit: 'metric', + }, + }) + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') throw new Error('Expected wall group') + expect( + geometry.children.some((shape) => shape.kind === 'polygon' && shape.fill === '#a9d5df'), + ).toBe(true) + expect( + geometry.children.some((shape) => shape.kind === 'polygon' && shape.fill === '#303942'), + ).toBe(true) + expect(geometry.children.some((shape) => shape.kind === 'endpoint-handle')).toBe(true) +}) + +test('plan cuts skip hosted door spans', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], height: 3, wallType: 'curtain' }) + const door = DoorNode.parse({ position: [1.5, 1.05, 0], width: 0.9, height: 2.1 }) + const shapes = buildCurtainWallFloorplan(wall, 3, '#000000', [door]) + for (const shape of shapes) { + if (shape.kind !== 'polygon') continue + const min = Math.min(...shape.points.map((p) => p[0])) + const max = Math.max(...shape.points.map((p) => p[0])) + expect(max <= 1.05 + 1e-6 || min >= 1.95 - 1e-6).toBe(true) + } +}) + +test('entrance jambs appear in plan on both sides of the door', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], height: 3, wallType: 'curtain' }) + const door = DoorNode.parse({ position: [1.5, 1.05, 0], width: 0.9, height: 2.1 }) + const shapes = buildCurtainWallFloorplan(wall, 3, '#000000', [door]) + for (const x of [1.025, 1.975]) { + expect( + shapes.some( + (shape) => + shape.kind === 'polygon' && + shape.fill === '#303942' && + Math.min(...shape.points.map((point) => point[0])) < x && + Math.max(...shape.points.map((point) => point[0])) > x, + ), + ).toBe(true) + } +}) + +test('arched window plan follows the curved section and retains glass beside its shoulders', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], height: 3, wallType: 'curtain' }) + const opening = WindowNode.parse({ + position: [2.25, 0.65, 0], + width: 1, + height: 1.3, + openingShape: 'arch', + archHeight: 0.9, + }) + const shapes = buildCurtainWallFloorplan(wall, 3, '#000', [opening]) + const at = (x: number, fill: string) => + shapes.some( + (shape) => + shape.kind === 'polygon' && + shape.fill === fill && + Math.min(...shape.points.map((p) => p[0])) < x && + Math.max(...shape.points.map((p) => p[0])) > x, + ) + expect(at(1.8, '#a9d5df')).toBe(true) + expect(at(2.25, '#a9d5df')).toBe(false) + expect( + shapes.filter((shape) => shape.kind === 'polygon' && shape.fill === '#303942').length, + ).toBeGreaterThan(0) +}) diff --git a/packages/nodes/src/wall/curtain-wall-floorplan.ts b/packages/nodes/src/wall/curtain-wall-floorplan.ts new file mode 100644 index 0000000000..7273088a75 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-floorplan.ts @@ -0,0 +1,107 @@ +import { + type AnyNode, + buildCurtainWallLayout, + type CurtainWallPiece, + type FloorplanGeometry, + getCurtainWallConfig, + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + type WallNode, +} from '@pascal-app/core' +import { curtainOpeningProfile, curtainProfileSpan } from '@pascal-app/viewer' + +export function buildCurtainWallFloorplan( + wall: WallNode, + height: number, + stroke: string, + children: readonly AnyNode[] = [], +): FloorplanGeometry[] { + const length = getWallCurveLength(wall) + if (length <= 1e-6) return [] + const config = getCurtainWallConfig(wall) + const cutHeight = Math.min(1.2, height * 0.5) + const openings = children + .filter((child) => child.type === 'door' || child.type === 'window') + .map((opening) => ({ + opening, + profile: curtainOpeningProfile(opening, config.perimeterWidth), + framed: opening.openingShape !== 'rectangle', + })) + const pieces = buildCurtainWallLayout( + length, + height, + getWallThickness(wall), + config, + children.filter((child) => child.type === 'door' || child.type === 'window'), + ) + .filter((piece) => piece.bottom <= cutHeight && piece.top > cutHeight) + .flatMap((piece) => { + let runs = [piece] + for (const { profile, framed } of openings) { + const span = curtainProfileSpan(framed ? profile.outer : profile.inner, cutHeight) + if (!span) continue + const [left, right] = span + runs = runs.flatMap((run) => { + if (right <= run.left || left >= run.right) return [run] + return [ + ...(left > run.left ? [{ ...run, right: left }] : []), + ...(right < run.right ? [{ ...run, left: right }] : []), + ] + }) + } + return runs + }) + for (const { profile, framed } of openings) { + if (!framed) continue + const outer = curtainProfileSpan(profile.outer, cutHeight) + const inner = curtainProfileSpan(profile.inner, cutHeight) + if (!outer) continue + const spans = inner + ? [ + [outer[0], inner[0]], + [inner[1], outer[1]], + ] + : [outer] + for (const [left, right] of spans) { + if (right! - left! <= 1e-6) continue + pieces.push({ + left: Math.max(0, left!), + right: Math.min(length, right!), + bottom: 0, + top: height, + front: getWallThickness(wall) / 2, + back: -getWallThickness(wall) / 2, + role: 'frame', + } satisfies CurtainWallPiece) + } + } + return pieces.map((piece): FloorplanGeometry => { + const points: [number, number][] = [] + for (const offset of [piece.front, piece.back]) { + const side: [number, number][] = [] + for (let i = 0; i <= 8; i++) { + const along = piece.left + ((piece.right - piece.left) * i) / 8 + const frame = getWallCurveFrameAt(wall, along / length) + side.push([ + frame.point.x + frame.normal.x * offset, + frame.point.y + frame.normal.y * offset, + ]) + } + points.push(...(offset === piece.front ? side : side.reverse())) + } + return { + kind: 'polygon', + points, + fill: + piece.role === 'glass' + ? config.glassColor + : piece.role === 'frame' + ? config.frameColor + : config.solidColor, + stroke, + strokeWidth: 0.008, + pointerEvents: 'none', + } + }) +} diff --git a/packages/nodes/src/wall/curtain-wall-paint.test.ts b/packages/nodes/src/wall/curtain-wall-paint.test.ts new file mode 100644 index 0000000000..97a8091135 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-paint.test.ts @@ -0,0 +1,69 @@ +import { afterEach, expect, test } from 'bun:test' +import { sceneRegistry, useScene, WallNode } from '@pascal-app/core' +import { getVisibleWallMaterials } from '@pascal-app/viewer' +import { BoxGeometry, Mesh, Ray, Vector3 } from 'three' +import { resolveWallRole, wallPaint } from './paint' + +const initial = useScene.getState() +globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} +afterEach(() => { + sceneRegistry.clear() + useScene.setState(initial) +}) + +test('frame paint resolves through the wall proxy, previews only the frame, and commits a material reference', () => { + const wall = WallNode.parse({ start: [0, 0], end: [4, 0], wallType: 'curtain' }) + useScene.setState({ nodes: { [wall.id]: wall }, materials: {} }) + const geometry = new BoxGeometry(0.1, 2, 0.1) + geometry.clearGroups() + geometry.addGroup(0, geometry.index!.count, 0) + const original = getVisibleWallMaterials(wall) + const mesh = new Mesh(geometry, original) + sceneRegistry.nodes.set(wall.id, mesh) + mesh.updateMatrixWorld(true) + const role = resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [0, 0, 0.05], + ray: new Ray(new Vector3(0, 0, 1), new Vector3(0, 0, -1)), + }) + expect(role).toBe('curtain-frame') + const materialPreset = 'library:preset-metal' + const restore = wallPaint.applyPreview({ + node: wall, + root: mesh, + role: role!, + material: undefined, + materialPreset, + }) + expect(restore).not.toBeNull() + expect(mesh.material[0]).not.toBe(original[0]) + expect(mesh.material[1]).toBe(original[1]) + expect(mesh.material[2]).toBe(original[2]) + expect(useScene.getState().nodes[wall.id]).toBe(wall) + restore?.() + expect(mesh.material).toBe(original) + wallPaint.commit?.({ node: wall, role: role!, material: undefined, materialPreset }) + const updated = useScene.getState().nodes[wall.id] as WallNode + expect(updated.slots).toEqual({ 'curtain-frame': materialPreset }) + expect( + wallPaint.getEffectiveMaterial?.({ + node: updated, + role: role!, + nodes: useScene.getState().nodes, + })?.materialPreset, + ).toBe(materialPreset) + wallPaint.commit?.({ node: updated, role: role!, material: undefined, materialPreset: undefined }) + const reset = useScene.getState().nodes[wall.id] as WallNode + expect(reset.slots?.['curtain-frame']).toBeUndefined() + expect( + wallPaint.getEffectiveMaterial?.({ node: reset, role: role!, nodes: useScene.getState().nodes }) + ?.material?.properties?.color, + ).toBe('#303942') + geometry.dispose() +}) diff --git a/packages/nodes/src/wall/curtain-wall-panel.tsx b/packages/nodes/src/wall/curtain-wall-panel.tsx new file mode 100644 index 0000000000..1025931711 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-panel.tsx @@ -0,0 +1,445 @@ +'use client' + +import { + type CurtainGrid, + type CurtainPanelType, + type CurtainWallConfig, + curtainGridPositions, + curtainPanelType, + getCurtainWallConfig, + getWallCurveLength, + type WallNode, +} from '@pascal-app/core' +import { + getLinearUnitLabel, + linearControlValueToMeters, + metersToLinearUnit, + PanelSection, + SegmentedControl, + SliderControl, +} from '@pascal-app/editor' +import { useState } from 'react' +import { CurtainFrameMaterial } from './curtain-frame-material' + +type Props = { + node: WallNode + height: number + unit: 'metric' | 'imperial' + onUpdate: (patch: Partial) => void + onPreview: (patch: Partial) => void + onCommit: () => void + onCancel: () => void +} + +function Choice({ + label, + value, + options, + onChange, +}: { + label: string + value: T + options: readonly { value: T; label: string }[] + onChange: (value: T) => void +}) { + return ( + + ) +} + +function LengthControl({ + label, + value, + min, + max, + unit, + onChange, + onCommit, + onCancel, +}: { + label: string + value: number + min: number + max: number + unit: Props['unit'] + onChange: (value: number) => void + onCommit: () => void + onCancel: () => void +}) { + return ( + + onChange(linearControlValueToMeters(next, unit, { minMeters: min, maxMeters: max })) + } + precision={3} + step={0.005} + unit={getLinearUnitLabel(unit)} + value={metersToLinearUnit(value, unit)} + /> + ) +} + +function GridSection({ + onPreview, + title, + value, + unit, + onChange, + onCommit, + onCancel, +}: { + title: string + value: CurtainGrid + unit: Props['unit'] + onChange: (grid: CurtainGrid) => void + onPreview: (grid: CurtainGrid) => void + onCommit: () => void + onCancel: () => void +}) { + return ( + + onChange({ ...value, layout })} + options={[ + { label: 'Panel count', value: 'count' }, + { label: 'Maximum spacing', value: 'maximum-spacing' }, + { label: 'Fixed spacing', value: 'fixed-spacing' }, + ]} + value={value.layout} + /> + {value.layout === 'count' ? ( + onPreview({ ...value, count: Math.round(count) })} + precision={0} + step={1} + value={value.count} + /> + ) : ( + onPreview({ ...value, spacing })} + unit={unit} + value={value.spacing} + /> + )} + {value.layout === 'fixed-spacing' && ( + onChange({ ...value, alignment })} + options={[ + { label: 'Start', value: 'start' }, + { label: 'Center', value: 'center' }, + { label: 'End', value: 'end' }, + ]} + value={value.alignment} + /> + )} + + ) +} + +const PANEL_OPTIONS = [ + { label: 'Glass', value: 'glass' }, + { label: 'Solid', value: 'solid' }, + { label: 'Empty', value: 'empty' }, +] as const + +export function CurtainWallPanel({ + node, + height, + unit, + onUpdate, + onPreview, + onCommit, + onCancel, +}: Props) { + const config = getCurtainWallConfig(node) + const update = (patch: Partial, preview = false) => { + const slots = { ...node.slots } + if (patch.glassOpacity !== undefined || patch.glassRoughness !== undefined) + delete slots['curtain-glass'] + ;(preview ? onPreview : onUpdate)({ curtainWall: { ...config, ...patch }, slots }) + } + const [selectedColumn, setColumn] = useState(0) + const [selectedRow, setRow] = useState(0) + const columnCount = curtainGridPositions(getWallCurveLength(node), config.columns).length - 1 + const rowCount = curtainGridPositions(height, config.rows).length - 1 + const column = Math.max(0, Math.min(selectedColumn, columnCount - 1)) + const row = Math.max(0, Math.min(selectedRow, rowCount - 1)) + const selectedType = curtainPanelType(config, column, row, rowCount) + const changePanel = (type?: CurtainPanelType) => + update({ + panels: [ + ...config.panels.filter((panel) => panel.column !== column || panel.row !== row), + ...(type ? [{ column, row, type }] : []), + ], + }) + const colors = [ + { key: 'frameColor', label: 'Frame color', slot: 'curtain-frame' }, + { key: 'glassColor', label: 'Glass tint', slot: 'curtain-glass' }, + { key: 'solidColor', label: 'Solid color', slot: 'curtain-solid' }, + ] as const + + return ( + <> + + update({ construction })} + options={[ + { label: 'Stick-built', value: 'stick' }, + { label: 'Unitized', value: 'unitized' }, + ]} + value={config.construction} + /> + update({ framing })} + options={[ + { label: 'Fully capped', value: 'capped' }, + { label: 'Vertical caps', value: 'vertical-caps' }, + { label: 'Horizontal caps', value: 'horizontal-caps' }, + { label: 'Structural glazing', value: 'structural-glazing' }, + ]} + value={config.framing} + /> +

+ {config.construction === 'unitized' + ? 'Separate framed modules with joints between units.' + : 'Continuous mullions and transoms assembled along the wall.'} +

+ {config.framing !== 'capped' && ( +

+ Uncapped members sit behind the glazing. +

+ )} +
+ update({ columns })} + onPreview={(columns) => update({ columns }, true)} + onCommit={onCommit} + onCancel={onCancel} + title="Columns" + unit={unit} + value={config.columns} + /> + update({ rows })} + onPreview={(rows) => update({ rows }, true)} + onCommit={onCommit} + onCancel={onCancel} + title="Rows" + unit={unit} + value={config.rows} + /> + {((getWallCurveLength(node) / config.columns.spacing > 32 && + config.columns.layout !== 'count') || + (height / config.rows.spacing > 32 && config.rows.layout !== 'count')) && ( +

+ Grid limited to 32 panels per axis. Spacing expands to fit this wall. +

+ )} + + update({ mullionWidth }, true)} + unit={unit} + value={config.mullionWidth} + /> + update({ transomWidth }, true)} + unit={unit} + value={config.transomWidth} + /> + update({ perimeterWidth }, true)} + unit={unit} + value={config.perimeterWidth} + /> + {(config.construction === 'unitized' || config.framing !== 'capped') && ( + update({ jointWidth }, true)} + unit={unit} + value={config.jointWidth} + /> + )} + + + update({ panelType })} + options={PANEL_OPTIONS} + value={config.panelType} + /> + update({ spandrel })} + options={[ + { label: 'None', value: 'none' }, + { label: 'Bottom row', value: 'bottom' }, + { label: 'Top row', value: 'top' }, + ]} + value={config.spandrel} + /> + update({ glassThickness }, true)} + unit={unit} + value={config.glassThickness} + /> + update({ glassOpacity }, true)} + precision={2} + step={0.05} + value={config.glassOpacity} + /> + update({ glassRoughness }, true)} + precision={2} + step={0.05} + value={config.glassRoughness} + /> + + {colors + .filter(({ key }) => key !== 'frameColor' || !node.slots?.['curtain-frame']) + .map(({ key, label, slot }) => ( + + ))} + + +

+ Columns run from the wall start. Rows count upward. +

+ setColumn(Math.round(value) - 1)} + precision={0} + step={1} + value={column + 1} + /> + setRow(Math.round(value) - 1)} + precision={0} + step={1} + value={row + 1} + /> + + +

+ Use the Door or Window tool to add an opening through the wall. +

+
+ + ) +} diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index 6c04844856..073c117c6e 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -14,8 +14,8 @@ import { import { createConicalRoofSectorAboveWall } from '../roof/conical-roof' import { wallDefinition } from './definition' -test('wallDefinition records the lean-to child schema migration', () => { - expect(wallDefinition.schemaVersion).toBe(8) +test('wallDefinition records the curtain wall schema version', () => { + expect(wallDefinition.schemaVersion).toBe(9) }) test('wall drafting surface classifies its top, ends, and two sides', () => { diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 32f9f5986b..8a8923ddd6 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -60,7 +60,7 @@ const SPLIT_CUT_COUNTS = Array.from({ length: WALL_SPLIT_MAX_CUTS }, (_, index) export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 8, + schemaVersion: 9, schema: WallNode, category: 'structure', surfaceRole: 'wall', @@ -142,7 +142,7 @@ export const wallDefinition: NodeDefinition = { // `{ slotId, label, default }` contract every other paintable kind exposes. // Paint still writes the legacy inline fields for base faces via // `wallPaint`; migrating those fully into `node.slots` is a later step. - slots: () => wallSlots(), + slots: (node) => wallSlots(node as WallNodeType), }, relations: { @@ -268,6 +268,7 @@ export const wallDefinition: NodeDefinition = { }, mcp: { - description: 'A wall segment defined by start + end points, with optional curve sagitta.', + description: + 'A wall defined by endpoints and optional curve sagitta. wallType selects standard or curtain. curtainWall configures construction, framing, grids, glazing, spandrels, and zero-based panel overrides. Thickness is frame depth for curtain walls.', }, } diff --git a/packages/nodes/src/wall/floorplan.ts b/packages/nodes/src/wall/floorplan.ts index 997d6975de..6048de13ac 100644 --- a/packages/nodes/src/wall/floorplan.ts +++ b/packages/nodes/src/wall/floorplan.ts @@ -6,6 +6,7 @@ import { type GeometryContext, getWallCurveFrameAt, getWallCurveLength, + getWallEffectiveHeightForNodes, getWallMidpointHandlePoint, getWallPlanFootprint, getWallThickness, @@ -22,6 +23,7 @@ import { renderPlannedConstructionDimensions, type WallConstructionDimensionPlan, } from './construction-dimensions' +import { buildCurtainWallFloorplan } from './curtain-wall-floorplan' import { hasWallCurveBlockingChildren } from './curve-eligibility' // Same constants the legacy `getFloorplanWall` uses (editor/lib/floorplan/walls.ts). @@ -158,7 +160,8 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp : isHovered && palette ? palette.wallHoverStroke : '#1f2937' - const fill = showSelectedChrome ? '#ffffff' : '#374151' + const fill = + node.wallType === 'curtain' ? 'transparent' : showSelectedChrome ? '#ffffff' : '#374151' const children: FloorplanGeometry[] = [ { @@ -178,6 +181,17 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp }, ] + if (node.wallType === 'curtain') { + children.push( + ...buildCurtainWallFloorplan( + node, + getWallEffectiveHeightForNodes(node, ctx.sceneNodes ?? {}), + stroke, + ctx.children, + ), + ) + } + if (automaticDimensions) { const dimensionStroke = isSelected && palette ? palette.selectedStroke : (palette?.measurementStroke ?? '#334155') diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index ae9bbba9b7..0524363202 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + getCurtainWallConfig, getEffectiveWallSurfaceMaterial, getWallBandSlotId, getWallFaceBandConfig, @@ -91,6 +92,15 @@ export function resolveWallRole(args: { ray?: Ray }): string | null { const { node, hitObject, materialIndex, normal, localPosition, ray } = args + if (node.wallType === 'curtain') { + const root = sceneRegistry.nodes.get(node.id) + if (!(root && ray)) return null + wallSlotRaycaster.ray.copy(ray) + const hit = wallSlotRaycaster.intersectObject(root, false)[0] + return ( + ['curtain-frame', 'curtain-glass', 'curtain-solid'][hit?.face?.materialIndex ?? -1] ?? null + ) + } const directSlotId = hitObject?.userData?.slotId if (typeof directSlotId === 'string' && WALL_SLOT_IDS.has(directSlotId)) { return directSlotId @@ -162,12 +172,13 @@ export function resolveWallRole(args: { */ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { const { role, material, materialPreset } = args - if (!(role in WALL_ARRAY_SLOT_INDEX)) { + const curtainIndex = ['curtain-frame', 'curtain-glass', 'curtain-solid'].indexOf(role) + if (curtainIndex < 0 && !(role in WALL_ARRAY_SLOT_INDEX)) { return previewSlotByUserData(args) } - const index = WALL_ARRAY_SLOT_INDEX[role as WallSurfaceSlotId] - if (!index) return previewSlotByUserData(args) + const index = curtainIndex >= 0 ? curtainIndex : WALL_ARRAY_SLOT_INDEX[role as WallSurfaceSlotId] + if (index === undefined) return previewSlotByUserData(args) const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId) if (!(mesh && (mesh as Mesh).isMesh)) return null @@ -208,6 +219,32 @@ export const wallPaint: PaintCapability = createSlotPaintCapability({ }), applyPreview: applyWallPreview, legacyEffective: (node: AnyNode, role: string) => { + if (node.type === 'wall' && node.wallType === 'curtain') { + const config = getCurtainWallConfig(node as WallNode) + const color = + role === 'curtain-frame' + ? config.frameColor + : role === 'curtain-glass' + ? config.glassColor + : role === 'curtain-solid' + ? config.solidColor + : undefined + if (!color) return null + return { + materialPreset: undefined, + material: { + preset: 'custom', + properties: { + color, + roughness: role === 'curtain-glass' ? config.glassRoughness : 0.4, + metalness: role === 'curtain-frame' ? 0.65 : 0, + opacity: role === 'curtain-glass' ? config.glassOpacity : 1, + transparent: role === 'curtain-glass', + side: 'front', + }, + }, + } + } const side = getWallSurfaceSideFromBandSlot(role) if (!side && role in WALL_SURFACE_SLOT_DEFAULTS) { return { diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 654e71282f..e1457b053e 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -37,10 +37,12 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Spline } from 'lucide-react' -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' +import { CurtainWallPanel } from './curtain-wall-panel' import { hasWallCurveBlockingChildren } from './curve-eligibility' import { buildWallLengthPatch } from './length-patch' +import { createWallPropertyPreview } from './property-preview' /** * Base half of the plane-bound repair: a stamped draft offset goes, and a @@ -142,22 +144,30 @@ export default function WallPanel() { const nodeRef = useRef(node) nodeRef.current = node - const handleUpdate = useCallback( - (updates: Partial) => { - if (!selectedId) return - useScene.getState().updateNode(selectedId as AnyNode['id'], updates) - }, + const propertyPreview = useMemo( + () => (selectedId ? createWallPropertyPreview(selectedId as AnyNodeId) : undefined), [selectedId], ) + useEffect(() => () => propertyPreview?.cancel(), [propertyPreview]) + const handleUpdate = useCallback( + (updates: Partial) => propertyPreview?.commit(updates), + [propertyPreview], + ) + const handlePreview = useCallback( + (updates: Partial) => propertyPreview?.preview(updates), + [propertyPreview], + ) + const handleCommit = useCallback(() => propertyPreview?.commit(), [propertyPreview]) + const handleCancel = useCallback(() => propertyPreview?.cancel(), [propertyPreview]) const handleUpdateLength = useCallback( (newLength: number) => { const n = nodeRef.current if (!n || newLength <= 0) return - handleUpdate(buildWallLengthPatch(n, newLength)) + handlePreview(buildWallLengthPatch(n, newLength)) }, - [handleUpdate], + [handlePreview], ) const handleTopModeChange = useCallback( @@ -238,8 +248,22 @@ export default function WallPanel() { title={node.name || 'Wall'} width={280} > + + handleUpdate({ wallType })} + options={[ + { label: 'Standard', value: 'standard' }, + { label: 'Curtain wall', value: 'curtain' }, + ]} + value={node.wallType ?? 'standard'} + /> + ) : ( - handleUpdate({ + handlePreview({ height: linearControlValueToMeters(v, unit, { maxMeters: 1000, minMeters: 0.1 }), }) } @@ -301,11 +329,15 @@ export default function WallPanel() { )} - handleUpdate({ + handlePreview({ thickness: linearControlValueToMeters(v, unit, { maxMeters: 1000, minMeters: 0.05, @@ -319,11 +351,15 @@ export default function WallPanel() { /> {!hasWallChildrenBlockingCurve && ( - handleUpdate({ + handlePreview({ curveOffset: normalizeWallCurveOffset( node, linearControlValueToMeters(v, unit, { @@ -341,44 +377,59 @@ export default function WallPanel() { )} - + {node.wallType === 'curtain' ? ( + + ) : ( + <> + - - - + + + + + )} {!hasWallChildrenBlockingCurve && ( diff --git a/packages/nodes/src/wall/property-preview.test.ts b/packages/nodes/src/wall/property-preview.test.ts new file mode 100644 index 0000000000..6ba942fe78 --- /dev/null +++ b/packages/nodes/src/wall/property-preview.test.ts @@ -0,0 +1,75 @@ +import { afterEach, expect, test } from 'bun:test' +import { + CurtainWallConfig, + getEffectiveNode, + subscribeSceneCommits, + useLiveNodeOverrides, + useScene, + WallNode, +} from '@pascal-app/core' +import { createWallPropertyPreview } from './property-preview' + +globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} + +const initial = useScene.getState() +afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + useScene.setState(initial) + useScene.temporal.getState().clear() +}) + +function setup() { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain', curtainWall: {} }) + useScene.setState({ nodes: { [wall.id]: wall }, dirtyNodes: new Set() }) + useScene.temporal.getState().resume() + useScene.temporal.getState().clear() + return { wall, edit: createWallPropertyPreview(wall.id) } +} + +test('a drag previews without document commits, then commits once and undoes in one step', () => { + const { wall, edit } = setup() + let commits = 0 + const stop = subscribeSceneCommits(() => commits++) + try { + for (const width of [0.06, 0.08, 0.12]) + edit.preview({ curtainWall: CurtainWallConfig.parse({ mullionWidth: width }) }) + expect(useScene.getState().nodes[wall.id]).toBe(wall) + expect(getEffectiveNode(wall).curtainWall?.mullionWidth).toBe(0.12) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) + expect(commits).toBe(0) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + edit.commit() + expect(commits).toBe(1) + expect(useLiveNodeOverrides.getState().get(wall.id)).toBeUndefined() + expect((useScene.getState().nodes[wall.id] as WallNode).curtainWall?.mullionWidth).toBe(0.12) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + expect((useScene.getState().nodes[wall.id] as WallNode).curtainWall?.mullionWidth).toBe(0.05) + } finally { + stop() + } +}) + +test('cancel restores the effective wall and preserves unrelated overrides', () => { + const { wall, edit } = setup() + useLiveNodeOverrides.getState().set(wall.id, { name: 'Other preview' }) + edit.preview({ thickness: 0.4 }) + edit.cancel() + expect(getEffectiveNode(wall).thickness).toBe(wall.thickness) + expect(getEffectiveNode(wall).name).toBe('Other preview') + expect(useScene.getState().nodes[wall.id]).toBe(wall) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) +}) + +test('a preview cleared by history cannot be recommitted by a later release', () => { + const { wall, edit } = setup() + edit.preview({ thickness: 0.4 }) + useLiveNodeOverrides.getState().clearAll() + edit.commit() + expect(useScene.getState().nodes[wall.id]).toBe(wall) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) +}) diff --git a/packages/nodes/src/wall/property-preview.ts b/packages/nodes/src/wall/property-preview.ts new file mode 100644 index 0000000000..55829d1824 --- /dev/null +++ b/packages/nodes/src/wall/property-preview.ts @@ -0,0 +1,34 @@ +import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@pascal-app/core' + +export function createWallPropertyPreview(id: AnyNodeId) { + let pending: Partial | undefined + const clear = () => { + if (!pending) return + useLiveNodeOverrides.getState().clearFields(id, Object.keys(pending)) + pending = undefined + useScene.getState().markDirty(id) + } + return { + preview(patch: Partial) { + pending = { ...pending, ...patch } + useLiveNodeOverrides.getState().set(id, patch) + useScene.getState().markDirty(id) + }, + commit(patch?: Partial) { + const live = useLiveNodeOverrides.getState().get(id) + const current = + pending && live + ? Object.fromEntries( + Object.keys(pending) + .filter((key) => key in live) + .map((key) => [key, live[key]]), + ) + : undefined + const updates = { ...current, ...patch } + if (Object.keys(updates).length && useScene.getState().nodes[id]?.type === 'wall') + useScene.getState().updateNode(id, updates) + clear() + }, + cancel: clear, + } +} diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index b670aa95d5..9c25a32bf2 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -12,6 +12,7 @@ import { import { getVisibleWallMaterials, NodeRenderer, + SHADOW_ONLY_LAYER, useLibraryMaterialsVersion, useNodeEvents, useViewer, @@ -69,6 +70,7 @@ const WallRenderer = ({ node }: { node: WallNode }) => { const ref = useRef(null!) const placeholderGeometry = useMemo(() => createPlaceholderGeometry(3), []) const collisionPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), []) + const shadowPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), []) useRegistry(node.id, 'wall', ref) @@ -80,8 +82,9 @@ const WallRenderer = ({ node }: { node: WallNode }) => { return () => { placeholderGeometry.dispose() collisionPlaceholderGeometry.dispose() + shadowPlaceholderGeometry.dispose() } - }, [collisionPlaceholderGeometry, placeholderGeometry]) + }, [collisionPlaceholderGeometry, placeholderGeometry, shadowPlaceholderGeometry]) const rawHandlers = useNodeEvents(node, 'wall') // Hidden walls participate in hover/selection NEAREST-FIRST: when the @@ -155,7 +158,7 @@ const WallRenderer = ({ node }: { node: WallNode }) => { // re-resolve when they land. const libraryMaterialsVersion = useLibraryMaterialsVersion() const baseMaterials = getVisibleWallMaterials( - node, + treatmentNode, shading, textures, colorPreset, @@ -178,18 +181,32 @@ const WallRenderer = ({ node }: { node: WallNode }) => { return ( - - + {}} + > + + - {hasWallTreatments(treatmentNode) && ( + {node.wallType !== 'curtain' && hasWallTreatments(treatmentNode) && ( radius * scale) as [number, number, number, number] } -function isSameRadiusTuple( - current: [number, number, number, number], - next: [number, number, number, number], -) { - return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6) -} - const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [ { label: 'Fixed', value: 'fixed' }, { label: 'Sliding', value: 'sliding' }, @@ -96,11 +89,11 @@ export default function WindowPanel() { const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) - const previewRef = useRef<{ - id: AnyNodeId - key: keyof WindowNode - value: unknown - } | null>(null) + const preview = useMemo( + () => (selectedId ? createWindowPropertyPreview(selectedId as AnyNodeId) : null), + [selectedId], + ) + useEffect(() => () => preview?.cancel(), [preview]) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined, @@ -128,57 +121,10 @@ export default function WindowPanel() { [selectedId], ) - const previewWindowUpdate = useCallback( - (key: K, value: WindowNode[K]) => { - if (!selectedId) return - const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] - if (liveNode?.type !== 'window') return - - if ( - !( - previewRef.current && - previewRef.current.id === selectedId && - previewRef.current.key === key - ) - ) { - previewRef.current = { - id: selectedId as AnyNodeId, - key, - value: liveNode[key], - } - } - - if (isSameWindowValue(liveNode[key], value)) return - - ;(liveNode as WindowNode)[key] = value - useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) - }, - [selectedId], - ) - - const commitWindowPreview = useCallback( - (key: K, value: WindowNode[K]) => { - if (!selectedId) return - - const scene = useScene.getState() - const liveNode = scene.nodes[selectedId as AnyNodeId] - const preview = previewRef.current - if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) { - ;(liveNode as WindowNode)[key] = preview.value as WindowNode[K] - scene.dirtyNodes.add(selectedId as AnyNodeId) - } - previewRef.current = null - - useScene.getState().updateNode( - selectedId as AnyNode['id'], - { - [key]: value, - } as Partial, - ) - scene.dirtyNodes.add(selectedId as AnyNodeId) - }, - [selectedId], - ) + const previewWindowUpdate = (key: K, value: WindowNode[K]) => + preview?.preview({ [key]: value } as Partial) + const commitWindowPreview = (key: K, value: WindowNode[K]) => + preview?.commit({ [key]: value } as Partial) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) @@ -320,31 +266,24 @@ export default function WindowPanel() { if (openingShape === 'rounded') { if (openingRadiusMode === 'individual') { - const currentRadii = openingCornerRadii as [number, number, number, number] const nextRadii = normalizeWindowCornerRadii( openingCornerRadii as [number, number, number, number], nextWidth, nextHeight, ) - if (!isSameRadiusTuple(currentRadii, nextRadii)) { - nextUpdates.openingCornerRadii = nextRadii - } + nextUpdates.openingCornerRadii = nextRadii } else { const nextRadius = Math.min( Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight), ) - if (Math.abs(nextRadius - cornerRadius) > 1e-6) { - nextUpdates.cornerRadius = nextRadius - } + nextUpdates.cornerRadius = nextRadius } } if (openingShape === 'arch') { const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05)) - if (Math.abs(nextArchHeight - archHeight) > 1e-6) { - nextUpdates.archHeight = nextArchHeight - } + nextUpdates.archHeight = nextArchHeight } return nextUpdates @@ -418,21 +357,6 @@ export default function WindowPanel() { /> - - - - {showWindowTypeSection && (
@@ -565,7 +489,10 @@ export default function WindowPanel() { handleUpdate(getDimensionUpdates({ width: v }))} + onChange={(v) => preview?.preview(getDimensionUpdates({ width: v }))} + onCommit={(v) => preview?.commit(getDimensionUpdates({ width: v }))} + onCancel={() => preview?.cancel()} + previewWhileTyping precision={2} restoreOnCommit={false} step={0.1} @@ -575,7 +502,10 @@ export default function WindowPanel() { handleUpdate(getDimensionUpdates({ height: v }))} + onChange={(v) => preview?.preview(getDimensionUpdates({ height: v }))} + onCommit={(v) => preview?.commit(getDimensionUpdates({ height: v }))} + onCancel={() => preview?.cancel()} + previewWhileTyping precision={2} restoreOnCommit={false} step={0.1} @@ -628,6 +558,9 @@ export default function WindowPanel() { min={0} onChange={(value) => previewWindowUpdate('cornerRadius', value)} onCommit={(value) => commitWindowPreview('cornerRadius', value)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={2} step={0.05} unit="m" @@ -648,6 +581,9 @@ export default function WindowPanel() { min={0} onChange={(value) => setOpeningCornerRadius(index as number, value)} onCommit={(value) => setOpeningCornerRadius(index as number, value, true)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={2} step={0.05} unit="m" @@ -662,6 +598,9 @@ export default function WindowPanel() { min={0} onChange={(value) => previewWindowUpdate('openingRevealRadius', value)} onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={3} step={0.005} unit="m" @@ -675,7 +614,10 @@ export default function WindowPanel() { label="Arch Height" max={Math.max(0.05, node.height)} min={0.05} - onChange={(value) => handleUpdate({ archHeight: value })} + onChange={(value) => previewWindowUpdate('archHeight', value)} + onCommit={(value) => commitWindowPreview('archHeight', value)} + onCancel={() => preview?.cancel()} + previewWhileTyping precision={2} restoreOnCommit={false} step={0.05} @@ -719,6 +661,9 @@ export default function WindowPanel() { min={0} onChange={(value) => previewWindowUpdate('cornerRadius', value)} onCommit={(value) => commitWindowPreview('cornerRadius', value)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={2} step={0.05} unit="m" @@ -739,6 +684,9 @@ export default function WindowPanel() { min={0} onChange={(value) => setOpeningCornerRadius(index as number, value)} onCommit={(value) => setOpeningCornerRadius(index as number, value, true)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={2} step={0.05} unit="m" @@ -753,6 +701,9 @@ export default function WindowPanel() { min={0} onChange={(value) => previewWindowUpdate('openingRevealRadius', value)} onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={3} step={0.005} unit="m" @@ -766,7 +717,10 @@ export default function WindowPanel() { label="Arch Height" max={Math.max(0.05, node.height)} min={0.05} - onChange={(value) => handleUpdate({ archHeight: value })} + onChange={(value) => previewWindowUpdate('archHeight', value)} + onCommit={(value) => commitWindowPreview('archHeight', value)} + onCancel={() => preview?.cancel()} + previewWhileTyping precision={2} restoreOnCommit={false} step={0.05} @@ -785,7 +739,11 @@ export default function WindowPanel() { handleUpdate({ frameThickness: v })} + onChange={(v) => previewWindowUpdate('frameThickness', v)} + onCommit={(v) => commitWindowPreview('frameThickness', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={3} step={0.01} unit="m" @@ -794,7 +752,11 @@ export default function WindowPanel() { handleUpdate({ frameDepth: v })} + onChange={(v) => previewWindowUpdate('frameDepth', v)} + onCommit={(v) => commitWindowPreview('frameDepth', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={3} step={0.01} unit="m" diff --git a/packages/nodes/src/window/property-preview.test.ts b/packages/nodes/src/window/property-preview.test.ts new file mode 100644 index 0000000000..f9b5e9e926 --- /dev/null +++ b/packages/nodes/src/window/property-preview.test.ts @@ -0,0 +1,28 @@ +import { afterEach, expect, test } from 'bun:test' +import { useLiveNodeOverrides, useScene, WallNode, WindowNode } from '@pascal-app/core' +import { createWindowPropertyPreview } from './property-preview' + +const initial = useScene.getState() +afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + useScene.setState(initial) +}) + +test('window dimensions and curved-frame parameters preview without document mutation, then commit together', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain' }) + const window = WindowNode.parse({ parentId: wall.id, openingShape: 'rounded', cornerRadius: 0.3 }) + useScene.setState({ nodes: { [wall.id]: wall, [window.id]: window }, dirtyNodes: new Set() }) + const preview = createWindowPropertyPreview(window.id) + preview.preview({ width: 0.4, cornerRadius: 0.2 }) + expect(useScene.getState().nodes[window.id]).toBe(window) + expect(window.cornerRadius).toBe(0.3) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) + preview.preview({ width: 1.8, cornerRadius: 0.3 }) + preview.commit() + expect(useScene.getState().nodes[window.id]).toMatchObject({ width: 1.8, cornerRadius: 0.3 }) + expect(useLiveNodeOverrides.getState().get(window.id)).toBeUndefined() + preview.preview({ archHeight: 0.8 }) + preview.cancel() + expect(useScene.getState().nodes[window.id]).toMatchObject({ archHeight: window.archHeight }) + expect(useLiveNodeOverrides.getState().get(window.id)).toBeUndefined() +}) diff --git a/packages/nodes/src/window/property-preview.ts b/packages/nodes/src/window/property-preview.ts new file mode 100644 index 0000000000..28786717d9 --- /dev/null +++ b/packages/nodes/src/window/property-preview.ts @@ -0,0 +1,40 @@ +import { type AnyNodeId, useLiveNodeOverrides, useScene, type WindowNode } from '@pascal-app/core' + +export function createWindowPropertyPreview(id: AnyNodeId) { + let pending: Partial | undefined + const dirty = () => { + const scene = useScene.getState() + scene.markDirty(id) + const parent = scene.nodes[id]?.parentId + if (parent) scene.markDirty(parent as AnyNodeId) + } + const clear = () => { + if (!pending) return + useLiveNodeOverrides.getState().clearFields(id, Object.keys(pending)) + pending = undefined + dirty() + } + return { + preview(patch: Partial) { + pending = { ...pending, ...patch } + useLiveNodeOverrides.getState().set(id, patch) + dirty() + }, + commit(patch?: Partial) { + const live = useLiveNodeOverrides.getState().get(id) + const current = + pending && live + ? Object.fromEntries( + Object.keys(pending) + .filter((key) => key in live) + .map((key) => [key, live[key]]), + ) + : undefined + const updates = { ...current, ...patch } + if (Object.keys(updates).length && useScene.getState().nodes[id]?.type === 'window') + useScene.getState().updateNode(id, updates) + clear() + }, + cancel: clear, + } +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 22c7f94fa1..7d23989659 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -280,6 +280,7 @@ export { type StairBodyMaterials, } from './systems/stair/stair-materials' export { StairSystem } from './systems/stair/stair-system' +export { curtainOpeningProfile, curtainProfileSpan } from './systems/wall/curtain-opening-frame' // Pure opening-cutout profile math shared by the wall CSG pipeline and // roof-wall opening cuts in `@pascal-app/nodes` — keeps shaped holes // (arch / rounded / frameless opening) identical across both hosts. diff --git a/packages/viewer/src/systems/wall/curtain-opening-frame.test.ts b/packages/viewer/src/systems/wall/curtain-opening-frame.test.ts new file mode 100644 index 0000000000..61c16251a7 --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-opening-frame.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from 'bun:test' +import { + calculateLevelMiters, + DoorNode, + sceneRegistry, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { + buildCurtainOpeningFrame, + curtainOpeningProfile, + curtainProfileSpan, +} from './curtain-opening-frame' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' +import { generateExtrudedWall } from './wall-system' + +for (const type of ['door', 'window'] as const) { + for (const shape of ['rectangle', 'rounded', 'arch'] as const) { + test(`${type} ${shape} surround follows the opening and leaves its center clear`, () => { + const opening = (type === 'door' ? DoorNode : WindowNode).parse({ + position: [1.5, 1.05, 0], + width: 1, + height: 2.1, + openingShape: shape, + cornerRadius: 0.3, + archHeight: 0.6, + openingRadiusMode: 'individual', + openingTopRadii: [0.2, 0.4], + openingCornerRadii: [0.2, 0.4, 0.1, 0.3], + }) + const { frame, cutter } = buildCurtainOpeningFrame(opening, 0.05, 0.15) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(frame, material) + const hit = (x: number, y: number) => + new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) + expect(hit(1.5, 1)).toHaveLength(0) + expect(hit(0.975, 1).length).toBeGreaterThan(0) + expect(hit(1.5, 2.125).length).toBeGreaterThan(0) + expect(hit(1.5, -0.025).length > 0).toBe(type === 'window') + if (shape === 'arch') expect(hit(1.025, 2)).toHaveLength(0) + expect(Array.from(frame.getAttribute('position').array).every(Number.isFinite)).toBe(true) + frame.dispose() + cutter.dispose() + material.dispose() + }) + } +} + +test('arched curtain opening retains glass at the shoulders and painted frame material index', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], height: 3, wallType: 'curtain' }) + const door = DoorNode.parse({ + position: [2.25, 1.05, 0], + width: 1, + height: 2.1, + openingShape: 'arch', + archHeight: 0.6, + }) + sceneRegistry.nodes.set(wall.id, new Mesh()) + const material = new MeshBasicMaterial({ side: DoubleSide }) + try { + const geometry = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + [door], + ) + const mesh = new Mesh(geometry, [material, material, material]) + const hit = (x: number, y: number) => + new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) + expect(hit(2.25, 1)).toHaveLength(0) + expect(hit(2.25, 2.125)[0]?.face?.materialIndex).toBe(0) + expect(hit(1.8, 2.05)[0]?.face?.materialIndex).toBe(1) + geometry.dispose() + } finally { + sceneRegistry.nodes.delete(wall.id) + material.dispose() + } +}) + +test('rounded profiles respect individual radii and arch height edits', () => { + const opening = WindowNode.parse({ + width: 2, + height: 2, + position: [1, 1, 0], + openingShape: 'rounded', + openingRadiusMode: 'individual', + openingCornerRadii: [0.1, 0.7, 0.2, 0.3], + }) + const span = curtainProfileSpan(curtainOpeningProfile(opening, 0.05).inner, 1.95)! + expect(span[0]).toBeLessThan(0.1) + expect(span[1]).toBeLessThan(1.7) + const arch = { ...opening, openingShape: 'arch' as const, archHeight: 0.8 } + const first = curtainProfileSpan(curtainOpeningProfile(arch, 0.05).inner, 1.8)! + const changed = curtainProfileSpan( + curtainOpeningProfile({ ...arch, archHeight: 0.3 }, 0.05).inner, + 1.8, + )! + expect(changed[1] - changed[0]).toBeGreaterThan(first[1] - first[0]) +}) + +test('arched surrounds stop at the host wall top', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], height: 3, wallType: 'curtain' }) + const opening = WindowNode.parse({ + position: [2.25, 2.5, 0], + width: 1, + height: 1, + openingShape: 'arch', + }) + const geometry = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + [opening], + ) + geometry.computeBoundingBox() + expect(geometry.boundingBox!.max.y).toBeCloseTo(3) + expect(Array.from(geometry.getAttribute('position').array).every(Number.isFinite)).toBe(true) + geometry.dispose() +}) diff --git a/packages/viewer/src/systems/wall/curtain-opening-frame.ts b/packages/viewer/src/systems/wall/curtain-opening-frame.ts new file mode 100644 index 0000000000..99c00e57b7 --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-opening-frame.ts @@ -0,0 +1,74 @@ +import type { DoorNode, WindowNode } from '@pascal-app/core' +import { ExtrudeGeometry, Path, Shape, Vector2 } from 'three' +import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' +import { buildOpeningCutoutShape } from './opening-cutout-geometry' + +export function curtainOpeningProfile(opening: DoorNode | WindowNode, width: number) { + const bottom = opening.position[1] - opening.height / 2 + const points = buildOpeningCutoutShape(opening, { + left: opening.position[0] - opening.width / 2, + right: opening.position[0] + opening.width / 2, + bottom, + top: bottom + opening.height, + }) + .getPoints(16) + .filter((point, index, all) => index === 0 || point.distanceToSquared(all[index - 1]!) > 1e-14) + if (points[0]!.distanceToSquared(points.at(-1)!) < 1e-14) points.pop() + const outer = points.map((point, index) => { + const previous = points[(index + points.length - 1) % points.length]! + const next = points[(index + 1) % points.length]! + const before = point.clone().sub(previous).normalize() + const after = next.clone().sub(point).normalize() + const n1 = new Vector2(before.y, -before.x) + const n2 = new Vector2(after.y, -after.x) + const result = point + .clone() + .addScaledVector(n1.clone().add(n2), width / Math.max(1e-6, 1 + n1.dot(n2))) + if (opening.type === 'door' && Math.abs(point.y - bottom) < 1e-7) result.y = bottom + return result + }) + return { inner: points, outer } +} + +export function buildCurtainOpeningFrame( + opening: DoorNode | WindowNode, + width: number, + depth: number, +) { + const { inner, outer } = curtainOpeningProfile(opening, width) + const outline = new Shape(outer) + outline.closePath() + let ring: Shape + if (opening.type === 'door') { + // The door has jambs and a head; its threshold remains owned by the door. + ring = new Shape([...outer.slice(1), outer[0]!, inner[0]!, ...inner.slice(1).reverse()]) + ring.closePath() + } else { + ring = new Shape(outer) + ring.closePath() + const hole = new Path([...inner].reverse()) + hole.closePath() + ring.holes.push(hole) + } + const extrude = (shape: Shape, thickness: number) => { + const geometry = new ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false, steps: 1 }) + geometry.translate(0, 0, -thickness / 2) + ensureRenderableGeometryAttributes(geometry) + return geometry + } + return { frame: extrude(ring, depth), cutter: extrude(outline, depth * 3) } +} + +export function curtainProfileSpan( + points: readonly Vector2[], + height: number, +): [number, number] | null { + const xs: number[] = [] + for (let i = 0; i < points.length; i++) { + const a = points[i]!, + b = points[(i + 1) % points.length]! + if ((a.y <= height && b.y > height) || (b.y <= height && a.y > height)) + xs.push(a.x + ((b.x - a.x) * (height - a.y)) / (b.y - a.y)) + } + return xs.length >= 2 ? [Math.min(...xs), Math.max(...xs)] : null +} diff --git a/packages/viewer/src/systems/wall/curtain-wall-door-resize.test.ts b/packages/viewer/src/systems/wall/curtain-wall-door-resize.test.ts new file mode 100644 index 0000000000..80b407cf9d --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-door-resize.test.ts @@ -0,0 +1,65 @@ +import { afterEach, expect, test } from 'bun:test' +import { + DoorNode, + LevelNode, + sceneRegistry, + useLiveNodeOverrides, + useScene, + WallNode, +} from '@pascal-app/core' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { runWallBuildFrame } from './wall-system' + +const initial = useScene.getState() +afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + sceneRegistry.clear() + useScene.setState(initial) +}) + +test('curtain door width previews rebuild a clear opening and cancel without document writes', () => { + const level = LevelNode.parse({ height: 3 }) + const wall = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [6, 0], + wallType: 'curtain', + }) + const door = DoorNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [1.5, 1.05, 0], + width: 0.9, + }) + wall.children = [door.id] + level.children = [wall.id] + useScene.setState({ + nodes: { [level.id]: level, [wall.id]: wall, [door.id]: door }, + dirtyNodes: new Set([wall.id]), + }) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(undefined, [material, material, material]) + sceneRegistry.nodes.set(wall.id, mesh) + sceneRegistry.byType.wall!.add(wall.id) + const hit = (x: number, y: number) => + new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) + try { + runWallBuildFrame() + expect(hit(0.85, 1).length).toBeGreaterThan(0) + useLiveNodeOverrides.getState().set(door.id, { width: 1.6 }) + useScene.getState().markDirty(wall.id) + runWallBuildFrame() + expect(hit(0.85, 1)).toHaveLength(0) + expect(hit(0.675, 1)[0]?.face?.materialIndex).toBe(0) + expect(hit(0.85, 2.4)[0]?.face?.materialIndex).toBe(1) + expect(useScene.getState().nodes[door.id]).toBe(door) + useLiveNodeOverrides.getState().clear(door.id) + useScene.getState().markDirty(wall.id) + runWallBuildFrame() + expect(hit(0.85, 1).length).toBeGreaterThan(0) + expect(useScene.getState().nodes[door.id]).toBe(door) + } finally { + mesh.geometry.dispose() + material.dispose() + } +}) diff --git a/packages/viewer/src/systems/wall/curtain-wall-geometry.test.ts b/packages/viewer/src/systems/wall/curtain-wall-geometry.test.ts new file mode 100644 index 0000000000..d8f017b0b8 --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-geometry.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'bun:test' +import { + CurtainWallConfig, + calculateLevelMiters, + DoorNode, + sceneRegistry, + WallNode, +} from '@pascal-app/core' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' +import { getMaterialsForWall } from './wall-materials' +import { generateExtrudedWall } from './wall-system' + +const material = new MeshBasicMaterial({ side: DoubleSide }) +function hit(mesh: Mesh, x: number, y: number) { + return new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) +} + +describe('curtain wall geometry', () => { + for (const construction of ['stick', 'unitized'] as const) { + for (const framing of [ + 'capped', + 'vertical-caps', + 'horizontal-caps', + 'structural-glazing', + ] as const) { + test(`${construction} / ${framing} produces finite glass and framing`, () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [6, 0], + height: 3, + thickness: 0.15, + wallType: 'curtain', + curtainWall: { construction, framing }, + }) + const geometry = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + ) + expect(geometry.groups.some((group) => group.materialIndex === 0 && group.count > 0)).toBe( + true, + ) + expect(geometry.groups.some((group) => group.materialIndex === 1 && group.count > 0)).toBe( + true, + ) + expect(Array.from(geometry.getAttribute('position').array).every(Number.isFinite)).toBe( + true, + ) + const mesh = new Mesh(geometry, [material, material, material]) + expect(hit(mesh, 0.75, 0.75).length).toBeGreaterThan(0) + geometry.dispose() + }) + } + } + test('hosted doors cut glass and mullions, while empty panel overrides remove infill', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [6, 0], + height: 3, + thickness: 0.15, + wallType: 'curtain', + curtainWall: { panels: [{ column: 2, row: 0, type: 'empty' }] }, + }) + const door = DoorNode.parse({ position: [1.5, 1.05, 0], width: 0.9, height: 2.1 }) + const registered = new Mesh() + sceneRegistry.nodes.set(wall.id, registered) + try { + const geometry = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + [door], + ) + const mesh = new Mesh(geometry, [material, material, material]) + expect(hit(mesh, 1.5, 1).length).toBe(0) + expect(hit(mesh, 1.5, 0.02).length).toBe(0) + expect(hit(mesh, 1.025, 1)[0]?.face?.materialIndex).toBe(0) + expect(hit(mesh, 1.975, 1)[0]?.face?.materialIndex).toBe(0) + expect(hit(mesh, 1.25, 2.125)[0]?.face?.materialIndex).toBe(0) + expect(hit(mesh, 1.25, 2.4)[0]?.face?.materialIndex).toBe(1) + expect(hit(mesh, 3.75, 0.75).length).toBe(0) + expect(hit(mesh, 0.75, 0.75).length).toBeGreaterThan(0) + geometry.dispose() + } finally { + sceneRegistry.nodes.delete(wall.id) + registered.geometry.dispose() + } + }) + test('curved walls and raised supports keep finite bounds', () => { + const wall = WallNode.parse({ + start: [2, 3], + end: [8, 6], + curveOffset: 1, + height: 3, + thickness: 0.15, + wallType: 'curtain', + }) + const geometry = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.5), + ) + geometry.computeBoundingBox() + expect(geometry.boundingBox!.max.y).toBeCloseTo(3) + expect(geometry.boundingBox!.min.y).toBeCloseTo(0) + expect(Array.from(geometry.getAttribute('position').array).every(Number.isFinite)).toBe(true) + geometry.dispose() + }) + test('material cache changes with curtain settings and wall type', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain' }) + const first = getMaterialsForWall(wall) + expect(first.visible[1]!.transparent).toBe(true) + const changed = getMaterialsForWall({ + ...wall, + curtainWall: CurtainWallConfig.parse({ glassOpacity: 0.7 }), + }) + expect(changed.visible[1]!.opacity).toBe(0.7) + expect(changed.materialHash).not.toBe(first.materialHash) + const standard = getMaterialsForWall({ ...wall, wallType: 'standard' }) + expect(standard.visible[1]!.transparent).toBe(false) + }) +}) diff --git a/packages/viewer/src/systems/wall/curtain-wall-geometry.ts b/packages/viewer/src/systems/wall/curtain-wall-geometry.ts new file mode 100644 index 0000000000..91ffe6faee --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-geometry.ts @@ -0,0 +1,183 @@ +import { + type AnyNode, + buildCurtainWallLayout, + type CurtainWallPiece, + getCurtainWallConfig, + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + isCurvedWall, + type WallNode, +} from '@pascal-app/core' +import { BufferGeometry, ExtrudeGeometry, Shape } from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { Brush, Evaluator, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' +import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' +import { buildCurtainOpeningFrame } from './curtain-opening-frame' +import { buildStraightCurtainPieces } from './curtain-wall-piece-geometry' + +function pieceGeometry(wall: WallNode, piece: CurtainWallPiece, length: number, base: number) { + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cos = Math.cos(angle), + sin = Math.sin(angle) + const samples = isCurvedWall(wall) + ? Math.max(1, Math.ceil(((piece.right - piece.left) / length) * 48)) + : 1 + const points: [number, number][] = [] + for (const offset of [piece.front, piece.back]) { + const side: [number, number][] = [] + for (let i = 0; i <= samples; i++) { + const along = piece.left + ((piece.right - piece.left) * i) / samples + if (!isCurvedWall(wall)) { + side.push([along, -offset]) + continue + } + const frame = getWallCurveFrameAt(wall, along / length) + const x = frame.point.x + frame.normal.x * offset - wall.start[0] + const z = frame.point.y + frame.normal.y * offset - wall.start[1] + side.push([x * cos + z * sin, -(-x * sin + z * cos)]) + } + points.push(...(offset === piece.front ? side : side.reverse())) + } + const shape = new Shape() + shape.moveTo(...points[0]!) + for (const point of points.slice(1)) shape.lineTo(...point) + shape.closePath() + const geometry = new ExtrudeGeometry(shape, { + depth: piece.top - piece.bottom, + bevelEnabled: false, + }) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, base + piece.bottom, 0) + ensureRenderableGeometryAttributes(geometry) + return geometry +} + +export function buildCurtainWallGeometry( + wall: WallNode, + envelope: BufferGeometry, + children: readonly AnyNode[] = [], +): BufferGeometry { + const length = getWallCurveLength(wall) + envelope.computeBoundingBox() + const bounds = envelope.boundingBox + if (!bounds || bounds.isEmpty() || length <= 1e-6) return envelope + const pieces = buildCurtainWallLayout( + length, + bounds.max.y - bounds.min.y, + getWallThickness(wall), + getCurtainWallConfig(wall), + children.filter((child) => child.type === 'door' || child.type === 'window'), + bounds.min.y, + ) + const shapedFrames = children + .filter( + (child) => + (child.type === 'window' || child.type === 'door') && child.openingShape !== 'rectangle', + ) + .map((opening) => + buildCurtainOpeningFrame( + opening as Extract, + getCurtainWallConfig(wall).perimeterWidth, + getWallThickness(wall), + ), + ) + const evaluator = new Evaluator() + evaluator.attributes = ['position', 'normal', 'uv', 'uv2'] + evaluator.useGroups = false + const shell = new Brush(envelope) + const position = envelope.getAttribute('position') + const near = (a: number, b: number) => Math.abs(a - b) < 1e-6 + const halfDepth = getWallThickness(wall) / 2 + const rectangular = + !isCurvedWall(wall) && + Array.from({ length: position.count }, (_, i) => i).every( + (i) => + (near(position.getX(i), 0) || near(position.getX(i), length)) && + (near(position.getY(i), bounds.min.y) || near(position.getY(i), bounds.max.y)) && + (near(position.getZ(i), -halfDepth) || near(position.getZ(i), halfDepth)), + ) + if (!rectangular) prepareBrushForCSG(shell) + const results: BufferGeometry[] = [] + const roles = ['frame', 'glass', 'solid'] as const + try { + for (const [materialIndex, role] of roles.entries()) { + const rolePieces = pieces.filter((piece) => piece.role === role) + if (!rolePieces.length && !(role === 'frame' && shapedFrames.length)) continue + let merged: BufferGeometry | null + if (!isCurvedWall(wall)) { + merged = buildStraightCurtainPieces( + rolePieces.map((piece) => ({ + ...piece, + left: piece.left === 0 ? Math.min(0, bounds.min.x) : piece.left, + right: piece.right === length ? Math.max(length, bounds.max.x) : piece.right, + })), + bounds.min.y, + ) + } else { + const sources = rolePieces.map((piece) => pieceGeometry(wall, piece, length, bounds.min.y)) + merged = mergeGeometries(sources, false) + for (const source of sources) source.dispose() + } + if (!merged) continue + for (const opening of shapedFrames) { + const brush: Brush = new Brush(merged) + const cutter = new Brush(opening.cutter) + prepareBrushForCSG(brush) + prepareBrushForCSG(cutter) + const cut: BufferGeometry = evaluator.evaluate(brush, cutter, SUBTRACTION).geometry + merged.dispose() + merged = cut.index ? cut.toNonIndexed() : cut + if (merged !== cut) cut.dispose() + } + if (role === 'frame' && shapedFrames.length) { + const frames = shapedFrames.map(({ frame }) => frame) + const combined = mergeGeometries([merged, ...frames], false) + merged.dispose() + if (!combined) continue + merged = combined + } + merged.computeBoundingBox() + if ( + rectangular && + merged.boundingBox && + bounds.clone().expandByScalar(1e-6).containsBox(merged.boundingBox) + ) { + merged.clearGroups() + merged.addGroup(0, merged.getAttribute('position').count, materialIndex) + results.push(merged) + continue + } + if (rectangular) prepareBrushForCSG(shell) + const brush = new Brush(merged) + prepareBrushForCSG(brush) + try { + // The existing wall envelope owns miter joins, support profiles, and all hosted cuts. + const clipped = evaluator.evaluate(brush, shell, INTERSECTION).geometry + const geometry = clipped.index ? clipped.toNonIndexed() : clipped + if (geometry !== clipped) clipped.dispose() + geometry.clearGroups() + geometry.addGroup(0, geometry.getAttribute('position').count, materialIndex) + results.push(geometry) + } finally { + merged.dispose() + } + } + if (!results.length) return new BufferGeometry() + const combined = mergeGeometries(results, false) ?? new BufferGeometry() + let start = 0 + for (const result of results) { + const count = result.getAttribute('position').count + combined.addGroup(start, count, result.groups[0]!.materialIndex) + start += count + } + return combined + } finally { + for (const opening of shapedFrames) { + opening.frame.dispose() + opening.cutter.dispose() + } + envelope.dispose() + for (const result of results) result.dispose() + } +} diff --git a/packages/viewer/src/systems/wall/curtain-wall-materials.test.ts b/packages/viewer/src/systems/wall/curtain-wall-materials.test.ts new file mode 100644 index 0000000000..b9e8d3d336 --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-materials.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from 'bun:test' +import { CurtainWallConfig, SceneMaterial, WallNode } from '@pascal-app/core' +import { Texture } from 'three' +import type { MeshStandardNodeMaterial } from 'three/webgpu' +import { resolveMaterialRef } from '../../lib/materials' +import { getMaterialsForWall } from './wall-materials' + +test('painted frames keep shared texture updates and do not dispose the paint material on edits', () => { + const ref = 'library:preset-metal' + const shared = resolveMaterialRef(ref, undefined, 'rendered') as MeshStandardNodeMaterial + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + wallType: 'curtain', + slots: { 'curtain-frame': ref }, + }) + const first = getMaterialsForWall(wall) + expect(first.visible[0]).toBe(shared) + const previousMap = shared.map + const texture = new Texture() + let disposed = false + const onDispose = () => { + disposed = true + } + shared.addEventListener('dispose', onDispose) + try { + shared.map = texture + expect((first.visible[0] as MeshStandardNodeMaterial).map).toBe(texture) + const next = getMaterialsForWall({ + ...wall, + curtainWall: CurtainWallConfig.parse({ glassOpacity: 0.6 }), + }) + expect(next.visible[0]).toBe(shared) + expect(disposed).toBe(false) + expect(next.visible[1]!.opacity).toBe(0.6) + } finally { + shared.map = previousMap + shared.removeEventListener('dispose', onDispose) + texture.dispose() + } +}) + +test('scene material edits update the frame while preserving glass and solid panel settings', () => { + const material = SceneMaterial.parse({ + id: 'smat_curtainframe', + name: 'Frame finish', + material: { preset: 'metal' }, + }) + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + wallType: 'curtain', + slots: { 'curtain-frame': `scene:${material.id}` }, + }) + const first = getMaterialsForWall(wall, 'rendered', true, 'clay', undefined, { + [material.id]: material, + }) + const edited = SceneMaterial.parse({ + ...material, + material: { + preset: 'custom', + properties: { color: '#ff0000', roughness: 0.2, metalness: 0.9 }, + }, + }) + const next = getMaterialsForWall(wall, 'rendered', true, 'clay', undefined, { + [material.id]: edited, + }) + expect(next.visible[0]).not.toBe(first.visible[0]) + expect((next.visible[0] as MeshStandardNodeMaterial).color.getHexString()).toBe('ff0000') + expect((next.visible[0] as MeshStandardNodeMaterial).roughness).toBe(0.2) + expect(next.visible[1]!.opacity).toBe(0.3) +}) diff --git a/packages/viewer/src/systems/wall/curtain-wall-materials.ts b/packages/viewer/src/systems/wall/curtain-wall-materials.ts new file mode 100644 index 0000000000..bfd0fb228b --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-materials.ts @@ -0,0 +1,51 @@ +import { + getCurtainWallConfig, + type SceneMaterial, + type SceneMaterialId, + type WallNode, +} from '@pascal-app/core' +import type { Material } from 'three' +import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' +import { type RenderShading, resolveMaterialRef } from '../../lib/materials' + +const ownedMaterials = new WeakSet() + +export function isOwnedCurtainWallMaterial(material: Material): boolean { + return ownedMaterials.has(material) +} + +export function createCurtainWallMaterials( + wall: WallNode, + shading: RenderShading, + materials?: Record, +): Material[] { + const config = getCurtainWallConfig(wall) + return ['frame', 'glass', 'solid'].map((role) => { + const ref = wall.slots?.[`curtain-${role}`] + const override = ref ? resolveMaterialRef(ref, materials, shading) : undefined + // Borrow the shared material so asynchronously loaded texture maps stay live. + if (override) return override + const glass = role === 'glass' + const color = glass + ? config.glassColor + : role === 'frame' + ? config.frameColor + : config.solidColor + const properties = { + color, + transparent: glass, + opacity: glass ? config.glassOpacity : 1, + depthWrite: !glass, + } + const material = + shading === 'solid' + ? new MeshLambertNodeMaterial(properties) + : new MeshStandardNodeMaterial({ + ...properties, + metalness: role === 'frame' ? 0.65 : 0, + roughness: glass ? config.glassRoughness : 0.4, + }) + ownedMaterials.add(material) + return material + }) +} diff --git a/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.test.ts b/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.test.ts new file mode 100644 index 0000000000..bb8e33cc8a --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'bun:test' +import type { CurtainWallPiece } from '@pascal-app/core' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { buildStraightCurtainPieces } from './curtain-wall-piece-geometry' + +test('a pane around a doorway has no internal glass faces above the header', () => { + const common = { front: 0.07, back: 0.05, role: 'glass' as const } + const pieces: CurtainWallPiece[] = [ + { ...common, left: 0, right: 1, bottom: 0, top: 3 }, + { ...common, left: 1, right: 2, bottom: 2, top: 3 }, + ] + const geometry = buildStraightCurtainPieces(pieces, 0) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(geometry, material) + const ray = (x: number, y: number) => + new Raycaster(new Vector3(x, y, 0.06), new Vector3(1, 0, 0)).intersectObject(mesh, false) + // Through the glass thickness, only the outer edge should remain. + expect(ray(0.5, 2.5).every((hit) => Math.abs(hit.point.x - 2) < 1e-6)).toBe(true) + expect(ray(0.5, 2.5).length).toBeGreaterThan(0) + expect(ray(0.5, 1)[0]?.point.x).toBeCloseTo(1) + expect( + new Raycaster(new Vector3(1.5, 1, 1), new Vector3(0, 0, -1)).intersectObject(mesh, false), + ).toHaveLength(0) + geometry.dispose() + material.dispose() +}) diff --git a/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts b/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts new file mode 100644 index 0000000000..b8f0282bff --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts @@ -0,0 +1,63 @@ +import type { CurtainWallPiece } from '@pascal-app/core' +import { BufferGeometry, Float32BufferAttribute } from 'three' +import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' + +// Adjacent fragments of one pane share a boundary, not a second glass surface. +export function buildStraightCurtainPieces(pieces: readonly CurtainWallPiece[], base: number) { + const positions: number[] = [] + const quad = (a: number[], b: number[], c: number[], d: number[]) => { + positions.push(...a, ...b, ...c, ...a, ...c, ...d) + } + const sides = ['left', 'right', 'bottom', 'top'] as const + const key = (piece: CurtainWallPiece, side: (typeof sides)[number], coordinate = piece[side]) => + `${side}:${coordinate.toFixed(7)}:${piece.front.toFixed(7)}:${piece.back.toFixed(7)}` + const edges = new Map() + for (const piece of pieces) { + for (const side of sides) { + const id = key(piece, side) + const neighbors = edges.get(id) ?? [] + neighbors.push(piece) + edges.set(id, neighbors) + } + } + for (const piece of pieces) { + const { left: l, right: r, bottom, top, front: f, back: k } = piece + const b = bottom + base + const t = top + base + quad([l, b, f], [r, b, f], [r, t, f], [l, t, f]) + quad([r, b, k], [l, b, k], [l, t, k], [r, t, k]) + for (const side of sides) { + const vertical = side === 'left' || side === 'right' + let runs: [number, number][] = vertical ? [[bottom, top]] : [[l, r]] + const opposite = + side === 'left' ? 'right' : side === 'right' ? 'left' : side === 'bottom' ? 'top' : 'bottom' + for (const other of edges.get(key(piece, opposite, piece[side])) ?? []) { + if (other === piece) continue + const low = vertical ? other.bottom : other.left + const high = vertical ? other.top : other.right + runs = runs.flatMap(([start, end]) => + high <= start || low >= end + ? [[start, end]] + : [ + ...(low > start ? [[start, low] as [number, number]] : []), + ...(high < end ? [[high, end] as [number, number]] : []), + ], + ) + } + for (const [start, end] of runs) { + if (vertical) { + const y0 = start + base, + y1 = end + base + if (side === 'left') quad([l, y0, k], [l, y0, f], [l, y1, f], [l, y1, k]) + else quad([r, y0, f], [r, y0, k], [r, y1, k], [r, y1, f]) + } else if (side === 'bottom') quad([start, b, k], [end, b, k], [end, b, f], [start, b, f]) + else quad([start, t, f], [end, t, f], [end, t, k], [start, t, k]) + } + } + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + ensureRenderableGeometryAttributes(geometry) + return geometry +} diff --git a/packages/viewer/src/systems/wall/curtain-wall-preview.test.ts b/packages/viewer/src/systems/wall/curtain-wall-preview.test.ts new file mode 100644 index 0000000000..4d7405b2f8 --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-preview.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, test } from 'bun:test' +import { + CurtainWallConfig, + sceneRegistry, + useLiveNodeOverrides, + useScene, + WallNode, +} from '@pascal-app/core' +import { Mesh, PerspectiveCamera } from 'three' +import { WallCutoutCache, type WallCutoutViewerState } from './wall-cutout-cache' + +const initial = useScene.getState() +afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + sceneRegistry.clear() + useScene.setState(initial) +}) + +test('selected curtain glass previews and cancels without changing the document or other walls', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain', curtainWall: {} }) + const other = WallNode.parse({ start: [0, 4], end: [6, 4], wallType: 'curtain' }) + useScene.setState({ nodes: { [wall.id]: wall, [other.id]: other }, materials: {} }) + const mesh = new Mesh(), + otherMesh = new Mesh() + for (const [node, object] of [ + [wall, mesh], + [other, otherMesh], + ] as const) { + sceneRegistry.nodes.set(node.id, object) + sceneRegistry.byType.wall!.add(node.id) + } + const viewer: WallCutoutViewerState = { + wallMode: 'up', + shading: 'solid', + textures: true, + colorPreset: 'clay', + sceneTheme: 'studio', + selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [wall.id] }, + previewSelectedIds: [], + hoveredId: null, + hoverHighlightMode: 'default', + } + const cache = new WallCutoutCache({ getState: () => viewer }) + const camera = new PerspectiveCamera() + cache.update(camera, 1) + const otherMaterial = otherMesh.material + const opacity = () => (Array.isArray(mesh.material) ? mesh.material[1]!.opacity : -1) + expect(opacity()).toBe(0.3) + useLiveNodeOverrides + .getState() + .set(wall.id, { curtainWall: CurtainWallConfig.parse({ glassOpacity: 0.8 }) }) + cache.update(camera, 2) + expect(opacity()).toBe(0.8) + expect(useScene.getState().nodes[wall.id]).toBe(wall) + expect(otherMesh.material).toBe(otherMaterial) + useLiveNodeOverrides.getState().clear(wall.id) + cache.update(camera, 3) + expect(opacity()).toBe(0.3) + expect(otherMesh.material).toBe(otherMaterial) +}) diff --git a/packages/viewer/src/systems/wall/curtain-wall-shadow.test.ts b/packages/viewer/src/systems/wall/curtain-wall-shadow.test.ts new file mode 100644 index 0000000000..4a66d413c8 --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-shadow.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from 'bun:test' +import { calculateLevelMiters, WallNode } from '@pascal-app/core' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' +import { buildCurtainWallShadowGeometry } from './curtain-wall-shadow' +import { generateExtrudedWall } from './wall-system' + +test('shadow geometry blocks frames and spandrels but leaves transparent pane openings', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [6, 0], + height: 3, + thickness: 0.15, + wallType: 'curtain', + curtainWall: { spandrel: 'bottom' }, + }) + const visible = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + ) + const shadow = buildCurtainWallShadowGeometry(visible, false) + const opaque = buildCurtainWallShadowGeometry(visible, true) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(shadow, material) + const hit = (x: number, y: number) => + new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh).length > 0 + expect(hit(0.75, 2.25)).toBe(false) + expect(hit(1.5, 2.25)).toBe(true) + expect(hit(0.75, 0.75)).toBe(true) + mesh.geometry = opaque + expect(hit(0.75, 2.25)).toBe(true) + expect(shadow.index!.count).toBeLessThan(opaque.index!.count) + visible.dispose() + shadow.dispose() + opaque.dispose() + material.dispose() +}) diff --git a/packages/viewer/src/systems/wall/curtain-wall-shadow.ts b/packages/viewer/src/systems/wall/curtain-wall-shadow.ts new file mode 100644 index 0000000000..6a2e83c3b9 --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-shadow.ts @@ -0,0 +1,20 @@ +import type { BufferGeometry } from 'three' + +export const CURTAIN_WALL_SHADOW_NAME = 'curtain-wall-shadow' + +export function buildCurtainWallShadowGeometry( + source: BufferGeometry, + opaqueGlass: boolean, +): BufferGeometry { + const geometry = source.clone() + const indices: number[] = [] + for (const group of source.groups) { + if (group.materialIndex === 1 && !opaqueGlass) continue + for (let i = group.start; i < group.start + group.count; i++) + indices.push(source.index ? source.index.getX(i) : i) + } + geometry.clearGroups() + geometry.setIndex(indices) + geometry.setDrawRange(0, indices.length) + return geometry +} diff --git a/packages/viewer/src/systems/wall/curtain-wall-window-resize.test.ts b/packages/viewer/src/systems/wall/curtain-wall-window-resize.test.ts new file mode 100644 index 0000000000..40658740ca --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-window-resize.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, test } from 'bun:test' +import { + LevelNode, + sceneRegistry, + useLiveNodeOverrides, + useScene, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { runWallBuildFrame } from './wall-system' + +const initial = useScene.getState() +afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + sceneRegistry.clear() + useScene.setState(initial) +}) + +test('curtain window width previews rebuild a clear opening and cancel without document writes', () => { + const level = LevelNode.parse({ height: 3 }) + const wall = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [6, 0], + wallType: 'curtain', + }) + const window = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [1.5, 1.5, 0], + width: 0.9, + height: 1, + }) + wall.children = [window.id] + level.children = [wall.id] + useScene.setState({ + nodes: { [level.id]: level, [wall.id]: wall, [window.id]: window }, + dirtyNodes: new Set([wall.id]), + }) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(undefined, [material, material, material]) + sceneRegistry.nodes.set(wall.id, mesh) + sceneRegistry.byType.wall!.add(wall.id) + const hit = (x: number, y: number) => + new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) + try { + runWallBuildFrame() + expect(hit(0.85, 1.4).length).toBeGreaterThan(0) + useLiveNodeOverrides.getState().set(window.id, { width: 1.6 }) + useScene.getState().markDirty(wall.id) + runWallBuildFrame() + expect(hit(0.85, 1.4)).toHaveLength(0) + expect(hit(0.675, 1.4)[0]?.face?.materialIndex).toBe(0) + expect(hit(1.5, 0.975)[0]?.face?.materialIndex).toBe(0) + expect(hit(1.5, 2.025)[0]?.face?.materialIndex).toBe(0) + expect(hit(0.85, 2.4)[0]?.face?.materialIndex).toBe(1) + expect(useScene.getState().nodes[window.id]).toBe(window) + for (const openingShape of ['rounded', 'arch', 'rectangle'] as const) { + useLiveNodeOverrides + .getState() + .set(window.id, { openingShape, cornerRadius: 0.25, archHeight: 0.4 }) + useScene.getState().markDirty(wall.id) + runWallBuildFrame() + expect(hit(1.5, 1.5)).toHaveLength(0) + expect(hit(1.5, 2.025)[0]?.face?.materialIndex).toBe(0) + expect(useScene.getState().nodes[window.id]).toBe(window) + } + useLiveNodeOverrides.getState().clear(window.id) + useScene.getState().markDirty(wall.id) + runWallBuildFrame() + expect(hit(0.85, 1.4).length).toBeGreaterThan(0) + expect(useScene.getState().nodes[window.id]).toBe(window) + } finally { + mesh.geometry.dispose() + material.dispose() + } +}) diff --git a/packages/viewer/src/systems/wall/wall-cutout-cache.ts b/packages/viewer/src/systems/wall/wall-cutout-cache.ts index 5c24245e86..39080a215f 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.ts @@ -2,6 +2,7 @@ // This extends the existing viewer-owned wall cutout and material implementation. import { type AnyNodeId, + getEffectiveNode, getLibraryMaterialsVersion, getWallFaceBandConfig, getWallPlaneTop, @@ -9,6 +10,7 @@ import { resolveWallEffectiveHeight, sceneRegistry, spatialGridManager, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallNode, @@ -128,6 +130,7 @@ export class WallCutoutCache { private selected = new Set() private highlightKey = '' private transformed = new Set() + private overrides = useLiveNodeOverrides.getState().overrides constructor(private readonly viewerStore: WallCutoutViewerStore = useViewer) {} @@ -149,6 +152,8 @@ export class WallCutoutCache { const libraryVersion = getLibraryMaterialsVersion() const textureVersion = getMaterialTextureVersion() const previous = this.viewer + const overrides = useLiveNodeOverrides.getState().overrides + const overridesChanged = this.overrides !== overrides const nodesChanged = this.nodes !== scene.nodes const registryChanged = this.registryRevision !== sceneRegistry.revision || this.wallCount !== wallIds.size @@ -190,6 +195,7 @@ export class WallCutoutCache { const releasedPreview = previewId(previous) !== previewId(viewer) ? previewId(previous) : null this.viewer = viewer const invalidated = + overridesChanged || appearanceChanged || nodesChanged || registryChanged || @@ -259,17 +265,24 @@ export class WallCutoutCache { const changed = pathChanged(id) const rebuilt = this.rebuilt.has(id) if (added || changed || rebuilt) this.refreshNormal(wall, visited) - wall.node = node - if (added || appearanceChanged || (nodesChanged && changed)) this.refreshAppearance(wall) + const overrideChanged = this.overrides.get(id) !== overrides.get(id) + wall.node = getEffectiveNode(node) + if (added || appearanceChanged || overrideChanged || (nodesChanged && changed)) + this.refreshAppearance(wall) if ( added || appearanceChanged || + overrideChanged || changed || rebuilt || releasedPreview === id || cameraChanged ) { - this.apply(wall, viewer.wallMode, added || appearanceChanged || (nodesChanged && changed)) + this.apply( + wall, + viewer.wallMode, + added || appearanceChanged || overrideChanged || (nodesChanged && changed), + ) } } } else { @@ -277,6 +290,7 @@ export class WallCutoutCache { } this.rebuilt.clear() this.transformed.clear() + this.overrides = overrides this.nodes = scene.nodes this.materials = scene.materials this.registryRevision = sceneRegistry.revision diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 81a0915964..9d7a522b4a 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -1,4 +1,5 @@ import { + getCurtainWallConfig, getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, @@ -30,6 +31,8 @@ import { resolveSurfaceColor, } from '../../lib/materials' +import { createCurtainWallMaterials, isOwnedCurtainWallMaterial } from './curtain-wall-materials' + type SceneMaterials = Record | undefined const DEFAULT_WALL_COLOR = '#e9e7e3' @@ -55,6 +58,7 @@ export interface WallMaterials { deleteInvisible: WallMaterialArray deleteTranslucent: WallMaterialArray materialHash: string + ownsVisible?: boolean } const wallMaterialCache = new Map() @@ -449,8 +453,29 @@ export function getWallMaterialHash( shading: RenderShading, sceneMaterials?: SceneMaterials, ): string { + const curtain = getCurtainWallConfig(wallNode) return JSON.stringify({ shading, + wallType: wallNode.wallType, + curtainWall: + wallNode.wallType === 'curtain' + ? [ + curtain.frameColor, + curtain.glassColor, + curtain.solidColor, + curtain.glassOpacity, + curtain.glassRoughness, + ] + : undefined, + curtainSlots: + wallNode.wallType === 'curtain' + ? Object.fromEntries( + ['curtain-frame', 'curtain-glass', 'curtain-solid'].map((slot) => [ + slot, + wallSlotMaterialSignature(wallNode, slot as WallSurfaceSlotId, sceneMaterials), + ]), + ) + : undefined, interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials), exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials), lowerInterior: wallSlotMaterialSignature(wallNode, 'lowerInterior', sceneMaterials), @@ -484,6 +509,7 @@ export function getMaterialsForWall( if (existing) { disposeOwnedMaterials([ + ...(existing.ownsVisible ? [existing.visible.filter(isOwnedCurtainWallMaterial)] : []), existing.invisible, existing.translucent, existing.deleteVisible, @@ -498,21 +524,24 @@ export function getMaterialsForWall( // fields → declared slot default, parity with the retired DEFAULT_WALL_MATERIAL). // Textures-off collapses every face to the themed wall role (the guaranteed // escape hatch). The edge/cap slot (index 0) stays role-based. - const visible: WallMaterialArray = textures - ? [ - wallRoleMaterial, - resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials), - resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'lowerInterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'middleInterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'upperInterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'topInterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'lowerExterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'middleExterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'upperExterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'topExterior', shading, sceneMaterials), - ] - : Array.from({ length: 11 }, () => wallRoleMaterial) + const visible: WallMaterialArray = + textures && wallNode.wallType === 'curtain' + ? createCurtainWallMaterials(wallNode, shading, sceneMaterials) + : textures + ? [ + wallRoleMaterial, + resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials), + resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'lowerInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'middleInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'upperInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'topInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'lowerExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'middleExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'upperExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'topExterior', shading, sceneMaterials), + ] + : Array.from({ length: 11 }, () => wallRoleMaterial) const wallRoleColor = resolveSurfaceColor('wall', colorPreset, sceneTheme) const invisible: WallMaterialArray = [ @@ -595,6 +624,7 @@ export function getMaterialsForWall( deleteInvisible, deleteTranslucent, materialHash, + ownsVisible: textures && wallNode.wallType === 'curtain', } wallMaterialCache.set(cacheKey, result) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index ce152367fb..7a8603e6e5 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -42,6 +42,8 @@ import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../li import { setGroupsSortedByMaterial } from '../../lib/geometry-groups' import { timeSpan } from '../../lib/perf-tracks' import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' +import { buildCurtainWallShadowGeometry, CURTAIN_WALL_SHADOW_NAME } from './curtain-wall-shadow' import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache' import { buildOpeningCutoutGeometry, @@ -1012,7 +1014,9 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { const builtGeo = generateExtrudedWall( node, - childrenNodes, + node.wallType === 'curtain' && !isCurvedWall(node) + ? childrenNodes.filter((child) => child.type !== 'door' && child.type !== 'window') + : childrenNodes, miterData, slabElevation, slabSupport.baseElevation, @@ -1029,13 +1033,23 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { new THREE.Quaternion().setFromAxisAngle(WALL_UV_Y_AXIS, -wallAngle), WALL_UV_UNIT_SCALE, ) - const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix) + const renderedGeo = + node.wallType === 'curtain' ? buildCurtainWallGeometry(node, builtGeo, childrenNodes) : builtGeo + const newGeo = applyWorldPlanarWallUVs(renderedGeo, wallWorldMatrix) mesh.geometry.dispose() // A degenerate rebuild (zero-length or fully cut wall) yields as few vertices // as the mount-time placeholder; the stamp keeps the sweep from re-marking it. newGeo.userData.built = true mesh.geometry = newGeo + const shadowMesh = mesh.getObjectByName(CURTAIN_WALL_SHADOW_NAME) as THREE.Mesh | undefined + if (shadowMesh && node.wallType === 'curtain') { + shadowMesh.geometry.dispose() + shadowMesh.geometry = buildCurtainWallShadowGeometry( + newGeo, + node.curtainWall?.glassOpacity === 1, + ) + } // Update collision mesh const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh if (collisionMesh) { diff --git a/wiki/curtain-walls.md b/wiki/curtain-walls.md new file mode 100644 index 0000000000..85058e0d39 --- /dev/null +++ b/wiki/curtain-walls.md @@ -0,0 +1,38 @@ +# Curtain walls + +Select a wall and choose **Wall type → Curtain wall** in its properties. Curtain walls remain wall nodes: they use the same drawing tools, dimensions, curve controls, selection, and hosted doors/windows. Switching back to Standard preserves curtain settings; switching to Curtain wall preserves standard wall finishes and trim settings. + +## Construction and framing + +Construction and framing are independent settings. Stick-built walls have continuous grid members. Unitized walls have separate framed modules and adjustable joints. Either can use fully capped, vertical-cap, horizontal-cap, or structural-glazing framing. Uncapped members sit behind the glazing. Joint controls appear for unitized or uncapped configurations. + +This distinction follows the assembly and glazing options described by [Schüco's unitized system](https://www.schueco.com/uk/specifiers/magazine/new-unitised-system) and its [stick-system structural glazing](https://www.schueco.com/de-en/fabricators/products/facades/mullion-transom-facades/sfc-85-sg-hi). + +## Settings + +Numeric curtain controls and wall dimensions preview through temporary overrides while dragging or typing. Release, Enter, or leaving the field commits one undoable scene change. Escape cancels the preview. Colors preview until the color field loses focus; dropdown choices commit immediately. + +- Columns and rows independently support panel count, maximum spacing, or fixed spacing. Fixed spacing offers start, center, and end alignment, following the grid controls described in [Revit's curtain wall properties](https://help.autodesk.com/cloudhelp/2026/ENU/Revit-ArchDesign/files/GUID-B9263125-74EA-4C78-AAAA-F40916EFE2DB.htm). +- Frame depth uses the existing wall thickness. Mullion, transom, and border widths have separate controls. +- Default panels can be glass, solid, or empty. A solid top or bottom row creates a spandrel band. +- Individual panels can override the default or spandrel setting. Columns count from the wall start; rows count from the bottom. Reset removes the override. Overrides outside a resized grid are retained if that grid grows again. +- Glass has tint, opacity, roughness, and panel thickness controls. Frame and solid-panel colors are editable. The paint tool exposes separate frame, glass, and solid slots. Editing a color removes that slot's paint override; editing glass opacity or roughness removes the glass paint override so the new setting takes effect. +- The Frame material control shows the assigned library or scene material. Choose a material in the paint tool and click a frame in 3D, or use **Apply selected paint to frame** in the wall panel. This affects the selected wall's framing only. **Use frame color** removes the frame material and restores its color control. +- Existing Door and Window tools cut through the curtain geometry. Empty infill removes the panel while retaining its surrounding frame. + +## Rendering and limits + +The 3D mesh follows curved walls and clips against the existing wall envelope for hosted openings, joins, and support profiles. The 2D floorplan shows frame and panel sections with door/window gaps. Standard wall bands and trim are hidden while curtain mode is active. + +Each grid axis is limited to 32 panels. When spacing would exceed that limit, spacing expands to fit. Frame widths and panel thickness are constrained to fit small cells and shallow walls. + +This is an architectural geometry model. It does not model fabrication profiles, gaskets, anchors, thermal performance, individual insulating-glass layers, spider fittings, double-skin facades, or operable-panel hardware. Curved walls use curved infill. Floorplan door/window gaps follow the opening profile at the section height. + + +## Doors in curtain walls + +Use the existing Door tool on a curtain wall. Rectangular doors automatically receive two entrance jambs and a header outside the door opening. These use the wall's border width, depth, and frame material, including paint-tool materials. The doorway clears intersecting infill, mullions, transoms, and the bottom rail; glazing above the header remains. The door retains its own frame, threshold, leaf type, glazing segments, hardware, and paint settings. + +Entrance framing is derived from the effective door dimensions and position, so it follows live moves and resizing without storing additional nodes. Deleting a door restores the curtain grid. Floorplan sections use the same entrance layout. Rounded and arched doors and windows receive a surround following their actual profile, including arch height and individual corner radii. Windows receive a full surround; doors retain an open bottom. The floorplan section follows the same outline. + +This models an integrated framed entrance, as described in [Kawneer's entrance systems](https://www.kawneer.com/products/doors-and-entrances/260-360-560-insulclad-thermal-entrances/). Doors can span grid cells; this does not implement Revit's panel-replacement placement workflow. From 8a341dbd042a1159bfd0a86ea5989bbe6bc70336 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 12:01:02 +0530 Subject: [PATCH 02/21] feat(walls): refine curtain wall controls and glass appearance - Add panel layout diagram and spacing summaries - Improve frame dimensions and glass appearance controls - Preserve painted glass and simplify wall finish handling --- .../shared/opening-documentation-fields.tsx | 199 ------------ .../nodes/src/wall/curtain-frame-material.tsx | 93 ------ .../nodes/src/wall/curtain-wall-panel.tsx | 286 ++++++++++++------ packages/nodes/src/zone/quantities-panel.tsx | 91 +----- wiki/curtain-walls.md | 8 +- 5 files changed, 204 insertions(+), 473 deletions(-) delete mode 100644 packages/nodes/src/shared/opening-documentation-fields.tsx delete mode 100644 packages/nodes/src/wall/curtain-frame-material.tsx diff --git a/packages/nodes/src/shared/opening-documentation-fields.tsx b/packages/nodes/src/shared/opening-documentation-fields.tsx deleted file mode 100644 index 83a724befc..0000000000 --- a/packages/nodes/src/shared/opening-documentation-fields.tsx +++ /dev/null @@ -1,199 +0,0 @@ -'use client' - -import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' - -type OpeningDocumentationPatch = { - mark?: string - constructionType?: 'framed' | 'masonry' - dimensionReference?: 'nominal' | 'rough-opening' | 'masonry-opening' | 'finish-opening' - roughOpeningWidth?: number - roughOpeningHeight?: number - masonryOpeningWidth?: number - masonryOpeningHeight?: number - finishOpeningWidth?: number - finishOpeningHeight?: number -} - -export function OpeningDocumentationFields({ - mark, - constructionType = 'framed', - dimensionReference = 'nominal', - roughOpeningWidth, - roughOpeningHeight, - masonryOpeningWidth, - masonryOpeningHeight, - finishOpeningWidth, - finishOpeningHeight, - onChange, -}: OpeningDocumentationPatch & { - onChange: (patch: OpeningDocumentationPatch) => void -}) { - return ( -
- -
- - -
-
- onChange({ roughOpeningWidth: value })} - value={roughOpeningWidth} - /> - onChange({ roughOpeningHeight: value })} - value={roughOpeningHeight} - /> -
-
- onChange({ masonryOpeningWidth: value })} - value={masonryOpeningWidth} - /> - onChange({ masonryOpeningHeight: value })} - value={masonryOpeningHeight} - /> -
-
- onChange({ finishOpeningWidth: value })} - value={finishOpeningWidth} - /> - onChange({ finishOpeningHeight: value })} - value={finishOpeningHeight} - /> -
-

- Leave RO, MO, and FO values blank until verified by the applicable manufacturer or trade. -

-
- ) -} - -function OptionalMeterInput({ - label, - value, - onChange, -}: { - label: string - value?: number - onChange: (value: number | undefined) => void -}) { - const unit = useViewer((state) => state.unit) - const displayValue = value === undefined ? '' : roundForInput(metersToLinearUnit(value, unit)) - - return ( - - ) -} - -function roundForInput(value: number): number { - return Math.round(value * 1000) / 1000 -} diff --git a/packages/nodes/src/wall/curtain-frame-material.tsx b/packages/nodes/src/wall/curtain-frame-material.tsx deleted file mode 100644 index b7340f785d..0000000000 --- a/packages/nodes/src/wall/curtain-frame-material.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client' - -import { - getCatalogMaterialById, - getCurtainWallConfig, - parseMaterialRef, - resolveMaterial, - type SceneMaterialId, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useEditor } from '@pascal-app/editor' -import { useLibraryMaterialsVersion } from '@pascal-app/viewer' -import { wallPaint } from './paint' - -export function CurtainFrameMaterial({ node, onCommit }: { node: WallNode; onCommit: () => void }) { - const brush = useEditor((state) => state.activePaintMaterial) - const materials = useScene((state) => state.materials) - const readOnly = useScene((state) => state.readOnly) - useLibraryMaterialsVersion() - const ref = node.slots?.['curtain-frame'] - const parsed = parseMaterialRef(ref) - const catalog = parsed?.kind === 'library' ? getCatalogMaterialById(parsed.id) : undefined - const custom = parsed?.kind === 'scene' ? materials[parsed.id as SceneMaterialId] : undefined - const name = catalog?.label ?? custom?.name ?? (ref ? 'Unavailable material' : 'Frame color') - const color = - catalog?.previewColor ?? - (custom ? resolveMaterial(custom.material).color : getCurtainWallConfig(node).frameColor) - const canApply = Boolean(brush?.material || brush?.materialPreset) - const apply = (reset = false) => { - onCommit() - const current = useScene.getState().nodes[node.id] - if (current?.type !== 'wall') return - wallPaint.commit?.({ - node: current, - role: 'curtain-frame', - material: reset ? undefined : brush?.material, - materialPreset: reset ? undefined : brush?.materialPreset, - }) - } - return ( -
-
Frame material
-
- {catalog?.previewThumbnailUrl ? ( - - ) : ( - - )} - - {name} - -
-
- - - {ref && ( - - )} -
-

- Choose a paint material, then click the frame in 3D or apply it here. -

-
- ) -} diff --git a/packages/nodes/src/wall/curtain-wall-panel.tsx b/packages/nodes/src/wall/curtain-wall-panel.tsx index 1025931711..9cb4a4c946 100644 --- a/packages/nodes/src/wall/curtain-wall-panel.tsx +++ b/packages/nodes/src/wall/curtain-wall-panel.tsx @@ -8,6 +8,7 @@ import { curtainPanelType, getCurtainWallConfig, getWallCurveLength, + getWallThickness, type WallNode, } from '@pascal-app/core' import { @@ -19,7 +20,6 @@ import { SliderControl, } from '@pascal-app/editor' import { useState } from 'react' -import { CurtainFrameMaterial } from './curtain-frame-material' type Props = { node: WallNode @@ -73,7 +73,9 @@ function LengthControl({ onChange, onCommit, onCancel, + profile = false, }: { + profile?: boolean label: string value: number min: number @@ -83,6 +85,9 @@ function LengthControl({ onCommit: () => void onCancel: () => void }) { + const millimeters = profile && unit === 'metric' + const display = (meters: number) => + millimeters ? meters * 1000 : metersToLinearUnit(meters, unit) return ( - onChange(linearControlValueToMeters(next, unit, { minMeters: min, maxMeters: max })) + onChange( + millimeters + ? Math.max(min, Math.min(max, next / 1000)) + : linearControlValueToMeters(next, unit, { minMeters: min, maxMeters: max }), + ) } - precision={3} - step={0.005} - unit={getLinearUnitLabel(unit)} - value={metersToLinearUnit(value, unit)} + precision={millimeters ? 1 : 3} + step={millimeters ? 1 : 0.005} + unit={millimeters ? 'mm' : getLinearUnitLabel(unit)} + value={display(value)} /> ) } function GridSection({ + length, + vertical = false, onPreview, title, value, @@ -112,6 +123,8 @@ function GridSection({ onCommit, onCancel, }: { + length: number + vertical?: boolean title: string value: CurtainGrid unit: Props['unit'] @@ -162,13 +175,23 @@ function GridSection({ onChange({ ...value, alignment })} options={[ - { label: 'Start', value: 'start' }, + { label: vertical ? 'Bottom' : 'Start', value: 'start' }, { label: 'Center', value: 'center' }, - { label: 'End', value: 'end' }, + { label: vertical ? 'Top' : 'End', value: 'end' }, ]} value={value.alignment} /> )} +

+ {(() => { + const positions = curtainGridPositions(length, value) + const spans = positions.slice(1).map((position, i) => position - positions[i]!) + if (!spans.length) return 'No panels' + const min = metersToLinearUnit(Math.min(...spans), unit) + const max = metersToLinearUnit(Math.max(...spans), unit) + return `${spans.length} ${vertical ? 'rows' : 'columns'} · ${min.toFixed(2)}${max - min > 0.005 ? `–${max.toFixed(2)}` : ''} ${getLinearUnitLabel(unit)} grid spacing` + })()} +

) } @@ -190,11 +213,9 @@ export function CurtainWallPanel({ }: Props) { const config = getCurtainWallConfig(node) const update = (patch: Partial, preview = false) => { - const slots = { ...node.slots } - if (patch.glassOpacity !== undefined || patch.glassRoughness !== undefined) - delete slots['curtain-glass'] - ;(preview ? onPreview : onUpdate)({ curtainWall: { ...config, ...patch }, slots }) + ;(preview ? onPreview : onUpdate)({ curtainWall: { ...config, ...patch } }) } + const [selectedColumn, setColumn] = useState(0) const [selectedRow, setRow] = useState(0) const columnCount = curtainGridPositions(getWallCurveLength(node), config.columns).length - 1 @@ -209,15 +230,21 @@ export function CurtainWallPanel({ ...(type ? [{ column, row, type }] : []), ], }) - const colors = [ - { key: 'frameColor', label: 'Frame color', slot: 'curtain-frame' }, - { key: 'glassColor', label: 'Glass tint', slot: 'curtain-glass' }, - { key: 'solidColor', label: 'Solid color', slot: 'curtain-solid' }, - ] as const + + const overridden = config.panels.some((panel) => panel.column === column && panel.row === row) + const xs = curtainGridPositions(getWallCurveLength(node), config.columns) + const ys = curtainGridPositions(height, config.rows) + const hasGlass = xs + .slice(1) + .some((_, c) => + ys.slice(1).some((_, r) => curtainPanelType(config, c, r, rowCount) === 'glass'), + ) + const paintedGlass = Boolean(node.slots?.['curtain-glass']) + const effectiveThickness = Math.min(config.glassThickness, getWallThickness(node) * 0.45) return ( <> - + update({ construction })} @@ -254,7 +281,8 @@ export function CurtainWallPanel({ onPreview={(columns) => update({ columns }, true)} onCommit={onCommit} onCancel={onCancel} - title="Columns" + title="Column layout" + length={getWallCurveLength(node)} unit={unit} value={config.columns} /> @@ -263,7 +291,9 @@ export function CurtainWallPanel({ onPreview={(rows) => update({ rows }, true)} onCommit={onCommit} onCancel={onCancel} - title="Rows" + title="Row layout" + length={height} + vertical unit={unit} value={config.rows} /> @@ -274,11 +304,12 @@ export function CurtainWallPanel({ Grid limited to 32 panels per axis. Spacing expands to fit this wall.

)} - + update({ mullionWidth }, true)} @@ -286,9 +317,10 @@ export function CurtainWallPanel({ value={config.mullionWidth} /> update({ transomWidth }, true)} @@ -296,9 +328,10 @@ export function CurtainWallPanel({ value={config.transomWidth} /> update({ perimeterWidth }, true)} @@ -307,9 +340,10 @@ export function CurtainWallPanel({ /> {(config.construction === 'unitized' || config.framing !== 'capped') && ( update({ jointWidth }, true)} @@ -318,7 +352,7 @@ export function CurtainWallPanel({ /> )} - + update({ panelType })} @@ -326,7 +360,7 @@ export function CurtainWallPanel({ value={config.panelType} /> update({ spandrel })} options={[ { label: 'None', value: 'none' }, @@ -335,7 +369,11 @@ export function CurtainWallPanel({ ]} value={config.spandrel} /> +

+ Opaque bands replace a full row with solid panels. Apply their finish with the paint tool. +

- update({ glassOpacity }, true)} - precision={2} - step={0.05} - value={config.glassOpacity} - /> - update({ glassRoughness }, true)} - precision={2} - step={0.05} - value={config.glassRoughness} - /> - - {colors - .filter(({ key }) => key !== 'frameColor' || !node.slots?.['curtain-frame']) - .map(({ key, label, slot }) => ( - - ))} + {effectiveThickness < config.glassThickness - 1e-6 && ( +

+ Effective thickness:{' '} + {unit === 'metric' + ? `${(effectiveThickness * 1000).toFixed(1)} mm` + : `${metersToLinearUnit(effectiveThickness, unit).toFixed(3)} ${getLinearUnitLabel(unit)}`} + , limited by frame depth. +

+ )}
- +

- Columns run from the wall start. Rows count upward. + Choose a cell below. Columns run from the wall start; rows count upward. +

+ + {xs.slice(1).flatMap((right, c) => + ys.slice(1).map((top, r) => { + const type = curtainPanelType(config, c, r, rowCount) + const selected = c === column && r === row + return ( + { + setColumn(c) + setRow(r) + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setColumn(c) + setRow(r) + } + }} + /> + ) + }), + )} + +

+ Column {column + 1}, row {row + 1} ·{' '} + {overridden ? 'Custom infill' : 'Following wall defaults'}

+
+ )} + {!paintedGlass && ( +
+ update({ glassOpacity: value / 100 }, true)} + precision={0} + step={5} + unit="%" + value={Math.round(config.glassOpacity * 100)} + /> + update({ glassRoughness }, true)} + precision={2} + step={0.05} + value={config.glassRoughness} + /> + +

+ Low roughness is smooth; high roughness gives a matte surface in rendered shading. +

+
+ )} +
+ )} ) } diff --git a/packages/nodes/src/zone/quantities-panel.tsx b/packages/nodes/src/zone/quantities-panel.tsx index e9c97fba6f..82934cfcd7 100644 --- a/packages/nodes/src/zone/quantities-panel.tsx +++ b/packages/nodes/src/zone/quantities-panel.tsx @@ -14,14 +14,12 @@ import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel, - MetricControl, PanelSection, - ToggleControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' -import { PanelSelect, PanelTextField } from '../shared/panel-fields' +import { PanelSelect } from '../shared/panel-fields' import { buildingUnitsForZone } from './unit-membership' type Point2D = readonly [number, number] @@ -198,92 +196,6 @@ function ZoneUnitPanel({ zone }: { zone: ZoneNode }) { ) } -function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) { - const updateNode = useScene((state) => state.updateNode) - const update = (patch: Partial) => updateNode(zone.id, patch) - const isRoom = zone.spaceRole === 'room' - - return ( - - update({ spaceRole: checked ? 'room' : 'generic' })} - /> - {isRoom ? ( - <> - update({ name })} - value={zone.name} - /> - update({ roomNumber })} - value={zone.roomNumber} - /> - - update({ enclosureStatus: enclosureStatus as ZoneNode['enclosureStatus'] }) - } - options={[ - { label: 'Auto-detect', value: 'auto' }, - { label: 'Enclosed', value: 'enclosed' }, - { label: 'Open', value: 'open' }, - ]} - value={zone.enclosureStatus} - /> - update({ occupancy })} - value={zone.occupancy} - /> - update({ floorFinish })} - value={zone.floorFinish} - /> - update({ wallFinish })} - value={zone.wallFinish} - /> - update({ ceilingFinish })} - value={zone.ceilingFinish} - /> - update({ ceilingHeight })} - precision={2} - step={0.05} - unit="m" - value={zone.ceilingHeight} - /> - - update({ - clearDimensionPolicy: clearDimensionPolicy as ZoneNode['clearDimensionPolicy'], - }) - } - options={[ - { label: 'None', value: 'none' }, - { label: 'Inside faces', value: 'inside-faces' }, - { label: 'Finish faces', value: 'finish-faces' }, - ]} - value={zone.clearDimensionPolicy} - /> - - ) : null} - - ) -} - export default function ZoneQuantitiesPanel() { const selectedZoneId = useViewer((state) => state.selection.zoneId) const unit = useViewer((state) => state.unit) @@ -329,7 +241,6 @@ export default function ZoneQuantitiesPanel() { return ( <> - diff --git a/wiki/curtain-walls.md b/wiki/curtain-walls.md index 85058e0d39..73f172945f 100644 --- a/wiki/curtain-walls.md +++ b/wiki/curtain-walls.md @@ -10,14 +10,14 @@ This distinction follows the assembly and glazing options described by [Schüco' ## Settings -Numeric curtain controls and wall dimensions preview through temporary overrides while dragging or typing. Release, Enter, or leaving the field commits one undoable scene change. Escape cancels the preview. Colors preview until the color field loses focus; dropdown choices commit immediately. +Numeric curtain controls and wall dimensions preview through temporary overrides while dragging or typing. Release, Enter, or leaving the field commits one undoable scene change. Escape cancels the preview. Glass tint previews until the color field loses focus; dropdown choices commit immediately. - Columns and rows independently support panel count, maximum spacing, or fixed spacing. Fixed spacing offers start, center, and end alignment, following the grid controls described in [Revit's curtain wall properties](https://help.autodesk.com/cloudhelp/2026/ENU/Revit-ArchDesign/files/GUID-B9263125-74EA-4C78-AAAA-F40916EFE2DB.htm). - Frame depth uses the existing wall thickness. Mullion, transom, and border widths have separate controls. - Default panels can be glass, solid, or empty. A solid top or bottom row creates a spandrel band. -- Individual panels can override the default or spandrel setting. Columns count from the wall start; rows count from the bottom. Reset removes the override. Overrides outside a resized grid are retained if that grid grows again. -- Glass has tint, opacity, roughness, and panel thickness controls. Frame and solid-panel colors are editable. The paint tool exposes separate frame, glass, and solid slots. Editing a color removes that slot's paint override; editing glass opacity or roughness removes the glass paint override so the new setting takes effect. -- The Frame material control shows the assigned library or scene material. Choose a material in the paint tool and click a frame in 3D, or use **Apply selected paint to frame** in the wall panel. This affects the selected wall's framing only. **Use frame color** removes the frame material and restores its color control. +- Select an individual panel in the clickable diagram or with column/row controls. The diagram highlights the selected cell, and the panel indicates whether it has a custom infill. Reset is enabled only for an override. Individual panels can override the default or spandrel setting. Columns count from the wall start; rows count from the bottom. Reset removes the override. Overrides outside a resized grid are retained if that grid grows again. +- Glass appearance follows panel layout and infill settings. Opacity is shown as a percentage; glass controls appear only when the wall contains glass. Painted glass keeps its material until **Use glass settings instead** is explicitly selected. +- Apply frame and solid-panel finishes directly with the paint tool. The paint tool exposes separate frame, glass, and solid slots; the wall panel keeps only the glass tint color control. - Existing Door and Window tools cut through the curtain geometry. Empty infill removes the panel while retaining its surrounding frame. ## Rendering and limits From 9b584af91f52552f2f3beb2473eb92fc9fff84b4 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 12:46:13 +0530 Subject: [PATCH 03/21] feat(walls): refine curtain wall controls and glass appearance - Constrain door and window sizing within curtain wall clearances - Add live property previews and panel highlighting - Improve curtain wall grid navigation and tint editing --- packages/nodes/src/door/definition.ts | 5 +- .../nodes/src/door/floorplan-affordances.ts | 6 +- packages/nodes/src/door/panel.tsx | 136 +++++++------- .../src/shared/curtain-opening-limits.test.ts | 83 +++++++++ .../src/shared/curtain-opening-limits.ts | 89 +++++++++ .../src/shared/opening-property-preview.ts | 62 +++++++ .../nodes/src/wall/curtain-panel-highlight.ts | 107 +++++++++++ .../nodes/src/wall/curtain-wall-panel.tsx | 173 +++++++++++++----- packages/nodes/src/window/definition.ts | 5 +- .../nodes/src/window/floorplan-affordances.ts | 6 +- packages/nodes/src/window/panel.tsx | 22 ++- .../nodes/src/window/property-preview.test.ts | 11 +- packages/nodes/src/window/property-preview.ts | 40 ---- .../wall/curtain-wall-dense-opening.test.ts | 34 ++++ .../src/systems/wall/curtain-wall-geometry.ts | 43 ++++- 15 files changed, 654 insertions(+), 168 deletions(-) create mode 100644 packages/nodes/src/shared/curtain-opening-limits.test.ts create mode 100644 packages/nodes/src/shared/curtain-opening-limits.ts create mode 100644 packages/nodes/src/shared/opening-property-preview.ts create mode 100644 packages/nodes/src/wall/curtain-panel-highlight.ts delete mode 100644 packages/nodes/src/window/property-preview.ts create mode 100644 packages/viewer/src/systems/wall/curtain-wall-dense-opening.test.ts diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 80b8b71199..ff7ae4f18f 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -7,6 +7,7 @@ import type { WallNode, } from '@pascal-app/core' import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { curtainOpeningResizeMax } from '../shared/curtain-opening-limits' import { buildDoorFloorplanSchedule, computeDoorFloorplanLevelData, @@ -58,7 +59,7 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor // limits read Infinity when wallId is unset). const roofMax = readRoofFaceWidthMax(n, scene, sign) if (roofMax !== null) return Math.max(MIN_DOOR_WIDTH, roofMax) - return readWallLength(n, scene) + return curtainOpeningResizeMax(n, scene.nodes(), 'x', sign) ?? readWallLength(n, scene) }, currentValue: (n) => n.width, onDrag: (node) => publishOpeningResizeGuides(node, false), @@ -100,6 +101,8 @@ function doorHeightHandle(): HandleDescriptor { max: (n, scene) => { const roofMax = readRoofFaceHeightMax(n, scene, 1) if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax) + const curtainMax = curtainOpeningResizeMax(n, scene.nodes(), 'y', 1) + if (curtainMax !== undefined) return curtainMax const bottom = n.position[1] - n.height / 2 return Math.max(MIN_DOOR_HEIGHT, readHostWallCeiling(n.wallId, scene) - bottom) }, diff --git a/packages/nodes/src/door/floorplan-affordances.ts b/packages/nodes/src/door/floorplan-affordances.ts index 38942e66a2..f25b7156af 100644 --- a/packages/nodes/src/door/floorplan-affordances.ts +++ b/packages/nodes/src/door/floorplan-affordances.ts @@ -7,6 +7,7 @@ import { useScene, type WallNode, } from '@pascal-app/core' +import { curtainOpeningLimits } from '../shared/curtain-opening-limits' const MIN_DOOR_WIDTH = 0.3 @@ -58,7 +59,10 @@ export const doorWidthAffordance: FloorplanAffordance = { // Max width keeps the dragged edge inside the wall span. With the // anchor fixed, the moving edge is `anchorX ± width`, so the largest // legal width is the headroom on the grow side. - const maxWidth = growDir > 0 ? wallLength - anchorX : anchorX + const limits = curtainOpeningLimits(node, nodes) + const margin = limits?.margin ?? 0 + const maxWidth = + growDir > 0 ? (limits?.length ?? wallLength) - margin - anchorX : anchorX - margin const projectToWallLocalX = (planPoint: readonly [number, number]) => { return (planPoint[0] - wallStart[0]) * dirX + (planPoint[1] - wallStart[1]) * dirZ diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index 7a560b34a7..c931108b90 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -15,7 +15,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useMemo } from 'react' +import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' +import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { scaleHandleHeight } from './door-math' const doorTypeOptions = [ @@ -137,11 +139,11 @@ export default function DoorPanel() { const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) - const previewRef = useRef<{ - id: AnyNodeId - key: keyof DoorNode - value: unknown - } | null>(null) + const preview = useMemo( + () => (selectedId ? createOpeningPropertyPreview(selectedId as AnyNodeId) : null), + [selectedId], + ) + useEffect(() => () => preview?.cancel(), [preview]) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined, @@ -154,6 +156,7 @@ export default function DoorPanel() { const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] if (liveNode?.type !== 'door') return + updates = constrainCurtainOpening(liveNode, updates, useScene.getState().nodes) const hasChange = Object.entries(updates).some(([key, value]) => { const currentValue = liveNode[key as keyof DoorNode] return !isSameDoorValue(currentValue, value) @@ -171,54 +174,10 @@ export default function DoorPanel() { [selectedId], ) - const previewDoorUpdate = useCallback( - (key: K, value: DoorNode[K]) => { - if (!selectedId) return - const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] - if (liveNode?.type !== 'door') return - - if ( - !( - previewRef.current && - previewRef.current.id === selectedId && - previewRef.current.key === key - ) - ) { - previewRef.current = { - id: selectedId as AnyNodeId, - key, - value: liveNode[key], - } - } - - if (isSameDoorValue(liveNode[key], value)) return - - ;(liveNode as DoorNode)[key] = value - useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) - }, - [selectedId], - ) - - const commitDoorPreview = useCallback( - (key: K, value: DoorNode[K]) => { - if (!selectedId) return - - const scene = useScene.getState() - const liveNode = scene.nodes[selectedId as AnyNodeId] - const preview = previewRef.current - if (liveNode?.type === 'door' && preview?.id === selectedId && preview.key === key) { - ;(liveNode as DoorNode)[key] = preview.value as DoorNode[K] - scene.dirtyNodes.add(selectedId as AnyNodeId) - } - previewRef.current = null - - useScene - .getState() - .updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) - scene.dirtyNodes.add(selectedId as AnyNodeId) - }, - [selectedId], - ) + const previewDoorUpdate = (key: K, value: DoorNode[K]) => + preview?.preview({ [key]: value } as Partial) + const commitDoorPreview = (key: K, value: DoorNode[K]) => + preview?.commit({ [key]: value } as Partial) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) @@ -304,6 +263,12 @@ export default function DoorPanel() { if (!(node && node.type === 'door' && selectedId)) return null + const limits = curtainOpeningLimits(node, useScene.getState().nodes) + const heightUpdates = (height: number): Partial => ({ + height, + position: [node.position[0], height / 2, node.position[2]], + handleHeight: scaleHandleHeight(node.handleHeight, node.height, height), + }) const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0) const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) const isOpening = node.openingKind === 'opening' @@ -701,33 +666,36 @@ export default function DoorPanel() { )} + {limits && ( +

+ Size is limited to the wall, including clearance for the opening frame. +

+ )} handleUpdate({ width: v })} + max={Math.min(maxDoorWidth, limits?.width ?? Infinity)} + min={Math.min(0.5, limits?.width ?? 0.5)} + onChange={(v) => previewDoorUpdate('width', v)} + onCommit={(v) => commitDoorPreview('width', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping precision={2} restoreOnCommit={false} - step={0.05} + step={0.01} unit="m" value={node.width} /> - handleUpdate({ - height: v, - position: [node.position[0], v / 2, node.position[2]], - // Keep the handle at the same relative height as the door resizes, - // matching the height-resize arrow. - handleHeight: scaleHandleHeight(node.handleHeight, node.height, v), - }) - } + max={limits?.height ?? 1000} + min={Math.min(1, limits?.height ?? 1)} + onChange={(v) => preview?.preview(heightUpdates(v))} + onCommit={(v) => preview?.commit(heightUpdates(v))} + onCancel={() => preview?.cancel()} + previewWhileTyping precision={2} restoreOnCommit={false} - step={0.05} + step={0.01} unit="m" value={node.height} /> @@ -780,6 +748,9 @@ export default function DoorPanel() { min={0} onChange={(v) => previewDoorUpdate('cornerRadius', v)} onCommit={(v) => commitDoorPreview('cornerRadius', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={2} step={0.05} unit="m" @@ -798,6 +769,9 @@ export default function DoorPanel() { min={0} onChange={(v) => setOpeningTopRadius(index as number, v)} onCommit={(v) => setOpeningTopRadius(index as number, v, true)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={2} step={0.05} unit="m" @@ -812,6 +786,9 @@ export default function DoorPanel() { min={0} onChange={(v) => previewDoorUpdate('openingRevealRadius', v)} onCommit={(v) => commitDoorPreview('openingRevealRadius', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={3} step={0.005} unit="m" @@ -824,7 +801,10 @@ export default function DoorPanel() { label="Arch Height" max={node.height} min={0.05} - onChange={(v) => handleUpdate({ archHeight: v })} + onChange={(v) => previewDoorUpdate('archHeight', v)} + onCommit={(v) => commitDoorPreview('archHeight', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping precision={2} restoreOnCommit={false} step={0.05} @@ -877,6 +857,9 @@ export default function DoorPanel() { min={0} onChange={(v) => previewDoorUpdate('cornerRadius', v)} onCommit={(v) => commitDoorPreview('cornerRadius', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={2} step={0.05} unit="m" @@ -895,6 +878,9 @@ export default function DoorPanel() { min={0} onChange={(v) => setOpeningTopRadius(index as number, v)} onCommit={(v) => setOpeningTopRadius(index as number, v, true)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={2} step={0.05} unit="m" @@ -909,6 +895,9 @@ export default function DoorPanel() { min={0} onChange={(v) => previewDoorUpdate('openingRevealRadius', v)} onCommit={(v) => commitDoorPreview('openingRevealRadius', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping + restoreOnCommit={false} precision={3} step={0.005} unit="m" @@ -921,7 +910,10 @@ export default function DoorPanel() { label="Arch Height" max={node.height} min={0.05} - onChange={(v) => handleUpdate({ archHeight: v })} + onChange={(v) => previewDoorUpdate('archHeight', v)} + onCommit={(v) => commitDoorPreview('archHeight', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping precision={2} restoreOnCommit={false} step={0.05} diff --git a/packages/nodes/src/shared/curtain-opening-limits.test.ts b/packages/nodes/src/shared/curtain-opening-limits.test.ts new file mode 100644 index 0000000000..c401580dde --- /dev/null +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, test } from 'bun:test' +import { + DoorNode, + getEffectiveNode, + useLiveNodeOverrides, + useScene, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { constrainCurtainOpening, curtainOpeningLimits } from './curtain-opening-limits' +import { createOpeningPropertyPreview } from './opening-property-preview' + +globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} +const initial = useScene.getState() +afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + useScene.setState(initial) + useScene.temporal.getState().clear() +}) + +const wall = WallNode.parse({ start: [0, 0], end: [10, 0], height: 2.5, wallType: 'curtain' }) +const door = DoorNode.parse({ parentId: wall.id, position: [1.07, 1.05, 0] }) +const nodes = { [wall.id]: wall, [door.id]: door } + +test('door dimensions leave room for its surround and preserve its floor anchor', () => { + const result = constrainCurtainOpening(door, { width: 3, height: 3.5 }, nodes) + expect(result.width).toBeCloseTo(2.04) + expect(result.height).toBeCloseTo(2.45) + expect(result.position?.[1]).toBeCloseTo(1.225) +}) + +test('window grows about its center and position edits keep all four surrounds inside', () => { + const window = WindowNode.parse({ parentId: wall.id, position: [5, 1.5, 0], width: 1, height: 1 }) + expect(curtainOpeningLimits(window, nodes)?.height).toBeCloseTo(1.9) + const result = constrainCurtainOpening(window, { position: [-5, 4, 0] }, nodes) + expect(result.position).toEqual([0.55, 1.9500000000000002, 0]) + expect(result.width).toBe(1) + expect(result.height).toBe(1) +}) + +test('non-curtain hosts retain their existing sizing policy', () => { + const patch = { width: 20 } + expect( + constrainCurtainOpening(door, patch, { + ...nodes, + [wall.id]: { ...wall, wallType: 'standard' }, + }), + ).toBe(patch) +}) + +test('door drag writes no scene history until commit and undoes in one step', () => { + useScene.setState({ nodes, dirtyNodes: new Set() }) + useScene.temporal.getState().resume() + useScene.temporal.getState().clear() + const preview = createOpeningPropertyPreview(door.id) + for (const width of [1.2, 1.5, 3]) preview.preview({ width }) + expect(useScene.getState().nodes[door.id]).toBe(door) + expect(getEffectiveNode(door).width).toBeCloseTo(2.04) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + preview.commit({ width: 3 }) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + expect((useScene.getState().nodes[door.id] as DoorNode).width).toBeCloseTo(2.04) + useScene.temporal.getState().undo() + expect((useScene.getState().nodes[door.id] as DoorNode).width).toBe(door.width) + useScene.temporal.getState().redo() + expect((useScene.getState().nodes[door.id] as DoorNode).width).toBeCloseTo(2.04) +}) + +test('cancel and a history-cleared preview cannot be committed by a stale release', () => { + useScene.setState({ nodes, dirtyNodes: new Set() }) + const preview = createOpeningPropertyPreview(door.id) + preview.preview({ height: 2.4 }) + preview.cancel() + expect(getEffectiveNode(door).height).toBe(door.height) + preview.preview({ height: 2.4 }) + useLiveNodeOverrides.getState().clearAll() + preview.commit({ height: 2.4 }) + expect(useScene.getState().nodes[door.id]).toBe(door) +}) diff --git a/packages/nodes/src/shared/curtain-opening-limits.ts b/packages/nodes/src/shared/curtain-opening-limits.ts new file mode 100644 index 0000000000..3402e26771 --- /dev/null +++ b/packages/nodes/src/shared/curtain-opening-limits.ts @@ -0,0 +1,89 @@ +import { + type AnyNode, + type AnyNodeId, + type DoorNode, + getCurtainWallConfig, + getWallCurveLength, + type WindowNode, +} from '@pascal-app/core' +import { resolveWallOpeningCeiling } from './wall-opening-ceiling' + +type Opening = DoorNode | WindowNode + +export function curtainOpeningResizeMax( + opening: Opening, + nodes: Readonly>, + axis: 'x' | 'y', + sign: number, +) { + const limits = curtainOpeningLimits(opening, nodes) + if (!limits) return undefined + if (axis === 'y') { + const anchor = opening.position[1] - (sign * opening.height) / 2 + return Math.max(0, sign > 0 ? limits.top - anchor : anchor - limits.bottom) + } + const direction = Math.cos(opening.rotation[1]) >= 0 ? sign : -sign + const anchor = opening.position[0] - (direction * opening.width) / 2 + return Math.max( + 0, + direction > 0 ? limits.length - limits.margin - anchor : anchor - limits.margin, + ) +} + +export function curtainOpeningLimits( + opening: Opening, + nodes: Readonly>, +) { + const wall = nodes[(opening.parentId ?? opening.wallId) as AnyNodeId] + if (wall?.type !== 'wall' || wall.wallType !== 'curtain') return null + const margin = getCurtainWallConfig(wall).perimeterWidth + const length = getWallCurveLength(wall) + const top = resolveWallOpeningCeiling(wall, nodes) - margin + const bottom = opening.type === 'door' ? 0 : margin + return { + margin, + length, + top, + bottom, + width: Math.max( + 0, + 2 * Math.min(opening.position[0] - margin, length - margin - opening.position[0]), + ), + height: Math.max( + 0, + opening.type === 'door' + ? top + : 2 * Math.min(opening.position[1] - bottom, top - opening.position[1]), + ), + } +} + +export function constrainCurtainOpening( + opening: T, + patch: Partial, + nodes: Readonly>, +): Partial { + const limits = curtainOpeningLimits(opening, nodes) + if (!limits || !('width' in patch || 'height' in patch || 'position' in patch)) return patch + const next = { ...opening, ...patch } + const moving = + patch.position !== undefined && patch.width === undefined && patch.height === undefined + const width = Math.min( + Math.max(0.01, next.width), + moving ? limits.length - 2 * limits.margin : limits.width, + ) + const height = Math.min( + Math.max(0.01, next.height), + moving ? limits.top - limits.bottom : limits.height, + ) + if (width < 0.01 || height < 0.01) return {} + const x = Math.max( + limits.margin + width / 2, + Math.min(limits.length - limits.margin - width / 2, next.position[0]), + ) + const y = + opening.type === 'door' + ? height / 2 + : Math.max(limits.bottom + height / 2, Math.min(limits.top - height / 2, next.position[1])) + return { ...patch, width, height, position: [x, y, next.position[2]] } +} diff --git a/packages/nodes/src/shared/opening-property-preview.ts b/packages/nodes/src/shared/opening-property-preview.ts new file mode 100644 index 0000000000..a73f5a5245 --- /dev/null +++ b/packages/nodes/src/shared/opening-property-preview.ts @@ -0,0 +1,62 @@ +import { + type AnyNodeId, + type DoorNode, + useLiveNodeOverrides, + useScene, + type WindowNode, +} from '@pascal-app/core' + +import { constrainCurtainOpening } from './curtain-opening-limits' + +export function createOpeningPropertyPreview(id: AnyNodeId) { + let pending: Partial | undefined + const dirty = () => { + const scene = useScene.getState() + scene.markDirty(id) + const parent = scene.nodes[id]?.parentId + if (parent) scene.markDirty(parent as AnyNodeId) + } + const clear = () => { + if (!pending) return + useLiveNodeOverrides.getState().clearFields(id, Object.keys(pending)) + pending = undefined + dirty() + } + return { + preview(patch: Partial) { + const node = useScene.getState().nodes[id] + if (node?.type !== 'door' && node?.type !== 'window') return + patch = constrainCurtainOpening(node as T, patch, useScene.getState().nodes) + pending = { ...pending, ...patch } + useLiveNodeOverrides.getState().set(id, patch) + dirty() + }, + commit(patch?: Partial) { + const live = useLiveNodeOverrides.getState().get(id) + if (pending && !Object.keys(pending).some((key) => live && key in live)) { + clear() + return + } + const current = + pending && live + ? Object.fromEntries( + Object.keys(pending) + .filter((key) => key in live) + .map((key) => [key, live[key]]), + ) + : undefined + const node = useScene.getState().nodes[id] + const updates = + node?.type === 'door' || node?.type === 'window' + ? constrainCurtainOpening( + node as T, + { ...current, ...patch } as Partial, + useScene.getState().nodes, + ) + : {} + if (Object.keys(updates).length) useScene.getState().updateNode(id, updates) + clear() + }, + cancel: clear, + } +} diff --git a/packages/nodes/src/wall/curtain-panel-highlight.ts b/packages/nodes/src/wall/curtain-panel-highlight.ts new file mode 100644 index 0000000000..48c4d29921 --- /dev/null +++ b/packages/nodes/src/wall/curtain-panel-highlight.ts @@ -0,0 +1,107 @@ +import { + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + sceneRegistry, + type WallNode, +} from '@pascal-app/core' +import { EDITOR_LAYER } from '@pascal-app/editor' +import { useEffect } from 'react' +import { + BufferGeometry, + DoubleSide, + Float32BufferAttribute, + Line, + LineBasicMaterial, + Mesh, + MeshBasicMaterial, + type Object3D, + Vector3, +} from 'three' + +export function useCurtainPanelHighlight( + wall: WallNode, + left: number, + right: number, + bottom: number, + top: number, +) { + useEffect(() => { + const host = sceneRegistry.nodes.get(wall.id) as Object3D | undefined + if (!host) return + const length = getWallCurveLength(wall) + if (length <= 0) return + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cos = Math.cos(angle), + sin = Math.sin(angle) + const points: Vector3[] = [] + for (const y of [bottom, top]) { + const edge: Vector3[] = [] + for (let i = 0; i <= 24; i++) { + const frame = getWallCurveFrameAt(wall, (left + ((right - left) * i) / 24) / length) + const offset = getWallThickness(wall) / 2 + 0.005 + const x = frame.point.x + frame.normal.x * offset - wall.start[0] + const z = frame.point.y + frame.normal.y * offset - wall.start[1] + edge.push(new Vector3(x * cos + z * sin, y, -x * sin + z * cos)) + } + points.push(...(y === bottom ? edge : edge.reverse())) + } + points.push(points[0]!.clone()) + const geometry = new BufferGeometry().setFromPoints(points) + const material = new LineBasicMaterial({ + color: '#fb923c', + depthTest: false, + depthWrite: false, + }) + const outline = new Line(geometry, material) + outline.layers.set(EDITOR_LAYER) + outline.raycast = () => {} + const vertices: number[] = [] + for (let i = 0; i < 24; i++) { + for (const index of [i, i + 1, 49 - i, i + 1, 48 - i, 49 - i]) { + vertices.push(...points[index]!.toArray()) + } + } + const fillGeometry = new BufferGeometry() + fillGeometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)) + const fillMaterial = new MeshBasicMaterial({ + color: '#fb923c', + transparent: true, + opacity: 0.6, + side: DoubleSide, + depthTest: false, + depthWrite: false, + }) + const fill = new Mesh(fillGeometry, fillMaterial) + fill.layers.set(EDITOR_LAYER) + fill.raycast = () => {} + let scene = host + while (scene.parent) scene = scene.parent + outline.matrixAutoUpdate = false + fill.matrixAutoUpdate = false + scene.add(outline, fill) + let frameId = 0 + const sync = () => { + const current = sceneRegistry.nodes.get(wall.id) as Object3D | undefined + outline.visible = fill.visible = Boolean(current?.visible) + if (current) { + current.updateWorldMatrix(true, false) + outline.matrix.copy(current.matrixWorld) + fill.matrix.copy(current.matrixWorld) + outline.matrixWorld.copy(current.matrixWorld) + fill.matrixWorld.copy(current.matrixWorld) + } + frameId = requestAnimationFrame(sync) + } + sync() + return () => { + cancelAnimationFrame(frameId) + outline.removeFromParent() + fill.removeFromParent() + fillGeometry.dispose() + fillMaterial.dispose() + geometry.dispose() + material.dispose() + } + }, [wall, left, right, bottom, top]) +} diff --git a/packages/nodes/src/wall/curtain-wall-panel.tsx b/packages/nodes/src/wall/curtain-wall-panel.tsx index 9cb4a4c946..c72dd59a8c 100644 --- a/packages/nodes/src/wall/curtain-wall-panel.tsx +++ b/packages/nodes/src/wall/curtain-wall-panel.tsx @@ -20,6 +20,7 @@ import { SliderControl, } from '@pascal-app/editor' import { useState } from 'react' +import { useCurtainPanelHighlight } from './curtain-panel-highlight' type Props = { node: WallNode @@ -216,6 +217,7 @@ export function CurtainWallPanel({ ;(preview ? onPreview : onUpdate)({ curtainWall: { ...config, ...patch } }) } + const [tintEditing, setTintEditing] = useState(false) const [selectedColumn, setColumn] = useState(0) const [selectedRow, setRow] = useState(0) const columnCount = curtainGridPositions(getWallCurveLength(node), config.columns).length - 1 @@ -231,9 +233,21 @@ export function CurtainWallPanel({ ], }) + const hiddenOverrides = config.panels.filter( + (panel) => panel.column >= columnCount || panel.row >= rowCount, + ) + const diagramWidth = Math.max(240, columnCount * 32) + const diagramHeight = Math.max(150, rowCount * 32) const overridden = config.panels.some((panel) => panel.column === column && panel.row === row) const xs = curtainGridPositions(getWallCurveLength(node), config.columns) const ys = curtainGridPositions(height, config.rows) + useCurtainPanelHighlight( + node, + xs[column] ?? 0, + xs[column + 1] ?? 0, + ys[row] ?? 0, + ys[row + 1] ?? 0, + ) const hasGlass = xs .slice(1) .some((_, c) => @@ -395,49 +409,97 @@ export function CurtainWallPanel({

- Choose a cell below. Columns run from the wall start; rows count upward. + Choose a cell or enter its column and row. Use arrow keys to move between cells. Columns + run from the wall start; rows count upward.

- - {xs.slice(1).flatMap((right, c) => - ys.slice(1).map((top, r) => { - const type = curtainPanelType(config, c, r, rowCount) - const selected = c === column && r === row - return ( - { - setColumn(c) - setRow(r) - }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() +
+ + {xs.slice(1).flatMap((_, c) => + ys.slice(1).map((_, r) => { + const type = curtainPanelType(config, c, r, rowCount) + const selected = c === column && r === row + return ( + { setColumn(c) setRow(r) - } - }} - /> - ) - }), - )} - + }} + onKeyDown={(event) => { + const arrows: Record = { + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, 1], + ArrowDown: [0, -1], + } + const delta = arrows[event.key] + if (delta) { + event.preventDefault() + const nextColumn = Math.max(0, Math.min(columnCount - 1, c + delta[0])) + const nextRow = Math.max(0, Math.min(rowCount - 1, r + delta[1])) + setColumn(nextColumn) + setRow(nextRow) + const target = + event.currentTarget.parentElement?.querySelector( + `[data-cell="${nextColumn}-${nextRow}"]`, + ) + target?.focus() + target?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setColumn(c) + setRow(r) + } + }} + /> + ) + }), + )} + +
+ {hiddenOverrides.length > 0 && ( +
+

+ {hiddenOverrides.length} custom panels are outside this grid. They return if the grid + expands. +

+ +
+ )}

Column {column + 1}, row {row + 1} ·{' '} {overridden ? 'Custom infill' : 'Following wall defaults'} @@ -531,20 +593,49 @@ export function CurtainWallPanel({ { if (event.key === 'Escape') { event.preventDefault() onCancel() + setTintEditing(false) } }} onChange={(event) => { + setTintEditing(true) update({ glassColor: event.target.value }, true) }} + onInput={(event) => { + setTintEditing(true) + update({ glassColor: event.currentTarget.value }, true) + }} type="color" value={config.glassColor} /> + {tintEditing && ( +

+ + +
+ )}

Low roughness is smooth; high roughness gives a matte surface in rendered shading.

diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index f590fb354e..9ab273f087 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -13,6 +13,7 @@ import { getDormerWallOpeningVerticalBounds, } from '@pascal-app/core' import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { curtainOpeningResizeMax } from '../shared/curtain-opening-limits' import { buildWindowFloorplanSchedule, computeWindowFloorplanLevelData, @@ -121,7 +122,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor n.width, onDrag: (node) => publishOpeningResizeGuides(node, true), @@ -169,6 +170,8 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor = { const dirX = dx / wallLength const dirZ = dz / wallLength - const maxWidth = growDir > 0 ? wallLength - anchorX : anchorX + const limits = curtainOpeningLimits(node, nodes) + const margin = limits?.margin ?? 0 + const maxWidth = + growDir > 0 ? (limits?.length ?? wallLength) - margin - anchorX : anchorX - margin const projectToWallLocalX = (planPoint: readonly [number, number]) => { return (planPoint[0] - wallStart[0]) * dirX + (planPoint[1] - wallStart[1]) * dirZ diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index 930fc916dc..17f67cc96e 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -22,7 +22,8 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' -import { createWindowPropertyPreview } from './property-preview' +import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' +import { createOpeningPropertyPreview } from '../shared/opening-property-preview' function isSameWindowValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { @@ -90,7 +91,7 @@ export default function WindowPanel() { const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const preview = useMemo( - () => (selectedId ? createWindowPropertyPreview(selectedId as AnyNodeId) : null), + () => (selectedId ? createOpeningPropertyPreview(selectedId as AnyNodeId) : null), [selectedId], ) useEffect(() => () => preview?.cancel(), [preview]) @@ -107,6 +108,7 @@ export default function WindowPanel() { const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] if (liveNode?.type !== 'window') return + updates = constrainCurtainOpening(liveNode, updates, useScene.getState().nodes) const hasChange = Object.entries(updates).some(([key, value]) => { const currentValue = liveNode[key as keyof WindowNode] return !isSameWindowValue(currentValue, value) @@ -201,6 +203,7 @@ export default function WindowPanel() { if (!(node && node.type === 'window' && selectedId)) return null + const limits = curtainOpeningLimits(node, useScene.getState().nodes) const numCols = node.columnRatios.length const numRows = node.rowRatios.length @@ -486,29 +489,36 @@ export default function WindowPanel() {
+ {limits && ( +

+ Size is limited to the wall, including clearance for the opening frame. +

+ )} preview?.preview(getDimensionUpdates({ width: v }))} onCommit={(v) => preview?.commit(getDimensionUpdates({ width: v }))} onCancel={() => preview?.cancel()} previewWhileTyping precision={2} restoreOnCommit={false} - step={0.1} + step={0.01} unit="m" value={node.width} /> preview?.preview(getDimensionUpdates({ height: v }))} onCommit={(v) => preview?.commit(getDimensionUpdates({ height: v }))} onCancel={() => preview?.cancel()} previewWhileTyping precision={2} restoreOnCommit={false} - step={0.1} + step={0.01} unit="m" value={node.height} /> diff --git a/packages/nodes/src/window/property-preview.test.ts b/packages/nodes/src/window/property-preview.test.ts index f9b5e9e926..25cce1d93b 100644 --- a/packages/nodes/src/window/property-preview.test.ts +++ b/packages/nodes/src/window/property-preview.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from 'bun:test' import { useLiveNodeOverrides, useScene, WallNode, WindowNode } from '@pascal-app/core' -import { createWindowPropertyPreview } from './property-preview' +import { createOpeningPropertyPreview } from '../shared/opening-property-preview' const initial = useScene.getState() afterEach(() => { @@ -10,9 +10,14 @@ afterEach(() => { test('window dimensions and curved-frame parameters preview without document mutation, then commit together', () => { const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain' }) - const window = WindowNode.parse({ parentId: wall.id, openingShape: 'rounded', cornerRadius: 0.3 }) + const window = WindowNode.parse({ + parentId: wall.id, + position: [3, 1.25, 0], + openingShape: 'rounded', + cornerRadius: 0.3, + }) useScene.setState({ nodes: { [wall.id]: wall, [window.id]: window }, dirtyNodes: new Set() }) - const preview = createWindowPropertyPreview(window.id) + const preview = createOpeningPropertyPreview(window.id) preview.preview({ width: 0.4, cornerRadius: 0.2 }) expect(useScene.getState().nodes[window.id]).toBe(window) expect(window.cornerRadius).toBe(0.3) diff --git a/packages/nodes/src/window/property-preview.ts b/packages/nodes/src/window/property-preview.ts deleted file mode 100644 index 28786717d9..0000000000 --- a/packages/nodes/src/window/property-preview.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type AnyNodeId, useLiveNodeOverrides, useScene, type WindowNode } from '@pascal-app/core' - -export function createWindowPropertyPreview(id: AnyNodeId) { - let pending: Partial | undefined - const dirty = () => { - const scene = useScene.getState() - scene.markDirty(id) - const parent = scene.nodes[id]?.parentId - if (parent) scene.markDirty(parent as AnyNodeId) - } - const clear = () => { - if (!pending) return - useLiveNodeOverrides.getState().clearFields(id, Object.keys(pending)) - pending = undefined - dirty() - } - return { - preview(patch: Partial) { - pending = { ...pending, ...patch } - useLiveNodeOverrides.getState().set(id, patch) - dirty() - }, - commit(patch?: Partial) { - const live = useLiveNodeOverrides.getState().get(id) - const current = - pending && live - ? Object.fromEntries( - Object.keys(pending) - .filter((key) => key in live) - .map((key) => [key, live[key]]), - ) - : undefined - const updates = { ...current, ...patch } - if (Object.keys(updates).length && useScene.getState().nodes[id]?.type === 'window') - useScene.getState().updateNode(id, updates) - clear() - }, - cancel: clear, - } -} diff --git a/packages/viewer/src/systems/wall/curtain-wall-dense-opening.test.ts b/packages/viewer/src/systems/wall/curtain-wall-dense-opening.test.ts new file mode 100644 index 0000000000..1e0bc2a2a0 --- /dev/null +++ b/packages/viewer/src/systems/wall/curtain-wall-dense-opening.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'bun:test' +import { calculateLevelMiters, DoorNode, WallNode } from '@pascal-app/core' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' +import { generateExtrudedWall } from './wall-system' + +test('dense-grid curved cuts preserve distant glass and leave the door clear', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [10, 0], + height: 2.5, + wallType: 'curtain', + curtainWall: { columns: { layout: 'count', count: 32 }, rows: { layout: 'count', count: 32 } }, + }) + const door = DoorNode.parse({ position: [2, 1.05, 0], width: 1.5, openingShape: 'arch' }) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const geometry = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + [door], + ) + try { + const mesh = new Mesh(geometry, [material, material, material]) + const hit = (x: number, y: number) => + new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) + expect(hit(2, 1)).toHaveLength(0) + expect(hit(8.28, 1.21)[0]?.face?.materialIndex).toBe(1) + expect(hit(0.02, 1)[0]?.face?.materialIndex).toBe(0) + expect(Array.from(geometry.getAttribute('position').array).every(Number.isFinite)).toBe(true) + } finally { + geometry.dispose() + material.dispose() + } +}) diff --git a/packages/viewer/src/systems/wall/curtain-wall-geometry.ts b/packages/viewer/src/systems/wall/curtain-wall-geometry.ts index 91ffe6faee..b7b7ad97ce 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-geometry.ts +++ b/packages/viewer/src/systems/wall/curtain-wall-geometry.ts @@ -2,6 +2,7 @@ import { type AnyNode, buildCurtainWallLayout, type CurtainWallPiece, + curtainGridPositions, getCurtainWallConfig, getWallCurveFrameAt, getWallCurveLength, @@ -98,12 +99,41 @@ export function buildCurtainWallGeometry( (near(position.getZ(i), -halfDepth) || near(position.getZ(i), halfDepth)), ) if (!rectangular) prepareBrushForCSG(shell) + const xs = curtainGridPositions(length, getCurtainWallConfig(wall).columns) + const ys = curtainGridPositions(bounds.max.y - bounds.min.y, getCurtainWallConfig(wall).rows) + const cutRegions = shapedFrames.map(({ cutter }) => { + cutter.computeBoundingBox() + const box = cutter.boundingBox! + // Keep every fragment of a pane in the same partition to avoid new glass seams. + const lower = (positions: number[], value: number) => + [...positions].reverse().find((p) => p <= value) ?? positions[0]! + const upper = (positions: number[], value: number) => + positions.find((p) => p >= value) ?? positions.at(-1)! + return { + left: lower(xs, box.min.x), + right: upper(xs, box.max.x), + bottom: lower(ys, box.min.y - bounds.min.y), + top: upper(ys, box.max.y - bounds.min.y), + } + }) const results: BufferGeometry[] = [] const roles = ['frame', 'glass', 'solid'] as const try { for (const [materialIndex, role] of roles.entries()) { - const rolePieces = pieces.filter((piece) => piece.role === role) - if (!rolePieces.length && !(role === 'frame' && shapedFrames.length)) continue + const allRolePieces = pieces.filter((piece) => piece.role === role) + const nearCut = (piece: CurtainWallPiece) => + !rectangular || + !cutRegions.length || + cutRegions.some( + (region) => + piece.left < region.right && + piece.right > region.left && + piece.bottom < region.top && + piece.top > region.bottom, + ) + const rolePieces = allRolePieces.filter(nearCut) + const untouchedPieces = allRolePieces.filter((piece) => !nearCut(piece)) + if (!allRolePieces.length && !(role === 'frame' && shapedFrames.length)) continue let merged: BufferGeometry | null if (!isCurvedWall(wall)) { merged = buildStraightCurtainPieces( @@ -121,6 +151,7 @@ export function buildCurtainWallGeometry( } if (!merged) continue for (const opening of shapedFrames) { + if (!merged.getAttribute('position').count) break const brush: Brush = new Brush(merged) const cutter = new Brush(opening.cutter) prepareBrushForCSG(brush) @@ -130,6 +161,14 @@ export function buildCurtainWallGeometry( merged = cut.index ? cut.toNonIndexed() : cut if (merged !== cut) cut.dispose() } + if (untouchedPieces.length) { + const untouched = buildStraightCurtainPieces(untouchedPieces, bounds.min.y) + const combined = mergeGeometries([merged, untouched], false) + untouched.dispose() + merged.dispose() + if (!combined) continue + merged = combined + } if (role === 'frame' && shapedFrames.length) { const frames = shapedFrames.map(({ frame }) => frame) const combined = mergeGeometries([merged, ...frames], false) From 874ed29465d36897c5d86bfb4b9bc2bcac84f1cd Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 13:30:06 +0530 Subject: [PATCH 04/21] optimize curtain wall geometry and stick framing - Merge continuous stick members and cull shared faces - Preserve hosted opening cuts and live shaped-opening performance --- .../src/schema/nodes/curtain-wall.test.ts | 18 ++ .../core/src/schema/nodes/curtain-wall.ts | 158 ++++++++++++------ .../src/systems/wall/curtain-wall-geometry.ts | 120 +++++++------ .../wall/curtain-wall-piece-geometry.ts | 65 ++++--- .../viewer/src/systems/wall/wall-system.tsx | 21 ++- 5 files changed, 249 insertions(+), 133 deletions(-) diff --git a/packages/core/src/schema/nodes/curtain-wall.test.ts b/packages/core/src/schema/nodes/curtain-wall.test.ts index 44b2ad3a6f..a336ffbf56 100644 --- a/packages/core/src/schema/nodes/curtain-wall.test.ts +++ b/packages/core/src/schema/nodes/curtain-wall.test.ts @@ -98,6 +98,24 @@ describe('curtain walls', () => { ]) expect(CurtainWallConfig.safeParse(input).success).toBe(false) }) + + test('dense stick grids use continuous members instead of one frame per panel edge', () => { + const columns = 32 + const rows = 32 + const pieces = buildCurtainWallLayout( + 10, + 3, + 0.15, + CurtainWallConfig.parse({ + construction: 'stick', + columns: { layout: 'count', count: columns }, + rows: { layout: 'count', count: rows }, + }), + ) + const frames = pieces.filter((piece) => piece.role === 'frame') + expect(frames).toHaveLength(columns + 1 + columns * (rows + 1)) + expect(frames.length).toBeLessThan(columns * rows * 2) + }) }) test('entrance frames adapt to door size, position, support elevation, and removal', () => { diff --git a/packages/core/src/schema/nodes/curtain-wall.ts b/packages/core/src/schema/nodes/curtain-wall.ts index bb6fa20234..6822ea651b 100644 --- a/packages/core/src/schema/nodes/curtain-wall.ts +++ b/packages/core/src/schema/nodes/curtain-wall.ts @@ -139,59 +139,111 @@ export function buildCurtainWallLayout( } const verticalCap = config.framing === 'capped' || config.framing === 'vertical-caps' const horizontalCap = config.framing === 'capped' || config.framing === 'horizontal-caps' - for (let column = 0; column < xs.length - 1; column++) { - for (let row = 0; row < ys.length - 1; row++) { - const x0 = xs[column]!, - x1 = xs[column + 1]!, - y0 = ys[row]!, - y1 = ys[row + 1]! - const gap = - config.construction === 'unitized' - ? Math.min(config.jointWidth / 2, (x1 - x0) / 10, (y1 - y0) / 10) - : 0 - const left = x0 + (column === 0 ? 0 : gap), - right = x1 - (column === xs.length - 2 ? 0 : gap) - const bottom = y0 + (row === 0 ? 0 : gap), - top = y1 - (row === ys.length - 2 ? 0 : gap) - const lw = Math.min( - column === 0 ? config.perimeterWidth : config.mullionWidth / 2, - (right - left) * 0.4, - ) - const rw = Math.min( - column === xs.length - 2 ? config.perimeterWidth : config.mullionWidth / 2, - (right - left) * 0.4, - ) - const bw = Math.min( - row === 0 ? config.perimeterWidth : config.transomWidth / 2, - (top - bottom) * 0.4, - ) - const tw = Math.min( - row === ys.length - 2 ? config.perimeterWidth : config.transomWidth / 2, - (top - bottom) * 0.4, - ) - add(left, left + lw, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') - add(right - rw, right, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') - add( - left + lw, - right - rw, - bottom, - bottom + bw, - horizontalCap ? depth / 2 : panelBack, - 'frame', - ) - add(left + lw, right - rw, top - tw, top, horizontalCap ? depth / 2 : panelBack, 'frame') - const type = curtainPanelType(config, column, row, ys.length - 1) - if (type === 'empty') continue - const joint = Math.min(config.jointWidth / 2, (right - left) * 0.1, (top - bottom) * 0.1) - add( - left + (verticalCap ? lw : joint), - right - (verticalCap ? rw : joint), - bottom + (horizontalCap ? bw : joint), - top - (horizontalCap ? tw : joint), - panelFront, - type, - panelBack, - ) + if (config.construction === 'stick') { + const verticalBounds = xs.map((x, index): [number, number] => { + if (index === 0) return [0, Math.min(config.perimeterWidth, (xs[1]! - x) * 0.4)] + if (index === xs.length - 1) + return [length - Math.min(config.perimeterWidth, (x - xs[index - 1]!) * 0.4), length] + return [ + x - Math.min(config.mullionWidth / 2, (x - xs[index - 1]!) * 0.4), + x + Math.min(config.mullionWidth / 2, (xs[index + 1]! - x) * 0.4), + ] + }) + const horizontalBounds = ys.map((y, index): [number, number] => { + if (index === 0) return [0, Math.min(config.perimeterWidth, (ys[1]! - y) * 0.4)] + if (index === ys.length - 1) + return [height - Math.min(config.perimeterWidth, (y - ys[index - 1]!) * 0.4), height] + return [ + y - Math.min(config.transomWidth / 2, (y - ys[index - 1]!) * 0.4), + y + Math.min(config.transomWidth / 2, (ys[index + 1]! - y) * 0.4), + ] + }) + + for (const [left, right] of verticalBounds) + add(left, right, 0, height, verticalCap ? depth / 2 : panelBack, 'frame') + for (const [bottom, top] of horizontalBounds) { + for (let column = 0; column < xs.length - 1; column++) { + add( + verticalBounds[column]![1], + verticalBounds[column + 1]![0], + bottom, + top, + horizontalCap ? depth / 2 : panelBack, + 'frame', + ) + } + } + + for (let column = 0; column < xs.length - 1; column++) { + for (let row = 0; row < ys.length - 1; row++) { + const type = curtainPanelType(config, column, row, ys.length - 1) + if (type === 'empty') continue + add( + verticalBounds[column]![1], + verticalBounds[column + 1]![0], + horizontalBounds[row]![1], + horizontalBounds[row + 1]![0], + panelFront, + type, + panelBack, + ) + } + } + } else { + for (let column = 0; column < xs.length - 1; column++) { + for (let row = 0; row < ys.length - 1; row++) { + const x0 = xs[column]!, + x1 = xs[column + 1]!, + y0 = ys[row]!, + y1 = ys[row + 1]! + const gap = + config.construction === 'unitized' + ? Math.min(config.jointWidth / 2, (x1 - x0) / 10, (y1 - y0) / 10) + : 0 + const left = x0 + (column === 0 ? 0 : gap), + right = x1 - (column === xs.length - 2 ? 0 : gap) + const bottom = y0 + (row === 0 ? 0 : gap), + top = y1 - (row === ys.length - 2 ? 0 : gap) + const lw = Math.min( + column === 0 ? config.perimeterWidth : config.mullionWidth / 2, + (right - left) * 0.4, + ) + const rw = Math.min( + column === xs.length - 2 ? config.perimeterWidth : config.mullionWidth / 2, + (right - left) * 0.4, + ) + const bw = Math.min( + row === 0 ? config.perimeterWidth : config.transomWidth / 2, + (top - bottom) * 0.4, + ) + const tw = Math.min( + row === ys.length - 2 ? config.perimeterWidth : config.transomWidth / 2, + (top - bottom) * 0.4, + ) + add(left, left + lw, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') + add(right - rw, right, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') + add( + left + lw, + right - rw, + bottom, + bottom + bw, + horizontalCap ? depth / 2 : panelBack, + 'frame', + ) + add(left + lw, right - rw, top - tw, top, horizontalCap ? depth / 2 : panelBack, 'frame') + const type = curtainPanelType(config, column, row, ys.length - 1) + if (type === 'empty') continue + const joint = Math.min(config.jointWidth / 2, (right - left) * 0.1, (top - bottom) * 0.1) + add( + left + (verticalCap ? lw : joint), + right - (verticalCap ? rw : joint), + bottom + (horizontalCap ? bw : joint), + top - (horizontalCap ? tw : joint), + panelFront, + type, + panelBack, + ) + } } } let fitted = pieces diff --git a/packages/viewer/src/systems/wall/curtain-wall-geometry.ts b/packages/viewer/src/systems/wall/curtain-wall-geometry.ts index b7b7ad97ce..333de9ab33 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-geometry.ts +++ b/packages/viewer/src/systems/wall/curtain-wall-geometry.ts @@ -83,6 +83,10 @@ export function buildCurtainWallGeometry( getWallThickness(wall), ), ) + const hasRectangularOpenings = children.some( + (child) => + (child.type === 'window' || child.type === 'door') && child.openingShape === 'rectangle', + ) const evaluator = new Evaluator() evaluator.attributes = ['position', 'normal', 'uv', 'uv2'] evaluator.useGroups = false @@ -99,6 +103,16 @@ export function buildCurtainWallGeometry( (near(position.getZ(i), -halfDepth) || near(position.getZ(i), halfDepth)), ) if (!rectangular) prepareBrushForCSG(shell) + const leftEnvelopeXs: number[] = [] + const rightEnvelopeXs: number[] = [] + if (!rectangular && !isCurvedWall(wall)) { + for (let index = 0; index < position.count; index++) { + const x = position.getX(index) + ;(x < length / 2 ? leftEnvelopeXs : rightEnvelopeXs).push(x) + } + } + const safeLeft = leftEnvelopeXs.length ? Math.max(...leftEnvelopeXs) : 0 + const safeRight = rightEnvelopeXs.length ? Math.min(...rightEnvelopeXs) : length const xs = curtainGridPositions(length, getCurtainWallConfig(wall).columns) const ys = curtainGridPositions(bounds.max.y - bounds.min.y, getCurtainWallConfig(wall).rows) const cutRegions = shapedFrames.map(({ cutter }) => { @@ -122,8 +136,8 @@ export function buildCurtainWallGeometry( for (const [materialIndex, role] of roles.entries()) { const allRolePieces = pieces.filter((piece) => piece.role === role) const nearCut = (piece: CurtainWallPiece) => - !rectangular || - !cutRegions.length || + isCurvedWall(wall) || + (!rectangular && (piece.left < safeLeft - 1e-6 || piece.right > safeRight + 1e-6)) || cutRegions.some( (region) => piece.left < region.right && @@ -131,76 +145,80 @@ export function buildCurtainWallGeometry( piece.bottom < region.top && piece.top > region.bottom, ) + const partitionPieces = !rectangular || cutRegions.length > 0 const rolePieces = allRolePieces.filter(nearCut) - const untouchedPieces = allRolePieces.filter((piece) => !nearCut(piece)) + const untouchedPieces = partitionPieces + ? allRolePieces.filter((piece) => !nearCut(piece)) + : [] + const builtPieces = partitionPieces ? rolePieces : allRolePieces if (!allRolePieces.length && !(role === 'frame' && shapedFrames.length)) continue - let merged: BufferGeometry | null - if (!isCurvedWall(wall)) { + let merged: BufferGeometry | null = null + if (!isCurvedWall(wall) && builtPieces.length) { merged = buildStraightCurtainPieces( - rolePieces.map((piece) => ({ + builtPieces.map((piece) => ({ ...piece, left: piece.left === 0 ? Math.min(0, bounds.min.x) : piece.left, right: piece.right === length ? Math.max(length, bounds.max.x) : piece.right, })), bounds.min.y, + hasRectangularOpenings || shapedFrames.length > 0, ) - } else { + } else if (builtPieces.length) { const sources = rolePieces.map((piece) => pieceGeometry(wall, piece, length, bounds.min.y)) merged = mergeGeometries(sources, false) for (const source of sources) source.dispose() } - if (!merged) continue - for (const opening of shapedFrames) { - if (!merged.getAttribute('position').count) break - const brush: Brush = new Brush(merged) - const cutter = new Brush(opening.cutter) - prepareBrushForCSG(brush) - prepareBrushForCSG(cutter) - const cut: BufferGeometry = evaluator.evaluate(brush, cutter, SUBTRACTION).geometry - merged.dispose() - merged = cut.index ? cut.toNonIndexed() : cut - if (merged !== cut) cut.dispose() - } - if (untouchedPieces.length) { - const untouched = buildStraightCurtainPieces(untouchedPieces, bounds.min.y) - const combined = mergeGeometries([merged, untouched], false) - untouched.dispose() - merged.dispose() - if (!combined) continue - merged = combined + if (merged) { + for (const opening of shapedFrames) { + if (!merged.getAttribute('position').count) break + const brush: Brush = new Brush(merged) + const cutter = new Brush(opening.cutter) + prepareBrushForCSG(brush) + prepareBrushForCSG(cutter) + const cut: BufferGeometry = evaluator.evaluate(brush, cutter, SUBTRACTION).geometry + merged.dispose() + merged = cut.index ? cut.toNonIndexed() : cut + if (merged !== cut) cut.dispose() + } } if (role === 'frame' && shapedFrames.length) { const frames = shapedFrames.map(({ frame }) => frame) - const combined = mergeGeometries([merged, ...frames], false) - merged.dispose() + const combined = mergeGeometries(merged ? [merged, ...frames] : frames, false) + merged?.dispose() if (!combined) continue merged = combined } - merged.computeBoundingBox() - if ( - rectangular && - merged.boundingBox && - bounds.clone().expandByScalar(1e-6).containsBox(merged.boundingBox) - ) { - merged.clearGroups() - merged.addGroup(0, merged.getAttribute('position').count, materialIndex) - results.push(merged) - continue + if (merged && !rectangular) { + const brush = new Brush(merged) + prepareBrushForCSG(brush) + try { + // The existing wall envelope owns miter joins, support profiles, and all hosted cuts. + const clipped = evaluator.evaluate(brush, shell, INTERSECTION).geometry + const geometry = clipped.index ? clipped.toNonIndexed() : clipped + if (geometry !== clipped) clipped.dispose() + merged.dispose() + merged = geometry + } catch (error) { + merged.dispose() + throw error + } } - if (rectangular) prepareBrushForCSG(shell) - const brush = new Brush(merged) - prepareBrushForCSG(brush) - try { - // The existing wall envelope owns miter joins, support profiles, and all hosted cuts. - const clipped = evaluator.evaluate(brush, shell, INTERSECTION).geometry - const geometry = clipped.index ? clipped.toNonIndexed() : clipped - if (geometry !== clipped) clipped.dispose() - geometry.clearGroups() - geometry.addGroup(0, geometry.getAttribute('position').count, materialIndex) - results.push(geometry) - } finally { - merged.dispose() + if (untouchedPieces.length) { + const untouched = buildStraightCurtainPieces(untouchedPieces, bounds.min.y, false) + if (merged) { + const combined = mergeGeometries([merged, untouched], false) + untouched.dispose() + merged.dispose() + if (!combined) continue + merged = combined + } else { + merged = untouched + } } + if (!merged) continue + merged.clearGroups() + merged.addGroup(0, merged.getAttribute('position').count, materialIndex) + results.push(merged) } if (!results.length) return new BufferGeometry() const combined = mergeGeometries(results, false) ?? new BufferGeometry() diff --git a/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts b/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts index b8f0282bff..a2ace5505b 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts +++ b/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts @@ -3,61 +3,72 @@ import { BufferGeometry, Float32BufferAttribute } from 'three' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' // Adjacent fragments of one pane share a boundary, not a second glass surface. -export function buildStraightCurtainPieces(pieces: readonly CurtainWallPiece[], base: number) { +export function buildStraightCurtainPieces( + pieces: readonly CurtainWallPiece[], + base: number, + cullSharedFaces = true, +) { const positions: number[] = [] - const quad = (a: number[], b: number[], c: number[], d: number[]) => { + const normals: number[] = [] + const quad = (a: number[], b: number[], c: number[], d: number[], normal: number[]) => { positions.push(...a, ...b, ...c, ...a, ...c, ...d) + for (let index = 0; index < 6; index++) normals.push(...normal) } const sides = ['left', 'right', 'bottom', 'top'] as const const key = (piece: CurtainWallPiece, side: (typeof sides)[number], coordinate = piece[side]) => `${side}:${coordinate.toFixed(7)}:${piece.front.toFixed(7)}:${piece.back.toFixed(7)}` const edges = new Map() - for (const piece of pieces) { - for (const side of sides) { - const id = key(piece, side) - const neighbors = edges.get(id) ?? [] - neighbors.push(piece) - edges.set(id, neighbors) + if (cullSharedFaces) { + for (const piece of pieces) { + for (const side of sides) { + const id = key(piece, side) + const neighbors = edges.get(id) ?? [] + neighbors.push(piece) + edges.set(id, neighbors) + } } } for (const piece of pieces) { const { left: l, right: r, bottom, top, front: f, back: k } = piece const b = bottom + base const t = top + base - quad([l, b, f], [r, b, f], [r, t, f], [l, t, f]) - quad([r, b, k], [l, b, k], [l, t, k], [r, t, k]) + quad([l, b, f], [r, b, f], [r, t, f], [l, t, f], [0, 0, 1]) + quad([r, b, k], [l, b, k], [l, t, k], [r, t, k], [0, 0, -1]) for (const side of sides) { const vertical = side === 'left' || side === 'right' let runs: [number, number][] = vertical ? [[bottom, top]] : [[l, r]] const opposite = side === 'left' ? 'right' : side === 'right' ? 'left' : side === 'bottom' ? 'top' : 'bottom' - for (const other of edges.get(key(piece, opposite, piece[side])) ?? []) { - if (other === piece) continue - const low = vertical ? other.bottom : other.left - const high = vertical ? other.top : other.right - runs = runs.flatMap(([start, end]) => - high <= start || low >= end - ? [[start, end]] - : [ - ...(low > start ? [[start, low] as [number, number]] : []), - ...(high < end ? [[high, end] as [number, number]] : []), - ], - ) + if (cullSharedFaces) { + for (const other of edges.get(key(piece, opposite, piece[side])) ?? []) { + if (other === piece) continue + const low = vertical ? other.bottom : other.left + const high = vertical ? other.top : other.right + runs = runs.flatMap(([start, end]) => + high <= start || low >= end + ? [[start, end]] + : [ + ...(low > start ? [[start, low] as [number, number]] : []), + ...(high < end ? [[high, end] as [number, number]] : []), + ], + ) + } } for (const [start, end] of runs) { if (vertical) { const y0 = start + base, y1 = end + base - if (side === 'left') quad([l, y0, k], [l, y0, f], [l, y1, f], [l, y1, k]) - else quad([r, y0, f], [r, y0, k], [r, y1, k], [r, y1, f]) - } else if (side === 'bottom') quad([start, b, k], [end, b, k], [end, b, f], [start, b, f]) - else quad([start, t, f], [end, t, f], [end, t, k], [start, t, k]) + if (side === 'left') quad([l, y0, k], [l, y0, f], [l, y1, f], [l, y1, k], [-1, 0, 0]) + else quad([r, y0, f], [r, y0, k], [r, y1, k], [r, y1, f], [1, 0, 0]) + } else if (side === 'bottom') + quad([start, b, k], [end, b, k], [end, b, f], [start, b, f], [0, -1, 0]) + else quad([start, t, f], [end, t, f], [end, t, k], [start, t, k], [0, 1, 0]) } } } const geometry = new BufferGeometry() geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) - geometry.computeVertexNormals() + geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) ensureRenderableGeometryAttributes(geometry) return geometry } diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 7a8603e6e5..7c4e350195 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1011,11 +1011,26 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { if (!live?.position) return effective return { ...effective, position: live.position } }) + const curtainGeometryChildren = + node.wallType === 'curtain' && !isCurvedWall(node) + ? childrenNodes.map((child) => { + if ( + (child.type !== 'door' && child.type !== 'window') || + child.openingShape === 'rectangle' + ) + return child + const isLive = + useLiveNodeOverrides.getState().get(child.id) !== undefined || + useLiveTransforms.getState().get(child.id) !== undefined + // Exact shaped CSG can exceed the frame budget; the committed rebuild restores the arch. + return isLive ? { ...child, openingShape: 'rectangle' as const } : child + }) + : childrenNodes const builtGeo = generateExtrudedWall( node, node.wallType === 'curtain' && !isCurvedWall(node) - ? childrenNodes.filter((child) => child.type !== 'door' && child.type !== 'window') + ? curtainGeometryChildren.filter((child) => child.type !== 'door' && child.type !== 'window') : childrenNodes, miterData, slabElevation, @@ -1034,7 +1049,9 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { WALL_UV_UNIT_SCALE, ) const renderedGeo = - node.wallType === 'curtain' ? buildCurtainWallGeometry(node, builtGeo, childrenNodes) : builtGeo + node.wallType === 'curtain' + ? buildCurtainWallGeometry(node, builtGeo, curtainGeometryChildren) + : builtGeo const newGeo = applyWorldPlanarWallUVs(renderedGeo, wallWorldMatrix) mesh.geometry.dispose() From fddcbd30e31854588115aa85d02a38c4ffdca84d Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 14:22:44 +0530 Subject: [PATCH 05/21] move curtain wall geometry into the nodes package - Add viewer adapter for curtain wall geometry - Decouple opening previews from core scene stores - Fix rectangular opening frame bounds --- .gitignore | 1 + biome.jsonc | 1 + package.json | 1 + packages/core/src/schema/index.ts | 4 - .../core/src/schema/nodes/curtain-wall.ts | 258 ------------------ packages/nodes/src/door/panel.tsx | 9 +- .../src/shared/curtain-opening-limits.test.ts | 5 +- .../shared/opening-property-preview-host.ts | 11 + .../src/shared/opening-property-preview.ts | 54 ++-- .../src}/wall/curtain-opening-frame.test.ts | 2 +- .../src}/wall/curtain-opening-frame.ts | 3 +- .../nodes/src/wall/curtain-wall-adapter.ts | 39 +++ .../wall/curtain-wall-dense-opening.test.ts | 2 +- .../wall/curtain-wall-door-resize.test.ts | 9 +- .../nodes/src/wall/curtain-wall-floorplan.ts | 5 +- .../src}/wall/curtain-wall-geometry.test.ts | 10 +- .../src}/wall/curtain-wall-geometry.ts | 35 ++- .../src/wall/curtain-wall-layout.test.ts} | 6 +- .../nodes/src/wall/curtain-wall-layout.ts | 258 ++++++++++++++++++ .../src}/wall/curtain-wall-materials.test.ts | 12 +- .../nodes/src/wall/curtain-wall-materials.ts | 81 ++++++ .../nodes/src/wall/curtain-wall-panel.tsx | 3 +- .../wall/curtain-wall-piece-geometry.test.ts | 2 +- .../src}/wall/curtain-wall-piece-geometry.ts | 4 +- .../src}/wall/curtain-wall-preview.test.ts | 5 +- .../src}/wall/curtain-wall-shadow.test.ts | 2 +- .../src}/wall/curtain-wall-shadow.ts | 0 .../wall/curtain-wall-window-resize.test.ts | 11 +- packages/nodes/src/wall/renderer.tsx | 6 +- packages/nodes/src/wall/system.tsx | 6 +- packages/nodes/src/window/panel.tsx | 9 +- .../nodes/src/window/property-preview.test.ts | 3 +- packages/viewer/src/index.ts | 21 +- packages/viewer/src/lib/csg-utils.ts | 2 +- .../systems/wall/curtain-wall-materials.ts | 51 ---- .../src/systems/wall/wall-cutout-cache.ts | 8 +- .../viewer/src/systems/wall/wall-cutout.tsx | 17 +- .../viewer/src/systems/wall/wall-materials.ts | 85 +++--- .../viewer/src/systems/wall/wall-system.tsx | 91 +++--- 39 files changed, 635 insertions(+), 497 deletions(-) create mode 100644 packages/nodes/src/shared/opening-property-preview-host.ts rename packages/{viewer/src/systems => nodes/src}/wall/curtain-opening-frame.test.ts (98%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-opening-frame.ts (95%) create mode 100644 packages/nodes/src/wall/curtain-wall-adapter.ts rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-dense-opening.test.ts (95%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-door-resize.test.ts (88%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-geometry.test.ts (93%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-geometry.ts (92%) rename packages/{core/src/schema/nodes/curtain-wall.test.ts => nodes/src/wall/curtain-wall-layout.test.ts} (98%) create mode 100644 packages/nodes/src/wall/curtain-wall-layout.ts rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-materials.test.ts (84%) create mode 100644 packages/nodes/src/wall/curtain-wall-materials.ts rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-piece-geometry.test.ts (95%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-piece-geometry.ts (95%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-preview.test.ts (88%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-shadow.test.ts (96%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-shadow.ts (100%) rename packages/{viewer/src/systems => nodes/src}/wall/curtain-wall-window-resize.test.ts (88%) delete mode 100644 packages/viewer/src/systems/wall/curtain-wall-materials.ts diff --git a/.gitignore b/.gitignore index e26d858f69..e41ddbb868 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ supabase/.temp/ # Build Outputs .next/ +.next-*/ out/ build dist diff --git a/biome.jsonc b/biome.jsonc index 8e545ad325..02ef888725 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -108,6 +108,7 @@ "apps/**/*.jsx", "!**/node_modules", "!**/.next", + "!**/.next-*", "!**/dist", "!**/build", "!**/public", diff --git a/package.json b/package.json index 11827fd0b1..a8973cb50f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "build": "turbo run build", "dev": "dotenv -e ./.env -e ./.env.defaults -- turbo run dev --env-mode=loose", "lint": "biome lint", + "lints": "bun run lint", "lint:fix": "biome lint --write", "format": "biome format --write", "format:check": "biome format", diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 4154730c71..d51ac992a6 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -109,13 +109,9 @@ export { } from './nodes/construction-dimension' export { CupolaMaterialRole, CupolaNode } from './nodes/cupola' export { - buildCurtainWallLayout, CurtainGrid, CurtainPanelType, CurtainWallConfig, - type CurtainWallPiece, - curtainGridPositions, - curtainPanelType, DEFAULT_CURTAIN_WALL, getCurtainWallConfig, } from './nodes/curtain-wall' diff --git a/packages/core/src/schema/nodes/curtain-wall.ts b/packages/core/src/schema/nodes/curtain-wall.ts index 6822ea651b..ac90e0f08e 100644 --- a/packages/core/src/schema/nodes/curtain-wall.ts +++ b/packages/core/src/schema/nodes/curtain-wall.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import type { DoorNode } from './door' export const CurtainGrid = z.object({ layout: z.enum(['count', 'maximum-spacing', 'fixed-spacing']).default('maximum-spacing'), @@ -58,260 +57,3 @@ 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) -} - -export type CurtainWallPiece = { - left: number - right: number - bottom: number - top: number - front: number - back: number - role: 'frame' | 'glass' | 'solid' -} - -export function curtainPanelType( - config: CurtainWallConfig, - column: number, - row: number, - rowCount: number, -): CurtainPanelType { - const override = config.panels.find((panel) => panel.column === column && panel.row === row) - if (override) return override.type - if ( - (config.spandrel === 'bottom' && row === 0) || - (config.spandrel === 'top' && row === rowCount - 1) - ) - return 'solid' - return config.panelType -} - -export function buildCurtainWallLayout( - length: number, - height: number, - depth: number, - config: CurtainWallConfig, - openings: readonly (Pick & { - type: 'door' | 'window' - })[] = [], - baseElevation = 0, -): CurtainWallPiece[] { - if (length <= 1e-6 || height <= 1e-6 || depth <= 0) return [] - const xs = curtainGridPositions(length, config.columns) - const ys = curtainGridPositions(height, config.rows) - const pieces: CurtainWallPiece[] = [] - const panelDepth = Math.min(config.glassThickness, depth * 0.45) - const panelFront = depth / 2 - Math.min(0.005, depth * 0.05) - const panelBack = panelFront - panelDepth - const add = ( - left: number, - right: number, - bottom: number, - top: number, - front: number, - role: CurtainWallPiece['role'], - back = -depth / 2, - ) => { - if (right - left > 1e-6 && top - bottom > 1e-6 && front - back > 1e-6) - pieces.push({ left, right, bottom, top, front, back, role }) - } - const verticalCap = config.framing === 'capped' || config.framing === 'vertical-caps' - const horizontalCap = config.framing === 'capped' || config.framing === 'horizontal-caps' - if (config.construction === 'stick') { - const verticalBounds = xs.map((x, index): [number, number] => { - if (index === 0) return [0, Math.min(config.perimeterWidth, (xs[1]! - x) * 0.4)] - if (index === xs.length - 1) - return [length - Math.min(config.perimeterWidth, (x - xs[index - 1]!) * 0.4), length] - return [ - x - Math.min(config.mullionWidth / 2, (x - xs[index - 1]!) * 0.4), - x + Math.min(config.mullionWidth / 2, (xs[index + 1]! - x) * 0.4), - ] - }) - const horizontalBounds = ys.map((y, index): [number, number] => { - if (index === 0) return [0, Math.min(config.perimeterWidth, (ys[1]! - y) * 0.4)] - if (index === ys.length - 1) - return [height - Math.min(config.perimeterWidth, (y - ys[index - 1]!) * 0.4), height] - return [ - y - Math.min(config.transomWidth / 2, (y - ys[index - 1]!) * 0.4), - y + Math.min(config.transomWidth / 2, (ys[index + 1]! - y) * 0.4), - ] - }) - - for (const [left, right] of verticalBounds) - add(left, right, 0, height, verticalCap ? depth / 2 : panelBack, 'frame') - for (const [bottom, top] of horizontalBounds) { - for (let column = 0; column < xs.length - 1; column++) { - add( - verticalBounds[column]![1], - verticalBounds[column + 1]![0], - bottom, - top, - horizontalCap ? depth / 2 : panelBack, - 'frame', - ) - } - } - - for (let column = 0; column < xs.length - 1; column++) { - for (let row = 0; row < ys.length - 1; row++) { - const type = curtainPanelType(config, column, row, ys.length - 1) - if (type === 'empty') continue - add( - verticalBounds[column]![1], - verticalBounds[column + 1]![0], - horizontalBounds[row]![1], - horizontalBounds[row + 1]![0], - panelFront, - type, - panelBack, - ) - } - } - } else { - for (let column = 0; column < xs.length - 1; column++) { - for (let row = 0; row < ys.length - 1; row++) { - const x0 = xs[column]!, - x1 = xs[column + 1]!, - y0 = ys[row]!, - y1 = ys[row + 1]! - const gap = - config.construction === 'unitized' - ? Math.min(config.jointWidth / 2, (x1 - x0) / 10, (y1 - y0) / 10) - : 0 - const left = x0 + (column === 0 ? 0 : gap), - right = x1 - (column === xs.length - 2 ? 0 : gap) - const bottom = y0 + (row === 0 ? 0 : gap), - top = y1 - (row === ys.length - 2 ? 0 : gap) - const lw = Math.min( - column === 0 ? config.perimeterWidth : config.mullionWidth / 2, - (right - left) * 0.4, - ) - const rw = Math.min( - column === xs.length - 2 ? config.perimeterWidth : config.mullionWidth / 2, - (right - left) * 0.4, - ) - const bw = Math.min( - row === 0 ? config.perimeterWidth : config.transomWidth / 2, - (top - bottom) * 0.4, - ) - const tw = Math.min( - row === ys.length - 2 ? config.perimeterWidth : config.transomWidth / 2, - (top - bottom) * 0.4, - ) - add(left, left + lw, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') - add(right - rw, right, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') - add( - left + lw, - right - rw, - bottom, - bottom + bw, - horizontalCap ? depth / 2 : panelBack, - 'frame', - ) - add(left + lw, right - rw, top - tw, top, horizontalCap ? depth / 2 : panelBack, 'frame') - const type = curtainPanelType(config, column, row, ys.length - 1) - if (type === 'empty') continue - const joint = Math.min(config.jointWidth / 2, (right - left) * 0.1, (top - bottom) * 0.1) - add( - left + (verticalCap ? lw : joint), - right - (verticalCap ? rw : joint), - bottom + (horizontalCap ? bw : joint), - top - (horizontalCap ? tw : joint), - panelFront, - type, - panelBack, - ) - } - } - } - let fitted = pieces - for (const opening of openings) { - if (opening.openingShape !== 'rectangle') continue - const left = opening.position[0] - opening.width / 2 - const right = opening.position[0] + opening.width / 2 - const bottom = opening.position[1] - opening.height / 2 - baseElevation - const top = bottom + opening.height - if (right <= 0 || left >= length || top <= 0 || bottom >= height) continue - const outerLeft = Math.max(0, left - config.perimeterWidth) - const outerRight = Math.min(length, right + config.perimeterWidth) - const outerBottom = Math.max( - 0, - bottom - (opening.type === 'window' ? config.perimeterWidth : 0), - ) - const outerTop = Math.min(height, top + config.perimeterWidth) - // Replace intersecting infill and grid members with a continuous entrance subframe. - fitted = fitted.flatMap((piece) => { - const x0 = Math.max(piece.left, outerLeft) - const x1 = Math.min(piece.right, outerRight) - const y0 = Math.max(piece.bottom, outerBottom) - const y1 = Math.min(piece.top, outerTop) - if (x1 <= x0 || y1 <= y0) return [piece] - return [ - { ...piece, right: x0 }, - { ...piece, left: x1 }, - { ...piece, left: x0, right: x1, top: y0 }, - { ...piece, left: x0, right: x1, bottom: y1 }, - ].filter((part) => part.right - part.left > 1e-6 && part.top - part.bottom > 1e-6) - }) - const frame = { front: depth / 2, back: -depth / 2, role: 'frame' as const } - fitted.push( - ...[ - ...(opening.type === 'window' - ? [ - { - ...frame, - left: Math.max(0, left), - right: Math.min(length, right), - bottom: outerBottom, - top: Math.min(height, bottom), - }, - ] - : []), - { - ...frame, - left: outerLeft, - right: Math.min(length, left), - bottom: outerBottom, - top: outerTop, - }, - { - ...frame, - left: Math.max(0, right), - right: outerRight, - bottom: outerBottom, - top: outerTop, - }, - { - ...frame, - left: Math.max(0, left), - right: Math.min(length, right), - bottom: Math.max(0, top), - top: outerTop, - }, - ].filter((part) => part.right - part.left > 1e-6 && part.top - part.bottom > 1e-6), - ) - } - return fitted -} diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index c931108b90..e866b16e26 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -18,6 +18,7 @@ import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' +import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' import { scaleHandleHeight } from './door-math' const doorTypeOptions = [ @@ -140,7 +141,13 @@ export default function DoorPanel() { const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const preview = useMemo( - () => (selectedId ? createOpeningPropertyPreview(selectedId as AnyNodeId) : null), + () => + selectedId + ? createOpeningPropertyPreview( + selectedId as AnyNodeId, + openingPropertyPreviewHost, + ) + : null, [selectedId], ) useEffect(() => () => preview?.cancel(), [preview]) diff --git a/packages/nodes/src/shared/curtain-opening-limits.test.ts b/packages/nodes/src/shared/curtain-opening-limits.test.ts index c401580dde..e6bb2e3b3d 100644 --- a/packages/nodes/src/shared/curtain-opening-limits.test.ts +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -9,6 +9,7 @@ import { } from '@pascal-app/core' import { constrainCurtainOpening, curtainOpeningLimits } from './curtain-opening-limits' import { createOpeningPropertyPreview } from './opening-property-preview' +import { openingPropertyPreviewHost } from './opening-property-preview-host' globalThis.requestAnimationFrame ??= (callback) => { callback(0) @@ -56,7 +57,7 @@ test('door drag writes no scene history until commit and undoes in one step', () useScene.setState({ nodes, dirtyNodes: new Set() }) useScene.temporal.getState().resume() useScene.temporal.getState().clear() - const preview = createOpeningPropertyPreview(door.id) + const preview = createOpeningPropertyPreview(door.id, openingPropertyPreviewHost) for (const width of [1.2, 1.5, 3]) preview.preview({ width }) expect(useScene.getState().nodes[door.id]).toBe(door) expect(getEffectiveNode(door).width).toBeCloseTo(2.04) @@ -72,7 +73,7 @@ test('door drag writes no scene history until commit and undoes in one step', () test('cancel and a history-cleared preview cannot be committed by a stale release', () => { useScene.setState({ nodes, dirtyNodes: new Set() }) - const preview = createOpeningPropertyPreview(door.id) + const preview = createOpeningPropertyPreview(door.id, openingPropertyPreviewHost) preview.preview({ height: 2.4 }) preview.cancel() expect(getEffectiveNode(door).height).toBe(door.height) diff --git a/packages/nodes/src/shared/opening-property-preview-host.ts b/packages/nodes/src/shared/opening-property-preview-host.ts new file mode 100644 index 0000000000..4457f1ad8e --- /dev/null +++ b/packages/nodes/src/shared/opening-property-preview-host.ts @@ -0,0 +1,11 @@ +import { type AnyNode, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import type { OpeningPropertyPreviewDependencies } from './opening-property-preview' + +export const openingPropertyPreviewHost: OpeningPropertyPreviewDependencies = { + nodes: () => useScene.getState().nodes, + override: (id) => useLiveNodeOverrides.getState().get(id), + setOverride: (id, patch) => useLiveNodeOverrides.getState().set(id, patch), + clearOverrideFields: (id, fields) => useLiveNodeOverrides.getState().clearFields(id, fields), + markDirty: (id) => useScene.getState().markDirty(id), + updateNode: (id, patch) => useScene.getState().updateNode(id, patch as Partial), +} diff --git a/packages/nodes/src/shared/opening-property-preview.ts b/packages/nodes/src/shared/opening-property-preview.ts index a73f5a5245..73a1f43afc 100644 --- a/packages/nodes/src/shared/opening-property-preview.ts +++ b/packages/nodes/src/shared/opening-property-preview.ts @@ -1,38 +1,43 @@ -import { - type AnyNodeId, - type DoorNode, - useLiveNodeOverrides, - useScene, - type WindowNode, -} from '@pascal-app/core' - +import type { AnyNode, AnyNodeId, DoorNode, WindowNode } from '@pascal-app/core' import { constrainCurtainOpening } from './curtain-opening-limits' -export function createOpeningPropertyPreview(id: AnyNodeId) { +export type OpeningPropertyPreviewDependencies = { + nodes: () => Readonly> + override: (id: AnyNodeId) => Partial | undefined + setOverride: (id: AnyNodeId, patch: Partial) => void + clearOverrideFields: (id: AnyNodeId, fields: string[]) => void + markDirty: (id: AnyNodeId) => void + updateNode: (id: AnyNodeId, patch: Partial) => void +} + +export function createOpeningPropertyPreview( + id: AnyNodeId, + dependencies: OpeningPropertyPreviewDependencies, +) { let pending: Partial | undefined const dirty = () => { - const scene = useScene.getState() - scene.markDirty(id) - const parent = scene.nodes[id]?.parentId - if (parent) scene.markDirty(parent as AnyNodeId) + dependencies.markDirty(id) + const parent = dependencies.nodes()[id]?.parentId + if (parent) dependencies.markDirty(parent as AnyNodeId) } const clear = () => { if (!pending) return - useLiveNodeOverrides.getState().clearFields(id, Object.keys(pending)) + dependencies.clearOverrideFields(id, Object.keys(pending)) pending = undefined dirty() } return { preview(patch: Partial) { - const node = useScene.getState().nodes[id] + const nodes = dependencies.nodes() + const node = nodes[id] if (node?.type !== 'door' && node?.type !== 'window') return - patch = constrainCurtainOpening(node as T, patch, useScene.getState().nodes) + patch = constrainCurtainOpening(node as T, patch, nodes) pending = { ...pending, ...patch } - useLiveNodeOverrides.getState().set(id, patch) + dependencies.setOverride(id, patch as Partial) dirty() }, commit(patch?: Partial) { - const live = useLiveNodeOverrides.getState().get(id) + const live = dependencies.override(id) if (pending && !Object.keys(pending).some((key) => live && key in live)) { clear() return @@ -42,19 +47,16 @@ export function createOpeningPropertyPreview(id ? Object.fromEntries( Object.keys(pending) .filter((key) => key in live) - .map((key) => [key, live[key]]), + .map((key) => [key, live[key as keyof typeof live]]), ) : undefined - const node = useScene.getState().nodes[id] + const nodes = dependencies.nodes() + const node = nodes[id] const updates = node?.type === 'door' || node?.type === 'window' - ? constrainCurtainOpening( - node as T, - { ...current, ...patch } as Partial, - useScene.getState().nodes, - ) + ? constrainCurtainOpening(node as T, { ...current, ...patch } as Partial, nodes) : {} - if (Object.keys(updates).length) useScene.getState().updateNode(id, updates) + if (Object.keys(updates).length) dependencies.updateNode(id, updates as Partial) clear() }, cancel: clear, diff --git a/packages/viewer/src/systems/wall/curtain-opening-frame.test.ts b/packages/nodes/src/wall/curtain-opening-frame.test.ts similarity index 98% rename from packages/viewer/src/systems/wall/curtain-opening-frame.test.ts rename to packages/nodes/src/wall/curtain-opening-frame.test.ts index 61c16251a7..3edcbb9312 100644 --- a/packages/viewer/src/systems/wall/curtain-opening-frame.test.ts +++ b/packages/nodes/src/wall/curtain-opening-frame.test.ts @@ -6,6 +6,7 @@ import { WallNode, WindowNode, } from '@pascal-app/core' +import { generateExtrudedWall } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' import { buildCurtainOpeningFrame, @@ -13,7 +14,6 @@ import { curtainProfileSpan, } from './curtain-opening-frame' import { buildCurtainWallGeometry } from './curtain-wall-geometry' -import { generateExtrudedWall } from './wall-system' for (const type of ['door', 'window'] as const) { for (const shape of ['rectangle', 'rounded', 'arch'] as const) { diff --git a/packages/viewer/src/systems/wall/curtain-opening-frame.ts b/packages/nodes/src/wall/curtain-opening-frame.ts similarity index 95% rename from packages/viewer/src/systems/wall/curtain-opening-frame.ts rename to packages/nodes/src/wall/curtain-opening-frame.ts index 99c00e57b7..40fe6d43e7 100644 --- a/packages/viewer/src/systems/wall/curtain-opening-frame.ts +++ b/packages/nodes/src/wall/curtain-opening-frame.ts @@ -1,7 +1,6 @@ import type { DoorNode, WindowNode } from '@pascal-app/core' +import { buildOpeningCutoutShape, ensureRenderableGeometryAttributes } from '@pascal-app/viewer' import { ExtrudeGeometry, Path, Shape, Vector2 } from 'three' -import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' -import { buildOpeningCutoutShape } from './opening-cutout-geometry' export function curtainOpeningProfile(opening: DoorNode | WindowNode, width: number) { const bottom = opening.position[1] - opening.height / 2 diff --git a/packages/nodes/src/wall/curtain-wall-adapter.ts b/packages/nodes/src/wall/curtain-wall-adapter.ts new file mode 100644 index 0000000000..fe4a12e9c4 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-adapter.ts @@ -0,0 +1,39 @@ +import { isCurvedWall, type WallNode } from '@pascal-app/core' +import type { WallGeometryAdapter } from '@pascal-app/viewer' +import type { Mesh } from 'three' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' +import { buildCurtainWallShadowGeometry, CURTAIN_WALL_SHADOW_NAME } from './curtain-wall-shadow' + +export const curtainWallGeometryAdapter: WallGeometryAdapter = { + prepareChildren(wall, children, context) { + if (wall.wallType !== 'curtain' || isCurvedWall(wall)) { + return { envelopeChildren: [...children], renderChildren: [...children] } + } + const renderChildren = children.map((child) => { + if ((child.type !== 'door' && child.type !== 'window') || child.openingShape === 'rectangle') + return child + return context.isLive(child.id) ? { ...child, openingShape: 'rectangle' as const } : child + }) + return { + envelopeChildren: renderChildren.filter( + (child) => child.type !== 'door' && child.type !== 'window', + ), + renderChildren, + } + }, + buildGeometry(wall, envelope, children) { + return wall.wallType === 'curtain' + ? buildCurtainWallGeometry(wall as WallNode, envelope, children) + : envelope + }, + syncAuxiliaryGeometry(wall, mesh, geometry) { + if (wall.wallType !== 'curtain') return + const shadowMesh = mesh.getObjectByName(CURTAIN_WALL_SHADOW_NAME) as Mesh | undefined + if (!shadowMesh) return + shadowMesh.geometry.dispose() + shadowMesh.geometry = buildCurtainWallShadowGeometry( + geometry, + wall.curtainWall?.glassOpacity === 1, + ) + }, +} diff --git a/packages/viewer/src/systems/wall/curtain-wall-dense-opening.test.ts b/packages/nodes/src/wall/curtain-wall-dense-opening.test.ts similarity index 95% rename from packages/viewer/src/systems/wall/curtain-wall-dense-opening.test.ts rename to packages/nodes/src/wall/curtain-wall-dense-opening.test.ts index 1e0bc2a2a0..4cc7c07a9b 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-dense-opening.test.ts +++ b/packages/nodes/src/wall/curtain-wall-dense-opening.test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'bun:test' import { calculateLevelMiters, DoorNode, WallNode } from '@pascal-app/core' +import { generateExtrudedWall } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' import { buildCurtainWallGeometry } from './curtain-wall-geometry' -import { generateExtrudedWall } from './wall-system' test('dense-grid curved cuts preserve distant glass and leave the door clear', () => { const wall = WallNode.parse({ diff --git a/packages/viewer/src/systems/wall/curtain-wall-door-resize.test.ts b/packages/nodes/src/wall/curtain-wall-door-resize.test.ts similarity index 88% rename from packages/viewer/src/systems/wall/curtain-wall-door-resize.test.ts rename to packages/nodes/src/wall/curtain-wall-door-resize.test.ts index 80b407cf9d..c9ea69e966 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-door-resize.test.ts +++ b/packages/nodes/src/wall/curtain-wall-door-resize.test.ts @@ -7,8 +7,9 @@ import { useScene, WallNode, } from '@pascal-app/core' +import { runWallBuildFrame } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' -import { runWallBuildFrame } from './wall-system' +import { curtainWallGeometryAdapter } from './curtain-wall-adapter' const initial = useScene.getState() afterEach(() => { @@ -44,18 +45,18 @@ test('curtain door width previews rebuild a clear opening and cancel without doc const hit = (x: number, y: number) => new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) try { - runWallBuildFrame() + runWallBuildFrame(curtainWallGeometryAdapter) expect(hit(0.85, 1).length).toBeGreaterThan(0) useLiveNodeOverrides.getState().set(door.id, { width: 1.6 }) useScene.getState().markDirty(wall.id) - runWallBuildFrame() + runWallBuildFrame(curtainWallGeometryAdapter) expect(hit(0.85, 1)).toHaveLength(0) expect(hit(0.675, 1)[0]?.face?.materialIndex).toBe(0) expect(hit(0.85, 2.4)[0]?.face?.materialIndex).toBe(1) expect(useScene.getState().nodes[door.id]).toBe(door) useLiveNodeOverrides.getState().clear(door.id) useScene.getState().markDirty(wall.id) - runWallBuildFrame() + runWallBuildFrame(curtainWallGeometryAdapter) expect(hit(0.85, 1).length).toBeGreaterThan(0) expect(useScene.getState().nodes[door.id]).toBe(door) } finally { diff --git a/packages/nodes/src/wall/curtain-wall-floorplan.ts b/packages/nodes/src/wall/curtain-wall-floorplan.ts index 7273088a75..b4be2702cc 100644 --- a/packages/nodes/src/wall/curtain-wall-floorplan.ts +++ b/packages/nodes/src/wall/curtain-wall-floorplan.ts @@ -1,7 +1,5 @@ import { type AnyNode, - buildCurtainWallLayout, - type CurtainWallPiece, type FloorplanGeometry, getCurtainWallConfig, getWallCurveFrameAt, @@ -9,7 +7,8 @@ import { getWallThickness, type WallNode, } from '@pascal-app/core' -import { curtainOpeningProfile, curtainProfileSpan } from '@pascal-app/viewer' +import { curtainOpeningProfile, curtainProfileSpan } from './curtain-opening-frame' +import { buildCurtainWallLayout, type CurtainWallPiece } from './curtain-wall-layout' export function buildCurtainWallFloorplan( wall: WallNode, diff --git a/packages/viewer/src/systems/wall/curtain-wall-geometry.test.ts b/packages/nodes/src/wall/curtain-wall-geometry.test.ts similarity index 93% rename from packages/viewer/src/systems/wall/curtain-wall-geometry.test.ts rename to packages/nodes/src/wall/curtain-wall-geometry.test.ts index d8f017b0b8..7c853f877f 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-geometry.test.ts +++ b/packages/nodes/src/wall/curtain-wall-geometry.test.ts @@ -6,10 +6,10 @@ import { sceneRegistry, WallNode, } from '@pascal-app/core' +import { generateExtrudedWall } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' import { buildCurtainWallGeometry } from './curtain-wall-geometry' -import { getMaterialsForWall } from './wall-materials' -import { generateExtrudedWall } from './wall-system' +import { getCurtainAwareWallMaterials } from './curtain-wall-materials' const material = new MeshBasicMaterial({ side: DoubleSide }) function hit(mesh: Mesh, x: number, y: number) { @@ -106,15 +106,15 @@ describe('curtain wall geometry', () => { }) test('material cache changes with curtain settings and wall type', () => { const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain' }) - const first = getMaterialsForWall(wall) + const first = getCurtainAwareWallMaterials(wall) expect(first.visible[1]!.transparent).toBe(true) - const changed = getMaterialsForWall({ + const changed = getCurtainAwareWallMaterials({ ...wall, curtainWall: CurtainWallConfig.parse({ glassOpacity: 0.7 }), }) expect(changed.visible[1]!.opacity).toBe(0.7) expect(changed.materialHash).not.toBe(first.materialHash) - const standard = getMaterialsForWall({ ...wall, wallType: 'standard' }) + const standard = getCurtainAwareWallMaterials({ ...wall, wallType: 'standard' }) expect(standard.visible[1]!.transparent).toBe(false) }) }) diff --git a/packages/viewer/src/systems/wall/curtain-wall-geometry.ts b/packages/nodes/src/wall/curtain-wall-geometry.ts similarity index 92% rename from packages/viewer/src/systems/wall/curtain-wall-geometry.ts rename to packages/nodes/src/wall/curtain-wall-geometry.ts index 333de9ab33..839f8162ad 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-geometry.ts +++ b/packages/nodes/src/wall/curtain-wall-geometry.ts @@ -1,8 +1,5 @@ import { type AnyNode, - buildCurtainWallLayout, - type CurtainWallPiece, - curtainGridPositions, getCurtainWallConfig, getWallCurveFrameAt, getWallCurveLength, @@ -10,11 +7,22 @@ import { isCurvedWall, type WallNode, } from '@pascal-app/core' +import { + Brush, + Evaluator, + ensureRenderableGeometryAttributes, + INTERSECTION, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' import { BufferGeometry, ExtrudeGeometry, Shape } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' -import { Brush, Evaluator, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' -import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' import { buildCurtainOpeningFrame } from './curtain-opening-frame' +import { + buildCurtainWallLayout, + type CurtainWallPiece, + curtainGridPositions, +} from './curtain-wall-layout' import { buildStraightCurtainPieces } from './curtain-wall-piece-geometry' function pieceGeometry(wall: WallNode, piece: CurtainWallPiece, length: number, base: number) { @@ -102,6 +110,23 @@ export function buildCurtainWallGeometry( (near(position.getY(i), bounds.min.y) || near(position.getY(i), bounds.max.y)) && (near(position.getZ(i), -halfDepth) || near(position.getZ(i), halfDepth)), ) + if (rectangular) { + for (const { frame } of shapedFrames) { + const framePosition = frame.getAttribute('position') + for (let index = 0; index < framePosition.count; index++) { + framePosition.setX( + index, + Math.max(bounds.min.x, Math.min(bounds.max.x, framePosition.getX(index))), + ) + framePosition.setY( + index, + Math.max(bounds.min.y, Math.min(bounds.max.y, framePosition.getY(index))), + ) + } + framePosition.needsUpdate = true + frame.computeVertexNormals() + } + } if (!rectangular) prepareBrushForCSG(shell) const leftEnvelopeXs: number[] = [] const rightEnvelopeXs: number[] = [] diff --git a/packages/core/src/schema/nodes/curtain-wall.test.ts b/packages/nodes/src/wall/curtain-wall-layout.test.ts similarity index 98% rename from packages/core/src/schema/nodes/curtain-wall.test.ts rename to packages/nodes/src/wall/curtain-wall-layout.test.ts index a336ffbf56..508274a7b0 100644 --- a/packages/core/src/schema/nodes/curtain-wall.test.ts +++ b/packages/nodes/src/wall/curtain-wall-layout.test.ts @@ -1,12 +1,10 @@ import { describe, expect, test } from 'bun:test' +import { CurtainGrid, CurtainWallConfig, WallNode } from '@pascal-app/core' import { buildCurtainWallLayout, - CurtainGrid, - CurtainWallConfig, curtainGridPositions, curtainPanelType, -} from './curtain-wall' -import { WallNode } from './wall' +} from './curtain-wall-layout' describe('curtain walls', () => { test('old walls remain standard and switching preserves both configurations', () => { diff --git a/packages/nodes/src/wall/curtain-wall-layout.ts b/packages/nodes/src/wall/curtain-wall-layout.ts new file mode 100644 index 0000000000..c36e7dd9a7 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-layout.ts @@ -0,0 +1,258 @@ +import type { CurtainGrid, CurtainPanelType, CurtainWallConfig, DoorNode } from '@pascal-app/core' + +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) +} + +export type CurtainWallPiece = { + left: number + right: number + bottom: number + top: number + front: number + back: number + role: 'frame' | 'glass' | 'solid' +} + +export function curtainPanelType( + config: CurtainWallConfig, + column: number, + row: number, + rowCount: number, +): CurtainPanelType { + const override = config.panels.find((panel) => panel.column === column && panel.row === row) + if (override) return override.type + if ( + (config.spandrel === 'bottom' && row === 0) || + (config.spandrel === 'top' && row === rowCount - 1) + ) + return 'solid' + return config.panelType +} + +export function buildCurtainWallLayout( + length: number, + height: number, + depth: number, + config: CurtainWallConfig, + openings: readonly (Pick & { + type: 'door' | 'window' + })[] = [], + baseElevation = 0, +): CurtainWallPiece[] { + if (length <= 1e-6 || height <= 1e-6 || depth <= 0) return [] + const xs = curtainGridPositions(length, config.columns) + const ys = curtainGridPositions(height, config.rows) + const pieces: CurtainWallPiece[] = [] + const panelDepth = Math.min(config.glassThickness, depth * 0.45) + const panelFront = depth / 2 - Math.min(0.005, depth * 0.05) + const panelBack = panelFront - panelDepth + const add = ( + left: number, + right: number, + bottom: number, + top: number, + front: number, + role: CurtainWallPiece['role'], + back = -depth / 2, + ) => { + if (right - left > 1e-6 && top - bottom > 1e-6 && front - back > 1e-6) + pieces.push({ left, right, bottom, top, front, back, role }) + } + const verticalCap = config.framing === 'capped' || config.framing === 'vertical-caps' + const horizontalCap = config.framing === 'capped' || config.framing === 'horizontal-caps' + if (config.construction === 'stick') { + const verticalBounds = xs.map((x, index): [number, number] => { + if (index === 0) return [0, Math.min(config.perimeterWidth, (xs[1]! - x) * 0.4)] + if (index === xs.length - 1) + return [length - Math.min(config.perimeterWidth, (x - xs[index - 1]!) * 0.4), length] + return [ + x - Math.min(config.mullionWidth / 2, (x - xs[index - 1]!) * 0.4), + x + Math.min(config.mullionWidth / 2, (xs[index + 1]! - x) * 0.4), + ] + }) + const horizontalBounds = ys.map((y, index): [number, number] => { + if (index === 0) return [0, Math.min(config.perimeterWidth, (ys[1]! - y) * 0.4)] + if (index === ys.length - 1) + return [height - Math.min(config.perimeterWidth, (y - ys[index - 1]!) * 0.4), height] + return [ + y - Math.min(config.transomWidth / 2, (y - ys[index - 1]!) * 0.4), + y + Math.min(config.transomWidth / 2, (ys[index + 1]! - y) * 0.4), + ] + }) + + for (const [left, right] of verticalBounds) + add(left, right, 0, height, verticalCap ? depth / 2 : panelBack, 'frame') + for (const [bottom, top] of horizontalBounds) { + for (let column = 0; column < xs.length - 1; column++) { + add( + verticalBounds[column]![1], + verticalBounds[column + 1]![0], + bottom, + top, + horizontalCap ? depth / 2 : panelBack, + 'frame', + ) + } + } + + for (let column = 0; column < xs.length - 1; column++) { + for (let row = 0; row < ys.length - 1; row++) { + const type = curtainPanelType(config, column, row, ys.length - 1) + if (type === 'empty') continue + add( + verticalBounds[column]![1], + verticalBounds[column + 1]![0], + horizontalBounds[row]![1], + horizontalBounds[row + 1]![0], + panelFront, + type, + panelBack, + ) + } + } + } else { + for (let column = 0; column < xs.length - 1; column++) { + for (let row = 0; row < ys.length - 1; row++) { + const x0 = xs[column]!, + x1 = xs[column + 1]!, + y0 = ys[row]!, + y1 = ys[row + 1]! + const gap = + config.construction === 'unitized' + ? Math.min(config.jointWidth / 2, (x1 - x0) / 10, (y1 - y0) / 10) + : 0 + const left = x0 + (column === 0 ? 0 : gap), + right = x1 - (column === xs.length - 2 ? 0 : gap) + const bottom = y0 + (row === 0 ? 0 : gap), + top = y1 - (row === ys.length - 2 ? 0 : gap) + const lw = Math.min( + column === 0 ? config.perimeterWidth : config.mullionWidth / 2, + (right - left) * 0.4, + ) + const rw = Math.min( + column === xs.length - 2 ? config.perimeterWidth : config.mullionWidth / 2, + (right - left) * 0.4, + ) + const bw = Math.min( + row === 0 ? config.perimeterWidth : config.transomWidth / 2, + (top - bottom) * 0.4, + ) + const tw = Math.min( + row === ys.length - 2 ? config.perimeterWidth : config.transomWidth / 2, + (top - bottom) * 0.4, + ) + add(left, left + lw, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') + add(right - rw, right, bottom, top, verticalCap ? depth / 2 : panelBack, 'frame') + add( + left + lw, + right - rw, + bottom, + bottom + bw, + horizontalCap ? depth / 2 : panelBack, + 'frame', + ) + add(left + lw, right - rw, top - tw, top, horizontalCap ? depth / 2 : panelBack, 'frame') + const type = curtainPanelType(config, column, row, ys.length - 1) + if (type === 'empty') continue + const joint = Math.min(config.jointWidth / 2, (right - left) * 0.1, (top - bottom) * 0.1) + add( + left + (verticalCap ? lw : joint), + right - (verticalCap ? rw : joint), + bottom + (horizontalCap ? bw : joint), + top - (horizontalCap ? tw : joint), + panelFront, + type, + panelBack, + ) + } + } + } + let fitted = pieces + for (const opening of openings) { + if (opening.openingShape !== 'rectangle') continue + const left = opening.position[0] - opening.width / 2 + const right = opening.position[0] + opening.width / 2 + const bottom = opening.position[1] - opening.height / 2 - baseElevation + const top = bottom + opening.height + if (right <= 0 || left >= length || top <= 0 || bottom >= height) continue + const outerLeft = Math.max(0, left - config.perimeterWidth) + const outerRight = Math.min(length, right + config.perimeterWidth) + const outerBottom = Math.max( + 0, + bottom - (opening.type === 'window' ? config.perimeterWidth : 0), + ) + const outerTop = Math.min(height, top + config.perimeterWidth) + // Replace intersecting infill and grid members with a continuous entrance subframe. + fitted = fitted.flatMap((piece) => { + const x0 = Math.max(piece.left, outerLeft) + const x1 = Math.min(piece.right, outerRight) + const y0 = Math.max(piece.bottom, outerBottom) + const y1 = Math.min(piece.top, outerTop) + if (x1 <= x0 || y1 <= y0) return [piece] + return [ + { ...piece, right: x0 }, + { ...piece, left: x1 }, + { ...piece, left: x0, right: x1, top: y0 }, + { ...piece, left: x0, right: x1, bottom: y1 }, + ].filter((part) => part.right - part.left > 1e-6 && part.top - part.bottom > 1e-6) + }) + const frame = { front: depth / 2, back: -depth / 2, role: 'frame' as const } + fitted.push( + ...[ + ...(opening.type === 'window' + ? [ + { + ...frame, + left: Math.max(0, left), + right: Math.min(length, right), + bottom: outerBottom, + top: Math.min(height, bottom), + }, + ] + : []), + { + ...frame, + left: outerLeft, + right: Math.min(length, left), + bottom: outerBottom, + top: outerTop, + }, + { + ...frame, + left: Math.max(0, right), + right: outerRight, + bottom: outerBottom, + top: outerTop, + }, + { + ...frame, + left: Math.max(0, left), + right: Math.min(length, right), + bottom: Math.max(0, top), + top: outerTop, + }, + ].filter((part) => part.right - part.left > 1e-6 && part.top - part.bottom > 1e-6), + ) + } + return fitted +} diff --git a/packages/viewer/src/systems/wall/curtain-wall-materials.test.ts b/packages/nodes/src/wall/curtain-wall-materials.test.ts similarity index 84% rename from packages/viewer/src/systems/wall/curtain-wall-materials.test.ts rename to packages/nodes/src/wall/curtain-wall-materials.test.ts index b9e8d3d336..1ba221c3fa 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-materials.test.ts +++ b/packages/nodes/src/wall/curtain-wall-materials.test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'bun:test' import { CurtainWallConfig, SceneMaterial, WallNode } from '@pascal-app/core' +import { resolveMaterialRef } from '@pascal-app/viewer' import { Texture } from 'three' import type { MeshStandardNodeMaterial } from 'three/webgpu' -import { resolveMaterialRef } from '../../lib/materials' -import { getMaterialsForWall } from './wall-materials' +import { getCurtainAwareWallMaterials } from './curtain-wall-materials' test('painted frames keep shared texture updates and do not dispose the paint material on edits', () => { const ref = 'library:preset-metal' @@ -14,7 +14,7 @@ test('painted frames keep shared texture updates and do not dispose the paint ma wallType: 'curtain', slots: { 'curtain-frame': ref }, }) - const first = getMaterialsForWall(wall) + const first = getCurtainAwareWallMaterials(wall) expect(first.visible[0]).toBe(shared) const previousMap = shared.map const texture = new Texture() @@ -26,7 +26,7 @@ test('painted frames keep shared texture updates and do not dispose the paint ma try { shared.map = texture expect((first.visible[0] as MeshStandardNodeMaterial).map).toBe(texture) - const next = getMaterialsForWall({ + const next = getCurtainAwareWallMaterials({ ...wall, curtainWall: CurtainWallConfig.parse({ glassOpacity: 0.6 }), }) @@ -52,7 +52,7 @@ test('scene material edits update the frame while preserving glass and solid pan wallType: 'curtain', slots: { 'curtain-frame': `scene:${material.id}` }, }) - const first = getMaterialsForWall(wall, 'rendered', true, 'clay', undefined, { + const first = getCurtainAwareWallMaterials(wall, 'rendered', true, 'clay', undefined, { [material.id]: material, }) const edited = SceneMaterial.parse({ @@ -62,7 +62,7 @@ test('scene material edits update the frame while preserving glass and solid pan properties: { color: '#ff0000', roughness: 0.2, metalness: 0.9 }, }, }) - const next = getMaterialsForWall(wall, 'rendered', true, 'clay', undefined, { + const next = getCurtainAwareWallMaterials(wall, 'rendered', true, 'clay', undefined, { [material.id]: edited, }) expect(next.visible[0]).not.toBe(first.visible[0]) diff --git a/packages/nodes/src/wall/curtain-wall-materials.ts b/packages/nodes/src/wall/curtain-wall-materials.ts new file mode 100644 index 0000000000..b2f89c97d8 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-materials.ts @@ -0,0 +1,81 @@ +import { + getCurtainWallConfig, + type SceneMaterial, + type SceneMaterialId, + type WallNode, +} from '@pascal-app/core' +import { + getMaterialsForWall, + type RenderShading, + resolveMaterialRef, + type WallMaterialOverride, + type WallMaterials, +} from '@pascal-app/viewer' +import type { Material } from 'three' +import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' + +export function createCurtainWallMaterials( + wall: WallNode, + shading: RenderShading, + materials?: Record, +): WallMaterialOverride { + const config = getCurtainWallConfig(wall) + return { + hash: JSON.stringify({ + config, + slots: ['curtain-frame', 'curtain-glass', 'curtain-solid'].map((slot) => { + const ref = wall.slots?.[slot] + return [ + ref, + ref?.startsWith('scene:') ? materials?.[ref.slice(6) as SceneMaterialId] : null, + ] + }), + }), + create: () => { + const owned: Material[] = [] + const visible = ['frame', 'glass', 'solid'].map((role) => { + const ref = wall.slots?.[`curtain-${role}`] + const override = ref ? resolveMaterialRef(ref, materials, shading) : undefined + if (override) return override + const glass = role === 'glass' + const color = glass + ? config.glassColor + : role === 'frame' + ? config.frameColor + : config.solidColor + const properties = { + color, + transparent: glass, + opacity: glass ? config.glassOpacity : 1, + depthWrite: !glass, + } + const material = + shading === 'solid' + ? new MeshLambertNodeMaterial(properties) + : new MeshStandardNodeMaterial({ + ...properties, + metalness: role === 'frame' ? 0.65 : 0, + roughness: glass ? config.glassRoughness : 0.4, + }) + owned.push(material) + return material + }) + return { visible, owned } + }, + } +} + +export function getCurtainAwareWallMaterials( + wall: WallNode, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: Parameters[3] = 'clay', + sceneTheme?: string, + materials?: Record, +): WallMaterials { + const override = + textures && wall.wallType === 'curtain' + ? createCurtainWallMaterials(wall, shading, materials) + : undefined + return getMaterialsForWall(wall, shading, textures, colorPreset, sceneTheme, materials, override) +} diff --git a/packages/nodes/src/wall/curtain-wall-panel.tsx b/packages/nodes/src/wall/curtain-wall-panel.tsx index c72dd59a8c..ab1ebb176e 100644 --- a/packages/nodes/src/wall/curtain-wall-panel.tsx +++ b/packages/nodes/src/wall/curtain-wall-panel.tsx @@ -4,8 +4,6 @@ import { type CurtainGrid, type CurtainPanelType, type CurtainWallConfig, - curtainGridPositions, - curtainPanelType, getCurtainWallConfig, getWallCurveLength, getWallThickness, @@ -21,6 +19,7 @@ import { } from '@pascal-app/editor' import { useState } from 'react' import { useCurtainPanelHighlight } from './curtain-panel-highlight' +import { curtainGridPositions, curtainPanelType } from './curtain-wall-layout' type Props = { node: WallNode diff --git a/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.test.ts b/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts similarity index 95% rename from packages/viewer/src/systems/wall/curtain-wall-piece-geometry.test.ts rename to packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts index bb8e33cc8a..52abc41820 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.test.ts +++ b/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test' -import type { CurtainWallPiece } from '@pascal-app/core' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import type { CurtainWallPiece } from './curtain-wall-layout' import { buildStraightCurtainPieces } from './curtain-wall-piece-geometry' test('a pane around a doorway has no internal glass faces above the header', () => { diff --git a/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts b/packages/nodes/src/wall/curtain-wall-piece-geometry.ts similarity index 95% rename from packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts rename to packages/nodes/src/wall/curtain-wall-piece-geometry.ts index a2ace5505b..8fda946a2f 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-piece-geometry.ts +++ b/packages/nodes/src/wall/curtain-wall-piece-geometry.ts @@ -1,6 +1,6 @@ -import type { CurtainWallPiece } from '@pascal-app/core' +import { ensureRenderableGeometryAttributes } from '@pascal-app/viewer' import { BufferGeometry, Float32BufferAttribute } from 'three' -import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' +import type { CurtainWallPiece } from './curtain-wall-layout' // Adjacent fragments of one pane share a boundary, not a second glass surface. export function buildStraightCurtainPieces( diff --git a/packages/viewer/src/systems/wall/curtain-wall-preview.test.ts b/packages/nodes/src/wall/curtain-wall-preview.test.ts similarity index 88% rename from packages/viewer/src/systems/wall/curtain-wall-preview.test.ts rename to packages/nodes/src/wall/curtain-wall-preview.test.ts index 4d7405b2f8..c5a8d01928 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-preview.test.ts +++ b/packages/nodes/src/wall/curtain-wall-preview.test.ts @@ -6,8 +6,9 @@ import { useScene, WallNode, } from '@pascal-app/core' +import { WallCutoutCache, type WallCutoutViewerState } from '@pascal-app/viewer' import { Mesh, PerspectiveCamera } from 'three' -import { WallCutoutCache, type WallCutoutViewerState } from './wall-cutout-cache' +import { getCurtainAwareWallMaterials } from './curtain-wall-materials' const initial = useScene.getState() afterEach(() => { @@ -40,7 +41,7 @@ test('selected curtain glass previews and cancels without changing the document hoveredId: null, hoverHighlightMode: 'default', } - const cache = new WallCutoutCache({ getState: () => viewer }) + const cache = new WallCutoutCache({ getState: () => viewer }, getCurtainAwareWallMaterials) const camera = new PerspectiveCamera() cache.update(camera, 1) const otherMaterial = otherMesh.material diff --git a/packages/viewer/src/systems/wall/curtain-wall-shadow.test.ts b/packages/nodes/src/wall/curtain-wall-shadow.test.ts similarity index 96% rename from packages/viewer/src/systems/wall/curtain-wall-shadow.test.ts rename to packages/nodes/src/wall/curtain-wall-shadow.test.ts index 4a66d413c8..e94d2e7883 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-shadow.test.ts +++ b/packages/nodes/src/wall/curtain-wall-shadow.test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'bun:test' import { calculateLevelMiters, WallNode } from '@pascal-app/core' +import { generateExtrudedWall } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' import { buildCurtainWallGeometry } from './curtain-wall-geometry' import { buildCurtainWallShadowGeometry } from './curtain-wall-shadow' -import { generateExtrudedWall } from './wall-system' test('shadow geometry blocks frames and spandrels but leaves transparent pane openings', () => { const wall = WallNode.parse({ diff --git a/packages/viewer/src/systems/wall/curtain-wall-shadow.ts b/packages/nodes/src/wall/curtain-wall-shadow.ts similarity index 100% rename from packages/viewer/src/systems/wall/curtain-wall-shadow.ts rename to packages/nodes/src/wall/curtain-wall-shadow.ts diff --git a/packages/viewer/src/systems/wall/curtain-wall-window-resize.test.ts b/packages/nodes/src/wall/curtain-wall-window-resize.test.ts similarity index 88% rename from packages/viewer/src/systems/wall/curtain-wall-window-resize.test.ts rename to packages/nodes/src/wall/curtain-wall-window-resize.test.ts index 40658740ca..5174dad43a 100644 --- a/packages/viewer/src/systems/wall/curtain-wall-window-resize.test.ts +++ b/packages/nodes/src/wall/curtain-wall-window-resize.test.ts @@ -7,8 +7,9 @@ import { WallNode, WindowNode, } from '@pascal-app/core' +import { runWallBuildFrame } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' -import { runWallBuildFrame } from './wall-system' +import { curtainWallGeometryAdapter } from './curtain-wall-adapter' const initial = useScene.getState() afterEach(() => { @@ -45,11 +46,11 @@ test('curtain window width previews rebuild a clear opening and cancel without d const hit = (x: number, y: number) => new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) try { - runWallBuildFrame() + runWallBuildFrame(curtainWallGeometryAdapter) expect(hit(0.85, 1.4).length).toBeGreaterThan(0) useLiveNodeOverrides.getState().set(window.id, { width: 1.6 }) useScene.getState().markDirty(wall.id) - runWallBuildFrame() + runWallBuildFrame(curtainWallGeometryAdapter) expect(hit(0.85, 1.4)).toHaveLength(0) expect(hit(0.675, 1.4)[0]?.face?.materialIndex).toBe(0) expect(hit(1.5, 0.975)[0]?.face?.materialIndex).toBe(0) @@ -61,14 +62,14 @@ test('curtain window width previews rebuild a clear opening and cancel without d .getState() .set(window.id, { openingShape, cornerRadius: 0.25, archHeight: 0.4 }) useScene.getState().markDirty(wall.id) - runWallBuildFrame() + runWallBuildFrame(curtainWallGeometryAdapter) expect(hit(1.5, 1.5)).toHaveLength(0) expect(hit(1.5, 2.025)[0]?.face?.materialIndex).toBe(0) expect(useScene.getState().nodes[window.id]).toBe(window) } useLiveNodeOverrides.getState().clear(window.id) useScene.getState().markDirty(wall.id) - runWallBuildFrame() + runWallBuildFrame(curtainWallGeometryAdapter) expect(hit(0.85, 1.4).length).toBeGreaterThan(0) expect(useScene.getState().nodes[window.id]).toBe(window) } finally { diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 9c25a32bf2..572374c19d 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -10,7 +10,6 @@ import { type WallNode, } from '@pascal-app/core' import { - getVisibleWallMaterials, NodeRenderer, SHADOW_ONLY_LAYER, useLibraryMaterialsVersion, @@ -21,6 +20,7 @@ import { type ComponentProps, useEffect, useLayoutEffect, useMemo, useRef } from import type { Mesh } from 'three' import { useShallow } from 'zustand/react/shallow' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { getCurtainAwareWallMaterials } from './curtain-wall-materials' import { extractWallSelectionRay, WALL_COLLISION_MESH_NAME, @@ -157,14 +157,14 @@ const WallRenderer = ({ node }: { node: WallNode }) => { // register after mount, and a dangling ref cached as the slot default must // re-resolve when they land. const libraryMaterialsVersion = useLibraryMaterialsVersion() - const baseMaterials = getVisibleWallMaterials( + const baseMaterials = getCurtainAwareWallMaterials( treatmentNode, shading, textures, colorPreset, sceneTheme, sceneMaterials, - ) + ).visible // biome-ignore lint/correctness/useExhaustiveDependencies: libraryMaterialsVersion invalidates the ref resolution inside createWallExtraSlotMaterials const extraMaterials = useMemo( () => createWallExtraSlotMaterials(node, shading, sceneMaterials), diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx index d121c9fd2b..68000fc5bc 100644 --- a/packages/nodes/src/wall/system.tsx +++ b/packages/nodes/src/wall/system.tsx @@ -4,6 +4,8 @@ import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@ import { timeSpan, WallCutout, WallSystem } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { useEffect } from 'react' +import { curtainWallGeometryAdapter } from './curtain-wall-adapter' +import { getCurtainAwareWallMaterials } from './curtain-wall-materials' import { buildWallTreatmentLevelData, clearWallTreatmentMiterCache, @@ -109,8 +111,8 @@ const WallSystems = () => { return ( <> - - + + ) diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index 17f67cc96e..bbb3973069 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -24,6 +24,7 @@ import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' +import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' function isSameWindowValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { @@ -91,7 +92,13 @@ export default function WindowPanel() { const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const preview = useMemo( - () => (selectedId ? createOpeningPropertyPreview(selectedId as AnyNodeId) : null), + () => + selectedId + ? createOpeningPropertyPreview( + selectedId as AnyNodeId, + openingPropertyPreviewHost, + ) + : null, [selectedId], ) useEffect(() => () => preview?.cancel(), [preview]) diff --git a/packages/nodes/src/window/property-preview.test.ts b/packages/nodes/src/window/property-preview.test.ts index 25cce1d93b..1bb653f6db 100644 --- a/packages/nodes/src/window/property-preview.test.ts +++ b/packages/nodes/src/window/property-preview.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test } from 'bun:test' import { useLiveNodeOverrides, useScene, WallNode, WindowNode } from '@pascal-app/core' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' +import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' const initial = useScene.getState() afterEach(() => { @@ -17,7 +18,7 @@ test('window dimensions and curved-frame parameters preview without document mut cornerRadius: 0.3, }) useScene.setState({ nodes: { [wall.id]: wall, [window.id]: window }, dirtyNodes: new Set() }) - const preview = createOpeningPropertyPreview(window.id) + const preview = createOpeningPropertyPreview(window.id, openingPropertyPreviewHost) preview.preview({ width: 0.4, cornerRadius: 0.2 }) expect(useScene.getState().nodes[window.id]).toBe(window) expect(window.cornerRadius).toBe(0.3) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 7d23989659..4e89ee9225 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -94,6 +94,8 @@ export { csgEvaluator, csgGeometry, csgMaterials, + Evaluator, + ensureRenderableGeometryAttributes, INTERSECTION, prepareBrushForCSG, SUBTRACTION, @@ -280,25 +282,40 @@ export { type StairBodyMaterials, } from './systems/stair/stair-materials' export { StairSystem } from './systems/stair/stair-system' -export { curtainOpeningProfile, curtainProfileSpan } from './systems/wall/curtain-opening-frame' // Pure opening-cutout profile math shared by the wall CSG pipeline and // roof-wall opening cuts in `@pascal-app/nodes` — keeps shaped holes // (arch / rounded / frameless opening) identical across both hosts. export { buildOpeningCutoutGeometry, + buildOpeningCutoutShape, getOpeningCutoutBottomPadding, hasFlatOpeningCutoutBottom, } from './systems/wall/opening-cutout-geometry' export { getWallHideState, WallCutout } from './systems/wall/wall-cutout' -export { getVisibleWallMaterials } from './systems/wall/wall-materials' +export { + WallCutoutCache, + type WallCutoutViewerState, + type WallCutoutViewerStore, +} from './systems/wall/wall-cutout-cache' +export { + getMaterialsForWall, + getVisibleWallMaterials, + type WallMaterialOverride, + type WallMaterials, + type WallMaterialsResolver, +} from './systems/wall/wall-materials' // Wall internals re-exported so `@pascal-app/nodes`' registry-driven wall // definition can compose them into `def.system` without duplicating the // 800+ lines of CSG / mitering logic during Phase 3. These exports are // removed in Phase 6 when the legacy mount points are deleted. export { drainRebuiltWalls, + generateExtrudedWall, getPendingWallRebuildCount, isWallInitialBuildActive, + runWallBuildFrame, + type WallGeometryAdapter, + type WallGeometryAdapterContext, WallSystem, } from './systems/wall/wall-system' export { diff --git a/packages/viewer/src/lib/csg-utils.ts b/packages/viewer/src/lib/csg-utils.ts index 2a0b6adf53..f2a7974c7a 100644 --- a/packages/viewer/src/lib/csg-utils.ts +++ b/packages/viewer/src/lib/csg-utils.ts @@ -119,4 +119,4 @@ export function subtractCsgBrush(left: Brush, right: Brush, evaluator: Evaluator // Re-export Brush + SUBTRACTION + ADDITION + INTERSECTION so kinds don't need a // direct `three-bvh-csg` dependency. -export { ADDITION, Brush, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' +export { ADDITION, Brush, Evaluator, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' diff --git a/packages/viewer/src/systems/wall/curtain-wall-materials.ts b/packages/viewer/src/systems/wall/curtain-wall-materials.ts deleted file mode 100644 index bfd0fb228b..0000000000 --- a/packages/viewer/src/systems/wall/curtain-wall-materials.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - getCurtainWallConfig, - type SceneMaterial, - type SceneMaterialId, - type WallNode, -} from '@pascal-app/core' -import type { Material } from 'three' -import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' -import { type RenderShading, resolveMaterialRef } from '../../lib/materials' - -const ownedMaterials = new WeakSet() - -export function isOwnedCurtainWallMaterial(material: Material): boolean { - return ownedMaterials.has(material) -} - -export function createCurtainWallMaterials( - wall: WallNode, - shading: RenderShading, - materials?: Record, -): Material[] { - const config = getCurtainWallConfig(wall) - return ['frame', 'glass', 'solid'].map((role) => { - const ref = wall.slots?.[`curtain-${role}`] - const override = ref ? resolveMaterialRef(ref, materials, shading) : undefined - // Borrow the shared material so asynchronously loaded texture maps stay live. - if (override) return override - const glass = role === 'glass' - const color = glass - ? config.glassColor - : role === 'frame' - ? config.frameColor - : config.solidColor - const properties = { - color, - transparent: glass, - opacity: glass ? config.glassOpacity : 1, - depthWrite: !glass, - } - const material = - shading === 'solid' - ? new MeshLambertNodeMaterial(properties) - : new MeshStandardNodeMaterial({ - ...properties, - metalness: role === 'frame' ? 0.65 : 0, - roughness: glass ? config.glassRoughness : 0.4, - }) - ownedMaterials.add(material) - return material - }) -} diff --git a/packages/viewer/src/systems/wall/wall-cutout-cache.ts b/packages/viewer/src/systems/wall/wall-cutout-cache.ts index 39080a215f..0afba26fd1 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.ts @@ -24,6 +24,7 @@ import { getMaterialsForWall, getSelectionHighlightMaterials, type WallMaterials, + type WallMaterialsResolver, } from './wall-materials' export function sameMaterialArray(a: Material | Material[], b: Material[]): boolean { @@ -132,7 +133,10 @@ export class WallCutoutCache { private transformed = new Set() private overrides = useLiveNodeOverrides.getState().overrides - constructor(private readonly viewerStore: WallCutoutViewerStore = useViewer) {} + constructor( + private readonly viewerStore: WallCutoutViewerStore = useViewer, + private readonly materialResolver: WallMaterialsResolver = getMaterialsForWall, + ) {} subscribeLiveTransforms(): () => void { return useLiveTransforms.subscribe((state, previous) => { @@ -327,7 +331,7 @@ export class WallCutoutCache { ) selectionHighlighted = !getWallFaceBandConfig(node, height).enabled } - const materials = getMaterialsForWall( + const materials = this.materialResolver( node, viewer.shading, viewer.textures, diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 2e794d511d..d609832060 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -10,7 +10,11 @@ import { type WallCutoutViewerStore, wallHiddenFromFacing, } from './wall-cutout-cache' -import { getMaterialsForWall, getSelectionHighlightMaterials } from './wall-materials' +import { + getMaterialsForWall, + getSelectionHighlightMaterials, + type WallMaterialsResolver, +} from './wall-materials' import { subscribeWallRebuilds } from './wall-rebuild-notifications' const v = new Vector3() @@ -46,10 +50,15 @@ export function getWallHideState( export const WallCutout = ({ viewerStore = useViewer, + materialResolver = getMaterialsForWall, }: { viewerStore?: WallCutoutViewerStore + materialResolver?: WallMaterialsResolver }) => { - const cache = useMemo(() => new WallCutoutCache(viewerStore), [viewerStore]) + const cache = useMemo( + () => new WallCutoutCache(viewerStore, materialResolver), + [viewerStore, materialResolver], + ) useEffect(() => subscribeWallRebuilds((id) => cache.rebuilt.add(id)), [cache]) @@ -68,7 +77,7 @@ export const WallCutout = ({ if (!wallMesh) return const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined if (wallNode?.type !== 'wall') return - const mats = getMaterialsForWall( + const mats = materialResolver( wallNode, viewerStore.getState().shading, viewerStore.getState().textures, @@ -104,7 +113,7 @@ export const WallCutout = ({ emitter.off('thumbnail:before-capture', restoreForCapture) emitter.off('thumbnail:after-capture', reapplyAfterCapture) } - }, [viewerStore]) + }, [viewerStore, materialResolver]) return null } diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 9d7a522b4a..ee2f26fc1b 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -1,5 +1,4 @@ import { - getCurtainWallConfig, getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, @@ -31,8 +30,6 @@ import { resolveSurfaceColor, } from '../../lib/materials' -import { createCurtainWallMaterials, isOwnedCurtainWallMaterial } from './curtain-wall-materials' - type SceneMaterials = Record | undefined const DEFAULT_WALL_COLOR = '#e9e7e3' @@ -58,9 +55,23 @@ export interface WallMaterials { deleteInvisible: WallMaterialArray deleteTranslucent: WallMaterialArray materialHash: string - ownsVisible?: boolean + ownedVisible?: Material[] +} + +export type WallMaterialOverride = { + hash: string + create: () => { visible: WallMaterialArray; owned?: Material[] } } +export type WallMaterialsResolver = ( + wallNode: WallNode, + shading?: RenderShading, + textures?: boolean, + colorPreset?: ColorPreset, + sceneTheme?: string, + sceneMaterials?: SceneMaterials, +) => WallMaterials + const wallMaterialCache = new Map() const dotPattern = Fn(() => { @@ -452,30 +463,11 @@ export function getWallMaterialHash( wallNode: WallNode, shading: RenderShading, sceneMaterials?: SceneMaterials, + overrideHash?: string, ): string { - const curtain = getCurtainWallConfig(wallNode) return JSON.stringify({ shading, - wallType: wallNode.wallType, - curtainWall: - wallNode.wallType === 'curtain' - ? [ - curtain.frameColor, - curtain.glassColor, - curtain.solidColor, - curtain.glassOpacity, - curtain.glassRoughness, - ] - : undefined, - curtainSlots: - wallNode.wallType === 'curtain' - ? Object.fromEntries( - ['curtain-frame', 'curtain-glass', 'curtain-solid'].map((slot) => [ - slot, - wallSlotMaterialSignature(wallNode, slot as WallSurfaceSlotId, sceneMaterials), - ]), - ) - : undefined, + overrideHash, interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials), exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials), lowerInterior: wallSlotMaterialSignature(wallNode, 'lowerInterior', sceneMaterials), @@ -496,10 +488,11 @@ export function getMaterialsForWall( colorPreset: ColorPreset = 'clay', sceneTheme?: string, sceneMaterials?: SceneMaterials, + override?: WallMaterialOverride, ): WallMaterials { const cacheKey = `${wallNode.id}-${shading}-${textures}-${colorPreset}-${sceneTheme ?? 'base'}` const materialHash = textures - ? getWallMaterialHash(wallNode, shading, sceneMaterials) + ? getWallMaterialHash(wallNode, shading, sceneMaterials, override?.hash) : JSON.stringify({ textures, colorPreset, sceneTheme }) const existing = wallMaterialCache.get(cacheKey) @@ -509,7 +502,7 @@ export function getMaterialsForWall( if (existing) { disposeOwnedMaterials([ - ...(existing.ownsVisible ? [existing.visible.filter(isOwnedCurtainWallMaterial)] : []), + ...(existing.ownedVisible ? [existing.ownedVisible] : []), existing.invisible, existing.translucent, existing.deleteVisible, @@ -519,29 +512,29 @@ export function getMaterialsForWall( } const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme) + const resolvedOverride = textures ? override?.create() : undefined // Colored mode: each face resolves slot-first (node.slots ref → legacy inline // fields → declared slot default, parity with the retired DEFAULT_WALL_MATERIAL). // Textures-off collapses every face to the themed wall role (the guaranteed // escape hatch). The edge/cap slot (index 0) stays role-based. - const visible: WallMaterialArray = - textures && wallNode.wallType === 'curtain' - ? createCurtainWallMaterials(wallNode, shading, sceneMaterials) - : textures - ? [ - wallRoleMaterial, - resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials), - resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'lowerInterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'middleInterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'upperInterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'topInterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'lowerExterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'middleExterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'upperExterior', shading, sceneMaterials), - resolveWallSlotMaterial(wallNode, 'topExterior', shading, sceneMaterials), - ] - : Array.from({ length: 11 }, () => wallRoleMaterial) + const visible: WallMaterialArray = resolvedOverride + ? resolvedOverride.visible + : textures + ? [ + wallRoleMaterial, + resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials), + resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'lowerInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'middleInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'upperInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'topInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'lowerExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'middleExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'upperExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'topExterior', shading, sceneMaterials), + ] + : Array.from({ length: 11 }, () => wallRoleMaterial) const wallRoleColor = resolveSurfaceColor('wall', colorPreset, sceneTheme) const invisible: WallMaterialArray = [ @@ -624,7 +617,7 @@ export function getMaterialsForWall( deleteInvisible, deleteTranslucent, materialHash, - ownsVisible: textures && wallNode.wallType === 'curtain', + ownedVisible: resolvedOverride?.owned, } wallMaterialCache.set(cacheKey, result) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 7c4e350195..407cffc50d 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -42,8 +42,6 @@ import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../li import { setGroupsSortedByMaterial } from '../../lib/geometry-groups' import { timeSpan } from '../../lib/perf-tracks' import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' -import { buildCurtainWallGeometry } from './curtain-wall-geometry' -import { buildCurtainWallShadowGeometry, CURTAIN_WALL_SHADOW_NAME } from './curtain-wall-shadow' import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache' import { buildOpeningCutoutGeometry, @@ -667,15 +665,33 @@ export function getPendingWallRebuildCount(): number { let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL -export const WallSystem = () => { +export type WallGeometryAdapterContext = { + isLive: (id: AnyNodeId) => boolean +} + +export type WallGeometryAdapter = { + prepareChildren?: ( + wall: WallNode, + children: readonly AnyNode[], + context: WallGeometryAdapterContext, + ) => { envelopeChildren: AnyNode[]; renderChildren: AnyNode[] } + buildGeometry?: ( + wall: WallNode, + envelope: THREE.BufferGeometry, + children: readonly AnyNode[], + ) => THREE.BufferGeometry + syncAuxiliaryGeometry?: (wall: WallNode, mesh: THREE.Mesh, geometry: THREE.BufferGeometry) => void +} + +export const WallSystem = ({ geometryAdapter }: { geometryAdapter?: WallGeometryAdapter } = {}) => { useScene((state) => state.dirtyNodes) useLiveNodeOverrides((s) => s.overrides) useEffect(() => () => clearLevelMiterCache(), []) - useFrame(runWallBuildFrame, 4) + useFrame(() => runWallBuildFrame(geometryAdapter), 4) return null } -export function runWallBuildFrame() { +export function runWallBuildFrame(geometryAdapter?: WallGeometryAdapter) { const initialBuild = isWallInitialBuildActive() const token = useScene.getState().hydrationToken if (token !== stalledHydrationToken) { @@ -684,13 +700,13 @@ export function runWallBuildFrame() { } drainStats.wallsConsumedThisFrame = 0 try { - consumeWallBuildFrame(initialBuild) + consumeWallBuildFrame(initialBuild, geometryAdapter) } finally { publishWallDrainStats() } } -function consumeWallBuildFrame(initialBuild: boolean) { +function consumeWallBuildFrame(initialBuild: boolean, geometryAdapter?: WallGeometryAdapter) { const clearDirty = useScene.getState().clearDirty // Self-heal: any registered wall still on its mount-time placeholder // geometry with NO dirty mark gets re-marked, so a lost mark (system @@ -790,7 +806,7 @@ function consumeWallBuildFrame(initialBuild: boolean) { const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) { - timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { + timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData, geometryAdapter), { properties: [['node', wallId]], }) clearDirty(wallId as AnyNodeId) @@ -873,7 +889,7 @@ function consumeWallBuildFrame(initialBuild: boolean) { const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) { - timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { + timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData, geometryAdapter), { properties: [['node', wallId]], }) notifyWallRebuilt(wallId) @@ -964,7 +980,11 @@ function getLevelWalls(levelId: string): WallNode[] { * (override-merged) so a 2D drag visibly moves the 3D mesh without * having touched `useScene` mid-drag. */ -function updateWallGeometry(wallId: string, miterData: WallMiterData) { +function updateWallGeometry( + wallId: string, + miterData: WallMiterData, + geometryAdapter?: WallGeometryAdapter, +) { const nodes = useScene.getState().nodes const sceneNode = nodes[wallId as WallNode['id']] if (sceneNode?.type !== 'wall') return @@ -993,45 +1013,27 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { : undefined const childrenIds = node.children || [] - // Merge live overrides into door / window children so cutouts track an - // in-flight resize drag (door width arrow, window height arrow, etc.) - // without waiting on the scene store. Non-cutout children pass through - // unchanged. const childrenNodes = childrenIds .map((childId) => nodes[childId]) .filter((n): n is AnyNode => n !== undefined) .map((child) => { if (child.type !== 'door' && child.type !== 'window') return child - // `getEffectiveNode` folds in resize overrides (width/height arrows). - // Position moves publish to `useLiveTransforms` instead, so fold that - // in too — opening cutout brushes are rebuilt directly from the - // effective node position rather than from the rendered proxy mesh. const effective = getEffectiveNode(child) const live = useLiveTransforms.getState().get(child.id) - if (!live?.position) return effective - return { ...effective, position: live.position } + return live?.position ? { ...effective, position: live.position } : effective }) - const curtainGeometryChildren = - node.wallType === 'curtain' && !isCurvedWall(node) - ? childrenNodes.map((child) => { - if ( - (child.type !== 'door' && child.type !== 'window') || - child.openingShape === 'rectangle' - ) - return child - const isLive = - useLiveNodeOverrides.getState().get(child.id) !== undefined || - useLiveTransforms.getState().get(child.id) !== undefined - // Exact shaped CSG can exceed the frame budget; the committed rebuild restores the arch. - return isLive ? { ...child, openingShape: 'rectangle' as const } : child - }) - : childrenNodes + const prepared = geometryAdapter?.prepareChildren?.(node, childrenNodes, { + isLive: (id) => + useLiveNodeOverrides.getState().get(id) !== undefined || + useLiveTransforms.getState().get(id) !== undefined, + }) ?? { + envelopeChildren: childrenNodes, + renderChildren: childrenNodes, + } const builtGeo = generateExtrudedWall( node, - node.wallType === 'curtain' && !isCurvedWall(node) - ? curtainGeometryChildren.filter((child) => child.type !== 'door' && child.type !== 'window') - : childrenNodes, + prepared.envelopeChildren, miterData, slabElevation, slabSupport.baseElevation, @@ -1049,9 +1051,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { WALL_UV_UNIT_SCALE, ) const renderedGeo = - node.wallType === 'curtain' - ? buildCurtainWallGeometry(node, builtGeo, curtainGeometryChildren) - : builtGeo + geometryAdapter?.buildGeometry?.(node, builtGeo, prepared.renderChildren) ?? builtGeo const newGeo = applyWorldPlanarWallUVs(renderedGeo, wallWorldMatrix) mesh.geometry.dispose() @@ -1059,14 +1059,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { // as the mount-time placeholder; the stamp keeps the sweep from re-marking it. newGeo.userData.built = true mesh.geometry = newGeo - const shadowMesh = mesh.getObjectByName(CURTAIN_WALL_SHADOW_NAME) as THREE.Mesh | undefined - if (shadowMesh && node.wallType === 'curtain') { - shadowMesh.geometry.dispose() - shadowMesh.geometry = buildCurtainWallShadowGeometry( - newGeo, - node.curtainWall?.glassOpacity === 1, - ) - } + geometryAdapter?.syncAuxiliaryGeometry?.(node, mesh, newGeo) // Update collision mesh const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh if (collisionMesh) { From 4a30c2f4034501f6651a1373c290742782ca4014 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 14:27:17 +0530 Subject: [PATCH 06/21] docs: add curtain wall variations screenshot --- docs/images/curtain-wall-variations.png | Bin 0 -> 437827 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/curtain-wall-variations.png diff --git a/docs/images/curtain-wall-variations.png b/docs/images/curtain-wall-variations.png new file mode 100644 index 0000000000000000000000000000000000000000..f040afc6e0cb7e8b3eb58308785d00512d02599e GIT binary patch literal 437827 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%0DB_-!#YE8gOponGT5t<*$oXG|2=MOYvY0fE7s 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

!hlnr~YS^_R2t!Lm=^A6IjC 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 ^5wVdx?iJL8ndAczUCv*G7W& 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 z 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 euy5b#TfbSV*!I`L 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 Date: Tue, 22 Sep 2026 15:34:31 +0530 Subject: [PATCH 07/21] fix(walls): address curtain wall review findings --- packages/nodes/src/door/panel.tsx | 16 ++ .../shared/opening-documentation-fields.tsx | 199 ++++++++++++++++++ .../nodes/src/wall/curtain-opening-frame.ts | 35 ++- .../nodes/src/wall/curtain-wall-adapter.ts | 4 +- .../src/wall/curtain-wall-geometry.test.ts | 33 +++ .../nodes/src/wall/curtain-wall-geometry.ts | 13 +- .../nodes/src/wall/curtain-wall-panel.tsx | 17 +- .../nodes/src/wall/property-preview.test.ts | 10 + packages/nodes/src/window/panel.tsx | 16 ++ 9 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 packages/nodes/src/shared/opening-documentation-fields.tsx diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index e866b16e26..5f34dc367c 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -17,6 +17,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' +import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' import { scaleHandleHeight } from './door-math' @@ -555,6 +556,21 @@ export default function DoorPanel() { )} + + + + void +}) { + return ( +

+ +
+ + +
+
+ onChange({ roughOpeningWidth: value })} + value={roughOpeningWidth} + /> + onChange({ roughOpeningHeight: value })} + value={roughOpeningHeight} + /> +
+
+ onChange({ masonryOpeningWidth: value })} + value={masonryOpeningWidth} + /> + onChange({ masonryOpeningHeight: value })} + value={masonryOpeningHeight} + /> +
+
+ onChange({ finishOpeningWidth: value })} + value={finishOpeningWidth} + /> + onChange({ finishOpeningHeight: value })} + value={finishOpeningHeight} + /> +
+

+ Leave RO, MO, and FO values blank until verified by the applicable manufacturer or trade. +

+
+ ) +} + +function OptionalMeterInput({ + label, + value, + onChange, +}: { + label: string + value?: number + onChange: (value: number | undefined) => void +}) { + const unit = useViewer((state) => state.unit) + const displayValue = value === undefined ? '' : roundForInput(metersToLinearUnit(value, unit)) + + return ( + + ) +} + +function roundForInput(value: number): number { + return Math.round(value * 1000) / 1000 +} diff --git a/packages/nodes/src/wall/curtain-opening-frame.ts b/packages/nodes/src/wall/curtain-opening-frame.ts index 40fe6d43e7..169d282c2e 100644 --- a/packages/nodes/src/wall/curtain-opening-frame.ts +++ b/packages/nodes/src/wall/curtain-opening-frame.ts @@ -1,6 +1,37 @@ -import type { DoorNode, WindowNode } from '@pascal-app/core' +import { + type DoorNode, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + type WallNode, + type WindowNode, +} from '@pascal-app/core' import { buildOpeningCutoutShape, ensureRenderableGeometryAttributes } from '@pascal-app/viewer' -import { ExtrudeGeometry, Path, Shape, Vector2 } from 'three' +import { type BufferGeometry, ExtrudeGeometry, Path, Shape, Vector2 } from 'three' + +export function mapCurtainOpeningGeometryToWall(geometry: BufferGeometry, wall: WallNode) { + if (!isCurvedWall(wall)) return + const length = getWallCurveLength(wall) + if (length <= 1e-6) return + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const position = geometry.getAttribute('position') + for (let index = 0; index < position.count; index++) { + const along = position.getX(index) + const depth = position.getZ(index) + const clampedAlong = Math.max(0, Math.min(length, along)) + const frame = getWallCurveFrameAt(wall, clampedAlong / length) + const extension = along - clampedAlong + const x = frame.point.x + frame.tangent.x * extension + frame.normal.x * depth - wall.start[0] + const z = frame.point.y + frame.tangent.y * extension + frame.normal.y * depth - wall.start[1] + position.setXYZ(index, x * cos + z * sin, position.getY(index), -x * sin + z * cos) + } + position.needsUpdate = true + geometry.boundingBox = null + geometry.boundingSphere = null + geometry.computeVertexNormals() +} export function curtainOpeningProfile(opening: DoorNode | WindowNode, width: number) { const bottom = opening.position[1] - opening.height / 2 diff --git a/packages/nodes/src/wall/curtain-wall-adapter.ts b/packages/nodes/src/wall/curtain-wall-adapter.ts index fe4a12e9c4..cbcdcb8051 100644 --- a/packages/nodes/src/wall/curtain-wall-adapter.ts +++ b/packages/nodes/src/wall/curtain-wall-adapter.ts @@ -1,4 +1,4 @@ -import { isCurvedWall, type WallNode } from '@pascal-app/core' +import type { WallNode } from '@pascal-app/core' import type { WallGeometryAdapter } from '@pascal-app/viewer' import type { Mesh } from 'three' import { buildCurtainWallGeometry } from './curtain-wall-geometry' @@ -6,7 +6,7 @@ import { buildCurtainWallShadowGeometry, CURTAIN_WALL_SHADOW_NAME } from './curt export const curtainWallGeometryAdapter: WallGeometryAdapter = { prepareChildren(wall, children, context) { - if (wall.wallType !== 'curtain' || isCurvedWall(wall)) { + if (wall.wallType !== 'curtain') { return { envelopeChildren: [...children], renderChildren: [...children] } } const renderChildren = children.map((child) => { diff --git a/packages/nodes/src/wall/curtain-wall-geometry.test.ts b/packages/nodes/src/wall/curtain-wall-geometry.test.ts index 7c853f877f..2d1da90f7c 100644 --- a/packages/nodes/src/wall/curtain-wall-geometry.test.ts +++ b/packages/nodes/src/wall/curtain-wall-geometry.test.ts @@ -3,11 +3,14 @@ import { CurtainWallConfig, calculateLevelMiters, DoorNode, + getWallCurveLength, sceneRegistry, WallNode, + WindowNode, } from '@pascal-app/core' import { generateExtrudedWall } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { curtainWallGeometryAdapter } from './curtain-wall-adapter' import { buildCurtainWallGeometry } from './curtain-wall-geometry' import { getCurtainAwareWallMaterials } from './curtain-wall-materials' @@ -104,6 +107,36 @@ describe('curtain wall geometry', () => { expect(Array.from(geometry.getAttribute('position').array).every(Number.isFinite)).toBe(true) geometry.dispose() }) + test('curved walls bend shaped opening frames and cutters with the curtain surface', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + curveOffset: 1, + height: 3, + thickness: 0.15, + wallType: 'curtain', + }) + const window = WindowNode.parse({ + position: [getWallCurveLength(wall) / 2, 1.5, 0], + width: 1, + height: 1, + openingShape: 'arch', + archHeight: 0.4, + }) + const prepared = curtainWallGeometryAdapter.prepareChildren!(wall, [window], { + isLive: () => false, + }) + expect(prepared.envelopeChildren).toEqual([]) + const geometry = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, prepared.envelopeChildren, calculateLevelMiters([wall])), + prepared.renderChildren, + ) + const mesh = new Mesh(geometry, [material, material, material]) + expect(hit(mesh, 2, 1.5)).toHaveLength(0) + expect(hit(mesh, 2, 0.5).length).toBeGreaterThan(0) + geometry.dispose() + }) test('material cache changes with curtain settings and wall type', () => { const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain' }) const first = getCurtainAwareWallMaterials(wall) diff --git a/packages/nodes/src/wall/curtain-wall-geometry.ts b/packages/nodes/src/wall/curtain-wall-geometry.ts index 839f8162ad..c4938d289b 100644 --- a/packages/nodes/src/wall/curtain-wall-geometry.ts +++ b/packages/nodes/src/wall/curtain-wall-geometry.ts @@ -17,7 +17,7 @@ import { } from '@pascal-app/viewer' import { BufferGeometry, ExtrudeGeometry, Shape } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' -import { buildCurtainOpeningFrame } from './curtain-opening-frame' +import { buildCurtainOpeningFrame, mapCurtainOpeningGeometryToWall } from './curtain-opening-frame' import { buildCurtainWallLayout, type CurtainWallPiece, @@ -84,13 +84,16 @@ export function buildCurtainWallGeometry( (child) => (child.type === 'window' || child.type === 'door') && child.openingShape !== 'rectangle', ) - .map((opening) => - buildCurtainOpeningFrame( + .map((opening) => { + const geometry = buildCurtainOpeningFrame( opening as Extract, getCurtainWallConfig(wall).perimeterWidth, getWallThickness(wall), - ), - ) + ) + mapCurtainOpeningGeometryToWall(geometry.frame, wall) + mapCurtainOpeningGeometryToWall(geometry.cutter, wall) + return geometry + }) const hasRectangularOpenings = children.some( (child) => (child.type === 'window' || child.type === 'door') && child.openingShape === 'rectangle', diff --git a/packages/nodes/src/wall/curtain-wall-panel.tsx b/packages/nodes/src/wall/curtain-wall-panel.tsx index ab1ebb176e..970a70a982 100644 --- a/packages/nodes/src/wall/curtain-wall-panel.tsx +++ b/packages/nodes/src/wall/curtain-wall-panel.tsx @@ -5,6 +5,7 @@ import { type CurtainPanelType, type CurtainWallConfig, getCurtainWallConfig, + getEffectiveNode, getWallCurveLength, getWallThickness, type WallNode, @@ -202,6 +203,15 @@ const PANEL_OPTIONS = [ { label: 'Empty', value: 'empty' }, ] as const +export function getCurtainWallUpdate( + node: WallNode, + patch: Partial, +): Pick { + return { + curtainWall: { ...getCurtainWallConfig(getEffectiveNode(node)), ...patch }, + } +} + export function CurtainWallPanel({ node, height, @@ -213,7 +223,7 @@ export function CurtainWallPanel({ }: Props) { const config = getCurtainWallConfig(node) const update = (patch: Partial, preview = false) => { - ;(preview ? onPreview : onUpdate)({ curtainWall: { ...config, ...patch } }) + ;(preview ? onPreview : onUpdate)(getCurtainWallUpdate(node, patch)) } const [tintEditing, setTintEditing] = useState(false) @@ -603,6 +613,11 @@ export function CurtainWallPanel({ setTintEditing(true) update({ glassColor: event.target.value }, true) }} + onBlur={() => { + if (!tintEditing) return + onCommit() + setTintEditing(false) + }} onInput={(event) => { setTintEditing(true) update({ glassColor: event.currentTarget.value }, true) diff --git a/packages/nodes/src/wall/property-preview.test.ts b/packages/nodes/src/wall/property-preview.test.ts index 6ba942fe78..760af97062 100644 --- a/packages/nodes/src/wall/property-preview.test.ts +++ b/packages/nodes/src/wall/property-preview.test.ts @@ -7,6 +7,7 @@ import { useScene, WallNode, } from '@pascal-app/core' +import { getCurtainWallUpdate } from './curtain-wall-panel' import { createWallPropertyPreview } from './property-preview' globalThis.requestAnimationFrame ??= (callback) => { @@ -73,3 +74,12 @@ test('a preview cleared by history cannot be recommitted by a later release', () expect(useScene.getState().nodes[wall.id]).toBe(wall) expect(useScene.temporal.getState().pastStates).toHaveLength(0) }) + +test('a discrete curtain setting preserves an in-flight curtain preview', () => { + const { wall, edit } = setup() + edit.preview(getCurtainWallUpdate(wall, { glassColor: '#123456' })) + edit.commit(getCurtainWallUpdate(wall, { framing: 'structural-glazing' })) + const committed = useScene.getState().nodes[wall.id] as WallNode + expect(committed.curtainWall?.glassColor).toBe('#123456') + expect(committed.curtainWall?.framing).toBe('structural-glazing') +}) diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index bbb3973069..33f3b0b588 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -23,6 +23,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' +import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' @@ -367,6 +368,21 @@ export default function WindowPanel() { />
+ + + + {showWindowTypeSection && (
From 6805a16e5bb59beab734f796bab34eca0b3ed266 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 15:39:20 +0530 Subject: [PATCH 08/21] test(core): make draft snapshot test self-contained --- packages/core/src/store/draft-attachment-snapshot.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/store/draft-attachment-snapshot.test.ts b/packages/core/src/store/draft-attachment-snapshot.test.ts index 7a808dbc95..2b944548fb 100644 --- a/packages/core/src/store/draft-attachment-snapshot.test.ts +++ b/packages/core/src/store/draft-attachment-snapshot.test.ts @@ -5,6 +5,12 @@ import type { AnyNode, AnyNodeId } from '../schema/types' import { subscribeSceneCommits } from './history-control' import useScene from './use-scene' +globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} + test('draft snapshots do not enumerate unaffected procedural attachment maps', () => { const saved = useScene.getState() const level = LevelNode.parse({}) From de0d0266334f41f60305530e3e0890072a19250f Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 15:52:58 +0530 Subject: [PATCH 09/21] fix(walls): preserve opening previews and painted shadows --- .../src/shared/curtain-opening-limits.test.ts | 16 +++++++++ .../src/shared/opening-property-preview.ts | 4 ++- .../nodes/src/wall/curtain-wall-adapter.ts | 12 ++++--- .../src/wall/curtain-wall-shadow.test.ts | 33 +++++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/nodes/src/shared/curtain-opening-limits.test.ts b/packages/nodes/src/shared/curtain-opening-limits.test.ts index e6bb2e3b3d..90c222fc50 100644 --- a/packages/nodes/src/shared/curtain-opening-limits.test.ts +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -71,6 +71,22 @@ test('door drag writes no scene history until commit and undoes in one step', () expect((useScene.getState().nodes[door.id] as DoorNode).width).toBeCloseTo(2.04) }) +test('a second property preview constrains against the in-flight opening', () => { + const window = WindowNode.parse({ + parentId: wall.id, + position: [5, 1.25, 0], + width: 1, + height: 1, + }) + useScene.setState({ nodes: { ...nodes, [window.id]: window }, dirtyNodes: new Set() }) + const preview = createOpeningPropertyPreview(window.id, openingPropertyPreviewHost) + preview.preview({ width: 1.8 }) + preview.preview({ height: 1.4 }) + expect(getEffectiveNode(window)).toMatchObject({ width: 1.8, height: 1.4 }) + preview.commit() + expect(useScene.getState().nodes[window.id]).toMatchObject({ width: 1.8, height: 1.4 }) +}) + test('cancel and a history-cleared preview cannot be committed by a stale release', () => { useScene.setState({ nodes, dirtyNodes: new Set() }) const preview = createOpeningPropertyPreview(door.id, openingPropertyPreviewHost) diff --git a/packages/nodes/src/shared/opening-property-preview.ts b/packages/nodes/src/shared/opening-property-preview.ts index 73a1f43afc..a11265d96d 100644 --- a/packages/nodes/src/shared/opening-property-preview.ts +++ b/packages/nodes/src/shared/opening-property-preview.ts @@ -31,7 +31,9 @@ export function createOpeningPropertyPreview( const nodes = dependencies.nodes() const node = nodes[id] if (node?.type !== 'door' && node?.type !== 'window') return - patch = constrainCurtainOpening(node as T, patch, nodes) + const live = dependencies.override(id) + const effective = live ? ({ ...node, ...live } as T) : (node as T) + patch = constrainCurtainOpening(effective, patch, nodes) pending = { ...pending, ...patch } dependencies.setOverride(id, patch as Partial) dirty() diff --git a/packages/nodes/src/wall/curtain-wall-adapter.ts b/packages/nodes/src/wall/curtain-wall-adapter.ts index cbcdcb8051..9b5224eb87 100644 --- a/packages/nodes/src/wall/curtain-wall-adapter.ts +++ b/packages/nodes/src/wall/curtain-wall-adapter.ts @@ -1,9 +1,13 @@ import type { WallNode } from '@pascal-app/core' import type { WallGeometryAdapter } from '@pascal-app/viewer' -import type { Mesh } from 'three' +import type { Material, Mesh } from 'three' import { buildCurtainWallGeometry } from './curtain-wall-geometry' import { buildCurtainWallShadowGeometry, CURTAIN_WALL_SHADOW_NAME } from './curtain-wall-shadow' +function isOpaque(material: Material | undefined) { + return material !== undefined && (!material.transparent || material.opacity >= 1) +} + export const curtainWallGeometryAdapter: WallGeometryAdapter = { prepareChildren(wall, children, context) { if (wall.wallType !== 'curtain') { @@ -30,10 +34,8 @@ export const curtainWallGeometryAdapter: WallGeometryAdapter = { if (wall.wallType !== 'curtain') return const shadowMesh = mesh.getObjectByName(CURTAIN_WALL_SHADOW_NAME) as Mesh | undefined if (!shadowMesh) return + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] shadowMesh.geometry.dispose() - shadowMesh.geometry = buildCurtainWallShadowGeometry( - geometry, - wall.curtainWall?.glassOpacity === 1, - ) + shadowMesh.geometry = buildCurtainWallShadowGeometry(geometry, isOpaque(materials[1])) }, } diff --git a/packages/nodes/src/wall/curtain-wall-shadow.test.ts b/packages/nodes/src/wall/curtain-wall-shadow.test.ts index e94d2e7883..5c38f3d891 100644 --- a/packages/nodes/src/wall/curtain-wall-shadow.test.ts +++ b/packages/nodes/src/wall/curtain-wall-shadow.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'bun:test' import { calculateLevelMiters, WallNode } from '@pascal-app/core' import { generateExtrudedWall } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { curtainWallGeometryAdapter } from './curtain-wall-adapter' import { buildCurtainWallGeometry } from './curtain-wall-geometry' import { buildCurtainWallShadowGeometry } from './curtain-wall-shadow' @@ -35,3 +36,35 @@ test('shadow geometry blocks frames and spandrels but leaves transparent pane op opaque.dispose() material.dispose() }) + +test('painted opaque glass casts a pane shadow', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [6, 0], + height: 3, + thickness: 0.15, + wallType: 'curtain', + }) + const visible = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + ) + const frame = new MeshBasicMaterial() + const glass = new MeshBasicMaterial({ opacity: 1, transparent: false }) + const solid = new MeshBasicMaterial() + const mesh = new Mesh(visible, [frame, glass, solid]) + const shadow = new Mesh(buildCurtainWallShadowGeometry(visible, false), new MeshBasicMaterial()) + shadow.name = 'curtain-wall-shadow' + mesh.add(shadow) + curtainWallGeometryAdapter.syncAuxiliaryGeometry!(wall, mesh, visible) + const hit = new Raycaster(new Vector3(0.75, 2.25, 2), new Vector3(0, 0, -1)).intersectObject( + shadow, + ).length + expect(hit).toBeGreaterThan(0) + shadow.geometry.dispose() + ;(shadow.material as MeshBasicMaterial).dispose() + visible.dispose() + frame.dispose() + glass.dispose() + solid.dispose() +}) From c40e4f3347da46a4fdb700cb399135ca6014139d Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 16:03:58 +0530 Subject: [PATCH 10/21] fix(walls): constrain curtain opening interactions --- packages/nodes/src/door/door-math.ts | 5 ++-- .../src/shared/curtain-opening-limits.test.ts | 9 ++++++ .../nodes/src/wall/curtain-wall-adapter.ts | 8 +++-- .../src/wall/curtain-wall-geometry.test.ts | 30 +++++++++++++++++++ packages/nodes/src/window/window-math.ts | 7 +++-- 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index b5517c9dcf..3bdf3fa97e 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,4 +1,4 @@ -import type { WallNode } from '@pascal-app/core' +import { getCurtainWallConfig, type WallNode } from '@pascal-app/core' /** * Keep the door handle at the same relative height when the door is resized: @@ -50,8 +50,9 @@ export function clampToWall( const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] const wallLength = Math.sqrt(dx * dx + dz * dz) + const margin = wallNode.wallType === 'curtain' ? getCurtainWallConfig(wallNode).perimeterWidth : 0 - const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + const clampedX = Math.max(margin + width / 2, Math.min(wallLength - margin - width / 2, localX)) const clampedY = height / 2 // Doors always sit at floor level return { clampedX, clampedY } } diff --git a/packages/nodes/src/shared/curtain-opening-limits.test.ts b/packages/nodes/src/shared/curtain-opening-limits.test.ts index 90c222fc50..0aba05272f 100644 --- a/packages/nodes/src/shared/curtain-opening-limits.test.ts +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -7,6 +7,8 @@ import { WallNode, WindowNode, } from '@pascal-app/core' +import { clampToWall as clampDoorToWall } from '../door/door-math' +import { clampToWall as clampWindowToWall } from '../window/window-math' import { constrainCurtainOpening, curtainOpeningLimits } from './curtain-opening-limits' import { createOpeningPropertyPreview } from './opening-property-preview' import { openingPropertyPreviewHost } from './opening-property-preview-host' @@ -43,6 +45,13 @@ test('window grows about its center and position edits keep all four surrounds i expect(result.height).toBe(1) }) +test('door and window placement keep the curtain perimeter surround clear', () => { + const doorPosition = clampDoorToWall(wall, 0, 1, 2) + const windowPosition = clampWindowToWall(wall, 0, 0, 1, 1, nodes) + expect(doorPosition).toEqual({ clampedX: 0.55, clampedY: 1 }) + expect(windowPosition).toEqual({ clampedX: 0.55, clampedY: 0.55 }) +}) + test('non-curtain hosts retain their existing sizing policy', () => { const patch = { width: 20 } expect( diff --git a/packages/nodes/src/wall/curtain-wall-adapter.ts b/packages/nodes/src/wall/curtain-wall-adapter.ts index 9b5224eb87..cd12a9cdee 100644 --- a/packages/nodes/src/wall/curtain-wall-adapter.ts +++ b/packages/nodes/src/wall/curtain-wall-adapter.ts @@ -1,4 +1,4 @@ -import type { WallNode } from '@pascal-app/core' +import { useLiveNodeOverrides, type WallNode } from '@pascal-app/core' import type { WallGeometryAdapter } from '@pascal-app/viewer' import type { Material, Mesh } from 'three' import { buildCurtainWallGeometry } from './curtain-wall-geometry' @@ -16,7 +16,11 @@ export const curtainWallGeometryAdapter: WallGeometryAdapter = { const renderChildren = children.map((child) => { if ((child.type !== 'door' && child.type !== 'window') || child.openingShape === 'rectangle') return child - return context.isLive(child.id) ? { ...child, openingShape: 'rectangle' as const } : child + const override = useLiveNodeOverrides.getState().get(child.id) + const liveBounds = + context.isLive(child.id) && + (!override || 'position' in override || 'width' in override || 'height' in override) + return liveBounds ? { ...child, openingShape: 'rectangle' as const } : child }) return { envelopeChildren: renderChildren.filter( diff --git a/packages/nodes/src/wall/curtain-wall-geometry.test.ts b/packages/nodes/src/wall/curtain-wall-geometry.test.ts index 2d1da90f7c..6706e55836 100644 --- a/packages/nodes/src/wall/curtain-wall-geometry.test.ts +++ b/packages/nodes/src/wall/curtain-wall-geometry.test.ts @@ -3,8 +3,10 @@ import { CurtainWallConfig, calculateLevelMiters, DoorNode, + getEffectiveNode, getWallCurveLength, sceneRegistry, + useLiveNodeOverrides, WallNode, WindowNode, } from '@pascal-app/core' @@ -137,6 +139,34 @@ describe('curtain wall geometry', () => { expect(hit(mesh, 2, 0.5).length).toBeGreaterThan(0) geometry.dispose() }) + test('live shape settings retain their shaped curtain opening preview', () => { + const wall = WallNode.parse({ start: [0, 0], end: [4, 0], wallType: 'curtain' }) + const window = WindowNode.parse({ + openingShape: 'rounded', + position: [2, 1.5, 0], + width: 1, + height: 1, + }) + try { + useLiveNodeOverrides.getState().set(window.id, { cornerRadius: 0.3 }) + const shaped = curtainWallGeometryAdapter.prepareChildren!(wall, [getEffectiveNode(window)], { + isLive: () => true, + }) + expect(shaped.renderChildren[0]).toMatchObject({ + openingShape: 'rounded', + cornerRadius: 0.3, + }) + useLiveNodeOverrides.getState().set(window.id, { width: 1.5 }) + const resized = curtainWallGeometryAdapter.prepareChildren!( + wall, + [getEffectiveNode(window)], + { isLive: () => true }, + ) + expect(resized.renderChildren[0]).toMatchObject({ openingShape: 'rectangle', width: 1.5 }) + } finally { + useLiveNodeOverrides.getState().clear(window.id) + } + }) test('material cache changes with curtain settings and wall type', () => { const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain' }) const first = getCurtainAwareWallMaterials(wall) diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index 08ff4cb329..b9d9b44d86 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,4 +1,4 @@ -import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, getCurtainWallConfig, type WallNode } from '@pascal-app/core' import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** @@ -54,9 +54,10 @@ export function clampToWall( const dz = wallNode.end[1] - wallNode.start[1] const wallLength = Math.sqrt(dx * dx + dz * dz) const wallHeight = resolveWallOpeningCeiling(wallNode, nodes) + const margin = wallNode.wallType === 'curtain' ? getCurtainWallConfig(wallNode).perimeterWidth : 0 - const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) - const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) + const clampedX = Math.max(margin + width / 2, Math.min(wallLength - margin - width / 2, localX)) + const clampedY = Math.max(margin + height / 2, Math.min(wallHeight - margin - height / 2, localY)) return { clampedX, clampedY } } From c45bd319a953e0806063b855684873fc3f881d65 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 16:12:08 +0530 Subject: [PATCH 11/21] fix(walls): clamp openings along curved hosts --- packages/nodes/src/door/door-math.ts | 6 ++---- .../src/shared/curtain-opening-limits.test.ts | 15 +++++++++++++++ packages/nodes/src/window/window-math.ts | 12 ++++++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index 3bdf3fa97e..bdcd38925c 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,4 +1,4 @@ -import { getCurtainWallConfig, type WallNode } from '@pascal-app/core' +import { getCurtainWallConfig, getWallCurveLength, type WallNode } from '@pascal-app/core' /** * Keep the door handle at the same relative height when the door is resized: @@ -47,9 +47,7 @@ export function clampToWall( width: number, height: number, ): { clampedX: number; clampedY: number } { - const dx = wallNode.end[0] - wallNode.start[0] - const dz = wallNode.end[1] - wallNode.start[1] - const wallLength = Math.sqrt(dx * dx + dz * dz) + const wallLength = getWallCurveLength(wallNode) const margin = wallNode.wallType === 'curtain' ? getCurtainWallConfig(wallNode).perimeterWidth : 0 const clampedX = Math.max(margin + width / 2, Math.min(wallLength - margin - width / 2, localX)) diff --git a/packages/nodes/src/shared/curtain-opening-limits.test.ts b/packages/nodes/src/shared/curtain-opening-limits.test.ts index 0aba05272f..644e27c8a7 100644 --- a/packages/nodes/src/shared/curtain-opening-limits.test.ts +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from 'bun:test' import { DoorNode, getEffectiveNode, + getWallCurveLength, useLiveNodeOverrides, useScene, WallNode, @@ -52,6 +53,20 @@ test('door and window placement keep the curtain perimeter surround clear', () = expect(windowPosition).toEqual({ clampedX: 0.55, clampedY: 0.55 }) }) +test('placement uses curved curtain arc length at the far perimeter', () => { + const curvedWall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + curveOffset: 1, + height: 2.5, + wallType: 'curtain', + }) + const curvedNodes = { [curvedWall.id]: curvedWall } + const expectedX = getWallCurveLength(curvedWall) - 0.55 + expect(clampDoorToWall(curvedWall, 100, 1, 2).clampedX).toBeCloseTo(expectedX) + expect(clampWindowToWall(curvedWall, 100, 1, 1, 1, curvedNodes).clampedX).toBeCloseTo(expectedX) +}) + test('non-curtain hosts retain their existing sizing policy', () => { const patch = { width: 20 } expect( diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index b9d9b44d86..b0aff8f2b7 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,4 +1,10 @@ -import { type AnyNode, type AnyNodeId, getCurtainWallConfig, type WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + getCurtainWallConfig, + getWallCurveLength, + type WallNode, +} from '@pascal-app/core' import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** @@ -50,9 +56,7 @@ export function clampToWall( height: number, nodes: Readonly>, ): { clampedX: number; clampedY: number } { - const dx = wallNode.end[0] - wallNode.start[0] - const dz = wallNode.end[1] - wallNode.start[1] - const wallLength = Math.sqrt(dx * dx + dz * dz) + const wallLength = getWallCurveLength(wallNode) const wallHeight = resolveWallOpeningCeiling(wallNode, nodes) const margin = wallNode.wallType === 'curtain' ? getCurtainWallConfig(wallNode).perimeterWidth : 0 From ed53583b99780a3b54d900c8eee08782cac2ebad Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 16:22:44 +0530 Subject: [PATCH 12/21] fix(walls): project curved opening resize to arc --- packages/nodes/src/door/floorplan-affordances.ts | 14 ++++---------- .../src/shared/curtain-opening-limits.test.ts | 6 ++++++ packages/nodes/src/shared/wall-attach-target.ts | 7 +++++++ packages/nodes/src/window/floorplan-affordances.ts | 12 ++++-------- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/nodes/src/door/floorplan-affordances.ts b/packages/nodes/src/door/floorplan-affordances.ts index f25b7156af..66cd29f0c5 100644 --- a/packages/nodes/src/door/floorplan-affordances.ts +++ b/packages/nodes/src/door/floorplan-affordances.ts @@ -3,11 +3,13 @@ import { type DoorNode, type FloorplanAffordance, type FloorplanAffordanceSession, + getWallCurveLength, useLiveNodeOverrides, useScene, type WallNode, } from '@pascal-app/core' import { curtainOpeningLimits } from '../shared/curtain-opening-limits' +import { projectPlanPointToWallLocalX } from '../shared/wall-attach-target' const MIN_DOOR_WIDTH = 0.3 @@ -46,15 +48,7 @@ export const doorWidthAffordance: FloorplanAffordance = { const anchorX = side === 'end' ? initialDoorX - initialWidth / 2 : initialDoorX + initialWidth / 2 - // Wall axis (level-local) for projecting pointer movement to a - // wall-local X delta. - const wallStart: readonly [number, number] = wall ? wall.start : [0, 0] - const wallEnd: readonly [number, number] = wall ? wall.end : [1, 0] - const dx = wallEnd[0] - wallStart[0] - const dz = wallEnd[1] - wallStart[1] - const wallLength = Math.hypot(dx, dz) || 1 - const dirX = dx / wallLength - const dirZ = dz / wallLength + const wallLength = wall ? getWallCurveLength(wall) : 1 // Max width keeps the dragged edge inside the wall span. With the // anchor fixed, the moving edge is `anchorX ± width`, so the largest @@ -65,7 +59,7 @@ export const doorWidthAffordance: FloorplanAffordance = { growDir > 0 ? (limits?.length ?? wallLength) - margin - anchorX : anchorX - margin const projectToWallLocalX = (planPoint: readonly [number, number]) => { - return (planPoint[0] - wallStart[0]) * dirX + (planPoint[1] - wallStart[1]) * dirZ + return wall ? projectPlanPointToWallLocalX(wall, planPoint) : planPoint[0] } // Initial pointer in wall-local X — anchored to where the user diff --git a/packages/nodes/src/shared/curtain-opening-limits.test.ts b/packages/nodes/src/shared/curtain-opening-limits.test.ts index 644e27c8a7..6a4dc16e68 100644 --- a/packages/nodes/src/shared/curtain-opening-limits.test.ts +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from 'bun:test' import { DoorNode, getEffectiveNode, + getWallCurveFrameAt, getWallCurveLength, useLiveNodeOverrides, useScene, @@ -13,6 +14,7 @@ import { clampToWall as clampWindowToWall } from '../window/window-math' import { constrainCurtainOpening, curtainOpeningLimits } from './curtain-opening-limits' import { createOpeningPropertyPreview } from './opening-property-preview' import { openingPropertyPreviewHost } from './opening-property-preview-host' +import { projectPlanPointToWallLocalX } from './wall-attach-target' globalThis.requestAnimationFrame ??= (callback) => { callback(0) @@ -65,6 +67,10 @@ test('placement uses curved curtain arc length at the far perimeter', () => { const expectedX = getWallCurveLength(curvedWall) - 0.55 expect(clampDoorToWall(curvedWall, 100, 1, 2).clampedX).toBeCloseTo(expectedX) expect(clampWindowToWall(curvedWall, 100, 1, 1, 1, curvedNodes).clampedX).toBeCloseTo(expectedX) + const pointer = getWallCurveFrameAt(curvedWall, 0.75).point + expect(projectPlanPointToWallLocalX(curvedWall, [pointer.x, pointer.y])).toBeCloseTo( + getWallCurveLength(curvedWall) * 0.75, + ) }) test('non-curtain hosts retain their existing sizing policy', () => { diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 3c16647d1d..09a3490183 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -213,6 +213,13 @@ export function resolveWallAttachmentAtPlanPoint( } } +export function projectPlanPointToWallLocalX( + wall: WallNode, + planPoint: readonly [number, number], +): number { + return resolveWallAttachmentAtPlanPoint(wall, planPoint, Number.POSITIVE_INFINITY)?.localX ?? 0 +} + /** * Return the closest wall attachment target in plan space, including curved * walls. This is deliberately separate from `findClosestWallInPlan`: doors, diff --git a/packages/nodes/src/window/floorplan-affordances.ts b/packages/nodes/src/window/floorplan-affordances.ts index c811cc069b..7a28ee92a9 100644 --- a/packages/nodes/src/window/floorplan-affordances.ts +++ b/packages/nodes/src/window/floorplan-affordances.ts @@ -2,12 +2,14 @@ import { type AnyNodeId, type FloorplanAffordance, type FloorplanAffordanceSession, + getWallCurveLength, useLiveNodeOverrides, useScene, type WallNode, type WindowNode, } from '@pascal-app/core' import { curtainOpeningLimits } from '../shared/curtain-opening-limits' +import { projectPlanPointToWallLocalX } from '../shared/wall-attach-target' const MIN_WINDOW_WIDTH = 0.3 @@ -44,13 +46,7 @@ export const windowWidthAffordance: FloorplanAffordance = { const anchorX = side === 'end' ? initialWindowX - initialWidth / 2 : initialWindowX + initialWidth / 2 - const wallStart: readonly [number, number] = wall ? wall.start : [0, 0] - const wallEnd: readonly [number, number] = wall ? wall.end : [1, 0] - const dx = wallEnd[0] - wallStart[0] - const dz = wallEnd[1] - wallStart[1] - const wallLength = Math.hypot(dx, dz) || 1 - const dirX = dx / wallLength - const dirZ = dz / wallLength + const wallLength = wall ? getWallCurveLength(wall) : 1 const limits = curtainOpeningLimits(node, nodes) const margin = limits?.margin ?? 0 @@ -58,7 +54,7 @@ export const windowWidthAffordance: FloorplanAffordance = { growDir > 0 ? (limits?.length ?? wallLength) - margin - anchorX : anchorX - margin const projectToWallLocalX = (planPoint: readonly [number, number]) => { - return (planPoint[0] - wallStart[0]) * dirX + (planPoint[1] - wallStart[1]) * dirZ + return wall ? projectPlanPointToWallLocalX(wall, planPoint) : planPoint[0] } const initialPointerLocalX = projectToWallLocalX(initialPlanPoint) From 97c3c5ea48e1e72c94a8ca14b07ce2a4640ab1c4 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 16:32:28 +0530 Subject: [PATCH 13/21] fix(walls): recover openings inside curtain perimeter --- .../src/shared/curtain-opening-limits.test.ts | 17 +++++++++++++++++ .../nodes/src/shared/curtain-opening-limits.ts | 14 ++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/nodes/src/shared/curtain-opening-limits.test.ts b/packages/nodes/src/shared/curtain-opening-limits.test.ts index 6a4dc16e68..3b162b0883 100644 --- a/packages/nodes/src/shared/curtain-opening-limits.test.ts +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -117,6 +117,23 @@ test('a second property preview constrains against the in-flight opening', () => expect(useScene.getState().nodes[window.id]).toMatchObject({ width: 1.8, height: 1.4 }) }) +test('editing an opening inside the perimeter moves it back into a legal position', () => { + const flushDoor = DoorNode.parse({ + parentId: wall.id, + position: [0, 1.05, 0], + width: 0.9, + }) + useScene.setState({ nodes: { ...nodes, [flushDoor.id]: flushDoor }, dirtyNodes: new Set() }) + const preview = createOpeningPropertyPreview(flushDoor.id, openingPropertyPreviewHost) + preview.preview({ width: 1 }) + expect(getEffectiveNode(flushDoor)).toMatchObject({ position: [0.55, 1.05, 0], width: 1 }) + preview.commit() + expect(useScene.getState().nodes[flushDoor.id]).toMatchObject({ + position: [0.55, 1.05, 0], + width: 1, + }) +}) + test('cancel and a history-cleared preview cannot be committed by a stale release', () => { useScene.setState({ nodes, dirtyNodes: new Set() }) const preview = createOpeningPropertyPreview(door.id, openingPropertyPreviewHost) diff --git a/packages/nodes/src/shared/curtain-opening-limits.ts b/packages/nodes/src/shared/curtain-opening-limits.ts index 3402e26771..62f300e566 100644 --- a/packages/nodes/src/shared/curtain-opening-limits.ts +++ b/packages/nodes/src/shared/curtain-opening-limits.ts @@ -68,14 +68,12 @@ export function constrainCurtainOpening( const next = { ...opening, ...patch } const moving = patch.position !== undefined && patch.width === undefined && patch.height === undefined - const width = Math.min( - Math.max(0.01, next.width), - moving ? limits.length - 2 * limits.margin : limits.width, - ) - const height = Math.min( - Math.max(0.01, next.height), - moving ? limits.top - limits.bottom : limits.height, - ) + const availableWidth = Math.max(0, limits.length - 2 * limits.margin) + const availableHeight = Math.max(0, limits.top - limits.bottom) + const widthLimit = moving || limits.width < 0.01 ? availableWidth : limits.width + const heightLimit = moving || limits.height < 0.01 ? availableHeight : limits.height + const width = Math.min(Math.max(0.01, next.width), widthLimit) + const height = Math.min(Math.max(0.01, next.height), heightLimit) if (width < 0.01 || height < 0.01) return {} const x = Math.max( limits.margin + width / 2, From 5aaefa82762d875ac64fbe349e71d5e6e717088d Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 16:48:33 +0530 Subject: [PATCH 14/21] fix(walls): align shaped frames with curved placement --- packages/nodes/src/door/door-math.ts | 19 +++++++++---------- .../src/shared/curtain-opening-limits.test.ts | 13 +++++++++++-- .../nodes/src/wall/curtain-opening-frame.ts | 13 ++++++++++--- .../src/wall/curtain-wall-geometry.test.ts | 15 +++++++++++++++ packages/nodes/src/window/window-math.ts | 13 ++++--------- 5 files changed, 49 insertions(+), 24 deletions(-) diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index bdcd38925c..479713f11d 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,4 +1,9 @@ -import { getCurtainWallConfig, getWallCurveLength, type WallNode } from '@pascal-app/core' +import { + getCurtainWallConfig, + getWallCurveFrameAt, + getWallCurveLength, + type WallNode, +} from '@pascal-app/core' /** * Keep the door handle at the same relative height when the door is resized: @@ -26,15 +31,9 @@ export function wallLocalToWorld( levelYOffset = 0, slabElevation = 0, ): [number, number, number] { - const wallAngle = Math.atan2( - wallNode.end[1] - wallNode.start[1], - wallNode.end[0] - wallNode.start[0], - ) - return [ - wallNode.start[0] + localX * Math.cos(wallAngle), - slabElevation + localY + levelYOffset, - wallNode.start[1] + localX * Math.sin(wallAngle), - ] + const wallLength = getWallCurveLength(wallNode) + const frame = getWallCurveFrameAt(wallNode, wallLength > 1e-6 ? localX / wallLength : 0) + return [frame.point.x, slabElevation + localY + levelYOffset, frame.point.y] } /** diff --git a/packages/nodes/src/shared/curtain-opening-limits.test.ts b/packages/nodes/src/shared/curtain-opening-limits.test.ts index 3b162b0883..4c69c74681 100644 --- a/packages/nodes/src/shared/curtain-opening-limits.test.ts +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -9,8 +9,14 @@ import { WallNode, WindowNode, } from '@pascal-app/core' -import { clampToWall as clampDoorToWall } from '../door/door-math' -import { clampToWall as clampWindowToWall } from '../window/window-math' +import { + clampToWall as clampDoorToWall, + wallLocalToWorld as doorWallLocalToWorld, +} from '../door/door-math' +import { + clampToWall as clampWindowToWall, + wallLocalToWorld as windowWallLocalToWorld, +} from '../window/window-math' import { constrainCurtainOpening, curtainOpeningLimits } from './curtain-opening-limits' import { createOpeningPropertyPreview } from './opening-property-preview' import { openingPropertyPreviewHost } from './opening-property-preview-host' @@ -71,6 +77,9 @@ test('placement uses curved curtain arc length at the far perimeter', () => { expect(projectPlanPointToWallLocalX(curvedWall, [pointer.x, pointer.y])).toBeCloseTo( getWallCurveLength(curvedWall) * 0.75, ) + const localX = getWallCurveLength(curvedWall) * 0.75 + expect(doorWallLocalToWorld(curvedWall, localX, 1, 2, 0.2)).toEqual([pointer.x, 3.2, pointer.y]) + expect(windowWallLocalToWorld(curvedWall, localX, 1, 2, 0.2)).toEqual([pointer.x, 3.2, pointer.y]) }) test('non-curtain hosts retain their existing sizing policy', () => { diff --git a/packages/nodes/src/wall/curtain-opening-frame.ts b/packages/nodes/src/wall/curtain-opening-frame.ts index 169d282c2e..2703e050b3 100644 --- a/packages/nodes/src/wall/curtain-opening-frame.ts +++ b/packages/nodes/src/wall/curtain-opening-frame.ts @@ -81,9 +81,16 @@ export function buildCurtainOpeningFrame( ring.holes.push(hole) } const extrude = (shape: Shape, thickness: number) => { - const geometry = new ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false, steps: 1 }) - geometry.translate(0, 0, -thickness / 2) - ensureRenderableGeometryAttributes(geometry) + const indexed = new ExtrudeGeometry(shape, { + depth: thickness, + bevelEnabled: false, + steps: 1, + }) + indexed.translate(0, 0, -thickness / 2) + ensureRenderableGeometryAttributes(indexed) + if (!indexed.index) return indexed + const geometry = indexed.toNonIndexed() + indexed.dispose() return geometry } return { frame: extrude(ring, depth), cutter: extrude(outline, depth * 3) } diff --git a/packages/nodes/src/wall/curtain-wall-geometry.test.ts b/packages/nodes/src/wall/curtain-wall-geometry.test.ts index 6706e55836..8f5d734e4c 100644 --- a/packages/nodes/src/wall/curtain-wall-geometry.test.ts +++ b/packages/nodes/src/wall/curtain-wall-geometry.test.ts @@ -12,6 +12,7 @@ import { } from '@pascal-app/core' import { generateExtrudedWall } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { buildCurtainOpeningFrame } from './curtain-opening-frame' import { curtainWallGeometryAdapter } from './curtain-wall-adapter' import { buildCurtainWallGeometry } from './curtain-wall-geometry' import { getCurtainAwareWallMaterials } from './curtain-wall-materials' @@ -139,6 +140,20 @@ describe('curtain wall geometry', () => { expect(hit(mesh, 2, 0.5).length).toBeGreaterThan(0) geometry.dispose() }) + test('shaped opening frames remain merge-compatible with cut curtain geometry', () => { + const window = WindowNode.parse({ + position: [2, 1.5, 0], + width: 1, + height: 1, + openingShape: 'rounded', + cornerRadius: 0.2, + }) + const { frame, cutter } = buildCurtainOpeningFrame(window, 0.05, 0.1) + expect(frame.index).toBeNull() + expect(cutter.index).toBeNull() + frame.dispose() + cutter.dispose() + }) test('live shape settings retain their shaped curtain opening preview', () => { const wall = WallNode.parse({ start: [0, 0], end: [4, 0], wallType: 'curtain' }) const window = WindowNode.parse({ diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index b0aff8f2b7..fda2ff7087 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -2,6 +2,7 @@ import { type AnyNode, type AnyNodeId, getCurtainWallConfig, + getWallCurveFrameAt, getWallCurveLength, type WallNode, } from '@pascal-app/core' @@ -30,15 +31,9 @@ export function wallLocalToWorld( levelYOffset = 0, slabElevation = 0, ): [number, number, number] { - const wallAngle = Math.atan2( - wallNode.end[1] - wallNode.start[1], - wallNode.end[0] - wallNode.start[0], - ) - return [ - wallNode.start[0] + localX * Math.cos(wallAngle), - slabElevation + localY + levelYOffset, - wallNode.start[1] + localX * Math.sin(wallAngle), - ] + const wallLength = getWallCurveLength(wallNode) + const frame = getWallCurveFrameAt(wallNode, wallLength > 1e-6 ? localX / wallLength : 0) + return [frame.point.x, slabElevation + localY + levelYOffset, frame.point.y] } /** From fecc22d732e2092e4dfa88077bdd701c53c549e8 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 16:56:56 +0530 Subject: [PATCH 15/21] perf(walls): keep curtain highlight stable during previews --- .../nodes/src/wall/curtain-panel-highlight.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/nodes/src/wall/curtain-panel-highlight.ts b/packages/nodes/src/wall/curtain-panel-highlight.ts index 48c4d29921..c4e7e707d3 100644 --- a/packages/nodes/src/wall/curtain-panel-highlight.ts +++ b/packages/nodes/src/wall/curtain-panel-highlight.ts @@ -26,22 +26,35 @@ export function useCurtainPanelHighlight( bottom: number, top: number, ) { + const wallId = wall.id + const startX = wall.start[0] + const startZ = wall.start[1] + const endX = wall.end[0] + const endZ = wall.end[1] + const curveOffset = wall.curveOffset + const thickness = getWallThickness(wall) + useEffect(() => { - const host = sceneRegistry.nodes.get(wall.id) as Object3D | undefined + const curveWall: Pick = { + start: [startX, startZ], + end: [endX, endZ], + curveOffset, + } + const host = sceneRegistry.nodes.get(wallId) as Object3D | undefined if (!host) return - const length = getWallCurveLength(wall) + const length = getWallCurveLength(curveWall) if (length <= 0) return - const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const angle = Math.atan2(endZ - startZ, endX - startX) const cos = Math.cos(angle), sin = Math.sin(angle) const points: Vector3[] = [] for (const y of [bottom, top]) { const edge: Vector3[] = [] for (let i = 0; i <= 24; i++) { - const frame = getWallCurveFrameAt(wall, (left + ((right - left) * i) / 24) / length) - const offset = getWallThickness(wall) / 2 + 0.005 - const x = frame.point.x + frame.normal.x * offset - wall.start[0] - const z = frame.point.y + frame.normal.y * offset - wall.start[1] + const frame = getWallCurveFrameAt(curveWall, (left + ((right - left) * i) / 24) / length) + const offset = thickness / 2 + 0.005 + const x = frame.point.x + frame.normal.x * offset - startX + const z = frame.point.y + frame.normal.y * offset - startZ edge.push(new Vector3(x * cos + z * sin, y, -x * sin + z * cos)) } points.push(...(y === bottom ? edge : edge.reverse())) @@ -82,7 +95,7 @@ export function useCurtainPanelHighlight( scene.add(outline, fill) let frameId = 0 const sync = () => { - const current = sceneRegistry.nodes.get(wall.id) as Object3D | undefined + const current = sceneRegistry.nodes.get(wallId) as Object3D | undefined outline.visible = fill.visible = Boolean(current?.visible) if (current) { current.updateWorldMatrix(true, false) @@ -103,5 +116,5 @@ export function useCurtainPanelHighlight( geometry.dispose() material.dispose() } - }, [wall, left, right, bottom, top]) + }, [wallId, startX, startZ, endX, endZ, curveOffset, thickness, left, right, bottom, top]) } From a95fae52af1ca25eabf1b7ed236d1e08e4d15708 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 17:12:08 +0530 Subject: [PATCH 16/21] fix(walls): gate individual panel editing --- .../nodes/src/wall/curtain-panel-highlight.ts | 17 +- .../nodes/src/wall/curtain-wall-panel.tsx | 264 ++++++++++-------- 2 files changed, 156 insertions(+), 125 deletions(-) diff --git a/packages/nodes/src/wall/curtain-panel-highlight.ts b/packages/nodes/src/wall/curtain-panel-highlight.ts index c4e7e707d3..59a1809d63 100644 --- a/packages/nodes/src/wall/curtain-panel-highlight.ts +++ b/packages/nodes/src/wall/curtain-panel-highlight.ts @@ -25,6 +25,7 @@ export function useCurtainPanelHighlight( right: number, bottom: number, top: number, + enabled: boolean, ) { const wallId = wall.id const startX = wall.start[0] @@ -35,6 +36,7 @@ export function useCurtainPanelHighlight( const thickness = getWallThickness(wall) useEffect(() => { + if (!enabled) return const curveWall: Pick = { start: [startX, startZ], end: [endX, endZ], @@ -116,5 +118,18 @@ export function useCurtainPanelHighlight( geometry.dispose() material.dispose() } - }, [wallId, startX, startZ, endX, endZ, curveOffset, thickness, left, right, bottom, top]) + }, [ + enabled, + wallId, + startX, + startZ, + endX, + endZ, + curveOffset, + thickness, + left, + right, + bottom, + top, + ]) } diff --git a/packages/nodes/src/wall/curtain-wall-panel.tsx b/packages/nodes/src/wall/curtain-wall-panel.tsx index 970a70a982..03d55143ec 100644 --- a/packages/nodes/src/wall/curtain-wall-panel.tsx +++ b/packages/nodes/src/wall/curtain-wall-panel.tsx @@ -17,6 +17,7 @@ import { PanelSection, SegmentedControl, SliderControl, + ToggleControl, } from '@pascal-app/editor' import { useState } from 'react' import { useCurtainPanelHighlight } from './curtain-panel-highlight' @@ -227,6 +228,7 @@ export function CurtainWallPanel({ } const [tintEditing, setTintEditing] = useState(false) + const [panelEditing, setPanelEditing] = useState(false) const [selectedColumn, setColumn] = useState(0) const [selectedRow, setRow] = useState(0) const columnCount = curtainGridPositions(getWallCurveLength(node), config.columns).length - 1 @@ -256,6 +258,7 @@ export function CurtainWallPanel({ xs[column + 1] ?? 0, ys[row] ?? 0, ys[row + 1] ?? 0, + panelEditing, ) const hasGlass = xs .slice(1) @@ -417,137 +420,150 @@ export function CurtainWallPanel({ )} -

- Choose a cell or enter its column and row. Use arrow keys to move between cells. Columns - run from the wall start; rows count upward. -

-
- - {xs.slice(1).flatMap((_, c) => - ys.slice(1).map((_, r) => { - const type = curtainPanelType(config, c, r, rowCount) - const selected = c === column && r === row - return ( - { - setColumn(c) - setRow(r) - }} - onKeyDown={(event) => { - const arrows: Record = { - ArrowLeft: [-1, 0], - ArrowRight: [1, 0], - ArrowUp: [0, 1], - ArrowDown: [0, -1], - } - const delta = arrows[event.key] - if (delta) { - event.preventDefault() - const nextColumn = Math.max(0, Math.min(columnCount - 1, c + delta[0])) - const nextRow = Math.max(0, Math.min(rowCount - 1, r + delta[1])) - setColumn(nextColumn) - setRow(nextRow) - const target = - event.currentTarget.parentElement?.querySelector( - `[data-cell="${nextColumn}-${nextRow}"]`, - ) - target?.focus() - target?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) - } - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - setColumn(c) - setRow(r) - } - }} - /> - ) - }), + + {panelEditing && ( + <> +

+ Choose a cell or enter its column and row. Use arrow keys to move between cells. + Columns run from the wall start; rows count upward. +

+
+ + {xs.slice(1).flatMap((_, c) => + ys.slice(1).map((_, r) => { + const type = curtainPanelType(config, c, r, rowCount) + const selected = c === column && r === row + return ( + { + setColumn(c) + setRow(r) + }} + onKeyDown={(event) => { + const arrows: Record = { + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, 1], + ArrowDown: [0, -1], + } + const delta = arrows[event.key] + if (delta) { + event.preventDefault() + const nextColumn = Math.max(0, Math.min(columnCount - 1, c + delta[0])) + const nextRow = Math.max(0, Math.min(rowCount - 1, r + delta[1])) + setColumn(nextColumn) + setRow(nextRow) + const target = + event.currentTarget.parentElement?.querySelector( + `[data-cell="${nextColumn}-${nextRow}"]`, + ) + target?.focus() + target?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setColumn(c) + setRow(r) + } + }} + /> + ) + }), + )} + +
+ {hiddenOverrides.length > 0 && ( +
+

+ {hiddenOverrides.length} custom panels are outside this grid. They return if the + grid expands. +

+ +
)} - -
- {hiddenOverrides.length > 0 && ( -
-

- {hiddenOverrides.length} custom panels are outside this grid. They return if the grid - expands. +

+ Column {column + 1}, row {row + 1} ·{' '} + {overridden ? 'Custom infill' : 'Following wall defaults'}

+ setColumn(Math.round(value) - 1)} + precision={0} + step={1} + value={column + 1} + /> + setRow(Math.round(value) - 1)} + precision={0} + step={1} + value={row + 1} + /> + -
+

+ Use the Door or Window tool to add an opening through the wall. +

+ )} -

- Column {column + 1}, row {row + 1} ·{' '} - {overridden ? 'Custom infill' : 'Following wall defaults'} -

- setColumn(Math.round(value) - 1)} - precision={0} - step={1} - value={column + 1} - /> - setRow(Math.round(value) - 1)} - precision={0} - step={1} - value={row + 1} - /> - - -

- Use the Door or Window tool to add an opening through the wall. -

{hasGlass && ( From 7cc7bedc71f6cdaff1a35a99272d4ec5c5383eed Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 17:14:49 +0530 Subject: [PATCH 17/21] fix(openings): remove documentation inspector fields --- packages/nodes/src/door/panel.tsx | 16 -- .../shared/opening-documentation-fields.tsx | 199 ------------------ packages/nodes/src/window/panel.tsx | 16 -- 3 files changed, 231 deletions(-) delete mode 100644 packages/nodes/src/shared/opening-documentation-fields.tsx diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index 5f34dc367c..e866b16e26 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -17,7 +17,6 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' -import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' import { scaleHandleHeight } from './door-math' @@ -556,21 +555,6 @@ export default function DoorPanel() { )} - - - - void -}) { - return ( -
- -
- - -
-
- onChange({ roughOpeningWidth: value })} - value={roughOpeningWidth} - /> - onChange({ roughOpeningHeight: value })} - value={roughOpeningHeight} - /> -
-
- onChange({ masonryOpeningWidth: value })} - value={masonryOpeningWidth} - /> - onChange({ masonryOpeningHeight: value })} - value={masonryOpeningHeight} - /> -
-
- onChange({ finishOpeningWidth: value })} - value={finishOpeningWidth} - /> - onChange({ finishOpeningHeight: value })} - value={finishOpeningHeight} - /> -
-

- Leave RO, MO, and FO values blank until verified by the applicable manufacturer or trade. -

-
- ) -} - -function OptionalMeterInput({ - label, - value, - onChange, -}: { - label: string - value?: number - onChange: (value: number | undefined) => void -}) { - const unit = useViewer((state) => state.unit) - const displayValue = value === undefined ? '' : roundForInput(metersToLinearUnit(value, unit)) - - return ( - - ) -} - -function roundForInput(value: number): number { - return Math.round(value * 1000) / 1000 -} diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index 33f3b0b588..bbb3973069 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -23,7 +23,6 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' -import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' @@ -368,21 +367,6 @@ export default function WindowPanel() { />
- - - - {showWindowTypeSection && (
From 063ee7ad651080447cd54f863b5e06f963b6d64d Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 19:24:27 +0530 Subject: [PATCH 18/21] fix(walls): add rounded opening controls and optimize previews - Add corner-radius handles with Shift-based individual rounding - Improve curtain-wall cutouts, preview rebuilds, and shadow updates --- packages/core/src/registry/handles.ts | 33 +++ packages/core/src/registry/index.ts | 2 + .../editor/handles/use-handle-drag.ts | 10 +- .../components/editor/node-arrow-handles.tsx | 130 +++++++++++- packages/nodes/src/door/definition.ts | 38 ++++ packages/nodes/src/shared/hosted-resize.ts | 3 +- .../shared/opening-property-preview-host.ts | 10 + .../src/shared/opening-property-preview.ts | 62 +++++- .../src/shared/opening-radius-handles.test.ts | 67 ++++++ .../src/wall/curtain-opening-frame.test.ts | 24 +++ .../nodes/src/wall/curtain-opening-frame.ts | 16 +- .../nodes/src/wall/curtain-wall-adapter.ts | 22 +- .../src/wall/curtain-wall-door-resize.test.ts | 3 + .../nodes/src/wall/curtain-wall-geometry.ts | 34 +++- .../wall/curtain-wall-piece-geometry.test.ts | 55 ++++- .../src/wall/curtain-wall-piece-geometry.ts | 190 +++++++++++++++++- .../src/wall/curtain-wall-shadow.test.ts | 61 +++++- .../curtain-wall-shape-performance.test.ts | 59 ++++++ packages/nodes/src/window/definition.ts | 46 +++++ .../nodes/src/window/property-preview.test.ts | 105 +++++++++- .../src/systems/door/door-hinges.test.ts | 49 +++++ .../viewer/src/systems/door/door-system.tsx | 32 +-- .../src/systems/window/window-system.tsx | 5 +- 23 files changed, 1003 insertions(+), 53 deletions(-) create mode 100644 packages/nodes/src/shared/opening-radius-handles.test.ts create mode 100644 packages/nodes/src/wall/curtain-wall-shape-performance.test.ts create mode 100644 packages/viewer/src/systems/door/door-hinges.test.ts diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 29ca639b6d..2d3946c916 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -79,6 +79,13 @@ export type HandleAnchor = 'center' | 'min' | 'max' /** Keyboard modifiers captured for a handle-resize tick. */ export type HandleDragModifiers = { readonly altKey: boolean + readonly shiftKey?: boolean +} + +export type HandlePreviewSession = { + preview: (patch: Partial) => void + commit: (patch?: Partial) => void + cancel: () => void } /** 3D position + rotation of the arrow in its portal target's local space. */ @@ -294,6 +301,31 @@ export type RadialResizeHandle = { decoration?: HandleDecoration } +/** + * In-plane corner-radius knob. The editor places the knob diagonally inward + * from `corner` and converts its pointer position back into a radius. Holding + * Shift is exposed through `modifiers` so kinds can switch from a shared + * radius to per-corner radii without teaching the editor their schema. + */ +export type CornerRadiusHandle = { + kind: 'corner-radius' + corner: readonly [x: -1 | 1, y: -1 | 1] + width: (node: N) => number + height: (node: N) => number + currentValue: (node: N) => number + max: number | ((node: N, sceneApi: SceneApi) => number) + apply: ( + node: N, + newValue: number, + sceneApi: SceneApi, + modifiers: HandleDragModifiers, + ) => Partial + createPreview?: (node: N) => HandlePreviewSession + visible?: (node: N, sceneApi: SceneApi) => boolean + portal?: HandlePortal + portalTarget?: HandlePortalTarget +} + /** * Curved / spiral stair sweep arrows. The renderer raycasts a horizontal * plane through the arrow's Y and emits the angular delta (radians, @@ -492,6 +524,7 @@ export type LatchHandle = { export type HandleDescriptor = | LinearResizeHandle | RadialResizeHandle + | CornerRadiusHandle | ArcResizeHandle | EndpointMoveHandle | TapActionHandle diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index a54071dde1..f141ced0c5 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,5 +1,6 @@ export type { ArcResizeHandle, + CornerRadiusHandle, Cursor, EditorApi, EndpointMoveHandle, @@ -10,6 +11,7 @@ export type { HandleList, HandlePlacement, HandlePortal, + HandlePreviewSession, LatchHandle, LinearResizeHandle, RadialResizeHandle, diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index f602f70489..2a81270f64 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -60,6 +60,7 @@ type HandleDragSession = { commit?: (patch: Partial) => void markDirty?: boolean onBegin?: () => void + onCancel?: () => void onEnd?: () => void overrideId?: AnyNodeId } @@ -196,6 +197,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { let historyPaused = true let releaseSpatialCapture: (() => void) | null = null let altKey = event.nativeEvent.altKey + let shiftKey = event.nativeEvent.shiftKey let pendingMoveEvent: PointerEvent | null = null let moveFrame = 0 @@ -208,7 +210,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { const processMove = (moveEvent: PointerEvent) => { const patch = session.move({ event: moveEvent, - modifiers: { altKey }, + modifiers: { altKey, shiftKey }, getPointerRay, intersectPlane, }) @@ -287,6 +289,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { } const onCancel = () => { + session.onCancel?.() clearOverride() cleanup() } @@ -298,6 +301,10 @@ export function useHandleDrag(args: UseHandleDragArgs) { altKey = true return } + if (e.key === 'Shift') { + shiftKey = true + return + } if (e.key !== 'Escape' && !isHistoryShortcut(e)) return e.preventDefault() e.stopPropagation() @@ -306,6 +313,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { } const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Alt') altKey = false + if (e.key === 'Shift') shiftKey = false } dragCleanupRef.current = onCancel diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 652d56cf98..1cbffbfe08 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, type ArcResizeHandle, + type CornerRadiusHandle, type Cursor, createSceneApi, DEFAULT_ANGLE_STEP, @@ -578,6 +579,17 @@ function ArrowHandle({ /> ) } + if (descriptor.kind === 'corner-radius') { + return ( + + ) + } if (descriptor.kind === 'tap-action') { // Tap-action handles (fence side-move arrows, corner pickers) aren't // resize handles, so the freeze-at-pre-drag mechanism — which only @@ -591,6 +603,119 @@ function ArrowHandle({ return null } +const CORNER_RADIUS_DOT_INSET = 0.2 +const CORNER_RADIUS_POSITION_FACTOR = Math.SQRT2 - 1 + +function CornerRadiusKnob({ + descriptor, + node, + handleIndex, + dragControls, + rideObject, +}: { + descriptor: CornerRadiusHandle + node: AnyNode + handleIndex: number + dragControls: HandleDragControls + rideObject: Object3D +}) { + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const [sx, sy] = descriptor.corner + const radius = descriptor.currentValue(node) + const inset = CORNER_RADIUS_DOT_INSET + CORNER_RADIUS_POSITION_FACTOR * radius + const position: [number, number, number] = [ + sx * (descriptor.width(node) / 2 - inset / Math.SQRT2), + sy * (descriptor.height(node) / 2 - inset / Math.SQRT2), + 0, + ] + + const activate = useHandleDrag({ + kind: 'drag', + cursor: 'grabbing', + dragControls, + handleIndex, + node, + rideObject, + setIsDragging, + onStart: ({ initialNode, nodeId, rideObject: dragRideObject, sceneApi: dragSceneApi }) => { + dragRideObject.updateMatrixWorld(true) + const worldMatrix = dragRideObject.matrixWorld.clone() + const inverseWorldMatrix = worldMatrix.clone().invert() + const center = new Vector3().setFromMatrixPosition(worldMatrix) + const normal = new Vector3().setFromMatrixColumn(worldMatrix, 2).normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center) + const maxRadius = resolveBound( + descriptor.max, + Number.POSITIVE_INFINITY, + initialNode, + dragSceneApi, + ) + const preview = descriptor.createPreview?.(initialNode as never) + + return { + markDirty: !preview, + commit: preview ? (patch) => preview.commit(patch as never) : undefined, + onBegin: () => { + useInteractionScope.getState().begin({ + kind: 'handle-drag', + nodeId, + handle: 'Corner radius', + }) + }, + onCancel: () => preview?.cancel(), + onEnd: () => { + useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag') + }, + move: ({ event, modifiers, intersectPlane }) => { + const hit = new Vector3() + if (!intersectPlane(event.clientX, event.clientY, plane, hit)) return null + hit.applyMatrix4(inverseWorldMatrix) + const diagonalDistance = + (descriptor.width(initialNode) / 2 + + descriptor.height(initialNode) / 2 - + sx * hit.x - + sy * hit.y) / + Math.SQRT2 + const next = Math.min( + maxRadius, + Math.max( + 0, + (diagonalDistance - CORNER_RADIUS_DOT_INSET) / CORNER_RADIUS_POSITION_FACTOR, + ), + ) + const patch = descriptor.apply(initialNode as never, next, dragSceneApi, modifiers) + preview?.preview(patch as never) + return patch as Partial + }, + } + }, + }) + + return ( + + {isHovered || isDragging ? ( + + ) : null} + + ) +} + function pickCursor(descriptor: LinearResizeHandle | RadialResizeHandle): Cursor { if (descriptor.kind === 'linear-resize' && descriptor.cursor) return descriptor.cursor return descriptor.axis === 'y' ? 'ns-resize' : 'ew-resize' @@ -709,7 +834,10 @@ function LinearArrow({ initialNode, nodeId, sceneApi, - initialModifiers: { altKey: event.nativeEvent.altKey }, + initialModifiers: { + altKey: event.nativeEvent.altKey, + shiftKey: event.nativeEvent.shiftKey, + }, }) : null const overrideId = linearBinding?.overrideId ?? nodeId diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index ff7ae4f18f..251bbed590 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -13,6 +13,8 @@ import { computeDoorFloorplanLevelData, } from '../shared/opening-documentation' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' +import { createOpeningPropertyPreview } from '../shared/opening-property-preview' +import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { readHostWallCeiling } from '../shared/wall-opening-ceiling' @@ -143,11 +145,47 @@ function doorMoveHandle(): HandleDescriptor { } } +function doorRadiusHandle(index: 0 | 1): HandleDescriptor { + const sign = index === 0 ? -1 : 1 + return { + kind: 'corner-radius', + corner: [sign, 1], + width: (node) => node.width, + height: (node) => node.height, + currentValue: (node) => + node.openingRadiusMode === 'individual' + ? (node.openingTopRadii[index] ?? 0) + : node.cornerRadius, + max: (node) => Math.min(node.width / 2, node.height), + apply: (node, radius, _scene, modifiers) => { + if (!modifiers.shiftKey) { + return { openingShape: 'rounded', openingRadiusMode: 'all', cornerRadius: radius } + } + const radii = + node.openingRadiusMode === 'individual' + ? [...node.openingTopRadii] + : [node.cornerRadius, node.cornerRadius] + radii[index] = radius + return { + openingShape: 'rounded', + openingRadiusMode: 'individual', + openingTopRadii: radii as [number, number], + } + }, + createPreview: (node) => + createOpeningPropertyPreview(node.id, openingPropertyPreviewHost), + visible: (node) => node.openingShape !== 'arch', + portal: 'grandparent', + } +} + const doorHandles: HandleDescriptor[] = [ doorMoveHandle(), doorWidthHandle('left'), doorWidthHandle('right'), doorHeightHandle(), + doorRadiusHandle(0), + doorRadiusHandle(1), ] /** diff --git a/packages/nodes/src/shared/hosted-resize.ts b/packages/nodes/src/shared/hosted-resize.ts index bd7e78609b..3d49000c1b 100644 --- a/packages/nodes/src/shared/hosted-resize.ts +++ b/packages/nodes/src/shared/hosted-resize.ts @@ -4,6 +4,7 @@ import { getScaledDimensions, getSurfaceProvider, type HandleDescriptor, + type HandleDragModifiers, nodeRegistry, type SceneApi, surfaceRegionContainsFootprint, @@ -179,7 +180,7 @@ export function withHostedChildren( ) return { ...descriptor, - apply(node, value, sceneApi, modifiers) { + apply(node: N, value: number, sceneApi: SceneApi, modifiers?: HandleDragModifiers) { const patch = descriptor.apply(node, value, sceneApi, modifiers) if (!hasHostedChildren(sceneApi) || plan(node, patch, sceneApi, modifiers)) return patch const target = sceneApi.get(descriptor.overrideTarget?.(node, sceneApi) ?? node.id) ?? node diff --git a/packages/nodes/src/shared/opening-property-preview-host.ts b/packages/nodes/src/shared/opening-property-preview-host.ts index 4457f1ad8e..24ff993731 100644 --- a/packages/nodes/src/shared/opening-property-preview-host.ts +++ b/packages/nodes/src/shared/opening-property-preview-host.ts @@ -8,4 +8,14 @@ export const openingPropertyPreviewHost: OpeningPropertyPreviewDependencies = { clearOverrideFields: (id, fields) => useLiveNodeOverrides.getState().clearFields(id, fields), markDirty: (id) => useScene.getState().markDirty(id), updateNode: (id, patch) => useScene.getState().updateNode(id, patch as Partial), + scheduleFrame: (callback) => { + if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback) + callback(0) + return 0 + }, + cancelFrame: (handle) => { + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle) + }, + scheduleDelay: (callback, delay) => setTimeout(callback, delay) as unknown as number, + cancelDelay: (handle) => clearTimeout(handle), } diff --git a/packages/nodes/src/shared/opening-property-preview.ts b/packages/nodes/src/shared/opening-property-preview.ts index a11265d96d..f2447ddaed 100644 --- a/packages/nodes/src/shared/opening-property-preview.ts +++ b/packages/nodes/src/shared/opening-property-preview.ts @@ -8,23 +8,68 @@ export type OpeningPropertyPreviewDependencies = { clearOverrideFields: (id: AnyNodeId, fields: string[]) => void markDirty: (id: AnyNodeId) => void updateNode: (id: AnyNodeId, patch: Partial) => void + scheduleFrame: (callback: FrameRequestCallback) => number + cancelFrame: (handle: number) => void + scheduleDelay: (callback: () => void, delay: number) => number + cancelDelay: (handle: number) => void } +const CURTAIN_PARENT_PREVIEW_INTERVAL_MS = 100 + export function createOpeningPropertyPreview( id: AnyNodeId, dependencies: OpeningPropertyPreviewDependencies, ) { let pending: Partial | undefined - const dirty = () => { + let dirtyFrame: number | undefined + let parentDelay: number | undefined + const parent = () => { + const parentId = dependencies.nodes()[id]?.parentId + return parentId ? dependencies.nodes()[parentId as AnyNodeId] : undefined + } + const dirtyOpening = () => { dependencies.markDirty(id) - const parent = dependencies.nodes()[id]?.parentId - if (parent) dependencies.markDirty(parent as AnyNodeId) + } + const dirtyParent = () => { + const parentId = dependencies.nodes()[id]?.parentId + if (parentId) dependencies.markDirty(parentId as AnyNodeId) + } + const cancelScheduledDirty = () => { + if (dirtyFrame === undefined) return + dependencies.cancelFrame(dirtyFrame) + dirtyFrame = undefined + } + const scheduleDirty = () => { + if (dirtyFrame !== undefined) return + let completedSynchronously = false + const handle = dependencies.scheduleFrame(() => { + completedSynchronously = true + dirtyFrame = undefined + dirtyOpening() + const parentNode = parent() + if (parentNode?.type !== 'wall' || parentNode.wallType !== 'curtain') { + dirtyParent() + return + } + if (parentDelay !== undefined) return + parentDelay = dependencies.scheduleDelay(() => { + parentDelay = undefined + dirtyParent() + }, CURTAIN_PARENT_PREVIEW_INTERVAL_MS) + }) + if (!completedSynchronously) dirtyFrame = handle } const clear = () => { if (!pending) return - dependencies.clearOverrideFields(id, Object.keys(pending)) + cancelScheduledDirty() + if (parentDelay !== undefined) { + dependencies.cancelDelay(parentDelay) + parentDelay = undefined + } + dependencies.clearOverrideFields(id, [...Object.keys(pending), 'metadata']) pending = undefined - dirty() + dirtyOpening() + dirtyParent() } return { preview(patch: Partial) { @@ -35,8 +80,11 @@ export function createOpeningPropertyPreview( const effective = live ? ({ ...node, ...live } as T) : (node as T) patch = constrainCurtainOpening(effective, patch, nodes) pending = { ...pending, ...patch } - dependencies.setOverride(id, patch as Partial) - dirty() + dependencies.setOverride(id, { + ...patch, + metadata: { ...node.metadata, ...effective.metadata, deferParentRebuild: true }, + } as Partial) + scheduleDirty() }, commit(patch?: Partial) { const live = dependencies.override(id) diff --git a/packages/nodes/src/shared/opening-radius-handles.test.ts b/packages/nodes/src/shared/opening-radius-handles.test.ts new file mode 100644 index 0000000000..cd54b2c3b4 --- /dev/null +++ b/packages/nodes/src/shared/opening-radius-handles.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import { + createSceneApi, + DoorNode, + type HandleDescriptor, + useScene, + WindowNode, +} from '@pascal-app/core' +import { doorDefinition } from '../door/definition' +import { windowDefinition } from '../window/definition' + +const scene = createSceneApi(useScene) + +function radiusHandles( + handles: unknown, +): Extract, { kind: 'corner-radius' }>[] { + return (handles as HandleDescriptor[]).filter( + (handle): handle is Extract, { kind: 'corner-radius' }> => + handle.kind === 'corner-radius', + ) +} + +describe('opening corner-radius handles', () => { + test('window exposes four dots and supports shared or Shift-isolated rounding', () => { + const window = WindowNode.parse({ id: 'window_radius', cornerRadius: 0.15 }) + const handles = radiusHandles(windowDefinition.handles) + + expect(handles).toHaveLength(4) + expect(handles[0]?.apply(window, 0.3, scene, { altKey: false })).toMatchObject({ + cornerRadius: 0.3, + openingRadiusMode: 'all', + openingShape: 'rounded', + }) + expect(handles[2]?.apply(window, 0.25, scene, { altKey: false, shiftKey: true })).toMatchObject( + { + openingCornerRadii: [0.15, 0.15, 0.25, 0.15], + openingRadiusMode: 'individual', + openingShape: 'rounded', + }, + ) + }) + + test('door exposes only its top two dots and hides them for an arch', () => { + const door = DoorNode.parse({ id: 'door_radius', cornerRadius: 0.15 }) + const handles = radiusHandles(doorDefinition.handles) + + expect(handles.map((handle) => handle.corner)).toEqual([ + [-1, 1], + [1, 1], + ]) + expect(handles[1]?.apply(door, 0.2, scene, { altKey: false, shiftKey: true })).toMatchObject({ + openingRadiusMode: 'individual', + openingShape: 'rounded', + openingTopRadii: [0.15, 0.2], + }) + expect(handles[0]?.visible?.({ ...door, openingShape: 'arch' }, scene)).toBe(false) + expect( + handles[0]?.visible?.( + { ...door, openingShape: 'rounded', cornerRadius: door.width / 2 }, + scene, + ), + ).toBe(true) + expect( + handles[0]?.visible?.({ ...door, openingShape: 'rounded', cornerRadius: 0.15 }, scene), + ).toBe(true) + }) +}) diff --git a/packages/nodes/src/wall/curtain-opening-frame.test.ts b/packages/nodes/src/wall/curtain-opening-frame.test.ts index 3edcbb9312..82beab4af5 100644 --- a/packages/nodes/src/wall/curtain-opening-frame.test.ts +++ b/packages/nodes/src/wall/curtain-opening-frame.test.ts @@ -98,6 +98,30 @@ test('rounded profiles respect individual radii and arch height edits', () => { expect(changed[1] - changed[0]).toBeGreaterThan(first[1] - first[0]) }) +for (const schema of [DoorNode, WindowNode]) { + test(`flipped ${schema === DoorNode ? 'door' : 'window'} surround mirrors individual radii`, () => { + const opening = schema.parse({ + position: [1, 1, 0], + width: 2, + height: 2, + openingShape: 'rounded', + openingRadiusMode: 'individual', + openingTopRadii: [0, 0.7], + openingCornerRadii: [0, 0.7, 0.2, 0.4], + }) + const original = curtainOpeningProfile(opening, 0.05) + const flipped = curtainOpeningProfile({ ...opening, rotation: [0, Math.PI, 0] }, 0.05) + for (const edge of ['inner', 'outer'] as const) { + for (const height of [0.1, 1, 1.9]) { + const before = curtainProfileSpan(original[edge], height)! + const after = curtainProfileSpan(flipped[edge], height)! + expect(after[0]).toBeCloseTo(2 - before[1]) + expect(after[1]).toBeCloseTo(2 - before[0]) + } + } + }) +} + test('arched surrounds stop at the host wall top', () => { const wall = WallNode.parse({ start: [0, 0], end: [6, 0], height: 3, wallType: 'curtain' }) const opening = WindowNode.parse({ diff --git a/packages/nodes/src/wall/curtain-opening-frame.ts b/packages/nodes/src/wall/curtain-opening-frame.ts index 2703e050b3..67e783aa0e 100644 --- a/packages/nodes/src/wall/curtain-opening-frame.ts +++ b/packages/nodes/src/wall/curtain-opening-frame.ts @@ -35,13 +35,25 @@ export function mapCurtainOpeningGeometryToWall(geometry: BufferGeometry, wall: export function curtainOpeningProfile(opening: DoorNode | WindowNode, width: number) { const bottom = opening.position[1] - opening.height / 2 + if (Math.cos(opening.rotation[1]) < 0 && opening.openingRadiusMode === 'individual') { + if (opening.type === 'window') { + const [topLeft, topRight, bottomRight, bottomLeft] = opening.openingCornerRadii + opening = { + ...opening, + openingCornerRadii: [topRight, topLeft, bottomLeft, bottomRight], + } + } else { + const [topLeft, topRight] = opening.openingTopRadii + opening = { ...opening, openingTopRadii: [topRight, topLeft] } + } + } const points = buildOpeningCutoutShape(opening, { left: opening.position[0] - opening.width / 2, right: opening.position[0] + opening.width / 2, bottom, top: bottom + opening.height, }) - .getPoints(16) + .getPoints(4) .filter((point, index, all) => index === 0 || point.distanceToSquared(all[index - 1]!) > 1e-14) if (points[0]!.distanceToSquared(points.at(-1)!) < 1e-14) points.pop() const outer = points.map((point, index) => { @@ -93,7 +105,7 @@ export function buildCurtainOpeningFrame( indexed.dispose() return geometry } - return { frame: extrude(ring, depth), cutter: extrude(outline, depth * 3) } + return { frame: extrude(ring, depth), cutter: extrude(outline, depth * 3), cutout: outer } } export function curtainProfileSpan( diff --git a/packages/nodes/src/wall/curtain-wall-adapter.ts b/packages/nodes/src/wall/curtain-wall-adapter.ts index cd12a9cdee..5cce03bf57 100644 --- a/packages/nodes/src/wall/curtain-wall-adapter.ts +++ b/packages/nodes/src/wall/curtain-wall-adapter.ts @@ -8,6 +8,8 @@ function isOpaque(material: Material | undefined) { return material !== undefined && (!material.transparent || material.opacity >= 1) } +const deferredShadowWalls = new Set() + export const curtainWallGeometryAdapter: WallGeometryAdapter = { prepareChildren(wall, children, context) { if (wall.wallType !== 'curtain') { @@ -30,12 +32,26 @@ export const curtainWallGeometryAdapter: WallGeometryAdapter = { } }, buildGeometry(wall, envelope, children) { - return wall.wallType === 'curtain' - ? buildCurtainWallGeometry(wall as WallNode, envelope, children) - : envelope + if (wall.wallType !== 'curtain') return envelope + const livePreview = children.some( + (child) => + (child.type === 'door' || child.type === 'window') && + useLiveNodeOverrides.getState().get(child.id) !== undefined, + ) + const deferShadow = children.some((child) => { + if (child.type !== 'door' && child.type !== 'window') return false + const metadata = useLiveNodeOverrides.getState().get(child.id)?.metadata as + | Record + | undefined + return metadata?.deferParentRebuild === true + }) + if (deferShadow) deferredShadowWalls.add(wall.id) + else deferredShadowWalls.delete(wall.id) + return buildCurtainWallGeometry(wall as WallNode, envelope, children, livePreview) }, syncAuxiliaryGeometry(wall, mesh, geometry) { if (wall.wallType !== 'curtain') return + if (deferredShadowWalls.has(wall.id)) return const shadowMesh = mesh.getObjectByName(CURTAIN_WALL_SHADOW_NAME) as Mesh | undefined if (!shadowMesh) return const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] diff --git a/packages/nodes/src/wall/curtain-wall-door-resize.test.ts b/packages/nodes/src/wall/curtain-wall-door-resize.test.ts index c9ea69e966..68f61553f1 100644 --- a/packages/nodes/src/wall/curtain-wall-door-resize.test.ts +++ b/packages/nodes/src/wall/curtain-wall-door-resize.test.ts @@ -46,10 +46,12 @@ test('curtain door width previews rebuild a clear opening and cancel without doc new Raycaster(new Vector3(x, y, 2), new Vector3(0, 0, -1)).intersectObject(mesh, false) try { runWallBuildFrame(curtainWallGeometryAdapter) + const committedVertexCount = mesh.geometry.getAttribute('position').count expect(hit(0.85, 1).length).toBeGreaterThan(0) useLiveNodeOverrides.getState().set(door.id, { width: 1.6 }) useScene.getState().markDirty(wall.id) runWallBuildFrame(curtainWallGeometryAdapter) + expect(mesh.geometry.getAttribute('position').count).toBeGreaterThan(committedVertexCount) expect(hit(0.85, 1)).toHaveLength(0) expect(hit(0.675, 1)[0]?.face?.materialIndex).toBe(0) expect(hit(0.85, 2.4)[0]?.face?.materialIndex).toBe(1) @@ -57,6 +59,7 @@ test('curtain door width previews rebuild a clear opening and cancel without doc useLiveNodeOverrides.getState().clear(door.id) useScene.getState().markDirty(wall.id) runWallBuildFrame(curtainWallGeometryAdapter) + expect(mesh.geometry.getAttribute('position').count).toBe(committedVertexCount) expect(hit(0.85, 1).length).toBeGreaterThan(0) expect(useScene.getState().nodes[door.id]).toBe(door) } finally { diff --git a/packages/nodes/src/wall/curtain-wall-geometry.ts b/packages/nodes/src/wall/curtain-wall-geometry.ts index c4938d289b..6ca20388c0 100644 --- a/packages/nodes/src/wall/curtain-wall-geometry.ts +++ b/packages/nodes/src/wall/curtain-wall-geometry.ts @@ -23,7 +23,10 @@ import { type CurtainWallPiece, curtainGridPositions, } from './curtain-wall-layout' -import { buildStraightCurtainPieces } from './curtain-wall-piece-geometry' +import { + buildStraightCurtainPieces, + buildStraightCurtainPiecesWithCutouts, +} from './curtain-wall-piece-geometry' function pieceGeometry(wall: WallNode, piece: CurtainWallPiece, length: number, base: number) { const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) @@ -66,6 +69,7 @@ export function buildCurtainWallGeometry( wall: WallNode, envelope: BufferGeometry, children: readonly AnyNode[] = [], + livePreview = false, ): BufferGeometry { const length = getWallCurveLength(wall) envelope.computeBoundingBox() @@ -182,21 +186,29 @@ export function buildCurtainWallGeometry( if (!allRolePieces.length && !(role === 'frame' && shapedFrames.length)) continue let merged: BufferGeometry | null = null if (!isCurvedWall(wall) && builtPieces.length) { - merged = buildStraightCurtainPieces( - builtPieces.map((piece) => ({ - ...piece, - left: piece.left === 0 ? Math.min(0, bounds.min.x) : piece.left, - right: piece.right === length ? Math.max(length, bounds.max.x) : piece.right, - })), - bounds.min.y, - hasRectangularOpenings || shapedFrames.length > 0, - ) + const straightPieces = builtPieces.map((piece) => ({ + ...piece, + left: piece.left === 0 ? Math.min(0, bounds.min.x) : piece.left, + right: piece.right === length ? Math.max(length, bounds.max.x) : piece.right, + })) + merged = + rectangular && shapedFrames.length + ? buildStraightCurtainPiecesWithCutouts( + straightPieces, + bounds.min.y, + shapedFrames.map(({ cutout }) => cutout), + ) + : buildStraightCurtainPieces( + straightPieces, + bounds.min.y, + livePreview ? false : hasRectangularOpenings || shapedFrames.length > 0, + ) } else if (builtPieces.length) { const sources = rolePieces.map((piece) => pieceGeometry(wall, piece, length, bounds.min.y)) merged = mergeGeometries(sources, false) for (const source of sources) source.dispose() } - if (merged) { + if (merged && !rectangular) { for (const opening of shapedFrames) { if (!merged.getAttribute('position').count) break const brush: Brush = new Brush(merged) diff --git a/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts b/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts index 52abc41820..23e808ceb5 100644 --- a/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts +++ b/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts @@ -1,7 +1,10 @@ import { expect, test } from 'bun:test' -import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector2, Vector3 } from 'three' import type { CurtainWallPiece } from './curtain-wall-layout' -import { buildStraightCurtainPieces } from './curtain-wall-piece-geometry' +import { + buildStraightCurtainPieces, + buildStraightCurtainPiecesWithCutouts, +} from './curtain-wall-piece-geometry' test('a pane around a doorway has no internal glass faces above the header', () => { const common = { front: 0.07, back: 0.05, role: 'glass' as const } @@ -24,3 +27,51 @@ test('a pane around a doorway has no internal glass faces above the header', () geometry.dispose() material.dispose() }) + +test('the live-preview path emits complete boxes from preallocated buffers', () => { + const common = { front: 0.07, back: 0.05, role: 'glass' as const } + const pieces: CurtainWallPiece[] = [ + { ...common, left: 0, right: 1, bottom: 0, top: 1 }, + { ...common, left: 1, right: 2, bottom: 1, top: 2 }, + ] + const geometry = buildStraightCurtainPieces(pieces, 0.5, false) + const positions = geometry.getAttribute('position') + const normals = geometry.getAttribute('normal') + + geometry.computeBoundingBox() + expect(positions.count).toBe(72) + expect(normals.count).toBe(positions.count) + expect(Array.from(positions.array).every(Number.isFinite)).toBe(true) + expect(Array.from(normals.array).every(Number.isFinite)).toBe(true) + expect(geometry.boundingBox?.min.x).toBeCloseTo(0) + expect(geometry.boundingBox?.min.y).toBeCloseTo(0.5) + expect(geometry.boundingBox?.min.z).toBeCloseTo(0.05) + expect(geometry.boundingBox?.max.x).toBeCloseTo(2) + expect(geometry.boundingBox?.max.y).toBeCloseTo(2.5) + expect(geometry.boundingBox?.max.z).toBeCloseTo(0.07) + geometry.dispose() +}) + +test('the straight shaped-opening path preserves a cutout contained inside a pane', () => { + const geometry = buildStraightCurtainPiecesWithCutouts( + [{ left: 0, right: 2, bottom: 0, top: 2, front: 0.07, back: 0.05, role: 'glass' }], + 0, + [ + [ + new Vector2(0.75, 0.75), + new Vector2(1.25, 0.75), + new Vector2(1.25, 1.25), + new Vector2(0.75, 1.25), + ], + ], + ) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(geometry, material) + const ray = (x: number, y: number) => + new Raycaster(new Vector3(x, y, 1), new Vector3(0, 0, -1)).intersectObject(mesh, false) + + expect(ray(1, 1)).toHaveLength(0) + expect(ray(0.25, 1).length).toBeGreaterThan(0) + geometry.dispose() + material.dispose() +}) diff --git a/packages/nodes/src/wall/curtain-wall-piece-geometry.ts b/packages/nodes/src/wall/curtain-wall-piece-geometry.ts index 8fda946a2f..43d3caf83d 100644 --- a/packages/nodes/src/wall/curtain-wall-piece-geometry.ts +++ b/packages/nodes/src/wall/curtain-wall-piece-geometry.ts @@ -1,18 +1,194 @@ +import { subtractPolygonsFromPolygon } from '@pascal-app/core' import { ensureRenderableGeometryAttributes } from '@pascal-app/viewer' -import { BufferGeometry, Float32BufferAttribute } from 'three' +import { BufferGeometry, Float32BufferAttribute, ShapeUtils, Vector2 } from 'three' import type { CurtainWallPiece } from './curtain-wall-layout' +type PolygonPoint = [number, number] + +export function buildStraightCurtainPiecesWithCutouts( + pieces: readonly CurtainWallPiece[], + base: number, + cutouts: readonly (readonly Vector2[])[], +) { + const positions: number[] = [] + const normals: number[] = [] + const preparedCutouts = cutouts.map((cutout) => { + const points = cutout.map((point): PolygonPoint => [point.x, point.y]) + const xs = points.map(([x]) => x) + const ys = points.map(([, y]) => y) + return { + points, + minX: Math.min(...xs), + maxX: Math.max(...xs), + minY: Math.min(...ys), + maxY: Math.max(...ys), + } + }) + const vertex = (point: readonly number[], normal: readonly number[]) => { + positions.push(point[0]!, point[1]!, point[2]!) + normals.push(normal[0]!, normal[1]!, normal[2]!) + } + const contains = (ring: readonly PolygonPoint[], point: PolygonPoint) => { + let inside = false + for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { + const a = ring[index]! + const b = ring[previous]! + if ( + a[1] > point[1] !== b[1] > point[1] && + point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0] + ) { + inside = !inside + } + } + return inside + } + const area = (ring: readonly PolygonPoint[]) => + Math.abs( + ring.reduce((sum, point, index) => { + const next = ring[(index + 1) % ring.length]! + return sum + point[0] * next[1] - next[0] * point[1] + }, 0) / 2, + ) + for (const piece of pieces) { + const bottom = piece.bottom + base + const top = piece.top + base + const overlapping = preparedCutouts.filter( + (cutout) => + cutout.maxX > piece.left && + cutout.minX < piece.right && + cutout.maxY > bottom && + cutout.minY < top, + ) + const subject: PolygonPoint[] = [ + [piece.left, bottom], + [piece.right, bottom], + [piece.right, top], + [piece.left, top], + ] + const rings = subtractPolygonsFromPolygon( + subject, + overlapping.map(({ points }) => points), + ).sort((a, b) => area(b) - area(a)) + const groups: Array<{ contour: PolygonPoint[]; holes: PolygonPoint[][] }> = [] + for (const ring of rings) { + const parent = groups.find(({ contour }) => contains(contour, ring[0]!)) + if (parent) parent.holes.push([...ring].reverse()) + else groups.push({ contour: ring, holes: [] }) + } + for (const { contour, holes } of groups) { + const flattened = [contour, ...holes].flat() + const triangles = ShapeUtils.triangulateShape( + contour.map(([x, y]) => new Vector2(x, y)), + holes.map((hole) => hole.map(([x, y]) => new Vector2(x, y))), + ) + for (const triangle of triangles) { + const aIndex = triangle[0]! + const bIndex = triangle[1]! + const cIndex = triangle[2]! + const a = flattened[aIndex]! + const b = flattened[bIndex]! + const c = flattened[cIndex]! + vertex([a[0], a[1], piece.front], [0, 0, 1]) + vertex([b[0], b[1], piece.front], [0, 0, 1]) + vertex([c[0], c[1], piece.front], [0, 0, 1]) + vertex([c[0], c[1], piece.back], [0, 0, -1]) + vertex([b[0], b[1], piece.back], [0, 0, -1]) + vertex([a[0], a[1], piece.back], [0, 0, -1]) + } + for (const ring of [contour, ...holes]) { + for (let index = 0; index < ring.length; index++) { + const a = ring[index]! + const b = ring[(index + 1) % ring.length]! + const dx = b[0] - a[0] + const dy = b[1] - a[1] + const length = Math.hypot(dx, dy) + if (length <= 1e-6) continue + const normal = [dy / length, -dx / length, 0] + vertex([a[0], a[1], piece.back], normal) + vertex([b[0], b[1], piece.back], normal) + vertex([b[0], b[1], piece.front], normal) + vertex([a[0], a[1], piece.back], normal) + vertex([b[0], b[1], piece.front], normal) + vertex([a[0], a[1], piece.front], normal) + } + } + } + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) + ensureRenderableGeometryAttributes(geometry) + return geometry +} + // Adjacent fragments of one pane share a boundary, not a second glass surface. export function buildStraightCurtainPieces( pieces: readonly CurtainWallPiece[], base: number, cullSharedFaces = true, ) { - const positions: number[] = [] - const normals: number[] = [] + if (!cullSharedFaces) { + const valuesPerPiece = 6 * 6 * 3 + const positions = new Float32Array(pieces.length * valuesPerPiece) + const normals = new Float32Array(pieces.length * valuesPerPiece) + let offset = 0 + const vertex = (point: readonly number[], normal: readonly number[]) => { + positions[offset] = point[0]! + normals[offset++] = normal[0]! + positions[offset] = point[1]! + normals[offset++] = normal[1]! + positions[offset] = point[2]! + normals[offset++] = normal[2]! + } + const quad = ( + a: readonly number[], + b: readonly number[], + c: readonly number[], + d: readonly number[], + normal: readonly number[], + ) => { + vertex(a, normal) + vertex(b, normal) + vertex(c, normal) + vertex(a, normal) + vertex(c, normal) + vertex(d, normal) + } + for (const piece of pieces) { + const { left: l, right: r, bottom, top, front: f, back: k } = piece + const b = bottom + base + const t = top + base + quad([l, b, f], [r, b, f], [r, t, f], [l, t, f], [0, 0, 1]) + quad([r, b, k], [l, b, k], [l, t, k], [r, t, k], [0, 0, -1]) + quad([l, b, k], [l, b, f], [l, t, f], [l, t, k], [-1, 0, 0]) + quad([r, b, f], [r, b, k], [r, t, k], [r, t, f], [1, 0, 0]) + quad([l, b, k], [r, b, k], [r, b, f], [l, b, f], [0, -1, 0]) + quad([l, t, f], [r, t, f], [r, t, k], [l, t, k], [0, 1, 0]) + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) + ensureRenderableGeometryAttributes(geometry) + return geometry + } + const positions = new Float32Array(pieces.length * 6 * 6 * 3) + const normals = new Float32Array(positions.length) + let offset = 0 + const vertex = (point: readonly number[], normal: readonly number[]) => { + positions[offset] = point[0]! + normals[offset++] = normal[0]! + positions[offset] = point[1]! + normals[offset++] = normal[1]! + positions[offset] = point[2]! + normals[offset++] = normal[2]! + } const quad = (a: number[], b: number[], c: number[], d: number[], normal: number[]) => { - positions.push(...a, ...b, ...c, ...a, ...c, ...d) - for (let index = 0; index < 6; index++) normals.push(...normal) + vertex(a, normal) + vertex(b, normal) + vertex(c, normal) + vertex(a, normal) + vertex(c, normal) + vertex(d, normal) } const sides = ['left', 'right', 'bottom', 'top'] as const const key = (piece: CurtainWallPiece, side: (typeof sides)[number], coordinate = piece[side]) => @@ -67,8 +243,8 @@ export function buildStraightCurtainPieces( } } const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) - geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) + geometry.setAttribute('position', new Float32BufferAttribute(positions.subarray(0, offset), 3)) + geometry.setAttribute('normal', new Float32BufferAttribute(normals.subarray(0, offset), 3)) ensureRenderableGeometryAttributes(geometry) return geometry } diff --git a/packages/nodes/src/wall/curtain-wall-shadow.test.ts b/packages/nodes/src/wall/curtain-wall-shadow.test.ts index 5c38f3d891..145906598d 100644 --- a/packages/nodes/src/wall/curtain-wall-shadow.test.ts +++ b/packages/nodes/src/wall/curtain-wall-shadow.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test' -import { calculateLevelMiters, WallNode } from '@pascal-app/core' +import { calculateLevelMiters, useLiveNodeOverrides, WallNode, WindowNode } from '@pascal-app/core' import { generateExtrudedWall } from '@pascal-app/viewer' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' import { curtainWallGeometryAdapter } from './curtain-wall-adapter' @@ -68,3 +68,62 @@ test('painted opaque glass casts a pane shadow', () => { glass.dispose() solid.dispose() }) + +test('live opening previews defer curtain shadow rebuilding until the final geometry', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [6, 0], + height: 3, + thickness: 0.15, + wallType: 'curtain', + }) + const window = WindowNode.parse({ + parentId: wall.id, + position: [1.5, 1.5, 0], + openingShape: 'rounded', + }) + const frame = new MeshBasicMaterial() + const glass = new MeshBasicMaterial({ transparent: true, opacity: 0.4 }) + const solid = new MeshBasicMaterial() + const visible = buildCurtainWallGeometry( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + [window], + ) + const mesh = new Mesh(visible, [frame, glass, solid]) + const shadow = new Mesh(buildCurtainWallShadowGeometry(visible, false), new MeshBasicMaterial()) + shadow.name = 'curtain-wall-shadow' + mesh.add(shadow) + const committedShadow = shadow.geometry + + useLiveNodeOverrides.getState().set(window.id, { + cornerRadius: 0.3, + metadata: { deferParentRebuild: true }, + }) + const previewGeometry = curtainWallGeometryAdapter.buildGeometry!( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + [window], + ) + curtainWallGeometryAdapter.syncAuxiliaryGeometry!(wall, mesh, previewGeometry) + expect(shadow.geometry).toBe(committedShadow) + + useLiveNodeOverrides.getState().clear(window.id) + const finalGeometry = curtainWallGeometryAdapter.buildGeometry!( + wall, + generateExtrudedWall(wall, [], calculateLevelMiters([wall])), + [window], + ) + curtainWallGeometryAdapter.syncAuxiliaryGeometry!(wall, mesh, finalGeometry) + expect(shadow.geometry).not.toBe(committedShadow) + + committedShadow.dispose() + shadow.geometry.dispose() + ;(shadow.material as MeshBasicMaterial).dispose() + previewGeometry.dispose() + finalGeometry.dispose() + visible.dispose() + frame.dispose() + glass.dispose() + solid.dispose() +}) diff --git a/packages/nodes/src/wall/curtain-wall-shape-performance.test.ts b/packages/nodes/src/wall/curtain-wall-shape-performance.test.ts new file mode 100644 index 0000000000..2de9e19feb --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-shape-performance.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect, test } from 'bun:test' +import { LevelNode, sceneRegistry, useScene, WallNode, WindowNode } from '@pascal-app/core' +import { runWallBuildFrame } from '@pascal-app/viewer' +import { Mesh } from 'three' +import { curtainWallGeometryAdapter } from './curtain-wall-adapter' + +globalThis.requestAnimationFrame ??= () => 0 +globalThis.cancelAnimationFrame ??= () => undefined + +const initial = useScene.getState() + +afterEach(() => { + sceneRegistry.clear() + useScene.setState(initial) +}) + +test('switching a curtain window top shape stays within one frame', () => { + const level = LevelNode.parse({ height: 3 }) + const wall = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [30, 0], + wallType: 'curtain', + curtainWall: { + columns: { layout: 'count', count: 32 }, + rows: { layout: 'count', count: 32 }, + }, + }) + const window = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [15, 1.5, 0], + width: 1.2, + height: 1.2, + openingShape: 'rectangle', + }) + wall.children = [window.id] + level.children = [wall.id] + useScene.setState({ + nodes: { [level.id]: level, [wall.id]: wall, [window.id]: window }, + dirtyNodes: new Set([wall.id]), + }) + const mesh = new Mesh() + sceneRegistry.nodes.set(wall.id, mesh) + sceneRegistry.byType.wall!.add(wall.id) + runWallBuildFrame(curtainWallGeometryAdapter) + + const measurements: number[] = [] + for (const openingShape of ['rounded', 'rectangle', 'arch', 'rectangle'] as const) { + useScene.getState().updateNode(window.id, { openingShape }) + useScene.getState().markDirty(wall.id) + const started = performance.now() + runWallBuildFrame(curtainWallGeometryAdapter) + measurements.push(performance.now() - started) + } + + expect(Math.max(...measurements)).toBeLessThan(25) + mesh.geometry.dispose() +}) diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 9ab273f087..aaa3a61692 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -19,6 +19,8 @@ import { computeWindowFloorplanLevelData, } from '../shared/opening-documentation' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' +import { createOpeningPropertyPreview } from '../shared/opening-property-preview' +import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { readHostWallCeiling } from '../shared/wall-opening-ceiling' @@ -200,11 +202,55 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { + const corners = [ + [-1, 1], + [1, 1], + [1, -1], + [-1, -1], + ] as const + return { + kind: 'corner-radius', + corner: corners[index], + width: (node) => node.width, + height: (node) => node.height, + currentValue: (node) => + node.openingRadiusMode === 'individual' + ? (node.openingCornerRadii[index] ?? 0) + : node.cornerRadius, + max: (node) => Math.min(node.width, node.height) / 2, + apply: (node, radius, _scene, modifiers) => { + if (!modifiers.shiftKey) { + return { openingShape: 'rounded', openingRadiusMode: 'all', cornerRadius: radius } + } + const radii = + node.openingRadiusMode === 'individual' + ? [...node.openingCornerRadii] + : [node.cornerRadius, node.cornerRadius, node.cornerRadius, node.cornerRadius] + radii[index] = radius + return { + openingShape: 'rounded', + openingRadiusMode: 'individual', + openingCornerRadii: radii as [number, number, number, number], + } + }, + createPreview: (node) => + createOpeningPropertyPreview(node.id, openingPropertyPreviewHost), + visible: (node) => node.openingShape !== 'arch', + portal: 'grandparent', + portalTarget: resolveWindowHandlePortalTarget, + } +} + const windowHandles: HandleDescriptor[] = [ windowWidthHandle('left'), windowWidthHandle('right'), windowHeightHandle('top'), windowHeightHandle('bottom'), + windowRadiusHandle(0), + windowRadiusHandle(1), + windowRadiusHandle(2), + windowRadiusHandle(3), ] /** diff --git a/packages/nodes/src/window/property-preview.test.ts b/packages/nodes/src/window/property-preview.test.ts index 1bb653f6db..aa84d392fa 100644 --- a/packages/nodes/src/window/property-preview.test.ts +++ b/packages/nodes/src/window/property-preview.test.ts @@ -3,6 +3,12 @@ import { useLiveNodeOverrides, useScene, WallNode, WindowNode } from '@pascal-ap import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' +globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} + const initial = useScene.getState() afterEach(() => { useLiveNodeOverrides.getState().clearAll() @@ -22,7 +28,6 @@ test('window dimensions and curved-frame parameters preview without document mut preview.preview({ width: 0.4, cornerRadius: 0.2 }) expect(useScene.getState().nodes[window.id]).toBe(window) expect(window.cornerRadius).toBe(0.3) - expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) preview.preview({ width: 1.8, cornerRadius: 0.3 }) preview.commit() expect(useScene.getState().nodes[window.id]).toMatchObject({ width: 1.8, cornerRadius: 0.3 }) @@ -32,3 +37,101 @@ test('window dimensions and curved-frame parameters preview without document mut expect(useScene.getState().nodes[window.id]).toMatchObject({ archHeight: window.archHeight }) expect(useLiveNodeOverrides.getState().get(window.id)).toBeUndefined() }) + +test('rapid opening previews rebuild once per frame and commit immediately', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], wallType: 'curtain' }) + const window = WindowNode.parse({ + parentId: wall.id, + position: [3, 1.25, 0], + openingShape: 'rounded', + cornerRadius: 0.1, + }) + useScene.setState({ nodes: { [wall.id]: wall, [window.id]: window }, dirtyNodes: new Set() }) + const frames = new Map() + const delays = new Map void>() + let nextFrame = 1 + let nextDelay = 1 + const preview = createOpeningPropertyPreview(window.id, { + ...openingPropertyPreviewHost, + scheduleFrame: (callback) => { + const handle = nextFrame++ + frames.set(handle, callback) + return handle + }, + cancelFrame: (handle) => frames.delete(handle), + scheduleDelay: (callback, delay) => { + expect(delay).toBe(100) + const handle = nextDelay++ + delays.set(handle, callback) + return handle + }, + cancelDelay: (handle) => delays.delete(handle), + }) + + preview.preview({ cornerRadius: 0.2 }) + preview.preview({ cornerRadius: 0.3 }) + preview.preview({ cornerRadius: 0.4 }) + expect(frames.size).toBe(1) + expect(useLiveNodeOverrides.getState().get(window.id)).toMatchObject({ + cornerRadius: 0.4, + metadata: { deferParentRebuild: true }, + }) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(false) + + const [handle, frame] = frames.entries().next().value! + frames.delete(handle) + frame(0) + expect(useScene.getState().dirtyNodes.has(window.id)).toBe(true) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(false) + expect(delays.size).toBe(1) + + const [delayHandle, delayedParent] = delays.entries().next().value! + delays.delete(delayHandle) + delayedParent() + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) + + useScene.setState({ dirtyNodes: new Set() }) + preview.preview({ cornerRadius: 0.5 }) + const [commitFrameHandle, commitFrame] = frames.entries().next().value! + frames.delete(commitFrameHandle) + commitFrame(0) + expect(delays.size).toBe(1) + preview.commit() + expect(frames.size).toBe(0) + expect(delays.size).toBe(0) + expect(useScene.getState().nodes[window.id]).toMatchObject({ cornerRadius: 0.5 }) + expect(useLiveNodeOverrides.getState().get(window.id)).toBeUndefined() + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) + + useScene.setState({ dirtyNodes: new Set() }) + preview.preview({ cornerRadius: 0.6 }) + const [cancelFrameHandle, cancelFrame] = frames.entries().next().value! + frames.delete(cancelFrameHandle) + cancelFrame(0) + expect(delays.size).toBe(1) + preview.cancel() + expect(frames.size).toBe(0) + expect(delays.size).toBe(0) + expect(useScene.getState().nodes[window.id]).toMatchObject({ cornerRadius: 0.5 }) + expect(useLiveNodeOverrides.getState().get(window.id)).toBeUndefined() + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) +}) + +test('standard walls keep their opening and parent previews on the same frame', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0] }) + const window = WindowNode.parse({ parentId: wall.id, openingShape: 'rounded' }) + useScene.setState({ nodes: { [wall.id]: wall, [window.id]: window }, dirtyNodes: new Set() }) + const preview = createOpeningPropertyPreview(window.id, { + ...openingPropertyPreviewHost, + scheduleFrame: (callback) => { + callback(0) + return 0 + }, + }) + + preview.preview({ cornerRadius: 0.3 }) + + expect(useScene.getState().dirtyNodes.has(window.id)).toBe(true) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) + preview.cancel() +}) diff --git a/packages/viewer/src/systems/door/door-hinges.test.ts b/packages/viewer/src/systems/door/door-hinges.test.ts new file mode 100644 index 0000000000..599c8c0730 --- /dev/null +++ b/packages/viewer/src/systems/door/door-hinges.test.ts @@ -0,0 +1,49 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime. +import { expect, test } from 'bun:test' +import { DoorNode } from '@pascal-app/core' +import { Mesh } from 'three' +import { buildDoorPreviewMesh } from './door-system' + +function hingePositions(patch: Partial) { + const mesh = buildDoorPreviewMesh(DoorNode.parse(patch)) + const positions = mesh.children + .filter((child) => child instanceof Mesh && child.userData.slotId === 'hardware') + .map((child) => [Math.sign(child.position.x), Number(child.position.y.toFixed(3))]) + mesh.traverse((child) => { + if (child instanceof Mesh) child.geometry.dispose() + }) + return positions +} + +for (const hingesSide of ['left', 'right'] as const) { + test(`${hingesSide} upper hinge disappears when rounding reaches it and returns below that radius`, () => { + const sign = hingesSide === 'left' ? -1 : 1 + const door = { hingesSide, openingShape: 'rounded' as const } + expect(hingePositions({ ...door, cornerRadius: 0.3 })).toEqual([ + [sign, -0.8], + [sign, -0.025], + ]) + expect(hingePositions({ ...door, cornerRadius: 0.2 })).toEqual([ + [sign, -0.8], + [sign, -0.025], + [sign, 0.75], + ]) + }) +} + +test('individual rounding only removes hinges on the affected leaf of double doors', () => { + expect( + hingePositions({ + doorType: 'double', + openingShape: 'rounded', + openingRadiusMode: 'individual', + openingTopRadii: [0.3, 0], + }), + ).toEqual([ + [-1, -0.8], + [-1, -0.025], + [1, -0.8], + [1, -0.025], + [1, 0.75], + ]) +}) diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 1d4eac4712..a58cb69549 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -186,8 +186,9 @@ export const DoorSystem = () => { // Rebuild the parent wall so its cutout reflects the updated door geometry // Avoid triggering expensive wall CSG rebuilds while the door is being interactively moved/duplicated. // The editor tools will request a final wall rebuild on commit. - const isTransient = !!(node.metadata as Record | null)?.isTransient - if (!isTransient && effectiveNode.parentId) { + const metadata = effectiveNode.metadata as Record | null + const deferParentRebuild = !!metadata?.isTransient || !!metadata?.deferParentRebuild + if (!deferParentRebuild && effectiveNode.parentId) { useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId) } } @@ -1327,18 +1328,21 @@ function addDoorLeaf( const hingeH = 0.1 const hingeW = 0.024 const hingeD = leafDepth + 0.016 - addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) - addBox( - mesh, - hardwareMaterial, - hingeW, - hingeH, - hingeD, - hingeMarkerX, - (leafBottom + leafTop) / 2, - 0, - ) - addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) + const hingeRadii = roundedBoundary?.radii ?? openingTopRadii + const cornerDrop = + openingShape === 'rounded' + ? hingeSide === 'left' + ? hingeRadii.topLeft + : hingeRadii.topRight + : openingShape === 'arch' + ? getClampedArchHeight(leafWidth, leafHeight, archHeight) + : 0 + // Hinges need their full height supported by the straight jamb below the curve. + const straightEdgeTop = leafTop - cornerDrop + for (const hingeY of [leafBottom + 0.25, (leafBottom + leafTop) / 2, leafTop - 0.25]) { + if (hingeY + hingeH / 2 > straightEdgeTop + 1e-6) continue + addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, hingeY, 0) + } } // When the leaf is swung open it projects into the room and would otherwise diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 38a6f5dac1..ee93260b9e 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -167,8 +167,9 @@ export const WindowSystem = () => { // Rebuild the parent wall so its cutout reflects the updated window geometry // Avoid triggering expensive wall CSG rebuilds while the window is being interactively moved/duplicated. // The editor tools will request a final wall rebuild on commit. - const isTransient = !!(node.metadata as Record | null)?.isTransient - if (!isTransient && effectiveNode.parentId) { + const metadata = effectiveNode.metadata as Record | null + const deferParentRebuild = !!metadata?.isTransient || !!metadata?.deferParentRebuild + if (!deferParentRebuild && effectiveNode.parentId) { useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId) } } From cdbc7a54a4148583c1e454ac76e11afad458e61c Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 19:45:39 +0530 Subject: [PATCH 19/21] test(walls): avoid timing-sensitive curtain rebuild assertion --- .../src/wall/curtain-wall-shape-performance.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/nodes/src/wall/curtain-wall-shape-performance.test.ts b/packages/nodes/src/wall/curtain-wall-shape-performance.test.ts index 2de9e19feb..4cbc3982b0 100644 --- a/packages/nodes/src/wall/curtain-wall-shape-performance.test.ts +++ b/packages/nodes/src/wall/curtain-wall-shape-performance.test.ts @@ -45,15 +45,16 @@ test('switching a curtain window top shape stays within one frame', () => { sceneRegistry.byType.wall!.add(wall.id) runWallBuildFrame(curtainWallGeometryAdapter) - const measurements: number[] = [] for (const openingShape of ['rounded', 'rectangle', 'arch', 'rectangle'] as const) { useScene.getState().updateNode(window.id, { openingShape }) useScene.getState().markDirty(wall.id) - const started = performance.now() + const previousGeometry = mesh.geometry runWallBuildFrame(curtainWallGeometryAdapter) - measurements.push(performance.now() - started) + + expect(mesh.geometry).not.toBe(previousGeometry) + expect(mesh.geometry.getAttribute('position').count).toBeGreaterThan(0) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(false) } - expect(Math.max(...measurements)).toBeLessThan(25) mesh.geometry.dispose() }) From 6a6d89f6523ee09c83373473e3db45e4933d4a35 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 20:00:13 +0530 Subject: [PATCH 20/21] feat(ui): explain single-side corner radius shortcut --- .../components/ui/helpers/helper-manager.tsx | 4 ++++ packages/editor/src/lib/contextual-help.test.ts | 17 +++++++++++++++++ packages/editor/src/lib/contextual-help.ts | 10 ++++++++++ 3 files changed, 31 insertions(+) diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 730f4b453d..5950fec86c 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -195,10 +195,14 @@ export function HelperManager() { : single?.type === 'duct-fitting' || single?.type === 'pipe-fitting' ? 'fitting' : null + const hasRoundedOpeningSelection = + (single?.type === 'door' || single?.type === 'window') && + single.openingShape === 'rounded' return resolveSelectModeHelpHints({ selectedCount: selectedNodes.length, hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), + hasRoundedOpeningSelection, commandPressed: modifiers.command, shiftPressed: modifiers.shift, mepSelection, diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts index 72aec2c3af..0f548ef107 100644 --- a/packages/editor/src/lib/contextual-help.test.ts +++ b/packages/editor/src/lib/contextual-help.test.ts @@ -126,4 +126,21 @@ describe('resolveSelectModeHelpHints', () => { }, ]) }) + + test('shows the single-side radius hint for rounded openings', () => { + expect( + resolveSelectModeHelpHints({ + selectedCount: 1, + hasMovableSelection: true, + hasRotatableSelection: false, + hasRoundedOpeningSelection: true, + commandPressed: false, + shiftPressed: false, + }), + ).toContainEqual({ + keys: ['Shift'], + label: 'Adjust one side’s corner radius', + active: false, + }) + }) }) diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 62825c57c7..ad2b3b437e 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -43,6 +43,7 @@ export type SelectModeHelpContext = { selectedCount: number hasMovableSelection: boolean hasRotatableSelection: boolean + hasRoundedOpeningSelection?: boolean commandPressed: boolean shiftPressed: boolean // When a single MEP node is selected its in-world handle rig (click a dot to @@ -64,6 +65,7 @@ export function resolveSelectModeHelpHints({ selectedCount, hasMovableSelection, hasRotatableSelection, + hasRoundedOpeningSelection = false, commandPressed, shiftPressed, mepSelection = null, @@ -139,6 +141,14 @@ export function resolveSelectModeHelpHints({ }) } + if (hasRoundedOpeningSelection) { + hints.push({ + keys: [SHIFT_KEY], + label: 'Adjust one side’s corner radius', + active: shiftPressed, + }) + } + hints.push({ keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK], label: 'Add or remove objects from the selection', From c0deecbf9056eb48151b3383ffb4ddb36b87550f Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 22 Sep 2026 20:01:25 +0530 Subject: [PATCH 21/21] fix(ui): show radius hint for all openings --- .../editor/src/components/ui/helpers/helper-manager.tsx | 6 ++---- packages/editor/src/lib/contextual-help.test.ts | 2 +- packages/editor/src/lib/contextual-help.ts | 6 +++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 5950fec86c..3355ad23f7 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -195,14 +195,12 @@ export function HelperManager() { : single?.type === 'duct-fitting' || single?.type === 'pipe-fitting' ? 'fitting' : null - const hasRoundedOpeningSelection = - (single?.type === 'door' || single?.type === 'window') && - single.openingShape === 'rounded' + const hasOpeningRadiusSelection = single?.type === 'door' || single?.type === 'window' return resolveSelectModeHelpHints({ selectedCount: selectedNodes.length, hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), - hasRoundedOpeningSelection, + hasOpeningRadiusSelection, commandPressed: modifiers.command, shiftPressed: modifiers.shift, mepSelection, diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts index 0f548ef107..a826cbb640 100644 --- a/packages/editor/src/lib/contextual-help.test.ts +++ b/packages/editor/src/lib/contextual-help.test.ts @@ -133,7 +133,7 @@ describe('resolveSelectModeHelpHints', () => { selectedCount: 1, hasMovableSelection: true, hasRotatableSelection: false, - hasRoundedOpeningSelection: true, + hasOpeningRadiusSelection: true, commandPressed: false, shiftPressed: false, }), diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index ad2b3b437e..2214f8900d 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -43,7 +43,7 @@ export type SelectModeHelpContext = { selectedCount: number hasMovableSelection: boolean hasRotatableSelection: boolean - hasRoundedOpeningSelection?: boolean + hasOpeningRadiusSelection?: boolean commandPressed: boolean shiftPressed: boolean // When a single MEP node is selected its in-world handle rig (click a dot to @@ -65,7 +65,7 @@ export function resolveSelectModeHelpHints({ selectedCount, hasMovableSelection, hasRotatableSelection, - hasRoundedOpeningSelection = false, + hasOpeningRadiusSelection = false, commandPressed, shiftPressed, mepSelection = null, @@ -141,7 +141,7 @@ export function resolveSelectModeHelpHints({ }) } - if (hasRoundedOpeningSelection) { + if (hasOpeningRadiusSelection) { hints.push({ keys: [SHIFT_KEY], label: 'Adjust one side’s corner radius',