update materials and instances

This commit is contained in:
NLSA
2026-03-20 09:46:23 +01:00
parent b433b91902
commit 69945259d2
8 changed files with 311 additions and 154 deletions
+28 -43
View File
@@ -13,16 +13,7 @@
import ifcopenshell
from specklepy.objects.base import Base
# Scale factors → MILLIMETRES (IFC file is declared as mm)
_UNIT_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0, "centimeters": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4,
}
from utils.helpers import _get, MM_SCALES as _UNIT_SCALES
# --------------------------------------------------------------------------- #
@@ -97,30 +88,6 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
return []
# --------------------------------------------------------------------------- #
# Safe data access helpers
# --------------------------------------------------------------------------- #
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects.
Tries attribute access first, then bracket access.
"""
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
def unwrap_chunks(raw) -> list:
"""
Flatten a Speckle data array into a plain Python list of numbers.
@@ -371,14 +338,21 @@ def mesh_to_ifc(
if not meshes:
return None, None
# Parent object's applicationId — used as fallback for material lookup
# when inner meshes (e.g. from BrepX) don't have their own applicationId
obj_app_id = _get(obj, "applicationId")
obj_scale = _resolve_scale(obj, scale)
# ------------------------------------------------------------------ #
# Pass 1: unpack vertices once per mesh, collect all scaled coords
# to compute world origin. Cache (verts, ms) for Pass 2.
# Pass 1: unpack and scale vertices once per mesh, compute origin
# incrementally without accumulating all vertices in memory.
# ------------------------------------------------------------------ #
mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh
all_scaled = []
xmin = ymin = zmin = float("inf")
xmax = ymax = float("-inf")
has_verts = False
for mesh in meshes:
raw_verts = _get(mesh, "vertices") or []
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
@@ -386,15 +360,25 @@ def mesh_to_ifc(
mesh_cache.append(None)
continue
ms = _resolve_scale(mesh, obj_scale)
# Pre-scale vertices once, reuse in Pass 2
scaled = [float(v) * ms for v in verts]
mesh_cache.append((verts, ms, scaled))
all_scaled.extend(scaled)
has_verts = True
if not all_scaled:
# Update bounding box from this mesh's scaled vertices
for i in range(0, len(scaled) - 2, 3):
x, y, z = scaled[i], scaled[i + 1], scaled[i + 2]
if x < xmin: xmin = x
if x > xmax: xmax = x
if y < ymin: ymin = y
if y > ymax: ymax = y
if z < zmin: zmin = z
if not has_verts:
return None, None
ox, oy, oz = compute_origin(all_scaled)
ox = (xmin + xmax) / 2.0
oy = (ymin + ymax) / 2.0
oz = zmin
# ------------------------------------------------------------------ #
# Pass 2: one faceset per mesh — reuse cached verts, only unpack faces
@@ -414,7 +398,7 @@ def mesh_to_ifc(
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" ⚠️ Face decode error: {e}")
print(f" Warning: Face decode error: {e}")
continue
# Offset pre-scaled vertices relative to origin (flat list, no tuples)
@@ -431,8 +415,9 @@ def mesh_to_ifc(
continue
# Apply material style to every faceset of this mesh
# Inner meshes (from BrepX) may lack applicationId — fall back to parent's
if material_manager:
mesh_app_id = _get(mesh, "applicationId")
mesh_app_id = _get(mesh, "applicationId") or obj_app_id
if mesh_app_id:
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
+38
View File
@@ -0,0 +1,38 @@
# =============================================================================
# helpers.py
# Shared utilities used across the exporter modules.
# =============================================================================
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects, dicts, or any hybrid.
Tries attribute access first, then bracket access.
"""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
# Scale factors → MILLIMETRES (IFC file is declared as mm)
MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0, "centimeters": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4,
}
+94 -35
View File
@@ -21,8 +21,10 @@
# =============================================================================
import math
import ifcopenshell.api
from specklepy.objects.base import Base
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets, _get_shared
from utils.helpers import _get, MM_SCALES
from utils.geometry import unwrap_chunks, decode_faces, build_ifc_facesets, _get_shared
def is_instance(obj) -> bool:
@@ -40,15 +42,18 @@ def build_definition_map(root: Base) -> dict:
Build a unified definition map that handles both formats.
Returns dict with keys:
"by_id" : {obj_id_lower[:32] → object} for Revit format
"by_app_id" : {applicationId_lower → object} for Revit format
"ifc_proxies" : {"DEFINITION:xxx" → proxy} for IFC format
"ifc_meshes" : {meshAppId → Mesh} for IFC format
"by_id" : {obj_id_lower[:32] → object} for Revit format
"by_app_id" : {applicationId_lower → object} for Revit format
"ifc_proxies" : {"DEFINITION:xxx" → proxy} for IFC format
"ifc_meshes" : {meshAppId → Mesh} for IFC format
"definition_sources": set of applicationId (lowercase) that are definition
geometry sources — these should be skipped during export
"""
by_id = {}
by_app_id = {}
ifc_proxies = {}
ifc_meshes = {}
definition_sources = set() # applicationIds used as definition geometry (skip during export)
# --- Walk entire tree for Revit format ---
_collect_all(root, by_id, by_app_id, depth=0)
@@ -61,6 +66,11 @@ def build_definition_map(root: Base) -> dict:
if app_id:
ifc_proxies[app_id] = proxy # original case (for IFC format)
ifc_proxies[app_id.lower()] = proxy # lowercase (for Revit format)
# Collect all objects referenced by this proxy as definition sources
object_ids = _get(proxy, "objects") or []
for oid in (object_ids if isinstance(object_ids, list) else [object_ids]):
if oid:
definition_sources.add(str(oid).lower())
elements = _get(root, "elements") or _get(root, "@elements") or []
for child in (elements if isinstance(elements, list) else []):
@@ -75,12 +85,14 @@ def build_definition_map(root: Base) -> dict:
print(f" Objects indexed by appId: {len(by_app_id)}")
print(f" IFC definition proxies: {len(ifc_proxies)}")
print(f" IFC definition meshes: {len(ifc_meshes)}")
print(f" Definition sources: {len(definition_sources)}")
return {
"by_id": by_id,
"by_app_id": by_app_id,
"ifc_proxies": ifc_proxies,
"ifc_meshes": ifc_meshes,
"by_id": by_id,
"by_app_id": by_app_id,
"ifc_proxies": ifc_proxies,
"ifc_meshes": ifc_meshes,
"definition_sources": definition_sources,
}
@@ -117,11 +129,14 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
continue
def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
def _get_revit_meshes(definition_id: str, definition_map: dict) -> tuple:
"""
Revit format:
definitionId (64-char hex) → InstanceDefinitionProxy.applicationId
proxy.objects[0] is a UUID applicationId → find mesh by applicationId
Returns (meshes, app_ids) where app_ids are all applicationIds encountered
in the resolution chain (definition objects, geometry objects) for material fallback.
"""
from utils.geometry import get_display_meshes
@@ -129,19 +144,34 @@ def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
ifc_proxies = definition_map.get("ifc_proxies", {})
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
if proxy is None:
return []
return [], []
# Step 2: get the mesh applicationIds from proxy.objects
object_ids = _get(proxy, "objects") or []
if not isinstance(object_ids, list):
object_ids = list(object_ids)
# Step 3: look up each mesh by applicationId
# Step 3: look up each mesh by applicationId, collecting all encountered app IDs
by_app_id = definition_map.get("by_app_id", {})
meshes = []
encountered_app_ids = []
for oid in object_ids:
obj = by_app_id.get(str(oid).lower())
if obj is not None:
# Collect this object's applicationId
obj_aid = _get(obj, "applicationId")
if obj_aid:
encountered_app_ids.append(str(obj_aid))
# Also collect applicationIds from displayValue items (BrepX, etc.)
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display:
items = display if isinstance(display, list) else [display]
for item in items:
item_aid = _get(item, "applicationId")
if item_aid:
encountered_app_ids.append(str(item_aid))
break
# The found object may itself be a mesh, or contain displayValue meshes
found_meshes = get_display_meshes(obj)
if found_meshes:
@@ -149,20 +179,21 @@ def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
else:
# It IS the mesh directly
meshes.append(obj)
return meshes
return meshes, encountered_app_ids
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list:
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> tuple:
"""
IFC format: definitionId = "DEFINITION:224058_mat0"
Look up proxy → objects list → meshes from ifc_meshes dict.
Returns (meshes, []) — no extra app_ids needed, mesh applicationIds match directly.
"""
ifc_proxies = definition_map.get("ifc_proxies", {})
ifc_meshes = definition_map.get("ifc_meshes", {})
proxy = ifc_proxies.get(definition_id)
if proxy is None:
return []
return [], []
object_ids = _get(proxy, "objects") or []
result = []
@@ -170,7 +201,7 @@ def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list:
mesh = ifc_meshes.get(str(oid))
if mesh is not None:
result.append(mesh)
return result
return result, []
def _resolve_instance_scale(obj, stream_scale: float) -> float:
@@ -183,7 +214,7 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float:
try:
units = obj[key]
if units and isinstance(units, str):
s = _UNIT_SCALES.get(units.lower().strip())
s = MM_SCALES.get(units.lower().strip())
if s is not None:
return s
except Exception:
@@ -206,20 +237,13 @@ _rep_map_cache: dict = {}
_identity_placement_cache: dict[int, object] = {}
_MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "in": 25.4,
}
# --------------------------------------------------------------------------- #
# IfcRepresentationMap builder — geometry created once per definition
# --------------------------------------------------------------------------- #
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
material_manager=None):
material_manager=None, fallback_app_ids: list = None,
definition_id: str = None):
"""
Build an IfcRepresentationMap from definition meshes.
Geometry is in local coordinates (mm, no instance transform applied).
@@ -234,18 +258,18 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
else:
raw_verts = _get(mesh, "vertices") or []
raw_faces = _get(mesh, "faces") or []
verts = unwrap_chunks(list(raw_verts))
faces_raw = unwrap_chunks(list(raw_faces))
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
if not verts or not faces_raw:
continue
mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm")
ms = _MM_SCALES.get(mesh_units.lower().strip(), 1.0)
ms = MM_SCALES.get(mesh_units.lower().strip(), 1.0)
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" ⚠️ Instance face decode: {e}")
print(f" Warning: Instance face decode: {e}")
continue
# Scale vertices once and cache the result
@@ -260,11 +284,29 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
continue
# Apply material style to each faceset
# Try: mesh applicationId → fallback IDs → definitionId mapping
if material_manager:
mesh_app_id = _get(mesh, "applicationId")
style = None
if mesh_app_id:
style = material_manager.get_style(str(mesh_app_id))
if not style and fallback_app_ids:
for fid in fallback_app_ids:
style = material_manager.get_style(fid)
if style:
break
if not style and definition_id:
style = material_manager.get_style_by_definition(definition_id)
if style:
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
try:
ifcopenshell.api.run(
"style.assign_item_style", ifc,
item=fs, style=style,
)
material_manager._apply_count += 1
except Exception:
pass
geom_items.extend(mesh_facesets)
@@ -387,9 +429,9 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
# --- Get or build IfcRepresentationMap (cached per definition_id) ---
if definition_id not in _rep_map_cache:
if ifc_format:
meshes = _get_ifc_meshes(definition_id, definition_map)
meshes, extra_app_ids = _get_ifc_meshes(definition_id, definition_map)
else:
meshes = _get_revit_meshes(definition_id, definition_map)
meshes, extra_app_ids = _get_revit_meshes(definition_id, definition_map)
if not meshes:
_stats["not_found"] += 1
@@ -397,8 +439,17 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
return None, placement
_stats["found"] += 1
# Build fallback app_id list: instance's own + definition chain IDs
instance_app_id = _get(obj, "applicationId")
fallback_ids = []
if instance_app_id:
fallback_ids.append(str(instance_app_id))
fallback_ids.extend(extra_app_ids)
_rep_map_cache[definition_id] = _build_rep_map(
ifc, body_context, meshes, ifc_format, material_manager
ifc, body_context, meshes, ifc_format, material_manager,
fallback_app_ids=fallback_ids,
definition_id=definition_id,
)
else:
# Track stats even for cached definitions
@@ -454,11 +505,19 @@ def get_definition_object(obj: Base, definition_map: dict):
return source
def is_definition_source(obj, definition_map: dict) -> bool:
"""Return True if this object is a definition geometry source (should not be exported standalone)."""
app_id = _get(obj, "applicationId")
if not app_id:
return False
return str(app_id).lower() in definition_map.get("definition_sources", set())
def print_instance_stats():
total = _stats["found"] + _stats["not_found"]
print(f" Instance resolution: {_stats['found']}/{total} definitions found")
if _stats["not_found"] > 0:
print(f" ⚠️ {_stats['not_found']} instances had no definition geometry")
print(f" Warning: {_stats['not_found']} instances had no definition geometry")
def reset_caches():
+41 -17
View File
@@ -25,6 +25,7 @@
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]:
@@ -36,22 +37,6 @@ def _argb_to_rgb(argb_int: int) -> tuple[float, float, float]:
return r, g, b
def _get(obj, key, default=None):
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
class MaterialManager:
"""
Builds a lookup from mesh applicationId → IfcSurfaceStyle,
@@ -64,6 +49,8 @@ class MaterialManager:
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):
@@ -139,6 +126,7 @@ class MaterialManager:
"""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(
@@ -147,5 +135,41 @@ class MaterialManager:
item=item,
style=style,
)
self._apply_count += 1
except Exception as e:
pass # Non-fatal — geometry still exports without colour
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}")
+28 -23
View File
@@ -14,33 +14,13 @@
import ifcopenshell
import ifcopenshell.api
from specklepy.objects.base import Base
from utils.helpers import _get
# ---------------------------------------------------------------------------
# Safe access helpers
# ---------------------------------------------------------------------------
def _get(obj, key, default=None):
"""Safe access for both dicts and Speckle Base objects."""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
def _to_dict(obj) -> dict:
"""Convert a Speckle Base object or dict to a plain dict."""
if obj is None:
@@ -150,6 +130,20 @@ _UNIT_QTY_MAP = {
"degree": ("IfcQuantityCount", "CountValue"),
}
# Name keyword → IFC quantity type (used when no units are provided)
_NAME_QTY_MAP = {
"length": ("IfcQuantityLength", "LengthValue"),
"width": ("IfcQuantityLength", "LengthValue"),
"height": ("IfcQuantityLength", "LengthValue"),
"depth": ("IfcQuantityLength", "LengthValue"),
"perimeter": ("IfcQuantityLength", "LengthValue"),
"area": ("IfcQuantityArea", "AreaValue"),
"volume": ("IfcQuantityVolume", "VolumeValue"),
"volumn": ("IfcQuantityVolume", "VolumeValue"), # common typo
"weight": ("IfcQuantityWeight", "WeightValue"),
"mass": ("IfcQuantityWeight", "WeightValue"),
}
# ---------------------------------------------------------------------------
# Set IFC element attributes from _properties.Attributes
@@ -240,7 +234,10 @@ def _try_float(value):
def write_quantity_sets(ifc, element, obj):
"""Write all quantity sets from _properties.Quantities."""
props = get_properties(obj)
quantities_section = _to_dict(props.get("Quantities"))
quantities_raw = props.get("Quantities")
if quantities_raw is None:
return
quantities_section = _to_dict(quantities_raw)
if not quantities_section:
return
@@ -273,14 +270,22 @@ def write_quantity_sets(ifc, element, obj):
try:
mapping = _UNIT_QTY_MAP.get(units)
if not mapping:
# Infer quantity type from name keywords
name_lower = name.lower()
for keyword, m in _NAME_QTY_MAP.items():
if keyword in name_lower:
mapping = m
break
if mapping:
qty_type, value_attr = mapping
qty = ifc.create_entity(
qty_type, Name=name, **{value_attr: value}
)
else:
# CountValue requires int
qty = ifc.create_entity(
"IfcQuantityCount", Name=name, CountValue=value
"IfcQuantityCount", Name=name, CountValue=int(value)
)
quantities.append(qty)
except Exception as e:
+1 -1
View File
@@ -113,7 +113,7 @@ class StoreyManager:
products=[storey],
)
self._storeys[level_name] = storey
print(f" 🏢 Created storey: {level_name}")
print(f" Created storey: {level_name}")
return self._storeys[level_name]