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/docs/images/curtain-wall-variations.png b/docs/images/curtain-wall-variations.png new file mode 100644 index 0000000000..f040afc6e0 Binary files /dev/null and b/docs/images/curtain-wall-variations.png differ diff --git a/package.json b/package.json index 04470f7ae0..10310d335c 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/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/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 3edfbea636..d51ac992a6 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -108,6 +108,13 @@ export { setConstructionDimensionDrawingSuppressedSegments, } from './nodes/construction-dimension' export { CupolaMaterialRole, CupolaNode } from './nodes/cupola' +export { + CurtainGrid, + CurtainPanelType, + CurtainWallConfig, + DEFAULT_CURTAIN_WALL, + getCurtainWallConfig, +} from './nodes/curtain-wall' export { DoorNode, DoorSegment, 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..ac90e0f08e --- /dev/null +++ b/packages/core/src/schema/nodes/curtain-wall.ts @@ -0,0 +1,59 @@ +import { z } from 'zod' + +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 +} 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/core/src/store/draft-attachment-snapshot.test.ts b/packages/core/src/store/draft-attachment-snapshot.test.ts index 7476cce6ed..d9c9cf7533 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({}) 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/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/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 730f4b453d..3355ad23f7 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -195,10 +195,12 @@ export function HelperManager() { : single?.type === 'duct-fitting' || single?.type === 'pipe-fitting' ? 'fitting' : null + 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)), + 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 72aec2c3af..a826cbb640 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, + hasOpeningRadiusSelection: 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..2214f8900d 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 + hasOpeningRadiusSelection?: 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, + hasOpeningRadiusSelection = false, commandPressed, shiftPressed, mepSelection = null, @@ -139,6 +141,14 @@ export function resolveSelectModeHelpHints({ }) } + if (hasOpeningRadiusSelection) { + 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', diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 80b8b71199..251bbed590 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -7,11 +7,14 @@ import type { WallNode, } from '@pascal-app/core' import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { curtainOpeningResizeMax } from '../shared/curtain-opening-limits' import { buildDoorFloorplanSchedule, 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' @@ -58,7 +61,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 +103,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) }, @@ -140,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/door/door-math.ts b/packages/nodes/src/door/door-math.ts index b5517c9dcf..479713f11d 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,4 +1,9 @@ -import 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] } /** @@ -47,11 +46,10 @@ 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(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/door/floorplan-affordances.ts b/packages/nodes/src/door/floorplan-affordances.ts index 38942e66a2..66cd29f0c5 100644 --- a/packages/nodes/src/door/floorplan-affordances.ts +++ b/packages/nodes/src/door/floorplan-affordances.ts @@ -3,10 +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 @@ -45,23 +48,18 @@ 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 // 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 + return wall ? projectPlanPointToWallLocalX(wall, planPoint) : planPoint[0] } // Initial pointer in wall-local X — anchored to where the user diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index eb7a1e597e..e866b16e26 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -15,8 +15,10 @@ 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 { OpeningDocumentationFields } from '../shared/opening-documentation-fields' +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 = [ @@ -138,11 +140,17 @@ 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, + openingPropertyPreviewHost, + ) + : null, + [selectedId], + ) + useEffect(() => () => preview?.cancel(), [preview]) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined, @@ -155,6 +163,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) @@ -172,54 +181,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: [] }) @@ -305,6 +270,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' @@ -584,21 +555,6 @@ 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} /> @@ -796,6 +755,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" @@ -814,6 +776,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" @@ -828,6 +793,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" @@ -840,7 +808,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} @@ -893,6 +864,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" @@ -911,6 +885,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" @@ -925,6 +902,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" @@ -937,7 +917,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..4c69c74681 --- /dev/null +++ b/packages/nodes/src/shared/curtain-opening-limits.test.ts @@ -0,0 +1,156 @@ +import { afterEach, expect, test } from 'bun:test' +import { + DoorNode, + getEffectiveNode, + getWallCurveFrameAt, + getWallCurveLength, + useLiveNodeOverrides, + useScene, + WallNode, + WindowNode, +} from '@pascal-app/core' +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' +import { projectPlanPointToWallLocalX } from './wall-attach-target' + +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('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('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) + const pointer = getWallCurveFrameAt(curvedWall, 0.75).point + 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', () => { + 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, 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) + 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('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('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) + 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..62f300e566 --- /dev/null +++ b/packages/nodes/src/shared/curtain-opening-limits.ts @@ -0,0 +1,87 @@ +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 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, + 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/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-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/shared/opening-property-preview-host.ts b/packages/nodes/src/shared/opening-property-preview-host.ts new file mode 100644 index 0000000000..24ff993731 --- /dev/null +++ b/packages/nodes/src/shared/opening-property-preview-host.ts @@ -0,0 +1,21 @@ +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), + 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 new file mode 100644 index 0000000000..f2447ddaed --- /dev/null +++ b/packages/nodes/src/shared/opening-property-preview.ts @@ -0,0 +1,114 @@ +import type { AnyNode, AnyNodeId, DoorNode, WindowNode } from '@pascal-app/core' +import { constrainCurtainOpening } from './curtain-opening-limits' + +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 + 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 + 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 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 + cancelScheduledDirty() + if (parentDelay !== undefined) { + dependencies.cancelDelay(parentDelay) + parentDelay = undefined + } + dependencies.clearOverrideFields(id, [...Object.keys(pending), 'metadata']) + pending = undefined + dirtyOpening() + dirtyParent() + } + return { + preview(patch: Partial) { + const nodes = dependencies.nodes() + const node = nodes[id] + if (node?.type !== 'door' && node?.type !== 'window') return + 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, + metadata: { ...node.metadata, ...effective.metadata, deferParentRebuild: true }, + } as Partial) + scheduleDirty() + }, + commit(patch?: Partial) { + const live = dependencies.override(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 as keyof typeof live]]), + ) + : undefined + const nodes = dependencies.nodes() + const node = nodes[id] + const updates = + node?.type === 'door' || node?.type === 'window' + ? constrainCurtainOpening(node as T, { ...current, ...patch } as Partial, nodes) + : {} + if (Object.keys(updates).length) dependencies.updateNode(id, updates as Partial) + clear() + }, + cancel: clear, + } +} 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/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/wall/curtain-opening-frame.test.ts b/packages/nodes/src/wall/curtain-opening-frame.test.ts new file mode 100644 index 0000000000..82beab4af5 --- /dev/null +++ b/packages/nodes/src/wall/curtain-opening-frame.test.ts @@ -0,0 +1,142 @@ +import { expect, test } from 'bun:test' +import { + calculateLevelMiters, + DoorNode, + sceneRegistry, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { generateExtrudedWall } from '@pascal-app/viewer' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { + buildCurtainOpeningFrame, + curtainOpeningProfile, + curtainProfileSpan, +} from './curtain-opening-frame' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' + +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]) +}) + +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({ + 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/nodes/src/wall/curtain-opening-frame.ts b/packages/nodes/src/wall/curtain-opening-frame.ts new file mode 100644 index 0000000000..67e783aa0e --- /dev/null +++ b/packages/nodes/src/wall/curtain-opening-frame.ts @@ -0,0 +1,123 @@ +import { + type DoorNode, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + type WallNode, + type WindowNode, +} from '@pascal-app/core' +import { buildOpeningCutoutShape, ensureRenderableGeometryAttributes } from '@pascal-app/viewer' +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 + 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(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) => { + 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 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), cutout: outer } +} + +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/nodes/src/wall/curtain-panel-highlight.ts b/packages/nodes/src/wall/curtain-panel-highlight.ts new file mode 100644 index 0000000000..59a1809d63 --- /dev/null +++ b/packages/nodes/src/wall/curtain-panel-highlight.ts @@ -0,0 +1,135 @@ +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, + enabled: boolean, +) { + 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(() => { + if (!enabled) return + 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(curveWall) + if (length <= 0) return + 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(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())) + } + 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(wallId) 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() + } + }, [ + enabled, + wallId, + startX, + startZ, + endX, + endZ, + curveOffset, + thickness, + left, + right, + bottom, + top, + ]) +} 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..5cce03bf57 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-adapter.ts @@ -0,0 +1,61 @@ +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' +import { buildCurtainWallShadowGeometry, CURTAIN_WALL_SHADOW_NAME } from './curtain-wall-shadow' + +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') { + return { envelopeChildren: [...children], renderChildren: [...children] } + } + const renderChildren = children.map((child) => { + if ((child.type !== 'door' && child.type !== 'window') || child.openingShape === 'rectangle') + return 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( + (child) => child.type !== 'door' && child.type !== 'window', + ), + renderChildren, + } + }, + buildGeometry(wall, envelope, children) { + 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] + shadowMesh.geometry.dispose() + shadowMesh.geometry = buildCurtainWallShadowGeometry(geometry, isOpaque(materials[1])) + }, +} diff --git a/packages/nodes/src/wall/curtain-wall-dense-opening.test.ts b/packages/nodes/src/wall/curtain-wall-dense-opening.test.ts new file mode 100644 index 0000000000..4cc7c07a9b --- /dev/null +++ b/packages/nodes/src/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 { generateExtrudedWall } from '@pascal-app/viewer' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' + +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/nodes/src/wall/curtain-wall-door-resize.test.ts b/packages/nodes/src/wall/curtain-wall-door-resize.test.ts new file mode 100644 index 0000000000..68f61553f1 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-door-resize.test.ts @@ -0,0 +1,69 @@ +import { afterEach, expect, test } from 'bun:test' +import { + DoorNode, + LevelNode, + sceneRegistry, + useLiveNodeOverrides, + useScene, + WallNode, +} from '@pascal-app/core' +import { runWallBuildFrame } from '@pascal-app/viewer' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { curtainWallGeometryAdapter } from './curtain-wall-adapter' + +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(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) + expect(useScene.getState().nodes[door.id]).toBe(door) + 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 { + mesh.geometry.dispose() + material.dispose() + } +}) 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..b4be2702cc --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-floorplan.ts @@ -0,0 +1,106 @@ +import { + type AnyNode, + type FloorplanGeometry, + getCurtainWallConfig, + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + type WallNode, +} from '@pascal-app/core' +import { curtainOpeningProfile, curtainProfileSpan } from './curtain-opening-frame' +import { buildCurtainWallLayout, type CurtainWallPiece } from './curtain-wall-layout' + +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-geometry.test.ts b/packages/nodes/src/wall/curtain-wall-geometry.test.ts new file mode 100644 index 0000000000..8f5d734e4c --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-geometry.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from 'bun:test' +import { + CurtainWallConfig, + calculateLevelMiters, + DoorNode, + getEffectiveNode, + getWallCurveLength, + sceneRegistry, + useLiveNodeOverrides, + WallNode, + WindowNode, +} 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' + +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('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('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({ + 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) + expect(first.visible[1]!.transparent).toBe(true) + 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 = getCurtainAwareWallMaterials({ ...wall, wallType: 'standard' }) + expect(standard.visible[1]!.transparent).toBe(false) + }) +}) diff --git a/packages/nodes/src/wall/curtain-wall-geometry.ts b/packages/nodes/src/wall/curtain-wall-geometry.ts new file mode 100644 index 0000000000..6ca20388c0 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-geometry.ts @@ -0,0 +1,280 @@ +import { + type AnyNode, + getCurtainWallConfig, + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + 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 { buildCurtainOpeningFrame, mapCurtainOpeningGeometryToWall } from './curtain-opening-frame' +import { + buildCurtainWallLayout, + type CurtainWallPiece, + curtainGridPositions, +} from './curtain-wall-layout' +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]) + 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[] = [], + livePreview = false, +): 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) => { + 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', + ) + 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) { + 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[] = [] + 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 }) => { + 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 allRolePieces = pieces.filter((piece) => piece.role === role) + const nearCut = (piece: CurtainWallPiece) => + isCurvedWall(wall) || + (!rectangular && (piece.left < safeLeft - 1e-6 || piece.right > safeRight + 1e-6)) || + cutRegions.some( + (region) => + piece.left < region.right && + piece.right > region.left && + piece.bottom < region.top && + piece.top > region.bottom, + ) + const partitionPieces = !rectangular || cutRegions.length > 0 + const rolePieces = allRolePieces.filter(nearCut) + 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 = null + if (!isCurvedWall(wall) && builtPieces.length) { + 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 && !rectangular) { + 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 ? [merged, ...frames] : frames, false) + merged?.dispose() + if (!combined) continue + merged = combined + } + 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 (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() + 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/nodes/src/wall/curtain-wall-layout.test.ts b/packages/nodes/src/wall/curtain-wall-layout.test.ts new file mode 100644 index 0000000000..508274a7b0 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-layout.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test' +import { CurtainGrid, CurtainWallConfig, WallNode } from '@pascal-app/core' +import { + buildCurtainWallLayout, + curtainGridPositions, + curtainPanelType, +} from './curtain-wall-layout' + +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('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', () => { + 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/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/nodes/src/wall/curtain-wall-materials.test.ts b/packages/nodes/src/wall/curtain-wall-materials.test.ts new file mode 100644 index 0000000000..1ba221c3fa --- /dev/null +++ b/packages/nodes/src/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 { resolveMaterialRef } from '@pascal-app/viewer' +import { Texture } from 'three' +import type { MeshStandardNodeMaterial } from 'three/webgpu' +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' + 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 = getCurtainAwareWallMaterials(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 = getCurtainAwareWallMaterials({ + ...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 = getCurtainAwareWallMaterials(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 = getCurtainAwareWallMaterials(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/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-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..03d55143ec --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-panel.tsx @@ -0,0 +1,678 @@ +'use client' + +import { + type CurtainGrid, + type CurtainPanelType, + type CurtainWallConfig, + getCurtainWallConfig, + getEffectiveNode, + getWallCurveLength, + getWallThickness, + type WallNode, +} from '@pascal-app/core' +import { + getLinearUnitLabel, + linearControlValueToMeters, + metersToLinearUnit, + PanelSection, + SegmentedControl, + SliderControl, + ToggleControl, +} 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 + 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, + profile = false, +}: { + profile?: boolean + label: string + value: number + min: number + max: number + unit: Props['unit'] + onChange: (value: number) => void + onCommit: () => void + onCancel: () => void +}) { + const millimeters = profile && unit === 'metric' + const display = (meters: number) => + millimeters ? meters * 1000 : metersToLinearUnit(meters, unit) + return ( + + onChange( + millimeters + ? Math.max(min, Math.min(max, next / 1000)) + : linearControlValueToMeters(next, unit, { minMeters: min, maxMeters: max }), + ) + } + 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, + unit, + onChange, + onCommit, + onCancel, +}: { + length: number + vertical?: boolean + 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: vertical ? 'Bottom' : 'Start', value: 'start' }, + { label: 'Center', value: 'center' }, + { 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` + })()} +

+
+ ) +} + +const PANEL_OPTIONS = [ + { label: 'Glass', value: 'glass' }, + { label: 'Solid', value: 'solid' }, + { 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, + unit, + onUpdate, + onPreview, + onCommit, + onCancel, +}: Props) { + const config = getCurtainWallConfig(node) + const update = (patch: Partial, preview = false) => { + ;(preview ? onPreview : onUpdate)(getCurtainWallUpdate(node, patch)) + } + + 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 + 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 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, + panelEditing, + ) + 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 })} + 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="Column layout" + length={getWallCurveLength(node)} + unit={unit} + value={config.columns} + /> + update({ rows })} + onPreview={(rows) => update({ rows }, true)} + onCommit={onCommit} + onCancel={onCancel} + title="Row layout" + length={height} + vertical + 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} + /> +

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

+ update({ glassThickness }, true)} + unit={unit} + value={config.glassThickness} + /> + {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. +

+ )} +
+ + + {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. +

+ +
+ )} +

+ 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 && ( + + {paintedGlass && ( +
+

Glass appearance is controlled by the paint tool.

+ +
+ )} + {!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} + /> + + {tintEditing && ( +
+ + +
+ )} +

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

+
+ )} +
+ )} + + ) +} diff --git a/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts b/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts new file mode 100644 index 0000000000..23e808ceb5 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-piece-geometry.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from 'bun:test' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector2, Vector3 } from 'three' +import type { CurtainWallPiece } from './curtain-wall-layout' +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 } + 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() +}) + +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 new file mode 100644 index 0000000000..43d3caf83d --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-piece-geometry.ts @@ -0,0 +1,250 @@ +import { subtractPolygonsFromPolygon } from '@pascal-app/core' +import { ensureRenderableGeometryAttributes } from '@pascal-app/viewer' +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, +) { + 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[]) => { + 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]) => + `${side}:${coordinate.toFixed(7)}:${piece.front.toFixed(7)}:${piece.back.toFixed(7)}` + const edges = new Map() + 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], [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' + 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], [-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.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-preview.test.ts b/packages/nodes/src/wall/curtain-wall-preview.test.ts new file mode 100644 index 0000000000..c5a8d01928 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-preview.test.ts @@ -0,0 +1,61 @@ +import { afterEach, expect, test } from 'bun:test' +import { + CurtainWallConfig, + sceneRegistry, + useLiveNodeOverrides, + useScene, + WallNode, +} from '@pascal-app/core' +import { WallCutoutCache, type WallCutoutViewerState } from '@pascal-app/viewer' +import { Mesh, PerspectiveCamera } from 'three' +import { getCurtainAwareWallMaterials } from './curtain-wall-materials' + +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 }, getCurtainAwareWallMaterials) + 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/nodes/src/wall/curtain-wall-shadow.test.ts b/packages/nodes/src/wall/curtain-wall-shadow.test.ts new file mode 100644 index 0000000000..145906598d --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-shadow.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from 'bun:test' +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' +import { buildCurtainWallGeometry } from './curtain-wall-geometry' +import { buildCurtainWallShadowGeometry } from './curtain-wall-shadow' + +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() +}) + +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() +}) + +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-shadow.ts b/packages/nodes/src/wall/curtain-wall-shadow.ts new file mode 100644 index 0000000000..6a2e83c3b9 --- /dev/null +++ b/packages/nodes/src/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/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..4cbc3982b0 --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-shape-performance.test.ts @@ -0,0 +1,60 @@ +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) + + for (const openingShape of ['rounded', 'rectangle', 'arch', 'rectangle'] as const) { + useScene.getState().updateNode(window.id, { openingShape }) + useScene.getState().markDirty(wall.id) + const previousGeometry = mesh.geometry + runWallBuildFrame(curtainWallGeometryAdapter) + + expect(mesh.geometry).not.toBe(previousGeometry) + expect(mesh.geometry.getAttribute('position').count).toBeGreaterThan(0) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(false) + } + + mesh.geometry.dispose() +}) diff --git a/packages/nodes/src/wall/curtain-wall-window-resize.test.ts b/packages/nodes/src/wall/curtain-wall-window-resize.test.ts new file mode 100644 index 0000000000..5174dad43a --- /dev/null +++ b/packages/nodes/src/wall/curtain-wall-window-resize.test.ts @@ -0,0 +1,79 @@ +import { afterEach, expect, test } from 'bun:test' +import { + LevelNode, + sceneRegistry, + useLiveNodeOverrides, + useScene, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { runWallBuildFrame } from '@pascal-app/viewer' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { curtainWallGeometryAdapter } from './curtain-wall-adapter' + +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(curtainWallGeometryAdapter) + expect(hit(0.85, 1.4).length).toBeGreaterThan(0) + useLiveNodeOverrides.getState().set(window.id, { width: 1.6 }) + useScene.getState().markDirty(wall.id) + 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) + 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(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(curtainWallGeometryAdapter) + 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/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..760af97062 --- /dev/null +++ b/packages/nodes/src/wall/property-preview.test.ts @@ -0,0 +1,85 @@ +import { afterEach, expect, test } from 'bun:test' +import { + CurtainWallConfig, + getEffectiveNode, + subscribeSceneCommits, + useLiveNodeOverrides, + useScene, + WallNode, +} from '@pascal-app/core' +import { getCurtainWallUpdate } from './curtain-wall-panel' +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) +}) + +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/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..572374c19d 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -10,8 +10,8 @@ import { type WallNode, } from '@pascal-app/core' import { - getVisibleWallMaterials, NodeRenderer, + SHADOW_ONLY_LAYER, useLibraryMaterialsVersion, useNodeEvents, useViewer, @@ -20,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, @@ -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 @@ -154,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( - node, + 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), @@ -178,18 +181,32 @@ const WallRenderer = ({ node }: { node: WallNode }) => { return ( - - + {}} + > + + - {hasWallTreatments(treatmentNode) && ( + {node.wallType !== 'curtain' && hasWallTreatments(treatmentNode) && ( { return ( <> - - + + ) diff --git a/packages/nodes/src/wall/wall-batch-system.tsx b/packages/nodes/src/wall/wall-batch-system.tsx index e9a366a89d..59d03acf08 100644 --- a/packages/nodes/src/wall/wall-batch-system.tsx +++ b/packages/nodes/src/wall/wall-batch-system.tsx @@ -161,6 +161,8 @@ function toCandidate( ): WallBatchCandidate | null { if (excludedNodeIds.has(nodeId)) return null if (node.visible === false) return null + // Curtain walls use a separate caster that excludes their transparent panes. + if (node.wallType === 'curtain') return null const mesh = sceneRegistry.nodes.get(nodeId) as Mesh | undefined if (!mesh?.visible) return null diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index f590fb354e..aaa3a61692 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -13,11 +13,14 @@ import { getDormerWallOpeningVerticalBounds, } from '@pascal-app/core' import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { curtainOpeningResizeMax } from '../shared/curtain-opening-limits' import { buildWindowFloorplanSchedule, 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' @@ -121,7 +124,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor n.width, onDrag: (node) => publishOpeningResizeGuides(node, true), @@ -169,6 +172,8 @@ 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/floorplan-affordances.ts b/packages/nodes/src/window/floorplan-affordances.ts index deacafee91..7a28ee92a9 100644 --- a/packages/nodes/src/window/floorplan-affordances.ts +++ b/packages/nodes/src/window/floorplan-affordances.ts @@ -2,11 +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 @@ -43,18 +46,15 @@ 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 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 + return wall ? projectPlanPointToWallLocalX(wall, planPoint) : planPoint[0] } const initialPointerLocalX = projectToWallLocalX(initialPlanPoint) diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index 4ec23c4e83..bbb3973069 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -21,8 +21,10 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' -import { useCallback, useRef } from 'react' -import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' +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') { @@ -62,13 +64,6 @@ function normalizeWindowCornerRadii( return next.map((radius) => 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 +91,17 @@ 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 + ? createOpeningPropertyPreview( + selectedId as AnyNodeId, + openingPropertyPreviewHost, + ) + : null, + [selectedId], + ) + useEffect(() => () => preview?.cancel(), [preview]) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined, @@ -114,6 +115,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) @@ -128,57 +130,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: [] }) @@ -255,6 +210,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 @@ -320,31 +276,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 +367,6 @@ export default function WindowPanel() { /> - - - - {showWindowTypeSection && (
@@ -562,23 +496,36 @@ export default function WindowPanel() { + {limits && ( +

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

+ )} handleUpdate(getDimensionUpdates({ width: v }))} + max={limits?.width} + min={0.01} + 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} + step={0.01} unit="m" value={node.width} /> handleUpdate(getDimensionUpdates({ height: v }))} + max={limits?.height} + min={0.01} + 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} + step={0.01} unit="m" value={node.height} /> @@ -628,6 +575,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 +598,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 +615,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 +631,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 +678,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 +701,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 +718,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 +734,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 +756,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 +769,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..aa84d392fa --- /dev/null +++ b/packages/nodes/src/window/property-preview.test.ts @@ -0,0 +1,137 @@ +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' + +globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} + +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, + position: [3, 1.25, 0], + openingShape: 'rounded', + cornerRadius: 0.3, + }) + useScene.setState({ nodes: { [wall.id]: wall, [window.id]: window }, dirtyNodes: new Set() }) + 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) + 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() +}) + +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/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index 08ff4cb329..fda2ff7087 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,4 +1,11 @@ -import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + getCurtainWallConfig, + getWallCurveFrameAt, + getWallCurveLength, + type WallNode, +} from '@pascal-app/core' import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** @@ -24,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] } /** @@ -50,13 +51,12 @@ 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 - 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 } } 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/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 22c7f94fa1..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, @@ -285,19 +287,35 @@ export { StairSystem } from './systems/stair/stair-system' // (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/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/wall/wall-cutout-cache.ts b/packages/viewer/src/systems/wall/wall-cutout-cache.ts index 5c24245e86..0afba26fd1 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, @@ -22,6 +24,7 @@ import { getMaterialsForWall, getSelectionHighlightMaterials, type WallMaterials, + type WallMaterialsResolver, } from './wall-materials' export function sameMaterialArray(a: Material | Material[], b: Material[]): boolean { @@ -128,8 +131,12 @@ export class WallCutoutCache { private selected = new Set() private highlightKey = '' 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) => { @@ -149,6 +156,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 +199,7 @@ export class WallCutoutCache { const releasedPreview = previewId(previous) !== previewId(viewer) ? previewId(previous) : null this.viewer = viewer const invalidated = + overridesChanged || appearanceChanged || nodesChanged || registryChanged || @@ -259,17 +269,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 +294,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 @@ -313,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 81a0915964..ee2f26fc1b 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -55,8 +55,23 @@ export interface WallMaterials { deleteInvisible: WallMaterialArray deleteTranslucent: WallMaterialArray materialHash: string + 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(() => { @@ -448,9 +463,11 @@ export function getWallMaterialHash( wallNode: WallNode, shading: RenderShading, sceneMaterials?: SceneMaterials, + overrideHash?: string, ): string { return JSON.stringify({ shading, + overrideHash, interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials), exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials), lowerInterior: wallSlotMaterialSignature(wallNode, 'lowerInterior', sceneMaterials), @@ -471,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) @@ -484,6 +502,7 @@ export function getMaterialsForWall( if (existing) { disposeOwnedMaterials([ + ...(existing.ownedVisible ? [existing.ownedVisible] : []), existing.invisible, existing.translucent, existing.deleteVisible, @@ -493,26 +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 - ? [ - 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 = [ @@ -595,6 +617,7 @@ export function getMaterialsForWall( deleteInvisible, deleteTranslucent, materialHash, + 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 ce152367fb..407cffc50d 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -665,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) { @@ -682,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 @@ -788,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) @@ -871,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) @@ -962,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 @@ -991,28 +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 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, - childrenNodes, + prepared.envelopeChildren, miterData, slabElevation, slabSupport.baseElevation, @@ -1029,13 +1050,16 @@ 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 = + geometryAdapter?.buildGeometry?.(node, builtGeo, prepared.renderChildren) ?? 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 + geometryAdapter?.syncAuxiliaryGeometry?.(node, mesh, newGeo) // Update collision mesh const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh if (collisionMesh) { 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) } } diff --git a/wiki/curtain-walls.md b/wiki/curtain-walls.md new file mode 100644 index 0000000000..73f172945f --- /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. 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. +- 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 + +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.