wip wall-mitering
This commit is contained in:
@@ -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<string, { start?: MiterData; end?: MiterData }>
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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<string, Junction>
|
||||||
|
throughWalls: Map<string, WallNode> // 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<string, Junction>()
|
||||||
|
|
||||||
|
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<string, WallNode>() // 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<string, Junction>()
|
||||||
|
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<string, Junction> {
|
||||||
|
const tJunctions = new Map<string, Junction>()
|
||||||
|
|
||||||
|
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<string, MiterData> {
|
||||||
|
const { point, walls } = junction
|
||||||
|
const result = new Map<string, MiterData>()
|
||||||
|
|
||||||
|
// 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<string, MiterData> {
|
||||||
|
const { point, walls } = junction
|
||||||
|
const result = new Map<string, MiterData>()
|
||||||
|
|
||||||
|
// 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<string>): Set<string> {
|
||||||
|
const adjacent = new Set<string>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
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 useScene from '../../store/use-scene'
|
||||||
|
import {
|
||||||
|
calculateLevelMiters,
|
||||||
|
getAdjacentWallIds,
|
||||||
|
type MiterData,
|
||||||
|
type Point2D,
|
||||||
|
type WallMiterMap,
|
||||||
|
} from './wall-mitering'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WALL SYSTEM
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export const WallSystem = () => {
|
export const WallSystem = () => {
|
||||||
const { nodes, dirtyNodes, clearDirty } = useScene()
|
const { nodes, dirtyNodes, clearDirty } = useScene()
|
||||||
@@ -10,34 +21,78 @@ export const WallSystem = () => {
|
|||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
|
|
||||||
|
// Collect dirty walls and their levels
|
||||||
|
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
if (!node) return
|
if (!node || node.type !== 'wall') return
|
||||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
|
||||||
|
|
||||||
// 1. If a window is dirty, we actually need to redraw its PARENT wall
|
const levelId = node.parentId
|
||||||
// if ((node.type === 'window' || node.type === 'door') && node.parentId) {
|
if (!levelId) return
|
||||||
// updateWallGeometry(node.parentId);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 2. If the wall itself is dirty
|
if (!dirtyWallsByLevel.has(levelId)) {
|
||||||
if (node.type === 'wall' && mesh) {
|
dirtyWallsByLevel.set(levelId, new Set())
|
||||||
updateWallGeometry(id)
|
|
||||||
}
|
}
|
||||||
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimization: Logic moved to a vanilla function so it can be called
|
/**
|
||||||
// by the Editor or the System without React overhead
|
* Gets all walls that belong to a level
|
||||||
function updateWallGeometry(wallId: string) {
|
*/
|
||||||
|
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']]
|
const node = useScene.getState().nodes[wallId as WallNode['id']]
|
||||||
if (!node) return
|
if (!node || node.type !== 'wall') return
|
||||||
if (node.type !== 'wall') return
|
|
||||||
|
|
||||||
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
||||||
if (!mesh) return
|
if (!mesh) return
|
||||||
@@ -47,37 +102,94 @@ function updateWallGeometry(wallId: string) {
|
|||||||
.map((childId) => useScene.getState().nodes[childId])
|
.map((childId) => useScene.getState().nodes[childId])
|
||||||
.filter((n): n is AnyNode => n !== undefined)
|
.filter((n): n is AnyNode => n !== undefined)
|
||||||
|
|
||||||
// Generate visual geometry with holes
|
const miters = miterMap.get(wallId)
|
||||||
const newGeo = generateExtrudedWall(node, childrenNodes)
|
const newGeo = generateExtrudedWall(node, childrenNodes, miters)
|
||||||
|
|
||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
mesh.geometry = newGeo
|
mesh.geometry = newGeo
|
||||||
|
|
||||||
// Update collision mesh with solid geometry (no holes)
|
// Update collision mesh
|
||||||
const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh
|
const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh
|
||||||
if (collisionMesh) {
|
if (collisionMesh) {
|
||||||
const collisionGeo = generateExtrudedWall(node, []) // No children = no holes
|
const collisionGeo = generateExtrudedWall(node, [], miters)
|
||||||
collisionMesh.geometry.dispose()
|
collisionMesh.geometry.dispose()
|
||||||
collisionMesh.geometry = collisionGeo
|
collisionMesh.geometry = collisionGeo
|
||||||
}
|
}
|
||||||
|
|
||||||
mesh.position.set(node.start[0], 0, node.start[1])
|
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])
|
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
|
||||||
mesh.rotation.y = -angle
|
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 start = new THREE.Vector2(wallNode.start[0], wallNode.start[1])
|
||||||
const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1])
|
const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1])
|
||||||
const length = start.distanceTo(end)
|
const length = start.distanceTo(end)
|
||||||
const height = wallNode.height || 2.5
|
const height = wallNode.height ?? 2.5
|
||||||
const thickness = wallNode.thickness || 0.1
|
const thickness = wallNode.thickness ?? 0.1
|
||||||
|
const halfT = thickness / 2
|
||||||
|
|
||||||
// 2. Create the Main Wall Shape (a rectangle in 2D)
|
// Wall angle for coordinate transforms
|
||||||
// We draw this on the XY plane, where X is "along the wall" and Y is "height"
|
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()
|
const shape = new THREE.Shape()
|
||||||
shape.moveTo(0, 0)
|
shape.moveTo(0, 0)
|
||||||
shape.lineTo(length, 0)
|
shape.lineTo(length, 0)
|
||||||
@@ -85,27 +197,16 @@ export function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[
|
|||||||
shape.lineTo(0, height)
|
shape.lineTo(0, height)
|
||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
// 3. Process Openings (Holes)
|
// Process holes (doors/windows)
|
||||||
// Compute wall's transform info for converting world coords to wall-local coords
|
|
||||||
const wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]]
|
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 wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh
|
||||||
const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0
|
const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0
|
||||||
|
|
||||||
childrenNodes.forEach((child) => {
|
childrenNodes.forEach((child) => {
|
||||||
// Only process items that are intended to be wall cutouts
|
|
||||||
if (child.type !== 'item') return
|
if (child.type !== 'item') return
|
||||||
|
|
||||||
const childMesh = sceneRegistry.nodes.get(child.id)
|
const childMesh = sceneRegistry.nodes.get(child.id)
|
||||||
|
if (!childMesh) return
|
||||||
if (!childMesh) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh
|
const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh
|
||||||
if (!cutoutMesh) return
|
if (!cutoutMesh) return
|
||||||
@@ -116,27 +217,102 @@ export function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. Extrude the Shape into 3D
|
// Create custom extrude geometry with mitered ends
|
||||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
const geometry = createMiteredExtrudeGeometry(
|
||||||
depth: thickness,
|
shape,
|
||||||
bevelEnabled: false,
|
height,
|
||||||
})
|
{
|
||||||
|
leftZ: startLeftZ,
|
||||||
// 5. Pivot Alignment
|
rightZ: startRightZ,
|
||||||
// Center the geometry thickness so the "start/end" line is in the middle of the wall
|
leftX: startLeftX,
|
||||||
geometry.translate(0, 0, -thickness / 2)
|
rightX: startRightX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
leftZ: endLeftZ,
|
||||||
|
rightZ: endRightZ,
|
||||||
|
leftX: endLeftX,
|
||||||
|
rightX: endRightX,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a Path from a cutout mesh geometry, transforming vertices
|
* Creates an extruded geometry with mitered (angled) ends
|
||||||
* from world space to wall-local space.
|
*/
|
||||||
*
|
function createMiteredExtrudeGeometry(
|
||||||
* Wall-local space:
|
shape: THREE.Shape,
|
||||||
* - Origin at wall start point
|
height: number,
|
||||||
* - X axis runs along the wall (toward end point)
|
startMiter: { leftZ: number; rightZ: number; leftX: number; rightX: number },
|
||||||
* - Y axis is height (relative to wall's world Y position)
|
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(
|
function createPathFromCutout(
|
||||||
cutoutMesh: THREE.Mesh,
|
cutoutMesh: THREE.Mesh,
|
||||||
@@ -150,37 +326,25 @@ function createPathFromCutout(
|
|||||||
const positions = geometry.attributes.position
|
const positions = geometry.attributes.position
|
||||||
if (!positions) return null
|
if (!positions) return null
|
||||||
|
|
||||||
// Update world matrix to get correct world positions
|
|
||||||
cutoutMesh.updateWorldMatrix(true, false)
|
cutoutMesh.updateWorldMatrix(true, false)
|
||||||
|
|
||||||
// Collect unique vertices (buffer geometry has duplicates for triangulation)
|
|
||||||
const uniquePoints: THREE.Vector2[] = []
|
const uniquePoints: THREE.Vector2[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const v3 = new THREE.Vector3()
|
const v3 = new THREE.Vector3()
|
||||||
|
|
||||||
// Precompute sin/cos for rotation
|
|
||||||
const cosAngle = Math.cos(-wallAngle)
|
const cosAngle = Math.cos(-wallAngle)
|
||||||
const sinAngle = Math.sin(-wallAngle)
|
const sinAngle = Math.sin(-wallAngle)
|
||||||
|
|
||||||
for (let i = 0; i < positions.count; i++) {
|
for (let i = 0; i < positions.count; i++) {
|
||||||
v3.fromBufferAttribute(positions, i)
|
v3.fromBufferAttribute(positions, i)
|
||||||
|
|
||||||
// Transform to world space
|
|
||||||
v3.applyMatrix4(cutoutMesh.matrixWorld)
|
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 worldX = v3.x - wallStart[0]
|
||||||
const worldZ = v3.z - wallStart[1]
|
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 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)}`
|
const key = `${localX.toFixed(4)},${localY.toFixed(4)}`
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
@@ -190,7 +354,7 @@ function createPathFromCutout(
|
|||||||
|
|
||||||
if (uniquePoints.length < 3) return null
|
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)
|
const centroid = new THREE.Vector2(0, 0)
|
||||||
for (const p of uniquePoints) {
|
for (const p of uniquePoints) {
|
||||||
centroid.add(p)
|
centroid.add(p)
|
||||||
@@ -203,11 +367,10 @@ function createPathFromCutout(
|
|||||||
return angleA - angleB
|
return angleA - angleB
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create the path
|
|
||||||
const path = new THREE.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++) {
|
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()
|
path.closePath()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user