ai-chatbot/packages/core/src/systems/wall/wall-mitering.ts
Wawa e618175bba Reset core/viewer/editor/mcp packages and apps/editor from private-editor
Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
  the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
  the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
  references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
  valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
  @types/react, @types/react-dom, @types/three to prevent JSX namespace
  fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
  to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
  match the new versions on every bump (so viewer/editor/mcp tarballs always
  reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts

Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
  what private-editor currently reports

Open PRs against editor-v2 will need rebasing/conflict resolution.
2026-05-09 20:52:26 +00:00

409 lines
13 KiB
TypeScript

import type { WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from './wall-curve'
// ============================================================================
// TYPES
// ============================================================================
export interface Point2D {
x: number
y: number
}
export interface WallMiterBoundaryPoints {
startLeft: Point2D
startRight: Point2D
endLeft: Point2D
endRight: Point2D
}
interface LineEquation {
a: number
b: number
c: number // ax + by + c = 0
}
// Map of wallId -> { left?: Point2D, right?: Point2D } for each junction
type WallIntersections = Map<string, { left?: Point2D; right?: Point2D }>
// Map of junctionKey -> WallIntersections
type JunctionData = Map<string, WallIntersections>
// ============================================================================
// 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 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 }
}
/**
* Checks if a point lies on a wall segment (not at its endpoints)
*/
function pointOnWallSegment(point: Point2D, wall: WallNode, tolerance = TOLERANCE): boolean {
const start: Point2D = { x: wall.start[0], y: wall.start[1] }
const end: Point2D = { x: wall.end[0], y: wall.end[1] }
// Check if point is at endpoints (those are handled separately)
if (pointToKey(point, tolerance) === pointToKey(start, tolerance)) return false
if (pointToKey(point, tolerance) === pointToKey(end, tolerance)) return false
// Vector from start to end
const v = { x: end.x - start.x, y: end.y - start.y }
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) return false
// Vector from start to point
const w = { x: point.x - start.x, y: point.y - start.y }
// Project point onto wall line (t is parametric position along segment)
const t = (v.x * w.x + v.y * w.y) / (L * L)
// Check if projection is within segment (not at endpoints)
if (t < tolerance || t > 1 - tolerance) return false
// Check distance from point to line
const projX = start.x + t * v.x
const projY = start.y + t * v.y
const dist = Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2)
return dist < tolerance
}
// ============================================================================
// JUNCTION DETECTION (exactly like demo)
// ============================================================================
interface Junction {
meetingPoint: Point2D
connectedWalls: Array<{ wall: WallNode; endType: 'start' | 'end' | 'passthrough' }>
}
function findJunctions(walls: WallNode[]): Map<string, Junction> {
const junctions = new Map<string, Junction>()
// First pass: group walls by their endpoints
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 keyStart = pointToKey(startPt)
const keyEnd = pointToKey(endPt)
if (!junctions.has(keyStart)) {
junctions.set(keyStart, { meetingPoint: startPt, connectedWalls: [] })
}
junctions.get(keyStart)?.connectedWalls.push({ wall, endType: 'start' })
if (!junctions.has(keyEnd)) {
junctions.set(keyEnd, { meetingPoint: endPt, connectedWalls: [] })
}
junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' })
}
// Second pass: detect T-junctions (walls passing through junction points)
for (const [_key, junction] of junctions.entries()) {
for (const wall of walls) {
// Skip if wall already in this junction
if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue
// Check if junction point lies on this wall's segment (not at endpoints)
if (pointOnWallSegment(junction.meetingPoint, wall)) {
junction.connectedWalls.push({ wall, endType: 'passthrough' })
}
}
}
// Filter to only junctions with 2+ walls
const actualJunctions = new Map<string, Junction>()
for (const [key, junction] of junctions.entries()) {
if (junction.connectedWalls.length >= 2) {
actualJunctions.set(key, junction)
}
}
return actualJunctions
}
function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') {
if (endType === 'passthrough') {
return {
x: wall.end[0] - wall.start[0],
y: wall.end[1] - wall.start[1],
}
}
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }
}
return endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
}
function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return {
point: frame.point,
tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y },
normal: frame.normal,
}
}
const point =
endType === 'start'
? { x: wall.start[0], y: wall.start[1] }
: { x: wall.end[0], y: wall.end[1] }
const vector =
endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
const length = Math.hypot(vector.x, vector.y)
if (length < 1e-9) {
return {
point,
tangent: { x: 1, y: 0 },
normal: { x: 0, y: 1 },
}
}
return {
point,
tangent: { x: vector.x / length, y: vector.y / length },
normal: { x: -vector.y / length, y: vector.x / length },
}
}
// ============================================================================
// MITER CALCULATION (exactly like demo)
// ============================================================================
interface ProcessedWall {
wallId: string
angle: number
edgeA: LineEquation // Left edge
edgeB: LineEquation // Right edge
isPassthrough: boolean // True if wall passes through junction (T-junction)
}
function calculateJunctionIntersections(
junction: Junction,
getThickness: (wall: WallNode) => number,
): WallIntersections {
const { meetingPoint, connectedWalls } = junction
const processedWalls: ProcessedWall[] = []
for (const { wall, endType } of connectedWalls) {
const halfT = getThickness(wall) / 2
if (endType === 'passthrough') {
// For passthrough walls (T-junctions), add both directions
// This allows walls meeting the middle of this wall to miter against it
const v1 = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
const v2 = { x: -v1.x, y: -v1.y }
for (const v of [v1, v2]) {
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) continue
const nUnit = { x: -v.y / L, y: v.x / L }
const pA = { x: meetingPoint.x + nUnit.x * halfT, y: meetingPoint.y + nUnit.y * halfT }
const pB = { x: meetingPoint.x - nUnit.x * halfT, y: meetingPoint.y - nUnit.y * halfT }
const edgeA = createLineFromPointAndVector(pA, v)
const edgeB = createLineFromPointAndVector(pB, v)
const angle = Math.atan2(v.y, v.x)
processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: true })
}
} else {
// Normal wall endpoint (start or end)
const v = getWallDirectionFromJunction(wall, endType)
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) continue
const nUnit = { x: -v.y / L, y: v.x / L }
const pA = { x: meetingPoint.x + nUnit.x * halfT, y: meetingPoint.y + nUnit.y * halfT }
const pB = { x: meetingPoint.x - nUnit.x * halfT, y: meetingPoint.y - nUnit.y * halfT }
const edgeA = createLineFromPointAndVector(pA, v)
const edgeB = createLineFromPointAndVector(pB, v)
const angle = Math.atan2(v.y, v.x)
processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: false })
}
}
// Sort by outgoing angle
processedWalls.sort((a, b) => a.angle - b.angle)
const wallIntersections = new Map<string, { left?: Point2D; right?: Point2D }>()
const n = processedWalls.length
if (n < 2) return wallIntersections
// Calculate intersections between adjacent walls (exactly like demo)
for (let i = 0; i < n; i++) {
const wall1 = processedWalls[i]!
const wall2 = processedWalls[(i + 1) % n]!
// Intersect left edge of wall1 with right edge of wall2
const det = wall1.edgeA.a * wall2.edgeB.b - wall2.edgeB.a * wall1.edgeA.b
// If lines are parallel (det ≈ 0), skip this intersection - walls will use defaults
if (Math.abs(det) < 1e-9) {
continue
}
const p = {
x: (wall1.edgeA.b * wall2.edgeB.c - wall2.edgeB.b * wall1.edgeA.c) / det,
y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det,
}
// Only assign intersection to non-passthrough walls
// Passthrough walls don't receive junction data (their geometry doesn't change)
if (!wall1.isPassthrough) {
if (!wallIntersections.has(wall1.wallId)) {
wallIntersections.set(wall1.wallId, {})
}
wallIntersections.get(wall1.wallId)!.left = p
}
if (!wall2.isPassthrough) {
if (!wallIntersections.has(wall2.wallId)) {
wallIntersections.set(wall2.wallId, {})
}
wallIntersections.get(wall2.wallId)!.right = p
}
}
return wallIntersections
}
// ============================================================================
// MAIN EXPORT
// ============================================================================
export interface WallMiterData {
// Junction data keyed by junction position key
junctionData: JunctionData
// All junctions for quick lookup
junctions: Map<string, Junction>
}
/**
* Calculates miter data for all walls on a level
*/
export function calculateLevelMiters(walls: WallNode[]): WallMiterData {
const getThickness = (wall: WallNode) => wall.thickness ?? 0.1
const junctions = findJunctions(walls)
const junctionData: JunctionData = new Map()
for (const [key, junction] of junctions.entries()) {
const wallIntersections = calculateJunctionIntersections(junction, getThickness)
junctionData.set(key, wallIntersections)
}
return { junctionData, junctions }
}
export function getWallMiterBoundaryPoints(
wall: WallNode,
miterData: WallMiterData,
): WallMiterBoundaryPoints | null {
const thickness = wall.thickness ?? 0.1
const halfThickness = thickness / 2
const startFrame = getWallBoundaryFrame(wall, 'start')
const endFrame = getWallBoundaryFrame(wall, 'end')
const startJunction = miterData.junctionData.get(pointToKey(startFrame.point))?.get(wall.id)
const endJunction = miterData.junctionData.get(pointToKey(endFrame.point))?.get(wall.id)
return {
startLeft: startJunction?.left ?? {
x: startFrame.point.x + startFrame.normal.x * halfThickness,
y: startFrame.point.y + startFrame.normal.y * halfThickness,
},
startRight: startJunction?.right ?? {
x: startFrame.point.x - startFrame.normal.x * halfThickness,
y: startFrame.point.y - startFrame.normal.y * halfThickness,
},
endLeft: endJunction?.right ?? {
x: endFrame.point.x + endFrame.normal.x * halfThickness,
y: endFrame.point.y + endFrame.normal.y * halfThickness,
},
endRight: endJunction?.left ?? {
x: endFrame.point.x - endFrame.normal.x * halfThickness,
y: endFrame.point.y - endFrame.normal.y * halfThickness,
},
}
}
/**
* 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 (endpoints meeting)
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 (dirty wall endpoint on other wall's segment)
if (pointOnWallSegment(dirtyStart, wall) || pointOnWallSegment(dirtyEnd, wall)) {
adjacent.add(wall.id)
continue
}
// Check reverse T-junction (other wall endpoint on dirty wall's segment)
if (pointOnWallSegment(wallStart, dirtyWall) || pointOnWallSegment(wallEnd, dirtyWall)) {
adjacent.add(wall.id)
}
}
}
return adjacent
}
// Re-export for backwards compatibility
export { pointToKey }