wall tool draft
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user