src/volume/csg/ConstructiveSolidGeometry.js
import { Box3, Vector3 } from "three";
import { layout } from "sparse-octree";
import { Material } from "../Material";
import { EdgeData } from "../EdgeData";
import { HermiteData } from "../HermiteData";
import { Edge } from "../Edge";
import { OperationType } from "./OperationType";
import { Union } from "./Union";
import { Difference } from "./Difference";
import { Intersection } from "./Intersection";
/**
* The world size of the current data cell.
*
* @type {Number}
* @private
*/
let cellSize = 0;
/**
* The lower bounds of the current data cell.
*
* @type {Vector3}
* @private
*/
const cellPosition = new Vector3();
/**
* Finds out which grid points lie inside the area of the given operation.
*
* @private
* @param {Operation} operation - A CSG operation.
* @return {Box3} The index bounds.
*/
function computeIndexBounds(operation) {
const s = cellSize;
const n = HermiteData.resolution;
const min = new Vector3(0, 0, 0);
const max = new Vector3(n, n, n);
const cellBounds = new Box3(cellPosition, cellPosition.clone().addScalar(cellSize));
const operationBounds = operation.getBoundingBox();
if(operation.type !== OperationType.INTERSECTION) {
if(operationBounds.intersectsBox(cellBounds)) {
min.copy(operationBounds.min).max(cellBounds.min).sub(cellBounds.min);
min.x = Math.ceil(min.x * n / s);
min.y = Math.ceil(min.y * n / s);
min.z = Math.ceil(min.z * n / s);
max.copy(operationBounds.max).min(cellBounds.max).sub(cellBounds.min);
max.x = Math.floor(max.x * n / s);
max.y = Math.floor(max.y * n / s);
max.z = Math.floor(max.z * n / s);
} else {
// The chunk is unaffected by this operation.
min.set(n, n, n);
max.set(0, 0, 0);
}
}
return new Box3(min, max);
}
/**
* Combines material indices.
*
* @private
* @param {Operation} operation - A CSG operation.
* @param {HermiteData} data0 - A target data set.
* @param {HermiteData} data1 - A predominant data set.
* @param {Box3} bounds - Grid iteration limits.
*/
function combineMaterialIndices(operation, data0, data1, bounds) {
const n = HermiteData.resolution;
const m = n + 1;
const mm = m * m;
const X = bounds.max.x;
const Y = bounds.max.y;
const Z = bounds.max.z;
let x, y, z;
for(z = bounds.min.z; z <= Z; ++z) {
for(y = bounds.min.y; y <= Y; ++y) {
for(x = bounds.min.x; x <= X; ++x) {
operation.updateMaterialIndex((z * mm + y * m + x), data0, data1);
}
}
}
}
/**
* Generates material indices.
*
* @private
* @param {DensityFunction} operation - A CSG operation.
* @param {HermiteData} data - A target data set.
* @param {Box3} bounds - Grid iteration limits.
*/
function generateMaterialIndices(operation, data, bounds) {
const s = cellSize;
const n = HermiteData.resolution;
const m = n + 1;
const mm = m * m;
const materialIndices = data.materialIndices;
const base = cellPosition;
const offset = new Vector3();
const position = new Vector3();
const X = bounds.max.x;
const Y = bounds.max.y;
const Z = bounds.max.z;
let materialIndex;
let materials = 0;
let x, y, z;
for(z = bounds.min.z; z <= Z; ++z) {
offset.z = z * s / n;
for(y = bounds.min.y; y <= Y; ++y) {
offset.y = y * s / n;
for(x = bounds.min.x; x <= X; ++x) {
offset.x = x * s / n;
materialIndex = operation.generateMaterialIndex(position.addVectors(base, offset));
if(materialIndex !== Material.AIR) {
materialIndices[z * mm + y * m + x] = materialIndex;
++materials;
}
}
}
}
data.materials = materials;
}
/**
* Combines edges.
*
* @private
* @param {Operation} operation - A CSG operation.
* @param {HermiteData} data0 - A target data set.
* @param {HermiteData} data1 - A predominant data set.
* @return {Object} The generated edge data.
*/
function combineEdges(operation, data0, data1) {
const n = HermiteData.resolution;
const m = n + 1;
const mm = m * m;
const indexOffsets = new Uint32Array([1, m, mm]);
const materialIndices = data0.materialIndices;
const edge1 = new Edge();
const edge0 = new Edge();
const edgeData1 = data1.edgeData;
const edgeData0 = data0.edgeData;
const lengths = new Uint32Array(3);
const edgeCount = EdgeData.calculate1DEdgeCount(n);
const edgeData = new EdgeData(
n,
Math.min(edgeCount, edgeData0.indices[0].length + edgeData1.indices[0].length),
Math.min(edgeCount, edgeData0.indices[1].length + edgeData1.indices[1].length),
Math.min(edgeCount, edgeData0.indices[2].length + edgeData1.indices[2].length)
);
let edges1, zeroCrossings1, normals1;
let edges0, zeroCrossings0, normals0;
let edges, zeroCrossings, normals;
let indexOffset;
let indexA1, indexB1;
let indexA0, indexB0;
let m1, m2;
let edge;
let c, d, i, j, il, jl;
// Process the edges along the X-axis, then Y and finally Z.
for(c = 0, d = 0; d < 3; c = 0, ++d) {
edges1 = edgeData1.indices[d];
edges0 = edgeData0.indices[d];
edges = edgeData.indices[d];
zeroCrossings1 = edgeData1.zeroCrossings[d];
zeroCrossings0 = edgeData0.zeroCrossings[d];
zeroCrossings = edgeData.zeroCrossings[d];
normals1 = edgeData1.normals[d];
normals0 = edgeData0.normals[d];
normals = edgeData.normals[d];
indexOffset = indexOffsets[d];
il = edges1.length;
jl = edges0.length;
// Process all generated edges.
for(i = 0, j = 0; i < il; ++i) {
indexA1 = edges1[i];
indexB1 = indexA1 + indexOffset;
m1 = materialIndices[indexA1];
m2 = materialIndices[indexB1];
if(m1 !== m2 && (m1 === Material.AIR || m2 === Material.AIR)) {
edge1.t = zeroCrossings1[i];
edge1.n.x = normals1[i * 3];
edge1.n.y = normals1[i * 3 + 1];
edge1.n.z = normals1[i * 3 + 2];
if(operation.type === OperationType.DIFFERENCE) {
edge1.n.negate();
}
edge = edge1;
// Process existing edges up to the generated edge.
while(j < jl && edges0[j] <= indexA1) {
indexA0 = edges0[j];
indexB0 = indexA0 + indexOffset;
edge0.t = zeroCrossings0[j];
edge0.n.x = normals0[j * 3];
edge0.n.y = normals0[j * 3 + 1];
edge0.n.z = normals0[j * 3 + 2];
m1 = materialIndices[indexA0];
if(indexA0 < indexA1) {
m2 = materialIndices[indexB0];
if(m1 !== m2 && (m1 === Material.AIR || m2 === Material.AIR)) {
// The edge exhibits a material change and there is no conflict.
edges[c] = indexA0;
zeroCrossings[c] = edge0.t;
normals[c * 3] = edge0.n.x;
normals[c * 3 + 1] = edge0.n.y;
normals[c * 3 + 2] = edge0.n.z;
++c;
}
} else {
// Resolve the conflict.
edge = operation.selectEdge(edge0, edge1, (m1 === Material.SOLID));
}
++j;
}
edges[c] = indexA1;
zeroCrossings[c] = edge.t;
normals[c * 3] = edge.n.x;
normals[c * 3 + 1] = edge.n.y;
normals[c * 3 + 2] = edge.n.z;
++c;
}
}
// Collect remaining edges.
while(j < jl) {
indexA0 = edges0[j];
indexB0 = indexA0 + indexOffset;
m1 = materialIndices[indexA0];
m2 = materialIndices[indexB0];
if(m1 !== m2 && (m1 === Material.AIR || m2 === Material.AIR)) {
edges[c] = indexA0;
zeroCrossings[c] = zeroCrossings0[j];
normals[c * 3] = normals0[j * 3];
normals[c * 3 + 1] = normals0[j * 3 + 1];
normals[c * 3 + 2] = normals0[j * 3 + 2];
++c;
}
++j;
}
lengths[d] = c;
}
return { edgeData, lengths };
}
/**
* Generates edge data.
*
* @private
* @param {DensityFunction} operation - A CSG operation.
* @param {HermiteData} data - A target data set.
* @param {Box3} bounds - Grid iteration limits.
* @return {Object} The generated edge data.
*/
function generateEdges(operation, data, bounds) {
const s = cellSize;
const n = HermiteData.resolution;
const m = n + 1;
const mm = m * m;
const indexOffsets = new Uint32Array([1, m, mm]);
const materialIndices = data.materialIndices;
const base = cellPosition;
const offsetA = new Vector3();
const offsetB = new Vector3();
const edge = new Edge();
const lengths = new Uint32Array(3);
const edgeData = new EdgeData(n, EdgeData.calculate1DEdgeCount(n));
let edges, zeroCrossings, normals, indexOffset;
let indexA, indexB;
let minX, minY, minZ;
let maxX, maxY, maxZ;
let c, d, a, axis;
let x, y, z;
// Process the edges along the X-axis, then Y and finally Z.
for(a = 4, c = 0, d = 0; d < 3; a >>= 1, c = 0, ++d) {
// X: [1, 0, 0] Y: [0, 1, 0] Z: [0, 0, 1].
axis = layout[a];
edges = edgeData.indices[d];
zeroCrossings = edgeData.zeroCrossings[d];
normals = edgeData.normals[d];
indexOffset = indexOffsets[d];
minX = bounds.min.x; maxX = bounds.max.x;
minY = bounds.min.y; maxY = bounds.max.y;
minZ = bounds.min.z; maxZ = bounds.max.z;
/* Include edges that straddle the bounding box and avoid processing grid
points at chunk borders. */
switch(d) {
case 0:
minX = Math.max(minX - 1, 0);
maxX = Math.min(maxX, n - 1);
break;
case 1:
minY = Math.max(minY - 1, 0);
maxY = Math.min(maxY, n - 1);
break;
case 2:
minZ = Math.max(minZ - 1, 0);
maxZ = Math.min(maxZ, n - 1);
break;
}
for(z = minZ; z <= maxZ; ++z) {
for(y = minY; y <= maxY; ++y) {
for(x = minX; x <= maxX; ++x) {
indexA = z * mm + y * m + x;
indexB = indexA + indexOffset;
// Check if the edge exhibits a material change.
if(materialIndices[indexA] !== materialIndices[indexB]) {
offsetA.set(
x * s / n,
y * s / n,
z * s / n
);
offsetB.set(
(x + axis[0]) * s / n,
(y + axis[1]) * s / n,
(z + axis[2]) * s / n
);
edge.a.addVectors(base, offsetA);
edge.b.addVectors(base, offsetB);
// Create and store the edge data.
operation.generateEdge(edge);
edges[c] = indexA;
zeroCrossings[c] = edge.t;
normals[c * 3] = edge.n.x;
normals[c * 3 + 1] = edge.n.y;
normals[c * 3 + 2] = edge.n.z;
++c;
}
}
}
}
lengths[d] = c;
}
return { edgeData, lengths };
}
/**
* Either generates or combines volume data based on the operation type.
*
* @private
* @param {Operation} operation - A CSG operation.
* @param {HermiteData} data0 - A target data set. May be empty or full.
* @param {HermiteData} [data1] - A predominant data set. Cannot be null.
*/
function update(operation, data0, data1) {
const bounds = computeIndexBounds(operation);
let result, edgeData, lengths, d;
let done = false;
// Grid points.
if(operation.type === OperationType.DENSITY_FUNCTION) {
generateMaterialIndices(operation, data0, bounds);
} else if(data0.empty) {
if(operation.type === OperationType.UNION) {
data0.set(data1);
done = true;
}
} else {
if(!(data0.full && operation.type === OperationType.UNION)) {
combineMaterialIndices(operation, data0, data1, bounds);
}
}
// Edges.
if(!done && !data0.empty && !data0.full) {
result = (operation.type === OperationType.DENSITY_FUNCTION) ?
generateEdges(operation, data0, bounds) :
combineEdges(operation, data0, data1);
edgeData = result.edgeData;
lengths = result.lengths;
// Cut off empty data.
for(d = 0; d < 3; ++d) {
edgeData.indices[d] = edgeData.indices[d].slice(0, lengths[d]);
edgeData.zeroCrossings[d] = edgeData.zeroCrossings[d].slice(0, lengths[d]);
edgeData.normals[d] = edgeData.normals[d].slice(0, lengths[d] * 3);
}
data0.edgeData = edgeData;
}
}
/**
* Executes the given operation to generate data.
*
* @private
* @param {Operation} operation - An operation.
* @return {HermiteData} The generated data or null if the data is empty.
*/
function execute(operation) {
const children = operation.children;
let result, data;
let i, l;
if(operation.type === OperationType.DENSITY_FUNCTION) {
// Create a data target.
result = new HermiteData();
// Use the density function to generate data.
update(operation, result);
}
// Union, Difference or Intersection.
for(i = 0, l = children.length; i < l; ++i) {
// Generate the full result of the child operation recursively.
data = execute(children[i]);
if(result === undefined) {
result = data;
} else if(data !== null) {
if(result === null) {
if(operation.type === OperationType.UNION) {
// Build upon the first non-empty data.
result = data;
}
} else {
// Combine the two data sets.
update(operation, result, data);
}
} else if(operation.type === OperationType.INTERSECTION) {
// An intersection with nothing results in nothing.
result = null;
}
if(result === null && operation.type !== OperationType.UNION) {
// Further subtractions and intersections would have no effect.
break;
}
}
return (result !== null && result.empty) ? null : result;
}
/**
* Constructive Solid Geometry combines Signed Distance Functions by using
* Boolean operators to generate and transform volume data.
*/
export class ConstructiveSolidGeometry {
/**
* Transforms the given Hermite data in two steps:
*
* 1. Generate data by executing the given SDF
* 2. Combine the generated data with the given data
*
* @param {Number[]} min - The lower bounds of the volume data cell.
* @param {Number} size - The size of the volume data cell.
* @param {HermiteData} data - The volume data that should be modified.
* @param {SignedDistanceFunction} sdf - An SDF.
* @return {HermiteData} The modified, uncompressed data or null if the result is empty.
*/
static run(min, size, data, sdf) {
cellPosition.fromArray(min);
cellSize = size;
if(data === null) {
if(sdf.operation === OperationType.UNION) {
// Prepare an empty target.
data = new HermiteData(false);
}
} else {
data.decompress();
}
// Step 1.
let operation = sdf.toCSG();
const generatedData = (data !== null) ? execute(operation) : null;
if(generatedData !== null) {
// Wrap the operation in a super operation.
switch(sdf.operation) {
case OperationType.UNION:
operation = new Union(operation);
break;
case OperationType.DIFFERENCE:
operation = new Difference(operation);
break;
case OperationType.INTERSECTION:
operation = new Intersection(operation);
break;
}
// Step 2.
update(operation, data, generatedData);
// Provoke an isosurface extraction.
data.contoured = false;
}
return (data !== null && data.empty) ? null : data;
}
}