From bd0d5435e37fe5998c45246fe39d996bc4190868 Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Sat, 19 Sep 2026 22:30:36 +0200 Subject: [PATCH 1/9] Fix undefined volumes placed by the CAD converter's name filter This fixes a problem in o2-cad-to-tgeo's --include-name filter and adds a self-test. - A part shared by an included and an excluded subtree was cached as dropped on its first visit and then placed on its second, so geom.C did not compile. - In the other visit order the part leaked into the excluded subtree. - An empty leaf placed twice left the same dangling placement. - The pruned expansion of a definition now has its own key, a cached definition that produced nothing is no longer placed, and a dangling placement stops the conversion. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/tools/O2_CADtoTGeo.py | 122 +++++++++++++++++++-- 1 file changed, 115 insertions(+), 7 deletions(-) diff --git a/Detectors/CADSupport/tools/O2_CADtoTGeo.py b/Detectors/CADSupport/tools/O2_CADtoTGeo.py index 85509b35464e4..1703031c6b07f 100644 --- a/Detectors/CADSupport/tools/O2_CADtoTGeo.py +++ b/Detectors/CADSupport/tools/O2_CADtoTGeo.py @@ -1963,14 +1963,22 @@ def _self_test_assembly(shape_tool, components): return label -def _self_test_convert(shape_tool): +def _self_test_named(shape_tool, label, name: str): + """Give an in-memory label the XCAF name a STEP file would carry.""" + from OCC.Core.TDataStd import TDataStd_Name + TDataStd_Name.Set(label, name) + return label + + +def _self_test_convert(shape_tool, name_filter: Optional["NameFilter"] = None): """Run the production traversal over an in-memory assembly and report what it placed. Returns (report, leaf occurrences), where the occurrences are (definition, world transform signature) pairs -- measured by walking the emitted graph, not read back out of the rule. """ reset_graph() - report = expand_free_shapes(shape_tool, meshparam=None, scale_to_cm=1.0) + report = expand_free_shapes(shape_tool, meshparam=None, scale_to_cm=1.0, + name_filter=name_filter) leaves = [occ for occ in enumerate_occurrences(placements, top_defs) if occ[0] in logical_volumes] return report, leaves @@ -2237,6 +2245,94 @@ def compound_of(*shapes): return tally.failures +def _self_test_dangling() -> List[str]: + """Placement children that are neither a logical volume nor an assembly.""" + return sorted({child for _parent, child, _trsf in placements + if child not in logical_volumes and child not in assemblies}) + + +def run_name_filter_self_test() -> int: + """Assert that --include-name keeps exactly the matching subtrees, whatever the visit order. + + Returns the number of failures; prints one line per check. + """ + tally = _Checks() + report = tally.report + + print("\nName filters: a part shared by included and excluded subtrees") + + def disc_like(cable_first: bool): + # A screw used both by a matching cable and by a non-matching bracket, as in the OT disc. + doc, st = _self_test_shape_tool() + screw = _self_test_named(st, _self_test_leaf(st, 1.0), "screw") + flat = _self_test_named(st, _self_test_leaf(st, 2.0), "A-flat print") + own = _self_test_named(st, _self_test_leaf(st, 3.0), "bracket plate") + cable = _self_test_named(st, _self_test_assembly( + st, [(flat, gp_Trsf()), (screw, _self_test_shift(dx=10.0))]), "A-flat cable") + bracket = _self_test_named(st, _self_test_assembly( + st, [(screw, gp_Trsf()), (own, _self_test_shift(dx=5.0))]), "bracket") + parts = [(cable, _self_test_shift(dy=100.0)), (bracket, gp_Trsf())] + _self_test_assembly(st, parts if cable_first else parts[::-1]) + st.UpdateAssemblies() + return doc, st + + include = NameFilter.from_patterns(["A-flat"], []) + + # --- 1. the shared screw is first met outside the included subtree ------------------------ + _doc, st = disc_like(cable_first=False) + _rep, occ = _self_test_convert(st, include) + report(not _self_test_dangling(), + "no placement points at a volume that was never defined", + f"dangling: {_self_test_dangling()}") + report(len(occ) == 2, "the cable's two parts are placed, the bracket's are not", + f"{len(occ)} placed") + + # --- 2. the same model visited in the other order gives the same answer ------------------- + _doc, st = disc_like(cable_first=True) + _rep, occ = _self_test_convert(st, include) + report(not _self_test_dangling() and len(occ) == 2, + "visit order does not matter: the screw met first inside the cable is not placed in " + "the bracket too", f"{len(occ)} placed, dangling: {_self_test_dangling()}") + + # --- 3. one assembly needed both whole and pruned ------------------------------------------ + _doc, st = _self_test_shape_tool() + screw = _self_test_named(st, _self_test_leaf(st, 1.0), "screw") + flat = _self_test_named(st, _self_test_leaf(st, 2.0), "A-flat print") + module = _self_test_named(st, _self_test_assembly( + st, [(screw, gp_Trsf()), (flat, _self_test_shift(dx=20.0))]), "module") + wrap = _self_test_named(st, _self_test_assembly(st, [(module, gp_Trsf())]), "A-flat wrap") + _self_test_assembly(st, [(module, gp_Trsf()), (wrap, _self_test_shift(dz=50.0))]) + st.UpdateAssemblies() + _rep, occ = _self_test_convert(st, include) + report(not _self_test_dangling() and len(occ) == 3, + "an assembly placed whole inside a match and pruned outside it gives 2 + 1 parts", + f"{len(occ)} placed, dangling: {_self_test_dangling()}") + + # --- 4. an empty leaf placed twice is dropped twice, not dangled the second time ---------- + from OCC.Core.BRep import BRep_Builder + from OCC.Core.TopoDS import TopoDS_Compound + _doc, st = _self_test_shape_tool() + comp = TopoDS_Compound() + BRep_Builder().MakeCompound(comp) + empty = st.AddShape(comp, False) + good = _self_test_leaf(st, 3.0) + _self_test_assembly(st, [(empty, gp_Trsf()), (empty, _self_test_shift(dx=5.0)), + (good, _self_test_shift(dx=10.0))]) + st.UpdateAssemblies() + _rep, occ = _self_test_convert(st) + report(not _self_test_dangling() and len(occ) == 1, + "an empty leaf instanced twice leaves no placement behind", + f"{len(occ)} placed, dangling: {_self_test_dangling()}") + + # --- 5. the negative control: without a filter everything is placed ------------------------ + _doc, st = disc_like(cable_first=False) + _rep, occ = _self_test_convert(st) + report(len(occ) == 4, "without a filter all four parts are placed", f"{len(occ)} placed") + + print(f"\n{tally.checks} checks, {tally.failures} failure(s)") + return tally.failures + + def _recognized_inner_wall(face, rec) -> Optional[bool]: """Decide, by measurement, which side of a RECOGNIZED quadric is outside the solid. @@ -3586,11 +3682,17 @@ def expand_definition( include_subtree=subtree_included, ) - def_key = f"{def_lid}@{occ_path}" if clip_enabled else def_lid - if not clip_enabled and def_lid in visited_defs: - return def_lid - if not clip_enabled: - visited_defs.add(def_lid) + # Outside an included subtree an --include-name filter prunes the definition, so the pruned + # expansion gets its own key; the whole one keeps the bare label entry. + pruned = name_filter is not None and name_filter.has_include and not subtree_included + if clip_enabled: + def_key = f"{def_lid}@{occ_path}" + else: + def_key = f"{def_lid}@pruned" if pruned else def_lid + if def_key in visited_defs: + # A definition that produced nothing (pruned away, empty) must not be placed. + return def_key if (def_key in logical_volumes or def_key in assemblies) else None + visited_defs.add(def_key) if nm and def_key not in def_names: def_names[def_key] = nm @@ -4000,6 +4102,11 @@ def expand_free_shapes( set(logical_volumes)) report_duplicate_placements(dup_report, def_names) verify_placement_invariant(placements, top_defs, set(logical_volumes), emitted) + dangling = sorted({child for _parent, child, _trsf in placements + if child not in logical_volumes and child not in assemblies}) + if dangling: + raise RuntimeError(f"{len(dangling)} placement target(s) were never defined, e.g. " + f"{dangling[:5]}; geom.C would not compile") return dup_report @@ -4637,6 +4744,7 @@ def main(): + run_planar_trim_self_test() + run_duplicate_placement_self_test() + run_multibody_leaf_self_test() + + run_name_filter_self_test() + run_in_field_media_self_test() + run_bom_token_self_test()) else 0) if args.step is None: From 88ee005c68f52546539633629b0c96784d59588d Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Sat, 19 Sep 2026 22:52:00 +0200 Subject: [PATCH 2/9] Skip CAD leaves that are open faces instead of solids This fixes a problem in o2-cad-to-tgeo with sheet bodies and adds a self-test. - A leaf made only of open faces encloses no volume, but was converted as a solid; its O2BVHSurfaceSolid then refused to close when geom.C was built. - Such a leaf is now skipped with a warning. - Open faces beside a closed body are dropped with a warning. - Closed shells without a solid around them are kept as volumes. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/tools/O2_CADtoTGeo.py | 103 ++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/Detectors/CADSupport/tools/O2_CADtoTGeo.py b/Detectors/CADSupport/tools/O2_CADtoTGeo.py index 1703031c6b07f..16517623fe762 100644 --- a/Detectors/CADSupport/tools/O2_CADtoTGeo.py +++ b/Detectors/CADSupport/tools/O2_CADtoTGeo.py @@ -66,7 +66,7 @@ ) from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopLoc import TopLoc_Location -from OCC.Core.TopAbs import TopAbs_REVERSED, TopAbs_WIRE, TopAbs_EDGE, TopAbs_FACE, TopAbs_SOLID +from OCC.Core.TopAbs import TopAbs_REVERSED, TopAbs_WIRE, TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL, TopAbs_SOLID from OCC.Core.TopTools import TopTools_IndexedMapOfShape from OCC.Core.TopoDS import topods from OCC.Extend.TopologyUtils import TopologyExplorer @@ -2241,6 +2241,76 @@ def compound_of(*shapes): "an empty leaf label is dropped with a warning and its siblings still convert", detail or f"{len(logical_volumes)} volume(s), {len(occ)} placed") + # --- 4. faces that enclose no volume, as in the ALICE3 OTR stave ------------------------- + from OCC.Core.BRepGProp import brepgprop + from OCC.Core.GProp import GProp_GProps + from OCC.Core.TopoDS import TopoDS_Shell + + def volume_of(shape): + props = GProp_GProps() + brepgprop.VolumeProperties(shape, props) + return props.Mass() + + def n_faces(shape): + exp = TopExp_Explorer(shape, TopAbs_FACE) + n = 0 + while exp.More(): + n += 1 + exp.Next() + return n + + def faces_of(shape): + exp = TopExp_Explorer(shape, TopAbs_FACE) + out = [] + while exp.More(): + out.append(exp.Current()) + exp.Next() + return out + + def one_face_shells(faces): + shells = [] + for face in faces: + shell = TopoDS_Shell() + BRep_Builder().MakeShell(shell) + BRep_Builder().Add(shell, face) + shells.append(shell) + return compound_of(*shells) + + box = BRepPrimAPI_MakeBox(2., 3., 4.) + _doc, st = _self_test_shape_tool() + sheets = st.AddShape(one_face_shells(faces_of(box.Shape())[:3]), False) + good = st.AddShape(BRepPrimAPI_MakeBox(3., 3., 3.).Shape(), False) + _self_test_assembly(st, [(sheets, gp_Trsf()), (good, _self_test_shift(dx=10.0))]) + st.UpdateAssemblies() + _rep, occ = _self_test_convert(st) + report(len(logical_volumes) == 1 and len(occ) == 1, + "a leaf of open faces that enclose no volume is dropped, its sibling still converts", + f"{len(logical_volumes)} volume(s), {len(occ)} placed") + + # --- 5. a closed shell with no solid around it still encloses a volume and is kept -------- + _doc, st = _self_test_shape_tool() + shell_only = st.AddShape(box.Shell(), False) + _self_test_assembly(st, [(shell_only, gp_Trsf())]) + st.UpdateAssemblies() + _rep, occ = _self_test_convert(st) + vol = volume_of(next(iter(def_shapes.values()))) if len(def_shapes) == 1 else 0.0 + report(len(logical_volumes) == 1 and len(occ) == 1 and abs(vol - 24.0) < 1e-9, + "a closed shell without a solid is kept as one volume enclosing the right volume", + f"{len(logical_volumes)} volume(s), volume {vol:.6g} (expected 24)") + + # --- 6. a solid with a stray face beside it keeps only the solid -------------------------- + _doc, st = _self_test_shape_tool() + stray = compound_of(box.Shape(), faces_of(BRepPrimAPI_MakeBox(gp_Pnt(9., 0., 0.), 1., 1., 1.) + .Shape())[0]) + part = st.AddShape(stray, False) + _self_test_assembly(st, [(part, gp_Trsf())]) + st.UpdateAssemblies() + _rep, occ = _self_test_convert(st) + shape = next(iter(def_shapes.values())) if len(def_shapes) == 1 else None + report(shape is not None and len(occ) == 1 and n_faces(shape) == 6, + "a solid with a stray face keeps its 6 faces and drops the stray one", + f"{len(def_shapes)} volume(s), {n_faces(shape) if shape is not None else '-'} face(s)") + print(f"\n{tally.checks} checks, {tally.failures} failure(s)") return tally.failures @@ -3611,6 +3681,24 @@ def solid_bodies_of(shape) -> list: return out +def shells_outside_solids(shape) -> Tuple[list, int]: + """The closed shells a shape carries outside any solid, and the count of its open faces.""" + closed, n_open = [], 0 + exp = TopExp_Explorer(shape, TopAbs_SHELL, TopAbs_SOLID) + while exp.More(): + shell = exp.Current() + if BRep_Tool.IsClosed(shell): + closed.append(shell) + else: + n_open += sum(1 for _ in TopologyExplorer(shell).faces()) + exp.Next() + exp = TopExp_Explorer(shape, TopAbs_FACE, TopAbs_SHELL) + while exp.More(): + n_open += 1 + exp.Next() + return closed, n_open + + def _register_leaf_shape(def_key: str, shape, meshparam, scale_to_cm: float, clip_enabled: bool, clip_box, clip_box_shape, world_trsf, def_lid: str) -> bool: @@ -3793,7 +3881,18 @@ def expand_definition( return def_key shape = shape_tool.GetShape(def_label) - bodies = solid_bodies_of(shape) + closed_shells, n_open = shells_outside_solids(shape) + bodies = solid_bodies_of(shape) + closed_shells + if n_open: + # Open faces enclose no volume: a sheet body, not something to fill with material. + if not bodies: + print(f"WARNING: CAD leaf {def_lid} ('{nm}') carries only {n_open} open face(s) " + f"that enclose no volume; skipping it.") + return None + print(f"WARNING: CAD leaf {def_lid} ('{nm}') carries {n_open} open face(s) beside " + f"its {len(bodies)} closed bod(y/ies); dropping the faces.") + if len(bodies) == 1: + shape = bodies[0] # A leaf label may hold several bodies; each becomes a volume the label places once. if len(bodies) > 1: From 4bb69416e7bc5e0b373fd66b8c32f88bab362e18 Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Sat, 19 Sep 2026 23:00:21 +0200 Subject: [PATCH 3/9] Report why PyROOT is missing and fail --csg required on it This fixes a problem in o2-cad-to-tgeo's CSG path when PyROOT does not import, and adds a self-test. - The import error was discarded, so a broken environment showed only as "ROOT unavailable" on every part. - --csg required then shipped all accepted parts as meshes and exited 0. - The converter now names the interpreter and the import error up front. - --csg required stops before reading the STEP file; --csg auto warns and goes on. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/tools/O2_CADtoTGeo.py | 64 +++++++++++++++++++ Detectors/CADSupport/tools/cadsupport/hook.py | 28 ++++++-- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/Detectors/CADSupport/tools/O2_CADtoTGeo.py b/Detectors/CADSupport/tools/O2_CADtoTGeo.py index 16517623fe762..08947312308af 100644 --- a/Detectors/CADSupport/tools/O2_CADtoTGeo.py +++ b/Detectors/CADSupport/tools/O2_CADtoTGeo.py @@ -2403,6 +2403,59 @@ def disc_like(cable_first: bool): return tally.failures +def run_missing_root_self_test() -> int: + """Assert that a PyROOT that does not import is reported with its cause, and that + --csg required refuses to run instead of shipping the accepted parts as meshes. + + Returns the number of failures; prints one line per check. + """ + import subprocess + import tempfile + + tally = _Checks() + report = tally.report + + print("\nCSG without PyROOT: name the cause, and --csg required fails") + + # Runs the converter with `import ROOT` raising, as in a broken environment. + blocker = ("import sys, runpy\n" + "class _NoRoot:\n" + " def find_spec(self, name, path=None, target=None):\n" + " if name == 'ROOT':\n" + " raise ImportError('libCore.so: cannot open shared object file')\n" + "sys.meta_path.insert(0, _NoRoot())\n" + "sys.argv = sys.argv[1:]\n" + "sys.path.insert(0, sys.argv[0].rsplit('/', 1)[0])\n" + "runpy.run_path(sys.argv[0], run_name='__main__')\n") + step = _Path(__file__).resolve().parent.parent / "examples" / "as1-oc-214.stp" + if not step.exists(): + report(False, "the example STEP file is there", f"missing: {step}") + return tally.failures + + def convert(mode): + with tempfile.TemporaryDirectory() as out: + res = subprocess.run([sys.executable, "-c", blocker, str(_Path(__file__).resolve()), + str(step), "--output-folder", out, "--csg", mode], + capture_output=True, text=True) + return res.returncode, res.stdout + res.stderr + + code, log = convert("required") + report(code != 0 and "PyROOT" in log and "libCore.so" in log, + "--csg required stops, naming PyROOT and the import error", + f"exit {code}; " + (log.strip().splitlines() or ["no output"])[-1][:160]) + report("PyROOT" in log and "Placement check" not in log, + "and it stops before reading the STEP file", + "the conversion ran" if "Placement check" in log else "stopped early") + + code, log = convert("auto") + report(code == 0 and "libCore.so" in log, + "--csg auto still converts, and says why the CSG parts are not shipped", + f"exit {code}") + + print(f"\n{tally.checks} checks, {tally.failures} failure(s)") + return tally.failures + + def _recognized_inner_wall(face, rec) -> Optional[bool]: """Decide, by measurement, which side of a RECOGNIZED quadric is outside the solid. @@ -4844,6 +4897,7 @@ def main(): + run_duplicate_placement_self_test() + run_multibody_leaf_self_test() + run_name_filter_self_test() + + run_missing_root_self_test() + run_in_field_media_self_test() + run_bom_token_self_test()) else 0) if args.step is None: @@ -4854,6 +4908,16 @@ def main(): print_geom(step_path) return + if args.csg in ("auto", "required"): + # CSG shapes are written through PyROOT; say up front when it does not import. + root_error = import_csg_hook().root_import_error() + if root_error and args.csg == "required": + ap.error(f"--csg required needs PyROOT, which does not import in {sys.executable}: " + f"{root_error}") + if root_error: + print(f"[WARN] PyROOT does not import in {sys.executable}: {root_error}. Accepted CSG " + "parts will ship one tier down (exact surfaces or mesh).") + out_folder = _Path(args.output_folder) clip_box = None diff --git a/Detectors/CADSupport/tools/cadsupport/hook.py b/Detectors/CADSupport/tools/cadsupport/hook.py index 44100f902e752..48d64a38d9b19 100644 --- a/Detectors/CADSupport/tools/cadsupport/hook.py +++ b/Detectors/CADSupport/tools/cadsupport/hook.py @@ -25,12 +25,23 @@ from cadsupport import emit, planar, primitives as prim, recognise # noqa: E402 +_ROOT_IMPORT_ERROR = False + + +def root_import_error(): + """None when PyROOT imports in this interpreter, else the error it raised, as text.""" + global _ROOT_IMPORT_ERROR + if _ROOT_IMPORT_ERROR is False: + try: + import ROOT # noqa: F401 + _ROOT_IMPORT_ERROR = None + except Exception as exc: # noqa: BLE001 + _ROOT_IMPORT_ERROR = f"{type(exc).__name__}: {exc}" + return _ROOT_IMPORT_ERROR + + def have_root(): - try: - import ROOT # noqa: F401 - return True - except Exception: # noqa: BLE001 - return False + return root_import_error() is None def scaled_to_cm(shape, scale_to_cm): @@ -133,13 +144,18 @@ def recognise_and_emit(def_shapes, def_names, scale_to_cm, out_folder, sanitize_ f"{len(flat_files)} as flat halfspace solids)") if n_csg and not root_available: n_deferred = sum(1 for r in records if r.get("shapeDeferred")) - print(f" [WARN] PyROOT is not importable in this interpreter: {n_deferred} accepted " + print(f" [WARN] PyROOT is not importable in {sys.executable} ({root_import_error()}): " + f"{n_deferred} accepted " "CSG part(s) were NOT emitted and geom.C dispatches them one tier down. " "csg_report.json records each as 'csg deferred: ROOT unavailable'. Run " "`python3 -m cadsupport.emit --from-json ` from the directory holding the cadsupport " "package, under the O2 environment, then " "reconvert (or re-run the gate), to ship them as CSG.") if mode == "required": + deferred = [r for r in records if r.get("shapeDeferred")] + if deferred: + raise ValueError(f"--csg required: {len(deferred)} accepted part(s) were not written " + f"because PyROOT does not import ({root_import_error()})") failed = [r for r in records if not r["accepted"]] if failed: lines = [f"--csg required: {len(failed)}/{len(records)} leaf solid(s) are not CSG:"] From fb070f74db578452b65c6ff7497ce9cd98d5ab4b Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Sat, 19 Sep 2026 23:48:22 +0200 Subject: [PATCH 4/9] Mesh the CAD volumes in parallel and show progress This makes o2-cad-to-tgeo mesh one volume per process and adds a self-test. - Meshing was 77% of the conversion time of a large model and ran on one core. - The volumes are now queued during the tree walk and meshed afterwards, over all cores by default; --jobs sets the count and --jobs 1 keeps the serial path. - A progress line reports how many volumes are done, with an ETA. - The ALICE 3 outer disc converts in 33 s instead of 3m14s, with byte-identical facet files. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/README.md | 5 + Detectors/CADSupport/tools/O2_CADtoTGeo.py | 117 ++++++++++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/Detectors/CADSupport/README.md b/Detectors/CADSupport/README.md index 72081a2244a97..adbfe431ae473 100644 --- a/Detectors/CADSupport/README.md +++ b/Detectors/CADSupport/README.md @@ -63,6 +63,11 @@ and falls through elsewhere. `required` stops with a report if any leaf cannot u 0.1. `--mesh-solid tgeo` emits ROOT's `TGeoTessellated`, which does not implement navigation; use it only for a macro that must load outside O2. +Meshing dominates the conversion of a large model, so the volumes are meshed one per process, +over all cores. `--jobs N` sets how many; `--jobs 1` meshes serially in this process. The +triangles are the same either way. A script that imports this module and asks for several jobs +needs the usual `if __name__ == "__main__":` guard. + The output folder holds: - `geom.C`; diff --git a/Detectors/CADSupport/tools/O2_CADtoTGeo.py b/Detectors/CADSupport/tools/O2_CADtoTGeo.py index 08947312308af..65c84acefab51 100644 --- a/Detectors/CADSupport/tools/O2_CADtoTGeo.py +++ b/Detectors/CADSupport/tools/O2_CADtoTGeo.py @@ -32,10 +32,13 @@ import csv import json import math +import multiprocessing +import os import random import re import struct import sys +import time from array import array from collections import Counter from dataclasses import dataclass @@ -256,11 +259,12 @@ def triangulate_asbbox(shape, scale_to_cm: float = 1.0): return tris * scale_to_cm if scale_to_cm != 1.0 else tris -def triangulate_CAD_solid(my_solid, meshparam, scale_to_cm: float = 1.0): +def triangulate_CAD_solid(my_solid, meshparam, scale_to_cm: float = 1.0, + in_parallel: bool = True): lin_defl = float(meshparam.get("lin_defl", 0.1)) ang_defl = float(meshparam.get("ang_defl", 0.1)) - BRepMesh_IncrementalMesh(my_solid, lin_defl, False, ang_defl, True) + BRepMesh_IncrementalMesh(my_solid, lin_defl, False, ang_defl, in_parallel) chunks = [] for face in TopologyExplorer(my_solid).faces(): @@ -2456,6 +2460,39 @@ def convert(mode): return tally.failures +def run_parallel_mesh_self_test() -> int: + """Assert that meshing in worker processes gives exactly the serial triangles. + + Returns the number of failures; prints one line per check. + """ + tally = _Checks() + report = tally.report + + print("\nParallel meshing: the same triangles as serial meshing") + + step = _Path(__file__).resolve().parent.parent / "examples" / "ExcavatorArm.step" + if not step.exists(): + report(False, "the example STEP file is there", f"missing: {step}") + return tally.failures + meshparam = {"do_meshing": True, "lin_defl": 0.1, "ang_defl": 0.1} + runs = {} + # Parallel first: a process that has meshed serially falls back, by design. + for jobs in (2, 1): + extract_graph(str(step), meshparam=meshparam, scale_to_cm=0.1, jobs=jobs) + runs[jobs] = dict(logical_volumes) + serial, parallel = runs[1], runs[2] + report(set(serial) == set(parallel) and len(serial) > 1, + "both runs mesh the same volumes", f"{len(serial)} vs {len(parallel)}") + same = [k for k in serial if k in parallel and np.array_equal(serial[k], parallel[k])] + report(len(same) == len(serial) and all(len(serial[k]) > 0 for k in serial), + "and every volume gets identical, non-empty triangles", + f"{len(same)}/{len(serial)} identical") + report(not pending_mesh, "nothing is left waiting to be meshed", f"{len(pending_mesh)} pending") + + print(f"\n{tally.checks} checks, {tally.failures} failure(s)") + return tally.failures + + def _recognized_inner_wall(face, rec) -> Optional[bool]: """Decide, by measurement, which side of a RECOGNIZED quadric is outside the solid. @@ -3697,6 +3734,7 @@ def clip_shape_to_box(shape, clip_box: ClipBox, clip_box_shape, local_to_world: placements = [] # (parent_def_lid, child_def_lid, gp_Trsf local) top_defs = set() # top definition lids visited_defs = set() # expanded defs +pending_mesh: List[str] = [] # def keys waiting for mesh_pending_volumes() def reset_graph() -> None: @@ -3710,6 +3748,7 @@ def reset_graph() -> None: placements = [] top_defs = set() visited_defs = set() + pending_mesh.clear() def cpp_var_for_def(lid: str) -> str: @@ -3769,11 +3808,72 @@ def _register_leaf_shape(def_key: str, shape, meshparam, scale_to_cm: float, def_shapes[def_key] = shape do_meshing = (meshparam is not None) and meshparam.get("do_meshing", None) is True - logical_volumes[def_key] = (triangulate_CAD_solid(shape, meshparam=meshparam, scale_to_cm=scale_to_cm) - if do_meshing else triangulate_asbbox(shape, scale_to_cm=scale_to_cm)) + if do_meshing: + # Meshed after the walk, by mesh_pending_volumes(), possibly in parallel. + logical_volumes[def_key] = None + pending_mesh.append(def_key) + else: + logical_volumes[def_key] = triangulate_asbbox(shape, scale_to_cm=scale_to_cm) return True +_MESH_ARGS: tuple = (None, 1.0) +_meshed_in_process = False + + +def _mesh_worker(def_key: str): + """Mesh one volume in a forked worker, which inherits the parent's shapes. + + OCCT's own face-level parallelism is off here: the parts already run in parallel, and nested + threads would only contend. It gives the same triangles either way. + """ + meshparam, scale_to_cm = _MESH_ARGS + return def_key, triangulate_CAD_solid(def_shapes[def_key], meshparam=meshparam, + scale_to_cm=scale_to_cm, in_parallel=False) + + +def _print_progress(label: str, done: int, total: int, t0: float) -> None: + """One progress line with an ETA: redrawn in place on a terminal, every 10% in a log.""" + elapsed = time.time() - t0 + eta = elapsed / done * (total - done) if done else 0.0 + line = f"{label}: {done}/{total} ({100.0 * done / total:.0f}%), {elapsed:.0f} s elapsed, ETA {eta:.0f} s" + if sys.stdout.isatty(): + print("\r" + line, end="\n" if done == total else "", flush=True) + elif done == total or (done * 10) // total != ((done - 1) * 10) // total: + print(line, flush=True) + + +def mesh_pending_volumes(meshparam, scale_to_cm: float, jobs: int = 1) -> None: + """Triangulate every volume queued by the walk; in `jobs` worker processes when jobs > 1.""" + keys = list(pending_mesh) + pending_mesh.clear() + if not keys: + return + global _MESH_ARGS, _meshed_in_process + t0 = time.time() + jobs = max(1, min(jobs, len(keys))) + if jobs > 1 and "fork" not in multiprocessing.get_all_start_methods(): + print(" [WARN] this platform cannot fork: meshing serially in one process.") + jobs = 1 + if jobs > 1 and _meshed_in_process: + # Forking a process that already holds OCCT's meshing threads deadlocks. + print(" [WARN] this process has already meshed serially: meshing serially again.") + jobs = 1 + label = f"Meshing {len(keys)} volume(s)" + (f" in {jobs} processes" if jobs > 1 else "") + if jobs == 1: + _meshed_in_process = True + for i, key in enumerate(keys): + logical_volumes[key] = triangulate_CAD_solid(def_shapes[key], meshparam=meshparam, + scale_to_cm=scale_to_cm) + _print_progress(label, i + 1, len(keys), t0) + return + _MESH_ARGS = (meshparam, scale_to_cm) + with multiprocessing.get_context("fork").Pool(jobs) as pool: + for i, (key, tris) in enumerate(pool.imap_unordered(_mesh_worker, keys)): + logical_volumes[key] = tris + _print_progress(label, i + 1, len(keys), t0) + + def expand_definition( def_label: TDF_Label, shape_tool, @@ -4200,6 +4300,7 @@ def extract_graph( clip_box: Optional[ClipBox] = None, clip_deduplicate: str = "intact", name_filter: Optional[NameFilter] = None, + jobs: int = 1, ): reset_graph() doc, shape_tool = load_step_with_xcaf(step_path) @@ -4211,6 +4312,7 @@ def extract_graph( clip_deduplicate=clip_deduplicate, name_filter=name_filter, ) + mesh_pending_volumes(meshparam, scale_to_cm, jobs=jobs) return doc, shape_tool @@ -4372,6 +4474,7 @@ def emit_root_macro( max_splits: Optional[int] = None, decompose_timeout: Optional[float] = None, mesh_solid: str = "o2", + jobs: int = 1, ): # exact_surfaces mode: # off : tessellated output only (default; leaves generated output unchanged). @@ -4402,6 +4505,7 @@ def emit_root_macro( clip_box=clip_box, clip_deduplicate=clip_deduplicate, name_filter=name_filter, + jobs=jobs, ) out_folder = out_folder.expanduser().resolve() @@ -4843,6 +4947,9 @@ def main(): ap.add_argument("--mesh", action="store_true", help="Use full BRepMesh triangulation instead of bounding boxes") ap.add_argument("--print-tree", action="store_true", help="Just prints the geometry tree") ap.add_argument("--mesh-prec", type=float, default=0.1, help="meshing precision. lower --> slower") + ap.add_argument("--jobs", "-j", type=int, default=os.cpu_count() or 1, + help="Processes that mesh the volumes with --mesh (default: all cores); 1 meshes " + "serially in this process") ap.add_argument("--in-field", nargs="?", const="2,10", default=None, metavar="IFIELD,FIELDM", help="Treat this module as sitting in the magnetic field: write the eight Geant " "medium parameters, with ifield and fieldm taken from the live field. " @@ -4898,6 +5005,7 @@ def main(): + run_multibody_leaf_self_test() + run_name_filter_self_test() + run_missing_root_self_test() + + run_parallel_mesh_self_test() + run_in_field_media_self_test() + run_bom_token_self_test()) else 0) if args.step is None: @@ -4991,6 +5099,7 @@ def main(): max_splits=args.max_splits, decompose_timeout=args.decompose_timeout, mesh_solid=args.mesh_solid, + jobs=args.jobs, ) out_macro.write_text(code) From e716fcc194bfa437e093c0eabec45bef18257ed8 Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Sat, 19 Sep 2026 23:49:46 +0200 Subject: [PATCH 5/9] Report progress of the surface and CSG phases This adds a progress line to the two remaining silent phases of o2-cad-to-tgeo. - Exact-surface extraction reports how many leaf solids are done, with an ETA. - The CSG cascade prefixes each part with its position in the run. - On the ALICE 3 stave the CSG cascade is now the slowest phase, at about 20 s of 24 s; it is still serial. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/tools/O2_CADtoTGeo.py | 5 ++++- Detectors/CADSupport/tools/cadsupport/hook.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Detectors/CADSupport/tools/O2_CADtoTGeo.py b/Detectors/CADSupport/tools/O2_CADtoTGeo.py index 65c84acefab51..2477864c3cb7d 100644 --- a/Detectors/CADSupport/tools/O2_CADtoTGeo.py +++ b/Detectors/CADSupport/tools/O2_CADtoTGeo.py @@ -4546,7 +4546,10 @@ def emit_root_macro( brep_files: Dict[str, str] = {} # def_lid -> absolute path of brep_*.brep (--dump-brep) failures: Dict[str, List[str]] = {} # def_lid -> unsupported-face reasons extracted: Dict[str, int] = {} # def_lid -> number of surface records written - for lid, shape in def_shapes.items(): + t_extract = time.time() + extract_label = f"Extracting exact surfaces from {len(def_shapes)} leaf solid(s)" + for i_leaf, (lid, shape) in enumerate(def_shapes.items()): + _print_progress(extract_label, i_leaf + 1, len(def_shapes), t_extract) surfaces, reasons, n_model_edges = extract_surfaces_for_shape( shape, scale_to_cm, recognize_surfaces=recognize_flag, recognition=recognition, lid=lid) diff --git a/Detectors/CADSupport/tools/cadsupport/hook.py b/Detectors/CADSupport/tools/cadsupport/hook.py index 48d64a38d9b19..762269a83e2bc 100644 --- a/Detectors/CADSupport/tools/cadsupport/hook.py +++ b/Detectors/CADSupport/tools/cadsupport/hook.py @@ -68,8 +68,11 @@ def recognise_and_emit(def_shapes, def_names, scale_to_cm, out_folder, sanitize_ csg_files = {} flat_files = {} records = [] - for lid, shape in def_shapes.items(): + n_parts = len(def_shapes) + for i_part, (lid, shape) in enumerate(def_shapes.items()): display = def_names.get(lid, "") + if verbose: + print(f" [{i_part + 1}/{n_parts}]", end=" ", flush=True) volname = sanitize_filename(display) if display else "vol" suffix = f"{volname}_{sanitize_filename(lid)}" solid = scaled[lid] if scaled and lid in scaled else scaled_to_cm(shape, scale_to_cm) From 879586f6e34f3fe9312d48f5fe79b53653457cba Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Sun, 20 Sep 2026 09:18:52 +0200 Subject: [PATCH 6/9] Recognise the CAD CSG candidates in parallel This speeds up the CSG cascade of the CAD converter and adds a self-test for it. - The per-part recognition loop now runs one leaf solid per forked process, under the --jobs option the mesher already had. - The OuterDisc converts in 12.9 s instead of 26.4 s with --csg auto --exact-surfaces auto, and the stave in 12.8 s instead of 26.4 s. - A worker decides acceptance but does not write shape_*.root. A TFile records its streamer infos in the order its own process first streamed them, so a pool wrote a different byte layout for the same shape. The parent writes those files in part order. - The shapes were the same either way: identical class and bounding box, and 0 of 64000 lattice points disagreed on Contains. - Recognition falls back to serial when this process has already meshed serially, for the same OCCT reason the mesher does. - The new self-test converts the example twice and compares the sidecars, geom.C, the shape file sizes and the tier of every part. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/README.md | 10 +- Detectors/CADSupport/tools/O2_CADtoTGeo.py | 80 ++++++- Detectors/CADSupport/tools/cadsupport/hook.py | 222 ++++++++++++------ 3 files changed, 232 insertions(+), 80 deletions(-) diff --git a/Detectors/CADSupport/README.md b/Detectors/CADSupport/README.md index adbfe431ae473..d19250cd2e2cd 100644 --- a/Detectors/CADSupport/README.md +++ b/Detectors/CADSupport/README.md @@ -63,10 +63,12 @@ and falls through elsewhere. `required` stops with a report if any leaf cannot u 0.1. `--mesh-solid tgeo` emits ROOT's `TGeoTessellated`, which does not implement navigation; use it only for a macro that must load outside O2. -Meshing dominates the conversion of a large model, so the volumes are meshed one per process, -over all cores. `--jobs N` sets how many; `--jobs 1` meshes serially in this process. The -triangles are the same either way. A script that imports this module and asks for several jobs -needs the usual `if __name__ == "__main__":` guard. +Meshing and CSG recognition dominate the conversion of a large model, so both run one part per +process, over all cores. `--jobs N` sets how many; `--jobs 1` runs both serially in this +process. The triangles, the sidecars and `geom.C` are the same either way; the `shape_*.root` +files are written by the parent process in part order, so they do not depend on `--jobs` either. +A script that imports this module and asks for several jobs needs the usual +`if __name__ == "__main__":` guard. The output folder holds: diff --git a/Detectors/CADSupport/tools/O2_CADtoTGeo.py b/Detectors/CADSupport/tools/O2_CADtoTGeo.py index 2477864c3cb7d..0afcb99b50ce2 100644 --- a/Detectors/CADSupport/tools/O2_CADtoTGeo.py +++ b/Detectors/CADSupport/tools/O2_CADtoTGeo.py @@ -2460,6 +2460,74 @@ def convert(mode): return tally.failures +def run_parallel_csg_self_test() -> int: + """Assert that recognising CSG in worker processes gives exactly the serial evidence. + + Returns the number of failures; prints one line per check. + """ + import subprocess + import tempfile + + tally = _Checks() + report = tally.report + + print("\nParallel CSG recognition: the same evidence as serial recognition") + + step = _Path(__file__).resolve().parent.parent / "examples" / "as1-oc-214.stp" + if not step.exists(): + report(False, "the example STEP file is there", f"missing: {step}") + return tally.failures + + outs = {} + # Parallel first, for the same reason the mesher runs it first: a process that has meshed + # serially falls back to serial recognition by design. + with tempfile.TemporaryDirectory() as root: + for jobs in (2, 1): + out = _Path(root) / f"jobs{jobs}" + out.mkdir() + res = subprocess.run([sys.executable, str(_Path(__file__).resolve()), str(step), + "--output-folder", str(out), "--csg", "auto", + "--jobs", str(jobs)], capture_output=True, text=True) + if res.returncode != 0: + report(False, f"the --jobs {jobs} conversion succeeds", + (res.stdout + res.stderr).strip().splitlines()[-1][:160]) + return tally.failures + outs[jobs] = out + + names = {jobs: sorted(f.name for f in outs[jobs].iterdir()) for jobs in outs} + report(names[1] == names[2] and len(names[1]) > 1, + "both runs write the same set of files", + f"{len(names[1])} vs {len(names[2])}") + + # geom.C and the report name their own output folder, which differs per run. + def read(jobs, name): + data = (outs[jobs] / name).read_bytes() + return data.replace(str(outs[jobs]).encode(), b"OUT") + + # csg_*.json is the recognition evidence, and shape_*.root is built from it. The .root + # files carry a per-file UUID and creation time, so they are compared by size. + differing = [n for n in names[1] if not n.endswith(".root") + and read(1, n) != read(2, n)] + report(not differing, "every sidecar, and geom.C, is byte-identical", + f"{len(differing)} differ: {differing[:3]}") + + sizes = {jobs: {n: (outs[jobs] / n).stat().st_size + for n in names[jobs] if n.endswith(".root")} for jobs in outs} + report(sizes[1] == sizes[2], "and every CSG shape file has the serial size", + f"{sum(1 for n in sizes[1] if sizes[1][n] != sizes[2].get(n))} differ") + + rep = {jobs: json.loads(read(jobs, "csg_report.json")) for jobs in outs} + tiers = {jobs: {row["part"]: row["representation"] for row in rep[jobs]["parts"]} + for jobs in rep} + report(tiers[1] == tiers[2] and len(tiers[1]) > 1, + "and the CSG report names the same tier for every part", + f"{sum(1 for k in tiers[1] if tiers[1][k] != tiers[2].get(k))} of " + f"{len(tiers[1])} part(s) differ") + + print(f"\n{tally.checks} checks, {tally.failures} failure(s)") + return tally.failures + + def run_parallel_mesh_self_test() -> int: """Assert that meshing in worker processes gives exactly the serial triangles. @@ -4644,9 +4712,14 @@ def emit_root_macro( print(f" decomposition timeout raised: {_decomp.TIMEOUT_S} -> " f"{decompose_timeout} s") _decomp.TIMEOUT_S = decompose_timeout + # Forking a process that has already meshed in-process deadlocks in OCCT's threads, + # exactly as it does for the mesher, so that fallback decides this phase too. + csg_jobs = 1 if _meshed_in_process else jobs + if jobs > 1 and csg_jobs == 1: + print(" [WARN] this process has already meshed serially: recognising CSG serially.") csg_files, flat_files, csg_records = hook.recognise_and_emit( def_shapes, def_names, scale_to_cm, out_folder, sanitize_filename, mode=csg_mode, - scaled=scaled_shapes) + scaled=scaled_shapes, jobs=csg_jobs, progress=_print_progress) csg_report_path = _Path(csg_report) if csg_report else (out_folder / "csg_report.json") # The lid -> sidecar mapping lets write_report compute tessellation exactness. csg_report_data = hook.write_report(csg_records, csg_report_path, dict(surface_files), @@ -4951,8 +5024,8 @@ def main(): ap.add_argument("--print-tree", action="store_true", help="Just prints the geometry tree") ap.add_argument("--mesh-prec", type=float, default=0.1, help="meshing precision. lower --> slower") ap.add_argument("--jobs", "-j", type=int, default=os.cpu_count() or 1, - help="Processes that mesh the volumes with --mesh (default: all cores); 1 meshes " - "serially in this process") + help="Processes that mesh the volumes with --mesh and recognise them with " + "--csg (default: all cores); 1 runs both serially in this process") ap.add_argument("--in-field", nargs="?", const="2,10", default=None, metavar="IFIELD,FIELDM", help="Treat this module as sitting in the magnetic field: write the eight Geant " "medium parameters, with ifield and fieldm taken from the live field. " @@ -5009,6 +5082,7 @@ def main(): + run_name_filter_self_test() + run_missing_root_self_test() + run_parallel_mesh_self_test() + + run_parallel_csg_self_test() + run_in_field_media_self_test() + run_bom_token_self_test()) else 0) if args.step is None: diff --git a/Detectors/CADSupport/tools/cadsupport/hook.py b/Detectors/CADSupport/tools/cadsupport/hook.py index 762269a83e2bc..310ffdfaec949 100644 --- a/Detectors/CADSupport/tools/cadsupport/hook.py +++ b/Detectors/CADSupport/tools/cadsupport/hook.py @@ -19,7 +19,9 @@ """ import json +import multiprocessing import sys +import time from pathlib import Path from cadsupport import emit, planar, primitives as prim, recognise # noqa: E402 @@ -55,90 +57,164 @@ def scaled_to_cm(shape, scale_to_cm): return BRepBuilderAPI_Transform(shape, trsf, True).Shape() +def _recognise_one_part(lid, shape, display, scale_to_cm, out_folder, sanitize_filename, + band_factor, scaled_solid, root_available, emit_shape=True): + """Recognise one leaf solid and write its evidence and shape files; returns its record. + + The single place a part is processed: the serial path and each worker process call this, so + parallel and serial runs cannot diverge. With `emit_shape` false the acceptance is decided + here but `shape_*.root` is left to the caller, because a TFile records its streamer infos in + the order its process first streamed them and a pool of workers would write a different byte + layout for the same shape. + """ + volname = sanitize_filename(display) if display else "vol" + suffix = f"{volname}_{sanitize_filename(lid)}" + solid = scaled_solid if scaled_solid is not None else scaled_to_cm(shape, scale_to_cm) + cache = {} + record = emit.process_solid(solid, suffix, band_factor=band_factor, cache=cache) + record["lid"] = lid + record["volume"] = display + # The placement is derived from the description alone (no ROOT needed), so the deferred + # `--from-json` path and this one cannot disagree about it. None means identity. + record["placement"] = (prim.placement_for_candidate(record["candidate"]) + if record["candidate"] else None) + (out_folder / f"csg_{suffix}.json").write_text(json.dumps( + {"part": suffix, "lid": lid, "candidate": record["candidate"], + "acceptance": record["acceptance"], "recogniser": record["recogniser"], + "placement": record["placement"]}, indent=1)) + if record["accepted"]: + is_flat = record["candidate"]["op"] == "flatCells" + if root_available: + # Built once and checked before any file is written. + built = prim.build_root(record["candidate"], "shape") + record["twinParity"] = emit.twin_parity(built[0]) + record["bboxRootVsOcctCm"] = emit.crosscheck_bbox( + record["candidate"], occ_shape=recognise.realised_for(cache, record["candidate"]), + built=built) + record["containsCrosscheck"] = emit.crosscheck_contains( + record["candidate"], solid, built=built) + # Either twin sampling refuses the part: a cell reaches past its declared box. + parity = record["twinParity"] + cross_twin = (record["containsCrosscheck"] or {}).get("twinDisagreements") + if parity is not None and parity["disagreements"]: + record["accepted"] = False + record["reason"] = emit.twin_decline_reason(parity) + elif cross_twin: + record["accepted"] = False + record["reason"] = emit.twin_decline_reason( + {"disagreements": cross_twin, + "points": record["containsCrosscheck"]["points"]}) + if not record["accepted"]: + record["shape"] = None + record["flatSidecar"] = None + else: + if is_flat: + # Written only here: a deferred part must not advertise a sidecar. + record["flatSidecar"] = write_flat_sidecar( + record["candidate"], out_folder, suffix) + target = (out_folder / f"shape_{suffix}.root").resolve() + record["shape"] = str(target) + if emit_shape: + emit.write_shape_object(built[0], built[1], target) + else: + record["shapePending"] = True + else: + record["shape"] = None + record["shapeDeferred"] = True + # Name the real cause: the environment, not the geometry. + record["reason"] = ("csg deferred: ROOT unavailable in this interpreter; the " + f"accepted candidate is in csg_{suffix}.json -- run " + "`python3 -m cadsupport.emit --from-json ` from the directory holding the " + "cadsupport package to complete it") + return record + + +# Set in the parent before the pool forks; each worker inherits it along with the live shapes. +_PART_ARGS: tuple = () + + +def _csg_part_worker(item): + """Recognise one part in a forked worker, which inherits the parent's shapes.""" + i_part, lid = item + (def_shapes, def_names, scale_to_cm, out_folder, sanitize_filename, + band_factor, scaled, root_available) = _PART_ARGS + display = def_names.get(lid, "") + scaled_solid = scaled[lid] if scaled and lid in scaled else None + return i_part, _recognise_one_part(lid, def_shapes[lid], display, scale_to_cm, out_folder, + sanitize_filename, band_factor, scaled_solid, + root_available, emit_shape=False) + + def recognise_and_emit(def_shapes, def_names, scale_to_cm, out_folder, sanitize_filename, - mode="auto", band_factor=1.0, verbose=True, scaled=None): + mode="auto", band_factor=1.0, verbose=True, scaled=None, + jobs=1, progress=None): """Recognise every leaf solid; emit what both acceptance tests admit. Returns `(csg_files, flat_files, records)`: lid -> `shape_*.root`, lid -> `flatcsg_*.bin` for `O2FlatCSG` parts (a part is in exactly one map), and the per-part evidence. `scaled` maps a - lid to the cm copy the caller already made. + lid to the cm copy the caller already made. With `jobs` > 1 the parts are recognised one per + forked process; `progress` is then called as `progress(label, done, total, t0)` instead of the + per-part evidence lines, which would interleave. """ out_folder = Path(out_folder) root_available = have_root() csg_files = {} flat_files = {} - records = [] - n_parts = len(def_shapes) - for i_part, (lid, shape) in enumerate(def_shapes.items()): - display = def_names.get(lid, "") - if verbose: - print(f" [{i_part + 1}/{n_parts}]", end=" ", flush=True) - volname = sanitize_filename(display) if display else "vol" - suffix = f"{volname}_{sanitize_filename(lid)}" - solid = scaled[lid] if scaled and lid in scaled else scaled_to_cm(shape, scale_to_cm) - cache = {} - record = emit.process_solid(solid, suffix, band_factor=band_factor, cache=cache) - record["lid"] = lid - record["volume"] = display - # The placement is derived from the description alone (no ROOT needed), so the deferred - # `--from-json` path and this one cannot disagree about it. None means identity. - record["placement"] = (prim.placement_for_candidate(record["candidate"]) - if record["candidate"] else None) - (out_folder / f"csg_{suffix}.json").write_text(json.dumps( - {"part": suffix, "lid": lid, "candidate": record["candidate"], - "acceptance": record["acceptance"], "recogniser": record["recogniser"], - "placement": record["placement"]}, indent=1)) - if record["accepted"]: - is_flat = record["candidate"]["op"] == "flatCells" - if root_available: - # Built once and checked before any file is written. - built = prim.build_root(record["candidate"], "shape") - record["twinParity"] = emit.twin_parity(built[0]) - record["bboxRootVsOcctCm"] = emit.crosscheck_bbox( - record["candidate"], occ_shape=recognise.realised_for(cache, record["candidate"]), - built=built) - record["containsCrosscheck"] = emit.crosscheck_contains( - record["candidate"], solid, built=built) - # Either twin sampling refuses the part: a cell reaches past its declared box. - parity = record["twinParity"] - cross_twin = (record["containsCrosscheck"] or {}).get("twinDisagreements") - if parity is not None and parity["disagreements"]: - record["accepted"] = False - record["reason"] = emit.twin_decline_reason(parity) - elif cross_twin: - record["accepted"] = False - record["reason"] = emit.twin_decline_reason( - {"disagreements": cross_twin, - "points": record["containsCrosscheck"]["points"]}) - if not record["accepted"]: - record["shape"] = None - record["flatSidecar"] = None - else: - if is_flat: - # Written only here: a deferred part must not advertise a sidecar. - record["flatSidecar"] = write_flat_sidecar( - record["candidate"], out_folder, suffix) - target = (out_folder / f"shape_{suffix}.root").resolve() - emit.write_shape_object(built[0], built[1], target) - record["shape"] = str(target) - if is_flat: - flat_files[lid] = record["flatSidecar"] - else: - csg_files[lid] = str(target) + lids = list(def_shapes.keys()) + n_parts = len(lids) + records = [None] * n_parts + global _PART_ARGS + + jobs = max(1, min(jobs, n_parts)) + if jobs > 1 and "fork" not in multiprocessing.get_all_start_methods(): + print(" [WARN] this platform cannot fork: recognising CSG serially in one process.") + jobs = 1 + + if jobs > 1: + t0 = time.time() + label = f"Recognising CSG in {n_parts} leaf solid(s) in {jobs} processes" + _PART_ARGS = (def_shapes, def_names, scale_to_cm, out_folder, sanitize_filename, + band_factor, scaled, root_available) + with multiprocessing.get_context("fork").Pool(jobs) as pool: + for done, (i_part, record) in enumerate( + pool.imap_unordered(_csg_part_worker, list(enumerate(lids))), start=1): + records[i_part] = record + if progress is not None: + progress(label, done, n_parts, t0) + else: + for i_part, lid in enumerate(lids): + display = def_names.get(lid, "") + if verbose: + print(f" [{i_part + 1}/{n_parts}]", end=" ", flush=True) + scaled_solid = scaled[lid] if scaled and lid in scaled else None + record = _recognise_one_part(lid, def_shapes[lid], display, scale_to_cm, out_folder, + sanitize_filename, band_factor, scaled_solid, + root_available) + records[i_part] = record + if verbose: + emit._print_record(record) + if record.get("shapeDeferred"): + print(f" [WARN] {record['volume'] or lid}: accepted as CSG but NOT emitted -- " + "ROOT unavailable; geom.C will dispatch this part one tier down") + + # The accepted shapes the workers deferred are written here, in one process and in part + # order, so the file bytes do not depend on --jobs. Rebuilding from the candidate is what + # the deferred `--from-json` path does too, and it costs milliseconds. + pending = [r for r in records if r.get("shapePending")] + for record in pending: + built = prim.build_root(record["candidate"], "shape") + emit.write_shape_object(built[0], built[1], Path(record["shape"])) + del record["shapePending"] + if pending and verbose: + print(f" wrote {len(pending)} CSG shape file(s) in the parent process") + + # The tier maps are read back off the records, so both paths fill them the same way. + for record in records: + if record["accepted"] and record.get("shape"): + if record["candidate"]["op"] == "flatCells": + flat_files[record["lid"]] = record["flatSidecar"] else: - record["shape"] = None - record["shapeDeferred"] = True - # Name the real cause: the environment, not the geometry. - record["reason"] = ("csg deferred: ROOT unavailable in this interpreter; the " - f"accepted candidate is in csg_{suffix}.json -- run " - "`python3 -m cadsupport.emit --from-json ` from the directory holding the " - "cadsupport package to complete it") - records.append(record) - if verbose: - emit._print_record(record) - if record.get("shapeDeferred"): - print(f" [WARN] {display or lid}: accepted as CSG but NOT emitted -- " - "ROOT unavailable; geom.C will dispatch this part one tier down") + csg_files[record["lid"]] = record["shape"] n_csg = sum(1 for r in records if r["accepted"]) if verbose: From 0fa8d017bc4fc3c76d7e290c9fc6a7f8e2ac522d Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Sun, 20 Sep 2026 09:19:11 +0200 Subject: [PATCH 7/9] Say what loading a large converted geometry costs This records where the time goes when a big geom.C is loaded, so the overlap check is not mistaken for the load. - On the 140-part OuterDisc: parse 1.3 s, build 15.7 s, CloseGeometry 0.0 s, Export 11.7 s. - CheckOverlaps takes 513.8 s on the same model and is off by default. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Detectors/CADSupport/README.md b/Detectors/CADSupport/README.md index d19250cd2e2cd..ccd0e60443eb8 100644 --- a/Detectors/CADSupport/README.md +++ b/Detectors/CADSupport/README.md @@ -89,6 +89,11 @@ for standalone use: (cd cad_out/excavator && root -l -b -q -e '.L geom.C' -e 'build_and_export("geom.root", true, true);') # also CheckOverlaps ``` +`checkOverlaps` costs far more than the load itself on a large model, so leave it off unless +you are looking for overlaps. Measured on the 140-part OuterDisc, 5.4M triangles: 1.3 s to +parse the macro, 15.7 s to build it (the payloads and their BVHs), 0.0 s to close the +geometry, 11.7 s to export, and 513.8 s for `CheckOverlaps`. + Other conversion options: | option | meaning | From 5703257d5c487cb56517131ee44105ceba91475a Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Thu, 24 Sep 2026 20:27:23 +0200 Subject: [PATCH 8/9] Count the surface tier by what the cascade emits This fixes a misleading line printed when a conversion uses both --csg and --exact-surfaces. - The count came from every part exact-surface extraction succeeded on, not from the parts geom.C carries as surface solids, so it overstated the tier by the parts CSG took. - On the OTR stave it said 137/137 where the macro holds 63 LoadSurfaces calls and the tier table says 63. - The parts that were extracted and then outranked by CSG are now reported separately. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/tools/O2_CADtoTGeo.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Detectors/CADSupport/tools/O2_CADtoTGeo.py b/Detectors/CADSupport/tools/O2_CADtoTGeo.py index 0afcb99b50ce2..80a10baa3b18c 100644 --- a/Detectors/CADSupport/tools/O2_CADtoTGeo.py +++ b/Detectors/CADSupport/tools/O2_CADtoTGeo.py @@ -4809,9 +4809,15 @@ def emit_root_macro( materials_cpp, medium_var_map = emit_materials_cpp(used_materials, in_field=in_field) # --- emit C++ macro --- - if surface_files: - print(f"Emitting {len(surface_files)}/{len(logical_volumes)} logical volumes as exact O2BVHSurfaceSolid " - f"(macro requires the ALICE O2 environment)") + # What the cascade actually hands to each tier, not what its extraction managed: a part + # recognised as CSG ships as CSG even though its surface sidecar was written too. + surface_lids = [lid for lid in surface_files + if lid not in flat_files and lid not in csg_files] + if surface_lids: + outranked = len(surface_files) - len(surface_lids) + note = f", {outranked} more extracted but carried as CSG" if outranked else "" + print(f"Emitting {len(surface_lids)}/{len(logical_volumes)} logical volumes as exact " + f"O2BVHSurfaceSolid{note} (macro requires the ALICE O2 environment)") # The tessellated fallback's shape class; "tgeo" navigates as bounding boxes. if mesh_solid not in ("o2", "tgeo"): From 143b61d6a14e66f2343b394b8ac8d075c734d9d6 Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Thu, 24 Sep 2026 20:27:23 +0200 Subject: [PATCH 9/9] Say that a converted geometry's payload paths are absolute This corrects the README on whether a converted folder can be moved. - The macro was documented as resolving its payloads relative to its own location. - It names them by absolute path, so the folder cannot be moved or handed on as it stands. - The sed that rewrites the prefix is given instead. Co-Authored-By: Claude Opus 5 --- Detectors/CADSupport/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Detectors/CADSupport/README.md b/Detectors/CADSupport/README.md index ccd0e60443eb8..35d07e203c8b7 100644 --- a/Detectors/CADSupport/README.md +++ b/Detectors/CADSupport/README.md @@ -78,7 +78,13 @@ The output folder holds: - `brep_*.brep` (with `--dump-brep`); - `surface_report.json` (with `--surface-report PATH`). -The macro loads its payloads relative to its own location, so move the folder as a whole. +The macro names its payloads by absolute path, so a converted folder cannot be moved or handed +on as it stands. Rewrite the prefix before shipping one, and run from the directory the payload +folder sits in: + +```bash +sed -i "s|/|payloads/|g" geom.C +``` `geom.C` exports `get_builder_hook_unchecked()`, which `o2-sim` calls, and `build_and_export(const char* out_root = "geom.root", bool check = true, bool checkOverlaps = false)`