Files
2026-03-20 16:52:18 +01:00

191 lines
7.5 KiB
Python

# =============================================================================
# materials.py
# Reads renderMaterialProxies from the Speckle root object and applies
# IfcSurfaceStyle colours to IFC geometry.
#
# Structure of renderMaterialProxies:
# root.renderMaterialProxies = [
# {
# id: "636259b3..."
# value: RenderMaterial {
# name: "Glass"
# diffuse: -16744256 ← ARGB packed int (A=255, R=0, G=128, B=192)
# opacity: 0.1 ← 0=transparent, 1=opaque
# }
# objects: ["a1a6b0c2-...", "d5dd3127-...", ...] ← mesh applicationIds
# },
# ...
# ]
#
# Usage:
# mgr = MaterialManager(ifc, root)
# mgr.apply_to_item(brep_item, mesh_app_id)
# =============================================================================
import ifcopenshell
import ifcopenshell.api
from specklepy.objects.base import Base
from utils.helpers import _get
def _argb_to_rgb(argb_int: int) -> tuple[float, float, float]:
"""Unpack a signed ARGB int to normalised (R, G, B) floats 0..1."""
unsigned = argb_int & 0xFFFFFFFF
r = ((unsigned >> 16) & 0xFF) / 255.0
g = ((unsigned >> 8) & 0xFF) / 255.0
b = (unsigned & 0xFF) / 255.0
return r, g, b
class MaterialManager:
"""
Builds a lookup from mesh applicationId → IfcSurfaceStyle,
then applies styles to IFC geometry items.
"""
def __init__(self, ifc: ifcopenshell.file, root: Base):
self._ifc = ifc
# mesh applicationId (lowercase) → IfcSurfaceStyle (populated lazily)
self._style_map: dict[str, object] = {}
# name → IfcSurfaceStyle (cache to avoid duplicates)
self._style_cache: dict[str, object] = {}
self._apply_count = 0
self._miss_count = 0
self._build(root)
def _build(self, root: Base):
"""
Parse renderMaterialProxies and store raw material data keyed by mesh applicationId.
IFC styles are created lazily (only when actually assigned to geometry) to avoid
orphaned IfcSurfaceStyle instances that would fail IFC105 validation.
"""
proxies = _get(root, "renderMaterialProxies") or []
if not isinstance(proxies, list):
proxies = list(proxies) if proxies else []
# mesh applicationId (lowercase) → (name, diffuse_argb, transparency)
self._material_data: dict[str, tuple] = {}
for proxy in proxies:
material = _get(proxy, "value")
if material is None:
continue
name = _get(material, "name") or "Unnamed"
diffuse = _get(material, "diffuse")
opacity = _get(material, "opacity")
if diffuse is None:
continue
opacity_val = float(opacity) if opacity is not None else 1.0
transparency = max(0.0, min(1.0, 1.0 - opacity_val))
objects = _get(proxy, "objects") or []
for app_id in (objects if isinstance(objects, list) else []):
if app_id:
self._material_data[str(app_id).lower()] = (name, int(diffuse), transparency)
print(f" Materials: {len(self._material_data)} mesh mappings (styles created on demand)")
def _get_or_create_style(self, name: str, diffuse_argb: int, transparency: float):
"""Return cached style or create a new IfcSurfaceStyle."""
cache_key = f"{name}|{diffuse_argb}|{transparency:.4f}"
if cache_key in self._style_cache:
return self._style_cache[cache_key]
r, g, b = _argb_to_rgb(diffuse_argb)
style = ifcopenshell.api.run("style.add_style", self._ifc, name=name)
ifcopenshell.api.run(
"style.add_surface_style",
self._ifc,
style=style,
ifc_class="IfcSurfaceStyleRendering",
attributes={
"SurfaceColour": {"Name": None, "Red": r, "Green": g, "Blue": b},
"Transparency": transparency,
"ReflectanceMethod": "NOTDEFINED",
},
)
self._style_cache[cache_key] = style
return style
def get_style(self, mesh_app_id: str):
"""Return the IfcSurfaceStyle for a mesh applicationId (created on demand), or None."""
key = str(mesh_app_id).lower()
# Return already-created style if cached
if key in self._style_map:
return self._style_map[key]
# Create style now only if this mesh has material data
data = self._material_data.get(key)
if data is None:
return None
name, diffuse, transparency = data
style = self._get_or_create_style(name, diffuse, transparency)
self._style_map[key] = style
return style
def get_style_with_fallbacks(self, primary_app_id: str = None,
fallback_app_ids: list = None,
definition_id: str = None):
"""3-tier style lookup: primary → fallbacks → definition mapping."""
style = None
if primary_app_id:
style = self.get_style(primary_app_id)
if not style and fallback_app_ids:
for fid in fallback_app_ids:
style = self.get_style(fid)
if style:
break
if not style and definition_id:
style = self.get_style_by_definition(definition_id)
return style
def apply_to_item(self, item, mesh_app_id: str):
"""Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet)."""
style = self.get_style(mesh_app_id)
if style is None:
self._miss_count += 1
return
try:
ifcopenshell.api.run(
"style.assign_item_style",
self._ifc,
item=item,
style=style,
)
self._apply_count += 1
except Exception as e:
pass # Non-fatal — geometry still exports without colour
def build_definition_material_map(self, definition_map: dict):
"""
Build a mapping from definitionId → material data by resolving which
InstanceProxy objects the material proxy references and finding their definitionId.
This handles the case where renderMaterialProxies.objects references inner
InstanceProxy applicationIds rather than the top-level element applicationIds.
"""
by_app_id = definition_map.get("by_app_id", {})
self._definition_material: dict[str, tuple] = {} # definitionId → (name, diffuse, transparency)
for app_id_key, mat_data in self._material_data.items():
obj = by_app_id.get(app_id_key)
if obj is None:
continue
def_id = _get(obj, "definitionId")
if def_id and isinstance(def_id, str):
self._definition_material[def_id.lower()] = mat_data
if self._definition_material:
print(f" Material definitionId mappings: {len(self._definition_material)}")
def get_style_by_definition(self, definition_id: str):
"""Return IfcSurfaceStyle for a definitionId (created on demand), or None."""
if not hasattr(self, '_definition_material'):
return None
key = str(definition_id).lower()
data = self._definition_material.get(key)
if data is None:
return None
name, diffuse, transparency = data
return self._get_or_create_style(name, diffuse, transparency)
def print_stats(self):
print(f" Materials applied: {self._apply_count}, missed: {self._miss_count}")