Visitar URL original
Beta clash detection findings: contained elements not detected, clash point off with coplanar faces, no hard clash tolerance · Issue #33 · ThatOpen/platform_services · GitHub
Skip to content

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

Open

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);

Activity

  1. self-assigned this
    on Aug 4, 2026
  2. HoyosJuan commented on Aug 10, 2026

    @HoyosJuan
    Contributor

    Hey @ngirgisConsevo, thanks so much for the thorough repro script!

    We started investigating and can confirm all 4 points, plus we found the root cause for each in the code:

    1. Containment not detected: confirmed. The hard clash traversal only tests triangle-triangle surface intersections, so a fully-contained element never has crossing triangles and slips through entirely.
    2. Clash point off with coplanar faces: confirmed. When no triangle edge crosses the other triangle's plane within bounds (which happens with coplanar faces), the midpoint calc falls back to a triangle centroid, exactly as you diagnosed.
    3. Touching counts as hard clash: confirmed. Near-zero separations get snapped to exactly 0 before the intersection test, so zero-penetration contact passes through as a clash.
    4. No tolerance for hard clash: confirmed, and it's a bit deeper than a missing parameter. The option type for hard clashes doesn't define a tolerance field at all yet, so this needs a small API extension, not just a wiring fix.

    On the smaller notes: you're right that volume is Monte Carlo sampled rather than exact. That was a deliberate tradeoff to keep clash detection performance from tanking, and in our own testing it's been reliable enough as a filter. That said, we're going to look into offering a few algorithms with different precision/performance tradeoffs so you can pick what fits your use case.

    On distance for hard clashes specifically: we actually have an ADR on this. We tried a proper penetration distance early on and tested hundreds of cases across different angles and element shapes, but couldn't land on something reliable, which is why we went with intersection volume as the severity signal instead. We're open to revisiting distance again, just wanted to share the context on why it isn't there today.

    We'll keep you posted as we work through fixes for 1-4!

  3. migfrazao2003 commented on Aug 31, 2026

    @migfrazao2003

    Hi Juan, any update on this issue?

  4. HoyosJuan commented on Sep 7, 2026

    @HoyosJuan
    Contributor

    Hi @migfrazao2003! Have planned to work on this one during the week. Will let you know once it is done :)

  5. migfrazao2003 commented on Oct 15, 2026

    @migfrazao2003

    Hi Juan, any update on this?
    It is a really important issue. With the current Clash detector component, we can have a lot of clashes that will not show up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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