Issues with CSG Subtraction: Rectangular vs. L-Shaped Floor Plans

I have developed a code that generates outlines for both rectangular and L-shaped floor plans. However, when attempting to apply openings, a noticeable discrepancy arises between the two shapes in terms of the applied Constructive Solid Geometry (CSG) subtraction.

I am seeking insights into the underlying reasons for this behavior and potential solutions to address it.

Despite my efforts to reverse the order of the vertices and recalculate the vertex normals, the issue persists. Additionally, I observed that increasing the thickness of the opening box causes it to remain visible in areas where it does not intersect with the L-shaped outline, but this does not occur with the rectangular outline.

function createRectangular(sizeLabel, position, extrusionLength, wallThickness) {
    const [width, length] = rectangularSizes[sizeLabel];

    // Outer shape
    const outerShape = new THREE.Shape();
    outerShape.moveTo(-width / 2, -length / 2);
    outerShape.lineTo(width / 2, -length / 2);
    outerShape.lineTo(width / 2, length / 2);
    outerShape.lineTo(-width / 2, length / 2);
    outerShape.lineTo(-width / 2, -length / 2);

    // Inner shape as a hole (smaller by wall thickness)
    const innerShape = new THREE.Path();
    innerShape.moveTo(-width / 2 + wallThickness, -length / 2 + wallThickness);
    innerShape.lineTo(width / 2 - wallThickness, -length / 2 + wallThickness);
    innerShape.lineTo(width / 2 - wallThickness, length / 2 - wallThickness);
    innerShape.lineTo(-width / 2 + wallThickness, length / 2 - wallThickness);
    innerShape.lineTo(-width / 2 + wallThickness, -length / 2 + wallThickness);

    // Add inner shape as a hole in the outer shape
    outerShape.holes.push(innerShape);

    // Extrude only the area between the inner and outer shapes
    const geometry = new THREE.ExtrudeGeometry(outerShape, { depth: extrusionLength, bevelEnabled: false });
  const material = new THREE.MeshStandardMaterial({ 
    color: 0x0077ff, 
    side: THREE.DoubleSide 
});

    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(...position);
    return mesh;
}

function createLShaped(sizeLabel, position, extrusionLength, wallThickness) {
    const [length1, length2, width] = lShapedSizes[sizeLabel];

// Outer L-shape - defined in clockwise order
const outerShape = new THREE.Shape();
outerShape.moveTo(-length1 / 2, -length2 / 2); // Start from bottom left
outerShape.lineTo(-length1 / 2, length2 / 2); // Go up to top left
outerShape.lineTo(length1 / 2, length2 / 2); // Go to top right
outerShape.lineTo(length1 / 2, length2 / 2 - width); // Go down to right arm top
outerShape.lineTo(-length1 / 2 + width, length2 / 2 - width); // Go to right arm bottom
outerShape.lineTo(-length1 / 2 + width, -length2 / 2); // Go down to bottom right
outerShape.lineTo(-length1 / 2, -length2 / 2); // Go to bottom left

// Inner L-shape as a hole - defined in clockwise order
const innerShape = new THREE.Path();
innerShape.moveTo(-length1 / 2 + wallThickness, -length2 / 2 + wallThickness); // Start from bottom left of hole
innerShape.lineTo(-length1 / 2 + wallThickness, length2 / 2 - wallThickness); // Go up to top left of hole
innerShape.lineTo(length1 / 2 - wallThickness, length2 / 2 - wallThickness); // Go to top right of hole
innerShape.lineTo(length1 / 2 - wallThickness, length2 / 2 - width + wallThickness); // Go down to right arm top of hole
innerShape.lineTo(-length1 / 2 + width - wallThickness, length2 / 2 - width + wallThickness); // Go to right arm bottom of hole
innerShape.lineTo(-length1 / 2 + width - wallThickness, -length2 / 2 + wallThickness); // Go down to bottom right of hole
innerShape.lineTo(-length1 / 2 + wallThickness, -length2 / 2 + wallThickness); // Go to bottom left of hole

// Add the inner shape as a hole
outerShape.holes.push(innerShape);


    // Extrude the shape with depth and double-sided material
    const geometry = new THREE.ExtrudeGeometry(outerShape, { depth: extrusionLength, bevelEnabled: false });
    geometry.computeVertexNormals(); // Ensure normals are correct for lighting

    const material = new THREE.MeshStandardMaterial({
        color: 0x0077ff,
        side: THREE.DoubleSide // Show both sides of the geometry
    });

    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(...position);

    return mesh;
}


function updateFloorplanMesh() {
    const floorplanSelection = document.getElementById('floorPlan').value;
    const sizeSelection = document.getElementById('floorPlanSize').value;

    if (floorplanMesh) {
        scene.remove(floorplanMesh);
    }

    const extrusionLength = parseFloat(document.getElementById('extrusionLength').value) || 96;
const wallThickness = parseFloat(document.getElementById('wallThickness').value) || 4;
    const position = [0, 0, 0];

    if (floorplanSelection === 'rectangular') {
        floorplanMesh = createRectangular(sizeSelection, position, extrusionLength, wallThickness);
    } else if (floorplanSelection === 'lShaped') {
        floorplanMesh = createLShaped(sizeSelection, position, extrusionLength, wallThickness);
    }

    // Rotate the floorplan mesh 90 degrees in the X direction
    floorplanMesh.rotation.x = -Math.PI / 2; // 90 degrees in radians

    scene.add(floorplanMesh);
}


let openingCount = 0; // Ensure this is declared outside the function
const openings = []; // Ensure openings is declared to store opening data

function addOpening() {
    openingCount++; // Increment the opening count

    // Create a box (opening)
    const geometry = new THREE.BoxGeometry(4, 80, 36);
    const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
    const box = new THREE.Mesh(geometry, material);

    // Position the box in the scene
    box.position.set(0, 0, 40); // Adjust the position if necessary

    // Store box details in openings array
    const openingData = {
        id: `opening${openingCount}`,
        box: box,
        position: { x: 0, y: 0, z: 40 },
        dimensions: { length: 4, width: 80, height: 36 },
        material: material // Store the material here
    };

    openings.push(openingData); // Add to openings array

    // Log the material for the opening
    console.log(`Material for ${openingData.id}:`, material);

    // Update the dropdown
    const openingSelect = document.getElementById('openingSelection');
    const option = document.createElement('option');
    option.value = openingData.id;
    option.text = `Opening ${openingCount}`;
    openingSelect.appendChild(option); // Add option to the dropdown
}

// Function to update input fields when an opening is selected
function updateInputFields() {
    const selectedOpeningId = document.getElementById('openingSelection').value;
    const opening = openings.find(o => o.id === selectedOpeningId);

    if (opening) {
        document.getElementById('positionX').value = opening.position.x;
        document.getElementById('positionY').value = opening.position.y;
        document.getElementById('positionZ').value = opening.position.z;

        document.getElementById('length').value = opening.dimensions.length;
        document.getElementById('width').value = opening.dimensions.width;
        document.getElementById('height').value = opening.dimensions.height;

        document.getElementById('rotationX').value = THREE.MathUtils.radToDeg(opening.box.rotation.x);
        document.getElementById('rotationY').value = THREE.MathUtils.radToDeg(opening.box.rotation.y);
        document.getElementById('rotationZ').value = THREE.MathUtils.radToDeg(opening.box.rotation.z);
    }
}

// Function to update the box in the scene according to input values
function updateOpeningParameters() {
    const selectedOpeningId = document.getElementById('openingSelection').value;
    const opening = openings.find(o => o.id === selectedOpeningId);

    if (opening) {
        // Update position
        opening.position.x = parseFloat(document.getElementById('positionX').value);
        opening.position.y = parseFloat(document.getElementById('positionY').value);
        opening.position.z = parseFloat(document.getElementById('positionZ').value);
        opening.box.position.set(opening.position.x, opening.position.y, opening.position.z);

        // Update dimensions
        const length = parseFloat(document.getElementById('length').value);
        const width = parseFloat(document.getElementById('width').value);
        const height = parseFloat(document.getElementById('height').value);
        
        opening.box.geometry.dispose(); // Dispose the old geometry
        opening.box.geometry = new THREE.BoxGeometry(length, height, width); // Create new geometry

        // Update rotation
        opening.box.rotation.x = THREE.MathUtils.degToRad(parseFloat(document.getElementById('rotationX').value));
        opening.box.rotation.y = THREE.MathUtils.degToRad(parseFloat(document.getElementById('rotationY').value));
        opening.box.rotation.z = THREE.MathUtils.degToRad(parseFloat(document.getElementById('rotationZ').value));

        // Update the opening mesh's matrix before subtraction
        opening.box.updateMatrix(); // Ensure local matrix is updated
        opening.box.updateMatrixWorld(); // Ensure world matrix is updated

    
    }
}

// Event listener for dropdown selection
document.getElementById('openingSelection').addEventListener('change', updateInputFields);

// Add event listeners for the position, rotation, and dimension inputs
const inputFields = document.querySelectorAll('.position-input, .rotation-input, .dimension-input');
inputFields.forEach(input => {
    input.addEventListener('input', updateOpeningParameters);
});

document.getElementById('addOpeningButton').addEventListener('click', addOpening);

// Add event listener for the 'Apply Openings' button
document.getElementById('applyOpeningsButton').addEventListener('click', () => {
    applyCSGSubtraction(); // Perform the CSG subtraction when button is clicked
});
function applyCSGSubtraction() {
    updateFloorplanMesh();

    // Check if the floorplan mesh exists
    if (!floorplanMesh) {
        console.error('Floorplan mesh is not defined. Please create a floorplan first.');
        return;
    }

    // Convert the floorplan mesh to CSG format
    let floorplanCSG = CSG.fromMesh(floorplanMesh, 0);

    // Create a red material for openings
    const openingMaterial = new THREE.MeshBasicMaterial({ 
        color: 0xff0000, 
        side: THREE.DoubleSide 
    });

    // Iterate over each opening for CSG subtraction
    openings.forEach(openingData => {
        const openingMesh = openingData.box;

        // Check if openingMesh is valid
        if (openingMesh) {
            // Compute vertex normals for the opening mesh
            openingMesh.geometry.computeVertexNormals();

            // Update the opening mesh's matrix before conversion
            openingMesh.updateMatrix(); // Ensure local matrix is updated
            openingMesh.updateMatrixWorld(); // Ensure world matrix is updated

            // Convert the box to CSG and apply subtraction
            const openingCSG = CSG.fromMesh(openingMesh, 1);
            floorplanCSG = floorplanCSG.subtract(openingCSG);

            // Hide the opening box mesh after subtraction
            openingMesh.visible = false; // Optionally remove from scene
        } else {
            console.warn('Opening mesh is invalid or not defined for opening data:', openingData);
        }
    });

    // Create the resulting mesh from the CSG operation with an array of materials
    const resultMesh = CSG.toMesh(floorplanCSG, floorplanMesh.matrix, [floorplanMesh.material, openingMaterial]);

    // Rotate the resulting mesh 90 degrees in the X direction
    resultMesh.rotation.x = -Math.PI / 2;

    // Ensure proper normals and transformations
    resultMesh.geometry.computeVertexNormals();
    resultMesh.updateMatrix();
    resultMesh.updateMatrixWorld();

    // Replace the original floorplan mesh with the new one
    scene.remove(floorplanMesh);
    scene.add(resultMesh);
    floorplanMesh = resultMesh; // Update floorplanMesh reference

    // Optionally update the scene matrix
    scene.updateMatrixWorld();
}



Not really enough to go on just from screenshots and pasted code. Can you put a reproduction in a glitch or codepen?

Here’s the approach I used to implement a solution:

To achieve the desired outcome, I developed a function designed to subtract the inner shape from the outer shape, applying it in a way that addresses the specific requirements.

I’ve attached the function here for review and further insights, pending determination of the core issue.

function createRectangular(sizeLabel, position, extrusionLength, wallThickness) {
    const [width, length] = rectangularSizes[sizeLabel];

    // Outer shape
    const outerShape = new THREE.Shape();
    outerShape.moveTo(-width / 2, -length / 2);
    outerShape.lineTo(width / 2, -length / 2);
    outerShape.lineTo(width / 2, length / 2);
    outerShape.lineTo(-width / 2, length / 2);
    outerShape.lineTo(-width / 2, -length / 2);

    // Inner shape as a hole (smaller by wall thickness)
    const innerShape = new THREE.Path();
    innerShape.moveTo(-width / 2 + wallThickness, -length / 2 + wallThickness);
    innerShape.lineTo(width / 2 - wallThickness, -length / 2 + wallThickness);
    innerShape.lineTo(width / 2 - wallThickness, length / 2 - wallThickness);
    innerShape.lineTo(-width / 2 + wallThickness, length / 2 - wallThickness);
    innerShape.lineTo(-width / 2 + wallThickness, -length / 2 + wallThickness);

    // Add inner shape as a hole in the outer shape
    outerShape.holes.push(innerShape);

    // Extrude only the area between the inner and outer shapes
    const geometry = new THREE.ExtrudeGeometry(outerShape, { depth: extrusionLength, bevelEnabled: false });
  const material = new THREE.MeshStandardMaterial({ 
    color: 0x0077ff, 
    side: THREE.DoubleSide 
});

    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(...position);
    return mesh;
}

function createLShaped(sizeLabel, position, extrusionLength, wallThickness) {
    const [length1, length2, width] = lShapedSizes[sizeLabel];

    // Outer shape (base L shape)
    const outerShape = new THREE.Shape();
    outerShape.moveTo(-length1 / 2, -length2 / 2);
    outerShape.lineTo(-length1 / 2, length2 / 2);
    outerShape.lineTo(length1 / 2, length2 / 2);
    outerShape.lineTo(length1 / 2, length2 / 2 - width);
    outerShape.lineTo(-length1 / 2 + width, length2 / 2 - width);
    outerShape.lineTo(-length1 / 2 + width, -length2 / 2);
    outerShape.autoClose = true;

    // Create the extruded geometry from only the outer shape
    const outerGeometry = new THREE.ExtrudeGeometry(outerShape, { depth: extrusionLength, bevelEnabled: false });
    const material = new THREE.MeshStandardMaterial({ color: 0x0077ff, side: THREE.DoubleSide });

    const outerMesh = new THREE.Mesh(outerGeometry, material);
    outerMesh.position.set(...position);

    // Apply the CSG subtraction
    return createLShapeSubtraction(outerMesh, sizeLabel, wallThickness, extrusionLength);
}
function createLShapeSubtraction(outerMesh, sizeLabel, wallThickness, extrusionLength) {
    const [length1, length2, width] = lShapedSizes[sizeLabel];

    // Inner shape (for CSG subtraction)
    const innerShape = new THREE.Shape();
    innerShape.moveTo(-length1 / 2 + wallThickness, -length2 / 2 + wallThickness);
    innerShape.lineTo(-length1 / 2 + wallThickness, length2 / 2 - wallThickness);
    innerShape.lineTo(length1 / 2 - wallThickness, length2 / 2 - wallThickness);
    innerShape.lineTo(length1 / 2 - wallThickness, length2 / 2 - width + wallThickness);
    innerShape.lineTo(-length1 / 2 + width - wallThickness, length2 / 2 - width + wallThickness);
    innerShape.lineTo(-length1 / 2 + width - wallThickness, -length2 / 2 + wallThickness);
    innerShape.autoClose = true;

    const innerGeometry = new THREE.ExtrudeGeometry(innerShape, { depth: extrusionLength, bevelEnabled: false });
    const innerMesh = new THREE.Mesh(innerGeometry);
    innerMesh.position.copy(outerMesh.position);

    // Perform CSG subtraction
    const outerCSG = CSG.fromMesh(outerMesh);
    const innerCSG = CSG.fromMesh(innerMesh);
    const subtractedCSG = outerCSG.subtract(innerCSG);

    const resultMesh = CSG.toMesh(subtractedCSG, outerMesh.matrix, outerMesh.material);
    resultMesh.position.copy(outerMesh.position);

    return resultMesh;
}


Which LLM are you using to do this? I’m curious if it is suggesting using my CSG library?

I wanted to make you aware of some other alternatives as well:

and:

Yes, I’m using the Manthrax library.

Both the rectangular and L-shaped geometries were extruded with defined inner and outer paths to create wall thickness. However, the L-shape’s extrusion presents some challenges due to its sharp turns, which caused inaccuracies in face normals. This led to gaps and inconsistencies during the CSG operation, primarily stemming from its winding order and extrusion intricacies.

I had to enable double-sided rendering for the L-shape’s inside faces, which wasn’t necessary for the rectangular shape. While this phenomenon is strange, my research on face rendering provided some clarity.

I created the outer shape first and then subtracted the inner shape using CSG, as originally planned, and it worked. However, I still wonder why this was necessary for the L-shape but not for the rectangle, which I expected to behave similarly.

Interestingly, I didn’t have to subtract the inner shape for the circular shape, further indicating that the issue lies with the L-shape’s extrusion.

This, along with roofs and foundations, should make designing a home or building straightforward, which is my ultimate goal. I’m willing to adapt as needed. While AutoCAD is user-friendly, it’s costly, and this method is more dynamic and free. I plan to apply foundations and roofs to these basic floor plan shapes next and may have questions about implementing intersection and addition CSG, as I’ve mainly been working with subtraction, which is functioning well.

Thanks though, I appreciate the reply.

Herein lies a more refined visualization for those interested in discerning the underlying reasons for this phenomenon. I have implemented a variable representing the Number of Sides to better display what is going on.

It appears that this issue is once again linked to the extrusion process. Specifically, it is not executing a constructive solid geometry (CSG) subtraction of the inner shape from the outer shape. Instead, the operation solely performs CSG subtraction using the openings from the shape mesh, which integrates both outer and inner components within the same function.


function createCircular(sizeLabel, position, extrusionLength, wallThickness, sides) {
    const [diameter] = circularSizes[sizeLabel];

    // Calculate the outer radius based on circumscription
    const circumscribedRadius = diameter / (2 * Math.cos(Math.PI / sides));

    // Calculate the rotation angle (half the angle between two sides)
    const angleBetweenSides = (2 * Math.PI) / sides; // Angle between two sides
    const halfAngle = angleBetweenSides / 2; // Half of the angle

    // Outer shape - circular (circumscribed)
    const outerShape = new THREE.Shape();
    for (let i = 0; i <= sides; i++) {
        const angle = (i / sides) * Math.PI * 2; // Calculate the angle
        // Rotate the vertex coordinates about the z-axis by halfAngle
        const x = circumscribedRadius * Math.cos(angle + halfAngle); // X coordinate for outer shape
        const y = circumscribedRadius * Math.sin(angle + halfAngle); // Y coordinate for outer shape
        if (i === 0) {
            outerShape.moveTo(x, y); // Move to the first point
        } else {
            outerShape.lineTo(x, y); // Draw line to the next point
        }
    }

    // Inner shape as a hole (smaller by wall thickness)
    const innerShape = new THREE.Path();
    const innerRadius = circumscribedRadius - wallThickness; // Inner radius smaller by wall thickness
    for (let i = 0; i <= sides; i++) {
        const angle = (i / sides) * Math.PI * 2; // Calculate the angle
        // Rotate the inner vertex coordinates about the z-axis by halfAngle
        const x = innerRadius * Math.cos(angle + halfAngle); // X coordinate for inner shape
        const y = innerRadius * Math.sin(angle + halfAngle); // Y coordinate for inner shape
        if (i === 0) {
            innerShape.moveTo(x, y); // Move to the first point of the inner shape
        } else {
            innerShape.lineTo(x, y); // Draw line to the next point of the inner shape
        }
    }

    // Add inner shape as a hole in the outer shape
    outerShape.holes.push(innerShape);

    // Extrude only the area between the inner and outer shapes
    const geometry = new THREE.ExtrudeGeometry(outerShape, { depth: extrusionLength, bevelEnabled: false });
    
    const material = new THREE.MeshStandardMaterial({ 
        color: 0x0077ff, 
        side: THREE.DoubleSide 
    });

    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(...position);

    return mesh;
}

Upon the application of the function createCircularSubtraction, it becomes evident that the CSG operation is now executed correctly, revealing the openings along with their corresponding faces, all rendered with the appropriate material. This observation further substantiates that the underlying issue lies within the extrusion process, specifically in the manner the outer and inner shapes are integrated, alongside considerations of winding order and face normals.

While I cannot provide a definitive explanation for this phenomenon, I believe it is imperative to bring this matter to the attention of other users, as it may represent a prevalent challenge encountered when utilizing CSG or extruding shapes.


function createCircular(sizeLabel, position, extrusionLength, wallThickness, sides) {
    const [diameter] = circularSizes[sizeLabel];

    // Calculate the outer radius based on circumscription
    const circumscribedRadius = diameter / (2 * Math.cos(Math.PI / sides));

    const angleBetweenSides = (2 * Math.PI) / sides; // Angle between two sides
    const halfAngle = angleBetweenSides / 2; // Half angle for rotation

    // Outer shape - circular (circumscribed)
    const outerShape = new THREE.Shape();
    for (let i = 0; i < sides; i++) {
        const angle = i * angleBetweenSides; // Calculate the angle
        // Rotate the vertex coordinates about the z-axis by halfAngle
        const x = circumscribedRadius * Math.cos(angle + halfAngle); // X coordinate for outer shape
        const y = circumscribedRadius * Math.sin(angle + halfAngle); // Y coordinate for outer shape
        if (i === 0) {
            outerShape.moveTo(x, y); // Move to the first point
        } else {
            outerShape.lineTo(x, y); // Draw line to the next point
        }
    }
    outerShape.closePath(); // Close the shape

    // Create the extruded geometry from the outer shape
    const outerGeometry = new THREE.ExtrudeGeometry(outerShape, { depth: extrusionLength, bevelEnabled: false });
    const material = new THREE.MeshStandardMaterial({ color: 0x0077ff, side: THREE.DoubleSide });

    const outerMesh = new THREE.Mesh(outerGeometry, material);
    outerMesh.position.set(...position);

    // Call the subtraction function to create the final mesh
    return createCircularSubtraction(outerMesh, sizeLabel, wallThickness, extrusionLength, sides);
}

function createCircularSubtraction(outerMesh, sizeLabel, wallThickness, extrusionLength, sides) {
    const [diameter] = circularSizes[sizeLabel];

    // Calculate the outer radius based on circumscription
    const circumscribedRadius = diameter / (2 * Math.cos(Math.PI / sides));

    const angleBetweenSides = (2 * Math.PI) / sides; // Angle between two sides
    const halfAngle = angleBetweenSides / 2; // Half angle for rotation

    // Inner shape as a hole (smaller by wall thickness)
    const innerShape = new THREE.Shape();
    const innerRadius = circumscribedRadius - wallThickness; // Inner radius smaller by wall thickness
    for (let i = 0; i < sides; i++) {
        const angle = i * angleBetweenSides; // Calculate the angle
        // Rotate the inner vertex coordinates about the z-axis by halfAngle
        const x = innerRadius * Math.cos(angle + halfAngle); // X coordinate for inner shape
        const y = innerRadius * Math.sin(angle + halfAngle); // Y coordinate for inner shape
        if (i === 0) {
            innerShape.moveTo(x, y); // Move to the first point of the inner shape
        } else {
            innerShape.lineTo(x, y); // Draw line to the next point of the inner shape
        }
    }
    innerShape.closePath(); // Close the shape

    // Create the geometry for the inner shape
    const innerGeometry = new THREE.ExtrudeGeometry(innerShape, { depth: extrusionLength, bevelEnabled: false });
    const innerMesh = new THREE.Mesh(innerGeometry);
    innerMesh.position.copy(outerMesh.position);

    // Perform CSG subtraction
    const outerCSG = CSG.fromMesh(outerMesh);
    const innerCSG = CSG.fromMesh(innerMesh);
    const subtractedCSG = outerCSG.subtract(innerCSG);

    const resultMesh = CSG.toMesh(subtractedCSG, outerMesh.matrix, outerMesh.material);
    resultMesh.position.copy(outerMesh.position);

    return resultMesh;
}

I hope others find this information useful.