Skip to content

Commit 26cc9b6

Browse files
BIMvoicelouistrue
andauthored
test(geometry): close four fixtures that cannot fail for the reason they exist (#3081)
* test(geometry): close four fixtures that share a symmetry with the bug they guard Each of these passed with the production line it exists to guard deleted or inverted. Every claim below was checked by applying the mutation and running the suite, not by reading. 1. `test_mirrored_profile_uses_derived_operator` mirrored a 2.0 x 4.0 IfcRectangleProfileDef about its own Y axis. The corner SET maps onto itself, and the four assertions were `contains()`, which is order-blind, so `mirror_profile_about_y_axis` was unguarded end to end: with `p.x = -p.x` replaced by `p.x = p.x` and `profile.outer.reverse()` deleted, the whole ifc-lite-geometry lib suite stayed green (718 passed), as did `issue_828_sectioned_solid_horizontal`, the only other in-crate IfcMirroredProfileDef coverage. The test's name was also wrong: IFC4 redeclares Operator as derived on the subtype, and `process_derived_with_depth` short-circuits before ever reading attribute 3. Replaced with an L-shaped outer contour plus an off-centre hole, asserting pointwise that x negates and the order reverses, and that both contours keep their chirality (the compensating `reverse()` is what stops earcut emitting inside-out triangles). A second test pins the short-circuit with an Operator that would visibly move the profile if it were applied. Five mutations now die: dropping the outer negation, the hole negation, either `reverse()`, and dropping the subtype short-circuit. 2. Every material-layer fixture in the repository is `[0.05, 0.20, 0.05]` of materials `[200, 201, 200]` at `offset = -0.15` on a 0.30-thick centred profile: a palindrome in both thickness and material, centred on the reference line, and `.AXIS2., .POSITIVE.` throughout. Reversing the layer stack in `build_layer_planes` leaves the cumulative interface distances (0.05, 0.25) and the asserted `vec![200, 201, 200]` unchanged, and replacing the authored offset with a hard-coded `-total/2` is numerically the identity. Both survived all seven pre-existing tests in the file; the reversal also survived `material_layers_local_frame_test`. Added a 50/250/100 mm buildup of three distinct materials on a profile shifted +50 mm in local Y, so `offset = -0.15 != -total/2`, and asserted each slab's band along the layer axis rather than only its material id. Added the first `.NEGATIVE.` fixture in the tree: `direction_sense` was 1.0 everywhere, so both of its uses in `build_layer_planes` and the `NEGATIVE => -1.0` arm of `resolve_layer_set_usage` were unreachable. Hard-coding `direction_sense = 1.0` now fails. 3. All five `projection_outline` tests pass `ProjectionAxis::Y`, and the only other in-crate caller passes `Z`. The `X` arm of `project`/`axis_coord` — sections looking along world X — had no test: swapping it to `(p[1], p[2])` left the lib suite green (720 passed). `ProjectionAxis::from_u8`, the WASM boundary decode, was untested too, so exchanging its 0 and 2 arms was invisible. Added one box with three distinct extents covering X and Z against each other, a check that flipping leaves V alone, and a from_u8 table test. 4. Every `IfcAxis2Placement3D` unit test in `transform.rs` has EITHER a non-zero Location OR a non-identity rotation, never both, so `transform[(_, 3)] = R * location` instead of `location` is the identity to all four of them (R*t == t at the identity, R*0 == 0 anywhere). It survived 718 of the crate's 719 other lib tests as well, dying only incidentally in an unrelated half-space test. Pinned in `tests/axis2_placement_frame.rs` (`transform.rs` is at its module-size ratchet budget) with a 90-degree placement at (10, 20, 30). No production code changed and no assertion was weakened. No ratchet budget was raised and the allowlist digest is untouched. Verification: ifc-lite-geometry --lib 721 passed / 0 failed / 1 ignored (718 before), full `-p ifc-lite-geometry --no-fail-fast` 0 failures, `module_size_ratchet` 5 passed, `cargo clippy -p ifc-lite-geometry --all-targets -D warnings` clean. * test(geometry): name the test that DOES catch the rotated-translation mutant The header said the mutation leaves "718 of the crate's 719 other lib tests" green. Two problems. The count is stale: main runs 721 today, and a number that shifts with every added test is a poor thing to record in a comment. Worse, the parenthetical hides which test fails. One does: `processors::tests::test_polygonal_bounded_half_space_respects_boundary`, whose placement is Location (0,0,5) with Axis (0,1,0), so `R * location` is (0,5,0) and the clip plane moves off the top face. It is not a substitute for this test, and the header now says why: it reaches `build_axis2_matrix` through `processors/helpers.rs`'s own attribute-extraction fork, one of five sharing that builder, and fails with "the clipped strip should be removed" -- pointing at a boolean, not a placement. Verified with the mutation the header names, `R * location`, not an adjacent one. A tx/ty swap leaves that test GREEN and reds 12 others, so it would not have supported this claim. * test(geometry): desymmetrise the projection fixture, and correct four claims Pre-flight review found a test that cannot fail INSIDE the PR whose purpose is closing tests that cannot fail. `each_projection_axis_picks_its_own_two_drawing_coordinates` used z in [-1,1]. Under axis=X that is u, and it is symmetric about zero, so the mirror assertion read (-1 + 1) == 0 and (1 + -1) == 0 with flipping DELETED. Proven: replacing `[if flipped { -u } else { u }, v]` with `[u, v]` left the test green. The fixture comment claimed "no extent equals another's negation" -- true, and the wrong property. It needed "no extent equals ITS OWN negation". z is now [-1,2] and the same mutation reds it: "flip must mirror u: -1..2 vs -1..2". Three factual corrections, each re-measured rather than reasoned: - "the one test that does catch it" was wrong. At FULL crate scope SIX existing tests fail on the R*location mutant. I measured with `cargo test --lib`, which excludes `tests/` -- the directory the new test is in -- so five of the six were invisible to the scope the claim was made at. Header now lists them and gives the real reason to keep the test: they all fail downstream of a boolean and point at a CSG result, this one reads the translation column. - "one of five sharing that matrix builder" is six: transform.rs:60 and :272, profile_extractor.rs:642, processors/helpers.rs:78 and :202, router/transforms/parsers.rs:56. - "transform.rs is at its module-size ratchet budget" is 513 lines against a budget of 525. The placement decision stands, the stated reason did not. Also dropped a "718 passed" count for the property it was evidence for. It was 718 when written and is 724 now. 724 passed, 0 failed. clippy --all-targets -D warnings clean. --------- Co-authored-by: Louis Trümpler <78563314+louistrue@users.noreply.github.com>
1 parent 6e51909 commit 26cc9b6

3 files changed

Lines changed: 320 additions & 17 deletions

File tree

rust/geometry/src/profiles/tests.rs

Lines changed: 165 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -351,31 +351,179 @@ use super::*;
351351
assert!(profile.outer.contains(&Point2::new(6.0, 18.0)));
352352
}
353353

354+
/// An ASYMMETRIC parent, so the mirror is observable at all.
355+
///
356+
/// The previous fixture here mirrored a `2.0 x 4.0` rectangle about its own
357+
/// Y-axis: the corner SET maps onto itself, and the assertions were
358+
/// `contains()` (order-blind), so `mirror_profile_about_y_axis` could be
359+
/// deleted outright and the test still passed. Verified by mutation:
360+
/// replacing the outer loop's `p.x = -p.x` with `p.x = p.x` and dropping
361+
/// its `reverse()` left the whole `ifc-lite-geometry` lib suite green, and
362+
/// `issue_828_sectioned_solid_horizontal` — the only other in-crate
363+
/// `IfcMirroredProfileDef` coverage — green too. Stated as a property
364+
/// rather than a pass count on purpose: the count was 718 when first
365+
/// measured and is 724 today, so a number here goes stale on the next
366+
/// commit while "no other test observes the mirror" stays checkable.
367+
///
368+
/// This L-shaped outer contour with an off-centre hole pins both halves of
369+
/// the reflection: every point's x negates, and each contour's winding
370+
/// reverses so an orientation-reversing reflection still hands the earcut
371+
/// tessellator a CCW outer loop.
372+
const L_WITH_HOLE_IFC: &str = r#"
373+
#1=IFCCARTESIANPOINT((0.0,0.0));
374+
#2=IFCCARTESIANPOINT((6.0,0.0));
375+
#3=IFCCARTESIANPOINT((6.0,2.0));
376+
#4=IFCCARTESIANPOINT((2.0,2.0));
377+
#5=IFCCARTESIANPOINT((2.0,5.0));
378+
#6=IFCCARTESIANPOINT((0.0,5.0));
379+
#7=IFCPOLYLINE((#1,#2,#3,#4,#5,#6,#1));
380+
#8=IFCCARTESIANPOINT((3.0,0.5));
381+
#9=IFCCARTESIANPOINT((5.0,0.5));
382+
#10=IFCCARTESIANPOINT((5.0,1.5));
383+
#11=IFCPOLYLINE((#8,#9,#10,#8));
384+
#12=IFCARBITRARYPROFILEDEFWITHVOIDS(.AREA.,$,#7,(#11));
385+
#13=IFCMIRROREDPROFILEDEF(.AREA.,$,#12,$,$);
386+
"#;
387+
388+
/// Twice the signed area of a closed contour (shoelace). Positive is CCW.
389+
fn signed_area2(points: &[Point2<f64>]) -> f64 {
390+
let n = points.len();
391+
(0..n)
392+
.map(|i| {
393+
let a = points[i];
394+
let b = points[(i + 1) % n];
395+
a.x * b.y - b.x * a.y
396+
})
397+
.sum()
398+
}
399+
354400
#[test]
355-
fn test_mirrored_profile_uses_derived_operator() {
401+
fn test_mirrored_profile_negates_x_and_reverses_winding() {
402+
let mut decoder = EntityDecoder::new(L_WITH_HOLE_IFC);
403+
let schema = IfcSchema::new();
404+
let processor = ProfileProcessor::new(schema);
405+
406+
let parent = processor
407+
.process(
408+
&decoder.decode_by_id(12).unwrap(),
409+
&mut decoder,
410+
TessellationQuality::Medium,
411+
)
412+
.unwrap();
413+
let mirrored = processor
414+
.process(
415+
&decoder.decode_by_id(13).unwrap(),
416+
&mut decoder,
417+
TessellationQuality::Medium,
418+
)
419+
.unwrap();
420+
421+
// Sanity: the parent really is asymmetric about x, so a dropped
422+
// negation cannot hide, and it is authored CCW with a hole.
423+
assert!(
424+
parent.outer.iter().any(|p| p.x != 0.0),
425+
"fixture must be off the mirror axis"
426+
);
427+
assert!(
428+
signed_area2(&parent.outer) > 0.0,
429+
"fixture outer contour must be authored CCW"
430+
);
431+
assert_eq!(parent.holes.len(), 1, "fixture must carry a hole");
432+
433+
// x -> -x, and the point order reverses.
434+
let n = parent.outer.len();
435+
assert_eq!(mirrored.outer.len(), n);
436+
for i in 0..n {
437+
let src = parent.outer[n - 1 - i];
438+
let got = mirrored.outer[i];
439+
assert!(
440+
(got.x + src.x).abs() < 1e-9 && (got.y - src.y).abs() < 1e-9,
441+
"outer[{i}]: expected ({}, {}), got ({}, {})",
442+
-src.x,
443+
src.y,
444+
got.x,
445+
got.y
446+
);
447+
}
448+
449+
// The hole travels with it — same negation, same reversal.
450+
assert_eq!(mirrored.holes.len(), 1);
451+
let (ph, mh) = (&parent.holes[0], &mirrored.holes[0]);
452+
assert_eq!(mh.len(), ph.len());
453+
for i in 0..ph.len() {
454+
let src = ph[ph.len() - 1 - i];
455+
let got = mh[i];
456+
assert!(
457+
(got.x + src.x).abs() < 1e-9 && (got.y - src.y).abs() < 1e-9,
458+
"hole[{i}]: expected ({}, {}), got ({}, {})",
459+
-src.x,
460+
src.y,
461+
got.x,
462+
got.y
463+
);
464+
}
465+
466+
// The reflection is orientation-reversing; the compensating `reverse()`
467+
// must hand the tessellator the SAME chirality it started with, or
468+
// downstream earcut emits inside-out triangles.
469+
assert!(
470+
signed_area2(&mirrored.outer) > 0.0,
471+
"mirrored outer must stay CCW, got area2 {}",
472+
signed_area2(&mirrored.outer)
473+
);
474+
assert!(
475+
signed_area2(mh).signum() == signed_area2(ph).signum(),
476+
"mirrored hole must keep the parent's chirality"
477+
);
478+
}
479+
480+
/// `IfcMirroredProfileDef` redeclares `Operator` as derived (`*`) in IFC4,
481+
/// so `process_derived_with_depth` short-circuits on the subtype and never
482+
/// reads attribute 3. Pin that with an Operator that WOULD move the profile
483+
/// if it were applied: the result must be the bare mirror.
484+
#[test]
485+
fn test_mirrored_profile_ignores_any_supplied_operator() {
356486
let content = r#"
357-
#1=IFCDIRECTION((-1.0,0.0));
358-
#2=IFCDIRECTION((0.0,1.0));
359-
#3=IFCCARTESIANPOINT((0.0,0.0));
360-
#4=IFCCARTESIANTRANSFORMATIONOPERATOR2D(#1,#2,#3,1.0);
361-
#5=IFCRECTANGLEPROFILEDEF(.AREA.,$,$,2.0,4.0);
362-
#6=IFCMIRROREDPROFILEDEF(.AREA.,$,#5,#4,$);
487+
#1=IFCCARTESIANPOINT((0.0,0.0));
488+
#2=IFCCARTESIANPOINT((6.0,0.0));
489+
#3=IFCCARTESIANPOINT((6.0,2.0));
490+
#4=IFCCARTESIANPOINT((2.0,2.0));
491+
#5=IFCCARTESIANPOINT((2.0,5.0));
492+
#6=IFCCARTESIANPOINT((0.0,5.0));
493+
#7=IFCPOLYLINE((#1,#2,#3,#4,#5,#6,#1));
494+
#12=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#7);
495+
#14=IFCDIRECTION((0.0,1.0));
496+
#15=IFCCARTESIANPOINT((100.0,200.0));
497+
#16=IFCCARTESIANTRANSFORMATIONOPERATOR2D(#14,$,#15,3.0);
498+
#17=IFCMIRROREDPROFILEDEF(.AREA.,$,#12,#16,$);
363499
"#;
364-
365500
let mut decoder = EntityDecoder::new(content);
366501
let schema = IfcSchema::new();
367502
let processor = ProfileProcessor::new(schema);
368-
369-
let profile_entity = decoder.decode_by_id(6).unwrap();
370-
let profile = processor
371-
.process(&profile_entity, &mut decoder, TessellationQuality::Medium)
503+
let mirrored = processor
504+
.process(
505+
&decoder.decode_by_id(17).unwrap(),
506+
&mut decoder,
507+
TessellationQuality::Medium,
508+
)
372509
.unwrap();
373510

374-
assert_eq!(profile.outer.len(), 4);
375-
assert!(profile.outer.contains(&Point2::new(1.0, -2.0)));
376-
assert!(profile.outer.contains(&Point2::new(-1.0, -2.0)));
377-
assert!(profile.outer.contains(&Point2::new(-1.0, 2.0)));
378-
assert!(profile.outer.contains(&Point2::new(1.0, 2.0)));
511+
// Bare mirror of (6,0) is (-6,0). Had the operator (rotate +90 deg,
512+
// scale 3, translate (100,200)) been applied on top, no point would
513+
// sit anywhere near it.
514+
assert!(
515+
mirrored
516+
.outer
517+
.iter()
518+
.any(|p| (p.x + 6.0).abs() < 1e-9 && p.y.abs() < 1e-9),
519+
"expected the bare mirror; got {:?}",
520+
mirrored.outer
521+
);
522+
assert!(
523+
mirrored.outer.iter().all(|p| p.x <= 1e-9 && p.y <= 5.0 + 1e-9),
524+
"no point may be displaced by the ignored operator; got {:?}",
525+
mirrored.outer
526+
);
379527
}
380528

381529
// ── trim_polyline / SweptDiskSolid trim-param coverage ────────────────────

rust/geometry/src/projection_outline.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,87 @@ mod tests {
305305
assert_eq!(out.contours.len(), 2, "two disjoint footprints expected");
306306
}
307307

308+
/// The other five tests in this module all pass `ProjectionAxis::Y`, and
309+
/// the only other in-crate caller (`contour_bool2d_tests`) passes `Z`. So
310+
/// the `X` arm of `project`/`axis_coord` — the plan/section axis used for
311+
/// elevations looking along world X — had no test at all: swapping it to
312+
/// `(p[1], p[2])` left the whole `ifc-lite-geometry` lib suite green.
313+
/// `ProjectionAxis::from_u8`, the WASM-boundary decode
314+
/// (`wasm-bindings/src/api/mesh_outline.rs`), was likewise untested, so
315+
/// exchanging its `0` and `2` arms was invisible too.
316+
///
317+
/// One box with three DISTINCT extents pins all three axes against each
318+
/// other: any axis permutation moves at least one of the ranges below.
319+
#[test]
320+
fn each_projection_axis_picks_its_own_two_drawing_coordinates() {
321+
// x ∈ [1,4], y ∈ [0,2], z ∈ [-1,2]. Three properties, and the third is
322+
// the one a symmetric range silently destroys: no two extents are
323+
// equal; no extent equals ANOTHER's negation; and no extent equals ITS
324+
// OWN negation. z was [-1,1], which satisfies the first two and fails
325+
// the third, so the mirror assertion below held at 0 == 0 with
326+
// flipping deleted entirely.
327+
let (pos, idx) = box_mesh(1.0, 4.0, 0.0, 2.0, -1.0, 2.0, false);
328+
329+
// axis = X → (u = z, v = y); the cut axis is x.
330+
let x = mesh_outline_2d(&pos, &idx, ProjectionAxis::X, false).expect("x outline");
331+
let (minu, minv, maxu, maxv) = bbox_2d(&x.contours);
332+
assert!(
333+
(minu + 1.0).abs() < 1e-4 && (maxu - 2.0).abs() < 1e-4,
334+
"axis=X u must be the z extent, got {minu}..{maxu}"
335+
);
336+
assert!(
337+
(minv - 0.0).abs() < 1e-4 && (maxv - 2.0).abs() < 1e-4,
338+
"axis=X v must be the y extent, got {minv}..{maxv}"
339+
);
340+
assert!(
341+
(x.axis_min - 1.0).abs() < 1e-4 && (x.axis_max - 4.0).abs() < 1e-4,
342+
"axis=X band must be the x extent, got {}..{}",
343+
x.axis_min,
344+
x.axis_max
345+
);
346+
347+
// axis = Z → (u = x, v = y); the cut axis is z.
348+
let z = mesh_outline_2d(&pos, &idx, ProjectionAxis::Z, false).expect("z outline");
349+
let (minu, minv, maxu, maxv) = bbox_2d(&z.contours);
350+
assert!(
351+
(minu - 1.0).abs() < 1e-4 && (maxu - 4.0).abs() < 1e-4,
352+
"axis=Z u must be the x extent, got {minu}..{maxu}"
353+
);
354+
assert!(
355+
(minv - 0.0).abs() < 1e-4 && (maxv - 2.0).abs() < 1e-4,
356+
"axis=Z v must be the y extent, got {minv}..{maxv}"
357+
);
358+
assert!(
359+
(z.axis_min + 1.0).abs() < 1e-4 && (z.axis_max - 2.0).abs() < 1e-4,
360+
"axis=Z band must be the z extent, got {}..{}",
361+
z.axis_min,
362+
z.axis_max
363+
);
364+
365+
// Flipping mirrors U and leaves V alone — `flipped_axis_mirrors_u`
366+
// above never checks that V survives.
367+
let xf = mesh_outline_2d(&pos, &idx, ProjectionAxis::X, true).expect("x flipped");
368+
let (fminu, fminv, fmaxu, fmaxv) = bbox_2d(&xf.contours);
369+
let (uminu, _, umaxu, _) = bbox_2d(&x.contours);
370+
assert!(
371+
(fminu + umaxu).abs() < 1e-4 && (fmaxu + uminu).abs() < 1e-4,
372+
"flip must mirror u: {fminu}..{fmaxu} vs {uminu}..{umaxu}"
373+
);
374+
assert!(
375+
(fminv - 0.0).abs() < 1e-4 && (fmaxv - 2.0).abs() < 1e-4,
376+
"flip must leave v alone, got {fminv}..{fmaxv}"
377+
);
378+
}
379+
380+
/// The 0/1/2 = x/y/z decode crossing the WASM boundary.
381+
#[test]
382+
fn from_u8_decodes_the_wasm_axis_convention() {
383+
assert_eq!(ProjectionAxis::from_u8(0), Some(ProjectionAxis::X));
384+
assert_eq!(ProjectionAxis::from_u8(1), Some(ProjectionAxis::Y));
385+
assert_eq!(ProjectionAxis::from_u8(2), Some(ProjectionAxis::Z));
386+
assert_eq!(ProjectionAxis::from_u8(3), None);
387+
}
388+
308389
#[test]
309390
fn empty_or_degenerate_returns_none() {
310391
assert!(mesh_outline_2d(&[], &[], ProjectionAxis::Y, false).is_none());
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
//! `IfcAxis2Placement3D` with BOTH a non-identity rotation and a non-zero
6+
//! Location — the one combination `transform.rs`'s own unit tests never make.
7+
//!
8+
//! `parse_axis2_placement_3d_defaults_missing_axis_and_ref_direction` is at
9+
//! `(10, 20, 30)` with the identity rotation, and the three orthogonalization
10+
//! tests (`..._defaults_ref_direction_when_only_axis_given`,
11+
//! `..._orthogonalizes_parallel_ref_direction_low_z` / `..._high_z`) all place
12+
//! the origin at `(0, 0, 0)`. Under either, the classic
13+
//! "the translation column got rotated too" mutation —
14+
//! `transform[(_, 3)] = R * location` instead of `location` — is the identity:
15+
//! `R·t == t` when `R` is the identity, and `R·0 == 0` for any `R`.
16+
//!
17+
//! Verified by mutation: rotating the translation column in
18+
//! `build_axis2_matrix` leaves all four of those unit tests green, while the
19+
//! test below fails.
20+
//!
21+
//! The crate is NOT blind to that mutation. At FULL crate scope six existing
22+
//! tests also fail:
23+
//!
24+
//! processors::tests::test_polygonal_bounded_half_space_respects_boundary
25+
//! tests/voids_inline_matrix_test.rs inline_void_matrix
26+
//! tests/rect_param_gate.rs param_fast_path_fires_watertight...
27+
//! tests/issue_1167_rotated_wall_opening.rs rotated_wall_opening_is_not_overcut
28+
//! tests/issue_1167_rotated_wall_opening.rs rotated_opening_cuts_clean_at_every_angle
29+
//! tests/issue_1167_real_wall.rs rotated_wall_openings_not_overcut_or_fragmented
30+
//!
31+
//! An earlier version of this header said "the one test that does catch it".
32+
//! That was measured with `cargo test --lib`, which excludes `tests/` -- the
33+
//! directory this file is in. Five of the six were invisible to the scope the
34+
//! claim was made at.
35+
//!
36+
//! Every one of them is downstream of a boolean or a cut volume, so each fails
37+
//! with something like "the clipped strip should be removed" and points at a
38+
//! CSG result rather than at a placement. This is the only test that reads the
39+
//! translation column directly, which is the reason to keep it -- not scarcity.
40+
//!
41+
//! This lives in `tests/` rather than beside them because the test body would
42+
//! push `transform.rs` (513 lines) past its 525-line ratchet budget.
43+
44+
use ifc_lite_core::EntityDecoder;
45+
use ifc_lite_geometry::parse_axis2_placement_3d;
46+
47+
#[test]
48+
fn a_rotated_placement_does_not_rotate_its_own_location() {
49+
// Axis = (0,0,1) with RefDirection = (0,1,0) is a +90 degree turn about Z,
50+
// so local X maps to world +Y and local Y to world -X. Under `R * location`
51+
// the translation column would read (-20, 10, 30) instead of (10, 20, 30).
52+
let content = "\
53+
#1=IFCCARTESIANPOINT((10.0,20.0,30.0));
54+
#2=IFCDIRECTION((0.0,0.0,1.0));
55+
#3=IFCDIRECTION((0.0,1.0,0.0));
56+
#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);";
57+
let mut decoder = EntityDecoder::new(content);
58+
let placement = decoder.decode_by_id(4).unwrap();
59+
60+
let m = parse_axis2_placement_3d(&placement, &mut decoder).unwrap();
61+
62+
// Sanity: the rotation really is non-trivial (local X -> world +Y) ...
63+
assert!((m[(0, 0)] - 0.0).abs() < 1e-9, "m00 {}", m[(0, 0)]);
64+
assert!((m[(1, 0)] - 1.0).abs() < 1e-9, "m10 {}", m[(1, 0)]);
65+
// ... and Y = Z x X = (0,0,1) x (0,1,0) = (-1,0,0).
66+
assert!((m[(0, 1)] + 1.0).abs() < 1e-9, "m01 {}", m[(0, 1)]);
67+
assert!((m[(1, 1)] - 0.0).abs() < 1e-9, "m11 {}", m[(1, 1)]);
68+
69+
// A placement is [R | t], not [R | R·t]: Location is already expressed in
70+
// the parent frame, so the rotation must not touch it.
71+
assert!((m[(0, 3)] - 10.0).abs() < 1e-9, "tx {}", m[(0, 3)]);
72+
assert!((m[(1, 3)] - 20.0).abs() < 1e-9, "ty {}", m[(1, 3)]);
73+
assert!((m[(2, 3)] - 30.0).abs() < 1e-9, "tz {}", m[(2, 3)]);
74+
}

0 commit comments

Comments
 (0)