port: SVG edge classification (#3668) onto wgpu [worklist #114-120]

Ports the 7-commit v0.8.0 SVG edge-classification feature (f0970b90b +
6 follow-ups) onto wgpu's heavily-diverged serializer. Reconstructed
block-by-block rather than merged, because both sides rewrote
SvgSerializer (v0.8.0 +419, wgpu +778) and git's conflict alignment was
misleading.

Key wgpu adaptations reasoned per block:
- IfcUtil::IfcBaseEntity* (pointer identity) -> express::Base (value),
  incl. as a std::map key in draw_hlr (express::Base has operator<);
  nullptr fallback -> express::Base{}
- boost::optional -> std::optional (css_class, dash_array)
- hlr_calc::result_type pair -> 3-tuple (adds per-edge class label)
- draw_hlr restructured with a group_by_product map: ONE path_object per
  product so multiple class buckets share a group and per-path classes
  survive Bonsai's merge (NOT naive per-item threading, which fragments
  groups -- caught during visual verification)
- settings wired into wgpu's apply_settings() (ctor-called), NOT the
  feature's added ready() call which wgpu already solved differently
- logger_.Warning -> logger().warning in write(geometry_data)

Verified end-to-end via IfcConvert on a curved-geometry stress scene
(Suzanne/Torus/Sphere/Cube/Cone/...): edges classify into outline/sharp/
crease/boundary/flush with correct per-product grouping and CSS styling.
Requires the model's storey to carry an Elevation and --svg-project to
trigger the HLR projection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-25 20:49:05 +10:00
parent 43fc405b58
commit a341ad29f3
11 changed files with 635 additions and 71 deletions
+376 -44
View File
@@ -244,9 +244,18 @@ void SvgSerializer::apply_settings() {
if (settings().get<SvgElevationRefGuid>().has()) {
setElevationRefGuid(settings().get<SvgElevationRefGuid>().get());
}
// SVG edge classification (issue #3668): geometry settings, read here so the
// ctor's apply_settings() call applies them for all callers (Bonsai/SWIG + IfcConvert).
svg_ridge_angle_min_deg_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgRidgeAngleMinDegrees>().get();
svg_valley_angle_min_deg_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgValleyAngleMinDegrees>().get();
svg_emit_flush_edges_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgEmitFlushEdges>().get();
svg_use_edge_classification_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgUseEdgeClassification>().get();
svg_render_crease_edges_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgRenderCreaseEdges>().get();
svg_render_sharp_edges_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgRenderSharpEdges>().get();
}
void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, std::optional<std::vector<double>> dash_array) {
void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, std::optional<std::vector<double>> dash_array, std::optional<std::string> css_class) {
/* ShapeFix_Wire fix;
Handle(ShapeExtend_WireData) data = new ShapeExtend_WireData;
for (TopExp_Explorer edges(result, TopAbs_EDGE); edges.More(); edges.Next()) {
@@ -492,6 +501,12 @@ void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, std:
if (!path.empty()) {
path.add("\"");
if (css_class) {
path.add(" class=\"");
path.add(*css_class);
path.add("\"");
}
if (dash_array) {
path.add(" stroke-dasharray=\"");
bool first = true;
@@ -935,6 +950,182 @@ namespace {
}
}
namespace {
// SVG edge classification (issue #3668). See edge-classification.md at the repo root for
// the authoritative definition of the five classes and their evaluation order.
enum class edge_style_class { boundary, outline, sharp, crease, flush };
const char* edge_style_class_name(edge_style_class c) {
switch (c) {
case edge_style_class::boundary: return "boundary";
case edge_style_class::outline: return "outline";
case edge_style_class::sharp: return "sharp";
case edge_style_class::crease: return "crease";
default: return "flush";
}
}
// Outward face normal, accounting for face orientation. Only planar faces are supported;
// returns false otherwise (caller should conservatively treat the edge as an outline).
bool face_normal_from_planar_face(const TopoDS_Face& f, gp_Dir& out) {
auto s = BRep_Tool::Surface(f);
if (s->DynamicType() != STANDARD_TYPE(Geom_Plane)) {
return false;
}
auto p = Handle(Geom_Plane)::DownCast(s);
gp_Dir d = p->Axis().Direction();
if (f.Orientation() == TopAbs_REVERSED) {
d.Reverse();
}
out = d;
return true;
}
double clamp_dot(double v) {
if (v < -1.0) return -1.0;
if (v > 1.0) return 1.0;
return v;
}
edge_style_class classify_edge_from_faces(
const TopoDS_Edge& edge,
const NCollection_List<TopoDS_Shape>& faces,
const gp_Dir& projection_direction,
double ridge_angle_min_deg,
double valley_angle_min_deg
) {
std::vector<TopoDS_Face> faces_vec;
for (NCollection_List<TopoDS_Shape>::Iterator it(faces); it.More(); it.Next()) {
const TopoDS_Shape& s = it.Value();
if (s.ShapeType() == TopAbs_FACE) {
faces_vec.push_back(TopoDS::Face(s));
}
}
// Boundary: naked edge, or non-manifold (3+ faces) -- the latter is explicitly out of
// scope for the 5-class scheme (a geometry-health/QA concern), so fall back to the
// same conservative bucket rather than force-fitting it into outline/sharp/crease.
if (faces_vec.size() != 2) {
return edge_style_class::boundary;
}
const TopoDS_Face& f0 = faces_vec[0];
const TopoDS_Face& f1 = faces_vec[1];
gp_Dir n0, n1;
if (!face_normal_from_planar_face(f0, n0) || !face_normal_from_planar_face(f1, n1)) {
// Conservative fallback for non-planar-face edges.
return edge_style_class::outline;
}
// Note the negation: `projection_direction` (as constructed by the caller from the
// drawing plane's axis) points from the scene *towards the camera*, not into the scene.
// A face that's actually front-facing (visible, facing the viewer) has an outward normal
// pointing the same general way as that -- i.e. a *positive* dot product -- so negate
// here to get the more intuitive "front-facing is negative" convention used below.
// Confirmed against this feature's own real-world test scene: the SOUTH ELEVATION
// camera's placement matrix transforms local +Z (what the un-negated projection_direction
// is built from) to world (0, 1, 0), while the camera's actual Blender-convention view
// direction (local -Z) transforms to world (0, -1, 0) -- i.e. exactly opposite.
const double d0 = -projection_direction.Dot(n0);
const double d1 = -projection_direction.Dot(n1);
// Front/back/edge-on classification of each face relative to the view direction, using
// a tolerance band around zero rather than a bare sign comparison. A face at or near
// edge-on to the camera (|d| within the band) is common for regular/symmetric
// tessellations viewed from "nice" angles (icospheres, N-gon cylinder/cone
// approximations) and must count as outline on both its edges, not just the one that
// happens to pair it with a clearly front-facing neighbour.
constexpr double kOutlineDotEps = 1.e-5;
const bool front0 = d0 < -kOutlineDotEps;
const bool back0 = d0 > kOutlineDotEps;
const bool front1 = d1 < -kOutlineDotEps;
const bool back1 = d1 > kOutlineDotEps;
// Outline: silhouette, either a genuine front/back flip, or either face is at/near
// edge-on to the view direction (also covers both faces edge-on at once).
if (!(front0 && front1) && !(back0 && back1)) {
return edge_style_class::outline;
}
// Signed deviation from flat (0 degrees between outward normals = perfectly flat, i.e.
// coplanar faces have identical outward normals). Positive = convex (ridge/sharp),
// negative = concave (valley/crease).
//
// Sign via a position-based (not orientation-based) test: find a vertex of f1 that
// isn't one of the shared edge's own endpoints, and check which side of f0's plane it
// falls on. If it's behind f0's plane (opposite side from f0's outward normal), f1
// curves back towards the solid's interior relative to f0 -- a convex fold, like a box
// corner. This avoids relying on TopoDS_Edge/wire orientation semantics (which proved
// unreliable in practice: an earlier attempt using edge.Orientation() combined with
// cross(n0, n1) gave a self-consistent-looking but wrong sign on real BRep topology --
// verified against known-convex geometry, e.g. every edge of a convex icosphere, where
// that approach misclassified a majority of edges as concave).
double deviation_deg = std::acos(clamp_dot(n0.Dot(n1))) * 180.0 / M_PI;
TopoDS_Vertex ev0, ev1;
TopExp::Vertices(edge, ev0, ev1);
const gp_Pnt edge_p0 = BRep_Tool::Pnt(ev0);
const gp_Pnt edge_p1 = BRep_Tool::Pnt(ev1);
for (TopExp_Explorer vexp(f1, TopAbs_VERTEX); vexp.More(); vexp.Next()) {
const gp_Pnt p = BRep_Tool::Pnt(TopoDS::Vertex(vexp.Current()));
if (p.Distance(edge_p0) > Precision::Confusion() && p.Distance(edge_p1) > Precision::Confusion()) {
const bool convex = gp_Vec(edge_p0, p).Dot(gp_Vec(n0.XYZ())) < 0.0;
if (!convex) {
deviation_deg = -deviation_deg;
}
break;
}
}
// View-relative flip for folds seen from behind through an opening (e.g. the "Rotated
// Box w/Boundary" test object -- a box with one face removed; the 3 interior lines
// visible through the opening read as the *inside* of an ordinary convex box corner,
// which should look like a crease, not a sharp ridge). Two earlier unconditional
// versions of this flip (triggered on plain back0&&back1, with no further gate) were
// tried and reverted -- see edge-classification.md follow-up notes -- because they
// corrupted otherwise-correct classification broadly, manifesting as spurious `crease`
// edges on a fully-convex icosphere test case that has no opening at all.
//
// That corruption wasn't a fundamental inability to distinguish "genuinely seen through
// a hole" from "ordinary far side of closed geometry": bucket membership here is purely
// a post-hoc query key into an already-completed, correct HLR visibility computation,
// so reclassifying an edge can never make a genuinely hidden edge appear or vice versa.
// The real cause is a threshold-crossing artifact: near the silhouette, facet-normal
// noise on regular/symmetric tessellations (icospheres, N-gon cylinder/cone
// approximations) makes some genuinely near-edge-on facets test as "back" under the
// flat-normal-based back0/back1 test even though they're still visible. An unconditional
// negate then took their small, correctly-`flush` solid-relative deviation and re-tested
// it against the *other* threshold -- `ridge_angle_min_deg` (45 degrees by default) and
// `valley_angle_min_deg` (12 degrees by default) are deliberately asymmetric, so a gentle
// ~20 degree convex facet transition that safely sits under the ridge threshold crosses
// well over the much smaller valley threshold once flipped, becoming a spurious `crease`.
//
// Fix: gate the flip so it can only reinterpret a fold that would already be visible
// (sharp or crease) under its own pre-flip threshold -- i.e. only folds sharp/deep
// enough to draw from the front get reinterpreted as the opposite class from behind.
// Gentle tessellation-noise deviations that are correctly `flush` either way never cross
// the asymmetric threshold gap, because they never reach the flip at all. Verified
// against the full test scene: every object's classification is byte-for-byte unchanged
// except "Rotated Box w/Boundary", whose 3 interior lines now correctly read `crease`
// (previously all 4 non-boundary edges read `sharp`).
if (back0 && back1) {
const bool would_show_unflipped =
(deviation_deg >= 0.0) ? (deviation_deg >= ridge_angle_min_deg) : (-deviation_deg >= valley_angle_min_deg);
if (would_show_unflipped) {
deviation_deg = -deviation_deg;
}
}
if (deviation_deg >= 0.0) {
return (deviation_deg >= ridge_angle_min_deg) ? edge_style_class::sharp : edge_style_class::flush;
} else {
return (-deviation_deg >= valley_angle_min_deg) ? edge_style_class::crease : edge_style_class::flush;
}
}
}
void SvgSerializer::write(const geometry_data& data) {
std::vector<section_data> section_heights_storage;
const std::vector<section_data>* section_heights_used = &section_heights_storage;
@@ -1348,6 +1539,92 @@ void SvgSerializer::write(const geometry_data& data) {
}
}
// SVG edge classification (issue #3668): classify *compound_to_hlr's edges (real
// face topology, pre-HLR) into per-class edge-only sub-compounds. The full shape
// is still registered via add()/it->second.add() below, unchanged, for correct
// occlusion; these buckets only affect which class each edge's visible portion is
// later extracted as (see hlr_calc::extract() in SvgSerializer.h).
//
// Gated behind svg_use_edge_classification_ (default false): the whole block must
// be skipped, not just individually suppressed per-edge, so that when disabled
// classified_edge_buckets stays empty for *every* product in the document, not
// just this one. hlr_calc::extract() only takes the classified-buckets branch
// when its shared classified_shapes_ list is non-empty; if even one product added
// classified buckets while others didn't, those others would silently fall back
// to unclassified linework while this one used classification, an inconsistent
// mix. Leaving classified_edge_buckets empty here means add_classified_edges() is
// never called for this product either, so every product uniformly falls through
// to the pre-existing product_shapes_ fallback -- the original, pre-classification
// linework.
std::map<std::string, TopoDS_Compound> classified_edge_buckets;
if (svg_use_edge_classification_) {
NCollection_IndexedDataMap<TopoDS_Shape, NCollection_List<TopoDS_Shape>, TopTools_ShapeMapHasher> edge_face_map;
TopExp::MapShapesAndAncestors(*compound_to_hlr, TopAbs_EDGE, TopAbs_FACE, edge_face_map);
gp_Dir view_dir;
try {
view_dir = gp_Dir(projection_direction);
} catch (const Standard_Failure&) {
view_dir = gp::DZ();
}
BRep_Builder BBcls;
for (int i = 1; i <= edge_face_map.Extent(); ++i) {
const TopoDS_Edge& cls_edge = TopoDS::Edge(edge_face_map.FindKey(i));
edge_style_class cls = edge_style_class::outline;
try {
cls = classify_edge_from_faces(cls_edge, edge_face_map.FindFromIndex(i), view_dir, svg_ridge_angle_min_deg_, svg_valley_angle_min_deg_);
} catch (const Standard_Failure& e) {
logger().warning("SER", 30, std::string("SVG edge classification OCC exception: ") + e.GetMessageString());
} catch (const std::exception& e) {
logger().warning("SER", 31, std::string("SVG edge classification exception: ") + e.what());
}
if (cls == edge_style_class::flush && !svg_emit_flush_edges_) {
continue;
}
if (cls == edge_style_class::crease && !svg_render_crease_edges_) {
continue;
}
if (cls == edge_style_class::sharp && !svg_render_sharp_edges_) {
continue;
}
std::string name = edge_style_class_name(cls);
auto bucket_it = classified_edge_buckets.find(name);
if (bucket_it == classified_edge_buckets.end()) {
TopoDS_Compound c;
BBcls.MakeCompound(c);
bucket_it = classified_edge_buckets.emplace(name, c).first;
}
BBcls.Add(bucket_it->second, cls_edge);
}
// Non-planar faces (e.g. a real analytic cylindrical wall from a
// circular-profile column/pile, swept via BRepPrimAPI_MakePrism rather than
// faceted) have a silhouette that HLR synthesizes on the fly -- it is not a
// pre-existing topological edge, so the edge-only loop above can never bucket
// it. OutLineVCompound(S) correlates a curved face's silhouette by the
// identity of the originating *face*, not any edge, so add the non-planar
// face itself into the outline bucket alongside whatever edges it already
// contributed (top/bottom/seam), giving HLR's per-face OutLine reconstruction
// something to match against.
for (TopExp_Explorer fexp(*compound_to_hlr, TopAbs_FACE); fexp.More(); fexp.Next()) {
const TopoDS_Face& f = TopoDS::Face(fexp.Current());
if (BRep_Tool::Surface(f)->DynamicType() != STANDARD_TYPE(Geom_Plane)) {
std::string name = edge_style_class_name(edge_style_class::outline);
auto bucket_it = classified_edge_buckets.find(name);
if (bucket_it == classified_edge_buckets.end()) {
TopoDS_Compound c;
BBcls.MakeCompound(c);
bucket_it = classified_edge_buckets.emplace(name, c).first;
}
BBcls.Add(bucket_it->second, f);
}
}
}
if (is_floor_plan_) {
if (storey) {
auto it = storey_hlr.find(storey);
@@ -1355,11 +1632,17 @@ void SvgSerializer::write(const geometry_data& data) {
it = storey_hlr.insert({ storey, hlr_t(logger(), use_prefiltering_, use_hlr_poly_, segment_projection_, projection_plane) }).first;
}
it->second.add(*compound_to_hlr, data.product);
for (auto& kv : classified_edge_buckets) {
it->second.add_classified_edges(data.product, kv.first, kv.second);
}
} else {
logger().warning("SER", 28, "Unable to invoke HLR due to absence of storey containment", data.product);
}
} else if (hlr) {
hlr->add(*compound_to_hlr, data.product);
for (auto& kv : classified_edge_buckets) {
hlr->add_classified_edges(data.product, kv.first, kv.second);
}
}
}
}
@@ -1907,49 +2190,63 @@ std::array<std::array<double, 3>, 3> SvgSerializer::resize() {
}
void SvgSerializer::draw_hlr(const gp_Pln& pln, const drawing_key& drawing_name) {
auto hlr_items = (drawing_name.first ? this->storey_hlr.find(drawing_name.first)->second : *hlr).build();
hlr_t& hlr_source = drawing_name.first ? this->storey_hlr.find(drawing_name.first)->second : *hlr;
auto hlr_items = hlr_source.build();
for (auto& p : hlr_items) {
const TopoDS_Shape& hlr_compound_unmirrored = p.second;
// SVG edge classification (issue #3668): each item's class is already known -- it was
// determined pre-HLR from real face topology (see the classified_edge_buckets block in
// write(const geometry_data&)) and threaded through via hlr_calc::extract(). No post-hoc
// lookup against HLR's own (face-less) output is needed. Multiple items can share the same
// product (one per non-empty class bucket); keep a single path_object/group per product so
// per-path classes survive Bonsai's merge_linework_and_add_metadata untouched, rather than
// creating a <g> per class (see plan notes on why that clobbers classes in Python).
std::map<express::Base, path_object*> group_by_product;
if (!hlr_compound_unmirrored.IsNull()) {
// Compound 3D curves for mirroring to work
ShapeFix_Edge sfe;
TopExp_Explorer exp(hlr_compound_unmirrored, TopAbs_EDGE);
for (; exp.More(); exp.Next()) {
sfe.FixAddCurve3d(TopoDS::Edge(exp.Current()));
for (auto& item : hlr_items) {
express::Base product = std::get<0>(item);
const std::string& cls = std::get<1>(item);
const TopoDS_Shape& hlr_compound_unmirrored = std::get<2>(item);
if (hlr_compound_unmirrored.IsNull()) {
continue;
}
// Compound 3D curves for mirroring to work
ShapeFix_Edge sfe;
TopExp_Explorer exp(hlr_compound_unmirrored, TopAbs_EDGE);
for (; exp.More(); exp.Next()) {
sfe.FixAddCurve3d(TopoDS::Edge(exp.Current()));
}
// Mirror to match SVG coord system.
// @todo this is very wasteful. We better do the Y-mirror in the SVG writing and
// not on the TopoDS_Shape input.
TopoDS_Shape hlr_compound;
if (!drawing_name.first) {
gp_Trsf trsf_mirror;
if (!mirror_y_) {
trsf_mirror.SetMirror(gp_Ax2(gp::Origin(), gp::DY()));
}
// Mirror to match SVG coord system.
// @todo this is very wasteful. We better do the Y-mirror in the SVG writing and
// not on the TopoDS_Shape input.
TopoDS_Shape hlr_compound;
if (!drawing_name.first) {
gp_Trsf trsf_mirror;
if (!mirror_y_) {
trsf_mirror.SetMirror(gp_Ax2(gp::Origin(), gp::DY()));
}
if (mirror_x_) {
gp_Trsf mirror_x;
mirror_x.SetMirror(gp_Ax2(gp::Origin(), gp::DX()));
trsf_mirror.PreMultiply(mirror_x);
}
BRepBuilderAPI_Transform make_transform_mirror(hlr_compound_unmirrored, trsf_mirror, true);
make_transform_mirror.Build();
hlr_compound = make_transform_mirror.Shape();
} else {
// In case of building storey-based floor plan the mirroring has already
// been taken into account before projection.
hlr_compound = hlr_compound_unmirrored;
if (mirror_x_) {
gp_Trsf mirror_x;
mirror_x.SetMirror(gp_Ax2(gp::Origin(), gp::DX()));
trsf_mirror.PreMultiply(mirror_x);
}
BRepBuilderAPI_Transform make_transform_mirror(hlr_compound_unmirrored, trsf_mirror, true);
make_transform_mirror.Build();
hlr_compound = make_transform_mirror.Shape();
} else {
// In case of building storey-based floor plan the mirroring has already
// been taken into account before projection.
hlr_compound = hlr_compound_unmirrored;
}
exp.Init(hlr_compound, TopAbs_EDGE);
BRep_Builder B;
path_object* po;
path_object*& po = group_by_product[product];
if (!po) {
std::string name;
if (p.first) {
name = nameElement(p.first);
if (product) {
name = nameElement(product);
boost::replace_all(name, "class=\"", "class=\"projection ");
} else {
name = "class=\"projection\"";
@@ -1959,13 +2256,19 @@ void SvgSerializer::draw_hlr(const gp_Pln& pln, const drawing_key& drawing_name)
} else {
po = &start_path(pln, drawing_name.second, name);
}
for (; exp.More(); exp.Next()) {
TopoDS_Wire w;
B.MakeWire(w);
B.Add(w, exp.Current());
write(*po, w);
}
}
std::optional<std::string> css_class;
if (!cls.empty()) {
css_class = cls;
}
BRep_Builder B;
for (TopExp_Explorer exp_mirrored(hlr_compound, TopAbs_EDGE); exp_mirrored.More(); exp_mirrored.Next()) {
TopoDS_Wire w;
B.MakeWire(w);
B.Add(w, exp_mirrored.Current());
write(*po, w, std::nullopt, css_class);
}
}
}
@@ -2372,6 +2675,35 @@ void SvgSerializer::doWriteHeader() {
" fill: none;\n"
" stroke-opacity: 0.6;\n"
" }\n"
// SVG edge classification (issue #3668) -- see edge-classification.md. These
// select directly on the <path> element (each classified edge carries its own
// class), not on an ancestor <g>, so they win over the inherited .projection
// path rule above regardless of specificity.
" path.outline {\n"
" stroke: #000000;\n"
" stroke-width: 0.35px;\n"
" stroke-opacity: 1;\n"
" }\n"
" path.boundary {\n"
" stroke: #000000;\n"
" stroke-width: 0.3px;\n"
" stroke-opacity: 0.9;\n"
" }\n"
" path.sharp {\n"
" stroke: #000000;\n"
" stroke-width: 0.25px;\n"
" stroke-opacity: 0.85;\n"
" }\n"
" path.crease {\n"
" stroke: #000000;\n"
" stroke-width: 0.18px;\n"
" stroke-opacity: 0.7;\n"
" }\n"
" path.flush {\n"
" stroke: #000000;\n"
" stroke-width: 0.1px;\n"
" stroke-opacity: 0.4;\n"
" }\n"
" .IfcDoor path,\n"
" .Symbol path {\n"
" fill: none;\n"