mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-28 15:53:00 +00:00
882 lines
40 KiB
Python
882 lines
40 KiB
Python
# Bonsai - OpenBIM Blender Add-on
|
|
# Copyright (C) 2023 Dion Moult <dion@thinkmoult.com>
|
|
#
|
|
# This file is part of Bonsai.
|
|
#
|
|
# Bonsai is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# Bonsai is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
from __future__ import annotations
|
|
import os
|
|
import re
|
|
import math
|
|
import bpy
|
|
import bmesh
|
|
import ifcopenshell.geom
|
|
import ifcopenshell.util.element
|
|
import ifcopenshell.util.geolocation
|
|
import ifcopenshell.util.placement
|
|
import ifcopenshell.util.representation
|
|
import ifcopenshell.util.shape
|
|
import ifcopenshell.util.unit
|
|
import bonsai.core.tool
|
|
import bonsai.tool as tool
|
|
import bonsai.bim.import_ifc
|
|
import numpy as np
|
|
import numpy.typing as npt
|
|
from mathutils import Vector, Matrix
|
|
from pathlib import Path
|
|
from typing import Union
|
|
|
|
|
|
# Progressively we'll refactor loading elements into Blender objects into this
|
|
# class. This will break down the monolithic import_ifc module and allow us to
|
|
# partially load and unload objects for huge models, partial model editing, and
|
|
# supplementary objects (e.g. drawings, structural analysis models, etc).
|
|
|
|
|
|
OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve]
|
|
|
|
|
|
class Loader(bonsai.core.tool.Loader):
|
|
unit_scale: float = 1
|
|
settings: bonsai.bim.import_ifc.IfcImportSettings = None
|
|
|
|
@classmethod
|
|
def set_unit_scale(cls, unit_scale: float) -> None:
|
|
cls.unit_scale = unit_scale
|
|
|
|
@classmethod
|
|
def set_settings(cls, settings: bonsai.bim.import_ifc.IfcImportSettings) -> None:
|
|
cls.settings = settings
|
|
|
|
@classmethod
|
|
def get_mesh_name_from_shape(cls, geometry: ifcopenshell.geom.ShapeType) -> str:
|
|
representation_id = geometry.id
|
|
if "-" in representation_id:
|
|
# Example: 2432-openings-2468, where
|
|
# 2432 is mapped representation id
|
|
# and 2468 is IFCRELVOIDSELEMENT
|
|
representation_id = int(re.sub(r"\D", "", representation_id.split("-")[0]))
|
|
else:
|
|
representation_id = int(re.sub(r"\D", "", representation_id))
|
|
representation = tool.Ifc.get().by_id(representation_id)
|
|
context_id = representation.ContextOfItems.id() if hasattr(representation, "ContextOfItems") else 0
|
|
return cls.get_mesh_name(context_id, representation_id)
|
|
|
|
@classmethod
|
|
def get_mesh_name(cls, context_id: int, representation_id: int) -> str:
|
|
return "{}/{}".format(context_id, representation_id)
|
|
|
|
@classmethod
|
|
def get_name(cls, element: ifcopenshell.entity_instance) -> str:
|
|
if element.is_a("IfcGridAxis"):
|
|
return "{}/{}".format(element.is_a(), element.AxisTag)
|
|
return "{}/{}".format(element.is_a(), getattr(element, "Name", "None"))
|
|
|
|
@classmethod
|
|
def link_mesh(
|
|
cls,
|
|
shape: Union[ifcopenshell.geom.ShapeElementType, ifcopenshell.geom.ShapeType],
|
|
mesh: tool.Geometry.TYPES_WITH_MESH_PROPERTIES,
|
|
) -> None:
|
|
geometry = shape.geometry if hasattr(shape, "geometry") else shape
|
|
mesh.BIMMeshProperties.ifc_definition_id = int(geometry.id.split("-")[0])
|
|
|
|
@classmethod
|
|
def create_surface_style_shading(cls, blender_material, surface_style):
|
|
surface_style = cls.surface_style_to_dict(surface_style)
|
|
alpha = 1.0
|
|
# Transparency was added in IFC4
|
|
if transparency := surface_style.get("Transparency", None):
|
|
alpha = 1 - transparency
|
|
blender_material.diffuse_color = surface_style["SurfaceColour"] + (alpha,)
|
|
blender_material.use_nodes = False
|
|
|
|
@classmethod
|
|
def restart_material_node_tree(cls, blender_material):
|
|
nodes = blender_material.node_tree.nodes
|
|
links = blender_material.node_tree.links
|
|
for n in nodes[:]:
|
|
nodes.remove(n)
|
|
output = nodes.new("ShaderNodeOutputMaterial")
|
|
output.location = Vector((300, 300))
|
|
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
|
|
bsdf.location = Vector((10, 300))
|
|
links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
|
|
|
|
@classmethod
|
|
def surface_style_to_dict(cls, surface_style):
|
|
if isinstance(surface_style, dict):
|
|
return surface_style
|
|
surface_style = surface_style.get_info()
|
|
|
|
color_to_tuple = lambda x: (x.Red, x.Green, x.Blue)
|
|
|
|
def convert_ifc_color_or_factor(color_or_factor):
|
|
if color_or_factor is None:
|
|
return
|
|
if color_or_factor.is_a("IfcColourRgb"):
|
|
return ("IfcColourRgb", color_to_tuple(color_or_factor))
|
|
# IfcNormalisedRatioMeasure
|
|
return ("IfcNormalisedRatioMeasure", color_or_factor.wrappedValue)
|
|
|
|
# can be only IfcColourRgb
|
|
if surface_style["SurfaceColour"]:
|
|
surface_style["SurfaceColour"] = color_to_tuple(surface_style["SurfaceColour"])
|
|
|
|
if surface_style["type"] == "IfcSurfaceStyleShading":
|
|
return surface_style
|
|
|
|
# IfcSurfaceStyleRendering
|
|
# IfcColourOrFactor
|
|
surface_style["DiffuseColour"] = convert_ifc_color_or_factor(surface_style["DiffuseColour"])
|
|
surface_style["SpecularColour"] = convert_ifc_color_or_factor(surface_style["SpecularColour"])
|
|
|
|
if specular_highlight := surface_style["SpecularHighlight"]:
|
|
if specular_highlight.is_a("IfcSpecularRoughness"):
|
|
surface_style["SpecularHighlight"] = specular_highlight.wrappedValue
|
|
else: # discard IfcSpecularExponent value
|
|
surface_style["SpecularHighlight"] = None
|
|
|
|
# NOTE: IfcSurfaceStyleRendering also has following attributes but we ignore them
|
|
# as they're about to get deprecated:
|
|
# TransmissionColour, DiffuseTransmissionColour, ReflectionColour
|
|
|
|
return surface_style
|
|
|
|
@classmethod
|
|
def surface_texture_to_dict(cls, surface_texture):
|
|
if isinstance(surface_texture, dict):
|
|
return surface_texture
|
|
mappings = surface_texture.IsMappedBy or []
|
|
surface_texture = surface_texture.get_info()
|
|
uv_mode = None
|
|
if mappings:
|
|
coordinates = mappings[0]
|
|
if coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD":
|
|
uv_mode = "Generated"
|
|
elif coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD-EYE":
|
|
uv_mode = "Camera"
|
|
surface_texture["uv_mode"] = uv_mode or "UV"
|
|
return surface_texture
|
|
|
|
@classmethod
|
|
def create_surface_style_rendering(cls, blender_material, surface_style):
|
|
surface_style = cls.surface_style_to_dict(surface_style)
|
|
cls.create_surface_style_shading(blender_material, surface_style)
|
|
|
|
reflectance_method = surface_style["ReflectanceMethod"]
|
|
if reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
|
|
print(f'WARNING. Unsupported reflectance method "{reflectance_method}" on style {surface_style}')
|
|
return
|
|
|
|
# TODO: reset pins to default values if no values passed
|
|
if reflectance_method in ["PHYSICAL", "NOTDEFINED"]:
|
|
blender_material.use_nodes = True
|
|
cls.restart_material_node_tree(blender_material)
|
|
bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED")
|
|
|
|
if surface_style["DiffuseColour"]:
|
|
color_type, color_value = surface_style["DiffuseColour"]
|
|
if color_type == "IfcColourRgb":
|
|
bsdf.inputs["Base Color"].default_value = color_value + (1,)
|
|
else: # "IfcNormalisedRatioMeasure"
|
|
color_value = tuple(v * color_value for v in surface_style["SurfaceColour"])
|
|
bsdf.inputs["Base Color"].default_value = color_value + (1,)
|
|
|
|
if surface_style["SpecularColour"]:
|
|
color_type, color_value = surface_style["SpecularColour"]
|
|
if color_type == "IfcNormalisedRatioMeasure":
|
|
bsdf.inputs["Metallic"].default_value = color_value
|
|
# IfcColourRgb is ignored
|
|
|
|
if surface_style["SpecularHighlight"]:
|
|
bsdf.inputs["Roughness"].default_value = surface_style["SpecularHighlight"]
|
|
|
|
if transparency := surface_style.get("Transparency", None):
|
|
bsdf.inputs["Alpha"].default_value = 1 - transparency
|
|
blender_material.blend_method = "BLEND"
|
|
|
|
elif reflectance_method == "FLAT":
|
|
blender_material.use_nodes = True
|
|
cls.restart_material_node_tree(blender_material)
|
|
|
|
output = tool.Blender.get_material_node(blender_material, "OUTPUT_MATERIAL")
|
|
bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED")
|
|
|
|
mix = blender_material.node_tree.nodes.new(type="ShaderNodeMixShader")
|
|
mix.location = bsdf.location
|
|
blender_material.node_tree.links.new(mix.outputs[0], output.inputs["Surface"])
|
|
|
|
blender_material.node_tree.nodes.remove(bsdf)
|
|
|
|
lightpath = blender_material.node_tree.nodes.new(type="ShaderNodeLightPath")
|
|
lightpath.location = mix.location - Vector((200, -200))
|
|
blender_material.node_tree.links.new(lightpath.outputs[0], mix.inputs[0])
|
|
|
|
bsdf = blender_material.node_tree.nodes.new(type="ShaderNodeBsdfTransparent")
|
|
bsdf.location = mix.location - Vector((200, 150))
|
|
blender_material.node_tree.links.new(bsdf.outputs[0], mix.inputs[1])
|
|
|
|
rgb = blender_material.node_tree.nodes.new(type="ShaderNodeRGB")
|
|
rgb.location = mix.location - Vector((200, 250))
|
|
blender_material.node_tree.links.new(rgb.outputs[0], mix.inputs[2])
|
|
|
|
if surface_style["DiffuseColour"]:
|
|
color_type, color_value = surface_style["DiffuseColour"]
|
|
if color_type == "IfcColourRgb":
|
|
rgb.outputs[0].default_value = color_value + (1,)
|
|
|
|
@classmethod
|
|
def create_surface_style_with_textures(cls, blender_material, rendering_style, texture_style):
|
|
"""supposed to be called after `create_surface_style_rendering`"""
|
|
if not isinstance(texture_style, list): # assume it's IfcSurfaceStyleWithTextures
|
|
textures = [cls.surface_texture_to_dict(t) for t in texture_style.Textures]
|
|
else:
|
|
textures = texture_style
|
|
rendering_style = cls.surface_style_to_dict(rendering_style)
|
|
|
|
# `rendering_style` is a dict and `textures` is a list of dicts
|
|
# containing ifc data, that way method can be called by just providing those dictionaries
|
|
# without actually changing IFC data
|
|
|
|
reflectance_method = rendering_style["ReflectanceMethod"]
|
|
if reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
|
|
print(f'WARNING. Unsupported reflectance method "{reflectance_method}" on style {rendering_style}')
|
|
return
|
|
|
|
for texture in textures:
|
|
mode = texture.get("Mode", None)
|
|
node = None
|
|
|
|
image_url = None
|
|
|
|
def get_image():
|
|
# TODO: orphaned textures after shader recreated?
|
|
if texture["type"] == "IfcImageTexture":
|
|
original_image_url = texture["URLReference"]
|
|
is_relative = not os.path.isabs(original_image_url)
|
|
nonlocal image_url
|
|
image_url = Path(original_image_url)
|
|
if is_relative:
|
|
ifc_path = Path(tool.Ifc.get_path())
|
|
image_url = ifc_path.parent / image_url
|
|
|
|
if not image_url.exists():
|
|
print(f"WARNING. Couldn't find texture by path {image_url}, it will be skipped.")
|
|
return
|
|
|
|
# keep url relative if it was before
|
|
image_url = str(image_url)
|
|
if is_relative and bpy.data.filepath:
|
|
image_url = bpy.path.relpath(image_url)
|
|
return bpy.data.images.load(image_url)
|
|
|
|
elif texture["type"] == "IfcBlobTexture":
|
|
# https://blender.stackexchange.com/questions/173206/how-to-efficiently-convert-a-pil-image-to-bpy-types-image
|
|
# https://blender.stackexchange.com/questions/62072/does-blender-have-a-method-to-a-get-png-formatted-bytearray-for-an-image-via-pyt
|
|
import io
|
|
from PIL import Image
|
|
|
|
value = texture["RasterCode"]
|
|
image_bytes = int(value, 2).to_bytes(len(value) // 8, "big")
|
|
pil_image = Image.open(io.BytesIO(image_bytes))
|
|
byte_to_normalized = 1.0 / 255.0
|
|
bpy_image = bpy.data.images.new("blob_texture", width=pil_image.width, height=pil_image.height)
|
|
# PIL returns rows ordered from top to bottom, blender from bottom to top
|
|
pil_pixel_data = np.asarray(pil_image.convert("RGBA"), dtype=np.float32)
|
|
bpy_image.pixels[:] = (pil_pixel_data * byte_to_normalized)[::-1].ravel()
|
|
bpy_image.pack()
|
|
return bpy_image
|
|
|
|
# IfcPixelTexture
|
|
n_components = texture["ColourComponents"]
|
|
width, height = texture["Width"], texture["Height"]
|
|
blender_pixel_data = np.ones(width * height * 4, dtype=np.float32)
|
|
|
|
# according to https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcPixelTexture.htm
|
|
# 1 component - grey scale intensity value
|
|
# 2 components - grey scale + alpha
|
|
# 3 components - RGB
|
|
# 4 components - RGBA
|
|
for i, pixel_str in enumerate(iterable=texture["Pixel"]):
|
|
pixel_bytes = int(pixel_str, 2).to_bytes(len(pixel_str) // 8, "big")
|
|
pixel_values = np.array(list(pixel_bytes)) / 255
|
|
cur_pos = i * 4
|
|
|
|
if n_components in (1, 2):
|
|
blender_pixel_data[cur_pos : cur_pos + 3] = pixel_values[0]
|
|
if n_components == 2:
|
|
blender_pixel_data[cur_pos + 3] = pixel_values[1]
|
|
continue
|
|
|
|
# 3, 4 components
|
|
blender_pixel_data[cur_pos : cur_pos + 3] = pixel_values[:3]
|
|
if n_components == 4:
|
|
blender_pixel_data[cur_pos + 3] = pixel_values[3]
|
|
|
|
bpy_image = bpy.data.images.new("pixel_texture", width=width, height=height)
|
|
bpy_image.pixels[:] = blender_pixel_data
|
|
bpy_image.pack()
|
|
return bpy_image
|
|
|
|
if reflectance_method in ["PHYSICAL", "NOTDEFINED"]:
|
|
bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED")
|
|
|
|
SUPPORTED_PBR_TEXTURES = ("NORMAL", "EMISSIVE", "METALLICROUGHNESS", "OCCLUSION", "DIFFUSE")
|
|
if mode not in SUPPORTED_PBR_TEXTURES:
|
|
print(
|
|
f"WARNING. Texture with {mode} Mode is not supported for style with PHYSICAL reflectance method.\n"
|
|
f"Supported types are: {', '.join(SUPPORTED_PBR_TEXTURES)}"
|
|
)
|
|
if texture["type"] == "IfcImageTexture":
|
|
print(f"Texture by path {image_url} will be skipped.")
|
|
continue
|
|
|
|
if (image := get_image()) is None:
|
|
continue
|
|
|
|
if mode == "NORMAL":
|
|
# add normal map node
|
|
normalmap = blender_material.node_tree.nodes.new(type="ShaderNodeNormalMap")
|
|
normalmap.location = bsdf.location - Vector((200, 600))
|
|
blender_material.node_tree.links.new(normalmap.outputs[0], bsdf.inputs["Normal"])
|
|
|
|
# add normal map sampler
|
|
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
|
node.location = normalmap.location - Vector((300, 0))
|
|
image.colorspace_settings.name = "Non-Color"
|
|
node.image = image
|
|
blender_material.node_tree.links.new(node.outputs[0], normalmap.inputs["Color"])
|
|
|
|
elif mode == "EMISSIVE":
|
|
output = tool.Blender.get_material_node(blender_material, "OUTPUT_MATERIAL")
|
|
|
|
# add "Add Shader" node
|
|
add = blender_material.node_tree.nodes.new(type="ShaderNodeAddShader")
|
|
add.location = bsdf.location + Vector((200, 350))
|
|
blender_material.node_tree.links.new(bsdf.outputs[0], add.inputs[1])
|
|
blender_material.node_tree.links.new(add.outputs[0], output.inputs[0])
|
|
|
|
# add emssion shader node
|
|
emission = blender_material.node_tree.nodes.new(type="ShaderNodeEmission")
|
|
emission.location = add.location - Vector((200, 0))
|
|
blender_material.node_tree.links.new(emission.outputs[0], add.inputs[0])
|
|
|
|
# add emission texture sampler
|
|
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
|
node.location = emission.location - Vector((350, 0))
|
|
node.image = image
|
|
blender_material.node_tree.links.new(node.outputs[0], emission.inputs[0])
|
|
|
|
elif mode == "METALLICROUGHNESS":
|
|
separate = blender_material.node_tree.nodes.new(type="ShaderNodeSeparateRGB")
|
|
separate.location = bsdf.location - Vector((200, 300))
|
|
blender_material.node_tree.links.new(separate.outputs[1], bsdf.inputs["Roughness"])
|
|
blender_material.node_tree.links.new(separate.outputs[2], bsdf.inputs["Metallic"])
|
|
|
|
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
|
node.location = separate.location - Vector((300, 0))
|
|
image.colorspace_settings.name = "Non-Color"
|
|
node.image = image
|
|
blender_material.node_tree.links.new(node.outputs[0], separate.inputs[0])
|
|
|
|
elif mode == "OCCLUSION":
|
|
|
|
def get_gltf_occlusion_output():
|
|
gltf_node_group_name = "glTF Material Output"
|
|
if node_group := bpy.data.node_groups.get(gltf_node_group_name, None):
|
|
return node_group
|
|
|
|
gltf_node_group = bpy.data.node_groups.new(gltf_node_group_name, "ShaderNodeTree")
|
|
gltf_node_group.inputs.new("NodeSocketFloat", "Occlusion")
|
|
gltf_node_group.nodes.new("NodeGroupOutput")
|
|
gltf_node_group_input = gltf_node_group.nodes.new("NodeGroupInput")
|
|
gltf_node_group_input.location = Vector((-200, 0))
|
|
return gltf_node_group
|
|
|
|
gltf_output_node_group = get_gltf_occlusion_output()
|
|
group = blender_material.node_tree.nodes.new(type="ShaderNodeGroup")
|
|
group.node_tree = gltf_output_node_group
|
|
group.location = bsdf.location + Vector((800, 0))
|
|
|
|
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
|
node.location = group.location - Vector((300, 0))
|
|
image.colorspace_settings.name = "Non-Color"
|
|
node.image = image
|
|
blender_material.node_tree.links.new(node.outputs[0], group.inputs["Occlusion"])
|
|
|
|
elif mode == "DIFFUSE":
|
|
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
|
node.location = bsdf.location - Vector((400, 0))
|
|
node.image = image
|
|
blender_material.node_tree.links.new(node.outputs[0], bsdf.inputs["Base Color"])
|
|
# leave it to default(OPAQUE) when no Transparency defined
|
|
if transparency := rendering_style.get("Transparency", None):
|
|
blender_material.node_tree.links.new(node.outputs[1], bsdf.inputs["Alpha"])
|
|
blender_material.blend_method = "BLEND"
|
|
|
|
elif reflectance_method == "FLAT":
|
|
bsdf = tool.Blender.get_material_node(blender_material, "MIX_SHADER")
|
|
if mode != "EMISSIVE":
|
|
print("WARNING. Only EMISSIVE Mode textures are supported for style with FLAT reflectance method.")
|
|
if texture["type"] == "IfcImageTexture":
|
|
print(f"{mode} Mode texture by path {image_url} will be skipped.")
|
|
else:
|
|
print(f"{mode} Mode texture will be skipped.")
|
|
continue
|
|
|
|
if (image := get_image) is None:
|
|
continue
|
|
|
|
# remove RGB node from `create_surface_style_rendering`
|
|
prev_node = bsdf.inputs[2].links[0].from_node
|
|
blender_material.node_tree.nodes.remove(prev_node)
|
|
|
|
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
|
node.location = bsdf.location - Vector((200, 250))
|
|
node.image = image
|
|
|
|
blender_material.node_tree.links.new(node.outputs[0], bsdf.inputs[2])
|
|
|
|
# extend the image by repeating pixels on its edges if RepeatS or RepeatT is False
|
|
repeat_s = texture.get("RepeatS", True)
|
|
repeat_t = texture.get("RepeatT", True)
|
|
if not repeat_s or not repeat_t:
|
|
node.extension = "EXTEND"
|
|
|
|
# IsMappedBy could only get with the entity_instance for IFC4/IFC4x3
|
|
coord = blender_material.node_tree.nodes.new(type="ShaderNodeTexCoord")
|
|
coord.location = node.location - Vector((200, 0))
|
|
if texture["uv_mode"] == "Generated":
|
|
blender_material.node_tree.links.new(coord.outputs["Generated"], node.inputs["Vector"])
|
|
elif texture["uv_mode"] == "Camera":
|
|
blender_material.node_tree.links.new(coord.outputs["Camera"], node.inputs["Vector"])
|
|
else: # uv_mode == UV
|
|
blender_material.node_tree.links.new(coord.outputs["UV"], node.inputs["Vector"])
|
|
|
|
@classmethod
|
|
def load_indexed_colour_map(cls, representation: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> None:
|
|
"""Ensure indexed colour map is loaded for representation if it's available.
|
|
|
|
Method doesn't support elements with openings, see #5405.
|
|
|
|
:param representation: IfcShapeRepresentation of any type. Representation may not have an indexed colour map,
|
|
method will automatically check if it does and will skip it otherwise.
|
|
|
|
:raises AssertionError: If mesh doesn't match the representation exactly, which usually occurs
|
|
if element geometry is altered by openings.
|
|
"""
|
|
if representation.RepresentationType != "Tessellation":
|
|
return
|
|
|
|
colours = []
|
|
for item in representation.Items:
|
|
if not item.is_a("IfcTessellatedFaceSet"):
|
|
continue
|
|
colours.extend(item.HasColours)
|
|
|
|
if not colours:
|
|
return
|
|
|
|
for colour in colours:
|
|
cls.load_indexed_map(colour, mesh)
|
|
|
|
@classmethod
|
|
def load_indexed_map(cls, index_map: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> None:
|
|
"""Add data from index map as blender mesh attribute.
|
|
|
|
:param index_map: IfcIndexedTextureMap or IfcIndexedColourMap
|
|
"""
|
|
|
|
map_type = "UV" if index_map.is_a("IfcIndexedTextureMap") else "Color"
|
|
|
|
# Get a BMesh representation
|
|
bm = bmesh.new()
|
|
bm.from_mesh(mesh)
|
|
if map_type == "UV":
|
|
# constistent naming with how Blender does it
|
|
layer = bm.loops.layers.uv.active or bm.loops.layers.uv.new("UVMap")
|
|
else:
|
|
layer = bm.loops.layers.float_color.new("Color")
|
|
|
|
# remap the faceset CoordList index to the vertices in blender mesh
|
|
coordinates_remap = []
|
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
faceset = index_map.MappedTo
|
|
for co in faceset.Coordinates.CoordList:
|
|
co = Vector(co) * si_conversion
|
|
index = min(bm.verts, key=lambda v: (v.co - co).length_squared).index
|
|
coordinates_remap.append(index)
|
|
|
|
# ifc indices start with 1
|
|
remap_verts_to_blender = lambda ifc_verts: [coordinates_remap[i - 1] for i in ifc_verts]
|
|
|
|
# faces_remap - ifc faces described using blender verts indices
|
|
# IFC4.3+
|
|
if index_map.is_a("IfcIndexedPolygonalTextureMap"):
|
|
faces_remap = [
|
|
remap_verts_to_blender(tex_coord_index.TexCoordsOf.CoordIndex)
|
|
for tex_coord_index in index_map.TexCoordIndices
|
|
]
|
|
texture_map = [tex_coord_index.TexCoordIndex for tex_coord_index in index_map.TexCoordIndices]
|
|
else: # IfcIndexedTriangleTextureMap or IfcIndexedColourMap
|
|
if faceset.is_a("IfcTriangulatedFaceSet"):
|
|
faces_remap = [remap_verts_to_blender(triangle_face) for triangle_face in faceset.CoordIndex]
|
|
else: # IfcPolygonalFaceSet
|
|
faces_remap = [remap_verts_to_blender(face.CoordIndex) for face in faceset.Faces]
|
|
if index_map.is_a("IfcIndexedTriangleTextureMap"):
|
|
texture_map = index_map.TexCoordIndex
|
|
else:
|
|
texture_map = index_map.ColourIndex
|
|
|
|
if map_type == "UV":
|
|
data_list = index_map.TexCoords.TexCoordsList
|
|
else:
|
|
data_list = index_map.Colours.ColourList
|
|
opacity = index_map.Opacity
|
|
opacity = opacity if opacity is not None else 1.0
|
|
data_list = [d + (opacity,) for d in data_list]
|
|
|
|
# Apply attribute to each face
|
|
for bface in bm.faces:
|
|
face = [loop.vert.index for loop in bface.loops]
|
|
# Find the corresponding index in data list by matching ifc faceset with blender face.
|
|
data_index = None
|
|
for tex_coord_index, face_remap in zip(texture_map, faces_remap, strict=True):
|
|
if not all(i in face_remap for i in face):
|
|
continue
|
|
# Subtract 1 as tex_coord_index starts with 1.
|
|
if map_type == "UV":
|
|
data_index = [tex_coord_index[face_remap.index(i)] - 1 for i in face]
|
|
else:
|
|
data_index = [tex_coord_index - 1 for i in face]
|
|
break
|
|
assert data_index is not None
|
|
|
|
# apply uv to each loop
|
|
for loop, i in zip(bface.loops, data_index):
|
|
if map_type == "UV":
|
|
loop[layer].uv = data_list[i]
|
|
else:
|
|
loop[layer] = data_list[i]
|
|
|
|
# Finish up, write the bmesh back to the mesh
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
|
|
if map_type == "Color":
|
|
# Couldn't find a way to do it from bmesh.
|
|
mesh.color_attributes.active_color_index = 0
|
|
|
|
@classmethod
|
|
def is_point_far_away(
|
|
cls, point: Union[ifcopenshell.entity_instance, npt.NDArray[np.float64]], is_meters: bool = True
|
|
) -> bool:
|
|
limit = cls.settings.distance_limit
|
|
limit = limit if is_meters else (limit / cls.unit_scale)
|
|
coords = getattr(point, "Coordinates", point)
|
|
return abs(coords[0]) > limit or abs(coords[1]) > limit or abs(coords[2]) > limit
|
|
|
|
@classmethod
|
|
def is_element_far_away(cls, element: ifcopenshell.entity_instance) -> bool:
|
|
try:
|
|
placement = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
|
point = placement[:, 3][0:3]
|
|
return tool.Loader.is_point_far_away(point, is_meters=False)
|
|
except:
|
|
return False
|
|
|
|
@classmethod
|
|
def create_settings(cls, is_gross=False):
|
|
results = []
|
|
for context in cls.settings.contexts:
|
|
settings = ifcopenshell.geom.settings()
|
|
settings.set("mesher-linear-deflection", cls.settings.deflection_tolerance)
|
|
settings.set("mesher-angular-deflection", cls.settings.angular_tolerance)
|
|
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
|
settings.set("context-ids", [context.id()])
|
|
settings.set("apply-default-materials", False)
|
|
settings.set("keep-bounding-boxes", True)
|
|
settings.set("layerset-first", True)
|
|
if is_gross:
|
|
settings.set("disable-opening-subtractions", True)
|
|
results.append(settings)
|
|
return results
|
|
|
|
@classmethod
|
|
def set_manual_blender_offset(cls, ifc_file: ifcopenshell.file) -> None:
|
|
false_origin = np.array(cls.settings.false_origin)
|
|
model_offset = np.array(
|
|
ifcopenshell.util.geolocation.auto_enh2xyz(
|
|
ifc_file, *cls.settings.false_origin, is_specified_in_map_units=False
|
|
)
|
|
)
|
|
zero_origin = np.array((0, 0, 0))
|
|
has_offset = not np.allclose(model_offset, zero_origin)
|
|
|
|
model_north = ifcopenshell.util.geolocation.get_grid_north(ifc_file)
|
|
project_north = cls.settings.project_north
|
|
model_rotation = tool.Cad.normalise_angle(project_north - model_north)
|
|
has_rotation = not np.isclose(model_north, project_north)
|
|
|
|
if not has_offset:
|
|
model_offset = false_origin = (0, 0, 0)
|
|
|
|
if np.isclose(project_north, 0):
|
|
project_north = 0
|
|
|
|
if has_offset or has_rotation:
|
|
props = bpy.context.scene.BIMGeoreferenceProperties
|
|
props.blender_offset_x = str(model_offset[0])
|
|
props.blender_offset_y = str(model_offset[1])
|
|
props.blender_offset_z = str(model_offset[2])
|
|
xaa, xao = ifcopenshell.util.geolocation.angle2xaxis(model_rotation)
|
|
props.blender_x_axis_abscissa = str(xaa)
|
|
props.blender_x_axis_ordinate = str(xao)
|
|
props.has_blender_offset = True
|
|
|
|
@classmethod
|
|
def guess_false_origin_and_project_north(
|
|
cls, ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance
|
|
) -> None:
|
|
if not element.ObjectPlacement or not element.ObjectPlacement.is_a("IfcLocalPlacement"):
|
|
return
|
|
placement = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
|
offset_point = [placement[0][3], placement[1][3], placement[2][3]]
|
|
cls.settings.false_origin = ifcopenshell.util.geolocation.auto_xyz2enh(
|
|
ifc_file, *offset_point, should_return_in_map_units=False
|
|
)
|
|
|
|
# Prioritise coordinate operation angles
|
|
angle = ifcopenshell.util.geolocation.get_grid_north(ifc_file)
|
|
if np.isclose(angle, 0.0):
|
|
# Fallback to the placement angle as a good guess
|
|
xaa, xao = placement[:, 0][0:2]
|
|
angle = ifcopenshell.util.geolocation.xaxis2angle(xaa, xao)
|
|
|
|
cls.settings.project_north = 0 if np.isclose(angle, 0) else angle
|
|
cls.set_manual_blender_offset(ifc_file)
|
|
|
|
@classmethod
|
|
def find_decomposed_ifc_class(
|
|
cls, element: ifcopenshell.entity_instance, ifc_class: str
|
|
) -> Union[ifcopenshell.entity_instance, None]:
|
|
if element.is_a(ifc_class):
|
|
return element
|
|
rel_aggregates = element.IsDecomposedBy
|
|
for rel_aggregate in rel_aggregates:
|
|
for part in rel_aggregate.RelatedObjects:
|
|
result = cls.find_decomposed_ifc_class(part, ifc_class)
|
|
if result:
|
|
return result
|
|
|
|
@classmethod
|
|
def create_generic_shape(
|
|
cls, element: ifcopenshell.entity_instance, is_gross: bool = False
|
|
) -> Union[ifcopenshell.geom.ShapeElementType, None]:
|
|
context_settings = cls.settings.gross_context_settings if is_gross else cls.settings.context_settings
|
|
for settings in context_settings:
|
|
try:
|
|
result = ifcopenshell.geom.create_shape(settings, element)
|
|
if result:
|
|
return result
|
|
except:
|
|
pass
|
|
|
|
@classmethod
|
|
def create_point_cloud_mesh(cls, representation: ifcopenshell.entity_instance) -> Union[bpy.types.Mesh, None]:
|
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
vertex_list = []
|
|
for item in representation.Items:
|
|
if item.is_a("IfcCartesianPointList3D"):
|
|
vertex_list.extend(Vector(list(coordinates)) * unit_scale for coordinates in item.CoordList)
|
|
elif item.is_a("IfcCartesianPointList2D"):
|
|
vertex_list.extend(Vector(list(coordinates)).to_3d() * unit_scale for coordinates in item.CoordList)
|
|
elif item.is_a("IfcCartesianPoint"):
|
|
vertex_list.append(Vector(list(item.Coordinates)) * unit_scale)
|
|
|
|
if len(vertex_list) == 0:
|
|
return None
|
|
|
|
mesh_name = tool.Geometry.get_representation_name(representation)
|
|
mesh = bpy.data.meshes.new(mesh_name)
|
|
mesh.from_pydata(vertex_list, [], [])
|
|
return mesh
|
|
|
|
@classmethod
|
|
def create_camera(
|
|
cls,
|
|
element: ifcopenshell.entity_instance,
|
|
representation: ifcopenshell.entity_instance,
|
|
shape: Union[ifcopenshell.geom.ShapeElementType, ifcopenshell.geom.ShapeType],
|
|
) -> bpy.types.Camera:
|
|
from bonsai.bim.module.drawing.prop import get_diagram_scales
|
|
|
|
if isinstance(shape, ifcopenshell.geom.ShapeElementType):
|
|
geometry = shape.geometry
|
|
else:
|
|
geometry = shape
|
|
|
|
v = geometry.verts
|
|
x = [v[i] for i in range(0, len(v), 3)]
|
|
y = [v[i + 1] for i in range(0, len(v), 3)]
|
|
z = [v[i + 2] for i in range(0, len(v), 3)]
|
|
width = max(x) - min(x)
|
|
height = max(y) - min(y)
|
|
depth = max(z) - min(z)
|
|
|
|
camera_type = "ORTHO"
|
|
if "IfcRectangularPyramid" in {e.is_a() for e in tool.Ifc.get().traverse(representation)}:
|
|
camera_type = "PERSP"
|
|
|
|
camera = bpy.data.cameras.new(tool.Loader.get_mesh_name_from_shape(geometry))
|
|
camera.type = camera_type
|
|
camera.show_limits = True
|
|
|
|
if camera_type == "ORTHO":
|
|
camera.clip_start = 0.002 # Technically 0, but Blender doesn't allow this, so 2mm it is!
|
|
camera.clip_end = depth
|
|
|
|
camera.BIMCameraProperties.width = width
|
|
camera.BIMCameraProperties.height = height
|
|
elif camera_type == "PERSP":
|
|
abs_min_z = abs(min(z))
|
|
abs_max_z = abs(max(z))
|
|
camera.clip_start = abs_max_z
|
|
camera.clip_end = abs_min_z
|
|
max_res = 1000
|
|
|
|
camera.BIMCameraProperties.width = width
|
|
camera.BIMCameraProperties.height = height
|
|
|
|
if width > height:
|
|
fov = 2 * math.atan(width / (2 * abs_min_z))
|
|
else:
|
|
fov = 2 * math.atan(height / (2 * abs_min_z))
|
|
|
|
camera.angle = fov
|
|
|
|
tool.Drawing.import_camera_props(element, camera)
|
|
return camera
|
|
|
|
@classmethod
|
|
def get_offset_point(cls, ifc_file: ifcopenshell.file) -> Union[npt.NDArray[np.float64], None]:
|
|
elements_checked = 0
|
|
# If more than these elements aren't far away, the file probably isn't
|
|
# absolutely positioned. We check more than 1 because sometimes users
|
|
# try to be clever and put "origin marker" objects.
|
|
element_checking_threshold = 3
|
|
elements = ifc_file.by_type("IfcElement")
|
|
|
|
if ifc_file.schema not in ("IFC2X3", "IFC4"):
|
|
if not elements:
|
|
elements = ifc_file.by_type("IfcLinearPositioningElement")
|
|
if not elements:
|
|
elements = ifc_file.by_type("IfcReferent")
|
|
if not elements:
|
|
elements = ifc_file.by_type("IfcGrid")
|
|
|
|
if ifc_file.schema == "IFC2X3":
|
|
if not elements:
|
|
elements = ifc_file.by_type("IfcSpatialStructureElement")
|
|
else:
|
|
if not elements:
|
|
elements = ifc_file.by_type("IfcSpatialElement")
|
|
|
|
for element in elements:
|
|
if elements_checked > element_checking_threshold:
|
|
return
|
|
if not element.Representation:
|
|
continue
|
|
shape = cls.create_generic_shape(element, is_gross=True)
|
|
if not shape:
|
|
continue
|
|
elements_checked += 1
|
|
mat = ifcopenshell.util.shape.get_shape_matrix(shape)
|
|
point = mat @ np.array((shape.geometry.verts[0], shape.geometry.verts[1], shape.geometry.verts[2], 1.0))
|
|
if cls.is_point_far_away(point, is_meters=True):
|
|
# Arbitrary origins should be to the nearest millimeter.
|
|
# Anything more precise is just ridiculous from a practical surveying perspective.
|
|
return [round(float(p), 3) / cls.unit_scale for p in point[:3]]
|
|
|
|
@classmethod
|
|
def guess_false_origin_from_elements(cls, ifc_file: ifcopenshell.file) -> None:
|
|
# Civil BIM applications like to work in absolute coordinates, where the
|
|
# ObjectPlacement is usually 0,0,0 (but not always, so we'll need to
|
|
# check for the actual transformation) but each individual coordinate of
|
|
# the shape representation is in absolute values.
|
|
offset_point = cls.get_offset_point(ifc_file)
|
|
if offset_point is None:
|
|
return
|
|
cls.settings.false_origin = ifcopenshell.util.geolocation.auto_xyz2enh(
|
|
ifc_file, *offset_point, should_return_in_map_units=False
|
|
)
|
|
if (angle := ifcopenshell.util.geolocation.get_grid_north(ifc_file)) and not tool.Cad.is_x(angle, 0):
|
|
cls.settings.project_north = angle
|
|
cls.set_manual_blender_offset(ifc_file)
|
|
|
|
@classmethod
|
|
def guess_false_origin(cls, ifc_file: ifcopenshell.file) -> None:
|
|
if ifc_file.schema == "IFC2X3":
|
|
project = ifc_file.by_type("IfcProject")[0]
|
|
else:
|
|
project = ifc_file.by_type("IfcContext")[0]
|
|
site = cls.find_decomposed_ifc_class(project, "IfcSite")
|
|
if site and cls.is_element_far_away(site):
|
|
return cls.guess_false_origin_and_project_north(ifc_file, site)
|
|
building = cls.find_decomposed_ifc_class(project, "IfcBuilding")
|
|
if building and cls.is_element_far_away(building):
|
|
return cls.guess_false_origin_and_project_north(ifc_file, building)
|
|
return cls.guess_false_origin_from_elements(ifc_file)
|
|
|
|
@classmethod
|
|
def apply_blender_offset_to_matrix_world(cls, obj: bpy.types.Object, matrix: np.ndarray) -> Matrix:
|
|
if (
|
|
not obj.data
|
|
and tool.Cad.is_x(matrix[0][3], 0)
|
|
and tool.Cad.is_x(matrix[1][3], 0)
|
|
and tool.Cad.is_x(matrix[2][3], 0)
|
|
):
|
|
# We assume any non-geometric matrix at 0,0,0 is not
|
|
# positionally significant and is left alone. This handles
|
|
# scenarios where often spatial elements are left at 0,0,0 and
|
|
# everything else is at map coordinates.
|
|
obj.BIMObjectProperties.blender_offset_type = "NOT_APPLICABLE"
|
|
return Matrix(matrix.tolist())
|
|
|
|
if obj.data and obj.data.get("has_cartesian_point_offset", None):
|
|
obj.BIMObjectProperties.blender_offset_type = "CARTESIAN_POINT"
|
|
if cartesian_point_offset := obj.data.get("cartesian_point_offset", None):
|
|
obj.BIMObjectProperties.cartesian_point_offset = cartesian_point_offset
|
|
offset_xyz = list(map(float, cartesian_point_offset.split(","))) + [1.0]
|
|
offset_xyz = matrix @ offset_xyz
|
|
matrix[0][3] = offset_xyz[0]
|
|
matrix[1][3] = offset_xyz[1]
|
|
matrix[2][3] = offset_xyz[2]
|
|
|
|
props = bpy.context.scene.BIMGeoreferenceProperties
|
|
if props.has_blender_offset:
|
|
if obj.BIMObjectProperties.blender_offset_type == "NONE":
|
|
obj.BIMObjectProperties.blender_offset_type = "OBJECT_PLACEMENT"
|
|
matrix = ifcopenshell.util.geolocation.global2local(
|
|
matrix,
|
|
float(props.blender_offset_x) * cls.unit_scale,
|
|
float(props.blender_offset_y) * cls.unit_scale,
|
|
float(props.blender_offset_z) * cls.unit_scale,
|
|
float(props.blender_x_axis_abscissa),
|
|
float(props.blender_x_axis_ordinate),
|
|
)
|
|
return Matrix(matrix.tolist())
|