Skip to content

Migrate Replicad to OCCT v8 bindings - #263

Open
rifont wants to merge 63 commits into
sgenoud:mainfrom
taucad:main
Open

Migrate Replicad to OCCT v8 bindings#263
rifont wants to merge 63 commits into
sgenoud:mainfrom
taucad:main

Conversation

@rifont

@rifont rifont commented Jun 14, 2026

Copy link
Copy Markdown

Summary

  • Migrates Replicad to OCCT 8.0.1 and the corresponding generated OpenCascade.js binding API, including consolidated overloads and transparent resolution of reference-counted handles.
  • Ships replicad-opencascadejs with a default single-threaded entry point and a pthread-enabled ./multi entry point; both expose the same API and use native WebAssembly exceptions.
  • Publishes both runtime variants as generated ESM modules with CommonJS compatibility shims, and decodes OCCT exceptions when evaluator operations fail.
  • Replaces JavaScript face and edge mesh traversal with Replicad-owned C++ extractors that return packed buffers while preserving locations, tolerances, exact edge identity, bounded hashes, triangle winding, and normal orientation.
  • Updates Replicad geometry construction, curves, projections, sketches, measurements, shape operations, import/export, and XCAF assembly export for the OCCT 8 bindings.

Native mesh extraction

The face and edge extractors implement Replicad's rendering contracts, so they remain in this repository. They replace per-face, per-node, and per-edge JavaScript traversal with one packed-buffer WASM call per extraction.

The face extractor clears cached triangulations before remeshing at the requested tolerance, preserves face locations, triangle winding, normal orientation, and bounded face hashes, and returns packed vertex/normal/triangle/group arrays. The edge extractor reuses face triangulation polygons when available, applies face locations, deduplicates shared edges by exact OCCT shape identity, tessellates free edges with the historical tolerance ordering, and returns packed line/group arrays.

The direct A/B builds the Birdhouse and Vase once, then measures the historical JavaScript face and edge implementations directly against the generated OpenCascade.js bindings and the native extractors. Model construction is not timed and no package dependency was added. With 3 warmups and 100 measured iterations:

Model Geometry Face median (JS → C++; speedup) Face p95 (JS → C++) Edge median (JS → C++; speedup) Edge p95 (JS → C++)
Birdhouse 62 faces / 1,552 triangles; 162 edges / 808 segments 22.773 → 11.993 ms (1.90×) 26.107 → 13.451 ms 14.898 → 2.585 ms (5.76×) 18.156 → 3.422 ms
Vase 11 faces / 36,436 triangles; 18 edges / 501 segments 378.447 → 249.132 ms (1.52×) 418.732 → 341.594 ms 2.626 → 1.066 ms (2.46×) 3.609 → 1.934 ms

Parity checks cover the complete outputs rather than only their sizes. For both models, face vertices agree within 1e-4, triangle topology and face group start/count/hash values agree, and both normal buffers contain one unit normal per vertex. Normal components are deliberately not compared for equality: the native extractor uses analytic OCCT surface normals, while the historical JavaScript path used mesh-derived Poly_Triangulation.ComputeNormals(). Edge traversal order is not contractual; after normalizing traversal and segment direction, every bounded edge hash, group count, and segment geometry agrees within 2e-3 (the packed native line buffer is float32, while the JavaScript baseline retains doubles).

Full Birdhouse + Vase face + edge benchmark script used for the reported A/B measurements
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { fileURLToPath } from "node:url";

import {
  draw,
  drawCircle,
  makePlane,
  setOC,
} from "../replicad/dist/replicad.js";
import initSingle from "./dist/replicad_single.js";

const HASH_CODE_MAX = 2_147_483_647;
const tolerance = 0.1;
const angularTolerance = 0.5;
const iterations = Number.parseInt(process.env.BENCH_ITERATIONS ?? "100", 10);
const warmups = 3;
const distDir = fileURLToPath(new URL("./dist/", import.meta.url));
const wasmPath = join(distDir, "replicad_single.wasm");

const withScope = (callback) => {
  const garbage = [];
  const track = (value) => {
    if (value && typeof value.delete === "function") garbage.push(value);
    return value;
  };

  try {
    return callback(track);
  } finally {
    for (let index = garbage.length - 1; index >= 0; index--)
      garbage[index].delete();
  }
};

const listUnique = (oc, shape, kind, cast, track) => {
  const explorer = track(
    new oc.TopExp_Explorer(shape, kind, oc.TopAbs_ShapeEnum.TopAbs_SHAPE)
  );
  const seen = [];

  while (explorer.More()) {
    const raw = track(explorer.Current());
    if (!seen.some((candidate) => candidate.IsSame(raw)))
      seen.push(track(cast(raw)));
    explorer.Next();
  }

  return seen;
};

const makeBirdhouse = (
  p = {
    height: 85,
    width: 120,
    thickness: 2,
    holeDiameter: 50,
    hookHeight: 10,
    filletEdges: true,
  }
) => {
  const length = p.width;
  const width = p.width * 0.9;

  let tobleroneShape = draw([-width / 2, 0])
    .lineTo([0, p.height])
    .lineTo([width / 2, 0])
    .close()
    .sketchOnPlane("XZ", -length / 2)
    .extrude(length)
    .shell(p.thickness, (faceFinder) => faceFinder.parallelTo("XZ"));

  if (p.filletEdges) {
    tobleroneShape = tobleroneShape.fillet(p.thickness / 2, (edgeFinder) =>
      edgeFinder
        .inDirection("Y")
        .either([
          (finder) => finder.inPlane("XY"),
          (finder) => finder.inPlane("XY", p.height),
        ])
    );
  }

  const hole = drawCircle(p.holeDiameter / 2)
    .sketchOnPlane(makePlane("YZ").translate([-length / 2, 0, p.height / 3]))
    .extrude(length);
  const base = tobleroneShape.cut(hole);
  const body = base.clone().fuse(base.rotate(90));
  const hookWidth = length / 2;
  const hook = draw([0, p.hookHeight / 2])
    .smoothSplineTo([p.hookHeight / 2, 0], -45)
    .lineTo([hookWidth / 2, 0])
    .line(-hookWidth / 4, p.hookHeight / 2)
    .smoothSplineTo([0, p.hookHeight], {
      endTangent: 180,
      endFactor: 0.6,
    })
    .closeWithMirror()
    .sketchOnPlane("XZ")
    .extrude(p.thickness)
    .translate([0, p.thickness / 2, p.height - p.thickness / 2]);

  return body.fuse(hook);
};

const makeVase = (
  p = {
    height: 100,
    baseWidth: 20,
    wallThickness: 5,
    lowerCircleRadius: 1.5,
    lowerCirclePosition: 0.25,
    higherCircleRadius: 0.75,
    higherCirclePosition: 0.75,
    topRadius: 0.9,
    topFillet: true,
    bottomHeavy: true,
  }
) => {
  const splines = [
    { position: p.lowerCirclePosition, radius: p.lowerCircleRadius },
    {
      position: p.higherCirclePosition,
      radius: p.higherCircleRadius,
      startFactor: p.bottomHeavy ? 3 : 1,
    },
    { position: 1, radius: p.topRadius, startFactor: p.bottomHeavy ? 3 : 1 },
  ];
  const profile = draw().hLine(p.baseWidth);
  for (const { position, radius, startFactor, endFactor } of splines) {
    profile.smoothSplineTo([p.baseWidth * radius, p.height * position], {
      endTangent: [0, 1],
      startFactor,
      endFactor,
    });
  }

  let vase = profile
    .lineTo([0, p.height])
    .close()
    .sketchOnPlane("XZ")
    .revolve();
  if (p.wallThickness) {
    vase = vase.shell(p.wallThickness, (faceFinder) =>
      faceFinder.containsPoint([0, 0, p.height])
    );
  }
  if (p.topFillet) {
    vase = vase.fillet(p.wallThickness / 3, (edgeFinder) =>
      edgeFinder.inPlane("XY", p.height)
    );
  }
  return vase;
};

// These are the pre-native mesh() and meshEdges() implementations from
// Shapes.ts, expressed directly against the generated OpenCascade.js bindings.
const extractFacesInJs = (oc, shape) =>
  withScope((track) => {
    oc.BRepTools.Clean(shape, false);
    track(
      new oc.BRepMesh_IncrementalMesh(
        shape,
        tolerance,
        false,
        angularTolerance,
        false
      )
    );

    let triangles = [];
    let vertices = [];
    let normals = [];
    const faceGroups = [];
    const faces = listUnique(
      oc,
      shape,
      oc.TopAbs_ShapeEnum.TopAbs_FACE,
      (raw) => oc.TopoDS.Face(raw),
      track
    );

    for (const face of faces) {
      const location = track(new oc.TopLoc_Location());
      const triangulation = track(
        oc.BRep_Tool.Triangulation(face, location, 0)
      );
      if (!triangulation || triangulation.isNull()) continue;

      const transformation = track(location.Transformation());
      const nodeCount = triangulation.NbNodes();
      const vertexOffset = vertices.length / 3;
      const faceVertices = new Array(nodeCount * 3);
      for (let index = 1; index <= nodeCount; index++) {
        const point = track(
          track(triangulation.Node(index)).Transformed(transformation)
        );
        faceVertices[(index - 1) * 3] = point.X();
        faceVertices[(index - 1) * 3 + 1] = point.Y();
        faceVertices[(index - 1) * 3 + 2] = point.Z();
      }

      const reversed =
        face.Orientation() === oc.TopAbs_Orientation.TopAbs_REVERSED;
      const normalSign = reversed ? -1 : 1;
      if (!triangulation.HasNormals()) triangulation.ComputeNormals();
      const faceNormals = new Array(nodeCount * 3);
      for (let index = 1; index <= nodeCount; index++) {
        const normal = track(
          track(triangulation.Normal(index)).Transformed(transformation)
        );
        faceNormals[(index - 1) * 3] = normal.X() * normalSign;
        faceNormals[(index - 1) * 3 + 1] = normal.Y() * normalSign;
        faceNormals[(index - 1) * 3 + 2] = normal.Z() * normalSign;
      }

      const triangleCount = triangulation.NbTriangles();
      const faceTriangles = new Array(triangleCount * 3);
      for (let index = 1; index <= triangleCount; index++) {
        const triangle = track(triangulation.Triangle(index));
        let first = triangle.Value(1);
        let second = triangle.Value(2);
        const third = triangle.Value(3);
        if (reversed) [first, second] = [second, first];
        faceTriangles[(index - 1) * 3] = first - 1 + vertexOffset;
        faceTriangles[(index - 1) * 3 + 1] = second - 1 + vertexOffset;
        faceTriangles[(index - 1) * 3 + 2] = third - 1 + vertexOffset;
      }

      faceGroups.push({
        start: triangles.length,
        count: faceTriangles.length,
        faceId: oc.ReplicadShapeHasher.HashCode(face, HASH_CODE_MAX),
      });
      triangles = triangles.concat(faceTriangles);
      vertices = vertices.concat(faceVertices);
      normals = normals.concat(faceNormals);
    }

    return { triangles, vertices, normals, faceGroups };
  });

const extractFacesInNative = (oc, shape) => {
  const raw = oc.ReplicadMeshExtractor.extract(
    shape,
    tolerance,
    angularTolerance,
    false
  );
  try {
    const buffer = oc.wasmMemory.buffer;
    const heapF32 = new Float32Array(buffer);
    const heapU32 = new Uint32Array(buffer);
    const heapI32 = new Int32Array(buffer);
    const vertices = Array.from(
      heapF32.subarray(
        raw.getVerticesPtr() / 4,
        raw.getVerticesPtr() / 4 + raw.getVerticesSize()
      )
    );
    const normals = Array.from(
      heapF32.subarray(
        raw.getNormalsPtr() / 4,
        raw.getNormalsPtr() / 4 + raw.getNormalsSize()
      )
    );
    const triangles = Array.from(
      heapU32.subarray(
        raw.getTrianglesPtr() / 4,
        raw.getTrianglesPtr() / 4 + raw.getTrianglesSize()
      )
    );
    const groups = heapI32.subarray(
      raw.getFaceGroupsPtr() / 4,
      raw.getFaceGroupsPtr() / 4 + raw.getFaceGroupsSize()
    );
    const faceGroups = [];
    for (let index = 0; index < groups.length; index += 3) {
      faceGroups.push({
        start: groups[index],
        count: groups[index + 1],
        faceId: groups[index + 2],
      });
    }
    return { triangles, vertices, normals, faceGroups };
  } finally {
    raw.delete();
  }
};

const extractEdgesInJs = (oc, shape) =>
  withScope((track) => {
    const recordedEdges = new Set();
    const lines = [];
    const edgeGroups = [];
    const location = track(new oc.TopLoc_Location());
    const faceKind = oc.TopAbs_ShapeEnum.TopAbs_FACE;
    const edgeKind = oc.TopAbs_ShapeEnum.TopAbs_EDGE;

    const addEdge = () => {
      const start = lines.length;
      let previousPoint = null;
      return [
        (point) => {
          const currentPoint = [point.X(), point.Y(), point.Z()];
          if (previousPoint) lines.push(...previousPoint, ...currentPoint);
          previousPoint = currentPoint;
        },
        (edgeHash) => {
          edgeGroups.push({
            start: start / 3,
            count: (lines.length - start) / 3,
            edgeId: edgeHash,
          });
          recordedEdges.add(edgeHash);
        },
      ];
    };

    const faces = listUnique(
      oc,
      shape,
      faceKind,
      (raw) => oc.TopoDS.Face(raw),
      track
    );
    for (const face of faces) {
      const triangulation = track(
        oc.BRep_Tool.Triangulation(face, location, 0)
      );
      if (!triangulation || triangulation.isNull()) continue;

      const edges = listUnique(
        oc,
        face,
        edgeKind,
        (raw) => oc.TopoDS.Edge(raw),
        track
      );
      for (const edge of edges) {
        const edgeHash = oc.ReplicadShapeHasher.HashCode(edge, HASH_CODE_MAX);
        if (recordedEdges.has(edgeHash)) continue;
        const edgeLocation = track(new oc.TopLoc_Location());
        const polygon = track(
          oc.BRep_Tool.PolygonOnTriangulation(edge, triangulation, edgeLocation)
        );
        if (!polygon || polygon.isNull() || polygon.NbNodes() === 0) continue;

        const [recordPoint, done] = addEdge();
        for (let index = 1; index <= polygon.NbNodes(); index++) {
          const node = track(triangulation.Node(polygon.Node(index)));
          const transformation = track(edgeLocation.Transformation());
          recordPoint(track(node.Transformed(transformation)));
        }
        done(edgeHash);
      }
    }

    const edges = listUnique(
      oc,
      shape,
      edgeKind,
      (raw) => oc.TopoDS.Edge(raw),
      track
    );
    for (const edge of edges) {
      const edgeHash = oc.ReplicadShapeHasher.HashCode(edge, HASH_CODE_MAX);
      if (recordedEdges.has(edgeHash)) continue;
      const adaptor = track(new oc.BRepAdaptor_Curve(edge));
      const deflection = track(
        new oc.GCPnts_TangentialDeflection(
          adaptor,
          tolerance,
          angularTolerance,
          2,
          1e-9,
          1e-7
        )
      );
      const [recordPoint, done] = addEdge();
      for (let index = 1; index <= deflection.NbPoints(); index++) {
        const point = track(deflection.Value(index));
        const transformation = track(location.Transformation());
        recordPoint(track(point.Transformed(transformation)));
      }
      done(edgeHash);
    }

    return { lines, edgeGroups };
  });

const extractEdgesInNative = (oc, shape) => {
  const raw = oc.ReplicadEdgeMeshExtractor.extract(
    shape,
    tolerance,
    angularTolerance
  );
  try {
    const buffer = oc.wasmMemory.buffer;
    const heapF32 = new Float32Array(buffer);
    const heapI32 = new Int32Array(buffer);
    const lines = Array.from(
      heapF32.subarray(
        raw.getLinesPtr() / 4,
        raw.getLinesPtr() / 4 + raw.getLinesSize()
      )
    );
    const groups = heapI32.subarray(
      raw.getEdgeGroupsPtr() / 4,
      raw.getEdgeGroupsPtr() / 4 + raw.getEdgeGroupsSize()
    );
    const edgeGroups = [];
    for (let index = 0; index < groups.length; index += 3) {
      edgeGroups.push({
        start: groups[index],
        count: groups[index + 1],
        edgeId: groups[index + 2],
      });
    }
    return { lines, edgeGroups };
  } finally {
    raw.delete();
  }
};

const assertFloatParity = (actual, expected, name, epsilon = 1e-5) => {
  assert.equal(actual.length, expected.length, `${name} lengths differ`);
  for (let index = 0; index < actual.length; index++) {
    assert.ok(
      Math.abs(actual[index] - expected[index]) <= epsilon,
      `${name} value ${index} differs: ${actual[index]} !== ${expected[index]}`
    );
  }
};

const assertTriangleParity = (actual, expected) => {
  assert.equal(
    actual.length,
    expected.length,
    "triangle buffer lengths differ"
  );
  for (let index = 0; index < actual.length; index += 3) {
    const [a, b, c] = expected.slice(index, index + 3);
    const [x, y, z] = actual.slice(index, index + 3);
    assert.ok(
      (x === a && y === b && z === c) ||
        (x === b && y === c && z === a) ||
        (x === c && y === a && z === b),
      `triangle ${index / 3} differs`
    );
  }
};

const assertUnitNormals = (normals, name) => {
  for (let index = 0; index < normals.length; index += 3) {
    const length = Math.hypot(
      normals[index],
      normals[index + 1],
      normals[index + 2]
    );
    assert.ok(
      Math.abs(length - 1) <= 1e-4,
      `${name} normal ${index / 3} is not unit length`
    );
  }
};

const assertFaceParity = (jsResult, nativeResult) => {
  assertFloatParity(nativeResult.vertices, jsResult.vertices, "vertex", 1e-4);
  assertTriangleParity(nativeResult.triangles, jsResult.triangles);
  assert.deepEqual(nativeResult.faceGroups, jsResult.faceGroups);
  assert.equal(
    nativeResult.normals.length,
    jsResult.normals.length,
    "normal buffer lengths differ"
  );
  assertUnitNormals(nativeResult.normals, "native");
  assertUnitNormals(jsResult.normals, "historical JS");
};

const canonicalEdgeGroups = ({ lines, edgeGroups }) => {
  const canonical = new Map();
  let expectedStart = 0;

  for (const { start, count, edgeId } of edgeGroups) {
    assert.equal(
      start,
      expectedStart,
      `edge ${edgeId} starts at ${start}, expected ${expectedStart}`
    );
    assert.equal(count % 2, 0, `edge ${edgeId} has an odd vertex count`);
    assert.ok(!canonical.has(edgeId), `duplicate edge hash ${edgeId}`);
    const values = lines.slice(start * 3, (start + count) * 3);
    const segments = [];
    for (let index = 0; index < values.length; index += 6) {
      segments.push(values.slice(index, index + 6));
    }
    canonical.set(edgeId, { count, segments });
    expectedStart += count;
  }

  assert.equal(
    expectedStart * 3,
    lines.length,
    "edge groups do not cover the line buffer"
  );
  return canonical;
};

const assertEdgeParity = (jsResult, nativeResult) => {
  const jsGroups = canonicalEdgeGroups(jsResult);
  const nativeGroups = canonicalEdgeGroups(nativeResult);
  assert.equal(nativeGroups.size, jsGroups.size, "edge group counts differ");
  for (const [edgeId, jsGroup] of jsGroups) {
    const nativeGroup = nativeGroups.get(edgeId);
    assert.ok(nativeGroup, `native output is missing edge ${edgeId}`);
    assert.equal(
      nativeGroup.count,
      jsGroup.count,
      `edge ${edgeId} vertex counts differ`
    );
    assert.equal(nativeGroup.segments.length, jsGroup.segments.length);
    const unmatched = [...nativeGroup.segments];
    for (const jsSegment of jsGroup.segments) {
      const close = (nativeSegment, reverse) =>
        nativeSegment.every((value, index) => {
          const jsIndex = reverse ? (index < 3 ? index + 3 : index - 3) : index;
          return Math.abs(value - jsSegment[jsIndex]) <= 2e-3;
        });
      const match = unmatched.findIndex(
        (nativeSegment) =>
          close(nativeSegment, false) || close(nativeSegment, true)
      );
      assert.notEqual(
        match,
        -1,
        `native output has no matching segment for edge ${edgeId}: ${JSON.stringify(
          jsSegment
        )} vs ${JSON.stringify(unmatched)}`
      );
      unmatched.splice(match, 1);
    }
  }
};

const percentile = (values, fraction) => {
  const sorted = [...values].sort((left, right) => left - right);
  return sorted[
    Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))
  ];
};

const summary = (name, values) => ({
  implementation: name,
  medianMs: percentile(values, 0.5).toFixed(3),
  p95Ms: percentile(values, 0.95).toFixed(3),
  minMs: Math.min(...values).toFixed(3),
});

assert.ok(
  Number.isInteger(iterations) && iterations > 0,
  "BENCH_ITERATIONS must be positive"
);
assert.ok(existsSync(wasmPath), `Missing ${wasmPath}; run pnpm build first`);

const oc = await initSingle({ locateFile: () => wasmPath });
setOC(oc);

const benchmark = (fixtureName, model) => {
  const shape = model.wrapped;
  try {
    const jsFaces = extractFacesInJs(oc, shape);
    const nativeFaces = extractFacesInNative(oc, shape);
    assertFaceParity(jsFaces, nativeFaces);
    const jsEdges = extractEdgesInJs(oc, shape);
    const nativeEdges = extractEdgesInNative(oc, shape);
    assertEdgeParity(jsEdges, nativeEdges);

    const timings = {
      jsFaces: [],
      nativeFaces: [],
      jsEdges: [],
      nativeEdges: [],
    };
    const implementations = [
      ["jsFaces", () => extractFacesInJs(oc, shape)],
      ["nativeFaces", () => extractFacesInNative(oc, shape)],
      ["jsEdges", () => extractEdgesInJs(oc, shape)],
      ["nativeEdges", () => extractEdgesInNative(oc, shape)],
    ];
    let consumed = 0;

    for (let index = -warmups; index < iterations; index++) {
      const offset =
        ((index % implementations.length) + implementations.length) %
        implementations.length;
      const order = implementations
        .slice(offset)
        .concat(implementations.slice(0, offset));
      for (const [name, implementation] of order) {
        const start = performance.now();
        const result = implementation();
        const elapsed = performance.now() - start;
        consumed += result.vertices?.length ?? result.lines.length;
        if (index >= 0) timings[name].push(elapsed);
      }
    }

    const faceSpeedup =
      percentile(timings.jsFaces, 0.5) / percentile(timings.nativeFaces, 0.5);
    const edgeSpeedup =
      percentile(timings.jsEdges, 0.5) / percentile(timings.nativeEdges, 0.5);
    console.log(
      `${fixtureName}: ${nativeFaces.faceGroups.length} faces, ${
        nativeFaces.triangles.length / 3
      } triangles, ` +
        `${nativeEdges.edgeGroups.length} edges, ${
          nativeEdges.lines.length / 6
        } line segments`
    );
    console.log("Face extraction");
    console.table([
      summary("historical JS", timings.jsFaces),
      summary("native C++", timings.nativeFaces),
    ]);
    console.log(`face median speedup: ${faceSpeedup.toFixed(2)}x`);
    console.log("Edge extraction");
    console.table([
      summary("historical JS", timings.jsEdges),
      summary("native C++", timings.nativeEdges),
    ]);
    console.log(`edge median speedup: ${edgeSpeedup.toFixed(2)}x`);
    assert.ok(consumed > 0);
  } finally {
    model.delete();
  }
};

benchmark("Birdhouse", makeBirdhouse());
benchmark("Vase", makeVase());

Face meshing deliberately cleans and remeshes so a coarser request cannot be served by an older finer cached mesh. Edge-only extraction first checks whether a suitable triangulation already exists and avoids an unnecessary second mesh pass.

ReplicadShapeHasher.HashCode(shape, upperBound) preserves Replicad's historical public contract: orientation-independent labels in [1, upperBound]. Internal shared-edge deduplication does not use that bounded label; it uses NCollection_Map<TopoDS_Shape, TopTools_ShapeMapHasher> so hash collisions cannot merge distinct edges.

Generated artifacts

Both checked-in modules were regenerated and validated from these pinned canary inputs, which contain the required OCCT 8.0.1 build:

  • ghcr.io/taucad/opencascade.js:canary-ebd263f1-single-threadedsha256:215198af0e2ca4c5f308e5540869f2419784dc290062d3eb03d34e4f22e0188c
  • ghcr.io/taucad/opencascade.js:canary-ebd263f1-multi-threadedsha256:5cb67064edc903ae50c254e32417da9662fa88ec2e734386c28a3806949862ce

Build manifests and provenance reports are intentionally ignored rather than published or tracked; the pinned image coordinates, build source/configuration, JS glue, declarations, symbols, and WASM remain committed. Adoption of a later stable 3.0.0 image is a separate follow-up.

Review cleanup

  • Applied repository-default formatting to the app worker.
  • Removed Tau package aliases and regenerated the lockfile.
  • Removed tracked manifest/provenance diagnostics.
  • Removed the temporary smart-pointer unwrap helper; OpenCascade.js now resolves returned dynamic pointer types internally and retains its accepted FinalizationRegistry lifecycle.
  • Kept the obsolete OCCT 7 JavaScript mesh fallback removed.
  • Kept current upstream CLI/evaluator/Studio architecture while applying the OCCT 8 loader changes.
  • Removed the Tau-specific STEPCAF chamfer regression from this upstream PR; Tau retains equivalent runtime coverage.

Verification

  • pnpm install --frozen-lockfile
  • Generated and validated single and pthread modules from the pinned canary images
  • Direct OpenCascade tests against both variants: 9/9
  • Packed-package CommonJS consumer smoke tests: single and pthread entry points both initialize and create OCCT geometry
  • Direct historical-JS/native-C++ face and edge benchmarks on the Birdhouse and Vase with complete topology/group/hash/geometry parity checks (script embedded above, not checked into the package)
  • pnpm --filter replicad typecheck
  • Replicad Vitest suite: 5 files, 40 tests, including the high-level mesh contract
  • pnpm --filter replicad build
  • Replicad CLI build and tests: 6/6
  • Replicad evaluator build and tests: 6/6
  • Studio lint and production build
  • App-example production build
  • Single/multi declaration surfaces are identical
  • npm pack --dry-run --json for replicad-opencascadejs and replicad; no diagnostic sidecars included
  • Formatting and git diff --check
  • Move from taucad/opencascade.js GHCR canary images to 3.0.0 release images

Studio/app builds retain their existing dependency, duplicate-WASM-emission, CSS nesting, Browserslist, and chunk-size warnings; they complete successfully.

AI Disclosure

  • AI assistance used: yes
  • Model: GPT-5 Codex
  • Scope: source investigation, implementation, test/benchmark authoring, generated-artifact validation, merge conflict resolution, and PR drafting
  • Human verification: draft PR remains open for maintainer review

rifont added 30 commits March 7, 2026 11:24
Rebuilt both WASM variants against OCCT V8 with -O3 optimization:
- replicad_single: compact build without exception support (18.96 MB)
- replicad_with_exceptions: full build with native WASM exceptions (22.22 MB)

Also includes replicad source updates for OCCT V8 API compatibility.

Made-with: Cursor
orts and add multi-threaded build
Comment thread docs/occt-v8-migration.md

The generated bindings now make the trailing `Message_ProgressRange` optional for `TransferRoots` and most `Build` and `Perform` methods. Omitting it materializes OCCT's default progress range internally, so this migration removes 15 manual progress-range allocations.

Two writer APIs still require an explicit range: `STEPControl_Writer.Transfer(..., theProgress)` and `STEPCAFControl_Writer.Perform(..., theProgress)`. Replicad retains progress-range allocations at exactly those two callsites.

@rifont rifont Aug 4, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏Calling this out as a PR comment since the ProgressRange removal features significantly through the PR. Providing a ProgressRange was pure ceremony at many callsites and now OpenCascade.js handles such optional params internally, so it can be safely omitted.

.map(([a, b, c]) => draw(a).lineTo(b).lineTo(c).close());
const drawings = triangles.map(([a, b, c]) =>
draw(a).lineTo(b).lineTo(c).close()
);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏I'm not sure why this test used randomized values earlier, but it makes the generated test snapshot non-deterministic. To make the snapshot generation deterministic, which it should be as a test fixture, I've removed the randomization here.

Comment on lines +28 to +30
const { Curve1, Curve2 } = intersector.Segment(i);
Curve2.delete();
yield new Curve2D(Curve1);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏In my testing I couldn't reproduce this failure mode, and the repo has no regression tests for it, but I'm leaving this block inside the try/catch as a precaution.

If this previous commentary sounds familiar, it would be great to add a regression to verify if the try/catch is still needed, if the bug is resolved in OCCT 8 then the try/catch could be removed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏This tiny shim maintains CJS compatibility alongside the new ESM whilst avoiding the need for a heavier vite-config approach. A similar shim can be seen below for the single threaded build.

ctool.SetColor(
shapeNode,
wrapColor(color || "#f00", alpha ?? 1),
// @ts-expect-error the type system does not work for these

@rifont rifont Aug 4, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏Enums have correct typings in the new OpenCascade.js 🎉

writer.SetLayerMode(true);
writer.SetNameMode(true);
oc.Interface_Static.SetIVal("write.surfacecurve.mode", true);
oc.Interface_Static.SetIVal("write.surfacecurve.mode", 1);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏This API changed from a boolean to a number from OCCT-7 -> OCCT-8

[xMin.current, yMin.current],
[xMax.current, yMax.current],
[this.wrapped.GetXMin(), this.wrapped.GetYMin()],
[this.wrapped.GetXMax(), this.wrapped.GetYMax()],

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏Pass-by-reference APIs, which are non-idiomatic JS, are now resolved via functions in the new OpenCascade.js.

);
}
return curve;
gc();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏It's necessary to garbage-collect after accessing the X + Y points now, since those values are obtained from memory with the new OpenCascade.js dynamic pointer management approach. Early garbage-collection would clear their values before they could be accessed.

smoothing[2],
degMax,
oc.GeomAbs_Shape.GeomAbs_C2,
tolerance

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏in OCCT-8 splines are now initialized separate to their constructors.

Comment thread packages/replicad/src/sketches/CompoundSketch.ts Outdated
Comment thread packages/replicad/src/addThickness.ts Outdated
this.wrapped,
true
true,
false

@rifont rifont Aug 5, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏OCCT-8 exposes a fourth copyMesh option on this binding, the old binding exposed on the three-argument form. Passing false preserves the historical behavior of copying geometry without copying an existing triangulation.

];
cornerMin.delete();
cornerMax.delete();
return result;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏As before, pass-by-reference idioms have been replaced by functional JS getters in the new OpenCascade.js

Comment thread packages/replicad/src/shapeHelpers.ts Outdated
Comment on lines 445 to +455

const poles = convertToJSArray(baseSurface.Poles_2());
const transform = new EllpsoidTransform(aLength, bLength, cLength);

poles.forEach((columns, r) => {
columns.forEach((value, c) => {
const newPoint = transform.applyToPoint(value);
baseSurface.SetPole_1(r + 1, c + 1, newPoint);
});
});
for (let u = 1; u <= baseSurface.NbUPoles(); u++) {
for (let v = 1; v <= baseSurface.NbVPoles(); v++) {
const newPoint = transform.applyToPoint(baseSurface.Pole(u, v));
baseSurface.SetPole(u, v, newPoint);
}
}
const shell = cast(
r(new oc.BRepBuilderAPI_MakeShell_2(baseSurface.UReversed(), false)).Shell()
r(new oc.BRepBuilderAPI_MakeShell(baseSurface.UReversed(), false)).Shell()

@rifont rifont Aug 5, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏The previously used convertToJSArray function is replaced here with a simpler iterator.

Comment thread packages/replicad/src/shapeHelpers.ts Outdated
const hash = item.HashCode(HASH_CODE_MAX);
if (!hashes.get(hash)) {
hashes.set(hash, true);
const isDuplicate = seen.some((s) => s.IsSame(item));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking):.IsSame() is the native OCCT predicate for shape equality and provides stronger matching guarantees with drastically less chance of collision (32-bit hash-code versus 64-bit OCCT matching).


const filteredFaces = filter.find(this);
const facesToRemove = r(new this.oc.TopTools_ListOfShape_1());
const facesToRemove = r(new this.oc.NCollection_List_TopoDS_Shape());

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking):NCollection is the new list primitive in OCCT-8

@rifont rifont Aug 5, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking):TopoDS lackages a CompSolid cast so we need to add a Cpp helper to downcast this shape type. This link points to the cast consumer in replicad within this PR.

);

if (triangulation.IsNull()) return null;
if (!triangulation || triangulation.isNull()) return null;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒 note (non-blocking): ‏Due to OpenCascade.js now transparently dereferencing pointers, we now need to ensure both the handle and the pointee are not null.

@rifont

rifont commented Aug 5, 2026

Copy link
Copy Markdown
Author

@sgenoud this PR is ready for review 🎉

The last close-out item I need to do before it's merge-ready is move from taucad/opencascade.js GHCR canary images to 3.0.0 release images, which should be ready later today/tomorrow. I will update the PR with this change as soon as it's ready.

@rifont
rifont marked this pull request as ready for review August 5, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants