Skip to content

Beta clash detection findings: contained elements not detected, clash point off with coplanar faces, no hard clash tolerance #33

Description

@ngirgisConsevo

Hey, we got the beta packages installed and ran the clash code through some tests on our side (@thatopen-platform/components-beta 3.5.16). Found a few things you should know about.

We called clashPipeline directly in Node with simple boxes where we know the right answer, so these are easy to reproduce:

  1. If one element is completely inside another, no clash is reported. Two boxes, one fully contained in the other, zero results in both set orders. Looks like the hard check only tests surface intersections, and containment never intersects surfaces. A pipe buried in a beam would slip through. This one is the important one for us.

  2. The clash point can end up outside the actual clash. Two boxes overlapping 0.5m in x but sharing top/bottom faces reported the point at x=0.33, outside the overlap. Seems like when the first triangle pair found is coplanar, the midpoint calc falls back to a triangle centroid. The sphere markers would show up in the wrong spot. When there are no coplanar faces the point is fine.

  3. Elements just touching (zero penetration) count as a hard clash. Maybe intended, but in real models everything sitting on a slab lights up.

  4. There's no tolerance option for hard clash tests. Clearance takes a tolerance but hard only has the volume flags, so there's no way to ignore shallow penetrations. Between this and point 3, real projects will drown in touching/grazing results. A penetration tolerance like Navisworks has (ignore anything under say 10mm) would make hard tests actually usable for coordination, and it would solve 3 as well.

Smaller notes: the volume is Monte Carlo sampled, so not exact for non-box shapes and it needs watertight meshes, worth documenting. And the clash ids are 32-bit hashes, on a big federation with tens of thousands of pairs collisions get realistic.

Test script below, drop it in a scaffolded beta app and run node tests/run-clash-tests.mjs.

run-clash-tests.mjs
/*
  Local ground-truth tests for @thatopen-platform/components-beta clash detection.
  Runs the pure clashPipeline/clashSelf functions directly in Node — no platform,
  no IFC files. Every case uses axis-aligned boxes whose expected result is known
  exactly from geometry.

  Run: node tests/run-clash-tests.mjs
*/
import { clashPipeline, clashSelf } from "@thatopen-platform/components-beta";

// ---------------------------------------------------------------------------
// Geometry helper: axis-aligned box as a watertight triangle mesh (12 tris).
// ---------------------------------------------------------------------------
let nextLocalId = 1;
function resetIds() { nextLocalId = 1; }
function makeBox(guid, [x0, y0, z0], [x1, y1, z1], category = "IFCBUILDINGELEMENTPROXY") {
  const positions = new Float32Array([
    x0, y0, z0,  x1, y0, z0,  x1, y1, z0,  x0, y1, z0, // z0 face corners
    x0, y0, z1,  x1, y0, z1,  x1, y1, z1,  x0, y1, z1, // z1 face corners
  ]);
  const index = new Uint32Array([
    0, 2, 1,  0, 3, 2, // bottom (z0), outward -z
    4, 5, 6,  4, 6, 7, // top (z1), outward +z
    0, 1, 5,  0, 5, 4, // -y
    2, 3, 7,  2, 7, 6, // +y
    1, 2, 6,  1, 6, 5, // +x
    3, 0, 4,  3, 4, 7, // -x
  ]);
  return {
    data: { guid, name: guid, category, localId: nextLocalId++, modelId: "test-model" },
    geometry: { positions, index },
  };
}

const results = [];
function check(name, actual, expected, note = "") {
  const pass = actual === expected;
  results.push({ name, pass, actual, expected, note });
  console.log(`${pass ? "PASS" : "FAIL"}  ${name} — expected ${expected}, got ${actual}${note ? `  (${note})` : ""}`);
}
function report(name, value) {
  console.log(`INFO  ${name}: ${JSON.stringify(value)}`);
}

// ---------------------------------------------------------------------------
// 1. Hard clash — clear overlap. Boxes overlap by 0.5 m in X. Volume = 0.5 m³.
// ---------------------------------------------------------------------------
{
  const a = makeBox("A-overlap", [0, 0, 0], [1, 1, 1]);
  const b = makeBox("B-overlap", [0.5, 0, 0], [1.5, 1, 1]);
  const out = clashPipeline([a], [b], { type: "hard", calculateVolume: true });
  check("hard: overlapping boxes detected", out.results.length, 1);
  if (out.results[0]) {
    const c = out.results[0];
    report("overlap clash point", c.point);
    report("overlap volume (true = 0.5 m³)", c.volume);
    const p = c.point;
    const inOverlap = p[0] >= 0.5 && p[0] <= 1 && p[1] >= 0 && p[1] <= 1 && p[2] >= 0 && p[2] <= 1;
    check("hard: clash point lies inside overlap region", inOverlap, true);
  }
}

// ---------------------------------------------------------------------------
// 2. Hard clash — separated boxes (0.5 m gap). Must find nothing.
// ---------------------------------------------------------------------------
{
  const a = makeBox("A-sep", [0, 0, 0], [1, 1, 1]);
  const b = makeBox("B-sep", [1.5, 0, 0], [2.5, 1, 1]);
  const out = clashPipeline([a], [b], { type: "hard" });
  check("hard: separated boxes not flagged", out.results.length, 0);
}

// ---------------------------------------------------------------------------
// 3. Hard clash — exactly touching faces (coplanar contact, zero penetration).
//    Boundary case: either answer can be argued; record the behavior.
// ---------------------------------------------------------------------------
{
  const a = makeBox("A-touch", [0, 0, 0], [1, 1, 1]);
  const b = makeBox("B-touch", [1, 0, 0], [2, 1, 1]);
  const out = clashPipeline([a], [b], { type: "hard" });
  report("hard: face-touching boxes (zero penetration) clash count", out.results.length);
}

// ---------------------------------------------------------------------------
// 4. Hard clash — full containment. Small box entirely inside big box: no
//    surface intersection exists. Surface-only algorithms miss this one.
// ---------------------------------------------------------------------------
{
  const a = makeBox("A-outer", [0, 0, 0], [1, 1, 1]);
  const b = makeBox("B-inner", [0.25, 0.25, 0.25], [0.75, 0.75, 0.75]);
  const out = clashPipeline([a], [b], { type: "hard" });
  check("hard: fully-contained box detected", out.results.length, 1, "surface-only algorithms miss containment");
}

// ---------------------------------------------------------------------------
// 5. Clearance — gap 0.5 m between boxes.
// ---------------------------------------------------------------------------
{
  const a = makeBox("A-clr", [0, 0, 0], [1, 1, 1]);
  const mk = (guid) => makeBox(guid, [1.5, 0, 0], [2.5, 1, 1]);

  const tight = clashPipeline([a], [mk("B-clr1")], { type: "clearance", tolerance: 0.4 });
  check("clearance: tol 0.4 < gap 0.5 → no violation", tight.results.length, 0);

  const loose = clashPipeline([a], [mk("B-clr2")], { type: "clearance", tolerance: 0.6 });
  check("clearance: tol 0.6 > gap 0.5 → violation", loose.results.length, 1);
  if (loose.results[0]) {
    const d = loose.results[0].distance;
    check("clearance: reported distance ≈ 0.5", Math.abs(d - 0.5) < 1e-4, true, `distance=${d}`);
  }

  const exact = clashPipeline([a], [mk("B-clr3")], { type: "clearance", tolerance: 0.5 });
  report("clearance: tol exactly = gap (boundary) violation count", exact.results.length);
}

// ---------------------------------------------------------------------------
// 6. Determinism — identical input twice must give identical ids and points.
// ---------------------------------------------------------------------------
{
  const run = () => {
    resetIds();
    const a = makeBox("A-det", [0, 0, 0], [1, 1, 1]);
    const b = makeBox("B-det", [0.5, 0.5, 0.5], [1.5, 1.5, 1.5]);
    return clashPipeline([a], [b], { type: "hard", calculateVolume: true });
  };
  const r1 = run();
  const r2 = run();
  const strip = (o) => JSON.stringify(o.results);
  check("determinism: two identical runs give identical results", strip(r1), strip(r2));
}

// ---------------------------------------------------------------------------
// 7. clashSelf — 3 elements, exactly one overlapping pair, no self-pairs.
// ---------------------------------------------------------------------------
{
  const a = makeBox("S-a", [0, 0, 0], [1, 1, 1]);
  const b = makeBox("S-b", [0.5, 0, 0], [1.5, 1, 1]); // overlaps a
  const c = makeBox("S-c", [10, 10, 10], [11, 11, 11]); // far away
  const out = clashSelf([a, b, c], { type: "hard" });
  check("clashSelf: exactly one pair found, no self-pairs", out.results.length, 1);
}

// ---------------------------------------------------------------------------
// 8. Thin penetration — 1 cm deep overlap must still register as hard clash.
// ---------------------------------------------------------------------------
{
  const a = makeBox("A-thin", [0, 0, 0], [1, 1, 1]);
  const b = makeBox("B-thin", [0.99, 0, 0], [1.99, 1, 1]);
  const out = clashPipeline([a], [b], { type: "hard" });
  check("hard: 1 cm penetration detected", out.results.length, 1);
}

// ---------------------------------------------------------------------------
// 9. Diagnostic: clash point with NO coplanar faces — beam through a wall.
//    B pierces A's +x face only; overlap region is x∈[0.5,1], y∈[0.25,0.75],
//    z∈[0.25,0.75]. Does the point land inside it when no face-touch noise?
// ---------------------------------------------------------------------------
{
  const a = makeBox("A-pierce", [0, 0, 0], [1, 1, 1]);
  const b = makeBox("B-pierce", [0.5, 0.25, 0.25], [1.5, 0.75, 0.75]);
  const out = clashPipeline([a], [b], { type: "hard", calculateVolume: true });
  check("hard: piercing beam detected", out.results.length, 1);
  if (out.results[0]) {
    const p = out.results[0].point;
    report("pierce clash point (overlap x∈[0.5,1], y,z∈[0.25,0.75])", p);
    report("pierce volume (true = 0.125 m³)", out.results[0].volume);
    const inOverlap = p[0] >= 0.5 && p[0] <= 1 && p[1] >= 0.25 && p[1] <= 0.75 && p[2] >= 0.25 && p[2] <= 0.75;
    check("hard: pierce clash point inside overlap region", inOverlap, true);
  }
}

// ---------------------------------------------------------------------------
// 10. Diagnostic: containment in both orders + touching-case volume.
// ---------------------------------------------------------------------------
{
  const outer = makeBox("D-outer", [0, 0, 0], [1, 1, 1]);
  const inner = makeBox("D-inner", [0.25, 0.25, 0.25], [0.75, 0.75, 0.75]);
  const ab = clashPipeline([inner], [outer], { type: "hard" });
  report("containment reversed order (inner as setA) clash count", ab.results.length);

  const t1 = makeBox("D-t1", [0, 0, 0], [1, 1, 1]);
  const t2 = makeBox("D-t2", [1, 0, 0], [2, 1, 1]);
  const touch = clashPipeline([t1], [t2], { type: "hard", calculateVolume: true });
  if (touch.results[0]) report("face-touch reported volume (true = 0)", touch.results[0].volume);
}

// ---------------------------------------------------------------------------
const failed = results.filter((r) => !r.pass);
console.log(`\n${results.length - failed.length}/${results.length} checks passed${failed.length ? ` — ${failed.length} FAILED` : ""}`);
process.exit(failed.length ? 1 : 0);

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions