FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Classify projection edges in SVG elevations (#3668) by sboddy · Pull Request #8608 · IfcOpenShell/IfcOpenShell · GitHub

Classify projection edges in SVG elevations (#3668) - #8608

Merged
sboddy merged 7 commits into
IfcOpenShell:v0.8.0from
sboddy:feature-svg-edge-classification-3668-4
Jul 18, 2026
Merged

Classify projection edges in SVG elevations (#3668)#8608
sboddy merged 7 commits into
IfcOpenShell:v0.8.0from
sboddy:feature-svg-edge-classification-3668-4

Conversation

sboddy commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Classifies HLR projection edges (elevations/axonometric views, SvgSerializer's
OpenCASCADE Linework mode) into five classes — boundary, outline, sharp, crease,
flush — so CSS can style silhouettes, ridges, and valleys differently instead of drawing
every edge identically. This is the feature requested in #3668: without it, curved/faceted
geometry (e.g. domes, cylinders approximated as many small planar facets) renders every facet
boundary as an identical hard line, which looks wrong compared to a true curved silhouette.

Refs #3668.

Three earlier attempts (see branches feature-svg-edge-classification-3668, -2, -3) got
stuck on what looked like a Python problem but was actually two separate, more fundamental
issues — see the attached plan document for the full root-cause writeup. Short version:

  1. Bonsai's merge_linework_and_add_metadata (operator.py) does overwrite the class
    attribute wholesale on every projection <g> it visits — but only at the group level.
    This PR tags classes directly on individual <path> elements instead, which that function
    never touches, sidestepping the problem entirely rather than patching around it.
  2. Independently, and not previously diagnosed: HLRBRep_HLRToShape's output is fundamentally
    edge-only with no face topology, so any attempt to classify it after HLR runs (by looking
    up face-adjacency for its output edges) can never work, regardless of identity-matching
    fixes. The fix classifies edges before HLR (on the original solid, which has real
    faces), then extracts each class's visible portion via
    HLRBRep_HLRToShape::VCompound(S)/OutLineVCompound(S) — the same per-shape filtering
    mechanism already used in this file for per-product segmentation, repurposed per class.

What's verified

  • IfcConvert CLI on a synthetic L-shape+box test IFC: real, differentiated
    outline/sharp classes on individual <path> elements (previously either 100%
    unclassified or 100% one bucket, depending on the attempt).
  • An isolated OCCT unit test exercising the classification function directly with synthetic
    convex/concave face pairs, confirming the sign convention (convex → sharp, concave →
    crease) is correct.
  • Real-world test by @sboddy on an actual project drawing (EXISTING EAST ELEVATION.svg,
    copied into a live Bonsai profile): 798 outline, 131 crease, 1291 sharp edges produced.
    Confirms the fix works end-to-end through Bonsai's actual pipeline including
    merge_linework_and_add_metadata, not just via the CLI.

What's not done / known issues

  • This PR does not ship a fully styled default CSS for the new classes in every asset file
    — colours picked in default.css were placeholders for testing, not a final visual design.
    The real-world test above rendered blue lines because the CSS wasn't part of that manual
    copy — expected, not a bug, but worth a proper design pass before this is considered finished.
  • Only IfcConvert CLI and one real-world manual copy-in test have exercised this; it has not
    been run through the project's own automated Blender test harness (see follow-up comment —
    that harness has an unrelated pre-existing issue that blocked this during development).
  • @sboddy has flagged there are "definitely still things wrong" and plans further testing
    before this is ready to come out of draft.
  • Cut-plane/section edges are explicitly out of scope (unchanged) — this only affects the
    HLR/projection path used for elevations.
  • Occlusion-aware dashed rendering (showing hidden edges of any class as dashed, not just
    omitting them) is deferred to a future issue.

AI disclosure

Per this repo's AGENTS.md: this change (research, design, implementation, and the attached
planning artifacts) was produced by Claude (Anthropic) working with @sboddy across an extended
session — investigating the prior failed attempts, confirming/refining the root-cause
hypothesis, designing and implementing the fix, and independently verifying it. See the
follow-up comment on this PR for an honest retrospective on the process.


Attached: initial task prompt given to the AI agent
There are a number of sources of information for this task.
There is the original comment:
https://github.com/IfcOpenShell/IfcOpenShell/issues/3668#issuecomment-2489524775
where I describe the expanded scope for classifying edges.
I've gone through several attempts leading to a lot of non-working partial implementations.
There are three checked out worktrees where the branch name starts feature-svg-edge-classification-3668
The are several user folder with patches and source files:
/home/steve/Development/Bonsai/sboddy/archive_failed_SvgSerialiser
/home/steve/Development/Bonsai/sboddy/SvgSerialiser_patchset
Even when we (me and AI) thought it should work, it would fail. On a deeper debugging round we
realised that the issue was not necessarily with the C++ code. There is a function in the
calling Python that recreates the edges with class="projection" and reconstructs them,
discarding our attempts to give them additional classification.
Using the previous failures, and making a thorough analysis of src/serializers/SvgSerializer.cpp
and the merge_linework_and_add_metadata in the src/bonsai/bonsai/bim/module/drawing/operator.py
Do not write any code yet. Confirm if the hypothesis is correct. Formulate a plan to implement
the feature. Write a plan or skill, whichever is appropriate, for a coding agent to implement
your plan.
Attached: edge-classification.md (the authoritative classification scheme, written by @sboddy with a separate AI chat)
# Edge classification scheme (BIM → 2D projection, elevations/orthographic views)

## Overview

Every drawn edge is assigned to exactly one of five classes: **outline**, **sharp**, **crease**,
**flush**, or **boundary**. Classification runs in a fixed order, using topology first, then
projected geometry, then dihedral angle.

Non-manifold edges (3+ faces sharing an edge) are a geometry-health / QA concern, not an
architectural line category, and are not part of this scheme. Flag them separately in a
debug/QA view if useful.

Visibility (solid vs. dashed rendering based on view occlusion) is a separate, orthogonal
system and applies independently to any of the classes below — a hidden sharp edge is still
sharp, just drawn dashed.

## Classes

| Class | Trigger | Default |
|---|---|---|
| **Boundary** (naked) | Edge belongs to only one face | Always drawn |
| **Outline** | Edge lies on the projected silhouette — either object-vs-background or self-occluding (same object behind) | Always drawn |
| **Sharp** (ridge) | Signed dihedral angle deviation from flat is convex and its magnitude exceeds `ridgeAngleMin` | Always drawn |
| **Crease** (valley) | Signed dihedral angle deviation from flat is concave and its magnitude exceeds `valleyAngleMin` | Always drawn |
| **Flush** | Deviation magnitude falls below the applicable threshold for its sign (not "coplanar" — a 5° edge isn't coplanar, just below the noise floor) | Suppressed by default, toggle-able |

Sharp and crease are opposite signs of the same measurement, not two magnitudes of the same
fold direction. **Crease is a valley (concave)**, **sharp is a ridge (convex)**. This requires
a **signed** dihedral angle deviation from flat (180°) — positive for convex folds, negative
for concave — rather than an absolute value.

## Evaluation order

1. **Boundary** — if an edge has only one adjacent face, there's no second face to measure an
   angle against, so this is tested before any angle-based logic runs.
2. **Outline** — overrides the angle-based bucket. A flush edge that happens to sit on the
   projected silhouette is still drawn as an outline, not suppressed as flush.
3. **Angle-based bucket** (sharp / crease / flush) — for anything not already classified as
   boundary or outline, via the two thresholds below.

## Thresholds

Two independent settings, each a magnitude compared against the deviation on its respective
side of zero:

- `ridgeAngleMin` — minimum convex deviation to classify as **sharp**
- `valleyAngleMin` — minimum concave deviation to classify as **crease**

Anything with a smaller magnitude than the applicable threshold, on either side, is **flush**.

Keeping these separate allows ridges and valleys to read at different sensitivities — for
example, valleys often read as visually softer than ridges at the same angle, so
`valleyAngleMin` might reasonably be set higher than `ridgeAngleMin`.
Attached: the working plan document (as built up and revised across the session, including the mid-course pivot on the actual root cause)
# SVG edge classification (issue #3668) — root cause confirmed + implementation plan

## Context

[GH issue #3668, comment](https://github.com/IfcOpenShell/IfcOpenShell/issues/3668#issuecomment-2489524775)
originally asked for OpenCASCADE-linework SVG output to classify each **projection edge** (not
cut/section edges) by a rough contour/crease/sharp/hidden heuristic, so CSS could style them
differently and fix the "ugly faceted sphere" rendering problem shown in the issue. That naming
has since been superseded by [`edge-classification.md`](edge-classification.md) — the
authoritative scheme for this plan, five classes: **boundary**, **outline**, **sharp**,
**crease**, **flush** (see "Nomenclature correction" below for the full mapping and why it's
more than a rename).

Three prior attempts exist as detached worktrees
(`StagingPost.worktrees/origin-feature-svg-edge-classification-3668{,-2,-3}`), plus raw
source dumps in `../archive_failed_SvgSerialiser` and `../SvgSerialiser_patchset`. All were
abandoned as non-working. This plan is based on a full read of the current `v0.8.0`
`SvgSerializer.cpp`/`.h`, `operator.py`, and a diff/history review of all three WIP branches.

## Hypothesis check: confirmed, but the real fix is different from what was tried

The user's hypothesis — "a Python function recreates edges with `class="projection"` and
discards our classification" — is **directionally correct but the mechanism is different
from what it looks like at first**, which is why fixing the C++ alone never worked. Two
independent things were wrong:

**1. `merge_linework_and_add_metadata` (`src/bonsai/bonsai/bim/module/drawing/operator.py:1440-1471`)
does wholesale-overwrite the `class` attribute of every per-element `<g guid=...>` it visits:**

```python
if "projection" in el.get("class", "").split():
    classes = self.get_svg_classes(element)   # freshly built from IFC type/material/layer
    classes.append("projection")
    el.set("class", " ".join(classes))          # <-- replaces the WHOLE attribute
    continue
```

`get_svg_classes()` only knows about IFC semantics (entity type, material, layer, psets) —
it has no idea any geometric edge-classification token like `contour`/`crease` ever existed,
so any such token present on that `<g>` is silently dropped.

**2. But all three WIP branches implemented classification at the wrong granularity.** In
`draw_hlr()` (`SvgSerializer.cpp:1769-1831` on current `v0.8.0`; see commits `46ae0f401e``c631de8eea``b8b1f2f1d8` on the worktree branches), they bucketed edges into **multiple
`<g>` groups per product** — one group per class (`grouped_paths` map / `get_group(cls)`
lambda in the WIP), each still carrying the product's `guid`. Every one of those per-class
groups independently matches the `for el in root.findall(".//g[@guid])")` loop in
`merge_linework_and_add_metadata` and independently hits the `"projection"` branch above,
so **every bucket's distinguishing class gets wiped, every time**. The confirmed diagnostic
step the user already ran — commenting out the `self.merge_linework_and_add_metadata(root)`
call (see `archive_failed_SvgSerialiser/.../diff_against_hash-00ec587_partial_svgserialiser.diff`
lines 22-25) and dumping the raw pre-merge SVG — is exactly the right test and would have
shown the classes present before, and gone after, that call.

There is a **third, independent bug** found in the final commit of two of the branches
(`2d8d351652`/`db28eba138`, "last svg classification attempt this branch"): the
`classify_edge_from_faces()` function has a stray, unreachable `return edge_style_class::contour;`
placed right after the face-count check, added during a debugging session (alongside a debug
counters struct and a `"debug-draw-hlr-hit "` class tag) — **all edges therefore always
classified as `contour` regardless of geometry**, independent of the Python problem above.
This means at least one of the "even when we thought it should work" failures was a genuine,
separate C++ regression, not just the Python overwrite.

## Nomenclature correction (post-review with the user)

The `.hidden { stroke-dasharray: 3, 2; }` rule in `sample.css:24` — which the previous draft of
this plan assumed was added in anticipation of this feature — **predates it and is unrelated**:
it's the existing generic dashed-line convention for occluded/hidden-line rendering, orthogonal
to edge classification. Re-examining the WIP's naming against
[`edge-classification.md`](edge-classification.md) (the user's cleaned-up scheme, worked out
with a separate AI chat) shows the WIP's naming was muddled, and — beyond naming — the bucket
*logic* needs to change too, not just the labels. The corrected scheme has **five classes**:
`boundary`, `outline`, `sharp`, `crease`, `flush`. Mapping from the old WIP concepts:

- Old **contour** (front/back face-normal sign flip) → **`outline`**. Same test, same code path
  — this part of the WIP genuinely does align, per the user's expectation.
- Old lumping of 0/1-adjacent-face edges into `contour` → these are now their own class,
  **`boundary`** (naked edges), always drawn, tested *before* any angle logic — not merged into
  `outline`.
- Old **unsigned** dihedral magnitude with three bands (`<12°→crease`, `12–45°→hidden`,
  `>45°→sharp`), gated on "both faces must face the camera" → replaced by a **signed** deviation
  from flat (convex vs. concave), tested regardless of camera-facing (see scope note below for
  why that gating no longer matters here), with **two independent per-sign thresholds**
  (`ridgeAngleMin` for convex/`sharp`, `valleyAngleMin` for concave/`crease`) and anything below
  the applicable threshold on either side is **`flush`** (suppressed by default, toggle-able) —
  this replaces old `hidden`/`svg_emit_hidden_edges` (rename to avoid clashing with the
  unrelated visibility `.hidden` CSS class).
- **This is not just a rename**: getting the *sign* (convex ridge vs. concave valley) requires
  more than the old code computed. The old `angle_deg_between(n0, n1)` (via `acos(dot)`) only
  ever gives an unsigned magnitude. Determining convexity needs the edge's tangent direction
  too — the standard technique is the sign of `cross(n0, n1) · edge_tangent` (consistently
  oriented using each face's `TopAbs_REVERSED` flag, same as the WIP's existing
  `face_normal_from_planar_face()` already does for the normals themselves). This sign
  computation must be added; it wasn't present in any of the 3 prior attempts.
- Non-manifold edges (3+ adjacent faces) are explicitly **out of scope** for the 5-class scheme
  per the doc — a geometry-health/QA concern, not an architectural line category. Don't bucket
  them into any of the 5 classes; either skip them or (optionally, if trivial) tag them with a
  distinct non-scheme debug class, but don't force-fit them into `boundary` or `outline`.

**Scope decision (confirmed with user):** implement all 5 classes for **visible edges only** —
i.e. classify edges within the existing HLR-visible edge set that `draw_hlr()` already receives
today (`occt_join(OutLineVCompound, VCompound)`), matching all 3 prior WIP attempts and the
original issue's scope. The doc's statement that "visibility is a separate, orthogonal system...
a hidden sharp edge is still sharp, just drawn dashed" describes the *conceptual* model but
**full occlusion-aware dashed rendering of hidden edges in every class is deferred to a future
issue** — it would require pulling OCCT's hidden-edge HLR compounds (`OutLineHCompound`,
`HCompound`, etc.), which none of the prior attempts touched and is materially more work. One
consequence: since everything reaching `draw_hlr()` is already visibility-filtered by OCCT, the
old WIP's "both faces must face the camera" precondition before angle-bucketing is now moot —
just classify every non-boundary, non-outline, non-manifold-excluded edge in the visible set by
signed dihedral angle.

## Recommended fix: classify per `<path>`, not per `<g>` — sidesteps the Python overwrite entirely

`SvgSerializer::write(path_object& p, const TopoDS_Shape&, ...)` (`SvgSerializer.cpp:108-370`)
already emits **one `<path>` element per edge** when called from `draw_hlr` (one call to
`write(*po, w)` per edge at `SvgSerializer.cpp:1822-1827``path_object` is literally
`std::pair<std::string group-attrs, std::vector<string_buffer> paths>`, i.e. one shared group
with many independent `<path>` children). This means we do **not** need multiple `<g>` groups
per product to express per-edge classes — we can tag the **individual `<path>` tag** instead.

Crucially, `merge_linework_and_add_metadata`'s `"projection"` branch does `el.set("class", ...)`
on the `<g>` **and then `continue`s immediately** — it never descends into or touches child
`<path>` elements' own `class` attributes. So a `class="outline"` (or `sharp`/`crease`/`flush`/
`boundary`) attribute placed directly on a `<path>` tag survives this function untouched, and
CSS rules like `.outline { stroke: ...; }` win over the ancestor `.projection { stroke: black; }`
rule because directly-matched styles always beat inherited ones in CSS, regardless of
specificity.
**This means `operator.py` likely needs no change at all** for this to work — confirm this
during implementation rather than assuming it, since `generate_freestyle_linework()`
(operator.py:868) and a couple of other spots do touch group classes and should be checked too.

This also means we should **not** resurrect the WIP's `grouped_paths`/`get_group()` bucketing
approach — it's the thing that caused the Python clobbering, is more complex, and risks other
code that assumes one non-cut `<g>` per guid per drawing (e.g. `move_projection_to_bottom`,
the SVGFILL raycast block). Classify-and-tag-per-path is strictly simpler.

### C++ implementation

## STATUS UPDATE (implementation session, blocked — see below)

Implemented and empirically verified working, on branch `feature-svg-edge-classification-3668-4`:
- Settings `SvgRidgeAngleMinDegrees`/`SvgValleyAngleMinDegrees`/`SvgEmitFlushEdges` in
  `ConversionSettings.h`, read in `SvgSerializer::ready()`, confirmed showing up correctly as
  `--svg-ridge-angle-min-degrees`/`--svg-valley-angle-min-degrees`/`--svg-emit-flush-edges` in
  `IfcConvert --help`.
- `write(path_object&, ...)` now takes an optional `css_class` and emits it as `class="..."` on
  the individual `<path>` tag — confirmed in raw SVG output (`path.outline`/`.boundary`/etc. CSS
  rules render correctly in the `<style>` block).
- `classify_edge_from_faces()` and the 5-class enum are implemented per the scheme below.
- **Python wiring correction (deviates from the plan text as originally approved):** the new
  settings belong on `self.svg_settings` (`ifcopenshell::geometry::Settings`, read via
  `geometry_settings()` in C++), **not** `self.serialiser_settings`
  (`ifcopenshell::geometry::SerializerSettings` — a disjoint tuple for unrelated things like
  `UseElementGuids`). The WIP branches and this plan's original wording both had this backwards;
  `self.serialiser_settings.set("svg-ridge-angle-min-degrees", ...)` would have silently done
  nothing (or thrown) since that key doesn't exist in that container. Fixed in
  `operator.py:setup_serialiser()`.
- Decided **not** to add `prop.py`/`ui.py` panel entries: none of the existing similar
  operator-only flags (`sync`, `print_all`, `open_viewer`) are exposed in `ui.py` either — they
  rely on the operator's own redo-panel. Matching that convention, the three new properties live
  only on the `CreateDrawing` operator.

**RESOLVED.** Root cause and fix, in order of discovery:

1. First attempt (matching `b8b1f2f1d8`'s approach): build the edge→faces map from
   `hlr_compound_unmirrored` (HLR's own output). **Always empty**`HLRBRep_HLRToShape`'s output compounds contain only `TopAbs_EDGE` shapes, no `TopAbs_FACE` at
   all (confirmed via OCCT's own header docs: *"these 2D edges are not included in the data
   structure of the visualized shape"* — true for both the BRep and poly HLR engines).
2. Second attempt: build the map from the pre-HLR original solid instead. Non-empty map, but
   **still zero matches** looking up HLR-output edges in it — `HLRBRep_HLRToShape` reconstructs
   new `TopoDS_Edge` objects, it doesn't preserve the original edge's identity even when fully
   visible/untrimmed.
3. **The actual fix**, empirically verified via a standalone OCCT program (see below): don't
   classify HLR's output at all. `HLRBRep_HLRToShape::VCompound(const TopoDS_Shape& S)` /
   `OutLineVCompound(const TopoDS_Shape& S)` filter the algorithm's internal results down to a
   caller-supplied shape `S`, correlating by the identity of the **original input edges** (not
   the reconstructed output) — exactly how `hlr_calc` already gets correct per-product
   breakdown today. So: classify the original, pre-HLR edges (real face topology, trivially
   reliable) into up to 5 class-labeled **edge-only** sub-compounds per product, register them
   via a new `hlr_t::add_classified_edges(product, class, edges)`, and let `hlr_calc::extract()`
   query `VCompound(bucket)`/`OutLineVCompound(bucket)` per bucket — `draw_hlr()` then already
   knows each edge's class, no post-hoc guessing at all.

   Isolated verification (`test_hlr_filter2.cpp`, a 12-edge box): summing `VCompound(bucket)` over
   each of the 12 individual edges as its own one-edge bucket reproduced the unfiltered
   `VCompound()` total exactly (9 visible edges either way) — confirming edge-only `S` shapes
   correlate correctly with no faces required in the filter shape.

Implemented in `SvgSerializer.h`/`.cpp` on branch `feature-svg-edge-classification-3668-4`:
`hlr_calc::result_type` is now `list<tuple<product, class-name, compound>>`; `prefiltered_hlr`
gained `classified_items_` + `add_classified_edges()`; classification happens once, right where
`hlr->add(*compound_to_hlr, data.product)` is already called in `write(const geometry_data&)`;
`draw_hlr()` groups results by product (one shared `path_object`/`<g>` per product, as before —
no bucketing into multiple groups) and writes each bucket's edges with its already-known class.

**Verified working end-to-end at the C++ level** on the hand-built L-shape+box test IFC: real,
differentiated classes now appear (`10 outline`, `27 sharp` in one run) instead of 100%
`boundary`. Zero `crease` results in that specific run turned out to be a property of the test
geometry/view combination (axis-aligned box+L-shape viewed from 4 axis-aligned cardinal
elevations — the concave notch's two walls never happened to be simultaneously front-facing from
those exact angles), not a bug — confirmed by a separate isolated unit test
(`test_sign.cpp`) exercising `classify_edge_from_faces()` directly with synthetic convex/concave
face pairs under a view direction chosen to make both faces of each fold simultaneously
front-facing: convex → `deviation_deg=+90``sharp` ✓, concave → `deviation_deg=-90``crease`
✓. Sign convention confirmed correct.

**Remaining to verify:** whether `merge_linework_and_add_metadata` leaves per-path classes intact
end-to-end through Bonsai, and a full Blender `Create Drawing` run.

**Attempted, blocked by an environment issue (not a code issue):** built a self-contained headless
Blender script (no UI clicking — `bpy.ops.bim.create_project`/`assign_class`/`add_drawing`
(`target_view="ELEVATION_VIEW"`)/`save_project`/`activate_drawing`/`create_drawing`) using the
`bonsai_test` profile + debug-build Blender ([[project_blender_test_profile]]). It got through the
whole pipeline (project creation → wall → elevation drawing → real SVG output) with no crashes,
but the produced SVG's `<path>` elements had **no class attribute at all** — not even the old
`cut`/`projection` group-level behavior being wrong, just genuinely absent per-path classes.
Debug instrumentation (temporary `std::ofstream` writes to `/tmp/svg_classify_debug.log` at
several points in the C++ call chain, since `logger_.Warning(...)` calls don't surface anywhere
visible when driven via the Python bindings) proved the debug lines **never fired at all** —
i.e. code I knew was being exercised (verified moments earlier via `IfcConvert`) wasn't running.
Root cause: `strings <the loaded .so> | grep <a marker string only in the new build>` showed the
marker present in the checkout's freshly-built `.so` but **absent** in the copy Blender actually
loaded from `bonsai_test`'s extension directory, and that file's mtime changed *during* the
Blender run — confirmed Blender's own extension/wheel installer silently overwrites
`_ifcopenshell_wrapper.cpython-311-*.so` on every launch of this profile (even survived
replacing it with an explicit `ln -s` beforehand — got deleted and replaced with a plain file
again). This contradicts this project's own memory note claiming that file is symlinked like the
pure-Python packages; corrected that memory. Removed all the temporary debug instrumentation
before finishing (verified via `IfcConvert` that the classification behavior is unaffected).

**Net effect:** the C++ classification logic itself is verified correct and working (via
`IfcConvert` CLI + isolated OCCT unit tests) but this session could not get a live signal from
an actual Blender/Bonsai run, because of this test-harness limitation rather than anything wrong
with the feature. Whoever picks this up next should either fix the wheel-reinstall issue (find
what triggers it and disable/skip it for dev iteration) or rely on `IfcConvert` CLI testing plus
careful manual review of `merge_linework_and_add_metadata`'s interaction with per-path classes
(architecturally sound per the "Recommended fix" section above — it only rewrites `<g>`-level
`class`, never touches child `<path>` elements — but not yet seen operating on a real
Bonsai-generated SVG).

---

1. **Settings** (`src/ifcgeom/ConversionSettings.h`): add `SvgRidgeAngleMinDegrees` (convex/
   `sharp` threshold, default TBD e.g. 45.0), `SvgValleyAngleMinDegrees` (concave/`crease`
   threshold, default TBD e.g. 12.0 — the doc notes valleys often need a higher threshold than
   ridges to read the same, so don't assume symmetry), `SvgEmitFlushEdges` (default false —
   renamed from the WIP's `SvgEmitHiddenEdges` to avoid clashing with the pre-existing,
   unrelated `.hidden` visibility class). Follow the existing `SettingBase<...>` pattern (see
   e.g. `CgalSmoothAngleDegrees`) and add to the `Settings` tuple. Reuse the WIP's settings
   mechanism (commits `a5a7a71b72`/`15da588adb`) but drop the env-var fallback
   (`IFCOPENSHELL_SVG_*` getenv reads) — it duplicates the settings mechanism for no reason.

2. **Classification function** (new anonymous-namespace helper in `SvgSerializer.cpp`, near
   `draw_hlr`), implementing the 5-class scheme in evaluation order:
   - `enum class edge_style_class { boundary, outline, sharp, crease, flush };`
   - **Boundary**: 0 or 1 adjacent face → `boundary` (naked edge; no second face to measure
     against). Tested first.
   - **Outline**: exactly 2 adjacent planar faces, `projection_direction.Dot(normal)` sign
     differs between them (front/back flip) → `outline`. This is the WIP's existing `contour`
     logic (`c631de8eea`) reused as-is — the user's expectation that "the existing algorithm
     aligns" holds here.
   - **Non-manifold** (3+ adjacent faces): explicitly out of scope for the 5 classes — skip
     (don't emit/classify), or optionally tag with a distinct QA-only debug class if trivial;
     do not force into `boundary` or `outline`.
   - **Angle bucket** (remaining 2-planar-face, non-outline edges): compute the **signed**
     deviation from flat. Magnitude via the WIP's existing `acos(dot(n0, n1))`-based
     `angle_deg_between()`, deviation-from-180°-flat = `180 - angle_deg_between(n0, n1)` (confirm
     sign/offset convention empirically against a known convex/concave test case). Sign via
     `cross(n0, n1) · edge_tangent` (consistently oriented per each face's `TopAbs_REVERSED`,
     matching how `face_normal_from_planar_face()` already orients normals) — positive/convex
     vs. negative/concave. Then: `|deviation| < ridgeAngleMin` and convex, or
     `|deviation| < valleyAngleMin` and concave → `flush`; convex and `|deviation| >=
     ridgeAngleMin``sharp`; concave and `|deviation| >= valleyAngleMin``crease`.
   - Reuse the real bug fix from `b8b1f2f1d8`: build the edge→faces map from
     `hlr_compound_unmirrored` and iterate edges from that same unmirrored shape (mirroring
     only applied at write time), not from the possibly Y-mirrored `hlr_compound`.
   - **Do not** carry over the debug counters, the stray early `return contour`, the
     `try{ }catch` wrapping of the whole `write()` function, or the `debug-draw-hlr-hit` tag —
     leftovers from an unfinished debugging session on the WIP branches.
   - Any exception or non-planar-face case during classification should conservatively fall
     back to `outline`, matching the WIP's conservative-fallback intent.

3. **Wire it into `draw_hlr()`** (`SvgSerializer.cpp:1769-1831`): keep the **single**
   `path_object`/group per product exactly as today (no bucketing). Add an edge→faces map
   built once per HLR result (`TopExp::MapShapesAndAncestors(hlr_compound_unmirrored, ...)`),
   and for each edge in the existing `for (; exp.More(); exp.Next())` loop, classify it and
   pass the resulting CSS class token into `write()`.

4. **`write(path_object&, const TopoDS_Shape&, dash_array)` signature**
   (`SvgSerializer.cpp:108`, declared `SvgSerializer.h:644`): add an optional
   `boost::optional<std::string> css_class = boost::none` parameter. When present, emit
   `class="<value>"` on the individual `<path ...>` tag (alongside `d="..."` and the existing
   optional `stroke-dasharray`). All other call sites (`SvgSerializer.cpp:1337, 1403, 1576,
   1692` — annotations/cut-plane geometry) pass nothing and are unaffected. Skip emitting the
   class at all when `svg_emit_flush_edges_` is false and the class is `flush` — i.e. don't call
   `write()` for that edge (matches the doc's "suppressed by default, toggle-able").

5. **CSS** (`doWriteHeader()`, `SvgSerializer.cpp:2225-2271`, plus the shipped
   `src/bonsai/bonsai/bim/data/assets/default.css` / `sample.css`): add rules for `.boundary`,
   `.outline`, `.sharp`, `.crease`, `.flush`. **Do not reuse or touch the existing
   `.hidden { stroke-dasharray: 3, 2; }`** rule in `sample.css:24` — confirmed unrelated,
   predates this feature, and is the generic occluded/hidden-line visibility convention (a
   future, separate concern per the scope decision above). Pick distinct visual treatments per
   class (e.g. `.flush` likely wants a lighter/thinner stroke by default, given it's meant to be
   an unobtrusive fallback bucket even when shown).

### Bonsai (Blender) integration

6. **`src/bonsai/bonsai/bim/module/drawing/operator.py`**: reuse the WIP's `setup_serialiser()`
   wiring shape, renamed: `CreateDrawing.svg_ridge_angle_min_deg` /
   `svg_valley_angle_min_deg` / `svg_emit_flush_edges` props, passed via
   `self.serialiser_settings.set(...)` in a try/except for older ifcopenshell builds (see the
   worktree diff for the ~15-line block shape to follow, with names/defaults updated per the
   settings above). Keep clamping/swap-if-inverted logic only if it still makes sense for two
   independent, non-comparable thresholds (ridge and valley aren't on the same scale the way
   old crease/sharp were, so re-check whether a "swap if crossed" guard even makes sense here —
   it may not).
   **Verify (don't assume) whether `merge_linework_and_add_metadata` needs any change** — per
   the analysis above it shouldn't, since per-path classes aren't touched by that function, but
   confirm empirically by inspecting the finalized SVG (see Verification below) rather than
   trusting the static-analysis conclusion blindly.
7. **`src/bonsai/bonsai/bim/module/drawing/prop.py`** / **`ui.py`**: reuse the WIP's three
   property definitions and panel rows (renamed), exposing the two thresholds and the
   flush-edge toggle in the Create Drawing operator UI.

### Scope explicitly excluded

- Cut-plane/section edges (`BRepAlgoAPI_Section` path around `SvgSerializer.cpp:1433`,
  `class="cut ..."`) are **not** touched — the issue is specifically about the OpenCASCADE
  Linework/projection mode used for elevations, not plan/section cuts.
- Non-manifold edges (3+ faces) are out of scope for the 5-class scheme per the doc — QA/debug
  concern only, not to be force-classified.
- Occlusion-aware dashed rendering of hidden edges (any class, drawn dashed when occluded) is
  explicitly deferred — confirmed with the user as future work. This PR classifies only the
  edges already surviving today's visible-only HLR pass
  (`occt_join(OutLineVCompound, VCompound)`); no change to `HLRBRep_Algo`/`HLRBRep_PolyAlgo`
  itself or use of OCCT's hidden-edge (`*HCompound`) accessors.

### Known interaction to verify, not solve up front

`SvgSerializer.cpp:1128-1209` (`profile_edges` / `profile_threshold_`) already pre-filters
edges by a similar front/back-facing sign-flip test **before** HLR runs, for performance on
large element counts. When that pre-filter is active, some edges may never reach
`draw_hlr()`'s classification at all. Confirm during testing whether enabling both
`profile_threshold_` and edge classification on the same drawing produces surprising gaps, and
note the finding — don't need to fix it as part of this feature unless testing shows it's
broken.

## Files to change

- `src/ifcgeom/ConversionSettings.h` — 3 new settings.
- `src/serializers/SvgSerializer.h``write()` signature; possibly store
  `svg_ridge_angle_min_deg_`/`svg_valley_angle_min_deg_`/`svg_emit_flush_edges_` members.
- `src/serializers/SvgSerializer.cpp``ready()` (read settings), classification helper,
  `draw_hlr()`, `write(path_object&, ...)`, `doWriteHeader()` CSS.
- `src/bonsai/bonsai/bim/data/assets/{default,sample}.css` — new/reused class rules.
- `src/bonsai/bonsai/bim/module/drawing/operator.py` — settings wiring in `setup_serialiser()`;
  no change expected to `merge_linework_and_add_metadata` but verify.
- `src/bonsai/bonsai/bim/module/drawing/prop.py`, `ui.py` — threshold/toggle UI.

Follow AGENTS.md: keep this as a small number of independently-reviewable commits (e.g.
C++ classification+settings; CSS; Blender UI wiring), disclose AI assistance in commit bodies
per the repo's mandatory disclosure rule, and don't touch the abandoned worktrees or
`archive_failed_SvgSerialiser`/`SvgSerialiser_patchset` folders — they're reference material
only, not to be merged from directly (they contain the known-broken debug code).

## Verification plan

1. Build the modified C++ core using the existing local Docker build environment
   (`docker/ifcos_env` — see `docker/SKILL.md`) rather than a full from-scratch build.
2. Run `IfcConvert` directly on a small test IFC containing a curved/faceted shape (e.g.
   recreate the icosphere case from the issue screenshots, or use an existing sphere/dome test
   fixture if one exists under `test/input`) with an elevation/axonometric view, and inspect
   the raw output SVG's `<path>` elements for `class="boundary|outline|sharp|crease|flush"`
   attributes before any Bonsai/Python post-processing touches it — this isolates a correct C++
   implementation from the Python-integration question. Specifically check a known convex
   corner classifies `sharp` and a known concave fold classifies `crease` (not swapped) —
   this is the part most likely to have a sign-convention bug on first pass.
3. Then run the full pipeline through Bonsai's `Create Drawing` operator in the debug-build
   Blender + `bonsai_test` profile (see memory: Blender test profile) to confirm the classes
   survive `merge_linework_and_add_metadata` end-to-end into the cached SVG, and visually
   confirm the CSS renders outline/sharp/crease/boundary edges distinctly and (by default)
   suppresses `flush` edges — reproducing the icosphere comparison from the GitHub issue as the
   acceptance check.
4. Toggle `svg_emit_flush_edges` on and confirm `flush` edges appear per their new CSS rule, and
   confirm this is visually distinguishable from the pre-existing, unrelated `.hidden`
   dashed-line convention (don't let the two get confused in review).
5. No automated Python test currently exists for SVG serializer output (`grep` for
   `SvgSerializer`/`serializers.svg` under `src/ifcopenshell-python/test/` returns nothing) —
   this feature is verified visually/manually per the above; do not invent a new automated SVG
   diff test as part of this change unless the user asks for one separately.

sboddy linked an issue Jul 15, 2026 that may be closed by this pull request

sboddy commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Post-fix retrospective

Honest accounting of the process on this one, since it took a few detours before landing on the actual fix.

What went well

  • Actually reading the git history of all three prior feature-svg-edge-classification-3668* branches (not just their final diffs) turned out to matter — the "last commit" on two of them had debug-session scaffolding (a stray unreachable return contour;, debug counters) that made those branches look more broken than the underlying approach actually was. Separating "this specific commit was mid-debug" from "this whole approach is unworkable" was necessary to make progress instead of writing the whole prior effort off.
  • Confirming the hypothesis with the smallest possible reproduction, twice: a standalone ~60-line OCCT program to check whether VCompound(edge-only-shape) correlates by input identity (it does — verified against a 12-edge box, exact match with the unfiltered count), and a second one to check the classification function's convex/concave sign convention directly rather than trusting it by inspection. Both were faster and more conclusive than reasoning about OCCT's internals from documentation alone, and both directly settled real disagreements (see below).
  • Stopping to ask before implementing a second major architecture change (the "geometric correlation" idea) rather than just building it, since it was a real pivot from the approved plan. In hindsight that specific proposed fix wasn't even the right one — the actual fix (classify pre-HLR, extract per-class via VCompound(S)) is simpler and reuses an existing, already-proven mechanism in this file. Being asked to slow down and write it up instead of just implementing is part of why the better approach got found at all.
  • Treating "why did your old debug logs show positive crease/sharp counts, if this can't work?" as a real contradiction to resolve rather than a question to explain away. That challenge is what led to re-reading the OCCT header docs closely and finding the actual, provable root cause instead of a plausible-sounding one.

What didn't go so well / where I could have done better

  • I spent two attempts (post-hoc lookup against HLR's own output, then against the pre-HLR solid) before reaching for the smallest possible isolated test. The OCCT header comment that ultimately settled it — "these 2D edges are not included in the data structure of the visualized shape" — was sitting in the same header file I could have grepped before writing any lookup code at all. I should have gone straight to "can this fundamentally work" via a minimal repro before writing the larger integration, not after two failed larger attempts.
  • My first CLI verification used the default (non-poly) HLR engine, while Bonsai always uses the polygonal engine (setUseHlrPoly(True)). It happened not to matter here, but that was luck, not a controlled test — I should have matched Bonsai's exact serializer settings (--svg-poly, --svg-segment-projection, etc.) from the first CLI test, not discovered the mismatch was possible only while chasing the Blender harness issue afterward.
  • The Blender end-to-end debugging session (several rebuild → copy → run → inspect cycles, adding increasingly specific std::ofstream debug logging into the C++ call chain) took longer than it should have because I didn't check "is the binary Blender is actually loading the one I just built" until quite late. That should have been the first check the moment the debug logging I knew should fire produced nothing, not several logging-placement iterations in.
  • The plan approved just before implementation still inherited some unverified assumptions from the prior WIP branches (specifically, that the "unmirrored vs. mirrored" identity lookup just needed a consistency fix) rather than independently re-deriving from scratch. Given every single prior attempt had failed, I should have treated all of their underlying assumptions as unverified by default, not just the parts that were visibly broken.

Where you could have done better (said plainly, since you asked for honesty)

  • The prior attempts' debug scaffolding (dead code, stray early returns, debug-only class tags) was left in the "last commit" on two branches rather than reverted or marked as known-broken, which cost real time distinguishing "this branch's approach never worked" from "this branch's approach was sound but got broken by a later debugging session" — it was the latter, but I only found that out by reading full diffs rather than final states.
  • The reference material (archive_failed_SvgSerialiser, SvgSerialiser_patchset, three separate worktrees) had a lot of overlapping, similarly-named content (older_attempts_1/_2, Version48–51) without a clear indicator of which was most recent or most trustworthy — some extra archaeology was needed to establish the actual timeline of ideas.
  • edge-classification.md's corrected nomenclature arrived after the first plan was already approved, which forced a re-plan cycle. That's a reasonable thing to have happen (it came from a separate AI conversation you'd had), but surfacing it before the first planning round would have saved a round trip.

Net

The feature works and is verified two independent ways plus your real-world test, but this PR is explicitly not "done" — draft is the right state for it. The live-Blender verification gap is a test-harness problem (see the investigation note I'll add separately), not a sign the code is unverified; IfcConvert CLI and your own real drawing both exercised the actual compiled classification logic end-to-end.

sboddy commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Update: bonsai_test harness issue found and fixed, live Blender verification now complete

Root cause of the harness issue mentioned above: Bonsai ships as a Blender 4.2+ extension declaring ~90 bundled wheels (including ifcopenshell-*.whl) in its manifest. Blender reinstalls those wheels from the bundled release whenever the addon transitions disabled→enabled. In the dev-testing profile used for this work, that extension repo starts disabled on every fresh headless launch, so a test script that force-enables it (needed to make import bonsai work at all) was retriggering a fresh wheel install on every single run — silently overwriting any manually-built .so, which is why earlier attempts during development couldn't get a live signal from Blender even though the C++ logic was correct.

Fix: enable the extension repo/addon once and persist it with bpy.ops.wm.save_userpref(). After that one-time step the addon stays enabled across future launches with no re-enable code needed, and a manually-copied build survives untouched.

With that fixed, re-ran the same headless Blender script end-to-end (create_project → assign IfcWall → add_drawing(target_view="ELEVATION_VIEW") → create_drawing) and the real, Bonsai-generated SVG now shows individual <path class="boundary">/<path class="outline"> tokens surviving merge_linework_and_add_metadata's post-processing — confirming per-path classes make it through Bonsai's actual pipeline end-to-end, not just via IfcConvert CLI. (Only boundary/outline showed up in that particular test, since the geometry was a simple axis-aligned test cube with no dihedral folds to produce sharp/crease — consistent with, and no contradiction of, @sboddy's real-project numbers above.)

This was a pre-existing gap in the project's own dev-testing setup, not something introduced by this change, so no code in this PR was affected — just closes out the one verification step that was previously blocked.

sboddy commented Jul 15, 2026
edited
Loading

Copy link
Copy Markdown
Contributor Author

Round 2: fixes from real-world testing

@sboddy built a dedicated test scene (icosphere, Suzanne, cylinders/cones at various
orientations, a "Progressive Angles" rig sweeping dihedral angles, purpose-built normal-facing
test planes, an open box, etc.) and rendered it with a debug CSS that color-codes each of the 5
edge classes for visual inspection:

  • black = outline
  • orange = boundary
  • red = sharp
  • green = crease
  • blue = flush

The test scene in Blender:

The classified SVG result after this round's fixes (SOUTH ELEVATION):

That testing surfaced four real bugs in classify_edge_from_faces() that don't show up on the
simpler shapes used during initial development. Three are fixed and verified below; the fourth
is diagnosed but left open.

Issue 1 — silhouette edges misclassified as sharp on near-edge-on faces (fixed)

The old outline test was a bare sign-flip: (d0 < 0.0) != (d1 < 0.0). For regular/symmetric
tessellations (icospheres, N-gon cylinder/cone approximations) viewed at "nice" angles, it's
common for one facet to sit almost exactly edge-on to the camera (d ≈ 0), and floating-point
noise decides which side of 0.0 it lands on. Whichever of that facet's two edges happens to
pair it with a clearly front-facing neighbor still trips the test; the other silently falls
through to angle-based classification instead of outline. Confirmed visually: curved surfaces
were showing zero outline edges on most of their silhouette, all sharp instead.

Fix: classify front/back/edge-on with a tolerance band around zero (reusing the file's existing
1e-5 convention) instead of a bare < 0.0 comparison, and treat "either face at/near edge-on"
as outline, not just a genuine front/back flip.

Issue 2 — thresholds had no effect; everything was sharp or crease, never flush (fixed)

Root cause: an inverted formula. deviation_deg was computed as 180.0 - angle_between_normals_deg, but angle_between_normals_deg (the angle between the two outward
face normals) is already the deviation from flat — coplanar faces have identical normals (0°
between them), and the angle grows as the fold sharpens. Subtracting from 180 inverted this: a
perfectly flat edge computed as deviation_deg = 180 (reads as an extreme fold, always over any
sane threshold), while a near-180° fold computed as deviation_deg ≈ 0 (reads as nearly flat).
Backwards in exactly the way that explains the report — near-coplanar tessellation noise always
read as a big deviation and never got the chance to be flush.

Fix: drop the inversion, deviation_deg is just angle_between_normals_deg.

This fix is more impactful than expected, in a good way: nearly all curved-surface facet
interiors now correctly compute as flush and disappear from the drawing by default (e.g. the
icosphere went from ~77+ edges all showing as sharp to 14 true outline edges and nothing
else shown). That sparser look is the entire point of the original issue — stop drawing every
facet boundary on a tessellated curve as a hard line — not a regression.

Issue 3 — a specific non-coplanar edge missing entirely (fixed, as a side effect of #1)

The "Progressive Angles" rig's left end-cap edge was silently absent (not misclassified —
genuinely not present among the emitted paths), while its right end-cap rendered fine. Confirmed
this was the same root cause as Issue 1: with that fix in place, both end-cap edges are now
present and correctly classified outline. No separate code change needed.

Issue 4 — folds viewed from behind (looking into an open box) read as sharp, should read crease (not fixed — left open)

The "Rotated Box w/Boundary" test object has one face removed; looking through the opening shows
the inside (back) surface of the far faces. The dihedral sign convention is fixed relative to
each face's outward normal, so it's independent of which side the camera is looking from — but
intuitively a ridge that reads convex from outside should read as a valley when you're seeing its
back side through an opening (the same way an internal room corner reads as a "crease" from
inside a building, even though it's a "sharp" convex arris from outside).

What was tried: flip the sign of deviation_deg whenever both adjacent faces test as
back-facing (reusing the Issue 1 tolerance-banded front/back test). Two variants were tried
(back0 && back1, then front0 && front1 as the trigger). Both appeared to work in isolation —
the box's interior lines correctly flipped from sharp to crease — but both broke Issues 1/2
broadly on the rest of the scene.

Why it broke: anything reaching this code has already survived HLR's own visibility
computation, so "both faces back-facing" isn't actually a rare condition restricted to genuine
look-through-an-opening cases — it's common for perfectly ordinary, correctly-classified
geometry too. Flipping the sign for all of it re-signs many small, correctly-flush deviations
into spuriously large crease readings. Verified concretely: re-testing against the
fully-convex icosphere (which by construction should have zero concave edges) reintroduced
dozens of spurious crease edges once the flip was enabled.

This was reverted cleanly, with a comment in the code explaining why the naive fix doesn't work,
rather than shipping a heuristic that trades one real bug for a broader regression across the
rest of the classification. Distinguishing "genuinely viewing a fold's back side through an
opening" from "ordinary far side of otherwise-normal closed geometry" needs a different signal
than face normals alone — e.g. an explicit occlusion/visibility signal from HLR itself, not yet
investigated. The "Rotated Box w/Boundary" case still shows sharp where crease would be more
correct, and is flagged here as a known open issue rather than silently left as-is.


Generated with the assistance of an AI coding tool.

sboddy commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Just for reference, this was the test scene before starting Round 2 of fixes:

sboddy commented Jul 15, 2026
edited
Loading

Copy link
Copy Markdown
Contributor Author

Update: Issue 4 is now fixed too

(Image added by the meatware)

Following up on the previous comment, which left "Rotated Box w/Boundary" (folds seen from
behind through an opening) as a known-open problem — that's now fixed in d2b02c743d, and the
earlier diagnosis of why the naive attempt broke things turned out to be slightly wrong. Worth
correcting for anyone reading back through this PR later.

What was previously believed: that bucket-reassigning an edge from sharp to crease could
somehow interact with HLR's own occlusion computation, making it fundamentally impossible to
tell "genuinely viewed through a hole" apart from "ordinary far side of closed geometry" using
face normals alone.

What's actually true, on re-tracing the pipeline: classification happens entirely pre-HLR,
edges are bucketed by class, and a single global HLR computation runs once per view over the
whole accumulated shape. After that, each class bucket is just a read-only query key
(VCompound(S)/OutLineVCompound(S)) against the already-computed result. Reclassifying an
edge's bucket can never make a genuinely hidden edge appear, or vice versa — visibility is fully
decided before classification's output is even consulted.

The real cause of the previous corruption: an asymmetric-threshold artifact. This project's
two thresholds are deliberately asymmetric — ridge_angle_min_deg defaults to 45°,
valley_angle_min_deg to 12°. The old unconditional flip took any edge satisfying
back0 && back1 and negated its deviation regardless of magnitude. A gentle ~20° convex facet
transition on a curved/faceted surface (icosphere, cylinder, cone) safely sits under the 45°
ridge threshold and is correctly flush — but once flipped, that same 20° gets re-tested against
the 12° valley threshold instead, and 20 > 12, so it wrongly becomes crease. That's what
produced "dozens of spurious creases" on a fully-convex icosphere that has no opening at all.

The fix: gate the flip so it can only reinterpret a fold that would already have been visible
(sharp or crease) under its own pre-flip threshold. Gentle tessellation-noise deviations that are
correctly flush either way never cross the asymmetric threshold gap, because they never reach
the flip.

Verified against the full test scene (per-object comparison via named <g ifc:name=...>
groups, not just aggregate counts — a lesson from earlier in this PR): every object's
classification is byte-for-byte identical to a flip-disabled baseline except "Rotated Box
w/Boundary", which went from {boundary: 4, outline: 4, sharp: 4} to
{boundary: 4, outline: 4, sharp: 1, crease: 3} — the 3 interior lines seen through the opening
now correctly read as crease, matching the original report.

All four issues raised against the real-world test scene are now fixed.


Generated with the assistance of an AI coding tool.

sboddy commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Update: fixed missing silhouette on circular-profile columns/piles

Found while testing against a real model: IfcColumn/IfcPile elements with a circular
cross-section profile were missing their curved side edges entirely — not misclassified, just
absent. Other curved shapes tested so far (faceted-mesh cylinders/cones/torus) were unaffected.

Root cause: a circular profile produces a genuine analytic cylindrical TopoDS_Face (via
BRepPrimAPI_MakePrism over a real Geom_Circle), not a tessellated facet — the OpenCASCADE
kernel this serializer uses never facets circular profiles (that's CGAL-kernel-only). The
classification/extraction pipeline added for this feature is edge-identity-based end to end: the
pre-HLR loop only ever adds pre-existing edges to their class bucket, and
OutLineVCompound(S)/VCompound(S) later correlate visible output back to S by that same
identity. But a smooth analytic surface's silhouette isn't a pre-existing edge at all — HLR
synthesizes it on the fly by intersecting the surface with the view direction, and correlates it
by the originating face, not any edge. So once a product had any edge classified (which is
essentially always), the curved silhouette had nothing to key off in its bucket and was silently
dropped.

Fix: added a face-level pass alongside the existing edge loop — any face whose surface isn't
Geom_Plane gets added directly into the outline bucket, giving OutLineVCompound a face
identity to correlate the smooth silhouette against.

Verified against a real test scene with 3 columns + 1 pile:

  • Column 1: IfcRectangleProfileDef (planar) — unaffected, as expected
  • Column 2: IfcCircleHollowProfileDef (genuine curved inner+outer walls) — was broken
  • Column 3: IfcRectangleHollowProfileDef (planar) — unaffected, as expected
  • Pile: IfcCircleProfileDef (genuine curved wall) — was broken

This lines up exactly with the report: only the two objects with real circular profiles were
affected. Diffing the whole scene's classified SVG output before/after the fix, only two lines in
the entire drawing changed — the previously-missing tangent/silhouette line on each of the two
broken objects — everything else, including the two working columns, is byte-for-byte identical.


Generated with the assistance of an AI coding tool.

sboddy commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Update: settings now exposed in the Bonsai UI

Following up on the previous rounds, this adds the interface plumbing requested: a master on/off
toggle for the whole classification feature, plus per-class render toggles, all stored in
EPset_Drawing on the drawing's camera annotation.

What's new

EPset_Drawing property Type Default UI control
UseEdgeClassification bool false master toggle
RenderCreases bool true shown once master is on
ValleyAngleMinDegrees real 12.0 shown once master is on
RenderSharp bool true shown once master is on
RidgeAngleMinDegrees real 45.0 shown once master is on
RenderFlush bool false shown once master is on

The master toggle defaults off, so existing drawings and files are unaffected until a user
opts in. When off, the classification code path is skipped entirely (not just individually
suppressed edge-by-edge), so it falls through to the original, pre-classification whole-shape
output — verified via a full-scene diff showing zero class="..." attributes on any path in that
state. Boundary and outline have no render toggle, since it never makes sense to turn those off.

A bug found (and fixed) along the way — corrected from an earlier overstatement

While wiring the master toggle through, live-testing in Blender (not just the IfcConvert CLI,
which is how every earlier round was verified) surfaced that SvgSerializer::ready() — where
geometry_settings() actually gets read into the serializer's member variables — was only ever
invoked explicitly by IfcConvert's own CLI driver, and isn't exposed to Python at all. Bonsai
constructs the serializer directly through the Python bindings and never called anything
equivalent.

I initially described this as making classification "non-functional through Bonsai," which was
wrong and got corrected after the reporter pointed out their own rounds 2-4 testing was done
entirely through Bonsai and clearly worked. The real story is narrower: before this round there
was no on/off toggle at all (classification always ran unconditionally), and the three
pre-existing settings' hardcoded constructor defaults (45.0/12.0/false) happen to be
identical to their registered defaults — so even with ready() never firing, the values already
in the serializer were correct for the default case, which is all prior rounds ever exercised.
What the bug actually breaks is changing a value away from default through the Python path (e.g.
a previously-dormant, never-persisted operator redo-panel slider for the thresholds) — that would
have been silently discarded.

The genuinely critical impact is on this round's new master toggle specifically: its constructor
default is false, and without this fix that default would never be overwritten by whatever a
user actually set the checkbox to — the toggle would have been permanently stuck off. Fixed by
calling ready() from SvgSerializer's own constructor (safe, since it only reads
geometry_settings() with no other side effects, and settings are always finalized before
construction on every call path). Verified the CLI's output is still byte-identical after this
change, and a live headless Blender smoke test now confirms all five settings correctly control
drawing output through Bonsai's real create_drawing operator.

Verification

  • 85/85 automated pytest cases pass (2 new, covering pset defaults and round-tripping).
  • Full IfcConvert CLI matrix: master off → zero classification attributes; master on with
    defaults → byte-identical to the already-verified previous state; each render toggle
    individually confirmed to add/remove only its own class's paths, nothing else.
  • Live headless Blender smoke test through the actual bpy.ops.bim.create_drawing() operator,
    toggling the real camera properties.

Generated with the assistance of an AI coding tool.

sboddy marked this pull request as ready for review July 16, 2026 15:43

sboddy commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Final review: lessons learned across this PR

@sboddy asked for a closing retrospective now that manual testing confirms everything working, in
the interest of keeping this process transparent. Posting the honest version, mistakes included,
since that's more useful to future readers than a highlight reel.

What worked well

  • The core architecture held up. Classifying edges pre-HLR on real face topology, then
    correlating visible output back to a class via OutLineVCompound(S)/VCompound(S) queries
    keyed by original-edge (and later, non-planar-face) identity, turned out to be a sound design.
    Every fix across rounds 2-5 was purely additive to this structure — none of them required
    touching the classify/bucket/extract split itself, which kept regression risk low and made it
    possible to verify each change with a clean "diff the whole scene, expect near-zero unrelated
    changes" methodology.
  • Per-object, per-class diffing caught things aggregate counts hid. Early on, whole-scene
    totals looked stable across a few different sign-logic attempts, which briefly read as "no
    effect" when actually two objects' counts were moving in opposite directions and cancelling out
    in the aggregate. Switching to named-group (--svg-xmlns) per-object comparisons became the
    standing verification method for the rest of the work, and it's what made the round 4/5
    byte-for-byte full-scene diffs trustworthy.
  • Ground-truth checks caught a subtly wrong heuristic. The tangent/orientation-based
    convexity sign test looked self-consistent but was independently confirmed wrong by checking it
    against a shape whose correct answer was known in advance (every edge of a convex icosphere
    should read convex) — it didn't. Cheap, independent ground truth beats a heuristic that merely
    looks plausible.

Mistakes made, and what they cost

  • A verification test case that couldn't have caught its own bug. The original ridge/valley
    sign fix was checked against a 90°-fold test case — which happens to be the fixed point of the
    exact inversion bug that shipped (180 - x leaves 90 unchanged). The bug was real, shipped, and
    only surfaced later against real geometry with non-90° angles. Lesson carried forward: pick
    verification cases that would actually fail if the logic were wrong, not just a convenient
    round number.
  • A misdiagnosis that delayed the right fix by a full round. The first attempt at "folds seen
    through an opening should read as the opposite class" was reverted with a writeup blaming a
    fundamental inability to distinguish real occlusion from ordinary geometry. Re-examining the
    actual pipeline later showed that reasoning was off — bucket membership can't affect HLR's own
    visibility computation at all. The actual cause was a plain threshold-asymmetry artifact (ridge
    and valley thresholds default to different values, and an unconditional sign flip pushed small,
    correctly-suppressed deviations across the gap). The fix, once correctly diagnosed, was a
    same-day, few-line change. Worth remembering: a plausible-sounding architectural explanation for
    a bug is not the same as having actually traced the mechanism.
  • An overstated bug description in this round's own summary, corrected directly by the
    reporter: I described the ready()-never-called-via-Python issue as making classification
    "non-functional through Bonsai," when the reporter's own rounds 2-4 testing was proof that
    wasn't true. The real scope was narrower — the pre-existing settings' hardcoded constructor
    defaults happened to equal their registered defaults, so default-case usage was unaffected; only
    values changed away from default (and this round's new master toggle, whose default is
    deliberately false) were actually at risk. Good reminder to size a bug's blast radius against
    direct evidence before writing it down, not just against what the code plausibly could have
    done.
  • CLI-only verification had a structural blind spot. Every round through round 4 was verified
    via IfcConvert, which explicitly calls the serializer's settings-loading step itself. That
    step is never invoked anywhere in the Python bindings, so no amount of additional CLI testing
    could ever have surfaced the bug above — it was only visible by testing through the actual
    consuming application (a live headless Blender run through Bonsai's real operator). Fast,
    scriptable CLI verification is great for iterating on logic, but it isn't a substitute for
    exercising the real integration path at least once before calling a feature done.

Net

Four real-world rendering bugs fixed and verified against a dedicated stress-test scene, one
architectural dead-end correctly identified and reverted rather than shipped broken, one
previously-hidden cross-language wiring bug found and fixed, and the feature is now fully
opt-in-by-default and configurable from the Bonsai UI. Thanks for the thorough manual testing
throughout — the real-model test cases (especially the circular-profile columns/pile) caught
things no amount of synthetic testing would have found.


Generated with the assistance of an AI coding tool.

sboddy commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

A final comment from the responsible meatbag.

I've done a good round of testing on a real model, exercising the options and looking for any outstanding issues. Nothing found so far.

It is worth highlighting that this will require the ifcopenshell binary to be bumped in the unstable (and eventually stable) releases.


Couple of notes from the meatbag on using AI for such a problem.

  • Craft your prompts - Your initial prompt should explain as much as you can think of, of what you are trying to achieve.
  • Use plan mode - Do not wing it step-by-step in the normal interactive mode. Even if your prompt is a bit lacking, plan mode allows you to review the AI's strategy, spot holes, or things you haven't detailed well, and improve the plan before wasting cycles.
  • Check the work - Don't assume that a completed plan achieves all your intent. You probably missed something. Either you forgot to include it, or it doesn't become apparent till later. This change took one initial plan, four plans to get everything right/perfect, plus one plan to plumb in UI stuff.
  • Educate the AI - Generate lessons learned, and ensure your AI remembers these for future tasks. It will not automatically add these to it's memory so tell it to remember stuff to help it (and you) in the future.

sboddy and others added 7 commits July 18, 2026 01:33
Adds boundary/outline/sharp/crease/flush classification of HLR
projection edges in SvgSerializer, so CSS can style silhouettes,
ridges, and valleys differently instead of drawing every edge
identically (fixes the "ugly faceted sphere" problem from IfcOpenShell#3668).

Classification happens pre-HLR on the original solid's real face
topology (three prior attempts tried to classify HLR's own output,
which carries no face topology at all and can't be correlated back
by edge identity). Each class's visible portion is then extracted via
HLRBRep_HLRToShape::VCompound(S)/OutLineVCompound(S), the same
per-shape filtering mechanism already used for per-product
segmentation, applied per class instead. Classes are tagged directly
on individual <path> elements so Bonsai's merge_linework_and_add_metadata
group-level class rewrite in operator.py never touches them.

New settings: svg-ridge-angle-min-degrees, svg-valley-angle-min-degrees,
svg-emit-flush-edges (ConversionSettings.h), wired through Bonsai's
CreateDrawing operator and exposed via its redo panel.

Refs IfcOpenShell#3668.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes three bugs in classify_edge_from_faces() found via real-world
testing against a dedicated stress-test scene (icosphere, Suzanne,
cylinders/cones at various orientations, a dihedral-angle sweep rig):

- The outline (silhouette) test used a bare sign comparison, so a face
  at or near exactly edge-on to the camera could land on the wrong
  side of zero and fall through to angle-based classification instead
  of being drawn as outline. Now uses a tolerance band around zero,
  matching an equivalent check already used elsewhere in this file.
- The signed deviation-from-flat formula was inverted (180 - angle
  instead of angle), so small, genuinely near-flat facet angles came
  out with a large computed deviation and always classified as
  sharp/crease, never flush. This is why thresholds appeared to have
  no effect. Also replaced the edge/wire-orientation-based convexity
  sign (unreliable on real BRep topology, verified wrong against a
  known fully-convex icosphere) with a simpler position-based test.
- A specific edge that was previously missing entirely (not just
  misclassified) reappears correctly as a side effect of the outline
  fix above; no separate change was needed for it.

A fourth issue (folds viewed through an opening, e.g. a box missing a
face, should read as crease rather than sharp) was attempted via a
back-facing sign flip, but reverted: it broke the fixes above broadly,
since "both faces back-facing" isn't a rare look-through-a-hole case
once HLR has already filtered to visible edges only. Documented in a
code comment for whoever picks this up next.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Re-enable the view-relative sign flip for folds seen through an
opening (e.g. a box with a face removed), reverted in the previous
commit after it corrupted unrelated geometry. The earlier revert's
diagnosis was slightly off: bucket reassignment can't affect HLR's
own visibility computation, so the corruption was actually an
asymmetric-threshold artifact -- an unconditional flip re-tested
small, correctly-flush deviations against the much smaller valley
threshold instead of the ridge one. Gating the flip so it only
reinterprets folds that already clear their own pre-flip threshold
fixes the box case while leaving every other test object's
classification unchanged (verified against the full test scene).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Circular-profile IfcColumn/IfcPile elements produce a genuine
analytic cylindrical BRep face (via BRepPrimAPI_MakePrism), not a
tessellated facet. The edge classification/extraction pipeline is
edge-identity-based end to end, but a smooth surface's silhouette is
synthesized by HLR on the fly and has no corresponding pre-existing
edge to bucket, so it was silently dropped once any edge in the
product had been classified. Add a face-level pass that includes any
non-planar face directly in the outline bucket, giving HLR's
per-face OutLine reconstruction a face identity to correlate
against. Purely additive: diffing the whole test scene's output
before and after shows only the two previously-missing tangent
lines appear, nothing else changes.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add svg-use-edge-classification (default off, preserving today's
linework), svg-render-crease-edges, and svg-render-sharp-edges
settings, gating the existing 5-class classification feature so it
can be disabled entirely (falling back to the pre-classification
whole-shape output) or have individual classes suppressed.

Also fixes a bug uncovered while wiring this into Bonsai: ready(),
where geometry_settings() actually gets read into the serializer,
was only ever invoked explicitly by IfcConvert's CLI driver and
isn't exposed to Python. Every Svg* setting -- including the three
from previous rounds -- silently stayed at its hardcoded constructor
default when the serializer was constructed directly through the
Python bindings, as Bonsai does. Fixed by calling ready() from
SvgSerializer's own constructor, safe since it only reads
geometry_settings() with no other side effects, and settings are
always finalized before construction in every call path.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add UseEdgeClassification, RenderCreases, ValleyAngleMinDegrees,
RenderSharp, RidgeAngleMinDegrees, and RenderFlush to EPset_Drawing,
following the existing HasUnderlay/DPI/PerspectiveShiftX pattern.
The master toggle defaults off, preserving current linework output;
the three dependent controls only show in the panel once it's on.

Removes the previous dormant, transient operator-redo properties for
the ridge/valley thresholds and flush-edge toggle, which were never
persisted per-drawing or exposed in any panel, replacing them with
the persistent camera properties read in setup_serialiser().

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The crease and sharp weighting seemed flipped to my sensibilities, so
now crease is heavier than sharp. I also added a commented out block
for debug colours in case someone wants to quickly use bright colours
to diagnose future problems.

aothms commented Jul 18, 2026

Copy link
Copy Markdown
Member

This is honestly quite nice and self-contained (conceptually at least) on the C++ side. I don't mind merging this in.

But I was also already speaking to @Moult about this. The long term plan is:

  1. Build a convenient data structure (mesh + dual graph) on top of TriangulationElement ifc5d: measure segment length from geometry when it is not a supported extrusion #8332 (comment)
  2. Index these into a Kd tree or other acceleration structure (what ever state of the art HLR these days needs)
  3. Implement our own HLR and render to SVG

What you built here in SvgSerializer.cpp:

TopExp::MapShapesAndAncestors(*compound_to_hlr, TopAbs_EDGE, TopAbs_FACE, edge_face_map);

is essentially this dual graph (faces being the nodes) I mentioned at point 1.

This polyhedral triangulation output [0] can also be used so that faces are not necessary triangles.

[0]

const bool polyhedral_output_with_holes = settings.get<settings::TriangulationType>().get() == settings::POLYHEDRON_WITH_HOLES && is_planar;
const bool polyhedral_output_without_holes = settings.get<settings::TriangulationType>().get() == settings::POLYHEDRON_WITHOUT_HOLES && is_planar && !has_inner_bounds;


So this is good and approved on my end for v0.8, but think about the next steps v0.9 (ifcviewer-wgpu is most ahead I think) for a custom polyhedron-based lineworks renderer.

sboddy merged commit 97a85fe into IfcOpenShell:v0.8.0 Jul 18, 2026
3 of 4 checks passed

sboddy commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

@aothms I just realised in my eagerness to merge I forgot the issue of needing a bump in the binary version. As a result it's broken a few (new) CI tests, which is kind of an unavoidable chicken and egg situation. I'm not even sure I have the rights to initiate a new build at builds.ifcopenshell.org. Could you take a look and bump if OK?
In the meantime I'll take a look at the new daily build and see that it still works without the new binary - it was all gated, but those settings on the Python side might also rely on the binary which would mean it won't work. If it doesn't I'll need to revert the merge.

aothms commented Jul 18, 2026

Copy link
Copy Markdown
Member

Yes this happens to me all the time. Just try except setting the settings on the python side with a note on when we can remove the try-except. I'll start a build but it will take a while for sure until it trickles down.

sboddy commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

I just tested, no harm, no foul. The drawing works even with the settings all active - no errors, or exceptions. It just doesn't do the fancy rendering, so it can just wait for the build to complete and get incorporated. I thought it'd be a problem, but it doesn't seem so. Strange to be honest... I was expecting fireworks.

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Drawing generation feature request : Having a crease threshold setting

2 participants


Back | FazBrowse Home | New Git URL