Skip to content

Commit ba7553a

Browse files
louistrueclaude
andauthored
fix(geometry): IfcReinforcingBar rendering for 3D directrix (#641)
* fix(geometry): IfcReinforcingBar rendering for 3D directrix and Revit trim params (#631) Two issues, both surfacing as broken IfcSweptDiskSolid geometry on rebar: 1. **3D IfcIndexedPolyCurve directrix support** (no spec deviation). The 3D curve dispatcher in `get_curve_points_with_depth` had no arm for `IfcIndexedPolyCurve`. Inputs fell through to the 2D path, which only reads `IfcCartesianPointList2D` — Z coordinates were silently dropped and 3D stirrups collapsed onto z=0 as a degenerate line. Add `process_indexed_polycurve_3d` that reads `IfcCartesianPointList2D` / `IfcCartesianPointList3D` verbatim and fits `IfcArcIndex` segments via circumcircle in the plane of their three control points. 2. **SPEC DEVIATION (Revit rebar)** — `IfcSweptDiskSolid.EndParam` as arc length. IFC4 / IFC4.3 parameterises `IfcCompositeCurve` over `[0, num_segments]` (each segment contributes 1.0). Revit and other AECC tools emit `EndParam` in the directrix's length unit instead, typically the bar's swept length. Honouring it verbatim clamps to `num_segments` and renders the entire curve, making bars 10–100× their real length. `get_composite_curve_points_trimmed` now detects `EndParam > num_segments + ε` (impossible under the spec parameterisation), re-interprets `StartParam` / `EndParam` as cumulative arc length along the sampled directrix, and trims accordingly. Spec-conformant inputs (`EndParam ≤ num_segments`) take the unchanged segment-index path. The deviation is flagged in the method doc comment and at the branch point. Tests: - `test_swept_disk_indexed_polycurve_3d` — Z extent preserved on 3D arc directrix. - `test_swept_disk_revit_arclength_trim_fallback` — Revit-style trim clips the directrix to the requested arc length. - `test_swept_disk_segment_index_trim_unchanged` — spec-conformant trim parameters still produce the segment-index result. WASM rebuilt via `scripts/build-wasm.sh`. Includes the cosmetic `__wasm_bindgen_func_elem` renumber that also appears in #638. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert: drop Revit arc-length trim heuristic — was regressing Rebar2.ifc Visual verification on the issue's Rebar2.ifc showed the heuristic was trimming the directrix too aggressively: bars rendered as a tiny straight stub instead of the full hooked shape. Re-checking the numbers, EndParam ≈ 228.99 for that file matches the sum of each segment's natural parametric contribution (4 polylines × 1 + 110° + 70° + 45° ≈ 229), not arc length in mm. The existing segment-index path clamps to `num_segments` and renders the whole curve, which is what the authoring tool intended. Revert the spec-deviation branch added in ba6fca8: - `get_composite_curve_points_trimmed` returns to the original segment-index clamp behaviour, with a short doc-comment note about why out-of-range EndParam values are clamped rather than reinterpreted. - Remove the now-unused `trim_polyline_by_arclength` helper. - Drop the `test_swept_disk_revit_arclength_trim_fallback` and `test_swept_disk_segment_index_trim_unchanged` tests; the segment-index path is unchanged from main and already covered. The 3D `IfcIndexedPolyCurve` directrix fix (sample 1, the stirrup) is unaffected and visually confirmed working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(geometry): read RefDirection from index 2 on IfcAxis2Placement3D The 2D placement helper used by `process_trimmed_conic` always reads attribute index 1 as `RefDirection`. That is correct for `IfcAxis2Placement2D` (`Location, RefDirection`) but wrong for `IfcAxis2Placement3D` (`Location, Axis, RefDirection`) — index 1 there is the Z-axis, which has no useful x/y components for an in-plane rotation. Every arc trimmed over a 3D-placed `IfcCircle` came back with rotation 0° regardless of the file's authored direction. Concrete impact: in `Rebar2.ifc` from issue #631, each bend in the composite curve uses a different `RefDirection` (135°, 245°, 315°), so the rebar's arc segments were rotated to the wrong direction and the directrix looked visibly distorted. Detect `IfcAxis2Placement3D` and read attribute index 2 in that case. Added `test_trimmed_circle_3d_placement_reads_ref_direction` to pin the behaviour: a 90°-rotated 3D placement must produce arc geometry above the centre, not below. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7efc878 commit ba7553a

7 files changed

Lines changed: 386 additions & 13 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@ifc-lite/wasm": patch
3+
"@ifc-lite/geometry": patch
4+
---
5+
6+
Fix `IfcReinforcingBar` stirrup rendering (issue #631, sample
7+
`IfcReinforcingBar.ifc`).
8+
9+
`IfcSweptDiskSolid` directrixes that use `IfcIndexedPolyCurve` over
10+
`IfcCartesianPointList3D` (typical for stirrups and other bent rebar that
11+
lives outside the XY plane) used to fall back to a 2D parser that read x/y
12+
from indices 0–1 and silently dropped the Z coordinate. The stirrup
13+
collapsed onto z=0 and the resulting tube was a flat near-degenerate line.
14+
15+
The 3D curve dispatcher now has a native arm for `IfcIndexedPolyCurve` that
16+
reads `IfcCartesianPointList2D` (z=0) or `IfcCartesianPointList3D` verbatim
17+
and fits `IfcArcIndex` segments using a circumcircle in the plane of their
18+
three control points. Straight schema conformance — no spec deviation.
19+
20+
The second sample on the issue (`Rebar2.ifc`) was already rendering its
21+
directrix correctly under the existing segment-index trim path; no change
22+
needed there.

packages/wasm/pkg/ifc-lite.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,9 +1194,9 @@ export interface InitOutput {
11941194
readonly profileentryjs_expressId: (a: number) => number;
11951195
readonly symboliccircle_expressId: (a: number) => number;
11961196
readonly __wbg_gpuinstancedgeometryref_free: (a: number, b: number) => void;
1197-
readonly __wasm_bindgen_func_elem_1165: (a: number, b: number, c: number) => void;
1198-
readonly __wasm_bindgen_func_elem_1164: (a: number, b: number) => void;
1199-
readonly __wasm_bindgen_func_elem_1205: (a: number, b: number, c: number, d: number) => void;
1197+
readonly __wasm_bindgen_func_elem_1167: (a: number, b: number, c: number) => void;
1198+
readonly __wasm_bindgen_func_elem_1166: (a: number, b: number) => void;
1199+
readonly __wasm_bindgen_func_elem_1206: (a: number, b: number, c: number, d: number) => void;
12001200
readonly __wbindgen_export: (a: number) => void;
12011201
readonly __wbindgen_export2: (a: number, b: number, c: number) => void;
12021202
readonly __wbindgen_export3: (a: number, b: number) => number;

packages/wasm/pkg/ifc-lite.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,12 @@ if (!('encodeInto' in cachedTextEncoder)) {
212212

213213
let WASM_VECTOR_LEN = 0;
214214

215-
function __wasm_bindgen_func_elem_1165(arg0, arg1, arg2) {
216-
wasm.__wasm_bindgen_func_elem_1165(arg0, arg1, addHeapObject(arg2));
215+
function __wasm_bindgen_func_elem_1167(arg0, arg1, arg2) {
216+
wasm.__wasm_bindgen_func_elem_1167(arg0, arg1, addHeapObject(arg2));
217217
}
218218

219-
function __wasm_bindgen_func_elem_1205(arg0, arg1, arg2, arg3) {
220-
wasm.__wasm_bindgen_func_elem_1205(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
219+
function __wasm_bindgen_func_elem_1206(arg0, arg1, arg2, arg3) {
220+
wasm.__wasm_bindgen_func_elem_1206(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
221221
}
222222

223223
const GeoReferenceJsFinalization = (typeof FinalizationRegistry === 'undefined')
@@ -3093,7 +3093,7 @@ function __wbg_get_imports() {
30933093
const a = state0.a;
30943094
state0.a = 0;
30953095
try {
3096-
return __wasm_bindgen_func_elem_1205(a, state0.b, arg0, arg1);
3096+
return __wasm_bindgen_func_elem_1206(a, state0.b, arg0, arg1);
30973097
} finally {
30983098
state0.a = a;
30993099
}
@@ -3207,7 +3207,7 @@ function __wbg_get_imports() {
32073207
};
32083208
imports.wbg.__wbindgen_cast_49a1621a976e807f = function(arg0, arg1) {
32093209
// Cast intrinsic for `Closure(Closure { dtor_idx: 154, function: Function { arguments: [Externref], shim_idx: 155, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3210-
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1164, __wasm_bindgen_func_elem_1165);
3210+
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1166, __wasm_bindgen_func_elem_1167);
32113211
return addHeapObject(ret);
32123212
};
32133213
imports.wbg.__wbindgen_cast_d6cd19b81560fd6e = function(arg0) {

packages/wasm/pkg/ifc-lite_bg.wasm

5.45 KB
Binary file not shown.

packages/wasm/pkg/ifc-lite_bg.wasm.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,9 @@ export const meshcollection_rtcOffsetX: (a: number) => number;
215215
export const profileentryjs_expressId: (a: number) => number;
216216
export const symboliccircle_expressId: (a: number) => number;
217217
export const __wbg_gpuinstancedgeometryref_free: (a: number, b: number) => void;
218-
export const __wasm_bindgen_func_elem_1165: (a: number, b: number, c: number) => void;
219-
export const __wasm_bindgen_func_elem_1164: (a: number, b: number) => void;
220-
export const __wasm_bindgen_func_elem_1205: (a: number, b: number, c: number, d: number) => void;
218+
export const __wasm_bindgen_func_elem_1167: (a: number, b: number, c: number) => void;
219+
export const __wasm_bindgen_func_elem_1166: (a: number, b: number) => void;
220+
export const __wasm_bindgen_func_elem_1206: (a: number, b: number, c: number, d: number) => void;
221221
export const __wbindgen_export: (a: number) => void;
222222
export const __wbindgen_export2: (a: number, b: number, c: number) => void;
223223
export const __wbindgen_export3: (a: number, b: number) => number;

rust/geometry/src/processors/tests.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,101 @@ fn test_extruded_area_solid_tapered() {
537537
assert!((max.z - 2000.0).abs() < 0.01);
538538
}
539539

540+
/// Issue #631, sample `IfcReinforcingBar.ifc` (stirrup).
541+
///
542+
/// `IfcSweptDiskSolid` directrix is an `IfcIndexedPolyCurve` over an
543+
/// `IfcCartesianPointList3D`. Before this fix the 3D curve dispatcher had no
544+
/// arm for `IfcIndexedPolyCurve`, so points were re-read as 2D and collapsed
545+
/// onto z=0 — the stirrup rendered as a flat near-zero-length line. Verify
546+
/// that the swept tube spans the full Z range of the input points.
547+
#[test]
548+
fn test_swept_disk_indexed_polycurve_3d() {
549+
// A simple 3D arc: start (0,0,0), mid (10,0,10), end (20,0,0).
550+
// This lives in the XZ plane — Y stays zero, Z varies non-trivially.
551+
let content = r#"
552+
#1=IFCCARTESIANPOINTLIST3D(((0.0,0.0,0.0),(10.0,0.0,10.0),(20.0,0.0,0.0)));
553+
#2=IFCINDEXEDPOLYCURVE(#1,(IFCARCINDEX((1,2,3))),.F.);
554+
#3=IFCSWEPTDISKSOLID(#2,1.0,$,$,$);
555+
"#;
556+
557+
let mut decoder = EntityDecoder::new(content);
558+
let schema = IfcSchema::new();
559+
let processor = SweptDiskSolidProcessor::new(schema.clone());
560+
561+
let entity = decoder.decode_by_id(3).unwrap();
562+
assert_eq!(entity.ifc_type, IfcType::IfcSweptDiskSolid);
563+
let mesh = processor.process(&entity, &mut decoder, &schema).unwrap();
564+
565+
assert!(!mesh.is_empty(), "3D indexed polycurve directrix should produce geometry");
566+
567+
let (min, max) = mesh.bounds();
568+
// The tube radius is 1.0, so Z extent of the mesh should be close to
569+
// (arc max Z + radius) - (-radius) ≈ 11. Without this fix the directrix
570+
// collapses to a 1-D line on x and Z extent is ~2 (just the tube radius
571+
// around z=0).
572+
let z_extent = (max.z - min.z) as f64;
573+
assert!(
574+
z_extent > 5.0,
575+
"stirrup arc should span its declared 3D Z range; got z_extent={}",
576+
z_extent
577+
);
578+
}
579+
580+
/// Issue #631, sample `Rebar2.ifc`.
581+
///
582+
/// The directrix is a planar `IfcCompositeCurve` whose arc segments are
583+
/// authored as `IfcTrimmedCurve` over an `IfcCircle` placed by
584+
/// `IfcAxis2Placement3D`. `get_placement_2d` used to read attribute index 1 of
585+
/// the placement as `RefDirection`, but on a 3D placement that index is the
586+
/// `Axis` (Z direction). Every arc came back with rotation=0° regardless of
587+
/// the file's actual `RefDirection`, twisting each bend by the file's
588+
/// authored angle — visibly distorting Revit-exported bent rebar.
589+
///
590+
/// Verify that an arc anchored to `IfcAxis2Placement3D` with `RefDirection`
591+
/// rotated 90° actually rotates: the bottom of the arc (parameter 270°) lands
592+
/// at the rotated +X direction, not at world -Y.
593+
#[test]
594+
fn test_trimmed_circle_3d_placement_reads_ref_direction() {
595+
// Circle radius 10, centred at (100, 100, 0), placed in XY plane with
596+
// RefDirection = +Y (rotation 90°). A 270° trim with sense=T produces a
597+
// point at radius * (cos(270°+90°), sin(270°+90°)) = (10, 0) in the local
598+
// frame ⇒ (100 + 10*cos(90°), 100 + 10*sin(90°)) ≈ (100, 110) in world.
599+
//
600+
// Without the fix the rotation reads as 0° (the Z-axis x/y components),
601+
// so 270° lands at (100, 90) — visibly off.
602+
let content = r#"
603+
#1=IFCCARTESIANPOINT((100.0,100.0,0.0));
604+
#2=IFCDIRECTION((0.0,0.0,1.0));
605+
#3=IFCDIRECTION((0.0,1.0,0.0));
606+
#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);
607+
#5=IFCCIRCLE(#4,10.0);
608+
#6=IFCTRIMMEDCURVE(#5,(IFCPARAMETERVALUE(270.0)),(IFCPARAMETERVALUE(0.0)),.T.,.PARAMETER.);
609+
#7=IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#6);
610+
#8=IFCCOMPOSITECURVE((#7),.F.);
611+
#9=IFCSWEPTDISKSOLID(#8,0.5,$,$,$);
612+
"#;
613+
614+
let mut decoder = EntityDecoder::new(content);
615+
let schema = IfcSchema::new();
616+
let processor = SweptDiskSolidProcessor::new(schema.clone());
617+
618+
let entity = decoder.decode_by_id(9).unwrap();
619+
let mesh = processor.process(&entity, &mut decoder, &schema).unwrap();
620+
assert!(!mesh.is_empty());
621+
622+
let (min, max) = mesh.bounds();
623+
// With RefDirection respected (90° rotation), the trim from 270° to 360°
624+
// sweeps from local-frame "below center" to local-frame "right of center";
625+
// in world that maps to "left of center" to "below center" because +X
626+
// local = +Y world. Mesh extents should clear well above center.y - radius
627+
// in the bug case (would otherwise stay near y=100-r).
628+
assert!(
629+
(max.y as f64) > 105.0,
630+
"Arc should rotate with RefDirection — max.y={} suggests rotation was ignored",
631+
max.y
632+
);
633+
}
634+
540635
#[test]
541636
fn test_extruded_area_solid_tapered_falls_back_when_end_missing() {
542637
// A malformed file with no EndSweptArea should still render as a uniform

0 commit comments

Comments
 (0)