bug(compose): synth_cylinder / synth_plate_with_holes produce solid-with-no-hole — CSG never fires on curved primitives inside plane bodies #83

Open
opened 2026-09-29 23:38:44 +00:00 by Mark · 0 comments
Owner

Summary

The compose engine's synth path produces correct single-solid topology for plane-only and curved-only inputs, but on inputs where a curved primitive (cylinder bore, hole, pocket) lives INSIDE a plane-body envelope, the curved primitive never gets CSG-subtracted. The output is a solid plate/cup/block with no hole.

Coverage metric passes (1.000 at 5mm) because the points that should be ON the hole wall are within tolerance of the solid surface — they ARE inside the solid, but the metric only checks distance, not topology. STEP roundtrip reads as 1 MANIFOLD_SOLID_BREP, but with the hole missing.

Reproduction

cd /home/b/Projects/pc2cad
.venv/bin/python pc2cad_cli.py samples/synth/synth_cylinder.ply \
    --out /tmp/synth_cylinder.step --engine compose --deterministic-ransac --seed 42 --mode analytic
.venv/bin/python tests/render_compare.py samples/synth/synth_cylinder.ply /tmp/synth_cylinder.step \
    --out-dir /tmp/render --tolerance 1.0
# coverage: 1.000 (PASS), step: solids=1 (PASS)
# But synth_cylinder is supposed to be a cup with a through-bore.
# Open the STEP in FreeCAD — the cup has NO hole.

Same pattern on samples/synth/synth_plate_with_holes.ply: fitter detects 0 holes (even though input has visible through-holes), synth produces a solid plate with no holes, coverage 1.000 at 5mm, 0 CSG features.

Root cause

The graph has edges for plane-to-plane binding (OPPOSES, ADJACENT, SHARES_CAP — wired in #80). It has NO edge for curved-to-plane binding ("this cylinder primitive is enclosed by these planes"). As a result:

  1. fit_primitives detects the cylinder bore (synth_cylinder: cylinder_index=1, radius=0.025, depth=0.1, aligned_to_dominant=True, surrounded=True).
  2. build_graph + cluster_bodies puts the cylinder primitive in a SEPARATE Body from the surrounding plates. Body 0 has planes only (axis=[0,0,1]); body 1 has the cylinder + 2 profile ops (axis=None).
  3. synthesize_body for body 0 sees only planes → _synthesize_planar → no CSG. Body 1 has axis=None and >1 primitive → returns None.
  4. The CSG-subtract branch in synthesize_body (line 1649) only fires when the curved primitive is in the SAME body as the enclosing planes, which never happens here.

The "ENCLOSES" or "AXIS_OF" edge from a curved primitive to its surrounding planes would let cluster_bodies merge them into one body. Without that edge, curved primitives end up in singleton/multi-primitive bodies with axis=None and the synth path returns nothing useful.

Expected

synth_cylinder.ply STEP contains a cup with a through-bore; coverage at 1mm ≥0.85 (some loss expected near the bore wall due to scan noise). synth_plate_with_holes.ply STEP contains a plate with multiple through-holes; coverage at 1mm ≥0.85.

Actual

Both STEPs have 1 solid, 0 holes. Coverage 1.000 at 5mm because the missing-hole points are INSIDE the solid (within tolerance). The metric gate is fooled.

Acceptance

  • synth_cylinder.ply STEP opens in FreeCAD as a cup with a visible through-bore
  • synth_plate_with_holes.ply STEP opens as a plate with visible through-holes
  • render_compare.py at tolerance=1mm reports coverage ≥0.85 on both
  • methodology_cone.ply (input is a teardrop, output is currently a plain cone) — separate fit gap, may also benefit from the same fix if the cone is detected as a curved primitive inside a planar envelope

Visual evidence

vision_analyze on /tmp/render_analytic/synth_cylinder/side_by_side.png (--mode analytic forced the synth path; CLI default --mode auto mis-routes these to freeform fallback, which produces a triangulated-mesh STEP — separate but related CLI bug):

  • left panel: cup-shaped point cloud with a hollow interior
  • right panel: solid cup with no through-bore, top is a closed surface

Notes

The CLI default --mode auto also routes these samples to the freeform fallback (because the auto-classifier doesn't recognise them as mechanical primitives despite the fitter finding good primitive fits). That routing produces a triangulated mesh STEP (0 MANIFOLD_SOLID_BREP, 2000 PLANE triangles, ~6MB) — a separate issue from this CSG gap, but the visible symptom is similar.

Not a regression: this CSG gap predates #80. Mark/pc2cad#76 added body-aware feature sweep specifically for bolt-circle patterns that RANSAC missed, suggesting the underlying ENCLOSES edge gap was already known but not addressed at the graph level.

Refs: scan-to-cad primitive-graph-phase3 reference (the CSG-with-subtract path documented but not exercised for in-body-bore cases), #80 (same BFS-binding gap class for planes), #76 (bolt-circle body-aware sweep that works around this gap by detecting features post-hoc rather than at the graph level).

## Summary The compose engine's synth path produces correct single-solid topology for plane-only and curved-only inputs, but on inputs where a curved primitive (cylinder bore, hole, pocket) lives INSIDE a plane-body envelope, the curved primitive never gets CSG-subtracted. The output is a solid plate/cup/block with no hole. Coverage metric passes (1.000 at 5mm) because the points that should be ON the hole wall are within tolerance of the solid surface — they ARE inside the solid, but the metric only checks distance, not topology. STEP roundtrip reads as 1 MANIFOLD_SOLID_BREP, but with the hole missing. ## Reproduction ```bash cd /home/b/Projects/pc2cad .venv/bin/python pc2cad_cli.py samples/synth/synth_cylinder.ply \ --out /tmp/synth_cylinder.step --engine compose --deterministic-ransac --seed 42 --mode analytic .venv/bin/python tests/render_compare.py samples/synth/synth_cylinder.ply /tmp/synth_cylinder.step \ --out-dir /tmp/render --tolerance 1.0 # coverage: 1.000 (PASS), step: solids=1 (PASS) # But synth_cylinder is supposed to be a cup with a through-bore. # Open the STEP in FreeCAD — the cup has NO hole. ``` Same pattern on samples/synth/synth_plate_with_holes.ply: fitter detects 0 holes (even though input has visible through-holes), synth produces a solid plate with no holes, coverage 1.000 at 5mm, 0 CSG features. ## Root cause The graph has edges for plane-to-plane binding (OPPOSES, ADJACENT, SHARES_CAP — wired in #80). It has NO edge for curved-to-plane binding ("this cylinder primitive is enclosed by these planes"). As a result: 1. fit_primitives detects the cylinder bore (synth_cylinder: cylinder_index=1, radius=0.025, depth=0.1, aligned_to_dominant=True, surrounded=True). 2. build_graph + cluster_bodies puts the cylinder primitive in a SEPARATE Body from the surrounding plates. Body 0 has planes only (axis=[0,0,1]); body 1 has the cylinder + 2 profile ops (axis=None). 3. synthesize_body for body 0 sees only planes → _synthesize_planar → no CSG. Body 1 has axis=None and >1 primitive → returns None. 4. The CSG-subtract branch in synthesize_body (line 1649) only fires when the curved primitive is in the SAME body as the enclosing planes, which never happens here. The "ENCLOSES" or "AXIS_OF" edge from a curved primitive to its surrounding planes would let cluster_bodies merge them into one body. Without that edge, curved primitives end up in singleton/multi-primitive bodies with axis=None and the synth path returns nothing useful. ## Expected synth_cylinder.ply STEP contains a cup with a through-bore; coverage at 1mm ≥0.85 (some loss expected near the bore wall due to scan noise). synth_plate_with_holes.ply STEP contains a plate with multiple through-holes; coverage at 1mm ≥0.85. ## Actual Both STEPs have 1 solid, 0 holes. Coverage 1.000 at 5mm because the missing-hole points are INSIDE the solid (within tolerance). The metric gate is fooled. ## Acceptance - synth_cylinder.ply STEP opens in FreeCAD as a cup with a visible through-bore - synth_plate_with_holes.ply STEP opens as a plate with visible through-holes - render_compare.py at tolerance=1mm reports coverage ≥0.85 on both - methodology_cone.ply (input is a teardrop, output is currently a plain cone) — separate fit gap, may also benefit from the same fix if the cone is detected as a curved primitive inside a planar envelope ## Visual evidence vision_analyze on /tmp/render_analytic/synth_cylinder/side_by_side.png (--mode analytic forced the synth path; CLI default --mode auto mis-routes these to freeform fallback, which produces a triangulated-mesh STEP — separate but related CLI bug): - left panel: cup-shaped point cloud with a hollow interior - right panel: solid cup with no through-bore, top is a closed surface ## Notes The CLI default --mode auto also routes these samples to the freeform fallback (because the auto-classifier doesn't recognise them as mechanical primitives despite the fitter finding good primitive fits). That routing produces a triangulated mesh STEP (0 MANIFOLD_SOLID_BREP, 2000 PLANE triangles, ~6MB) — a separate issue from this CSG gap, but the visible symptom is similar. Not a regression: this CSG gap predates #80. Mark/pc2cad#76 added body-aware feature sweep specifically for bolt-circle patterns that RANSAC missed, suggesting the underlying ENCLOSES edge gap was already known but not addressed at the graph level. Refs: scan-to-cad primitive-graph-phase3 reference (the CSG-with-subtract path documented but not exercised for in-body-bore cases), #80 (same BFS-binding gap class for planes), #76 (bolt-circle body-aware sweep that works around this gap by detecting features post-hoc rather than at the graph level).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Mark/pc2cad#83
No description provided.