ifc_lite_ffi/
lib.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! C FFI bindings for ifc-lite.
6//!
7//! Exports functions for use via P/Invoke from C#:
8//! - `ifc_lite_parse`: parse an IFC file and return JSON bytes
9//! - `ifc_lite_parse_ex`: parse with configurable opening filter
10//! - `ifc_lite_free`: free a buffer previously returned by parse functions
11//!
12//! Build: `cargo build --profile server-release -p ifc-lite-ffi`
13//! Output: `target/server-release/ifc_lite_ffi.dll`
14//!
15//! The `server-release` profile is mandatory, not a convenience: the workspace
16//! default `release` profile sets `panic = 'abort'`, which turns the
17//! `catch_unwind` guards below into no-ops. Built that way, a parser panic
18//! aborts the entire host CAD process instead of returning error code `3`.
19//! `server-release` inherits `release` but restores `panic = "unwind"`.
20
21// Keep this declaration before every other item-level attribute. The revert
22// oracle maps the sibling test module from here; placing it later in this long
23// file can make an unrelated `#[cfg(...)]` look like its owner.
24#[cfg(test)]
25mod tests;
26
27// Native global allocator (#1623): the platform system heap's global lock was
28// ~70% of native geometry self-time and capped rayon scaling to ~1.8x on
29// IfcMappedItem-heavy models; mimalloc's per-thread heaps lifted an all-cores
30// geometry pass on a 462MB metering-station model 35s -> 16.7s (2.1x). Rust-only
31// — the DLL's Rust allocations route through mimalloc; the host process's own
32// malloc/free are untouched.
33#[cfg(feature = "mimalloc")]
34#[global_allocator]
35static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
36
37use ifc_lite_processing::{process_geometry_filtered, OpeningFilterMode, ParseResponse};
38use panic_log::{ensure_panic_logging, InFlightPath};
39use std::slice;
40use std::sync::OnceLock;
41
42mod panic_log;
43
44/// Stack size for the geometry worker threads (256 MiB).
45///
46/// IFC geometry processing recurses deeply: BSP-tree CSG (via `csgrs`) and chains of nested
47/// boolean clipping (e.g. a wall with hundreds of openings) build call stacks far past the
48/// default ~1 MiB worker stack. Overflowing it hits the guard page and aborts the whole host
49/// process (Rhino) with `STACK_OVERFLOW` (0xC00000FD) — no panic, no unwind, nothing
50/// `catch_unwind` can intercept. A large stack gives that recursion room to complete.
51const PARSE_STACK_SIZE: usize = 256 * 1024 * 1024;
52
53/// Dedicated rayon pool whose worker threads have a large stack (see [`PARSE_STACK_SIZE`]).
54///
55/// The actual per-element geometry work runs inside `process_geometry` via
56/// `par_iter` on rayon workers, so the recursion lives on *their* stacks — not the caller's.
57/// Running the parse through `pool.install(..)` makes both the entry closure and every nested
58/// `par_iter` use these large-stack workers. Built once and reused.
59///
60/// `None` when the pool cannot be built (the OS refused to spawn its threads).
61/// That is error code `3` for the caller, never an `expect`: this runs on the
62/// `extern "C"` path, and a panic unwinding out of an `extern "C"` function
63/// aborts the host process. A failed build is not cached, so a later call
64/// retries; two first calls racing may each build a pool, and the loser is
65/// dropped.
66fn parse_pool() -> Option<&'static rayon::ThreadPool> {
67    static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
68    if let Some(pool) = POOL.get() {
69        return Some(pool);
70    }
71    let pool = build_parse_pool(PARSE_STACK_SIZE).ok()?;
72    Some(POOL.get_or_init(|| pool))
73}
74
75fn build_parse_pool(stack_size: usize) -> Result<rayon::ThreadPool, rayon::ThreadPoolBuildError> {
76    rayon::ThreadPoolBuilder::new()
77        .stack_size(stack_size)
78        .thread_name(|i| format!("ifc-lite-parse-{i}"))
79        .build()
80}
81
82/// Run `work` on the large-stack pool with `path_str` registered as an
83/// [`InFlightPath`] for the whole call, so a panic on any pool worker is logged
84/// against this file.
85///
86/// One `catch_unwind` around the registration, the pool acquisition and all
87/// of `work`: a panic anywhere in there is error code `3` instead of an unwind
88/// out of the `extern "C"` function, which aborts the host. `install` carries
89/// a worker's panic back to this thread, so the guard sits outside it.
90fn run_in_pool<T: Send>(
91    path_str: &str,
92    work: impl FnOnce() -> Result<T, i32> + Send,
93) -> Result<T, i32> {
94    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
95        let _in_flight = InFlightPath::register(path_str);
96        parse_pool().ok_or(3)?.install(work)
97    }))
98    .unwrap_or(Err(3))
99}
100
101/// Shared body of both parse entry points: read the file, then on the
102/// large-stack pool and under [`run_in_pool`]'s `catch_unwind` run geometry
103/// processing and serialize the response to JSON bytes. The mesh frame comes
104/// from the pipeline's one selector (#4643); nothing here re-anchors it.
105///
106/// Returns the JSON buffer on success, or one of the FFI error codes on failure
107/// (`2` read, `3` panic or no pool, `4` serialization) — `0`/`1` are decided by
108/// the wrappers, which own pointer validation.
109fn parse_impl(path_str: &str, mode: OpeningFilterMode) -> Result<Vec<u8>, i32> {
110    let content = std::fs::read_to_string(path_str).map_err(|_| 2)?;
111
112    run_in_pool(path_str, move || {
113        let result = process_geometry_filtered(&content, mode);
114        // The source text is not needed past geometry; free it before the
115        // JSON buffer is built next to the meshes.
116        drop(content);
117
118        let response = ParseResponse {
119            cache_key: String::new(),
120            meshes: result.meshes,
121            mesh_coordinate_space: Some(result.mesh_coordinate_space),
122            site_transform: result.site_transform,
123            building_transform: result.building_transform,
124            metadata: result.metadata,
125            stats: result.stats,
126            // This fork's `ParseResponse` carries 2D symbol data; the FFI parse path
127            // is geometry-only, so emit an empty (default) set. `ProcessingResult`
128            // has no `symbolic_data` to forward here.
129            symbolic_data: Default::default(),
130        };
131
132        serde_json::to_vec(&response).map_err(|_| 4)
133    })
134}
135
136/// Validate the path bytes and out-pointers, run [`parse_impl`], and write the
137/// resulting buffer through the out-parameters. Shared by both exported
138/// functions so the null checks and contract live in exactly one place.
139///
140/// # Safety
141/// The caller upholds the contract stated on [`ifc_lite_parse`].
142unsafe fn run_parse(
143    path_ptr: *const u8,
144    path_len: usize,
145    mode: OpeningFilterMode,
146    out_ptr: *mut *mut u8,
147    out_len: *mut usize,
148) -> i32 {
149    ensure_panic_logging();
150
151    // Every return leaves each non-null out-parameter defined, so a host that
152    // reuses the variables never sees a previous call's freed buffer.
153    if !out_ptr.is_null() {
154        *out_ptr = std::ptr::null_mut();
155    }
156    if !out_len.is_null() {
157        *out_len = 0;
158    }
159
160    // Defensive null checks: a C#/P-Invoke marshalling slip would otherwise be
161    // undefined behavior in `from_raw_parts` / the result writes below.
162    if path_ptr.is_null() || out_ptr.is_null() || out_len.is_null() {
163        return 1;
164    }
165
166    let path_bytes = slice::from_raw_parts(path_ptr, path_len);
167    let path_str = match std::str::from_utf8(path_bytes) {
168        Ok(s) => s,
169        Err(_) => return 1,
170    };
171
172    let json_bytes = match parse_impl(path_str, mode) {
173        Ok(b) => b,
174        Err(code) => return code,
175    };
176
177    let len = json_bytes.len();
178    let ptr = Box::into_raw(json_bytes.into_boxed_slice()) as *mut u8;
179
180    *out_ptr = ptr;
181    *out_len = len;
182
183    0
184}
185
186/// Parse an IFC file and return JSON bytes.
187///
188/// # Arguments
189/// - `path_ptr` / `path_len`: UTF-8 encoded file path
190/// - `out_ptr`: receives pointer to allocated JSON bytes
191/// - `out_len`: receives length of allocated JSON bytes
192///
193/// # Returns
194/// - `0` on success
195/// - `1` if a pointer is null or the path is invalid UTF-8
196/// - `2` if the file cannot be read
197/// - `3` if parsing panics or the worker pool cannot be started (a panic is
198///   only recoverable in an unwinding build such as `server-release`; see the
199///   module header)
200/// - `4` if JSON serialization fails
201///
202/// On `0`, `*out_ptr` / `*out_len` describe the buffer. On every other code,
203/// each of them that is non-null is set to null / 0, so a host may free on
204/// "non-null" without re-zeroing between calls (`ifc_lite_free(NULL, 0)` is a
205/// no-op).
206///
207/// # Safety
208/// - `path_ptr` must be valid for reads of `path_len` bytes, all inside one
209///   allocation, and `path_len` must not exceed `isize::MAX`. `path_len` is a
210///   count of UTF-8 bytes: passing a UTF-16 length (a .NET `string.Length`)
211///   for a non-ASCII path reads past the buffer. Only a null `path_ptr` is
212///   detected; a wrong length is undefined behaviour, not error `1`.
213/// - `out_ptr` and `out_len` must each be null or valid for writes and
214///   properly aligned. Their storage must not overlap each other or the
215///   allocation behind `path_ptr`: both are written before the path is read.
216/// - A buffer returned on `0` must be freed exactly once, with
217///   `ifc_lite_free` and the same `*out_len`.
218#[no_mangle]
219pub unsafe extern "C" fn ifc_lite_parse(
220    path_ptr: *const u8,
221    path_len: usize,
222    out_ptr: *mut *mut u8,
223    out_len: *mut usize,
224) -> i32 {
225    run_parse(path_ptr, path_len, OpeningFilterMode::Default, out_ptr, out_len)
226}
227
228/// Parse an IFC file with a configurable opening filter and return JSON bytes.
229///
230/// # Arguments
231/// - `path_ptr` / `path_len`: UTF-8 encoded file path
232/// - `opening_filter_mode`: 0 = Default, 1 = IgnoreAll, 2 = IgnoreOpaque
233/// - `out_ptr`: receives pointer to allocated JSON bytes
234/// - `out_len`: receives length of allocated JSON bytes
235///
236/// # Returns
237/// Same error codes and out-parameter states as `ifc_lite_parse`. An
238/// unrecognised `opening_filter_mode` is treated as `0`.
239///
240/// # Safety
241/// Same contract as `ifc_lite_parse`.
242#[no_mangle]
243pub unsafe extern "C" fn ifc_lite_parse_ex(
244    path_ptr: *const u8,
245    path_len: usize,
246    opening_filter_mode: i32,
247    out_ptr: *mut *mut u8,
248    out_len: *mut usize,
249) -> i32 {
250    let mode = match opening_filter_mode {
251        1 => OpeningFilterMode::IgnoreAll,
252        2 => OpeningFilterMode::IgnoreOpaque,
253        _ => OpeningFilterMode::Default,
254    };
255
256    run_parse(path_ptr, path_len, mode, out_ptr, out_len)
257}
258
259/// Free a buffer previously returned by `ifc_lite_parse` or `ifc_lite_parse_ex`.
260///
261/// # Safety
262/// `ptr` and `len` must be exactly a pointer and length written by a parse
263/// function that returned `0`; any other length deallocates with the wrong
264/// layout. Must not be called more than once for the same buffer. A null
265/// `ptr` or a `len` of 0 is a no-op.
266#[no_mangle]
267pub unsafe extern "C" fn ifc_lite_free(ptr: *mut u8, len: usize) {
268    if !ptr.is_null() && len > 0 {
269        let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len));
270    }
271}