|
OCCT-Light 0.1
C ABI and C++ veneer for multi-language CAD workflows
|
How OCCT-Light reaches Python, C#, JS/TS (Node and web), and every future language. The C ABI is the contract; this document is the uniform recipe every per-language facade follows so the cost of adding the seventh language is roughly the cost of the second.
Companion docs: ARCHITECTURE.md · ABI_PATTERNS.md · BREPGRAPH_AS_CANONICAL.md · MODULES.md · CODING_STYLE.md.
ABI_PATTERNS.md §17 gives a one-line table; this document is the long-form. When in doubt about a single ABI shape, that doc wins; when a language-level question contradicts a binding shortcut, this doc wins.
build/abi.json; the ergonomic layer is hand-written but covered by parity and focused smoke tests so it cannot silently drift.ndarray, .NET Span<T>, JS TypedArray) without an intermediate copy.Result). Inverse on input — no native error type may leak into a callback that crosses back.occtl-hpp) to bindings.** The veneer is for C++ consumers. Bindings call the C ABI directly. The veneer's shape is a useful reference for what an idiomatic layer feels like, but it is never an FFI target.Graph.repair() that has no analogue in C#. New behavior lands in the C ABI first, then propagates to every facade.The binding strategy is intentionally simple:
build/abi.json;Each language facade is three layers, named consistently:
The raw and generated typed layers are private. Users import occtl, never occtl._raw or generated subpackages directly. Document generated layers as implementation details; do not stabilise them. This means a binding can switch its raw layer (e.g. cffi ABI mode → cffi API mode → cython → manylinux prebuilt) without a major-version bump as long as the idiomatic surface is unchanged.
The idiomatic layer is the binding's stable surface. It carries its own SemVer, independent from the wrapper SemVer. A new wrapper minor version may add a new C function; until the idiomatic layer surfaces it, the binding can ship its existing minor version unchanged.
Node and WASM intentionally converge on the same public TypeScript shapes, but the shared type package is not split out yet. Keep new TS APIs portable between the two backends unless the runtime model makes that impossible.
Every facade implements these ten responsibilities the same way. When implementing a new language facade, work down this list.
On first use, the facade calls:
This must run before any other call. The expected ABI version is baked into the binding at build time (read from occtl_core.h during the raw-layer generation). On mismatch the binding raises a binding-specific AbiMismatchError carrying both versions; the binding does not attempt to proceed with a stale library — this is the failure mode that produces the worst, latest-bound crashes if ignored.
occtl_runtime_init(NULL) is then called once per process at first use. The binding stores a bool _initialised to keep it idempotent. occtl_runtime_shutdown() is not wired to module teardown by default — process exit handles it, and atexit hooks in dynamic loaders are a known source of crashes.
Each non-OK status produced by a C call is converted at the idiomatic-layer boundary, never deeper. The pattern in every language:
The error object carries status, message, source UID, extended subcode — the same four fields every language sees. The primary status is the language's enum; the extended code is an integer. Bindings never invent additional error categories — if the C ABI returns OCCTL_NOT_DONE, the Python exception is NotDoneError, not RuntimeError.
Iterator end-of-iteration is not an error. OCCTL_NOT_FOUND returned from occtl_node_iter_next is translated to native StopIteration (Python), false from MoveNext (C# IEnumerator), or end-of-iterator (JS), and the raw error slot is not read. See §4.6.
Every occtl_*_t* becomes a handle wrapper that owns the pointer and calls the matching _free on disposal:
| Language | Mechanism |
|---|---|
| Python | __del__ + explicit close(); contextlib.AbstractContextManager. Forbid resurrection. Optional weakref.finalize for the safety net. |
| C# | IDisposable + SafeHandle subclass per type. Finalizer as safety net only — users are expected to using. |
| Node | Napi::ObjectWrap<T> with finaliser registered for GC; manual .close() available. |
| WASM | Embind class with delete() required (Emscripten convention). Auto-finalisation via FinalizationRegistry where it survives the userland API. |
| Rust | impl Drop, paired with unsafe constructor in occtl-sys and safe wrapper in occtl. |
| Go | runtime.SetFinalizer + explicit Close(). |
Handles are move-only in languages with the concept; in others, copies are forbidden and clone goes through an explicit clone() (which calls a C-side clone when one exists, otherwise errors).
occtl_*_free is NULL-tolerant per ABI_PATTERNS.md §4; the binding may rely on this. Calling free twice is not defined to be safe at the binding layer — the wrapper sets its pointer to NULL after free and ignores subsequent disposes.
Graph lifecycle naming parity. Idiomatic facades should expose both the language-native constructor form and explicit factory/adoption aliases so cross-language examples stay mechanically translatable:
Graph() / Graph.create() (or Graph::new() + Graph::create() in Rust, NewGraph() + CreateGraph() in Go).from_pointer_unsafe / fromPointerUnsafe / FromPointerUnsafe), with Rust remaining unsafe at this boundary.close/Close in addition to finalizer/drop behavior.occtl_node_id_t, occtl_uid_t, occtl_ref_id_t, occtl_rep_id_t are 64-bit POD bundles in C. In bindings they become distinct nominal types so the type system catches "passed a face id where a vertex id was expected":
| Language | Mechanism |
|---|---|
| Python | class NodeId(NamedTuple): bits: int with __class_getitem__-typed factories; static type checkers (mypy / pyright) catch mixing |
| C# | readonly record struct NodeId(ulong Bits) |
| TS | ‘type NodeId = { __brand: 'NodeId’; bits: bigint }(branded type) \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Rust \ilinebr </td> <td class="markdownTableBodyNone">#[derive(Copy)] struct NodeId(u64);` newtype |
Bindings do not unpack the bit layout. Kind queries go through the C function (occtl_graph_node_kind), exactly as the C++ veneer does.
occtl_*_info_t structs are constructed by the binding's builder and the binding always sets struct_version to the value of OCCTL_*_VERSION_N the binding was compiled against. Users never set it. p_next is exposed as a typed-chain in languages that can express it (TS discriminated union, Rust enum) and as None/null in others — but for v1 it is always None, since no extension chains ship yet.
The raw layer calls occtl_*_info_init(&info) to set version, then overrides fields. This is the ABI_PATTERNS.md §8 contract: always go through the initializer, never write struct_version from the binding-layer directly.
The §10.3 opaque iterator becomes the language-native shape:
Rules:
generator.close() / IDisposable.Dispose) frees the iterator.OCCTL_NOT_FOUND is the end-of-iteration sentinel; do not read occtl_error_last on this path (ABI_PATTERNS.md §10.3).§10.2 spans (occtl_triangulation_view_t, B-spline pole arrays, etc.) map to the binding's native zero-copy array:
| Language | Type | Mechanism |
|---|---|---|
| Python | numpy.ndarray | np.frombuffer(ffi.buffer(ptr, count*sizeof(T)), dtype=T) + .reshape(count, 3) for xyz |
| C# | Span<T> (or ReadOnlySpan<T>) | new ReadOnlySpan<double>(ptr.ToPointer(), count) (in unsafe block, hidden) |
| TS / Node | Float64Array, Uint32Array | new Float64Array(napi_external_buffer) over the native pointer |
| TS / WASM | Float64Array view over HEAPF64 | new Float64Array(Module.HEAPF64.buffer, ptr, count) — must be re-acquired after any growth of the WASM heap |
| Rust | &[T] | unsafe { std::slice::from_raw_parts(ptr, count) } |
Lifetime is the binding's responsibility to enforce. §10.2 says "valid until the graph is mutated or freed"; the idiomatic layer either:
(a) Materialises a copy on access when the span is small or the lifetime is awkward. Default for ergonomic accessors.
(b) Hands back a view tied to the parent: the parent handle is held by the view, so the view object's lifetime extends the parent. Default for hot paths (mesh data, big pole arrays). Document view.copy() as the escape hatch when the user wants to outlive the parent.
WASM is the special case: any growMemory call invalidates every TypedArray view into HEAPF64. The WASM facade re-acquires views on every accessor and forbids users from caching them across calls that may allocate. This is enforced by a view.invalidate() hook the Module wires up.
The C ABI is UTF-8 everywhere (ABI_PATTERNS.md §9). Bindings:
; C#Encoding.UTF8.GetBytes(or[MarshalAs(UnmanagedType.LPUTF8Str)]on .NET 5+); JSTextEncoder; Rust&CStrconstructed from aString`.Length-counted strings (name, name_len) take a bytes-like input. Both NUL-terminated and length-counted forms exist in the C ABI; the binding picks the safer length-counted variant when both exist.
Bindings hold a strong reference to the wrapper containing user_data for the registration's lifetime. Forgetting to do this is the #1 binding bug.
Rules:
noexcept (ABI_PATTERNS.md §12). The binding wraps the user's callback to catch native exceptions and translate them into OCCTL_CANCELLED or OCCTL_ERROR plus an opaque extended code identifying the binding origin.user_data is always the last parameter. Bindings do not surface user_data to userland — they bind it via closure.OCCTL_CANCELLED returned from the callback short-circuits the host operation per ABI_PATTERNS.md §6.1.Same model as the C ABI (ARCHITECTURE.md §8):
lock), document the user-visible recommendation, do not bake it into the binding silently.await).Regenerating the raw layer requires libclang.
tools/abi_dump.pywalks the enabledinclude/occtl/*.hheaders viaclang.cindexand emitsbuild/abi.json, which every facade generator consumes. Install once withpip install libclang(the PyPI package bundles the native dylib; no systemlibclang-devis required). The CMake targetocctl-abi-dumpruns the dumper for you; per-bindingocctl-<lang>-regenerate/occtl-<lang>-generatetargets depend on it. The configured build also emitsOCCTLFeatures.json; generators use it withabi.jsonto pick the feature-set library, enabled components, and header set.
abi.json carries the Doxygen summaries, parameter docs, return/status docs, and threadsafety tags extracted from the C headers. Binding generators must preserve that documentation where the target language has a native documentation surface: Python .pyi/docstrings, C# XML docs, TypeScript JSDoc, Rust doc comments, and Go exported comments. Hand-written ergonomic layers may add idiomatic examples, but they must not contradict the C header text.
Why cffi ABI mode.
pip install occtl only fetches the platform-appropriate wheel for libocctl-*.so/.dll plus the pure-Python facade. No setup.py build_ext against the user's headers.cdef provide strong signature validation.Generated layers.
occtl/_raw.py, occtl/_generated/*.py, and occtl/_typed/*.py are generated from build/abi.json, which is produced from include/occtl/*.h by the libclang-backed tools/abi_dump.py. _raw.py owns the cffi declarations and ffi.dlopen(...); _generated is the 1:1 function layer; _typed promotes mechanically inferable options and helper functions into documented dataclasses and typed wrappers.
The cdef does not preprocess #define macros; constants are mirrored in a generated occtl/_abi.py from the same ABI catalog.
Idiomatic layer.
occtl.Graph — __enter__ / __exit__, close(), faces(), edges(), …occtl.geom — POD types as @dataclass(frozen=True, slots=True). Point3(x, y, z) is a plain Python dataclass with __array__ returning a NumPy view (zero-copy when the dataclass-of-doubles ends up packed; copy otherwise).occtl.geom.BSpline.poles — returns a NumPy ndarray view over §10.2 span memory, lifetime-tied to the parent curve handle by a weakref.occtl.viz — optional full-with-viz facade for offscreen/native visualization; the AI/notebook path is Driver → Viewer → View → Presentable, then read_pixels_rgba() or dump_image().occtl.Error, occtl.NotDoneError, occtl.GeometryInvalidError, etc. — class NotDoneError(Error) per status code, parented at Error.Type stubs.
occtl/*.pyi are generated from the C headers (with hand-written overlays for the ergonomic surface). Type-checkers (mypy / pyright) see the binding as fully typed.
NumPy interop.
The idiomatic layer's policy is zero-copy for the spans, copy for small POD reads. triangulation.nodes is a (N, 3) float64 ndarray view; vertex.point() returns a fresh Point3 (copy, three doubles).
Async.
asyncio support is opt-in via await occtl.aio.read_step(path) — the binding offloads to a thread pool and awaits the result. No native async at the C ABI; doing real async would mean coroutine-driven progress callbacks, which is a v2 conversation.
Packaging.
manylinux_2_28_x86_64, manylinux_2_28_aarch64, macosx_11_0_arm64, macosx_11_0_x86_64, win_amd64, win_arm64. Source dist excluded (no C compile in install path).auditwheel bundles libocctl-*.so into the wheel; OCCT toolkits are also bundled (vendored), since users are unlikely to have an OCCT install on their PATH.Why P/Invoke + source generator.
[LibraryImport] declarations from the C headers at compile time. This catches signature drift at build, not at first call.[LibraryImport] and Span<T> / Memory<T> throughout the surface.Raw layer.
OcctL.Native — generated by a Roslyn source generator that reads include/occtl/*.h and emits one partial class per header. The generator runs as part of the build, not committed; the developer ergonomics match the binding shipping as a regular NuGet package.
Idiomatic layer.
OcctL.Graph : SafeHandle, IDisposable — using lifetime.OcctL.NodeId : IEquatable<NodeId> — readonly record struct NodeId(ulong Bits).OcctL.Geom.Point3 : IEquatable<Point3> — readonly record struct Point3(double X, double Y, double Z), laid out compatibly with occtl_point3_t so MemoryMarshal.Cast lets us reinterpret a Span<double> as Span<Point3> without copy.OcctL.OcctLException : Exception — carries Status, Uid, ExtendedCode. Subclasses per status code.**Span<T> interop.**
Span views over §10.2 spans use MemoryMarshal.CreateReadOnlySpan over the native pointer. The wrapping struct (MeshView) holds a GCHandle on the parent graph wrapper to extend the parent's lifetime past the view's last use; Dispose() releases.
NuGet packaging.
OcctL.Core package — pure managed assembly with the idiomatic layer + the generated raw layer's source.OcctL.Native.{rid} packages — runtime-specific native libraries (runtimes/{rid}/native/libocctl-*.{so,dll,dylib}). The .NET build system picks the right one per RID at publish time.OcctL references OcctL.Core + all OcctL.Native.{rid}.[LibraryImport] is AOT-compatible; the binding compiles with IsAotCompatible=true.Why N-API (not nan, not direct V8).
node-addon-api is the C++ helper; the addon links the OCCT-Light C ABI directly.Raw layer.
bindings/node/src/native/raw.cc — C++ glue exposing C functions as Napi::Function. Auto-generated by a TS script that reads include/occtl/*.h and emits the addon source.
Idiomatic layer (TypeScript).
class Graph implements Disposable — [Symbol.dispose]() per the explicit-resource-management proposal; .close() fallback.— branded type; constructed by the binding only. -Point3 = { x: number; y: number; z: number }. -vizwrappers expose generated raw calls for driver/viewer/view/presentable handles; higher-level frontends can layer native-window ownership on top. -class OcctLError extends Error— carriesstatus,uid,extended.Runtime feature probing exportsAVAILABLE_MODULES(derived fromOCCTLFeatures.jsonwhen present, otherwise inferred from the native export table). Hand-written facades throwUnsupported` deterministically when a disabled symbol is requested.**TypedArray interop.**
§10.2 spans use napi_create_external_buffer to wrap the native pointer in a Buffer, then create a Float64Array view over it. The buffer's finalize callback releases the GC-handle on the parent graph wrapper, keeping the parent alive while the view is reachable.
Packaging.
npm install occtl fetches prebuilt binaries via prebuildify for the four common Node ABI × platform combinations (linux-x64, linux-arm64, macos-arm64, macos-x64, win-x64). Falls back to source build with node-gyp if a prebuild is missing.occtl.d.ts) — generated from the C headers + hand-written overlays.Why Emscripten + Embind, not WebAssembly Component Model (yet).
Build.
The wasm build links the selected single OCCT-Light feature-set target statically (Emscripten compiles OCCT itself + OCCT-Light into one .wasm). Output: occtl.wasm + occtl.js (the JS glue that loads the wasm and exposes the Embind types).
The wasm bundle is large (OCCT is ~30 MB compressed). Code-splitting per module (loading prim and mesh lazily after core/geom/topo) is a v2 conversation; v1 ships the unified bundle.
Raw layer.
bindings/wasm/src/native/raw.cc — Embind glue exposing each public C function as a function. Auto-generated.
Idiomatic layer.
Identical surface to the Node TS facade. The two facades export the same types from @occtl/types (a third package shared between them). User code can move between Node and web without touching the API surface.
Memory model — the WASM gotcha.
Module.HEAPF64 etc. and may be invalidated if any subsequent call causes the heap to grow..copy() to a JS array on the JS heap. The TS API surface marks "view" return types vs "owned array" return types in the type signature (Float64ArrayView vs Float64Array).Packaging.
npm install @occtl/wasm ships occtl.wasm + occtl.js + types.await Occtl.load().@occtl/node.dist/occtl.wasm is treated as a setup error, not an optional scenario.The model scales by the §8 checklist. Three explicit pre-staked plans:
occtl-sys crate — bindgen over include/occtl/occtl.h, raw unsafe extern "C" declarations. occtl crate — safe wrapper, Result<T, occtl::Error> everywhere, impl Drop on handle types, iterator types implementing Iterator. Optional serde derives for the POD types behind a feature flag.cgo raw layer + occtl package with idiomatic Go: error return values, close-on-owner handles, and range-friendly helpers. The current implementation reads installed feature metadata (OCCTLFeatures.json) to derive CGO_* flags and Go build tags, so reduced feature sets compile without unresolved symbols.build/abi.json + thin idiomatic facade (Graph, primitive builders, booleans, history helpers) + JUnit smoke/parity/coverage checks. Promotion requires CI matrix hardening and packaging into publishable Maven artifacts.OcctL Swift Package with class Graph, struct NodeId, error translation, Sequence conformance for iterators. Same SafeHandle pattern as C# (deinit { occtl_graph_free(ptr) }).Additional language facades can be added in v2+ using the same checklist.
| Language | Format | Vendoring policy | Versioning |
|---|---|---|---|
| Python | wheel per platform/arch | Wheel bundles libocctl-*.so/.dylib/.dll and OCCT toolkits | binding SemVer; pins min wrapper version >=X.Y |
| C# | NuGet (managed + RID-native packages) | Native libraries shipped per RID under runtimes/{rid}/native/ | binding SemVer; metapackage pins exact RID-native versions |
| Node | npm (+ prebuildify binaries) | Native .node files per ABI/platform | binding SemVer; engines.node sets the supported ABI |
| WASM | npm (with .wasm + JS glue) | Self-contained (the wasm has OCCT inlined) | binding SemVer; ships exactly one wasm version per package version |
| Rust | crates.io (occtl-sys static-links libocctl-*.a) | build.rs reads OCCTL_DIR, links static | binding SemVer; pins compatible wrapper via links = |
| Go | go modules (links selected OCCT-Light shared lib) | env from bindings/go/tools/occtl_features_env.py or one-shot go_with_features.py | binding SemVer; module pins min wrapper |
| Java | Maven artifact(s) | JNA loads selected feature-set shared library from OCCTL_LIBRARY_PATH / OCCTL_LIBRARY_NAME | binding SemVer; artifact declares minimum ABI version |
| Swift | Swift Package + binary target | Binary target ships .xcframework with the lib | binding SemVer |
Static vs dynamic linking of the native library is a per-language ergonomics call, not an ABI question — the C ABI is the same either way.
Package preparation is script-first. User-facing entrypoints live under tools/scripts/, while implementation lives under tools/packaging/. Any later CI/Actions integration should wrap these scripts, not replace them:
tools/scripts/build_binding_packages.shtools/scripts/build_binding_packages.ps1tools/scripts/build_binding_packages.pySupported targets:
python-wheel and python-condacsharp-nugetnode-npm and wasm-npmjava-mavenrust-cratego-moduleThe packaging script emits binding-packages-manifest.json under the selected output directory with artifact paths and SHA-256 checksums for release handoff.
Build-system wrapper targets:
occtl-bindings-packages-dry-runocctl-bindings-packagesWorkspace unification helpers:
bindings/README.mdtools/scripts/bindings.pyoverview (logical zone map)audit (required path checks)clean --dry-run / clean (transient artefact cleanup)All bindings inherit AGPL-3.0-or-later from OCCT-Light. The Python occtl wheel, NuGet OcctL package, npm occtl and @occtl/wasm packages all declare AGPL. Downstream binaries must comply; this is documented in every binding's README first paragraph. OCCT itself is LGPL-2.1; the wrapper does not change OCCT's license; bundling OCCT in a wheel is a redistribution and is honoured as LGPL distribution.
Every binding ships three tiers.
Tier 1 — smoke. A single test per binding that:
occtl_runtime_abi_version() matches.occtl_prim_make_box (when prim lands; until then via topo builders), counts faces, frees the graph.OCCTL_INVALID_ARGUMENT, reads the translated native exception, confirms .message is non-empty.Tier 1 is the required contract for every binding. Current CI enforces Tier-1 smoke for Python, C#, Node, and Java; WASM, Rust, and Go bindings currently run focused coverage/parity checks in CI while their full Tier-1 runtime matrix is being finalized. Smoke/parity runners must be fail-fast: missing native artifacts or unresolved loader paths are setup errors, not bypassed tests.
Tier 2 — parity. A binding-agnostic test grid lives at tests/binding_parity/. Each row is a small scenario expressed in JSON (or as a generated C++ executable that prints expected output). Every binding's parity test runs the same scenario through its facade and compares output. This catches "Python returns 3.0 where C# returns 3" types of drift.
Tier 3 — symbol coverage. For each binding, a build-time check consumes build/abi.json, lists every OCCTL_API function, and confirms the generated raw/typed layers expose it. The ergonomic layer has focused presence tests for the commands users naturally call directly (Graph builders, booleans, I/O helpers, history, selectors, spans, viz screenshot helpers). Bindings may start with raw coverage and parity-smoke checks, but promotion to full Tier-1 requires generated coverage over the current ABI plus ergonomic presence tests for every promoted command family.
The C++ veneer's symbol-coverage check (ABI_PATTERNS.md §16) is the model — bindings adopt the same pattern.
When a contributor wants to add language <L>:
bindings/<L>/. Sub-structure mirrors §3 layout.build/abi.json or the public C headers. Whatever tool the language community settles on (cffi, bindgen, source generators, N-API glue, cgo, Swift ClangImporter). The raw layer is private; document it as such.abi.json. This layer is also private unless a language has no better packaging boundary.tests/binding_parity/.install look like for the user.ABI_PATTERNS.md §17, this doc's §5, and the project root README.md.A new binding lands in v0.x while the API surface is still evolving; promotion to v1 stable requires Tier 1–3 fully green over two release cycles.
occtl-hpp is for C++ users; bindings don't see it).OCCTL_NOT_DONE, OCCTL_GEOMETRY_INVALID, or any error code. Retry policy is userland's decision.await / Promise boundaries without an explicit lifetime extension on the parent handle. Doubly do not do this on WASM, where the pointer may dangle after a growMemory.user_data / void* to userland. Callbacks bind context via closure; the C-side user_data is the binding's bookkeeping.import occtl, never occtl._raw.abi.json, and promoted ergonomic command families need explicit presence tests.