Compare commits

..

2 Commits

Author SHA1 Message Date
Petru Conduraru d053530743 ifcgeom: butt-join near-tangent sweep corners instead of hanging #8400
A second, independent trigger for the MakePipeShell hang from #8400: a kink
angle at a directrix joint in roughly the 0.57-5 degree band produces two
nearly-coaxial pipe surfaces at the corner, and BRepFill_TrimShellCorner's
projection (ProjLib_CompProjectedCurve::Init) never converges. This happens
regardless of chord length, so the sub-radius chord collapse already fixed in
the directrix-hang commit does not prevent it.

BRepOffsetAPI_MakePipeShell::SetTransitionMode always leaves Angmin (the kink
angle below which OCCT treats a joint as a safe butt-join rather than routing
it through the corner trim) at its hardcoded default of 1e-2 rad, and does not
expose a way to change it. Confirmed by pulling the OCCT 7.9.2 source: the
wrapper is a 1:1 forwarding layer over BRepFill_PipeShell (same Add/Set/Build/
FirstShape/LastShape/Shape semantics), and its own Build() discards the
Message_ProgressRange argument it declares, so no progress/cancellation signal
reaches this path either way. Switch to BRepFill_PipeShell directly and raise
Angmin to 0.1 rad (~5.7 deg) so the dangerous band takes the butt-join path.

Verified against the reporter's minimal repro (IfcCableCarrierSegment, 101-
segment composite curve directrix, disk radius 1.6cm, kink angles 0.15-2 deg):
clean v0.8.0 and v0.8.0 with only the prior directrix-collapse fix both still
hang (30s+ timeout, matches the reported bug); with this change it converts in
~1s (4781 verts, 5684 faces, no validation errors). Independently bisected the
Angmin threshold on this repro: 0.02 rad still hangs, 0.06 rad fails cleanly
("BRep_API: command not done", no hang), 0.1 rad converges.

Regression-checked all 50 test/input fixtures containing IfcSweptDiskSolid
(the ones parseable under this build's IFC4-only schema config produce
identical exit codes and, where conversion succeeds outright, identical
output, before and after) and a synthetic 30 degree corner (well above the
new threshold): byte-identical output, confirming ordinary corners are
unaffected. One real fixture (987--cableSegment) has joints that fall in the
newly-affected band and now converts with fewer vertices/faces at those
corners (butt-join instead of trim); both variants pass --validate with no
errors, and nearest-neighbour vertex deviation between the two outputs peaks
under 1mm on a 3.9mm-radius cable, i.e. a real but small and bounded fidelity
trade at corners that previously could hang forever.

Generated with the assistance of an AI coding tool.
2026-07-19 07:13:40 +03:00
Petru Conduraru 779f6e0922 ifcgeom: collapse degenerate directrix segments to avoid a MakePipeShell hang #8400
IfcConvert hangs forever on a swept-disk solid whose IfcCompositeCurve
directrix contains segments far shorter than the disk radius. Where a segment
is shorter than OCCT's corner-trim region (which scales with the pipe radius),
BRepFill_TrimShellCorner must trim two nearly-coincident pipe surfaces and
ProjLib_CompProjectedCurve's Newton walk never converges, so
BRepOffsetAPI_MakePipeShell::Build spins indefinitely. That OCCT path checks no
UserBreak/progress range, so it cannot be interrupted from the outside.

Sanitize the directrix before the sweep: for open, purely linear directrix
wires, collapse any segment shorter than max(radius/10, 10*precision) to its
midpoint (each vertex moves below the disk radius, in a region the disk cannot
resolve), pinning the endpoints and leaving non-linear or near-closed wires
untouched. Emit GEO 259 via the kernel logger_ when a collapse occurs.

Verified with a local OpenCascade build (OCC 7.9.2): the reported sample hangs
before (killed after 90 s, ~84 s CPU) and terminates after in ~285 ms with a
valid tube (2956 verts), logging "Collapsed 16 degenerate directrix segment(s)"
(matching the reporter's count). Clean swept solids trigger no collapse and are
unchanged.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:53:25 +03:00
4 changed files with 123 additions and 76 deletions
@@ -2343,9 +2343,7 @@ class ActivateDrawingBase(tool.Ifc.Operator):
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
)
drawing: bpy.props.IntProperty()
@@ -2367,25 +2365,16 @@ class ActivateDrawingBase(tool.Ifc.Operator):
default=False,
options={"SKIP_SAVE"},
)
include_annotations_in_selection: bpy.props.BoolProperty(
name="Include Annotations In Selection",
description="Also select the loaded annotation objects, not just the drawing cameras.",
default=False,
options={"SKIP_SAVE"},
)
if TYPE_CHECKING:
drawing: int
should_view_from_camera: bool
use_quick_preview: bool
load_selected_annotations: bool
include_annotations_in_selection: bool
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
if event.type == "LEFTMOUSE" and event.shift and event.ctrl:
self.load_selected_annotations = True
if event.alt:
self.include_annotations_in_selection = True
return self.execute(context)
if event.type == "LEFTMOUSE" and event.alt:
self.should_view_from_camera = False
@@ -2400,34 +2389,15 @@ class ActivateDrawingBase(tool.Ifc.Operator):
bpy.ops.bim.load_drawings()
if self.load_selected_annotations:
objs_to_select = []
active_camera = None
for d in props.drawings:
if not (d.is_drawing and d.is_selected):
continue
selected_drawing = tool.Ifc.get().by_id(d.ifc_definition_id)
# Importing the camera (if missing) ensures the drawing's
# collection exists so the annotations get collected into it.
if not (camera := tool.Ifc.get_object(selected_drawing)):
camera = tool.Drawing.import_drawing(selected_drawing)
group = tool.Drawing.get_drawing_group(selected_drawing)
tool.Drawing.import_annotations_in_group(group)
if active_camera is None:
active_camera = camera
objs_to_select.append(camera)
if self.include_annotations_in_selection:
for element in tool.Drawing.get_group_elements(group) or []:
if element.is_a("IfcAnnotation") and element.ObjectType != "DRAWING":
if annotation_obj := tool.Ifc.get_object(element):
objs_to_select.append(annotation_obj)
# Select the checked drawings' objects, with the first drawing's camera as active.
bpy.ops.object.select_all(action="DESELECT")
for obj in objs_to_select:
obj.select_set(True)
if active_camera is not None:
context.view_layer.objects.active = active_camera
if not tool.Ifc.get_object(selected_drawing):
tool.Drawing.import_drawing(selected_drawing)
tool.Drawing.import_annotations_in_group(tool.Drawing.get_drawing_group(selected_drawing))
return {"FINISHED"}
drawing = tool.Ifc.get().by_id(self.drawing)
@@ -2516,9 +2486,7 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
)
+4 -17
View File
@@ -2315,23 +2315,10 @@ class Blender(bonsai.core.tool.Blender):
bpy.ops.object.hide_view_clear(select=False)
bpy.ops.object.select_all(action="DESELECT")
# Objects with "disable selection" (hide_select) enabled cannot be
# selected, so they would be treated as unselected and hidden by
# hide_view_set(unselected=True) below - see #7681. Temporarily clear
# hide_select so they can be selected (and thus kept visible), then
# restore it afterwards.
unselectable = []
try:
for obj in objs:
if obj.hide_select:
obj.hide_select = False
unselectable.append(obj)
obj.select_set(True)
with bpy.context.temp_override(**override):
bpy.ops.object.hide_view_set(unselected=True)
finally:
for obj in unselectable:
obj.hide_select = True
for obj in objs:
obj.select_set(True)
with bpy.context.temp_override(**override):
bpy.ops.object.hide_view_set(unselected=True)
bpy.ops.object.select_all(action="DESELECT")
for name in previously_selected:
@@ -22,6 +22,7 @@
#include "wire_utils.h"
#include <BRepOffsetAPI_MakePipeShell.hxx>
#include <BRepFill_PipeShell.hxx>
#include <Geom_Plane.hxx>
#include <ShapeAnalysis_Surface.hxx>
#include <BRepBuilderAPI_Transform.hxx>
@@ -30,6 +31,10 @@
#include <TopExp.hxx>
#include <Geom_Circle.hxx>
#include <BRepBuilderAPI_MakeSolid.hxx>
#include <BRepTools_WireExplorer.hxx>
#include <BRepBuilderAPI_MakePolygon.hxx>
#include <Geom_Line.hxx>
#include <Geom_TrimmedCurve.hxx>
using namespace ifcopenshell::geometry;
using namespace ifcopenshell::geometry::kernels;
@@ -81,6 +86,82 @@ namespace {
}
return false;
}
// Collapses ultra-short straight segments in an open, purely linear directrix.
//
// Revit MEP flexible-conduit exports discretize a spline directrix with chords
// far shorter than the swept disk radius (down to ~1/50 of the radius). Where a
// directrix segment is shorter than the corner trim region (which scales with
// the pipe radius), BRepFill_TrimShellCorner has to trim two nearly-coincident
// pipe surfaces; ProjLib_CompProjectedCurve's Newton walk then fails to converge
// and BRepOffsetAPI_MakePipeShell::Build() spins forever. No UserBreak/progress
// range is checked anywhere in that OCCT path, so the hang cannot be interrupted
// from the outside (see IfcOpenShell #8400).
//
// Interior vertices bordering a sub-threshold segment collapse to that segment's
// midpoint (each vertex moves at most min_len/2, i.e. below the disk radius, in a
// region the disk cannot resolve anyway); the directrix endpoints are pinned.
// Wires containing any non-linear segment, or (nearly) closed wires, are left
// untouched, so ordinary directrices are unaffected.
bool collapse_short_directrix_edges(TopoDS_Wire& w, double min_len, int& n_collapsed) {
std::vector<gp_Pnt> pts;
for (BRepTools_WireExplorer exp(w); exp.More(); exp.Next()) {
const TopoDS_Edge& e = exp.Current();
double u0, u1;
Handle(Geom_Curve) crv = BRep_Tool::Curve(e, u0, u1);
if (crv.IsNull()) {
return false;
}
if (crv->DynamicType() == STANDARD_TYPE(Geom_TrimmedCurve)) {
crv = Handle(Geom_TrimmedCurve)::DownCast(crv)->BasisCurve();
}
if (crv->DynamicType() != STANDARD_TYPE(Geom_Line)) {
return false;
}
if (pts.empty()) {
pts.push_back(BRep_Tool::Pnt(TopExp::FirstVertex(e, Standard_True)));
}
pts.push_back(BRep_Tool::Pnt(TopExp::LastVertex(e, Standard_True)));
}
// Need at least two segments, and keep genuinely closed loops out of scope.
if (pts.size() < 3 || pts.front().Distance(pts.back()) < min_len) {
return false;
}
bool changed = true;
while (changed) {
changed = false;
for (size_t i = 0; i + 1 < pts.size() && pts.size() > 2; ++i) {
if (pts[i].Distance(pts[i + 1]) >= min_len) {
continue;
}
if (i == 0) {
// keep the directrix start point fixed
pts.erase(pts.begin() + 1);
} else if (i + 2 == pts.size()) {
// keep the directrix end point fixed
pts.erase(pts.begin() + i);
} else {
pts[i] = gp_Pnt((pts[i].XYZ() + pts[i + 1].XYZ()) / 2.);
pts.erase(pts.begin() + i + 1);
}
++n_collapsed;
changed = true;
break;
}
}
if (n_collapsed == 0) {
return false;
}
BRepBuilderAPI_MakePolygon mp;
for (const auto& p : pts) {
mp.Add(p);
}
if (!mp.IsDone()) {
return false;
}
w = mp.Wire();
return true;
}
}
bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, TopoDS_Shape& result) {
@@ -160,6 +241,26 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo
gp_Trsf directrix;
TopoDS_Wire wire = boost::get<TopoDS_Wire>(w);
{
// Sanitize degenerate directrix segments that would make OCCT's
// MakePipeShell corner-trimming loop forever (see #8400). The threshold
// is kept well below the swept disk radius, so only sub-feature-scale
// noise (which the disk cannot resolve) is collapsed; it is floored at a
// small multiple of model precision for the tiny/degenerate radius case.
double radius = 0.;
if (scs->basis && scs->basis->kind() == taxonomy::FACE) {
auto& loops = std::static_pointer_cast<taxonomy::face>(scs->basis)->children;
if (!loops.empty() && loops[0]->children.size() == 1 && loops[0]->children[0]->basis && loops[0]->children[0]->basis->kind() == taxonomy::CIRCLE) {
radius = std::static_pointer_cast<taxonomy::circle>(loops[0]->children[0]->basis)->radius;
}
}
const double min_len = (std::max)(radius / 10., settings_.get<settings::Precision>().get() * 10.);
int n_collapsed = 0;
if (collapse_short_directrix_edges(wire, min_len, n_collapsed)) {
logger_.Message(Logger::LOG_WARNING, "GEO", 259, "Collapsed " + std::to_string(n_collapsed) + " degenerate directrix segment(s) shorter than a tenth of the swept disk radius", scs->instance);
}
}
const bool is_plane = surface && surface->DynamicType() == STANDARD_TYPE(Geom_Plane);
gp_Pln pln;
@@ -255,20 +356,22 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo
continue;
}
BRepOffsetAPI_MakePipeShell builder(wire);
builder.Add(section);
builder.SetTransitionMode(contains_circular_segments(wire) && wire_is_c1_continuous(wire, 1.e-2) ? BRepBuilderAPI_Transformed : BRepBuilderAPI_RightCorner);
// Raise Angmin so near-tangent corners butt-join instead of hanging in
// BRepFill_TrimShellCorner (#8400); not exposed via BRepOffsetAPI_MakePipeShell.
const double transition_angmin = 0.1; // rad, ~5.7 deg
Handle(BRepFill_PipeShell) builder = new BRepFill_PipeShell(wire);
builder->Add(section);
builder->SetTransition(contains_circular_segments(wire) && wire_is_c1_continuous(wire, 1.e-2) ? BRepFill_Modified : BRepFill_Right, transition_angmin);
if (directrix_on_plane) {
builder.SetMode(pln.Axis().Direction());
builder->Set(pln.Axis().Direction());
} else if (!is_plane) {
builder.SetMode(surface_face);
builder->Set(surface_face);
}
builder.Build();
if (!builder.IsDone()) {
if (!builder->Build()) {
return false;
}
auto w0 = TopoDS::Wire(builder.FirstShape());
auto w1 = TopoDS::Wire(builder.LastShape());
auto w0 = TopoDS::Wire(builder->FirstShape());
auto w1 = TopoDS::Wire(builder->LastShape());
if (mf0) {
mf0->Add(w0);
mf1->Add(w1);
@@ -282,7 +385,7 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo
mf1.reset(new BRepBuilderAPI_MakeFace(f1));
}
for (TopExp_Explorer exp2(builder.Shape(), TopAbs_FACE); exp2.More(); exp2.Next()) {
for (TopExp_Explorer exp2(builder->Shape(), TopAbs_FACE); exp2.More(); exp2.Next()) {
BB.Add(comp, exp2.Current());
}
}
@@ -72,8 +72,6 @@ Filtering is typically used to select any IFC element or type.
"``IfcPump, location=""Level 3""``", "Locations bubble up the hierarchy. So if a pump is in a space and that space is on Level 3, then you can say ""all pumps on level 3"" which will include that pump in the space."
"``IfcElement, query:""parent.Name""=""My Site""``", "Only elements *immediately* under ""My Site"" in the spatial hierarchy. Unlike the ``location`` and ``parent`` filters, which both match at any depth, the ``parent`` query key resolves the direct parent only, so nested storeys (and their contents) are excluded."
The filter elements syntax works by specifying one or more groups of filters
separated by a ``+`` character. Each filter group will return a set of filtered
elements, and these are unioned together.
@@ -113,15 +111,6 @@ will search through all IfcTypeProducts and IfcProducts in the IFC project.
"Parent", "Filter", "``parent{{=}}{{value}}``", "``parent=Foo`` specifies the criteria that elements must be a direct or indirect child in the spatial hierarchy to an element with a ``Name`` attribute with a value of ``Foo``."
"Query", "Filter", "``query:{{keys}}{{=}}{{value}}``", "``query:types.count=0`` specifies the criteria that elements must have zero type occurrences. The query keys corresponds to the syntax used in the `Getting element values`_ section"
.. note::
The ``location`` and ``parent`` filters both match at **any depth** in the
spatial hierarchy. To match only elements *immediately* contained in (or
aggregated under) a spatial element, use the ``parent`` query key, which
resolves the direct parent only. For example,
``query:"parent.Name"="My Site"`` selects elements directly under ``My
Site`` but excludes anything nested inside its sub-storeys or spaces.
When you specify a filter with a ``{{=}}`` check, you can choose from one of
the following comparison checks:
@@ -202,7 +191,7 @@ Valid keys are:
"``storey``", "Gets the first IfcBuildingStorey spatial element that an element is contained in."
"``building``", "Gets the first IfcBuilding spatial element that an element is contained in."
"``site``", "Gets the first IfcSite spatial element that an element is contained in."
"``parent``", "Gets the **immediate** parent element in the spatial hierarchy (the direct spatial container, or the direct aggregate/nest/fill/void parent). Combine with ``.Name`` in a query filter to match only immediate children, e.g. ``query:""parent.Name""=""My Site""``."
"``parent``", "Gets the parent element in the spatial hierarchy."
"``classification``", "Gets the element's classification reference(s)"
"``group``", "Gets the element's group(s)"
"``system``", "Gets the element's system(s). This is a subset of group(s)."