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;