Skip to content

CSG as an Intermediate Representation ​

Most code-CAD libraries hand you eager geometry: box(10, 10, 10) immediately calls into the kernel, returns a real B-Rep solid, and that solid sits in WASM memory until you .delete() it. Chain a few operations and you've allocated and freed dozens of intermediate shapes whose only purpose was to feed the next call.

brepjs's csg namespace offers a different surface for the same operations. Instead of materializing geometry, the builders construct a content-addressed DAG: a tree of IRNode values, each describing what to build without actually building it. Geometry is produced on demand by an Evaluator, which caches by structural hash so re-evaluating the same subtree is free.

A tree, not a shape ​

Every builder returns a plain data object:

typescript
import { csg } from 'brepjs/quick';

const node = csg.box(10, 20, 30);
// {
//   kind: 'Box',
//   x: NumLitExpr(10), y: NumLitExpr(20), z: NumLitExpr(30),
//   structuralHash: 0x9f3a...n,
//   freeParams: Set(0) {},
// }

No kernel call, no WASM allocation. Same for every builder: cylinder, sphere, fuse, cut, translate, even compound. The tree is just description.

typescript
import { csg } from 'brepjs/quick';

const tree = csg.cut(csg.box(100, 100, 100), csg.sphere(40));
// kind: 'Cut'
//   a: kind: 'Box' (100×100×100)
//   b: kind: 'Sphere' (radius 40)

The structure of the tree is the structure of the computation. Nothing has been computed yet.

Content-addressed hashing ​

Every node carries a structuralHash: a 64-bit FNV-1a Merkle hash of its kind plus its children's hashes. Two trees that describe the same geometry hash identically, regardless of how they were built:

typescript
import { csg } from 'brepjs/quick';

const a = csg.fuse(csg.box(10, 10, 10), csg.sphere(5));
const b = csg.fuse(csg.box(10, 10, 10), csg.sphere(5));

a.structuralHash === b.structuralHash; // true

Change any literal anywhere in the tree and the hash changes:

typescript
csg.box(10, 10, 10).structuralHash === csg.box(10, 10, 11).structuralHash;
// false - last dimension differs

The hash is computed once at build time and stored on the node, so cache lookups during evaluation are O(1) per node rather than O(subtree-size). The hash also normalizes -0 to +0 and serializes floats bit-exactly via DataView.setFloat64, so identical doubles always hash the same.

Free parameters propagate upward ​

Builders accept literal numbers or expressions. Expressions parameterize the tree: csg.param('w') is a placeholder bound at evaluation time. Each node tracks the set of parameter names it transitively depends on:

typescript
import { csg } from 'brepjs/quick';

const tree = csg.translate(csg.fuse(csg.box(csg.param('w'), 10, 10), csg.sphere(csg.param('r'))), [
  csg.param('dx'),
  0,
  0,
]);

[...tree.freeParams].sort(); // ['dx', 'r', 'w']

freeParams is computed bottom-up by the builders at build time. The evaluator uses it to project the env before computing the cache key: a node that doesn't reference dx will never see dx enter its cache key, so unrelated env edits cannot invalidate it. This is the core mechanism behind incremental re-evaluation:

When dx changes, only Translate invalidates. When r changes, Sphere + Fuse + Translate invalidate but Box survives. The granularity is structural: the cost of a parameter edit is proportional to the size of the subtrees that depend on it, not the size of the whole tree.

Output kinds and structural validity ​

The IR doesn't have separate node types for solids vs faces vs edges; there's one IRNode union with a kind discriminator. Output kind is derived structurally via outputKindOf:

typescript
import { csg } from 'brepjs/quick';

csg.outputKindOf(csg.box(10, 10, 10)); // 'Solid'
csg.outputKindOf(csg.translate(csg.box(10, 10, 10), [1, 0, 0])); // 'Solid'
csg.outputKindOf(csg.cut(csg.box(10, 10, 10), csg.sphere(3))); // 'Solid'
csg.outputKindOf(csg.circle(5)); // 'Edge'
csg.outputKindOf(
  csg.polygon([
    [0, 0, 0],
    [1, 0, 0],
    [0, 1, 0],
  ])
); // 'Face'

Booleans inherit the kind of their a argument; transforms inherit from target; primitives are fixed. There's no cross-kind boolean: fuse(box, circle) is rejected at the kernel boundary, not by the TypeScript types at v1.

Two equivalent ways to compute the same geometry ​

Both snippets produce the same 1000 - π·125 ≈ 477 mm³ solid:

typescript
import { box, sphere, cut, measureVolume, unwrap } from 'brepjs/quick';

const cube = box(10, 10, 10);
const ball = sphere(2.5);
const part = unwrap(cut(cube, ball));
const v = unwrap(measureVolume(part));
// 3 kernel allocations: cube, ball, part - all live until disposed
typescript
import { csg, measureVolume, unwrap } from 'brepjs/quick';

const tree = csg.cut(csg.box(10, 10, 10), csg.sphere(2.5));
using ev = new csg.Evaluator();
const v = unwrap(measureVolume(unwrap(ev.evaluate(tree))));
// 3 kernel allocations the first time. 0 on every subsequent ev.evaluate(tree).

The eager API is the right answer when you build a part once. The IR is the right answer when:

  • the same tree shape gets re-evaluated with different parameter bindings (slider UIs, optimizer sweeps),
  • multiple branches of a build share common subtrees that would otherwise be recomputed,
  • you want to serialize the build recipe to JSON without serializing materialized B-Rep data,
  • you want to inspect, simplify, or edit the build graph before evaluation.

If none of those apply, the topology API is leaner. The IR is a tool, not a replacement.

Where this fits in brepjs ​

The IR is purely a build-time abstraction layered on top of the kernel adapter: csg.Evaluator calls the same getKernel().fuseSolids(...) that the topology API does. Everything that works on a Solid works on the result of ev.evaluate(tree): finders, fillets, measurement, export. The IR is the parametric front of the pipeline; the topology API picks up where it leaves off.

Operations without a built-in node ​

Some operations have no IR node: patterns, threads, drafts, imported parts. Wrap one in csg.extension and it still lives inside the tree. The node carries a name, a declared output kind, child nodes, scalar params (literals or expressions), and JSON-safe data. It hashes, caches, serializes, and reacts to param changes like a built-in node. The geometry comes from a function you register on the Evaluator under the same name:

typescript
import { csg, fuseAll, isShape3D, ok, err, translate, unwrap, validationError } from 'brepjs/quick';

const row = csg.extension('row', 'Solid', {
  children: [csg.box(5, 5, 5)],
  params: { count: csg.param('n'), spacing: 10 },
});

using ev = new csg.Evaluator({
  extensions: {
    row: ({ params, children }) => {
      const [part] = children;
      if (!part || !isShape3D(part)) {
        return err(validationError('ROW_INPUT', 'row needs one 3D child'));
      }
      const count = params['count'] ?? 1;
      if (count === 1) return ok(part);
      const copies = Array.from({ length: count }, (_, i) =>
        translate(part, [i * (params['spacing'] ?? 0), 0, 0])
      );
      try {
        return fuseAll(copies);
      } finally {
        for (const c of copies) c[Symbol.dispose]();
      }
    },
  },
});

const shape = unwrap(ev.evaluate(csg.fuse(row, csg.box(5, 40, 5)), { n: 3 }));

The function receives params already resolved to numbers and children already materialized (and cached like any other node). Children are borrowed: don't dispose them. The Evaluator owns whatever the function returns, including a child returned unchanged, so you never dispose the result either. An extension whose name has no registered function evaluates to an Err with code CSG_EXTENSION_UNKNOWN. Because the result is cached by content, the function must depend only on its params, data, and children.

The rest of the surface ​

The examples above touch box, sphere, cylinder, cut, fuse, and translate. The full builder set adds cone, torus, polygon, circle, line, vertex, mirror, scale, rotate, compound, instance (transform-only replication — one source × N placements, materialized on export; see Performance), and extension (see above), plus the N-ary booleans fuseAll and cutAll. Expression helpers include add/mul shortcuts, unaryOp (sin, cos, sqrt, abs, neg), component for indexing vectors, and buildVec for constructing them from scalars. All exported from 'brepjs' under the csg.* namespace; see the API reference for type signatures.

Next steps ​

  • Parametric CSG walkthrough: build a gridfinity-style bin with named parameters, change a slider, watch the cache absorb the unchanged subtrees.
  • Declarative Models: the component layer built on this IR — brepjs-families projects prop-driven element trees onto it, so identical components share one materialization while each keeps its own identity.
  • CSG caching internals: cacheStats, optimize, serialization, what the cache key actually contains, and what doesn't cache.
  • Migrating from a hand-rolled cache: if you've built your own Map<key, Solid> cache around eager geometry, here's the IR equivalent and what you'd inherit by switching.

Released under the Apache 2.0 License.