Skip to content

Bonsai: emit one SVG class per metadata value, and per layer where cut - #9494

Open
theoryshaw wants to merge 3 commits into
IfcOpenShell:v0.9.0from
theoryshaw:svg_class_per_value
Open

theoryshaw wants to merge 3 commits into
IfcOpenShell:v0.9.0from
theoryshaw:svg_class_per_value

Conversation

@theoryshaw

@theoryshaw theoryshaw commented Sep 14, 2026

Copy link
Copy Markdown
Member

Stacked on #9493, and should land after #9044.

This branch carries #9493's commit as well, because the layer-aware half here calls get_cut_material, which #9493 introduces — review the two commits after it (68f47d88, aa737cec), or wait for #9493 to merge and this reduces to those two. The second of them follows #9044's Category precedence, so merging this before #9044 leaves the two halves briefly inconsistent for models that set Category on the set item.

The problem

A drawing's EPset_Drawing.Metadata queries become CSS classes on each cut group. A query can resolve to several values — mats.Category on a layered wall — and the whole list was stringified into one class. A three-layer wall carrying a single Category came out as:

matsCategory-NoneNoneBrick

That matches no meaningful selector, and it's unstable: add a layer and the class changes. Separately, a list holding only Nonemats.Category on a beam whose material has no Category — produced matsCategory-None, because a one-element list of None is still truthy.

The change

One class per value, skipping None entries and anything left empty by canonicalisation, de-duplicated:

before  matsName-GMRedHollowClayBlocksAIRGAPBRICKMODULAR312x214x7124x223x8
        matsCategory-NoneNoneBrick
after   matsName-GMRedHollowClayBlocks  matsName-AIRGAP  matsName-BRICKMODULAR312x214x7124x223x8
        matsCategory-Brick

Single-material elements are unchanged, so the common case sees no churn. Beams with an uncategorised material now emit no matsCategory class instead of matsCategory-None.

Material queries resolve per layer where the cut is one material layer. Otherwise a slab's concrete layer is tagged with the gravel layer's classes too — true of the element, false of that cut — and once those classes match selectors, whichever rule sits later in the stylesheet paints it wrongly.

This was found live rather than by inspection. A footing merged with a slab's concrete layer rendered with the gravel pattern:

423  .matsCategory-Gravel   { fill: url(#gravel); }
447  .matsCategory-Concrete { fill: url(#concrete); }
459  .matsCategory-Gravel   { fill: url(#gravel); }   <- later, wins

because the merged group inherited matsCategory-Gravel from the slab element. After the change each layer group describes itself:

slab, concrete layer group : matsName-CONCRETETHICKEDENEDGE      matsCategory-Concrete
slab, vapour layer group   : matsName-10MILVAPORBARRIER…GRAVEL   matsCategory-Gravel
slab, whole-element group  : both names, both categories          (unchanged - no layer)

The whole-element cut group of a multi-material element still carries every value, which is right: that cut really does span them all.

Why per-layer here, when #9493 deliberately did not redirect material.*

Worth stating, since the two decisions look contradictory. In JoinCriteria the query runs against an element, and which layer is in play is invisible in the query text — so redirecting the word would hide something the author cannot see, which is why that PR introduced an explicit cut_material instead. Here the <g> is the layer: every other class on it (IfcMaterialLayer, layer-material-*) already describes the layer, so a class claiming otherwise is simply wrong about the element it is attached to.

If reviewers prefer symmetry, the alternative is to teach Metadata the cut_material. head too and leave mats.* alone — but then existing Metadata settings keep producing the mis-tagging until each user edits them.

Behaviour change worth flagging

This makes previously dead classes live. Any element with several materials only ever emitted the concatenated form, which matched nothing; those elements will now match single-value selectors already present in stylesheets. That is the point of the change, but it can alter existing drawings — in the test project, 43 elements begin matching a .matsCategory-* fill rule they never matched before.

Related

  • material.Category doesn't seem to work in EPset_Drawing's Metadata parameter.  #3844 — Dion noted the same wart from the other end: "If you use mats.Name it will turn up as things like matsName-Testy1MaterialTesty2Material."

  • util.selector: resolve mats.Category via the material set item #9044 — resolves mats.Category through the material set item, now with a fallback to the material's own Category (25b2b4aa, added after I reported a model where that data would otherwise be lost). The second commit here mirrors that precedence for layer groups, so the same query gives the same answer whether or not the cut happens to be a layer. Verified together against that model:

    poche wall (layer has Category, material does not)
      element mats.Category  -> ['Materials']
      layer group class      -> matsCategory-Materials
    brick wall (materials have Category, layers do not)
      element mats.Category  -> [None, None, 'Brick']
      brick layer group      -> matsCategory-Brick
    slab
      concrete layer group   -> matsCategory-Concrete
      vapour layer group     -> matsCategory-Gravel
    
  • Bonsai: per element drawing appearance via assignable CSS classes #8800 — also extends get_svg_classes; no overlap with these lines.

🤖 Generated with Claude Code

theoryshaw and others added 2 commits September 13, 2026 17:23
Fixes IfcOpenShell#7830. Addresses the second half of IfcOpenShell#7000.

A material layer's cut linework could never merge with a non-layered element of
the same material, no matter what JoinCriteria was set to. The key was built by
evaluating every criterion against the element, then, if the cut was a material
layer, evaluating every criterion again against the layer and appending those
values. A layered cut's key tuple was therefore roughly twice the length of an
unlayered one's, so the two could not hash equal even when every value agreed.

The list was also doing two unrelated jobs at once: controlling which elements
merge, and which layers stay apart. It only worked because "material.Name" and
"Material.Name" happened to cancel at the element level while diverging at the
layer level - "Material.Name" resolves to None on a wall, but to the layer's own
IfcMaterial on an IfcMaterialLayer, purely because "Material" is a real
attribute there. That was added in IfcOpenShell#7700 to fix IfcOpenShell#7252 and IfcOpenShell#7369, where every
layer hashed alike and collapsed into one polygon.

Introduce "cut_material": the material of the cut itself - that layer's material
for a layered cut, or the element's own material where it has exactly one. The
layer pass goes away, so the key is one tuple of fixed length either way, and
the same material keys identically whether or not it arrived as a layer. This
replaces both "material.Name" and "Material.Name" in the default criteria;
layers still keep each other apart, so IfcOpenShell#7252 and IfcOpenShell#7369 do not regress.

"cut_material" is deliberately join-local vocabulary rather than a new selector
key. Which material a cut carries depends on which layer is being drawn, which
is drawing state no query over the element alone can express, and the drawing
join code is the only caller in the tree that ever passed a non-product entity
to get_element_value. Keeping it separate leaves "material.Name" meaning the
layer set name here exactly as it does in Metadata, IfcCSV and text literals.

Where nothing identifies a cut's material - no material at all, or several with
unlayered geometry - the IFC class is appended instead, so unidentified cuts do
not all share one key and merge on contact.

With EPset_Drawing.JoinClasses (IfcOpenShell#4395) naming the classes and "class" omitted
from JoinCriteria, a slab's concrete layer now reads continuous into a concrete
footing. Verified on a real project: the two footings and the slab's concrete
layer merge into a single closed path, while the slab's vapour barrier layer,
the wall layers, and the unlayered slab outline all stay separate.

Note one behaviour change under the default criteria: a layered cut is now keyed
on its layer's material alone, where previously the layer set name also
participated. Two different layer sets sharing a material will merge where they
touch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A drawing's EPset_Drawing.Metadata queries become CSS classes on each cut group.
A query may resolve to several values - "mats.Category" on a layered wall, say -
and the whole list was being stringified into one class, so a three layer wall
carrying a single Category came out as:

    matsCategory-NoneNoneBrick

That matches no meaningful selector, and it is unstable: adding a layer changes
the class. A list holding only None, such as "mats.Category" on a beam whose
material has no Category, produced "matsCategory-None", because a list of one
None is still truthy.

Emit one class per value instead, skipping None entries and any value with
nothing left after canonicalisation, and de-duplicating. The wall above now
gets matsCategory-Brick, and the beam gets no matsCategory class at all.

Material queries are also resolved against the layer's own material where the
cut is a single material layer. Otherwise the concrete layer of a slab is tagged
with the gravel layer's classes as well - true of the element, but not of that
cut - and now that those classes match CSS selectors, whichever rule appears
later in the stylesheet paints the layer wrongly. This was found live: a footing
merged with a slab's concrete layer rendered with the gravel pattern, because
the merged group inherited matsCategory-Gravel from the slab element and the
gravel rule sits after the concrete rule in the stylesheet.

The whole element cut group of a multi material element still carries every
value, which is correct - that cut really does span them all.

Note this makes previously dead classes live. Any element with several materials
was only ever emitting the concatenated form, which matched nothing; such
elements will now match single value selectors that were already in a
stylesheet. That is the point of the change, but it can alter existing drawings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows the precedence util.selector uses for "mats.Category" since IfcOpenShell#9044:
Category is defined on both the material set item and the IfcMaterial it
references, and the set item wins where it is set.

Without this a layer group read Category from the material only, so a layer
that carries its own Category emitted no class while the element level query
reported one - the same query giving two answers depending on whether the cut
happened to be a layer, which is what the per layer resolution was meant to
stop. Found on a wall whose GENERIC POCHE layer is categorised 'Materials'
while the material itself has no Category.

Name is unaffected and still comes from the material, which is where it lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant