From dc3f1a6898c5cd6d0d3e8ff5bbca0134a48a59e9 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 26 Jan 2026 10:08:57 +0900 Subject: [PATCH] wip wall-mitering --- .../core/src/systems/wall/wall-mitering.ts | 617 ++++++++++++++++++ .../core/src/systems/wall/wall-system.tsx | 313 ++++++--- 2 files changed, 855 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/systems/wall/wall-mitering.ts diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts new file mode 100644 index 00000000..1ca00fc2 --- /dev/null +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -0,0 +1,617 @@ +import type { WallNode } from '../../schema' + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface Point2D { + x: number + y: number +} + +interface LineEquation { + a: number + b: number + c: number // ax + by + c = 0 +} + +interface WallEndpoint { + wall: WallNode + endType: 'start' | 'end' +} + +interface Junction { + point: Point2D + walls: WallEndpoint[] +} + +export interface MiterData { + left: Point2D + right: Point2D +} + +// Map of wallId -> { start?: MiterData, end?: MiterData } +export type WallMiterMap = Map + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +const TOLERANCE = 0.001 + +function pointToKey(p: Point2D, tolerance = TOLERANCE): string { + const snap = 1 / tolerance + return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}` +} + +function getOutgoingVector(wall: WallNode, endType: 'start' | 'end'): Point2D { + if (endType === 'start') { + return { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + } + return { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } +} + +function createLineFromPointAndVector(p: Point2D, v: Point2D): LineEquation { + const a = -v.y + const b = v.x + const c = -(a * p.x + b * p.y) + return { a, b, c } +} + +function intersectLines(l1: LineEquation, l2: LineEquation): Point2D | null { + const det = l1.a * l2.b - l2.a * l1.b + if (Math.abs(det) < 1e-9) return null + const x = (l1.b * l2.c - l2.b * l1.c) / det + const y = (l2.a * l1.c - l1.a * l2.c) / det + return { x, y } +} + +function normalize(v: Point2D): Point2D { + const len = Math.sqrt(v.x * v.x + v.y * v.y) + if (len < 1e-9) return { x: 0, y: 0 } + return { x: v.x / len, y: v.y / len } +} + +function dot(a: Point2D, b: Point2D): number { + return a.x * b.x + a.y * b.y +} + +/** + * Check if a point lies on a wall segment (excluding endpoints) + */ +function pointOnWallSegment( + point: Point2D, + wallStart: Point2D, + wallEnd: Point2D, + tolerance = TOLERANCE, +): boolean { + const wallVec = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y } + const wallLen = Math.sqrt(wallVec.x * wallVec.x + wallVec.y * wallVec.y) + if (wallLen < 1e-9) return false + + const toPoint = { x: point.x - wallStart.x, y: point.y - wallStart.y } + + // Project point onto wall line + const t = dot(toPoint, wallVec) / (wallLen * wallLen) + + // Check if within segment (with margin to exclude endpoints) + if (t <= tolerance / wallLen || t >= 1 - tolerance / wallLen) return false + + // Check perpendicular distance + const projX = wallStart.x + t * wallVec.x + const projY = wallStart.y + t * wallVec.y + const dist = Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2) + + return dist < tolerance +} + +// ============================================================================ +// JUNCTION DETECTION +// ============================================================================ + +interface JunctionResult { + junctions: Map + throughWalls: Map // junctionKey -> host wall that the junction lies on +} + +/** + * Finds all junctions (where wall endpoints meet, including T-junctions on wall segments) + */ +function findCornerJunctions(walls: WallNode[]): JunctionResult { + const junctionMap = new Map() + + for (const wall of walls) { + const startPt: Point2D = { x: wall.start[0], y: wall.start[1] } + const endPt: Point2D = { x: wall.end[0], y: wall.end[1] } + + const startKey = pointToKey(startPt) + const endKey = pointToKey(endPt) + + if (!junctionMap.has(startKey)) { + junctionMap.set(startKey, { point: startPt, walls: [] }) + } + junctionMap.get(startKey)!.walls.push({ wall, endType: 'start' }) + + if (!junctionMap.has(endKey)) { + junctionMap.set(endKey, { point: endPt, walls: [] }) + } + junctionMap.get(endKey)!.walls.push({ wall, endType: 'end' }) + } + + // For each junction point, check if it lies on any wall's segment (T-junction) + // Store this info separately - the host wall should NOT be modified + const throughWallsAtJunction = new Map() // junctionKey -> host wall + + for (const [key, junction] of junctionMap) { + const wallIdsInJunction = new Set(junction.walls.map((w) => w.wall.id)) + + for (const wall of walls) { + if (wallIdsInJunction.has(wall.id)) continue + + const wallStart: Point2D = { x: wall.start[0], y: wall.start[1] } + const wallEnd: Point2D = { x: wall.end[0], y: wall.end[1] } + + // Check if junction point lies on this wall's segment + if (pointOnWallSegment(junction.point, wallStart, wallEnd)) { + // Store the through wall separately - don't add to junction.walls + // The host wall should NOT get miter data + throughWallsAtJunction.set(key, wall) + break // Only need one through wall per junction + } + } + } + + // Only keep junctions with 2+ walls + const actualJunctions = new Map() + for (const [key, junction] of junctionMap) { + if (junction.walls.length >= 2) { + actualJunctions.set(key, junction) + } + } + + return { junctions: actualJunctions, throughWalls: throughWallsAtJunction } +} + +/** + * Finds T-junctions where a wall endpoint meets another wall's side + */ +function findTJunctions(walls: WallNode[]): Map { + const tJunctions = new Map() + + for (const wall of walls) { + const endpoints: { pt: Point2D; endType: 'start' | 'end' }[] = [ + { pt: { x: wall.start[0], y: wall.start[1] }, endType: 'start' }, + { pt: { x: wall.end[0], y: wall.end[1] }, endType: 'end' }, + ] + + for (const { pt, endType } of endpoints) { + const key = pointToKey(pt) + + // Skip if this is already a corner junction + // (will be handled by findCornerJunctions) + + for (const otherWall of walls) { + if (otherWall.id === wall.id) continue + + const otherStart: Point2D = { x: otherWall.start[0], y: otherWall.start[1] } + const otherEnd: Point2D = { x: otherWall.end[0], y: otherWall.end[1] } + + // Check if endpoint touches the other wall's endpoints + const touchesStart = pointToKey(pt) === pointToKey(otherStart) + const touchesEnd = pointToKey(pt) === pointToKey(otherEnd) + if (touchesStart || touchesEnd) continue + + // Check if endpoint lies on the other wall's segment + if (pointOnWallSegment(pt, otherStart, otherEnd)) { + if (!tJunctions.has(key)) { + tJunctions.set(key, { point: pt, walls: [] }) + } + const junction = tJunctions.get(key)! + + // Add the incoming wall if not already present + if (!junction.walls.some((w) => w.wall.id === wall.id && w.endType === endType)) { + junction.walls.push({ wall, endType }) + } + + // Add the host wall as a "through" wall (we'll handle it specially) + // Use 'start' as a convention for through walls + if (!junction.walls.some((w) => w.wall.id === otherWall.id)) { + junction.walls.push({ wall: otherWall, endType: 'start' }) + } + } + } + } + } + + return tJunctions +} + +// ============================================================================ +// MITER CALCULATION +// ============================================================================ + +/** + * Calculates mitered corners for a junction (including T-junctions with through walls) + * @param throughWall - Optional wall that the junction lies on (for T-junctions) + */ +function calculateCornerMiters( + junction: Junction, + getThickness: (wall: WallNode) => number, + throughWall?: WallNode, +): Map { + const { point, walls } = junction + const result = new Map() + + // If there's a through wall, handle as combined corner + T-junction + // The through wall is NOT modified - only incoming walls get miter data + if (throughWall) { + const hostHalfT = getThickness(throughWall) / 2 + const hostDir = normalize({ + x: throughWall.end[0] - throughWall.start[0], + y: throughWall.end[1] - throughWall.start[1], + }) + const hostNormal = { x: -hostDir.y, y: hostDir.x } + + // Host wall edge points at junction + const hostLeft = { x: point.x + hostNormal.x * hostHalfT, y: point.y + hostNormal.y * hostHalfT } + const hostRight = { x: point.x - hostNormal.x * hostHalfT, y: point.y - hostNormal.y * hostHalfT } + const hostEdgeLeft = createLineFromPointAndVector(hostLeft, hostDir) + const hostEdgeRight = createLineFromPointAndVector(hostRight, hostDir) + + // Build processed list for incoming walls + const incomingProcessed: { + wallId: string + angle: number + edgeLeft: LineEquation + edgeRight: LineEquation + defaultLeft: Point2D + defaultRight: Point2D + approachDot: number + }[] = [] + + for (const { wall, endType } of walls) { + const halfT = getThickness(wall) / 2 + const v = getOutgoingVector(wall, endType) + const vNorm = normalize(v) + + if (Math.abs(vNorm.x) < 1e-9 && Math.abs(vNorm.y) < 1e-9) continue + + const normal = { x: -vNorm.y, y: vNorm.x } + const leftPt = { x: point.x + normal.x * halfT, y: point.y + normal.y * halfT } + const rightPt = { x: point.x - normal.x * halfT, y: point.y - normal.y * halfT } + + const incomingDir = { x: -vNorm.x, y: -vNorm.y } + const approachDot = dot(incomingDir, hostNormal) + + incomingProcessed.push({ + wallId: wall.id, + angle: Math.atan2(v.y, v.x), + edgeLeft: createLineFromPointAndVector(leftPt, v), + edgeRight: createLineFromPointAndVector(rightPt, v), + defaultLeft: leftPt, + defaultRight: rightPt, + approachDot, + }) + } + + // Sort ALL walls by angle for proper adjacency + incomingProcessed.sort((a, b) => a.angle - b.angle) + + // Initialize all walls with default values + for (const w of incomingProcessed) { + result.set(w.wallId, { left: w.defaultLeft, right: w.defaultRight }) + } + + const n = incomingProcessed.length + + // Process consecutive pairs of walls + for (let i = 0; i < n; i++) { + const curr = incomingProcessed[i]! + const next = incomingProcessed[(i + 1) % n]! + + const currFromLeft = curr.approachDot > 0 + const nextFromLeft = next.approachDot > 0 + + if (currFromLeft === nextFromLeft) { + // Same side: miter their adjacent edges together + const cornerInt = intersectLines(curr.edgeRight, next.edgeLeft) + if (cornerInt) { + result.get(curr.wallId)!.right = cornerInt + result.get(next.wallId)!.left = cornerInt + } + } else { + // Different sides: their inner edges meet at intersection (inside the host wall) + const cornerInt = intersectLines(curr.edgeRight, next.edgeLeft) + if (cornerInt) { + result.get(curr.wallId)!.right = cornerInt + result.get(next.wallId)!.left = cornerInt + } + } + } + + // Now set the outer edges to meet the host wall surface + // For each wall, find which edge is "outermost" (not adjacent to a same-side wall) + for (let i = 0; i < n; i++) { + const curr = incomingProcessed[i]! + const prev = incomingProcessed[(i - 1 + n) % n]! + const next = incomingProcessed[(i + 1) % n]! + + const currFromLeft = curr.approachDot > 0 + const prevFromLeft = prev.approachDot > 0 + const nextFromLeft = next.approachDot > 0 + + // Target host edge based on which side this wall approaches from + const targetHostEdge = currFromLeft ? hostEdgeRight : hostEdgeLeft + + // Left edge is outer if prev wall is on different side (or if only one wall) + if (n === 1 || prevFromLeft !== currFromLeft) { + const leftInt = intersectLines(curr.edgeLeft, targetHostEdge) + if (leftInt) result.get(curr.wallId)!.left = leftInt + } + + // Right edge is outer if next wall is on different side (or if only one wall) + if (n === 1 || nextFromLeft !== currFromLeft) { + const rightInt = intersectLines(curr.edgeRight, targetHostEdge) + if (rightInt) result.get(curr.wallId)!.right = rightInt + } + } + + return result + } + + // Standard corner junction processing (no through wall) + const processed: { + wallId: string + angle: number + edgeLeft: LineEquation + edgeRight: LineEquation + defaultLeft: Point2D + defaultRight: Point2D + }[] = [] + + for (const { wall, endType } of walls) { + const halfT = getThickness(wall) / 2 + const v = getOutgoingVector(wall, endType) + const vNorm = normalize(v) + + if (Math.abs(vNorm.x) < 1e-9 && Math.abs(vNorm.y) < 1e-9) continue + + const normal = { x: -vNorm.y, y: vNorm.x } + const leftPt = { x: point.x + normal.x * halfT, y: point.y + normal.y * halfT } + const rightPt = { x: point.x - normal.x * halfT, y: point.y - normal.y * halfT } + + processed.push({ + wallId: wall.id, + angle: Math.atan2(v.y, v.x), + edgeLeft: createLineFromPointAndVector(leftPt, v), + edgeRight: createLineFromPointAndVector(rightPt, v), + defaultLeft: leftPt, + defaultRight: rightPt, + }) + } + + // Sort by angle for proper adjacency + processed.sort((a, b) => a.angle - b.angle) + + const n = processed.length + if (n < 2) return result + + // Initialize with defaults + for (const p of processed) { + result.set(p.wallId, { left: p.defaultLeft, right: p.defaultRight }) + } + + // Calculate intersections between adjacent walls + for (let i = 0; i < n; i++) { + const curr = processed[i]! + const next = processed[(i + 1) % n]! + + const intersection = intersectLines(curr.edgeLeft, next.edgeRight) + + if (intersection) { + result.get(curr.wallId)!.left = intersection + result.get(next.wallId)!.right = intersection + } + } + + return result +} + +/** + * Calculates miter for a T-junction (wall endpoint meeting another wall's side) + */ +function calculateTJunctionMiters( + junction: Junction, + getThickness: (wall: WallNode) => number, +): Map { + const { point, walls } = junction + const result = new Map() + + // Separate incoming walls (those with endpoint at junction) from host wall + const incomingWalls: WallEndpoint[] = [] + let hostWall: WallNode | null = null + + for (const { wall, endType } of walls) { + const wallStart: Point2D = { x: wall.start[0], y: wall.start[1] } + const wallEnd: Point2D = { x: wall.end[0], y: wall.end[1] } + + const startKey = pointToKey(wallStart) + const endKey = pointToKey(wallEnd) + const junctionKey = pointToKey(point) + + if (startKey === junctionKey || endKey === junctionKey) { + incomingWalls.push({ wall, endType }) + } else { + hostWall = wall + } + } + + if (!hostWall || incomingWalls.length === 0) return result + + // If there are multiple incoming walls, use corner miter logic with throughWall + // This handles cases where walls meet at a T-junction point but weren't grouped as a corner junction + if (incomingWalls.length >= 2) { + const cornerJunction: Junction = { point, walls: incomingWalls } + return calculateCornerMiters(cornerJunction, getThickness, hostWall) + } + + // Single incoming wall: handle as simple T-junction + // Get host wall direction and normal + const hostDir = normalize({ + x: hostWall.end[0] - hostWall.start[0], + y: hostWall.end[1] - hostWall.start[1], + }) + const hostNormal = { x: -hostDir.y, y: hostDir.x } + const hostHalfT = getThickness(hostWall) / 2 + + // Host wall edge points at junction + const hostLeft = { x: point.x + hostNormal.x * hostHalfT, y: point.y + hostNormal.y * hostHalfT } + const hostRight = { + x: point.x - hostNormal.x * hostHalfT, + y: point.y - hostNormal.y * hostHalfT, + } + + // For each incoming wall, extend to meet the host wall's edges + for (const { wall, endType } of incomingWalls) { + const halfT = getThickness(wall) / 2 + const v = getOutgoingVector(wall, endType) + const vNorm = normalize(v) + + if (Math.abs(vNorm.x) < 1e-9 && Math.abs(vNorm.y) < 1e-9) continue + + const normal = { x: -vNorm.y, y: vNorm.x } + + // Default corner points + const leftPt = { x: point.x + normal.x * halfT, y: point.y + normal.y * halfT } + const rightPt = { x: point.x - normal.x * halfT, y: point.y - normal.y * halfT } + + // Create edge lines for incoming wall + const edgeLeft = createLineFromPointAndVector(leftPt, v) + const edgeRight = createLineFromPointAndVector(rightPt, v) + + // Determine which side of the host wall the incoming wall approaches from + // Use the OPPOSITE of outgoing direction (incoming direction) dotted with host normal + const incomingDir = { x: -vNorm.x, y: -vNorm.y } + const approachDot = dot(incomingDir, hostNormal) + + // Pick the host edge facing the incoming wall + // If dot > 0, wall approaches from the opposite side of hostNormal, use hostRight (near surface) + // If dot < 0, wall approaches from the hostNormal side, use hostLeft (near surface) + const targetHostEdge = + approachDot > 0 + ? createLineFromPointAndVector(hostRight, hostDir) + : createLineFromPointAndVector(hostLeft, hostDir) + + // Both edges of incoming wall meet the same host edge + const leftIntersection = intersectLines(edgeLeft, targetHostEdge) + const rightIntersection = intersectLines(edgeRight, targetHostEdge) + + result.set(wall.id, { + left: leftIntersection || leftPt, + right: rightIntersection || rightPt, + }) + } + + return result +} + +// ============================================================================ +// MAIN EXPORT +// ============================================================================ + +/** + * Calculates miter data for all walls on a level + */ +export function calculateLevelMiters(walls: WallNode[]): WallMiterMap { + const miterMap: WallMiterMap = new Map() + const getThickness = (wall: WallNode) => wall.thickness ?? 0.1 + + // Process corner junctions + const { junctions: cornerJunctions, throughWalls } = findCornerJunctions(walls) + for (const [key, junction] of cornerJunctions) { + // Pass the through wall (if any) for T-junction handling + const throughWall = throughWalls.get(key) + const miters = calculateCornerMiters(junction, getThickness, throughWall) + + for (const { wall, endType } of junction.walls) { + const miterData = miters.get(wall.id) + if (!miterData) continue + + if (!miterMap.has(wall.id)) { + miterMap.set(wall.id, {}) + } + miterMap.get(wall.id)![endType] = miterData + } + } + + // Process T-junctions + const tJunctions = findTJunctions(walls) + for (const [, junction] of tJunctions) { + const miters = calculateTJunctionMiters(junction, getThickness) + + for (const { wall, endType } of junction.walls) { + const miterData = miters.get(wall.id) + if (!miterData) continue + + // Don't overwrite corner junction miters + if (miterMap.get(wall.id)?.[endType]) continue + + if (!miterMap.has(wall.id)) { + miterMap.set(wall.id, {}) + } + miterMap.get(wall.id)![endType] = miterData + } + } + + return miterMap +} + +/** + * Gets wall IDs that share junctions with the given walls + */ +export function getAdjacentWallIds(allWalls: WallNode[], dirtyWallIds: Set): Set { + const adjacent = new Set() + + for (const dirtyId of dirtyWallIds) { + const dirtyWall = allWalls.find((w) => w.id === dirtyId) + if (!dirtyWall) continue + + const dirtyStart: Point2D = { x: dirtyWall.start[0], y: dirtyWall.start[1] } + const dirtyEnd: Point2D = { x: dirtyWall.end[0], y: dirtyWall.end[1] } + + for (const wall of allWalls) { + if (wall.id === dirtyId) continue + + const wallStart: Point2D = { x: wall.start[0], y: wall.start[1] } + const wallEnd: Point2D = { x: wall.end[0], y: wall.end[1] } + + // Check corner connections + const startKey = pointToKey(wallStart) + const endKey = pointToKey(wallEnd) + const dirtyStartKey = pointToKey(dirtyStart) + const dirtyEndKey = pointToKey(dirtyEnd) + + if ( + startKey === dirtyStartKey || + startKey === dirtyEndKey || + endKey === dirtyStartKey || + endKey === dirtyEndKey + ) { + adjacent.add(wall.id) + continue + } + + // Check T-junction connections + if ( + pointOnWallSegment(dirtyStart, wallStart, wallEnd) || + pointOnWallSegment(dirtyEnd, wallStart, wallEnd) || + pointOnWallSegment(wallStart, dirtyStart, dirtyEnd) || + pointOnWallSegment(wallEnd, dirtyStart, dirtyEnd) + ) { + adjacent.add(wall.id) + } + } + } + + return adjacent +} diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 8e1780b7..6ff3eaad 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -1,8 +1,19 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import type { AnyNode, WallNode } from '../../schema' +import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import useScene from '../../store/use-scene' +import { + calculateLevelMiters, + getAdjacentWallIds, + type MiterData, + type Point2D, + type WallMiterMap, +} from './wall-mitering' + +// ============================================================================ +// WALL SYSTEM +// ============================================================================ export const WallSystem = () => { const { nodes, dirtyNodes, clearDirty } = useScene() @@ -10,34 +21,78 @@ export const WallSystem = () => { useFrame(() => { if (dirtyNodes.size === 0) return + // Collect dirty walls and their levels + const dirtyWallsByLevel = new Map>() + dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node) return - const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh + if (!node || node.type !== 'wall') return - // 1. If a window is dirty, we actually need to redraw its PARENT wall - // if ((node.type === 'window' || node.type === 'door') && node.parentId) { - // updateWallGeometry(node.parentId); - // return; - // } + const levelId = node.parentId + if (!levelId) return - // 2. If the wall itself is dirty - if (node.type === 'wall' && mesh) { - updateWallGeometry(id) + if (!dirtyWallsByLevel.has(levelId)) { + dirtyWallsByLevel.set(levelId, new Set()) } - clearDirty(id) // Reset for next frame + dirtyWallsByLevel.get(levelId)!.add(id) }) + + // Process each level that has dirty walls + for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { + const levelWalls = getLevelWalls(levelId) + const miterMap = calculateLevelMiters(levelWalls) + + // Update dirty walls + for (const wallId of dirtyWallIds) { + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh + if (mesh) { + updateWallGeometry(wallId, miterMap) + } + clearDirty(wallId as AnyNodeId) + } + + // Update adjacent walls that share junctions + const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds) + for (const wallId of adjacentWallIds) { + if (!dirtyWallIds.has(wallId)) { + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh + if (mesh) { + updateWallGeometry(wallId, miterMap) + } + } + } + } }) return null } -// Optimization: Logic moved to a vanilla function so it can be called -// by the Editor or the System without React overhead -function updateWallGeometry(wallId: string) { +/** + * Gets all walls that belong to a level + */ +function getLevelWalls(levelId: string): WallNode[] { + const { nodes } = useScene.getState() + const level = nodes[levelId as AnyNodeId] + + if (!level || level.type !== 'level') return [] + + const walls: WallNode[] = [] + for (const childId of level.children) { + const child = nodes[childId] + if (child?.type === 'wall') { + walls.push(child as WallNode) + } + } + + return walls +} + +/** + * Updates the geometry for a single wall + */ +function updateWallGeometry(wallId: string, miterMap: WallMiterMap) { const node = useScene.getState().nodes[wallId as WallNode['id']] - if (!node) return - if (node.type !== 'wall') return + if (!node || node.type !== 'wall') return const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (!mesh) return @@ -47,37 +102,94 @@ function updateWallGeometry(wallId: string) { .map((childId) => useScene.getState().nodes[childId]) .filter((n): n is AnyNode => n !== undefined) - // Generate visual geometry with holes - const newGeo = generateExtrudedWall(node, childrenNodes) + const miters = miterMap.get(wallId) + const newGeo = generateExtrudedWall(node, childrenNodes, miters) mesh.geometry.dispose() mesh.geometry = newGeo - // Update collision mesh with solid geometry (no holes) + // Update collision mesh const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh if (collisionMesh) { - const collisionGeo = generateExtrudedWall(node, []) // No children = no holes + const collisionGeo = generateExtrudedWall(node, [], miters) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo } mesh.position.set(node.start[0], 0, node.start[1]) - - // Rotate mesh to look at 'end' point const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) mesh.rotation.y = -angle } -export function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[]) { - // 1. Calculate Wall Dimensions +/** + * Generates extruded wall geometry with mitering and holes + * + * Geometry approach: + * - Shape is drawn on XY plane (X = along wall, Y = height) + * - Extruded by wall thickness along Z + * - This allows holes (doors/windows) to work correctly on the wall face + * - Mitering adjusts the extrusion offset at start/end + */ +export function generateExtrudedWall( + wallNode: WallNode, + childrenNodes: AnyNode[], + miters?: { start?: MiterData; end?: MiterData }, +) { const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1]) const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1]) const length = start.distanceTo(end) - const height = wallNode.height || 2.5 - const thickness = wallNode.thickness || 0.1 + const height = wallNode.height ?? 2.5 + const thickness = wallNode.thickness ?? 0.1 + const halfT = thickness / 2 - // 2. Create the Main Wall Shape (a rectangle in 2D) - // We draw this on the XY plane, where X is "along the wall" and Y is "height" + // Wall angle for coordinate transforms + const wallAngle = Math.atan2(end.y - start.y, end.x - start.x) + const cosA = Math.cos(-wallAngle) + const sinA = Math.sin(-wallAngle) + + // Transform world point to wall-local space + const worldToLocal = (worldPt: Point2D): { x: number; z: number } => { + const dx = worldPt.x - wallNode.start[0] + const dy = worldPt.y - wallNode.start[1] + return { + x: dx * cosA - dy * sinA, + z: dx * sinA + dy * cosA, + } + } + + // Calculate miter offsets at start and end + // These determine how far the wall extends/retracts at each end for proper joints + let startLeftZ = halfT + let startRightZ = -halfT + let endLeftZ = halfT + let endRightZ = -halfT + + // Miter offset along the wall's X axis (for angled cuts) + let startLeftX = 0 + let startRightX = 0 + let endLeftX = length + let endRightX = length + + if (miters?.start) { + const left = worldToLocal(miters.start.left) + const right = worldToLocal(miters.start.right) + startLeftZ = left.z + startRightZ = right.z + startLeftX = left.x + startRightX = right.x + } + + if (miters?.end) { + // At end, left/right are relative to outgoing direction (reversed) + const left = worldToLocal(miters.end.right) + const right = worldToLocal(miters.end.left) + endLeftZ = left.z + endRightZ = right.z + endLeftX = left.x + endRightX = right.x + } + + // Create the main wall shape (XY plane: X = along wall, Y = height) const shape = new THREE.Shape() shape.moveTo(0, 0) shape.lineTo(length, 0) @@ -85,27 +197,16 @@ export function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[ shape.lineTo(0, height) shape.closePath() - // 3. Process Openings (Holes) - // Compute wall's transform info for converting world coords to wall-local coords + // Process holes (doors/windows) const wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]] - const wallAngle = Math.atan2( - wallNode.end[1] - wallNode.start[1], - wallNode.end[0] - wallNode.start[0], - ) - - // Get the wall mesh's world Y position (from level offset) const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0 childrenNodes.forEach((child) => { - // Only process items that are intended to be wall cutouts if (child.type !== 'item') return const childMesh = sceneRegistry.nodes.get(child.id) - - if (!childMesh) { - return - } + if (!childMesh) return const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh if (!cutoutMesh) return @@ -116,27 +217,102 @@ export function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[ } }) - // 4. Extrude the Shape into 3D - const geometry = new THREE.ExtrudeGeometry(shape, { - depth: thickness, - bevelEnabled: false, - }) - - // 5. Pivot Alignment - // Center the geometry thickness so the "start/end" line is in the middle of the wall - geometry.translate(0, 0, -thickness / 2) + // Create custom extrude geometry with mitered ends + const geometry = createMiteredExtrudeGeometry( + shape, + height, + { + leftZ: startLeftZ, + rightZ: startRightZ, + leftX: startLeftX, + rightX: startRightX, + }, + { + leftZ: endLeftZ, + rightZ: endRightZ, + leftX: endLeftX, + rightX: endRightX, + }, + ) return geometry } /** - * Creates a Path from a cutout mesh geometry, transforming vertices - * from world space to wall-local space. - * - * Wall-local space: - * - Origin at wall start point - * - X axis runs along the wall (toward end point) - * - Y axis is height (relative to wall's world Y position) + * Creates an extruded geometry with mitered (angled) ends + */ +function createMiteredExtrudeGeometry( + shape: THREE.Shape, + height: number, + startMiter: { leftZ: number; rightZ: number; leftX: number; rightX: number }, + endMiter: { leftZ: number; rightZ: number; leftX: number; rightX: number }, +): THREE.BufferGeometry { + // First, create standard extrude geometry + const thickness = Math.max( + Math.abs(startMiter.leftZ - startMiter.rightZ), + Math.abs(endMiter.leftZ - endMiter.rightZ), + 0.1, + ) + + const geometry = new THREE.ExtrudeGeometry(shape, { + depth: thickness, + bevelEnabled: false, + }) + + // Translate so center is at Z=0 + geometry.translate(0, 0, -thickness / 2) + + // Get position attribute for modification + const positions = geometry.attributes.position + const vertices = positions.array as Float32Array + + // Modify vertex positions for mitering + for (let i = 0; i < positions.count; i++) { + const x = vertices[i * 3]! + const y = vertices[i * 3 + 1]! + const z = vertices[i * 3 + 2]! + + // Get shape bounds to determine which end we're at + const shapePoints = shape.getPoints() + const minX = Math.min(...shapePoints.map((p: THREE.Vector2) => p.x)) + const maxX = Math.max(...shapePoints.map((p: THREE.Vector2) => p.x)) + const wallLength = maxX - minX + + // Determine position along wall (0 to 1) + const t = wallLength > 0 ? (x - minX) / wallLength : 0 + + // Interpolate Z offset based on position along wall and which side (left/right) + const isLeftSide = z > 0 + const startZ = isLeftSide ? startMiter.leftZ : startMiter.rightZ + const endZ = isLeftSide ? endMiter.leftZ : endMiter.rightZ + + // Linear interpolation of Z offset + const newZ = startZ + t * (endZ - startZ) + + // Also adjust X for angled cuts at ends + let newX = x + if (t < 0.01) { + // Near start + const startX = isLeftSide ? startMiter.leftX : startMiter.rightX + newX = startX + } else if (t > 0.99) { + // Near end + const endX = isLeftSide ? endMiter.leftX : endMiter.rightX + newX = endX + } + + vertices[i * 3] = newX + vertices[i * 3 + 2] = newZ + } + + positions.needsUpdate = true + geometry.computeVertexNormals() + + return geometry +} + +/** + * Creates a Path from a cutout mesh for door/window holes */ function createPathFromCutout( cutoutMesh: THREE.Mesh, @@ -150,37 +326,25 @@ function createPathFromCutout( const positions = geometry.attributes.position if (!positions) return null - // Update world matrix to get correct world positions cutoutMesh.updateWorldMatrix(true, false) - // Collect unique vertices (buffer geometry has duplicates for triangulation) const uniquePoints: THREE.Vector2[] = [] const seen = new Set() const v3 = new THREE.Vector3() - // Precompute sin/cos for rotation const cosAngle = Math.cos(-wallAngle) const sinAngle = Math.sin(-wallAngle) for (let i = 0; i < positions.count; i++) { v3.fromBufferAttribute(positions, i) - - // Transform to world space v3.applyMatrix4(cutoutMesh.matrixWorld) - // Transform from world space to wall-local space: - // 1. Translate so wall start is at origin (in XZ plane) const worldX = v3.x - wallStart[0] const worldZ = v3.z - wallStart[1] - // 2. Rotate around Y axis to align wall with local X axis - // The wall shape is drawn on XY plane, so we need: - // - localX = distance along wall - // - localY = height relative to wall's Y position const localX = worldX * cosAngle - worldZ * sinAngle - const localY = v3.y - wallWorldY // Subtract wall's world Y to get local height + const localY = v3.y - wallWorldY - // Create a key for deduplication (with small tolerance) const key = `${localX.toFixed(4)},${localY.toFixed(4)}` if (!seen.has(key)) { seen.add(key) @@ -190,7 +354,7 @@ function createPathFromCutout( if (uniquePoints.length < 3) return null - // Sort points in counter-clockwise order around centroid + // Sort in counter-clockwise order const centroid = new THREE.Vector2(0, 0) for (const p of uniquePoints) { centroid.add(p) @@ -203,11 +367,10 @@ function createPathFromCutout( return angleA - angleB }) - // Create the path const path = new THREE.Path() - path.moveTo(uniquePoints[0]?.x || 0, uniquePoints[0]?.y || 0) + path.moveTo(uniquePoints[0]?.x ?? 0, uniquePoints[0]?.y ?? 0) for (let i = 1; i < uniquePoints.length; i++) { - path.lineTo(uniquePoints[i]?.x || 0, uniquePoints[i]?.y || 0) + path.lineTo(uniquePoints[i]?.x ?? 0, uniquePoints[i]?.y ?? 0) } path.closePath()