wall tool draft

This commit is contained in:
wass08
2026-02-17 16:22:45 +09:00
parent 13fd1848d1
commit dd5f63df64
19 changed files with 553 additions and 11 deletions
+3 -1
View File
@@ -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<ZoneNode>
export type SlabEvent = NodeEvent<SlabNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode>
export type WindowEvent = NodeEvent<WindowNode>
// 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
@@ -19,6 +19,7 @@ export const sceneRegistry = {
roof: new Set<string>(),
scan: new Set<string>(),
guide: new Set<string>(),
window: new Set<string>(),
},
};
+3 -2
View File
@@ -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
+1
View File
@@ -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'
+46
View File
@@ -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<typeof WindowNode>
+2
View File
@@ -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<typeof AnyNode>
@@ -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
@@ -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;
}
@@ -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' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'window' && <WindowRenderer node={node} />}
{node.type === 'zone' && <ZoneRenderer node={node} />}
{node.type === 'roof' && <RoofRenderer node={node} />}
{node.type === 'scan' && <ScanRenderer node={node} />}
@@ -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<Mesh>(null!)
useRegistry(node.id, 'window', ref)
return (
<mesh
ref={ref}
castShadow
receiveShadow
visible={node.visible}
position={node.position}
rotation={node.rotation}
>
{/* WindowSystem replaces this geometry each time the node is dirty */}
<boxGeometry args={[0, 0, 0]} />
<meshStandardMaterial color="#d1d5db" />
</mesh>
)
}
@@ -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<ViewerProps> = ({ children, selectionManager = 'default'
<RoofSystem />
<SlabSystem />
<WallSystem />
<WindowSystem />
<ZoneSystem />
<PostProcessing />
@@ -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)