From dd5f63df640ec2f96e51aa786488229a8488e303 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 17 Feb 2026 16:22:45 +0900 Subject: [PATCH] wall tool draft --- .../components/editor/selection-manager.tsx | 6 +- apps/editor/components/tools/tool-manager.tsx | 2 + .../components/tools/window/window-tool.tsx | 333 ++++++++++++++++++ .../ui/action-menu/structure-tools.tsx | 2 +- .../sidebar/panels/site-panel/tree-node.tsx | 3 + .../panels/site-panel/window-tree-node.tsx | 50 +++ apps/editor/store/use-editor.tsx | 1 + packages/core/src/events/bus.ts | 4 +- .../hooks/scene-registry/scene-registry.ts | 1 + packages/core/src/index.ts | 5 +- packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/window.ts | 46 +++ packages/core/src/schema/types.ts | 2 + .../core/src/systems/wall/wall-system.tsx | 2 +- .../core/src/systems/window/window-system.tsx | 69 ++++ .../components/renderers/node-renderer.tsx | 2 + .../renderers/window/window-renderer.tsx | 24 ++ .../viewer/src/components/viewer/index.tsx | 3 +- .../components/viewer/selection-manager.tsx | 8 +- 19 files changed, 553 insertions(+), 11 deletions(-) create mode 100644 apps/editor/components/tools/window/window-tool.tsx create mode 100644 apps/editor/components/ui/sidebar/panels/site-panel/window-tree-node.tsx create mode 100644 packages/core/src/schema/nodes/window.ts create mode 100644 packages/core/src/systems/window/window-system.tsx create mode 100644 packages/viewer/src/components/renderers/window/window-renderer.tsx diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index acd9f0cb..ffe551c2 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -20,7 +20,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => { return nodeLevelId === currentLevelId; }; -type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof'; +type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window'; interface SelectionStrategy { types: SelectableNodeType[]; @@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record = { }, structure: { - types: ["wall", "item", "zone", "slab", "ceiling", "roof"], + types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window"], handleSelect: (node, isShift) => { const { selection, setSelection } = useViewer.getState(); if (node.type === 'zone') { @@ -80,6 +80,8 @@ const SELECTION_STRATEGIES: Record = { (node as ItemNode).asset.category === "window" ); } + if (node.type === "window") return true; + return false; } }, diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 5a5ec72c..5de5725e 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -12,6 +12,7 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor' import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabTool } from './slab/slab-tool' import { WallTool } from './wall/wall-tool' +import { WindowTool } from './window/window-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneTool } from './zone/zone-tool' @@ -26,6 +27,7 @@ const tools: Record>> = { roof: RoofTool, item: ItemTool, zone: ZoneTool, + window: WindowTool, }, furnish: { item: ItemTool, diff --git a/apps/editor/components/tools/window/window-tool.tsx b/apps/editor/components/tools/window/window-tool.tsx new file mode 100644 index 00000000..10c7663b --- /dev/null +++ b/apps/editor/components/tools/window/window-tool.tsx @@ -0,0 +1,333 @@ +import { + type AnyNodeId, + emitter, + type ItemNode, + useScene, + type WallEvent, + type WallNode, + WindowNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef } from 'react' +import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { + calculateCursorRotation, + calculateItemRotation, + getSideFromNormal, + isValidWallSideFace, + snapToHalf, +} from '../item/placement-math' + +// Shared edge material — reuse across renders, just toggle color +const edgeMaterial = new LineBasicNodeMaterial({ + color: 0xef4444, // red-500 default (invalid) + linewidth: 3, + depthTest: false, + depthWrite: false, +}) + +/** + * Converts wall-local (X along wall, Y = height) to world XYZ. + * Wall-local Y maps directly to world Y; X maps along the wall direction. + */ +function wallLocalToWorld( + wallNode: WallNode, + localX: number, + localY: number, +): [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), + localY, + wallNode.start[1] + localX * Math.sin(wallAngle), + ] +} + +/** + * Clamps window center position so it stays fully within wall bounds. + */ +function clampToWall( + wallNode: WallNode, + localX: number, + localY: number, + 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 wallHeight = wallNode.height ?? 2.5 + + const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) + return { clampedX, clampedY } +} + +/** + * Directly checks the wall's children for bounding-box overlap with a proposed window. + * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). + * The spatial grid only tracks `item` nodes, so windows must be checked this way. + * Reads the wall's latest children from the store (not the event node) to avoid stale data. + */ +function hasWallChildOverlap( + wallId: string, + clampedX: number, + clampedY: number, + width: number, + height: number, + ignoreId?: string, +): boolean { + const nodes = useScene.getState().nodes + const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined + if (!wallNode) return true // Block if wall not found + const halfW = width / 2 + const halfH = height / 2 + const newBottom = clampedY - halfH + const newTop = clampedY + halfH + const newLeft = clampedX - halfW + const newRight = clampedX + halfW + + for (const childId of wallNode.children) { + if (childId === ignoreId) continue + const child = nodes[childId as AnyNodeId] + if (!child) continue + + let childLeft: number, childRight: number, childBottom: number, childTop: number + + if (child.type === 'item') { + const item = child as ItemNode + if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue + const [w, h] = item.asset.dimensions + childLeft = item.position[0] - w / 2 + childRight = item.position[0] + w / 2 + childBottom = item.position[1] // items store bottom Y + childTop = item.position[1] + h + } else if (child.type === 'window') { + const win = child as WindowNode + childLeft = win.position[0] - win.width / 2 + childRight = win.position[0] + win.width / 2 + childBottom = win.position[1] - win.height / 2 // windows store center Y + childTop = win.position[1] + win.height / 2 + } else { + continue + } + + const xOverlap = newLeft < childRight && newRight > childLeft + const yOverlap = newBottom < childTop && newTop > childBottom + if (xOverlap && yOverlap) return true + } + + return false +} + +/** + * Window tool — places WindowNodes on walls only. + * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions. + */ +export const WindowTool: React.FC = () => { + const draftRef = useRef(null) + const cursorGroupRef = useRef(null!) + const edgesRef = useRef(null!) + + useEffect(() => { + useScene.temporal.getState().pause() + + const getLevelId = () => useViewer.getState().selection.levelId + + const markWallDirty = (wallId: string) => { + useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + } + + const destroyDraft = () => { + if (!draftRef.current) return + const wallId = draftRef.current.parentId + useScene.getState().deleteNode(draftRef.current.id) + draftRef.current = null + // Rebuild wall so it removes the cutout from the deleted draft + if (wallId) markWallDirty(wallId) + } + + const hideCursor = () => { + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + } + + const updateCursor = ( + worldPosition: [number, number, number], + cursorRotationY: number, + valid: boolean, + ) => { + const group = cursorGroupRef.current + if (!group) return + group.visible = true + group.position.set(...worldPosition) + group.rotation.y = cursorRotationY + edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444) + } + + const onWallEnter = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + const levelId = getLevelId() + if (!levelId) return + + destroyDraft() + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + + const width = 1.5 + const height = 1.5 + + const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) + + const node = WindowNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + metadata: { isTransient: true }, + }) + + useScene.getState().createNode(node, event.node.id as AnyNodeId) + draftRef.current = node + + const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id) + + updateCursor(wallLocalToWorld(event.node, clampedX, clampedY), cursorRotation, valid) + event.stopPropagation() + } + + const onWallMove = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + + const width = draftRef.current?.width ?? 1.5 + const height = draftRef.current?.height ?? 1.5 + + const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) + + if (draftRef.current) { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) + } + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, width, height, + draftRef.current?.id, + ) + + updateCursor(wallLocalToWorld(event.node, clampedX, clampedY), cursorRotation, valid) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!draftRef.current) return + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, localY, + draftRef.current.width, draftRef.current.height, + ) + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + draftRef.current.width, draftRef.current.height, + draftRef.current.id, + ) + if (!valid) return + + const draft = draftRef.current + draftRef.current = null + + // Delete transient draft (paused, invisible to undo) + useScene.getState().deleteNode(draft.id) + + // Resume → create permanent node (single undoable action) + useScene.temporal.getState().resume() + + const node = WindowNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + width: draft.width, + height: draft.height, + frameThickness: draft.frameThickness, + frameDepth: draft.frameDepth, + columnRatios: draft.columnRatios, + rowRatios: draft.rowRatios, + dividerThickness: draft.dividerThickness, + sill: draft.sill, + sillDepth: draft.sillDepth, + sillThickness: draft.sillThickness, + }) + + useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.temporal.getState().pause() + + event.stopPropagation() + } + + const onWallLeave = () => { + destroyDraft() + hideCursor() + } + + const onCancel = () => { + destroyDraft() + hideCursor() + } + + emitter.on('wall:enter', onWallEnter) + emitter.on('wall:move', onWallMove) + emitter.on('wall:click', onWallClick) + emitter.on('wall:leave', onWallLeave) + emitter.on('tool:cancel', onCancel) + + return () => { + destroyDraft() + hideCursor() + useScene.temporal.getState().resume() + emitter.off('wall:enter', onWallEnter) + emitter.off('wall:move', onWallMove) + emitter.off('wall:click', onWallClick) + emitter.off('wall:leave', onWallLeave) + emitter.off('tool:cancel', onCancel) + } + }, []) + + // Cursor geometry: window outline rectangle (width × height × frameDepth) + const boxGeo = new BoxGeometry(1.5, 1.5, 0.07) + const edgesGeo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + + return ( + + + + ) +} diff --git a/apps/editor/components/ui/action-menu/structure-tools.tsx b/apps/editor/components/ui/action-menu/structure-tools.tsx index b228c643..7b698553 100644 --- a/apps/editor/components/ui/action-menu/structure-tools.tsx +++ b/apps/editor/components/ui/action-menu/structure-tools.tsx @@ -18,7 +18,7 @@ export const tools: ToolConfig[] = [ { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, { id: 'item', iconSrc: '/icons/door.png', label: 'Door', catalogCategory: 'door' }, - { id: 'item', iconSrc: '/icons/window.png', label: 'Window', catalogCategory: 'window' }, + { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, ] diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx index a7f829f9..711e5d7f 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -9,6 +9,7 @@ import { LevelTreeNode } from "./level-tree-node"; import { RoofTreeNode } from "./roof-tree-node"; import { SlabTreeNode } from "./slab-tree-node"; import { WallTreeNode } from "./wall-tree-node"; +import { WindowTreeNode } from "./window-tree-node"; import { ZoneTreeNode } from "./zone-tree-node"; interface TreeNodeProps { @@ -36,6 +37,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) { return ; case "item": return ; + case "window": + return ; case "zone": return ; default: diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/window-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/window-tree-node.tsx new file mode 100644 index 00000000..d7cee760 --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/site-panel/window-tree-node.tsx @@ -0,0 +1,50 @@ +'use client' + +import { WindowNode } from "@pascal-app/core" +import { useViewer } from "@pascal-app/viewer" +import Image from "next/image" +import { useState } from "react" +import { RenamePopover } from "./rename-popover" +import { TreeNodeWrapper } from "./tree-node" +import { TreeNodeActions } from "./tree-node-actions" + +interface WindowTreeNodeProps { + node: WindowNode + depth: number +} + +export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) { + const [renameOpen, setRenameOpen] = useState(false) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)) + const isHovered = useViewer((state) => state.hoveredId === node.id) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const defaultName = `Window (${node.width}×${node.height}m)` + + return ( + + } + label={node.name || defaultName} + depth={depth} + hasChildren={false} + expanded={false} + onToggle={() => {}} + onClick={() => setSelection({ selectedIds: [node.id] })} + onDoubleClick={() => setRenameOpen(true)} + onMouseEnter={() => setHoveredId(node.id)} + onMouseLeave={() => setHoveredId(null)} + isSelected={isSelected} + isHovered={isHovered} + isVisible={node.visible !== false} + actions={} + /> + + ) +} diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 1eb9e574..7ded1403 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -27,6 +27,7 @@ export type StructureTool = | 'stair' | 'item' | 'zone' + | 'window' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 4e72c79b..3cd26468 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,6 @@ import type { ThreeEvent } from '@react-three/fiber' import mitt from 'mitt' -import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema' import type { AnyNode } from '../schema/types' // Base event interfaces @@ -27,6 +27,7 @@ export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent +export type WindowEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -81,6 +82,7 @@ type EditorEvents = GridEvents & NodeEvents<'slab', SlabEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'roof', RoofEvent> & + NodeEvents<'window', WindowEvent> & CameraControlEvents & ToolEvents diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 0e0b7a49..5701b1bd 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -19,6 +19,7 @@ export const sceneRegistry = { roof: new Set(), scan: new Set(), guide: new Set(), + window: new Set(), }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ee679cf..a9eabea1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,17 +3,17 @@ export type { BuildingEvent, CameraControlEvent, + CeilingEvent, EventSuffix, GridEvent, ItemEvent, LevelEvent, NodeEvent, + RoofEvent, SiteEvent, SlabEvent, WallEvent, ZoneEvent, - CeilingEvent, - RoofEvent, } from './events/bus' // Events export { emitter, eventSuffixes } from './events/bus' @@ -37,6 +37,7 @@ export { ItemSystem } from './systems/item/item-system' export { RoofSystem } from './systems/roof/roof-system' export { SlabSystem } from './systems/slab/slab-system' export { WallSystem } from './systems/wall/wall-system' +export { WindowSystem } from './systems/window/window-system' export { isObject } from './utils/types' // Asset storage diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 8c0d8222..934e89e1 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -17,5 +17,6 @@ export { RoofNode } from './nodes/roof' export { ScanNode } from './nodes/scan' export { GuideNode } from './nodes/guide' export type { AnyNodeId, AnyNodeType } from './types' +export { WindowNode } from './nodes/window' // Union types export { AnyNode } from './types' diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts new file mode 100644 index 00000000..7d415e49 --- /dev/null +++ b/packages/core/src/schema/nodes/window.ts @@ -0,0 +1,46 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const WindowNode = BaseNode.extend({ + id: objectId('window'), + type: nodeType('window'), + + // Position in wall-local coordinate system (center of window) + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + side: z.enum(['front', 'back']).optional(), + + // Wall reference + wallId: z.string().optional(), + + // Overall dimensions + width: z.number().default(1.5), + height: z.number().default(1.5), + + // Frame + frameThickness: z.number().default(0.05), + frameDepth: z.number().default(0.07), + + // Divisions — ratios allow non-uniform panes + // [0.5, 0.5] = two equal panes + // [0.6, 0.4] = one larger, one smaller + // [1] = single pane (no division) + columnRatios: z.array(z.number()).default([1]), + rowRatios: z.array(z.number()).default([1]), + dividerThickness: z.number().default(0.03), + + // Sill + sill: z.boolean().default(true), + sillDepth: z.number().default(0.08), + sillThickness: z.number().default(0.03), +}).describe(dedent`Window node - a parametric window placed on a wall + - position: center of the window in wall-local coordinate system + - width/height: overall outer dimensions + - frameThickness: width of the frame members + - frameDepth: how deep the frame sits within the wall + - columnRatios/rowRatios: pane division ratios + - sill: whether to show a window sill +`) + +export type WindowNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index bd97e9f1..6e7454c6 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -9,6 +9,7 @@ import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' import { WallNode } from './nodes/wall' +import { WindowNode } from './nodes/window' import { ZoneNode } from './nodes/zone' export const AnyNode = z.discriminatedUnion('type', [ @@ -23,6 +24,7 @@ export const AnyNode = z.discriminatedUnion('type', [ RoofNode, ScanNode, GuideNode, + WindowNode, ]) export type AnyNode = z.infer diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 133f3d1f..31208a1c 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -309,7 +309,7 @@ function collectCutoutBrushes( const wallMatrixInverse = wallMesh.matrixWorld.clone().invert() for (const child of childrenNodes) { - if (child.type !== 'item') continue + if (child.type !== 'item' && child.type !== 'window') continue const childMesh = sceneRegistry.nodes.get(child.id) if (!childMesh) continue diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/core/src/systems/window/window-system.tsx new file mode 100644 index 00000000..81e481c7 --- /dev/null +++ b/packages/core/src/systems/window/window-system.tsx @@ -0,0 +1,69 @@ +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' +import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import type { AnyNodeId, WindowNode } from '../../schema' +import useScene from '../../store/use-scene' + +const glassMaterial = new MeshStandardNodeMaterial({ + name: 'glass', + color: 'lightgray', + roughness: 0.8, + metalness: 0, + transparent: true, + opacity: 0.35, + side: DoubleSide, + depthWrite: false, +}) + +export const WindowSystem = () => { + const dirtyNodes = useScene((state) => state.dirtyNodes) + const clearDirty = useScene((state) => state.clearDirty) + + useFrame(() => { + if (dirtyNodes.size === 0) return + + const nodes = useScene.getState().nodes + + dirtyNodes.forEach((id) => { + const node = nodes[id] + if (!node || node.type !== 'window') return + + const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh + if (!mesh) return // Keep dirty until mesh mounts + + updateWindowMesh(node as WindowNode, mesh) + clearDirty(id as AnyNodeId) + + // Rebuild the parent wall so its cutout reflects the updated window geometry + if ((node as WindowNode).parentId) { + useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId) + } + }) + }) + + return null +} + +function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { + // Replace geometry with a box matching the overall window dimensions + mesh.geometry.dispose() + mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) + mesh.material = glassMaterial + + // Sync transform from node (React may lag behind the system by a frame during drag) + mesh.position.set(node.position[0], node.position[1], node.position[2]) + mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2]) + + // Update (or create) the named cutout mesh used by wall-system for CSG subtraction + let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined + if (!cutout) { + cutout = new THREE.Mesh() + cutout.name = 'cutout' + mesh.add(cutout) + } + cutout.geometry.dispose() + // Extends 1m through the wall so the CSG brush covers full wall thickness + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + cutout.visible = false; +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index b3aacaea..02fd4d9c 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -11,6 +11,7 @@ import { ScanRenderer } from './scan/scan-renderer' import { SiteRenderer } from './site/site-renderer' import { SlabRenderer } from './slab/slab-renderer' import { WallRenderer } from './wall/wall-renderer' +import { WindowRenderer } from './window/window-renderer' import { ZoneRenderer } from './zone/zone-renderer' export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { @@ -27,6 +28,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'item' && } {node.type === 'slab' && } {node.type === 'wall' && } + {node.type === 'window' && } {node.type === 'zone' && } {node.type === 'roof' && } {node.type === 'scan' && } diff --git a/packages/viewer/src/components/renderers/window/window-renderer.tsx b/packages/viewer/src/components/renderers/window/window-renderer.tsx new file mode 100644 index 00000000..761e3c41 --- /dev/null +++ b/packages/viewer/src/components/renderers/window/window-renderer.tsx @@ -0,0 +1,24 @@ +import { useRegistry, type WindowNode } from '@pascal-app/core' +import { useRef } from 'react' +import type { Mesh } from 'three' + +export const WindowRenderer = ({ node }: { node: WindowNode }) => { + const ref = useRef(null!) + + useRegistry(node.id, 'window', ref) + + return ( + + {/* WindowSystem replaces this geometry each time the node is dirty */} + + + + ) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 3533c0c4..85f66def 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { CeilingSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem } from '@pascal-app/core' +import { CeilingSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem, WindowSystem } from '@pascal-app/core' import { Bvh } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' import * as THREE from 'three/webgpu' @@ -65,6 +65,7 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' + diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 433a0657..e591157c 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -28,6 +28,7 @@ type SelectableNodeType = | 'level' | 'zone' | 'wall' + | 'window' | 'item' | 'slab' | 'ceiling' @@ -192,9 +193,9 @@ const getStrategy = (): SelectionStrategy | null => { } } - // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs) + // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows) return { - types: ['wall', 'item', 'slab', 'ceiling', 'roof'], + types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'], handleClick: (node) => { const { selectedIds } = useViewer.getState().selection // Toggle selection - if already selected, deselect; otherwise select @@ -216,7 +217,7 @@ const getStrategy = (): SelectionStrategy | null => { } }, isValid: (node) => { - const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof'] + const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'] if (!validTypes.includes(node.type)) return false return isNodeInZone(node, levelId, zoneId) }, @@ -268,6 +269,7 @@ export const SelectionManager = () => { 'slab', 'ceiling', 'roof', + 'window', ] for (const type of allTypes) { emitter.on(`${type}:enter`, onEnter)