Pascal Editor Wall System Geometry Generation: Mitering and CSG Pipeline

The Pascal Editor wall system generates real-time architectural geometry by detecting dirty wall nodes, computing mitered junction offsets, and applying CSG subtraction for door/window cut-outs using three-bvh-csg.

The pascalorg/editor repository implements a deterministic, real-time wall geometry pipeline in the @pascal-app/core package. This system transforms declarative wall nodes into render-ready Three.js meshes through a three-stage process involving dirty-node tracking, precise miter calculations, and Boolean operations.

Dirty-Node Detection and Level Grouping

On every render frame, the WallSystem component in [wall-system.tsx](https://github.com/pascalorg/editor/blob/main/packages/core/src/systems/wall/wall-system.tsx) monitors the scene store for walls marked as dirty—indicating changes to geometry, position, or children. The system collects these IDs and groups them by their parent level node to process related walls together.

The main loop (lines 30-78) performs three critical operations:

  1. Gathering walls per level – Iterates through dirtyNodes to map wall IDs to their parent level IDs
  2. Processing each level – Calls calculateLevelMiters for all walls in affected levels
  3. Updating adjacent walls – Refreshes neighboring walls that share junctions with modified walls (lines 52-66) to ensure miter continuity
export const WallSystem = () => {
  const dirtyNodes = useScene(state => state.dirtyNodes);
  const clearDirty = useScene(state => state.clearDirty);

  useFrame(() => {
    if (dirtyNodes.size === 0) return;

    // Gather walls per level
    const dirtyWallsByLevel = new Map<string, Set<string>>();
    dirtyNodes.forEach(id => {
      const node = useScene.getState().nodes[id];
      if (node?.type !== 'wall' || !node.parentId) return;
      const levelId = node.parentId;
      if (!dirtyWallsByLevel.has(levelId))
        dirtyWallsByLevel.set(levelId, new Set());
      dirtyWallsByLevel.get(levelId)!.add(id);
    });

    // Process each level
    for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
      const levelWalls = getLevelWalls(levelId);
      const miterData = calculateLevelMiters(levelWalls);

      // Update the walls that changed
      dirtyWallIds.forEach(wallId => {
        const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh;
        if (mesh) {
          updateWallGeometry(wallId, miterData);
          clearDirty(wallId as AnyNodeId);
        }
      });

      // Also refresh neighbours that share a junction
      const adjacentIds = getAdjacentWallIds(levelWalls, dirtyWallIds);
      adjacentIds.forEach(id => {
        if (!dirtyWallIds.has(id)) {
          const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh;
          if (mesh) updateWallGeometry(id, miterData);
        }
      });
    }
  }, 4);        // run after the main scene update
  return null;
};

Miter Calculation and Junction Detection

The mitering algorithm lives in [wall-mitering.ts](https://github.com/pascalorg/editor/blob/main/packages/core/src/systems/wall/wall-mitering.ts) and determines how wall thickness should be trimmed at intersections to create clean architectural joints.

Detecting Wall Junctions

The findJunctions function (lines 84-127) indexes walls by their endpoints using pointToKey hashing. It identifies:

  • Corner junctions – Where two walls meet at endpoints
  • T-junctions – Where a wall end meets another wall's interior (detected via pointOnWallSegment)

Results are stored in a Map<string, Junction> where keys represent snapped 2-D coordinates.

Computing Miter Offsets

For every junction, calculateJunctionIntersections (lines 42-98) builds offset lines for each participating wall:

  1. Edge creation – Constructs left and right offset lines using the wall's normal vector (nUnit) and half-thickness
  2. Passthrough handling – For T-junctions, generates edges in both directions so neighbors can miter against the intersected wall's middle
  3. Intersection calculation – Sorts edges by outgoing angle and intersects successive pairs to determine trim points

The resulting WallMiterData contains a map of junction keys to per-wall intersection points used during extrusion.

Extrusion and CSG Boolean Operations

The generateExtrudedWall function in [wall-system.tsx](https://github.com/pascalorg/editor/blob/main/packages/core/src/systems/wall/wall-system.tsx) converts mitered 2-D polygons into 3-D geometry with door and window cut-outs.

Building the Plan Footprint

Before extrusion, getWallPlanFootprint in [wall-footprint.ts](https://github.com/pascalorg/editor/blob/main/packages/core/src/systems/wall/wall-footprint.ts) (lines 11-60) constructs a closed polygon from the pre-computed miter data:

export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] {
  const { junctionData } = miterData;
  const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] };
  const wallEnd: Point2D   = { x: wallNode.end[0],   y: wallNode.end[1] };
  const thickness = getWallThickness(wallNode);
  const halfT = thickness / 2;

  const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y };
  const L = Math.sqrt(v.x * v.x + v.y * v.y);
  if (L < 1e-9) return [];

  const nUnit = { x: -v.y / L, y: v.x / L };
  const keyStart = pointToKey(wallStart);
  const keyEnd   = pointToKey(wallEnd);

  const startJunction = junctionData.get(keyStart)?.get(wallNode.id);
  const endJunction   = junctionData.get(keyEnd)  ?.get(wallNode.id);

  const pStartLeft  = startJunction?.left  || { x: wallStart.x + nUnit.x * halfT, y: wallStart.y + nUnit.y * halfT };
  const pStartRight = startJunction?.right || { x: wallStart.x - nUnit.x * halfT, y: wallStart.y - nUnit.y * halfT };
  const pEndLeft    = endJunction?.right   || { x: wallEnd.x   + nUnit.x * halfT, y: wallEnd.y   + nUnit.y * halfT };
  const pEndRight   = endJunction?.left    || { x: wallEnd.x   - nUnit.x * halfT, y: wallEnd.y   - nUnit.y * halfT };

  const polygon: Point2D[] = [pStartRight, pEndRight];
  if (endJunction) polygon.push(wallEnd);
  polygon.push(pEndLeft, pStartLeft);
  if (startJunction) polygon.push(wallStart);
  return polygon;
}

Three.js Extrusion and Rotation

The wall geometry generation follows a coordinate transformation pipeline:

  1. Localization – Rotates world-space points to align the wall length with the X-axis
  2. Shape creation – Builds a THREE.Shape from the localized footprint points
  3. Vertical extrusion – Uses THREE.ExtrudeGeometry with the wall height, then rotates the result (rotateX(-Math.PI / 2)) to align the extrusion with the world Y-axis

Boolean Subtraction with three-bvh-csg

For architectural openings, the system uses three-bvh-csg to subtract door and window volumes:

  • collectCutoutBrushes (lines 50-84 of wall-system.tsx) creates thin box geometries spanning the full wall thickness for each child node marked as a cut-out
  • BVH optimization – Both wall and cut-out geometries receive bounding-volume hierarchies via computeBoundsTree for fast CSG evaluation
  • Subtraction operation – The csgEvaluator.evaluate function processes the wall brush against each cut-out brush using the SUBTRACTION operation
export function generateExtrudedWall(
  wallNode: WallNode,
  childrenNodes: AnyNode[],
  miterData: WallMiterData,
  slabElevation = 0,
) {
  // Build the plan-view polygon with correct miter offsets
  const polyPoints = getWallPlanFootprint(wallNode, miterData);
  if (polyPoints.length < 3) return new THREE.BufferGeometry();

  // Transform world-space points to wall-local space
  const wallAngle = Math.atan2(v.y, v.x);
  const cosA = Math.cos(-wallAngle), sinA = Math.sin(-wallAngle);
  const worldToLocal = (pt: Point2D) => {
    const dx = pt.x - wallStart.x, dy = pt.y - wallStart.y;
    return { x: dx * cosA - dy * sinA, z: dx * sinA + dy * cosA };
  };
  const localPoints = polyPoints.map(worldToLocal);

  // Create a Three.js Shape and extrude it vertically
  const footprint = new THREE.Shape();
  footprint.moveTo(localPoints[0]!.x, -localPoints[0]!.z);
  for (let i = 1; i < localPoints.length; i++) {
    footprint.lineTo(localPoints[i]!.x, -localPoints[i]!.z);
  }
  const geometry = new THREE.ExtrudeGeometry(footprint, { depth: height, bevelEnabled: false });
  geometry.rotateX(-Math.PI / 2);
  geometry.computeVertexNormals();

  // Gather door/window cut-outs and run CSG subtraction
  const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness);
  if (cutoutBrushes.length === 0) return geometry;

  // Prepare BVH for fast CSG
  geometry.computeBoundsTree = computeBoundsTree;
  geometry.computeBoundsTree({ maxLeafSize: 10 });
  const wallBrush = new Brush(geometry);
  let resultBrush = wallBrush;
  for (const cutoutBrush of cutoutBrushes) {
    resultBrush = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION);
  }
  return resultBrush.geometry;
}

Practical Implementation Examples

Adding a Wall and Triggering Geometry Generation

import useScene from '@pascal-app/core/store/use-scene';
import { v4 as uuid } from 'uuid';

// Create a wall node (start-end in world X-Z plane)
const newWall = {
  id: uuid(),
  type: 'wall' as const,
  parentId: levelId,                // the level the wall belongs to
  start: [0, 0],
  end: [5, 0],
  height: 2.8,
  thickness: 0.12,
  children: [],                     // doors/windows will be added later
};

// Insert into scene and mark dirty for processing
useScene.getState().addNode(newWall);
useScene.getState().markDirty(newWall.id);

Adding a Window Cut-Out

const windowNode = {
  id: uuid(),
  type: 'window' as const,
  parentId: newWall.id,
  start: [2, 0],
  end: [3, 0],
};

useScene.getState().addNode(windowNode);
useScene.getState().markDirty(newWall.id);   // triggers CSG subtraction

Manual Re-Mitering for Global Changes

When updating wall thickness globally without individual node changes:

import { calculateLevelMiters } from '@pascal-app/core/systems/wall/wall-mitering';
import { getLevelWalls } from '@pascal-app/core/systems/wall/wall-system';

const levelId = 'level-abc';
const walls = getLevelWalls(levelId);
const miterData = calculateLevelMiters(walls);

// Apply new miter data to every wall in the level
walls.forEach(w => updateWallGeometry(w.id, miterData));

Summary

  • Dirty-node tracking in wall-system.tsx groups changed walls by level and processes them in frame-priority order 4, ensuring scene consistency before geometry generation.
  • Miter calculation uses vector math and intersection detection in wall-mitering.ts to compute exact offset points for clean wall junctions, handling both corners and T-junctions.
  • Plan footprint generation in wall-footprint.ts constructs closed 2-D polygons that incorporate pre-computed miter offsets for both wall ends.
  • CSG pipeline utilizes three-bvh-csg with BVH acceleration to subtract door and window volumes from extruded wall meshes in real-time.
  • Adjacent wall updates ensure that modifying one wall triggers recomputation of neighbors sharing junctions, maintaining geometric continuity across the level.

Frequently Asked Questions

How does the Pascal Editor handle T-junctions in wall geometry?

The findJunctions function in wall-mitering.ts detects T-junctions by checking if a wall endpoint lies on another wall's interior segment using pointOnWallSegment. For these cases, the algorithm marks the intersected wall as a "passthrough" and generates offset edges in both directions, allowing the connecting wall to miter correctly against the middle of the intersected wall rather than its endpoint.

What CSG library does Pascal Editor use for wall cut-outs?

The system uses three-bvh-csg, an optimized constructive solid geometry library that leverages bounding volume hierarchies (BVH) for fast Boolean operations. Both the wall geometry and cut-out brushes receive BVH structures via computeBoundsTree before evaluation, enabling real-time subtraction of complex door and window openings even in large architectural scenes.

How does the wall system optimize performance for complex scenes?

Performance optimization occurs at multiple stages: level-based grouping limits miter calculations to affected floors only; dirty-node tracking ensures only modified walls and their immediate neighbors are recomputed; and BVH acceleration in the CSG pipeline reduces the complexity of Boolean operations from O(n²) to O(log n) for mesh intersections.

Can wall thickness be updated dynamically without rebuilding the entire scene?

Yes. The calculateLevelMiters function can be invoked manually with the current wall set to regenerate miter data based on new thickness values. Calling updateWallGeometry for each wall in the level applies the new offsets without requiring individual node dirty flags, though all affected walls must be reprocessed to maintain proper junction geometry.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →