Skip to main content

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
21use ifc_lite_processing::{
22    process_geometry_filtered, OpeningFilterMode, ParseResponse, ProcessingResult,
23};
24use std::backtrace::Backtrace;
25use std::cell::RefCell;
26use std::io::Write;
27use std::slice;
28use std::sync::{Once, OnceLock};
29
30/// Stack size for the geometry worker threads (256 MiB).
31///
32/// IFC geometry processing recurses deeply: BSP-tree CSG (via `csgrs`) and chains of nested
33/// boolean clipping (e.g. a wall with hundreds of openings) build call stacks far past the
34/// default ~1 MiB worker stack. Overflowing it hits the guard page and aborts the whole host
35/// process (Rhino) with `STACK_OVERFLOW` (0xC00000FD) — no panic, no unwind, nothing
36/// `catch_unwind` can intercept. A large stack gives that recursion room to complete.
37const PARSE_STACK_SIZE: usize = 256 * 1024 * 1024;
38
39/// Dedicated rayon pool whose worker threads have a large stack (see [`PARSE_STACK_SIZE`]).
40///
41/// The actual per-element geometry work runs inside `process_geometry` via
42/// `par_iter` on rayon workers, so the recursion lives on *their* stacks — not the caller's.
43/// Running the parse through `pool.install(..)` makes both the entry closure and every nested
44/// `par_iter` use these large-stack workers. Built once and reused.
45fn parse_pool() -> &'static rayon::ThreadPool {
46    static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
47    POOL.get_or_init(|| {
48        rayon::ThreadPoolBuilder::new()
49            .stack_size(PARSE_STACK_SIZE)
50            .thread_name(|i| format!("ifc-lite-parse-{i}"))
51            .build()
52            .expect("failed to build ifc-lite parse thread pool")
53    })
54}
55
56thread_local! {
57    /// Path of the IFC file currently being parsed on this thread, so the panic hook
58    /// can name the offending file. Empty when no parse is in flight.
59    static CURRENT_IFC_PATH: RefCell<String> = const { RefCell::new(String::new()) };
60}
61
62static PANIC_HOOK_INIT: Once = Once::new();
63
64/// Installs a process-wide panic hook exactly once.
65///
66/// The hook appends the IFC path being parsed, the panic message/location and a
67/// captured backtrace to `%TEMP%/ifc_lite_panic.log`, then chains to the previous
68/// hook (preserving the default stderr output). Panic hooks run *before* the runtime
69/// unwinds or aborts, so this leaves a breadcrumb identifying the file even in a
70/// `panic = "abort"` build where `catch_unwind` cannot recover.
71fn ensure_panic_logging() {
72    PANIC_HOOK_INIT.call_once(|| {
73        let previous_hook = std::panic::take_hook();
74        std::panic::set_hook(Box::new(move |info| {
75            let path = CURRENT_IFC_PATH.with(|p| p.borrow().clone());
76            let backtrace = Backtrace::force_capture();
77            let log_path = std::env::temp_dir().join("ifc_lite_panic.log");
78            if let Ok(mut file) = std::fs::OpenOptions::new()
79                .create(true)
80                .append(true)
81                .open(&log_path)
82            {
83                let _ = writeln!(
84                    file,
85                    "==== ifc-lite panic ====\nfile: {}\n{info}\nbacktrace:\n{backtrace}\n",
86                    if path.is_empty() { "<unknown>" } else { &path },
87                );
88            }
89
90            previous_hook(info);
91        }));
92    });
93}
94
95/// Threshold in meters below which a site translation is treated as identity
96/// (origin-anchored) and there is nothing to subtract.
97const LARGE_COORD_THRESHOLD: f64 = 1000.0;
98
99/// Coordinate-space tags emitted by the processing pipeline in
100/// `ProcessingResult::mesh_coordinate_space` (see `ParseResponse` docs). The
101/// pipeline keeps these private, so the FFI layer mirrors the serialized
102/// string contract here.
103const SITE_LOCAL_MESH_COORDINATE_SPACE: &str = "site_local";
104const RAW_IFC_MESH_COORDINATE_SPACE: &str = "raw_ifc";
105
106/// Post-process meshes so all positions end up in uniform site-local coordinates.
107///
108/// The decision is driven by the coordinate-space tier the pipeline already
109/// computed (`result.mesh_coordinate_space`), not by sniffing vertex magnitudes:
110/// - `raw_ifc`: no RTC anchor was applied, so vertices are still in world space.
111///   Subtract the `IfcSite` translation from *every* mesh and relabel the result
112///   as `site_local`.
113/// - `site_local` / `model_rtc`: already anchored upstream — leave untouched.
114///   (`model_rtc`'s anchor is not the site translation, so subtracting it here
115///   would double-offset the geometry.)
116/// - unknown / absent: do nothing (conservative).
117///
118/// Keying off the tier instead of a per-mesh `first vertex > 1 km` heuristic
119/// fixes two failure modes: large/campus sites whose site-local meshes legitimately
120/// start far from the local origin (no longer wrongly shifted), and world-space
121/// meshes that happen to start near the origin (no longer wrongly skipped).
122fn normalize_to_site_local(result: &mut ProcessingResult) {
123    // Only `raw_ifc` meshes are still in world space and need shifting.
124    if result.mesh_coordinate_space.as_deref() != Some(RAW_IFC_MESH_COORDINATE_SPACE) {
125        return;
126    }
127
128    let (site_tx, site_ty, site_tz) = match result.site_transform {
129        // Column-major 4x4: translation at indices 12, 13, 14.
130        Some(ref st) if st.len() >= 16 => (st[12], st[13], st[14]),
131        _ => return,
132    };
133
134    // If the site sits at (near) the origin there is nothing to subtract.
135    if site_tx.abs() < LARGE_COORD_THRESHOLD
136        && site_ty.abs() < LARGE_COORD_THRESHOLD
137        && site_tz.abs() < LARGE_COORD_THRESHOLD
138    {
139        return;
140    }
141
142    for mesh in &mut result.meshes {
143        // Subtract the site translation with f64 precision, then store as f32.
144        for chunk in mesh.positions.chunks_exact_mut(3) {
145            chunk[0] = (chunk[0] as f64 - site_tx) as f32;
146            chunk[1] = (chunk[1] as f64 - site_ty) as f32;
147            chunk[2] = (chunk[2] as f64 - site_tz) as f32;
148        }
149    }
150
151    // The meshes are now anchored to the site; advertise that to the caller so
152    // it isn't told `raw_ifc` for data we just relocated.
153    result.mesh_coordinate_space = Some(SITE_LOCAL_MESH_COORDINATE_SPACE.to_string());
154}
155
156/// Shared body of both parse entry points: read the file, run geometry
157/// processing inside the large-stack pool under `catch_unwind`, normalize mesh
158/// coordinates, and serialize the response to JSON bytes.
159///
160/// Returns the JSON buffer on success, or one of the FFI error codes on failure
161/// (`2` read, `3` processing panic, `4` serialization) — `0`/`1` are decided by
162/// the wrappers, which own pointer validation.
163fn parse_impl(path_str: &str, mode: OpeningFilterMode) -> Result<Vec<u8>, i32> {
164    let content = std::fs::read_to_string(path_str).map_err(|_| 2)?;
165
166    CURRENT_IFC_PATH.with(|p| *p.borrow_mut() = path_str.to_string());
167
168    let result = parse_pool().install(|| {
169        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
170            process_geometry_filtered(&content, mode)
171        }))
172    });
173
174    CURRENT_IFC_PATH.with(|p| p.borrow_mut().clear());
175
176    let mut result = result.map_err(|_| 3)?;
177
178    // Normalize all meshes to uniform site-local coordinates.
179    normalize_to_site_local(&mut result);
180
181    let response = ParseResponse {
182        cache_key: String::new(),
183        meshes: result.meshes,
184        mesh_coordinate_space: result.mesh_coordinate_space,
185        site_transform: result.site_transform,
186        building_transform: result.building_transform,
187        metadata: result.metadata,
188        stats: result.stats,
189        // This fork's `ParseResponse` carries 2D symbol data; the FFI parse path
190        // is geometry-only, so emit an empty (default) set. `ProcessingResult`
191        // has no `symbolic_data` to forward here.
192        symbolic_data: Default::default(),
193    };
194
195    serde_json::to_vec(&response).map_err(|_| 4)
196}
197
198/// Validate the path bytes and out-pointers, run [`parse_impl`], and write the
199/// resulting buffer through the out-parameters. Shared by both exported
200/// functions so the null checks and contract live in exactly one place.
201///
202/// # Safety
203/// `out_ptr`/`out_len` (when non-null) must be valid for writes.
204unsafe fn run_parse(
205    path_ptr: *const u8,
206    path_len: usize,
207    mode: OpeningFilterMode,
208    out_ptr: *mut *mut u8,
209    out_len: *mut usize,
210) -> i32 {
211    ensure_panic_logging();
212
213    // Defensive null checks: a C#/P-Invoke marshalling slip would otherwise be
214    // undefined behavior in `from_raw_parts` / the out-pointer writes below.
215    if path_ptr.is_null() || out_ptr.is_null() || out_len.is_null() {
216        return 1;
217    }
218
219    let path_bytes = slice::from_raw_parts(path_ptr, path_len);
220    let path_str = match std::str::from_utf8(path_bytes) {
221        Ok(s) => s,
222        Err(_) => return 1,
223    };
224
225    let json_bytes = match parse_impl(path_str, mode) {
226        Ok(b) => b,
227        Err(code) => return code,
228    };
229
230    let len = json_bytes.len();
231    let ptr = Box::into_raw(json_bytes.into_boxed_slice()) as *mut u8;
232
233    *out_ptr = ptr;
234    *out_len = len;
235
236    0
237}
238
239/// Parse an IFC file and return JSON bytes.
240///
241/// # Arguments
242/// - `path_ptr` / `path_len`: UTF-8 encoded file path
243/// - `out_ptr`: receives pointer to allocated JSON bytes
244/// - `out_len`: receives length of allocated JSON bytes
245///
246/// # Returns
247/// - `0` on success
248/// - `1` if a pointer is null or the path is invalid UTF-8
249/// - `2` if the file cannot be read
250/// - `3` if geometry processing fails
251/// - `4` if JSON serialization fails
252///
253/// # Safety
254/// Caller must free the returned buffer with `ifc_lite_free`.
255#[no_mangle]
256pub unsafe extern "C" fn ifc_lite_parse(
257    path_ptr: *const u8,
258    path_len: usize,
259    out_ptr: *mut *mut u8,
260    out_len: *mut usize,
261) -> i32 {
262    run_parse(path_ptr, path_len, OpeningFilterMode::Default, out_ptr, out_len)
263}
264
265/// Parse an IFC file with a configurable opening filter and return JSON bytes.
266///
267/// # Arguments
268/// - `path_ptr` / `path_len`: UTF-8 encoded file path
269/// - `opening_filter_mode`: 0 = Default, 1 = IgnoreAll, 2 = IgnoreOpaque
270/// - `out_ptr`: receives pointer to allocated JSON bytes
271/// - `out_len`: receives length of allocated JSON bytes
272///
273/// # Returns
274/// Same error codes as `ifc_lite_parse`.
275///
276/// # Safety
277/// Caller must free the returned buffer with `ifc_lite_free`.
278#[no_mangle]
279pub unsafe extern "C" fn ifc_lite_parse_ex(
280    path_ptr: *const u8,
281    path_len: usize,
282    opening_filter_mode: i32,
283    out_ptr: *mut *mut u8,
284    out_len: *mut usize,
285) -> i32 {
286    let mode = match opening_filter_mode {
287        1 => OpeningFilterMode::IgnoreAll,
288        2 => OpeningFilterMode::IgnoreOpaque,
289        _ => OpeningFilterMode::Default,
290    };
291
292    run_parse(path_ptr, path_len, mode, out_ptr, out_len)
293}
294
295/// Free a buffer previously returned by `ifc_lite_parse` or `ifc_lite_parse_ex`.
296///
297/// # Safety
298/// `ptr` and `len` must match a previous return from a parse function.
299/// Must not be called more than once for the same buffer.
300#[no_mangle]
301pub unsafe extern "C" fn ifc_lite_free(ptr: *mut u8, len: usize) {
302    if !ptr.is_null() && len > 0 {
303        let _ = Box::from_raw(slice::from_raw_parts_mut(ptr, len));
304    }
305}
306
307#[cfg(test)]
308mod tests;