diff --git a/apps/editor/components/ui/panels/panel-manager.tsx b/apps/editor/components/ui/panels/panel-manager.tsx index afef33ad..6697574a 100644 --- a/apps/editor/components/ui/panels/panel-manager.tsx +++ b/apps/editor/components/ui/panels/panel-manager.tsx @@ -8,6 +8,7 @@ import { ItemPanel } from './item-panel' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { SlabPanel } from './slab-panel' +import { WindowPanel } from './window-panel' export function PanelManager() { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -33,6 +34,8 @@ export function PanelManager() { return case 'ceiling': return + case 'window': + return } } } diff --git a/apps/editor/components/ui/panels/window-panel.tsx b/apps/editor/components/ui/panels/window-panel.tsx new file mode 100644 index 00000000..2e40e633 --- /dev/null +++ b/apps/editor/components/ui/panels/window-panel.tsx @@ -0,0 +1,215 @@ +'use client' + +import { type AnyNode, type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { X } from 'lucide-react' +import Image from 'next/image' +import { useCallback } from 'react' +import { NumberInput } from '@/components/ui/primitives/number-input' +import { Switch } from '@/components/ui/primitives/switch' + +export function WindowPanel() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const setSelection = useViewer((s) => s.setSelection) + const nodes = useScene((s) => s.nodes) + const updateNode = useScene((s) => s.updateNode) + + const selectedId = selectedIds[0] + const node = selectedId + ? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined) + : undefined + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + // Mark dirty so window-system regenerates geometry + useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + }, + [selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + if (!node || node.type !== 'window' || selectedIds.length !== 1) return null + + const columns = node.columnRatios.length + const rows = node.rowRatios.length + + return ( +
+ {/* Header */} +
+
+ +

+ {node.name || `Window (${node.width}×${node.height}m)`} +

+
+ +
+ + {/* Content */} +
+ {/* Dimensions */} +
+ +
+
+ handleUpdate({ width: v })} + min={0.2} + precision={2} + className="flex-1" + /> + m +
+
+ handleUpdate({ height: v })} + min={0.2} + precision={2} + className="flex-1" + /> + m +
+
+
+ + {/* Frame */} +
+ +
+
+ handleUpdate({ frameThickness: v })} + min={0.01} + precision={3} + className="flex-1" + /> + m +
+
+ handleUpdate({ frameDepth: v })} + min={0.01} + precision={3} + className="flex-1" + /> + m +
+
+
+ + {/* Grid */} +
+ +
+
+ { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ columnRatios: Array(n).fill(1) }) + }} + min={1} + max={8} + precision={0} + className="flex-1" + /> +
+
+ { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ rowRatios: Array(n).fill(1) }) + }} + min={1} + max={8} + precision={0} + className="flex-1" + /> +
+
+ {(columns > 1 || rows > 1) && ( +
+ handleUpdate({ dividerThickness: v })} + min={0.005} + precision={3} + className="flex-1" + /> + m +
+ )} +
+ + {/* Sill */} +
+
+ + handleUpdate({ sill: checked })} + /> +
+ {node.sill && ( +
+
+ handleUpdate({ sillDepth: v })} + min={0.01} + precision={3} + className="flex-1" + /> + m +
+
+ handleUpdate({ sillThickness: v })} + min={0.005} + precision={3} + className="flex-1" + /> + m +
+
+ )} +
+
+
+ ) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a9eabea1..877623d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export type { SiteEvent, SlabEvent, WallEvent, + WindowEvent, ZoneEvent, } from './events/bus' // Events @@ -22,12 +23,21 @@ export { sceneRegistry, useRegistry, } from './hooks/scene-registry/scene-registry' +export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager' export { initSpatialGridSync, resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' -export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager' +// Asset storage +export { loadAssetUrl, saveAsset } from './lib/asset-storage' +// Space detection +export { + detectSpacesForLevel, + initSpaceDetectionSync, + type Space, + wallTouchesOthers, +} from './lib/space-detection' // Schema export * from './schema' export { default as useScene } from './store/use-scene' @@ -38,9 +48,4 @@ 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 -export { saveAsset, loadAssetUrl } from './lib/asset-storage' -// Space detection -export { detectSpacesForLevel, wallTouchesOthers, initSpaceDetectionSync, type Space } from './lib/space-detection' diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/core/src/systems/window/window-system.tsx index 81e481c7..a72ce7e5 100644 --- a/packages/core/src/systems/window/window-system.tsx +++ b/packages/core/src/systems/window/window-system.tsx @@ -7,15 +7,25 @@ import useScene from '../../store/use-scene' const glassMaterial = new MeshStandardNodeMaterial({ name: 'glass', - color: 'lightgray', - roughness: 0.8, - metalness: 0, + color: 'lightblue', + roughness: 0.05, + metalness: 0.1, transparent: true, - opacity: 0.35, + opacity: 0.3, side: DoubleSide, depthWrite: false, }) +const frameMaterial = new MeshStandardNodeMaterial({ + name: 'window-frame', + color: '#e8e8e8', + roughness: 0.6, + metalness: 0, +}) + +// Invisible material for root mesh — used as selection hitbox only +const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) + export const WindowSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) @@ -45,17 +55,117 @@ export const WindowSystem = () => { return null } +function addBox( + parent: THREE.Object3D, + material: THREE.Material, + w: number, h: number, d: number, + x: number, y: number, z: number, +) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + m.position.set(x, y, z) + parent.add(m) +} + function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { - // Replace geometry with a box matching the overall window dimensions + // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) - mesh.material = glassMaterial + mesh.material = hitboxMaterial // 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 + // Dispose and remove all old visual children; preserve 'cutout' + for (const child of [...mesh.children]) { + if (child.name === 'cutout') continue + if (child instanceof THREE.Mesh) child.geometry.dispose() + mesh.remove(child) + } + + const { + width, height, frameDepth, frameThickness, + columnRatios, rowRatios, dividerThickness, + sill, sillDepth, sillThickness, + } = node + + const innerW = width - 2 * frameThickness + const innerH = height - 2 * frameThickness + + // ── Frame members ── + // Top / bottom — full width + addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0) + addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0) + // Left / right — inner height to avoid corner overlap + addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0) + addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0) + + // ── Pane grid ── + const numCols = columnRatios.length + const numRows = rowRatios.length + + const usableW = innerW - (numCols - 1) * dividerThickness + const usableH = innerH - (numRows - 1) * dividerThickness + + const colSum = columnRatios.reduce((a, b) => a + b, 0) + const rowSum = rowRatios.reduce((a, b) => a + b, 0) + const colWidths = columnRatios.map(r => (r / colSum) * usableW) + const rowHeights = rowRatios.map(r => (r / rowSum) * usableH) + + // Compute column x-centers starting from left edge of inner area + const colXCenters: number[] = [] + let cx = -innerW / 2 + for (let c = 0; c < numCols; c++) { + colXCenters.push(cx + colWidths[c]! / 2) + cx += colWidths[c]! + if (c < numCols - 1) cx += dividerThickness + } + + // Compute row y-centers starting from bottom edge of inner area + const rowYCenters: number[] = [] + let cy = -innerH / 2 + for (let r = 0; r < numRows; r++) { + rowYCenters.push(cy + rowHeights[r]! / 2) + cy += rowHeights[r]! + if (r < numRows - 1) cy += dividerThickness + } + + // Column dividers — full inner height + cx = -innerW / 2 + for (let c = 0; c < numCols - 1; c++) { + cx += colWidths[c]! + addBox(mesh, frameMaterial, dividerThickness, innerH, frameDepth, cx + dividerThickness / 2, 0, 0) + cx += dividerThickness + } + + // Row dividers — per column width, so they don't overlap column dividers + cy = -innerH / 2 + for (let r = 0; r < numRows - 1; r++) { + cy += rowHeights[r]! + const divY = cy + dividerThickness / 2 + for (let c = 0; c < numCols; c++) { + addBox(mesh, frameMaterial, colWidths[c]!, dividerThickness, frameDepth, colXCenters[c]!, divY, 0) + } + cy += dividerThickness + } + + // Glass panes + const glassDepth = Math.max(0.004, frameDepth * 0.08) + for (let c = 0; c < numCols; c++) { + for (let r = 0; r < numRows; r++) { + addBox(mesh, glassMaterial, colWidths[c]!, rowHeights[r]!, glassDepth, colXCenters[c]!, rowYCenters[r]!, 0) + } + } + + // ── Sill ── + if (sill) { + const sillW = width + sillDepth * 0.4 // slightly wider than frame + // Protrudes from the front face of the frame (+Z) + const sillZ = frameDepth / 2 + sillDepth / 2 + addBox(mesh, frameMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ) + } + + // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ── let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined if (!cutout) { cutout = new THREE.Mesh() @@ -63,7 +173,6 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { 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; + cutout.visible = false } diff --git a/packages/viewer/src/components/renderers/window/window-renderer.tsx b/packages/viewer/src/components/renderers/window/window-renderer.tsx index 761e3c41..75c20188 100644 --- a/packages/viewer/src/components/renderers/window/window-renderer.tsx +++ b/packages/viewer/src/components/renderers/window/window-renderer.tsx @@ -1,11 +1,13 @@ import { useRegistry, type WindowNode } from '@pascal-app/core' import { useRef } from 'react' import type { Mesh } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' export const WindowRenderer = ({ node }: { node: WindowNode }) => { const ref = useRef(null!) useRegistry(node.id, 'window', ref) + const handlers = useNodeEvents(node, 'window') return ( { visible={node.visible} position={node.position} rotation={node.rotation} + {...handlers} > {/* WindowSystem replaces this geometry each time the node is dirty */} diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 41d4a453..7e166726 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -17,11 +17,13 @@ import { type SlabNode, type WallEvent, type WallNode, + type WindowEvent, + type WindowNode, type ZoneEvent, type ZoneNode, } from '@pascal-app/core' import type { ThreeEvent } from '@react-three/fiber' -import useViewer from '../store/use-viewer'; +import useViewer from '../store/use-viewer' type NodeConfig = { site: { node: SiteNode; event: SiteEvent } @@ -33,6 +35,7 @@ type NodeConfig = { slab: { node: SlabNode; event: SlabEvent } ceiling: { node: CeilingNode; event: CeilingEvent } roof: { node: RoofNode; event: RoofEvent } + window: { node: WindowNode; event: WindowEvent } } type NodeType = keyof NodeConfig @@ -69,10 +72,25 @@ export function useNodeEvents(node: NodeConfig[T]['node'], t if (e.button !== 0) return emit('click', e) }, - onPointerEnter: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('enter', e)}, - onPointerLeave: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('leave', e)}, - onPointerMove: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('move', e)}, - onDoubleClick: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('double-click', e)}, - onContextMenu: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('context-menu', e)}, + onPointerEnter: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return + emit('enter', e) + }, + onPointerLeave: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return + emit('leave', e) + }, + onPointerMove: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return + emit('move', e) + }, + onDoubleClick: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return + emit('double-click', e) + }, + onContextMenu: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return + emit('context-menu', e) + }, } }