From 33816bd82b40eaca204b0f453f5577d4eed4d5f2 Mon Sep 17 00:00:00 2001 From: Julien Masnada Date: Mon, 27 Jan 2025 09:21:30 +0100 Subject: [PATCH] Improve decoration of walls, floors and ceilings. Improve creation of floor's slab --- .../Resources/ui/preferences-sh3d-import.ui | 37 +- src/Mod/BIM/importers/importSH3DHelper.py | 895 ++++++++++++------ 2 files changed, 629 insertions(+), 303 deletions(-) diff --git a/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui b/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui index d916e0bde7..14a6d5efa5 100644 --- a/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui +++ b/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui @@ -37,6 +37,9 @@ Mod/Arch + + true + @@ -96,6 +99,9 @@ Mod/Arch + + true + @@ -112,6 +118,9 @@ Mod/Arch + + true + @@ -261,9 +270,9 @@ - 255 - 255 - 255 + 246 + 246 + 246 @@ -306,6 +315,9 @@ Mod/Arch + + true + @@ -388,6 +400,25 @@ + + + + Create Facebinders, Baseboards for walls, floors and ceilings for Rooms + + + Decorate surfaces + + + sh3dDecorateSurfaces + + + Mod/Arch + + + true + + + diff --git a/src/Mod/BIM/importers/importSH3DHelper.py b/src/Mod/BIM/importers/importSH3DHelper.py index 52f10c12b9..d4e16fe834 100644 --- a/src/Mod/BIM/importers/importSH3DHelper.py +++ b/src/Mod/BIM/importers/importSH3DHelper.py @@ -19,6 +19,8 @@ # * * # *************************************************************************** """Helper functions that are used by SH3D importer.""" +import itertools +import numpy as np import math import os import re @@ -28,14 +30,15 @@ import xml.etree.ElementTree as ET import zipfile import Arch +import BOPTools.SplitFeatures +import BOPTools.BOPFeatures import Draft import DraftGeomUtils import DraftVecUtils import Mesh import MeshPart -import numpy import Part -from draftobjects.facebinder import Facebinder + from draftutils.messages import _err, _log, _msg, _wrn from draftutils.params import get_param_arch @@ -44,7 +47,6 @@ import FreeCAD as App if App.GuiUp: import FreeCADGui as Gui from draftutils.translate import translate - from draftviewproviders.view_facebinder import ViewProviderFacebinder else: # \cond def translate(_, text): @@ -86,7 +88,7 @@ Z_NORM = App.Vector(0, 0, 1) # "Sliding 4-pane", "Awning"] # unzip -p all-windows.sh3d Home.xml | \ # grep 'catalogId=' | \ -# sed -e 's/.*catalogId=//;s/ name=.*/: ("Open 2-pane","Window"),/' | sort -u +# sed -e 's/.*catalogId=//;s/ name=.*/: ("Fixed","Window"),/' | sort -u # unzip -p all-doors.sh3d Home.xml | \ # grep 'catalogId=' | \ # sed -e 's/.*catalogId=//;s/ name=.*/: ("Simple door","Door")/' | sort -u @@ -115,39 +117,51 @@ DOOR_MODELS = { 'Scopia#glass_door': ("Glass door","Door"), 'Scopia#puerta': ("Simple door","Door"), - 'eTeks#doubleFrenchWindow126x200': ("Open 2-pane","Window"), - 'eTeks#doubleHungWindow80x122': ("Open 2-pane","Window"), - 'eTeks#doubleOutwardOpeningWindow': ("Open 2-pane","Window"), - 'eTeks#doubleWindow126x123': ("Open 2-pane","Window"), - 'eTeks#doubleWindow126x163': ("Open 2-pane","Window"), - 'eTeks#fixedTriangleWindow85x85': ("Open 2-pane","Window"), - 'eTeks#fixedWindow85x123': ("Open 2-pane","Window"), - 'eTeks#frenchWindow85x200': ("Open 2-pane","Window"), - 'eTeks#halfRoundWindow': ("Open 2-pane","Window"), - 'eTeks#roundWindow': ("Open 2-pane","Window"), - 'eTeks#sliderWindow126x200': ("Open 2-pane","Window"), - 'eTeks#window85x123': ("Open 2-pane","Window"), - 'eTeks#window85x163': ("Open 2-pane","Window"), - 'Kator Legaz#window-01': ("Open 2-pane","Window"), - 'Kator Legaz#window-08-02': ("Open 2-pane","Window"), - 'Kator Legaz#window-08': ("Open 2-pane","Window"), - 'Scopia#turn-window': ("Open 2-pane","Window"), - 'Scopia#window_2x1_medium_with_large_pane': ("Open 2-pane","Window"), - 'Scopia#window_2x1_with_sliders': ("Open 2-pane","Window"), - 'Scopia#window_2x3_arched': ("Open 2-pane","Window"), - 'Scopia#window_2x3': ("Open 2-pane","Window"), - 'Scopia#window_2x3_regular': ("Open 2-pane","Window"), - 'Scopia#window_2x4_arched': ("Open 2-pane","Window"), - 'Scopia#window_2x4': ("Open 2-pane","Window"), - 'Scopia#window_2x6': ("Open 2-pane","Window"), - 'Scopia#window_3x1': ("Open 2-pane","Window"), - 'Scopia#window_4x1': ("Open 2-pane","Window"), - 'Scopia#window_4x3_arched': ("Open 2-pane","Window"), - 'Scopia#window_4x3': ("Open 2-pane","Window"), - 'Scopia#window_4x5': ("Open 2-pane","Window"), + 'eTeks#doubleFrenchWindow126x200': ("Fixed","Window"), + 'eTeks#doubleHungWindow80x122': ("Fixed","Window"), + 'eTeks#doubleOutwardOpeningWindow': ("Fixed","Window"), + 'eTeks#doubleWindow126x123': ("Fixed","Window"), + 'eTeks#doubleWindow126x163': ("Fixed","Window"), + 'eTeks#fixedTriangleWindow85x85': ("Fixed","Window"), + 'eTeks#fixedWindow85x123': ("Fixed","Window"), + 'eTeks#frenchWindow85x200': ("Fixed","Window"), + 'eTeks#halfRoundWindow': ("Fixed","Window"), + 'eTeks#roundWindow': ("Fixed","Window"), + 'eTeks#sliderWindow126x200': ("Fixed","Window"), + 'eTeks#window85x123': ("Fixed","Window"), + 'eTeks#window85x163': ("Fixed","Window"), + 'Kator Legaz#window-01': ("Fixed","Window"), + 'Kator Legaz#window-08-02': ("Fixed","Window"), + 'Kator Legaz#window-08': ("Fixed","Window"), + 'Scopia#turn-window': ("Fixed","Window"), + 'Scopia#window_2x1_medium_with_large_pane': ("Fixed","Window"), + 'Scopia#window_2x1_with_sliders': ("Fixed","Window"), + 'Scopia#window_2x3_arched': ("Fixed","Window"), + 'Scopia#window_2x3': ("Fixed","Window"), + 'Scopia#window_2x3_regular': ("Fixed","Window"), + 'Scopia#window_2x4_arched': ("Fixed","Window"), + 'Scopia#window_2x4': ("Fixed","Window"), + 'Scopia#window_2x6': ("Fixed","Window"), + 'Scopia#window_3x1': ("Fixed","Window"), + 'Scopia#window_4x1': ("Fixed","Window"), + 'Scopia#window_4x3_arched': ("Fixed","Window"), + 'Scopia#window_4x3': ("Fixed","Window"), + 'Scopia#window_4x5': ("Fixed","Window"), } + +ET_XPATH_LEVEL = 'level' +ET_XPATH_ROOM = 'room' +ET_XPATH_WALL = 'wall' +ET_XPATH_DOOR_OR_WINDOWS = './/doorOrWindow' +ET_XPATH_PIECE_OF_FURNITURE = './/pieceOfFurniture' +ET_XPATH_LIGHT = 'light' +ET_XPATH_OBSERVER_CAMERA = 'observerCamera' +ET_XPATH_CAMERA = 'camera' +ET_XPATH_DUMMY_SLAB = 'DummySlab' +ET_XPATH_DUMMY_DECORATE = 'DummyDecorate' + class SH3DImporter: """The main class to import an SH3D file. @@ -179,8 +193,8 @@ class SH3DImporter: self.building = None self.default_floor = None self.floors = {} - self.walls = [] - self.space_upper_faces = [] + self.walls = {} + self.spaces = {} def import_sh3d_from_string(self, home:str): """Import the SH3D Home from a String. @@ -234,17 +248,15 @@ class SH3DImporter: # Get all the FreeCAD object in the active doc, in order to allow # for merge of existing object if self.preferences["MERGE"]: - for object in doc.Objects: - if hasattr(object, 'id'): - self.fc_objects[object.id] = object + list(map(lambda o2: self.add_fc_objects(o2), list(filter(lambda o1: hasattr(o1, 'id'), doc.Objects)))) # Let's create the project and site for this import self._setup_project(home) # Import the element if any. If none are defined # create a default one. - if home.find(path='level') != None: - self._import_elements(home, 'level') + if home.find(ET_XPATH_LEVEL) != None: + self._import_elements(home, ET_XPATH_LEVEL) else: # Has the default floor already been created from a # previous import? @@ -252,54 +264,47 @@ class SH3DImporter: self.default_floor = self.fc_objects.get('Level') if 'Level' in self.fc_objects else self._create_default_floor() # Importing elements ... - self._import_elements(home, 'room') + self._import_elements(home, ET_XPATH_ROOM) # Importing elements ... - self._import_elements(home, 'wall') + self._import_elements(home, ET_XPATH_WALL) + self._refresh() + # Walls&Rooms have been imported. Created the floor slabs + self._create_slabs() self._refresh() if self.preferences["CREATE_GROUND_MESH"]: self._create_ground_mesh(home) + self._refresh() - self._refresh() if App.GuiUp and self.preferences["FIT_VIEW"]: Gui.SendMsgToActiveView("ViewFit") # Importing elements ... if self.preferences["IMPORT_DOORS_AND_WINDOWS"]: - self._import_elements(home, 'doorOrWindow') - for furniture_group in home.findall('furnitureGroup'): - self._import_elements(furniture_group, 'doorOrWindow', False) + self._import_elements(home, ET_XPATH_DOOR_OR_WINDOWS) self._refresh() - group = App.ActiveDocument.Facebinders - for element in group.Group: - faces = [] - new_sel_subshapes = [] - for (sel_object, sel_subshapes) in element.Faces: - for sel_subshape in sel_subshapes: - sel_subshape = sel_subshape[1:] if sel_subshape.startswith('?') else sel_subshape - new_sel_subshapes.append(sel_subshape) - faces.append((sel_object, new_sel_subshapes)) - element.Faces = faces + + # Door&Windows have been imported. Now we can decorate... + if self.preferences["DECORATE_SURFACES"]: + self._decorate_surfaces() self._refresh() # Importing && elements ... if self.preferences["IMPORT_FURNITURES"]: - self._import_elements(home, 'pieceOfFurniture') - for furniture_group in home.findall('furnitureGroup'): - self._import_elements(furniture_group, 'pieceOfFurniture', False) + self._import_elements(home, ET_XPATH_PIECE_OF_FURNITURE) self._refresh() # Importing elements ... if self.preferences["IMPORT_LIGHTS"]: - self._import_elements(home, 'light') + self._import_elements(home, ET_XPATH_LIGHT) self._refresh() # Importing elements ... if self.preferences["IMPORT_CAMERAS"]: - self._import_elements(home, 'observerCamera') - self._import_elements(home, 'camera') + self._import_elements(home, ET_XPATH_OBSERVER_CAMERA) + self._import_elements(home, ET_XPATH_CAMERA) self._refresh() if self.preferences["CREATE_RENDER_PROJECT"] and self.site: @@ -338,29 +343,32 @@ class SH3DImporter: 'CREATE_GROUND_MESH': get_param_arch("sh3dCreateGroundMesh"), 'DEFAULT_GROUND_COLOR': color_fc2sh(get_param_arch("sh3dDefaultGroundColor")), 'DEFAULT_SKY_COLOR': color_fc2sh(get_param_arch("sh3dDefaultSkyColor")), + 'DECORATE_SURFACES': get_param_arch("sh3dDecorateSurfaces"), } def _setup_handlers(self): self.handlers = { - 'level': LevelHandler(self), - 'room': RoomHandler(self), - 'wall': WallHandler(self), + ET_XPATH_LEVEL: LevelHandler(self), + ET_XPATH_ROOM: RoomHandler(self), + ET_XPATH_WALL: WallHandler(self), + ET_XPATH_DUMMY_SLAB : None, } if self.preferences["IMPORT_DOORS_AND_WINDOWS"]: - self.handlers['doorOrWindow'] = DoorOrWindowHandler(self) - self.handlers['furnitureGroup'] = None + self.handlers[ET_XPATH_DOOR_OR_WINDOWS] = DoorOrWindowHandler(self) + + if self.preferences["DECORATE_SURFACES"]: + self.handlers[ET_XPATH_DUMMY_DECORATE] = None, if self.preferences["IMPORT_FURNITURES"]: - self.handlers['pieceOfFurniture'] = FurnitureHandler(self) - self.handlers['furnitureGroup'] = None + self.handlers[ET_XPATH_PIECE_OF_FURNITURE] = FurnitureHandler(self) if self.preferences["IMPORT_LIGHTS"]: - self.handlers['light'] = LightHandler(self) + self.handlers[ET_XPATH_LIGHT] = LightHandler(self) if self.preferences["IMPORT_CAMERAS"]: camera_handler = CameraHandler(self) - self.handlers['observerCamera'] = camera_handler - self.handlers['camera'] = camera_handler + self.handlers[ET_XPATH_OBSERVER_CAMERA] = camera_handler + self.handlers[ET_XPATH_CAMERA] = camera_handler def _refresh(self): App.ActiveDocument.recompute() @@ -392,10 +400,14 @@ class SH3DImporter: value = str(value.get(name, "")) elif type_ == "App::PropertyFloat": value = float(value.get(name, 0)) + elif type_ == "App::PropertyQuantity": + value = float(value.get(name, 0)) elif type_ == "App::PropertyInteger": value = int(value.get(name, 0)) + elif type_ == "App::PropertyPercent": + value = int(value.get(name, 0)) elif type_ == "App::PropertyBool": - value = bool(value.get(name, True)) + value = value.get(name, "true") == "true" if self.preferences["DEBUG"]: _log(f"Setting @{obj}.{name} = {value}") setattr(obj, name, value) @@ -414,6 +426,17 @@ class SH3DImporter: if name not in obj.PropertiesList: obj.addProperty(property_type, name, group, description) + def add_fc_objects(self, obj): + """Register `obj`. + + This object can then be referenced later on by + other objects (i.e. light, etc.) + + Args: + obj (AppDocumentObject): the object to register + """ + self.fc_objects[obj.id] = obj + def get_fc_object(self, id, sh_type): """Returns the FC doc element corresponding to the imported id and sh_type @@ -455,6 +478,14 @@ class SH3DImporter: return self.default_floor return self.floors.get(level_id, None) + def add_space(self, floor, space): + if floor.id not in self.spaces: + self.spaces[floor.id] = [] + self.spaces[floor.id].append(space) + + def get_spaces(self, floor): + return self.spaces.get(floor.id, []) + def get_space(self, floor, p): """Returns the Space this point belongs to. @@ -468,26 +499,35 @@ class SH3DImporter: Space: the space the object belongs to or None """ closest_space = None - for (space_floor, space, space_face) in self.space_upper_faces: - if not space_face: #?!? - continue - space_face_z = space_face.CenterOfMass.z - projection = App.Vector(p.x, p.y, space_face_z) + for space in self.spaces.get(floor.id, []): + space_face = space.Base.Shape + space_z = space_face.CenterOfMass.z + projection = App.Vector(p.x, p.y, space_z) # Checks that: # - the point's projection is inside the face # - the point is above the face # - the point's parent and the face's are on the same level # NOTE: If two rooms overlap on the same level, the result is # undefined... - if space_face.isInside(projection, 1, True) and space_face_z < p.z and space_floor.id == floor.id: + if space_face.isInside(projection, 1, True) and space_z < p.z: closest_space = space return closest_space - def add_wall(self, wall): - self.walls.append(wall) + def add_wall(self, floor, wall): + if floor.id not in self.walls: + self.walls[floor.id] = [] + self.walls[floor.id].append(wall) - def get_walls(self): - return self.walls + def get_walls(self, floor): + """Returns the wall belonging to the specified level + + Args: + floor (Arch.Level): the level for which to return the list of wall + + Returns: + list: the list of Arch.Wall + """ + return self.walls.get(floor.id, []) def _create_groups(self): """Create FreeCAD Group for the different imported elements @@ -507,10 +547,6 @@ class SH3DImporter: elm (str): the element """ - if 'Project' in self.fc_objects: - self.project = self.fc_objects.get('Project') - elif self.preferences["CREATE_IFC_PROJECT"]: - self.project = self._create_project() if 'Site' in self.fc_objects: self.site = self.fc_objects.get('Site') else: @@ -522,11 +558,15 @@ class SH3DImporter: else: self.building = self._create_building(elm) - if self.preferences["CREATE_IFC_PROJECT"]: - self.project.addObject(self.site) - self.site.addObject(self.building) + if 'Project' in self.fc_objects: + self.project = self.fc_objects.get('Project') + elif self.preferences["CREATE_IFC_PROJECT"]: + self.project = self._create_project() + if self.project: + self.project.addObject(self.site) + def _create_project(self): """Create a default Arch::Project object """ @@ -589,7 +629,7 @@ class SH3DImporter: self.site.addObject(ground) - def _import_elements(self, parent, tag, update_progress=True): + def _import_elements(self, parent, xpath): """Generic function to import a specific element. This function will lookup the handler registered for the elements @@ -599,31 +639,40 @@ class SH3DImporter: Args: parent (Element): the parent of the elements to be imported. Usually the element. - tag (str): the tag of the elements to be imported. + xpath (str): the xpath of the elements to be imported. update_progress (bool, optional): whether to update the progress. Set to false when importing a group of elements. Defaults to True. """ - tags = list(self.handlers.keys()) - elements = parent.findall(tag) - if update_progress and self.progress_bar: + xpaths = list(self.handlers.keys()) + elements = parent.findall(xpath) + tag_name = xpath[3:] if xpath.startswith('.') else xpath + + total_steps, current_step, total_elements = self._get_progress_info(xpath, elements) + if self.progress_bar: self.progress_bar.stop() - self.progress_bar.start(f"Step {tags.index(tag)+1}/{len(tags)}: importing {len(elements)} '{tag}' elements. Please wait ...", len(elements)) - _msg(f"Importing {len(elements)} '{tag}' elements ...") + self.progress_bar.start(f"Step {current_step}/{total_steps}: importing {total_elements} '{tag_name}' elements. Please wait ...", total_elements) + _msg(f"Importing {total_elements} '{tag_name}' elements ...") def _process(tuple): (i, elm) = tuple - _msg(f"Importing {tag}#{i} ({self.current_object_count + 1}/{self.total_object_count}) ...") + _msg(f"Importing {tag_name}#{i} ({self.current_object_count + 1}/{self.total_object_count}) ...") try: - self.handlers[tag].process(parent, i, elm) + self.handlers[xpath].process(parent, i, elm) except Exception as e: - _err(f"Failed to import <{tag}>#{i} ({elm.get('id', elm.get('name'))}):") + _err(f"Failed to import <{tag_name}>#{i} ({elm.get('id', elm.get('name'))}):") _err(str(e)) _err(traceback.format_exc()) - if update_progress and self.progress_bar: + if self.progress_bar: self.progress_bar.next() self.current_object_count = self.current_object_count + 1 list(map(_process, enumerate(elements))) + def _get_progress_info(self, xpath, elements): + xpaths = list(self.handlers.keys()) + total_steps = len(xpaths) + current_step = xpaths.index(xpath)+1 + return total_steps, current_step, len(elements) + def _set_site_properties(self, elm): # All information in environment?, backgroundImage?, print?, compass # are added to the site object. Some are furthermore added to the ground @@ -691,6 +740,52 @@ class SH3DImporter: else: _msg(f"No tag found in <{elm.tag}>") + def _create_slabs(self): + floors = self.floors.values() + total_steps, current_step, total_elements = self._get_progress_info(ET_XPATH_DUMMY_SLAB, floors) + if self.progress_bar: + self.progress_bar.stop() + self.progress_bar.start(f"Step {current_step}/{total_steps}: Creating {total_elements} 'slab' elements. Please wait ...", total_elements) + _msg(f"Creating {total_elements} 'slab' elements ...") + handler = self.handlers[ET_XPATH_LEVEL] + def _create_slab(tuple): + (i, floor) = tuple + _msg(f"Creating slab#{i} for floor '{floor.Label}' ...") + try: + handler.create_slabs(floor) + except Exception as e: + _err(f"Failed to create slab#{i} for floor '{floor.Label}':") + _err(str(e)) + _err(traceback.format_exc()) + if self.progress_bar: + self.progress_bar.next() + list(map(_create_slab, enumerate(floors))) + + def _decorate_surfaces(self): + + all_spaces = self.spaces.values() + all_spaces = list(itertools.chain(*all_spaces)) + all_walls = self.walls.values() + all_walls = list(itertools.chain(*all_walls)) + + total_elements = len(all_spaces)+len(all_walls) + + if self.progress_bar: + self.progress_bar.stop() + self.progress_bar.start(f"Decorating {total_elements} elements. Please wait ...", total_elements) + _msg(f"Decorating {total_elements} elements ...") + + handler = self.handlers[ET_XPATH_ROOM] + for i, space in enumerate(all_spaces): + handler.post_process(space) + if self.progress_bar: self.progress_bar.next() + + handler = self.handlers[ET_XPATH_WALL] + for i, wall in enumerate(all_walls): + handler.post_process(wall) + if self.progress_bar: self.progress_bar.next() + if self.progress_bar: self.progress_bar.stop() + class BaseHandler: """The base class for all importers.""" @@ -741,6 +836,9 @@ class BaseHandler: """ return self.importer.get_floor(level_id) + def get_spaces(self, floor): + return self.importer.get_spaces(floor) + def get_space(self, floor, p): """Returns the Space this point belongs to. @@ -755,47 +853,30 @@ class BaseHandler: """ return self.importer.get_space(floor, p) - def _get_upper_face(self, faces): - """Returns the upper face of a given list of faces + def get_walls(self, floor): + return self.importer.get_walls(floor) - More specifically returns the face with the highest z. - It is used to figure out which space a furniture belongs to. + def _ps(self, section, print_z: bool = False): + # Pretty print a Section in a condensed way + if hasattr(section, 'Shape'): + v = section.Shape.Vertexes + else: + # a Part.Face + v = section.Vertexes + return f"[{self._pv(v[0].Point, print_z)}, {self._pv(v[1].Point, print_z)}, {self._pv(v[2].Point, print_z)}, {self._pv(v[3].Point, print_z)}]" - Args: - faces (list): The list of faces + def _pe(self, edge, print_z: bool = False): + # Print an Edge in a condensed way + v = edge.Vertexes + return f"[{self._pv(v[0].Point, print_z)}, {self._pv(v[1].Point, print_z)}]" - Returns: - Face: the upper face - """ - upper_face = None - com_max_z = -float('inf') - for face in faces: - com = face.CenterOfMass - if com.z > com_max_z: - upper_face = face - com_max_z = com.z - return upper_face - - def _get_lower_face(self, faces): - """Returns the lower face of a given list of faces - - More specifically returns the face with the lowest z. - It is used to figure out which space a furniture belongs to. - - Args: - faces (list): The list of faces - - Returns: - Face: the lower face - """ - lower_face = None - com_min_z = float('inf') - for face in faces: - com = face.CenterOfMass - if com.z < com_min_z: - lower_face = face - com_min_z = com.z - return lower_face + def _pv(self, v, print_z: bool = False, ndigits: None = None): + # Print an Vector in a condensed way + if hasattr(v,'X'): + return f"({round(getattr(v, 'X'), ndigits)},{round(getattr(v, 'Y'), ndigits)}{',' + str(round(getattr(v, 'Z'), ndigits)) if print_z else ''})" + elif hasattr(v,'x'): + return f"({round(getattr(v, 'x'), ndigits)},{round(getattr(v, 'y'), ndigits)}{',' + str(round(getattr(v, 'z'), ndigits)) if print_z else ''})" + raise ValueError(f"Expected a Point or Vector, got {type(v)}") class LevelHandler(BaseHandler): @@ -822,9 +903,8 @@ class LevelHandler(BaseHandler): floor.Placement.Base.z = dim_sh2fc(float(elm.get('elevation'))) floor.Height = dim_sh2fc(float(elm.get('height'))) self._set_properties(floor, elm) - - floor.ViewObject.Visibility = elm.get('visible', 'true') == 'true' - self._add_groups(floor) + floor.Visibility = elm.get('visible', 'true') == 'true' + self._create_groups(floor) self.importer.add_floor(floor) def create_default_floor(self): @@ -833,8 +913,8 @@ class LevelHandler(BaseHandler): floor.Placement.Base.z = 0 floor.Height = 2500 - self._set_properties(floor, dict({'shType': 'level', 'id':'Level', 'floorThickness':dim_fc2sh(250), 'elevationIndex': 0, 'viewable': True})) - self._add_groups(floor) + self._set_properties(floor, dict({'shType': 'level', 'id':'Level', 'floorThickness':25, 'elevationIndex': 0, 'viewable': True})) + self._create_groups(floor) self.importer.add_floor(floor) return floor @@ -842,20 +922,126 @@ class LevelHandler(BaseHandler): def _set_properties(self, obj, elm): self.setp(obj, "App::PropertyString", "shType", "The element type", 'level') self.setp(obj, "App::PropertyString", "id", "The floor's id", elm) - self.setp(obj, "App::PropertyFloat", "floorThickness", "The floor's slab thickness", dim_sh2fc(float(elm.get('floorThickness')))) + self.setp(obj, "App::PropertyQuantity", "floorThickness", "The floor's slab thickness", dim_sh2fc(float(elm.get('floorThickness')))) self.setp(obj, "App::PropertyInteger", "elevationIndex", "The floor number", elm) self.setp(obj, "App::PropertyBool", "viewable", "Whether the floor is viewable", elm) + def _create_groups(self, floor): + # This is a special group that does not appear in the TreeView. + group = floor.newObject("App::DocumentObjectGroup") + group.Label = f"References-{floor.Label}" + self.setp(floor, "App::PropertyString", "ReferenceFacesGroupName", "The DocumentObjectGroup name for all Reference Faces on this floor", group.Name) + group.Visibility = False + group.ViewObject.ShowInTree = False - def _add_groups(self, floor): - group = floor.newObject("App::DocumentObjectGroup", "Facebinders") - self.setp(floor, "App::PropertyString", "FacebinderGroupName", "The DocumentObjectGroup name for all Facebinders on this floor", group.Name) + if self.importer.preferences["DECORATE_SURFACES"]: + group = floor.newObject("App::DocumentObjectGroup") + group.Label = f"Decoration-{floor.Label}-Walls" + self.setp(floor, "App::PropertyString", "DecorationWallsGroupName", "The DocumentObjectGroup name for all wall decorations on this floor", group.Name) + group = floor.newObject("App::DocumentObjectGroup") + group.Label = f"Decoration-{floor.Label}-Ceilings" + self.setp(floor, "App::PropertyString", "DecorationCeilingsGroupName", "The DocumentObjectGroup name for all ceilings decoration on this floor", group.Name) + group = floor.newObject("App::DocumentObjectGroup") + group.Label = f"Decoration-{floor.Label}-Floors" + self.setp(floor, "App::PropertyString", "DecorationFloorsGroupName", "The DocumentObjectGroup name for all floors decoration on this floor", group.Name) + group = floor.newObject("App::DocumentObjectGroup") + group.Label = f"Decoration-{floor.Label}-Baseboards" + self.setp(floor, "App::PropertyString", "DecorationBaseboardsGroupName", "The DocumentObjectGroup name for all baseboards on this floor", group.Name) if self.importer.preferences["IMPORT_FURNITURES"]: - group = floor.newObject("App::DocumentObjectGroup", "Furnitures") + group = floor.newObject("App::DocumentObjectGroup", f"Furnitures-{floor.Label}") self.setp(floor, "App::PropertyString", "FurnitureGroupName", "The DocumentObjectGroup name for all furnitures in this floor", group.Name) - group = floor.newObject("App::DocumentObjectGroup", "Baseboards") - self.setp(floor, "App::PropertyString", "BaseboardGroupName", "The DocumentObjectGroup name for all baseboards on this floor", group.Name) + + def create_slabs(self, floor): + """Creates a Arch.Slab for the given floor. + + Creating a slab consists in projecting all the structures of that + floor into a plane, then create a extrusion for each one and then + fuse thogether (in order to simplify the slab geometry). + + Args: + floor (Arch.Floor): the Arch Floor for which to create the Slab + """ + # Take the walls and only the spaces whose floor is actually visible. + objects_to_project = list(filter(lambda s: s.floorVisible, self.get_spaces(floor))) + objects_to_project.extend(self.get_walls(floor)) + objects_to_fuse = self._get_object_to_fuse(floor, objects_to_project) + if len(objects_to_fuse) > 0: + if len(objects_to_fuse) > 1: + bf = BOPTools.BOPFeatures.BOPFeatures(App.ActiveDocument) + slab_base = bf.make_multi_fuse([ o.Name for o in objects_to_fuse]) + slab_base.Label = f"{floor.Label}-footprint" + else: + slab_base = objects_to_fuse[0] + slab_base.Label = f"{floor.Label}-footprint" + + slab = Arch.makeStructure(slab_base) + slab.Label = f"{floor.Label}-slab" + slab.setExpression('Height', f"{slab_base.Name}.Shape.BoundBox.ZLength") + slab.Normal = -Z_NORM + floor.addObject(slab) + else: + _wrn(f"No object found for floor {floor.Label}. No slab created.") + + def _get_object_to_fuse(self, floor, objects_to_project): + group = floor.newObject("App::DocumentObjectGroup", f"SlabObjects-{floor.Label}") + group.Visibility = False + group.ViewObject.ShowInTree = False + + objects_to_fuse = [] + for object in objects_to_project: + # Project the floor's objects onto the XY plane + sv = Draft.make_shape2dview(object, Z_NORM) + sv.Label = f"SV-{floor.Label}-{object.Label}" + sv.Placement.Base.z = floor.Placement.Base.z + sv.Visibility = False + sv.recompute() + group.addObject(sv) + + wire = Part.Wire(sv.Shape.Edges) + if not wire.isClosed(): + # Sometimes the wire is not closed because the edges are + # not sorted and do not form a "chain". Therefore, sort them, + # recreate the wire while also rounding the precision of the + # Vertices in order to avoid not closing because the points + # are not close enougth + wire = Part.Wire(Part.__sortEdges__(self._round(sv.Shape.Edges))) + if not wire.isClosed(): + _wrn(f"Projected Face for {object.Label} does not produce a closed wire. Not adding to slab construction ...") + continue + + face = Part.Face(wire) + extrude = face.extrude(-Z_NORM*floor.floorThickness.Value) + part = Part.show(extrude, "Footprint") + part.Label = f"Extrude-{floor.Label}-{object.Label}-footprint" + part.recompute() + part.Visibility = False + part.ViewObject.ShowInTree = False + objects_to_fuse.append(part) + return objects_to_fuse + + def _round(self, edges, decimals=2): + """ + Rounds the coordinates of all vertices in a list of edges to the specified number of decimals. + + :param edges: A list of Part.Edge objects. + :param decimals: Number of decimal places to round to (default: 2). + :return: A list of edges with rounded vertices. + """ + new_edges = [] + + for edge in edges: + vertices = edge.Vertexes + if len(vertices) != 2: # Line or similar + raise ValueError("Unsupported edge type: Only straight edges are handled.") + new_vertices = [ + App.Vector(round(v.X, decimals), round(v.Y, decimals), round(v.Z, decimals)) + for v in vertices + ] + # Create a new edge with the rounded vertices + new_edge = Part.Edge(Part.LineSegment(new_vertices[0], new_vertices[1])) + new_edges.append(new_edge) + return new_edges class RoomHandler(BaseHandler): @@ -878,57 +1064,43 @@ class RoomHandler(BaseHandler): floor = self.get_floor(level_id) assert floor != None, f"Missing floor '{level_id}' for '{elm.get('id')}' ..." - # A Room is composed of a space with the slab as the base object - - points = [] - for point in elm.findall('point'): - x = float(point.get('x')) - y = float(point.get('y')) - z = dim_fc2sh(floor.Placement.Base.z) - points.append(coord_sh2fc(App.Vector(x, y, z))) - - slab = None + space = face = None if self.importer.preferences["MERGE"]: - slab = self.get_fc_object(elm.get("id"), 'room') - - if not slab: - line = Draft.make_wire(points, placement=App.Placement(), closed=True, face=True, support=None) - slab = Arch.makeStructure(line, height=floor.floorThickness) - - slab.Label = elm.get('name', 'Room') + '-slab' - slab.IfcType = "Slab" - slab.Normal = -Z_NORM - - color = elm.get('floorColor', self.importer.preferences["DEFAULT_FLOOR_COLOR"]) - set_color_and_transparency(slab, color) - self._set_properties(slab, elm) - - slab.recompute(True) - - # No 1-to-1 correspondance between SH3D and FC element. - # Creating a fake SH3D elemement in order to take advantage of the - # different lookup facilities. NOTE the suffix '-space' for both - # the sh_type and id... - space = None - if self.importer.preferences["MERGE"]: - space = self.get_fc_object(elm.get("id")+"-space", 'room-space') + space = self.get_fc_object(elm.get("id"), 'room') + # A Room is composed of a space with a Face as the base object if not space: - space = Arch.makeSpace(slab) + floor_z = dim_fc2sh(floor.Placement.Base.z) + points = [ coord_sh2fc(App.Vector(float(p.get('x')), float(p.get('y')), floor_z)) for p in elm.findall('point') ] + # remove consecutive identical points + points = [points[i] for i in range(len(points)) if i == 0 or points[i] != points[i - 1]] + + # Create a reference face that can be used later on to create + # the floor & ceiling decoration... + reference_face = Draft.make_wire(points, closed=True, face=True, support=None) + reference_face.Label = elm.get('name', 'Room') + '-reference' + reference_face.Visibility = False + reference_face.recompute() + floor.getObject(floor.ReferenceFacesGroupName).addObject(reference_face) + + # NOTE: for room to properly display and calculate the area, the + # Base object can not be a face but must have a height... + footprint = App.ActiveDocument.addObject("Part::Feature", "Footprint") + footprint.Shape = reference_face.Shape.extrude(Z_NORM) + footprint.Label = elm.get('name', 'Room') + '-footprint' + + space = Arch.makeSpace(footprint) space.IfcType = "Space" space.Label = elm.get('name', 'Room') - self._set_space_properties(space, elm) + self._set_properties(space, elm) - self.importer.fc_objects[slab.id] = slab - self.importer.fc_objects[space.id] = space + space.setExpression('ElevationWithFlooring', f"{footprint.Name}.Shape.BoundBox.ZMin") + self.setp(space, "App::PropertyLink", "ReferenceFace", "The Reference Part.Face", reference_face) + self.setp(space, "App::PropertyString", "ReferenceFloorName", "The name of the Arch.Floor this room belongs to", floor.Name) - upper_face = self._get_upper_face(slab.Shape.Faces) - if not upper_face: - _wrn(f"Couldn't find the upper face of slab {slab.Label} on level {floor.Label}!") - else: - self.importer.space_upper_faces.append((floor, space, upper_face)) + self.importer.add_space(floor, space) - slab.Visibility = True + space.Visibility = True if space.floorVisible else False floor.addObject(space) @@ -947,15 +1119,41 @@ class RoomHandler(BaseHandler): self.setp(obj, "App::PropertyFloat", "areaYOffset", "The room's area annotation y offset", elm) self.setp(obj, "App::PropertyBool", "floorVisible", "Whether the floor of the room is displayed", elm) self.setp(obj, "App::PropertyString", "floorColor", "The room's floor color", floor_color) - self.setp(obj, "App::PropertyFloat", "floorShininess", "The room's floor shininess", elm) + self.setp(obj, "App::PropertyPercent", "floorShininess", "The room's floor shininess", percent_sh2fc(elm.get('floorShininess', 0))) self.setp(obj, "App::PropertyBool", "ceilingVisible", "Whether the ceiling of the room is displayed", elm) self.setp(obj, "App::PropertyString", "ceilingColor", "The room's ceiling color", ceiling_color) - self.setp(obj, "App::PropertyFloat", "ceilingShininess", "The room's ceiling shininess", elm) + self.setp(obj, "App::PropertyPercent", "ceilingShininess", "The room's ceiling shininess", percent_sh2fc(elm.get('ceilingShininess', 0))) self.setp(obj, "App::PropertyBool", "ceilingFlat", "", elm) - def _set_space_properties(self, obj, elm): - self.setp(obj, "App::PropertyString", "shType", "The element type", 'room-space') - self.setp(obj, "App::PropertyString", "id", "The slab's id", elm.get('id', str(uuid.uuid4()))+"-space") + def post_process(self, obj): + if self.importer.preferences["DECORATE_SURFACES"]: + floor = App.ActiveDocument.getObject(obj.ReferenceFloorName) + self._add_facebinder(floor, obj, "floor") + self._add_facebinder(floor, obj, "ceiling") + + def _add_facebinder(self, floor, space, side): + facebinder_id = f"{floor.id}-{space.id}-{side}-facebinder" + facebinder = None + if self.importer.preferences["MERGE"]: + facebinder = self.get_fc_object(facebinder_id, 'facebinder') + + if not facebinder: + # NOTE: always use Face1 as this is a 2D object + facebinder = Draft.make_facebinder(( space.ReferenceFace, ("Face1", ) )) + facebinder.Extrusion = 1 + facebinder.Label = space.Label + f" {side} finish" + + facebinder.Placement.Base.z = 1 if (side == "floor") else floor.Height.Value-1 + facebinder.Visibility = getattr(space, f"{side}Visible") + set_color_and_transparency(facebinder, getattr(space, f"{side}Color")) + set_shininess(facebinder, getattr(space, f"{side}Shininess", 0)) + + self.setp(facebinder, "App::PropertyString", "shType", "The element type", 'facebinder') + self.setp(facebinder, "App::PropertyString", "id", "The element's id", facebinder_id) + self.setp(facebinder, "App::PropertyString", "ReferenceRoomName", "The Reference Arch.Space", space.Name) + + group_name = getattr(floor, "DecorationFloorsGroupName") if (side == "floor") else getattr(floor, "DecorationCeilingsGroupName") + floor.getObject(group_name).addObject(facebinder) class WallHandler(BaseHandler): @@ -991,24 +1189,20 @@ class WallHandler(BaseHandler): wall.IfcType = "Wall" wall.Label = f"wall{i}" + wall.Base.Label = f"wall{i}-wallshape" self._set_properties(wall, elm) + self._set_baseboard_properties(wall, elm) + self.setp(wall, "App::PropertyString", "ReferenceFloorName", "The Name of the Arch.Floor this walls belongs to", floor.Name) + wall.recompute(True) - self._create_facebinders(floor, wall, elm) - - if self.importer.preferences["IMPORT_FURNITURES"]: - for baseboard in elm.findall('baseboard'): - space = self._import_baseboard(floor, wall, baseboard) - if space: - space.Boundaries = space.Boundaries + [wall] - floor.addObject(wall) if base_object: floor.addObject(base_object) base_object.Visibility = False base_object.Label = base_object.Label + "-" + wall.Label - self.importer.add_wall(wall) + self.importer.add_wall(floor, wall) def _get_sibling_wall(self, parent, wall, sibling_attribute_name): sibling_wall_id = wall.get(sibling_attribute_name, None) @@ -1021,11 +1215,32 @@ class WallHandler(BaseHandler): return sibling_wall def _set_properties(self, obj, elm): + + top_color = elm.get('topColor', self.importer.preferences["DEFAULT_FLOOR_COLOR"]) + left_side_color = elm.get('leftSideColor', self.importer.preferences["DEFAULT_FLOOR_COLOR"]) + right_side_color = elm.get('rightSideColor', self.importer.preferences["DEFAULT_FLOOR_COLOR"]) + self.setp(obj, "App::PropertyString", "shType", "The element type", 'wall') self.setp(obj, "App::PropertyString", "id", "The wall's id", elm) self.setp(obj, "App::PropertyString", "wallAtStart", "The Id of the contiguous wall at the start of this wall", elm) self.setp(obj, "App::PropertyString", "wallAtEnd", "The Id of the contiguous wall at the end of this wall", elm) self.setp(obj, "App::PropertyString", "pattern", "The pattern of this wall in plan view", elm) + self.setp(obj, "App::PropertyString", "topColor", "The wall inner color", top_color) + self.setp(obj, "App::PropertyString", "leftSideColor", "The wall inner color", left_side_color) + self.setp(obj, "App::PropertyPercent","leftSideShininess", "The room's ceiling shininess", percent_sh2fc(elm.get('leftSideShininess', 0))) + self.setp(obj, "App::PropertyString", "rightSideColor", "The wall inner color", right_side_color) + self.setp(obj, "App::PropertyPercent","rightSideShininess", "The room's ceiling shininess", percent_sh2fc(elm.get('rightSideShininess', 0))) + + def _set_baseboard_properties(self, obj, elm): + # Baseboard are a little bit special: + # Since their placement and other characteristics are dependant of + # the wall elements to be created (such as Door&Windows), their + # creation is delayed until the + for baseboard in elm.findall('baseboard'): + side = baseboard.get('attribute') + self.setp(obj, "App::PropertyQuantity", f"{side}Thickness", f"The thickness of the {side} baseboard", dim_sh2fc(float(baseboard.get("thickness")))) + self.setp(obj, "App::PropertyQuantity", f"{side}Height", f"The height of the {side} baseboard", dim_sh2fc(float(baseboard.get("height")))) + self.setp(obj, "App::PropertyString", f"{side}Color", f"The color of the {side} baseboard", baseboard.get("color")) def _create_wall(self, floor, prev, next, elm): """Create an Arch::Structure from an SH3D Element. @@ -1070,7 +1285,7 @@ class WallHandler(BaseHandler): # object based on ruled surface instead. # See https://github.com/FreeCAD/FreeCAD/issues/18658 and related OCCT # ticket - if (sweep.Shape.isNull() or not sweep.Shape.isValid()): + if sweep.Shape.isNull() or not sweep.Shape.isValid(): if is_wall_straight: _log(f"Sweep's shape is invalid, using ruled surface instead ...") App.ActiveDocument.removeObject(sweep.Label) @@ -1082,13 +1297,13 @@ class WallHandler(BaseHandler): else: wall = Arch.makeWall(sweep) - # Keep track of base object. Used for baseboard import + # Keep track of base objects. Used to decorate walls self.importer.set_property(wall, "App::PropertyLinkList", "BaseObjects", "The different base objects whose sweep failed. Kept for compatibility reasons", [section_start, section_end, spine]) # TODO: Width is incorrect when joining walls - wall.setExpression('Length', f'{spine.Label}.Length') - wall.setExpression('Width', f'({section_start.Label}.Length + {section_end.Label}.Length) / 2') - wall.setExpression('Height', f'({section_start.Label}.Height + {section_end.Label}.Height) / 2') + wall.setExpression('Length', f'{spine.Name}.Length') + wall.setExpression('Width', f'({section_start.Name}.Length + {section_end.Name}.Length) / 2') + wall.setExpression('Height', f'({section_start.Name}.Height + {section_end.Name}.Height) / 2') return wall, base_object @@ -1104,7 +1319,7 @@ class WallHandler(BaseHandler): Part::Sweep: the Part::Sweep """ App.ActiveDocument.recompute([section_start, section_end, spine]) - sweep = App.ActiveDocument.addObject('Part::Sweep') + sweep = App.ActiveDocument.addObject('Part::Sweep', "WallShape") sweep.Sections = [section_start, section_end] sweep.Spine = spine sweep.Solid = True @@ -1137,7 +1352,7 @@ class WallHandler(BaseHandler): compound.Links = [ruled_surface, section_start, section_end, spine] compound.recompute() - compound_solid = App.ActiveDocument.addObject("Part::Feature") + compound_solid = App.ActiveDocument.addObject("Part::Feature", "WallShape") compound_solid.Shape = Part.Solid(Part.Shell(compound.Shape.Faces)) return compound_solid, compound @@ -1170,11 +1385,6 @@ class WallHandler(BaseHandler): height_start = dim_sh2fc(elm.get('height', dim_fc2sh(floor.Height))) height_end = dim_sh2fc(elm.get('heightAtEnd', dim_fc2sh(height_start))) - # NOTE: the wall height is adjusted with the floor thickness - # BUG: It should be adjusted for all floor except the last one. - height_start = height_start + floor.floorThickness - height_end = height_end + floor.floorThickness - start = coord_sh2fc(App.Vector(x_start, y_start, z)) end = coord_sh2fc(App.Vector(x_end, y_end, z)) @@ -1197,6 +1407,7 @@ class WallHandler(BaseHandler): section_end = self._get_section(wall_details, False, next_wall_details) spine = Draft.makeLine(start, end) + spine.Label = f"Spine" App.ActiveDocument.recompute([section_start, section_end, spine]) if self.importer.preferences["DEBUG"]: _log(f"_create_straight_segment(): wall {self._pv(start)}->{self._pv(end)} => section_start={self._ps(section_start)}, section_end={self._ps(section_end)}") @@ -1237,7 +1448,12 @@ class WallHandler(BaseHandler): # The Length property is used in the Wall to calculate volume, etc... # Since make Circle does not calculate this Length I calculate it here... self.importer.set_property(spine, "App::PropertyFloat", "Length", "The length of the Arc", length, group="Draft") + # The Start and End property are used in the Wall to determine Facebinders + # characteristics... + self.importer.set_property(spine, "App::PropertyVector", "Start", "The start point of the Arc", start, group="Draft") + self.importer.set_property(spine, "App::PropertyVector", "End", "The end point of the Arc", end, group="Draft") + spine.Label = f"Spine" App.ActiveDocument.recompute([section_start, section_end, spine]) if self.importer.preferences["DEBUG"]: _log(f"_create_curved_segment(): wall {self._pv(start)}->{self._pv(end)} => section_start={self._ps(section_start)}, section_end={self._ps(section_end)}") @@ -1301,6 +1517,7 @@ class WallHandler(BaseHandler): section.recompute() _color_section(section) + section.Label = "Section-start" if at_start else "Section-end" return section def _get_intersection_edge(self, lside, rside, sibling_lside, sibling_rside): @@ -1359,7 +1576,7 @@ class WallHandler(BaseHandler): # We take the center that preserve the arc_extent orientation (in FC # coordinate). The orientation is calculated from start to end center = circles[0].Center - if numpy.sign(arc_extent) != numpy.sign(DraftVecUtils.angle(start-center, end-center, Z_NORM)): + if np.sign(arc_extent) != np.sign(DraftVecUtils.angle(start-center, end-center, Z_NORM)): invert_angle = True center = circles[1].Center @@ -1410,85 +1627,87 @@ class WallHandler(BaseHandler): """ return (b - a).cross(c - a).normalize() - def _ps(self, section, print_z: bool = False): - # Pretty print a Section in a condensed way - if hasattr(section, 'Shape'): - v = section.Shape.Vertexes - else: - # a Part.Face - v = section.Vertexes - return f"[{self._pv(v[0].Point, print_z)}, {self._pv(v[1].Point, print_z)}, {self._pv(v[2].Point, print_z)}, {self._pv(v[3].Point, print_z)}]" + def post_process(self, obj): + if self.importer.preferences["DECORATE_SURFACES"]: + floor = App.ActiveDocument.getObject(obj.ReferenceFloorName) - def _pe(self, edge, print_z: bool = False): - # Print an Edge in a condensed way - v = edge.Vertexes - return f"[{self._pv(v[0].Point, print_z)}, {self._pv(v[1].Point, print_z)}]" + (left_face_name, left_face, right_face_name, right_face) = self._get_faces(obj) - def _pv(self, v, print_z: bool = False, ndigits: None = None): - # Print an Vector in a condensed way - if hasattr(v,'X'): - return f"({round(getattr(v, 'X'), ndigits)},{round(getattr(v, 'Y'), ndigits)}{',' + str(round(getattr(v, 'Z'), ndigits)) if print_z else ''})" - elif hasattr(v,'x'): - return f"({round(getattr(v, 'x'), ndigits)},{round(getattr(v, 'y'), ndigits)}{',' + str(round(getattr(v, 'z'), ndigits)) if print_z else ''})" - raise ValueError(f"Expected a Point or Vector, got {type(v)}") + self._create_facebinders(floor, obj, left_face_name, right_face_name) - def _create_facebinders(self, floor, wall, elm): + self._create_baseboards(floor, obj, left_face, right_face) + + def _create_facebinders(self, floor, wall, left_face_name, right_face_name): """Set the wall's colors taken from `elm`. Creates 2 FaceBinders (left and right) and sets the corresponding color and the shininess of the wall. Args: + floor (Arch::Level): the level the wall belongs to. Used to group + the resulting Facebinders wall (Arch::Wall): the wall to paint elm (Element): the xml element for the wall to be imported + left_face_name (str): the name of the left face suitable for selecting + right_face_name (str): the name of the right face suitable for selecting """ # The top color is the color of the "mass" of the wall - top_color = elm.get('topColor', self.importer.preferences["DEFAULT_FLOOR_COLOR"]) + top_color = wall.topColor set_color_and_transparency(wall, top_color) + self._create_facebinder(floor, wall,left_face_name, "left") + self._create_facebinder(floor, wall, right_face_name, "right") - left_facebinder = Draft.make_facebinder(( wall, ("Face2", ) )) - left_facebinder.Extrusion = 1 - left_facebinder.Label = wall.Label + "-fb-left" - left_side_color = elm.get('leftSideColor', top_color) - set_color_and_transparency(left_facebinder, left_side_color) - left_side_shininess = elm.get('leftSideShininess', 0) - set_shininess(left_facebinder, left_side_shininess) - floor.getObject(floor.FacebinderGroupName).addObject(left_facebinder) + def _create_facebinder(self, floor, wall, face_name, side): + if face_name: + facebinder_id = f"{wall.id}-{side}-facebinder" + facebinder = None + if self.importer.preferences["MERGE"]: + facebinder = self.get_fc_object(facebinder_id, 'facebinder') - right_facebinder = Draft.make_facebinder(( wall, ("Face4", ) )) - right_facebinder.Extrusion = 1 - right_facebinder.Label = wall.Label + "-fb-right" - right_side_color = elm.get('rightSideColor', top_color) - set_color_and_transparency(right_facebinder, right_side_color) - right_side_shininess = elm.get('rightSideShininess', 0) - set_shininess(right_facebinder, right_side_shininess) - floor.getObject(floor.FacebinderGroupName).addObject(right_facebinder) + if not facebinder: + facebinder = Draft.make_facebinder(( wall, (face_name, ) )) + facebinder.Extrusion = 1 + facebinder.Label = wall.Label + f" {side} side finish" - def _import_baseboard(self, floor, wall, elm): + color = getattr(wall, f"{side}SideColor") + set_color_and_transparency(facebinder, color) + shininess = getattr(wall, f"{side}SideShininess", 0) + set_shininess(facebinder, shininess) + self.setp(facebinder, "App::PropertyString", "shType", "The element type", 'facebinder') + self.setp(facebinder, "App::PropertyString", "id", "The element's id", facebinder_id) + self.setp(facebinder, "App::PropertyString", "ReferenceWallName", "The element's wall Name", wall.Name) + + floor.getObject(floor.DecorationWallsGroupName).addObject(facebinder) + else: + _wrn(f"Failed to determine {side} face for wall {wall.Label}!") + + def _create_baseboards(self, floor, wall, left_face, right_face): """Creates and returns a Part::Extrusion from the imported_baseboard object Args: floor (Slab): the Slab the wall belongs to wall (Wall): the Arch wall - elm (Element): the wall being imported + elm (Element): the wall being imported (with child baseboards) + left_face (Part.Face): the left hand side of the wall + right_face (Part.Face): the right hand side of the wall Returns: Part::Extrusion: the newly created object """ - wall_width = float(wall.Width) + for side in ["leftSideBaseboard", "rightSideBaseboard"]: + if hasattr(wall, f"{side}Height"): + face = left_face if side == "leftSideBaseboard" else right_face + self._create_baseboard(floor, wall, side, face) - baseboard_width = dim_sh2fc(elm.get('thickness')) - baseboard_height = dim_sh2fc(elm.get('height')) + def _create_baseboard(self, floor, wall, side, face): - # This is brittle in case the wall is merged and the there are already - # some doors, windows, etc... - side = elm.get('attribute') - faces = wall.Base.Shape.Faces - face = faces[1] if side == 'leftSideBaseboard' else faces[3] + baseboard_width = getattr(wall, f"{side}Thickness").Value + baseboard_height = getattr(wall, f"{side}Height").Value # Once I have the face, I get the lowest edge. lowest_z = float('inf') bottom_edge = None + for edge in face.Edges: if edge and edge.CenterOfMass and edge.CenterOfMass.z < lowest_z: lowest_z = edge.CenterOfMass.z @@ -1512,16 +1731,16 @@ class WallHandler(BaseHandler): for edge in [edge0, edge1, edge2, edge3]: edge.Vertexes[0].Point.z = edge.Vertexes[1].Point.z = ref_z - baseboard_id = f"{wall.id}-{side}" + baseboard_id = f"{wall.id} {side}" baseboard = None if self.importer.preferences["MERGE"]: baseboard = self.get_fc_object(baseboard_id, 'baseboard') if not baseboard: - base = App.ActiveDocument.addObject("Part::Feature", "baseboard-base") + base = App.ActiveDocument.addObject("Part::Feature", f"{wall.Label} {side} base") base.Shape = Part.makeFace([ Part.Wire([edge0, edge1, edge2, edge3]) ]) base.Visibility = False - baseboard = App.ActiveDocument.addObject('Part::Extrusion', f"{wall.Label}-{side}") + baseboard = App.ActiveDocument.addObject('Part::Extrusion', f"{wall.Label} {side}") baseboard.Base = base baseboard.DirMode = "Custom" @@ -1535,23 +1754,79 @@ class WallHandler(BaseHandler): baseboard.TaperAngle = 0 baseboard.TaperAngleRev = 0 - set_color_and_transparency(baseboard, elm.get('color')) + set_color_and_transparency(baseboard, getattr(wall, f"{side}Color")) self.setp(baseboard, "App::PropertyString", "shType", "The element type", 'baseboard') self.setp(baseboard, "App::PropertyString", "id", "The element's id", baseboard_id) - self.setp(baseboard, "App::PropertyLink", "parent", "The element parent", wall) + self.setp(baseboard, "App::PropertyString", "ReferenceWallName", "The element's wall Name", wall.Name) baseboard.recompute(True) + floor.getObject(floor.DecorationBaseboardsGroupName).addObject(baseboard) - space = self.get_space(floor, baseboard.Shape.BoundBox.Center) - if space: - space.Group = space.Group + [baseboard] - else: - _log(f"No space found to enclose {baseboard.Label}. Adding to generic group.") - floor.getObject(floor.BaseboardGroupName).addObject(baseboard) + def _get_faces(self, wall): + """Returns the name of the left and right face for `wall` - # Returns the Space for the wall to be added to the space.Boundaries - return space + The face names are suitable for selection later on when creating + the Facebinders and baseboards. Note, that this must be executed + once the wall has been completly been constructued. If a window + or door is added afterward, this will have an impact on what is + considered the left and right side of the wall + + Args: + wall (Arch.Wall): the wall for which we have to determine + the left and right side. + + Returns: + tuple: a tuple of string containing the name of the left and + right side of the wall + """ + # In order to handle curved walls, take the oriented line (from + # start to end) that pass throuh the center of gravity of the wall + # Hopefully the COG of the face will always be on the correct side + # of the COG of the wall + wall_start = wall.BaseObjects[2].Start + wall_end = wall.BaseObjects[2].End + wall_cog_start = wall.Shape.CenterOfGravity + wall_cog_end = wall_cog_start + wall_end - wall_start + + left_face_name = right_face_name = None + left_face = right_face = None + for (i, face) in enumerate(wall.Shape.Faces): + face_cog = face.CenterOfGravity + + # The face COG is not on the same z as the wall COG + # just skipping. + if not math.isclose(face_cog.z, wall_cog_start.z, abs_tol=1): + continue + + side = self._get_face_side(wall_cog_start, wall_cog_end, face_cog) + # NOTE: face names start at 1... + if side > 0: + left_face_name = f"Face{i+1}" + left_face = face + elif side < 0: + right_face_name = f"Face{i+1}" + right_face = face + if left_face_name and right_face_name: + # Optimization. Is it always true? + break + return (left_face_name, left_face, right_face_name, right_face) + + def _get_face_side(self, start:App.Vector, end:App.Vector, cog:App.Vector): + # Compute vectors + ab = end - start # Vector from start to end + ac = cog - start # Vector from start to CenterOfGravity + + ab.z = 0 + ac.z = 0 + + # Compute the cross product (z-component is enough for 2D test) + cross_z = ab.x * ac.y - ab.y * ac.x + + # Determine the position of point cog + if math.isclose(cross_z, 0, abs_tol=1): + return 0 + return cross_z class BaseFurnitureHandler(BaseHandler): @@ -1594,7 +1869,7 @@ class BaseFurnitureHandler(BaseHandler): self.setp(obj, "App::PropertyBool", "deformable", "Whether the object is deformable", elm) self.setp(obj, "App::PropertyBool", "texturable", "Whether the object is texturable", elm) self.setp(obj, "App::PropertyString", "staircaseCutOutShape", "", elm) - self.setp(obj, "App::PropertyFloat", "shininess", "The object's shininess", elm) + self.setp(obj, "App::PropertyPercent", "shininess", "The object's shininess", percent_sh2fc(elm.get('shininess', 0))) self.setp(obj, "App::PropertyFloat", "valueAddedTaxPercentage", "The object's VAT percentage", elm) self.setp(obj, "App::PropertyString", "currency", "The object's price currency", str(elm.get('currency', 'EUR'))) @@ -1696,18 +1971,27 @@ class DoorOrWindowHandler(BaseFurnitureHandler): corner = center.add(App.Vector(-width/2, -depth/2, -height/2)) + # Then create a box that represent the BoundingBox of the windows + # to find out which wall contains the window. solid = Part.makeBox(width, depth, height) solid.rotate(solid.CenterOfMass, Z_NORM, math.degrees(ang_sh2fc(angle))) solid.translate(corner) # Get all the walls hosting that door/window... wall_width = -DEFAULT_WALL_WIDTH - walls = self._get_containing_walls(solid) + walls = self._get_containing_walls(floor, solid) if len(walls) == 0: _err(f"Missing wall for {elm.get('id')}. Defaulting to width {DEFAULT_WALL_WIDTH} ...") else: - wall_width = walls[0].Width - + # NOTE: + # The main host (the one defining the width of the door/window) is + # the one that contains the CenterOfMass of the windows, or maybe + # the one that has the same normal? + wall_width = float(walls[0].Width) + com = solid.CenterOfMass + for wall in walls: + if wall.Shape.isInside(com, 1, False): + wall_width = float(wall.Width) center2corner = App.Vector(-width/2, -wall_width/2, 0) rotation = App.Rotation(Z_NORM, math.degrees(ang_sh2fc(angle))) @@ -1730,13 +2014,14 @@ class DoorOrWindowHandler(BaseFurnitureHandler): (windowtype, ifc_type) = ('Simple door', 'Door') # See the https://wiki.freecad.org/Arch_Window for details about these values - h1 = 50 - h2 = 50 - h3 = 50 - o1 = 40 - w1 = float(wall_width)-o1 # make sure the door takes the whole wall (facebinder+baseboard) - w2 = 40 - o2 = (w1-w2) / 2 + # Only using Opening / Fixed / Simple Door + h1 = min(50,height*.025) # 2.5% of frame + h2 = h1 + h3 = 0 + w1 = wall_width + w2 = min(20.0,wall_width*.2) # 20% of width + o1 = 0 + o2 = (wall_width-w2)/2 window = Arch.makeWindowPreset(windowtype, width, height, h1, h2, h3, w1, w2, o1, o2, pl) window.IfcType = ifc_type @@ -1752,10 +2037,11 @@ class DoorOrWindowHandler(BaseFurnitureHandler): window.Hosts = walls return window - def _get_containing_walls(self, solid): + def _get_containing_walls(self, floor, solid): """Returns the wall(s) intersecting with the door/window. Args: + floor (Arch.Level): the level the solid must belongs to solid (Part.Solid): the solid to test against each wall's bounding box @@ -1763,7 +2049,7 @@ class DoorOrWindowHandler(BaseFurnitureHandler): list(Arch::Wall): the wall(s) containing the given solid """ host_walls = [] - for wall in self.importer.get_walls(): + for wall in self.importer.get_walls(floor): if solid.common(wall.Shape).Volume > 0: host_walls.append(wall) return host_walls @@ -1821,7 +2107,7 @@ class FurnitureHandler(BaseFurnitureHandler): # We add the object to the list of known object that can then # be referenced elsewhere in the SH3D model (i.e. lights). - self.importer.fc_objects[feature.id] = feature + self.importer.add_fc_objects(feature) def _create_equipment(self, floor, elm): width = dim_sh2fc(float(elm.get('width'))) @@ -1852,7 +2138,7 @@ class FurnitureHandler(BaseFurnitureHandler): App.Vector(rij[3], rij[4], rij[5]), App.Vector(rij[6], rij[7], rij[8]) ) - _msg(f"model_rotation is not yet implemented ...") + _msg(f"{elm.get('id')}: modelRotation is not yet implemented ...") transform.scale(width/bb.XLength, height/bb.YLength, depth/bb.ZLength) # NOTE: the model is facing up, thus y and z are inverted transform.rotateX(math.pi/2) @@ -2093,8 +2379,17 @@ def _color_section(section): def set_shininess(obj, shininess): + # TODO: it seems a shininess of 0 means the wall loose its + # color. We leave it at the default setting untill a later time + return if not App.GuiUp or not shininess: return - if hasattr(obj.ViewObject, "Shininess"): - # Shininess goes from 0 -> 0.25 in SH3d and 0 -> 100 in FC - obj.ViewObject.Shininess = int((100*shininess)/0.25) + if hasattr(obj.ViewObject, "ShapeAppearance"): + mat = obj.ViewObject.ShapeAppearance[0] + mat.Shininess = float(shininess)/100 + obj.ViewObject.ShapeAppearance = mat + + +def percent_sh2fc(percent): + # percent goes from 0 -> 1 in SH3d and 0 -> 100 in FC + return int(float(percent)*100)