feat: establish reproducible FreeCAD web compatibility baseline

This commit is contained in:
2026-08-10 16:11:38 -04:00
parent e5a5d74dbc
commit b962a5c3b5
733 changed files with 349081 additions and 647 deletions

View File

@@ -1,8 +1,8 @@
# Native OCCT history provider
This provider is compiled from the cached OCCT checkout selected by
`OCCT_SOURCE_DIR` and exposes the native Boolean history API through Emscripten
Embind. The provider keeps `TopoDS_Shape` handles and history queries in one
`OCCT_SOURCE_DIR` and exposes native Boolean and PartDesign history APIs through
Emscripten Embind. The provider keeps `TopoDS_Shape` handles and history queries in one
WASM instance. A handle from `@bitbybit-dev/occt` must never be passed directly
to this module because WebAssembly linear memories are independent.
@@ -21,11 +21,67 @@ Emscripten toolchain.
The exported `booleanHistory(object, tool, operation)` function supports
`fuse`, `cut`, and `common`. It returns the native result shape, per-input
`modified`/`generated`/`deleted` records, and capability flags. The provider also
exports `shapeToStep(shape)` and `booleanHistoryFromStep(objectStep, toolStep,
operation)`. The latter is the cross-WASM transport: Bitbybit exports STEP text,
the native provider reads it with `STEPControl_Reader`, returns the Boolean
result again as `resultStep`, and no linear-memory pointer crosses the boundary.
`modified`/`generated`/`deleted` records, a validity/topology/quality summary,
and capability flags. `prismHistory(profile, dx, dy, dz)` adds the first
PartDesign Pad contract: profile vertices can generate edges and profile edges
can generate faces, while the profile face is preserved as a result face.
`booleanProbe` and `booleanResult` are intentionally smaller operations for
Worker stress/ownership gates; callers must delete every returned Embind shape
handle. Primitive constructors cover positioned boxes, cylinders, spheres,
cones and a test rectangle Face. The provider also exports `shapeToStep(shape)`,
`booleanHistoryFromStep(objectStep, toolStep, operation)` and
`prismHistoryFromStep(profileStep, dx, dy, dz)`. The STEP functions are the
cross-WASM transport: Bitbybit exports STEP text, the native provider reads it
with `STEPControl_Reader`, returns the result again as `resultStep`, and no
linear-memory pointer crosses the boundary. Pad history currently requires a
valid planar Face profile; solids are rejected by OCCT rather than silently
treated as sketches. `loftHistoryFromStep(firstSectionStep, secondSectionStep,
ruled)` adds a verified two-section solid loft using `BRepOffsetAPI_ThruSections`.
It records generated edges/faces for both source profiles; more than two
sections, closed lofts, and PartDesign base fusion/cut stay on the signature
history path until their multi-source contract is implemented.
`pipeHistoryFromStep(profileStep, spineStep)` adds a verified single-edge
open-spine solid sweep using `BRepOffsetAPI_MakePipe`. It records profile
vertex/edge generation, profile end caps, and spine edge/endpoint provenance;
multi-edge spines, Frenet/transition modes, and hollow profiles remain explicit
unsupported cases.
`filletHistoryFromStep(baseStep, radius)` enables the first
native dress-up slice: OCCT `BRepFilletAPI_MakeFillet` applies a fixed radius to
all source edges and returns per-edge/face `Modified`, `Generated`, and `Deleted`
relations with result-kind mapping. The native build enables `TKFillet` and its
`TKBool` dependency.
`chamferHistoryFromStep(baseStep, distance)` uses the same native history
collector with `BRepFilletAPI_MakeChamfer` for a symmetric, fixed-distance,
all-edge chamfer.
`holeHistoryFromStep(baseStep, radius, depth, px, py, pz, dx, dy, dz)` composes
an OCCT cylinder and `BRepAlgoAPI_Cut` for a straight cylindrical hole with
explicit position and direction, returning the Boolean source history. Thread,
counterbore, countersink, and face-attached hole semantics remain outside this
minimal native contract.
`draftHistoryFromStep(baseStep, faceIndex, angle, direction, neutralPlane,
reversed)` uses `BRepOffsetAPI_DraftAngle` from `TKOffset` for one explicitly
selected source face. It returns native per-subshape history and rejects an
invalid face index, zero direction, zero angle, or invalid neutral plane before
publishing a result. Multi-face propagation and STEP files whose imported face
orientation OCCT cannot draft remain explicit unsupported/error cases.
`thicknessHistoryFromStep(baseStep, faceIndex, offset, intersectionJoin)` uses
`BRepOffsetAPI_MakeThickSolid` for one explicitly removed source face and
returns native offset history. Zero offsets and invalid face indexes are
rejected before execution.
`linearPatternHistoryFromStep(baseStep, dx, dy, dz)` creates one transformed
copy and fuses it with the source, preserving both Boolean input histories.
The contract intentionally covers exactly two whole-shape instances.
`polarPatternHistoryFromStep(baseStep, axis, angle)` creates one rotated copy
and fuses it with the source. It likewise covers exactly two whole-shape
instances around an explicit finite axis.
`mirroredHistoryFromStep(baseStep, planeOrigin, planeNormal)` creates one plane
reflected copy and fuses it with the source. The native contract is limited to
one whole-shape base, one standard document plane, and a fused result.
The `multiTransformHistoryFromStep(baseStep, steps)` binding accepts two to six
ordered two-instance linear, polar, or mirrored whole-shape steps. Each created
instance retains its transformed Base subshape mapping; one multi-argument Fuse
then aggregates every instance history back to the original Base indexes. The
single-step protocol continues to reuse the individual verified bindings.
The TypeScript boundary mapper is `mapNativeOcctHistoryRecords`; the reusable
`createNativeOcctStepHistoryBridge` exports Bitbybit ShapeHandles to STEP and

View File

@@ -26,12 +26,12 @@ emcmake cmake -S "${OCCT_SOURCE_DIR}" -B "${OCCT_BUILD_DIR}" -G Ninja \
-DBUILD_LIBRARY_TYPE=Static \
-DBUILD_MODULE_FoundationClasses=ON \
-DBUILD_MODULE_ModelingData=ON \
-DBUILD_MODULE_ModelingAlgorithms=OFF \
-DBUILD_MODULE_ModelingAlgorithms=ON \
-DBUILD_MODULE_Visualization=OFF \
-DBUILD_MODULE_ApplicationFramework=OFF \
-DBUILD_MODULE_DataExchange=ON \
-DBUILD_MODULE_Draw=OFF \
-DBUILD_ADDITIONAL_TOOLKITS="TKBO;TKDESTEP" \
-DBUILD_ADDITIONAL_TOOLKITS="TKBO;TKFillet;TKOffset;TKDESTEP" \
-DBUILD_DOC_Overview=OFF \
-DBUILD_Inspector=OFF \
-DBUILD_SAMPLES_QT=OFF \
@@ -42,7 +42,7 @@ emcmake cmake -S "${OCCT_SOURCE_DIR}" -B "${OCCT_BUILD_DIR}" -G Ninja \
-DUSE_RAPIDJSON=OFF
cmake --build "${OCCT_BUILD_DIR}" --target \
TKernel TKMath TKG2d TKG3d TKGeomBase TKGeomAlgo TKShHealing TKPrim TKTopAlgo TKBRep TKBO \
TKernel TKMath TKG2d TKG3d TKGeomBase TKGeomAlgo TKShHealing TKPrim TKTopAlgo TKBRep TKBool TKBO TKFillet TKOffset \
TKDE TKXSBase TKDESTEP \
--parallel "${JOBS}"
mkdir -p "${DIST_DIR}"
@@ -53,6 +53,9 @@ em++ "${ROOT_DIR}/native/occt-history/occt_history.cpp" \
-std=c++17 -O2 -fexceptions --bind \
-Wl,--start-group \
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKBO.a" \
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKFillet.a" \
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKOffset.a" \
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKBool.a" \
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKDESTEP.a" \
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKXSBase.a" \
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKDE.a" \

File diff suppressed because it is too large Load Diff

View File

@@ -19,4 +19,121 @@ assert.equal(typeof response.hasDeleted, 'boolean')
assert.equal(serializedResponse.provider, 'occt-native')
assert.equal(serializedResponse.records.length > 0, true)
assert.match(serializedResponse.resultStep, /^ISO-10303-21;/)
console.log(JSON.stringify({ occtVersion: response.occtVersion, recordCount: response.records.length, hasModified: response.hasModified, hasGenerated: response.hasGenerated, hasDeleted: response.hasDeleted }, null, 2))
const pocketBase = occt.makeBox(10, 10, 10)
const pocketProfile = occt.makeRectangleFace(2, 3)
const pocket = occt.pocketHistory(pocketBase, pocketProfile, 0, 0, 5)
const pocketFromStep = occt.pocketHistoryFromStep(occt.shapeToStep(pocketBase), occt.shapeToStep(pocketProfile), 0, 0, 5)
assert.equal(pocket.provider, 'occt-native')
assert.equal(pocket.summary.isValid, true)
assert.equal(pocket.summary.solids, 1)
assert.ok(Math.abs(pocket.summary.volume - 970) <= 1e-7)
assert.equal(pocket.records.length > 0, true)
assert.equal(pocketFromStep.summary.isValid, true)
assert.ok(Math.abs(pocketFromStep.summary.volume - 970) <= 1e-7)
const revolution = occt.revolutionHistory(pocketProfile, -1, 0, 0, 0, 1, 0, 360)
const revolutionFromStep = occt.revolutionHistoryFromStep(occt.shapeToStep(pocketProfile), -1, 0, 0, 0, 1, 0, 360)
assert.equal(revolution.provider, 'occt-native')
assert.equal(revolution.summary.isValid, true)
assert.equal(revolution.summary.solids, 1)
assert.equal(revolution.records.length > 0, true)
assert.equal(revolutionFromStep.summary.isValid, true)
assert.equal(revolutionFromStep.summary.solids, 1)
const grooveBase = occt.makeBoxPlaced(10, 10, 8, -5, 0, -4)
const grooveProfile = occt.makeRectangleFacePlaced(1, 3, 0.5, 2, 0)
const groove = occt.grooveHistory(grooveBase, grooveProfile, 0, 0, 0, 0, 1, 0, 360)
const grooveFromStep = occt.grooveHistoryFromStep(occt.shapeToStep(grooveBase), occt.shapeToStep(grooveProfile), 0, 0, 0, 0, 1, 0, 360)
assert.equal(groove.provider, 'occt-native')
assert.equal(groove.summary.isValid, true)
assert.equal(groove.summary.solids, 1)
assert.ok(Math.abs(groove.summary.volume - (800 - 6 * Math.PI)) <= 1e-7)
assert.equal(groove.records.length > 0, true)
assert.equal(grooveFromStep.summary.isValid, true)
assert.ok(Math.abs(grooveFromStep.summary.volume - groove.summary.volume) <= 1e-7)
const loftFirst = occt.makeRectangleFacePlaced(2, 2, 0, 0, 0)
const loftSecond = occt.makeRectangleFacePlaced(2, 2, 0, 0, 5)
const loftFromStep = occt.loftHistoryFromStep(occt.shapeToStep(loftFirst), occt.shapeToStep(loftSecond), false)
assert.equal(loftFromStep.provider, 'occt-native')
assert.equal(loftFromStep.summary.isValid, true)
assert.equal(loftFromStep.summary.solids, 1)
assert.ok(Math.abs(loftFromStep.summary.volume - 20) <= 1e-7)
assert.equal(loftFromStep.records.length > 0, true)
assert.equal(loftFromStep.records.some((record) => record.source === 'object'), true)
assert.equal(loftFromStep.records.some((record) => record.source === 'tool'), true)
const pipeProfile = occt.makeRectangleFace(2, 2)
const pipeSpine = occt.makeLineWire(0, 0, 0, 0, 0, 5)
const pipeFromStep = occt.pipeHistoryFromStep(occt.shapeToStep(pipeProfile), occt.shapeToStep(pipeSpine))
assert.equal(pipeFromStep.provider, 'occt-native')
assert.equal(pipeFromStep.summary.isValid, true)
assert.equal(pipeFromStep.summary.solids, 1)
assert.ok(Math.abs(pipeFromStep.summary.volume - 20) <= 1e-7)
assert.equal(pipeFromStep.records.length > 0, true)
assert.equal(pipeFromStep.records.some((record) => record.source === 'object'), true)
assert.equal(pipeFromStep.records.some((record) => record.source === 'tool'), true)
const filletBase = occt.makeBox(6, 6, 6)
const filletFromStep = occt.filletHistoryFromStep(occt.shapeToStep(filletBase), 0.4)
assert.equal(filletFromStep.provider, 'occt-native')
assert.equal(filletFromStep.summary.isValid, true)
assert.equal(filletFromStep.summary.solids, 1)
assert.equal(filletFromStep.records.length > 0, true)
assert.equal(filletFromStep.hasModified, true)
assert.equal(filletFromStep.hasGenerated, true)
assert.equal(filletFromStep.hasDeleted, true)
const chamferFromStep = occt.chamferHistoryFromStep(occt.shapeToStep(filletBase), 0.4)
assert.equal(chamferFromStep.provider, 'occt-native')
assert.equal(chamferFromStep.summary.isValid, true)
assert.equal(chamferFromStep.summary.solids, 1)
assert.equal(chamferFromStep.records.length > 0, true)
assert.equal(chamferFromStep.hasModified, true)
assert.equal(chamferFromStep.hasGenerated, true)
assert.equal(chamferFromStep.hasDeleted, true)
const draftBase = occt.makeBox(10, 10, 10)
const draftFromStep = occt.draftHistoryFromStep(occt.shapeToStep(draftBase), 0, 5, 0, 0, 1, 0, 0, 0, 0, 0, 1, false)
assert.equal(draftFromStep.provider, 'occt-native')
assert.equal(draftFromStep.summary.isValid, true)
assert.equal(draftFromStep.summary.solids, 1)
assert.equal(draftFromStep.records.length > 0, true)
assert.equal(draftFromStep.hasModified, true)
const thicknessBase = occt.makeBox(6, 6, 6)
const thicknessFromStep = occt.thicknessHistoryFromStep(occt.shapeToStep(thicknessBase), 1, -0.4, false)
assert.equal(thicknessFromStep.provider, 'occt-native')
assert.equal(thicknessFromStep.summary.isValid, true)
assert.equal(thicknessFromStep.summary.solids, 1)
assert.equal(thicknessFromStep.records.length > 0, true)
assert.equal(thicknessFromStep.hasModified, true)
const linearPatternFromStep = occt.linearPatternHistoryFromStep(occt.shapeToStep(occt.makeBox(2, 2, 2)), 2, 0, 0)
assert.equal(linearPatternFromStep.provider, 'occt-native')
assert.equal(linearPatternFromStep.summary.isValid, true)
assert.equal(linearPatternFromStep.summary.solids, 1)
assert.ok(Math.abs(linearPatternFromStep.summary.volume - 16) <= 1e-7)
assert.equal(linearPatternFromStep.records.length > 0, true)
const polarPatternBase = occt.makeBoxPlaced(2, 1, 1, -1, -0.5, -0.5)
const polarPatternFromStep = occt.polarPatternHistoryFromStep(occt.shapeToStep(polarPatternBase), 0, 0, 0, 0, 0, 1, 90)
assert.equal(polarPatternFromStep.provider, 'occt-native')
assert.equal(polarPatternFromStep.summary.isValid, true)
assert.equal(polarPatternFromStep.summary.solids, 1)
assert.ok(Math.abs(polarPatternFromStep.summary.volume - 3) <= 1e-7)
assert.equal(polarPatternFromStep.records.length > 0, true)
const mirroredBase = occt.makeBoxPlaced(2, 1, 1, -0.5, -0.5, -0.5)
const mirroredFromStep = occt.mirroredHistoryFromStep(occt.shapeToStep(mirroredBase), 0, 0, 0, 1, 0, 0)
assert.equal(mirroredFromStep.provider, 'occt-native')
assert.equal(mirroredFromStep.summary.isValid, true)
assert.equal(mirroredFromStep.summary.solids, 1)
assert.ok(Math.abs(mirroredFromStep.summary.volume - 3) <= 1e-7)
assert.equal(mirroredFromStep.records.length > 0, true)
const orderedMultiTransform = occt.multiTransformHistoryFromStep(occt.shapeToStep(mirroredBase), [
{ type: 'linear', direction: [1, 0, 0] },
{ type: 'mirrored', axisOrigin: [0, 0, 0], direction: [1, 0, 0] },
])
assert.equal(orderedMultiTransform.provider, 'occt-native')
assert.equal(orderedMultiTransform.summary.isValid, true)
assert.equal(orderedMultiTransform.summary.solids, 1)
assert.ok(Math.abs(orderedMultiTransform.summary.volume - 5) <= 1e-7)
assert.equal(orderedMultiTransform.records.length > 0, true)
const holeFromStep = occt.holeHistoryFromStep(occt.shapeToStep(occt.makeBox(10, 10, 10)), 1, 10, 0, 0, -5, 0, 0, 1)
assert.equal(holeFromStep.provider, 'occt-native')
assert.equal(holeFromStep.summary.isValid, true)
assert.equal(holeFromStep.summary.solids, 1)
assert.equal(holeFromStep.records.length > 0, true)
assert.equal(holeFromStep.hasModified, true)
assert.equal(holeFromStep.hasDeleted, true)
console.log(JSON.stringify({ occtVersion: response.occtVersion, recordCount: response.records.length, hasModified: response.hasModified, hasGenerated: response.hasGenerated, hasDeleted: response.hasDeleted, pocket: { recordCount: pocket.records.length, volume: pocket.summary.volume, fromStepVolume: pocketFromStep.summary.volume, hasModified: pocket.hasModified, hasGenerated: pocket.hasGenerated, hasDeleted: pocket.hasDeleted }, revolution: { recordCount: revolution.records.length, volume: revolution.summary.volume, fromStepVolume: revolutionFromStep.summary.volume, hasModified: revolution.hasModified, hasGenerated: revolution.hasGenerated, hasDeleted: revolution.hasDeleted }, groove: { recordCount: groove.records.length, volume: groove.summary.volume, fromStepVolume: grooveFromStep.summary.volume, hasModified: groove.hasModified, hasGenerated: groove.hasGenerated, hasDeleted: groove.hasDeleted }, loft: { recordCount: loftFromStep.records.length, volume: loftFromStep.summary.volume, hasGenerated: loftFromStep.hasGenerated }, pipe: { recordCount: pipeFromStep.records.length, volume: pipeFromStep.summary.volume, hasModified: pipeFromStep.hasModified, hasGenerated: pipeFromStep.hasGenerated }, fillet: { recordCount: filletFromStep.records.length, volume: filletFromStep.summary.volume, hasModified: filletFromStep.hasModified, hasGenerated: filletFromStep.hasGenerated, hasDeleted: filletFromStep.hasDeleted }, chamfer: { recordCount: chamferFromStep.records.length, volume: chamferFromStep.summary.volume, hasModified: chamferFromStep.hasModified, hasGenerated: chamferFromStep.hasGenerated, hasDeleted: chamferFromStep.hasDeleted }, draft: { recordCount: draftFromStep.records.length, volume: draftFromStep.summary.volume, hasModified: draftFromStep.hasModified, hasGenerated: draftFromStep.hasGenerated, hasDeleted: draftFromStep.hasDeleted }, thickness: { recordCount: thicknessFromStep.records.length, volume: thicknessFromStep.summary.volume, hasModified: thicknessFromStep.hasModified, hasGenerated: thicknessFromStep.hasGenerated, hasDeleted: thicknessFromStep.hasDeleted }, mirrored: { recordCount: mirroredFromStep.records.length, volume: mirroredFromStep.summary.volume, hasModified: mirroredFromStep.hasModified, hasGenerated: mirroredFromStep.hasGenerated, hasDeleted: mirroredFromStep.hasDeleted }, orderedMultiTransform: { recordCount: orderedMultiTransform.records.length, volume: orderedMultiTransform.summary.volume, hasModified: orderedMultiTransform.hasModified, hasGenerated: orderedMultiTransform.hasGenerated, hasDeleted: orderedMultiTransform.hasDeleted }, hole: { recordCount: holeFromStep.records.length, volume: holeFromStep.summary.volume, hasModified: holeFromStep.hasModified, hasGenerated: holeFromStep.hasGenerated, hasDeleted: holeFromStep.hasDeleted } }, null, 2))