From 065c5cff2206a11baf2dc7baf11352f8dffae22f Mon Sep 17 00:00:00 2001 From: JULIEN MASNADA Date: Tue, 18 Feb 2025 11:45:40 +0100 Subject: [PATCH] [BIM] SH3DImporter: miscellaneous improvments (#19335) * Fix duplicated groups * Fixed windows spaning several floors * Fixing doorOrWindow positioning. Allowing to DEBUG_GEOMETRY * Fixed default color for floor and ground * Fixed some windows positioning issue * Fixed invalid modification of wall array. Changed default window type to allow opening * fixed some import failures. Added some new windows * Fixed some more edge cases * Use doc transaction. Fixed import of room based on self-intersecting polygon. * Added default furniture color. Fixed wall reference face to fix slab creation * Replace Debug setting by DebugGeometry * Fixed corner cases when importing Door & Furniitures * Fix emissive color and shininess * Remove external package dependency * Fixed furniture placement and transformation * Make sure lights are properly imported * Fixed duplicated camera and ground when merging --- .../Resources/ui/preferences-sh3d-import.ui | 61 +- src/Mod/BIM/importers/importSH3DHelper.py | 1535 ++++++++++++----- 2 files changed, 1127 insertions(+), 469 deletions(-) diff --git a/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui b/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui index 14a6d5efa5..5a66257658 100644 --- a/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui +++ b/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui @@ -43,16 +43,15 @@ - + - Shows verbose debug messages during import of SH3D files in the Report - view panel. Log level message must be allowed for this setting to have an effect. + DEBUG: keep the construction geometries in the ActiveDocument. Useful when debugging a failed import - Show debug messages + Debug Geometry - sh3dDebug + sh3dDebugGeometry Mod/Arch @@ -230,9 +229,9 @@ - 150 - 169 - 186 + 168 + 168 + 168 @@ -345,9 +344,9 @@ - 168 - 168 - 168 + 230 + 230 + 230 @@ -419,6 +418,46 @@ + + + + + + Default furniture Color + + + sh3dDefaultFurnitureColor + + + + + + + + 0 + 0 + + + + This color is used when a furniture does not define its own color. + + + + 168 + 150 + 26 + + + + sh3dDefaultFurnitureColor + + + Mod/Arch + + + + + diff --git a/src/Mod/BIM/importers/importSH3DHelper.py b/src/Mod/BIM/importers/importSH3DHelper.py index c32084c01b..f531cc216f 100644 --- a/src/Mod/BIM/importers/importSH3DHelper.py +++ b/src/Mod/BIM/importers/importSH3DHelper.py @@ -38,6 +38,7 @@ import DraftVecUtils import Mesh import MeshPart import Part +import TechDraw from draftutils.messages import _err, _log, _msg, _wrn from draftutils.params import get_param_arch @@ -56,6 +57,12 @@ else: # Used to make section edges more visible (https://coolors.co/5bc0eb-fde74c-9bc53d-e55934-fa7921) DEBUG_EDGES_COLORS = ["5bc0eb", "fde74c", "9bc53d", "e55934", "fa7921"] DEBUG_POINT_COLORS = ["011627", "ff0022", "41ead4", "fdfffc", "b91372"] +RED = (255,0,0,1) +GREEN = (0,255,0,1) +BLUE = (0,0,255,1) +MAGENTA = (255,85,255,1) +MAGENTA_LIGHT = (255,85,127,1) +ORANGE = (255,85,0,1) try: from Render import Camera, PointLight @@ -66,8 +73,9 @@ except : # SweetHome3D is in cm while FreeCAD is in mm FACTOR = 10 +TOLERANCE = 1 DEFAULT_WALL_WIDTH = 100 -TOLERANCE = float(.1) +TWO_PI = 2* math.pi DEFAULT_MATERIAL = App.Material( DiffuseColor=(1.00,0.00,0.00), AmbientColor=(0.33,0.33,0.33), @@ -81,6 +89,7 @@ ORIGIN = App.Vector(0, 0, 0) X_NORM = App.Vector(1, 0, 0) Y_NORM = App.Vector(0, 1, 0) Z_NORM = App.Vector(0, 0, 1) +NO_ROT = App.Rotation() # The Windows lookup map. This is really brittle and a better system should # be found. Arch.WindowPresets = ["Fixed", "Open 1-pane", "Open 2-pane", @@ -88,7 +97,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=.*/: ("Fixed","Window"),/' | sort -u +# sed -e 's/.*catalogId=//;s/ name=.*/: ("Open 1-pane","Window"),/' | sort -u # unzip -p all-doors.sh3d Home.xml | \ # grep 'catalogId=' | \ # sed -e 's/.*catalogId=//;s/ name=.*/: ("Simple door","Door")/' | sort -u @@ -116,41 +125,52 @@ DOOR_MODELS = { 'Scopia#glassDoor2': ("Glass door","Door"), 'Scopia#glass_door': ("Glass door","Door"), 'Scopia#puerta': ("Simple door","Door"), + "PeterSmolik#door1": ("Simple door","Door"), + "PeterSmolik#doorGlassPanels": ("Simple door","Door"), + "PeterSmolik#door1": ("Simple door","Door"), + 'Siath#emergencyGlassDoubleDoor': ("Simple door","Door"), + 'OlaKristianHoff#door_window_thick_double_2x4': ("Simple door","Door"), + 'Mchnz#craftsmanDoorClosed': ("Simple door","Door"), - '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#doubleFrenchWindow126x200': ("Open 1-pane","Window"), + 'eTeks#doubleHungWindow80x122': ("Open 1-pane","Window"), + 'eTeks#doubleOutwardOpeningWindow': ("Open 1-pane","Window"), + 'eTeks#doubleWindow126x123': ("Open 1-pane","Window"), + 'eTeks#doubleWindow126x163': ("Open 1-pane","Window"), + 'eTeks#fixedTriangleWindow85x85': ("Open 1-pane","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"), - + 'eTeks#frenchWindow85x200': ("Open 1-pane","Window"), + 'eTeks#halfRoundWindow': ("Open 1-pane","Window"), + 'eTeks#roundWindow': ("Open 1-pane","Window"), + 'eTeks#sliderWindow126x200': ("Open 1-pane","Window"), + 'eTeks#window85x123': ("Open 1-pane","Window"), + 'eTeks#window85x163': ("Open 1-pane","Window"), + 'eTeks#serviceHatch': ("Fixed","Window"), + 'Kator Legaz#window-01': ("Open 1-pane","Window"), + 'Kator Legaz#window-08-02': ("Open 1-pane","Window"), + 'Kator Legaz#window-08': ("Open 1-pane","Window"), + 'Scopia#turn-window': ("Open 1-pane","Window"), + 'Scopia#window_2x1_medium_with_large_pane': ("Open 1-pane","Window"), + 'Scopia#window_2x1_with_sliders': ("Open 1-pane","Window"), + 'Scopia#window_2x3_arched': ("Open 1-pane","Window"), + 'Scopia#window_2x3': ("Open 1-pane","Window"), + 'Scopia#window_2x3_regular': ("Open 1-pane","Window"), + 'Scopia#window_2x4_arched': ("Open 1-pane","Window"), + 'Scopia#window_2x4': ("Open 1-pane","Window"), + 'Scopia#window_2x6': ("Open 1-pane","Window"), + 'Scopia#window_3x1': ("Open 1-pane","Window"), + 'Scopia#window_4x1': ("Open 1-pane","Window"), + 'Scopia#window_4x3_arched': ("Open 1-pane","Window"), + 'Scopia#window_4x3': ("Open 1-pane","Window"), + 'Scopia#window_4x5': ("Open 1-pane","Window"), + 'Artist373#rectangularFivePanesWindow': ("Open 1-pane","Window"), + 'OlaKristianHoff#window_shop': ("Open 1-pane","Window"), + 'OlaKristianHoff#window_double_2x3_frame_sill': ("Open 1-pane","Window"), + 'OlaKristianHoff#window_deep': ("Open 1-pane","Window"), + 'OlaKristianHoff#fixed_window_2x2': ("Fixed","Window"), + 'OlaKristianHoff#window_double_3x3': ("Open 1-pane","Window"), } - ET_XPATH_LEVEL = 'level' ET_XPATH_ROOM = 'room' ET_XPATH_WALL = 'wall' @@ -162,6 +182,27 @@ ET_XPATH_CAMERA = 'camera' ET_XPATH_DUMMY_SLAB = 'DummySlab' ET_XPATH_DUMMY_DECORATE = 'DummyDecorate' +class Transaction(object): + + def __init__(self, title, doc= None): + if doc is None: + doc = App.ActiveDocument + self.title = title + self.document = doc + + def __enter__(self): + self.document.openTransaction(self.title) + + def __exit__(self, exc_type, exc_value, exc_traceback): + if exc_value is None: + self.document.commitTransaction() + elif DEBUG_GEOMETRY: + _err(f"Transactino failed but DEBUG_GEOMETRY is set. Commiting transaction anyway.") + self.document.commitTransaction() + else: + self.document.abortTransaction() + + class SH3DImporter: """The main class to import an SH3D file. @@ -260,7 +301,7 @@ class SH3DImporter: else: # Has the default floor already been created from a # previous import? - if self.preferences["DEBUG"]: _log("No level defined. Using default level ...") + if self.preferences["DEBUG_GEOMETRY"]: _log("No level defined. Using default level ...") self.default_floor = self.fc_objects.get('Level') if 'Level' in self.fc_objects else self._create_default_floor() # Importing elements ... @@ -286,7 +327,7 @@ class SH3DImporter: self._import_elements(home, ET_XPATH_DOOR_OR_WINDOWS) self._refresh() - # Door&Windows have been imported. Now we can decorate... + # doorOrWndows have been imported. Now we can decorate... if self.preferences["DECORATE_SURFACES"]: self._decorate_surfaces() self._refresh() @@ -297,7 +338,7 @@ class SH3DImporter: self._refresh() # Importing elements ... - if self.preferences["IMPORT_LIGHTS"]: + if self.preferences["IMPORT_LIGHTS"] or self.preferences["IMPORT_FURNITURES"]: self._import_elements(home, ET_XPATH_LIGHT) self._refresh() @@ -327,7 +368,6 @@ class SH3DImporter: def _get_preferences(self): """Retrieve the SH3D preferences available in Mod/Arch.""" self.preferences = { - 'DEBUG': get_param_arch("sh3dDebug"), 'IMPORT_DOORS_AND_WINDOWS': get_param_arch("sh3dImportDoorsAndWindows"), 'IMPORT_FURNITURES': get_param_arch("sh3dImportFurnitures"), 'IMPORT_LIGHTS': get_param_arch("sh3dImportLights") and RENDER_IS_AVAILABLE, @@ -344,6 +384,8 @@ class SH3DImporter: 'DEFAULT_GROUND_COLOR': color_fc2sh(get_param_arch("sh3dDefaultGroundColor")), 'DEFAULT_SKY_COLOR': color_fc2sh(get_param_arch("sh3dDefaultSkyColor")), 'DECORATE_SURFACES': get_param_arch("sh3dDecorateSurfaces"), + 'DEFAULT_FURNITURE_COLOR': color_fc2sh(get_param_arch("sh3dDefaultFurnitureColor")), + 'DEBUG_GEOMETRY': get_param_arch("sh3dDebugGeometry"), } def _setup_handlers(self): @@ -361,6 +403,7 @@ class SH3DImporter: if self.preferences["IMPORT_FURNITURES"]: self.handlers[ET_XPATH_PIECE_OF_FURNITURE] = FurnitureHandler(self) + self.handlers[ET_XPATH_LIGHT] = LightHandler(self) if self.preferences["IMPORT_LIGHTS"]: self.handlers[ET_XPATH_LIGHT] = LightHandler(self) @@ -371,7 +414,6 @@ class SH3DImporter: self.handlers[ET_XPATH_CAMERA] = camera_handler def _refresh(self): - App.ActiveDocument.recompute() if App.GuiUp: Gui.updateGui() @@ -393,7 +435,7 @@ class SH3DImporter: if valid_values: setattr(obj, name, valid_values) if value is None: - if self.preferences["DEBUG"]:_log(f"Setting obj.{name}=None") + if self.preferences["DEBUG_GEOMETRY"]:_log(f"Setting obj.{name}=None") return if type(value) is ET.Element or type(value) is type(dict()): if type_ == "App::PropertyString": @@ -408,7 +450,7 @@ class SH3DImporter: value = int(value.get(name, 0)) elif type_ == "App::PropertyBool": value = value.get(name, "true") == "true" - if self.preferences["DEBUG"]: + if self.preferences["DEBUG_GEOMETRY"]: _log(f"Setting @{obj}.{name} = {value}") setattr(obj, name, value) @@ -451,10 +493,10 @@ class SH3DImporter: fc_object = self.fc_objects[id] if sh_type: assert fc_object.shType == sh_type, f"Invalid shType: expected {sh_type}, got {fc_object.shType}" - if self.preferences["DEBUG"]: + if self.preferences["DEBUG_GEOMETRY"]: _log(translate("BIM", f"Merging imported element '{id}' with existing element of type '{type(fc_object)}'")) return fc_object - if self.preferences["DEBUG"]: + if self.preferences["DEBUG_GEOMETRY"]: _log(translate("BIM", f"No element found with id '{id}' and type '{sh_type}'")) return None @@ -462,6 +504,9 @@ class SH3DImporter: self.floors[floor.id] = floor self.building.addObject(floor) + def get_all_floors(self): + return self.floors.values() + def get_floor(self, level_id): """Returns the Floor associated with the level_id. @@ -483,6 +528,9 @@ class SH3DImporter: self.spaces[floor.id] = [] self.spaces[floor.id].append(space) + def get_all_spaces(self): + return list(itertools.chain(*self.spaces.values())) + def get_spaces(self, floor): return self.spaces.get(floor.id, []) @@ -501,7 +549,7 @@ class SH3DImporter: closest_space = None for space in self.spaces.get(floor.id, []): space_face = space.Base.Shape - space_z = space_face.CenterOfMass.z + space_z = space_face.CenterOfGravity.z projection = App.Vector(p.x, p.y, space_z) # Checks that: # - the point's projection is inside the face @@ -518,6 +566,14 @@ class SH3DImporter: self.walls[floor.id] = [] self.walls[floor.id].append(wall) + def get_all_walls(self): + """Returns a map of all the walls in the building grouped by floor + + Returns: + dict: the map of all the walls + """ + return list(itertools.chain(*self.walls.values())) + def get_walls(self, floor): """Returns the wall belonging to the specified level @@ -533,12 +589,12 @@ class SH3DImporter: """Create FreeCAD Group for the different imported elements """ doc = App.ActiveDocument - if self.preferences["IMPORT_LIGHTS"] and not doc.getObject("Lights"): - _log(f"Creating Lights group ...") - doc.addObject("App::DocumentObjectGroup", "Lights") if self.preferences["IMPORT_CAMERAS"] and not doc.getObject("Cameras"): _log(f"Creating Cameras group ...") doc.addObject("App::DocumentObjectGroup", "Cameras") + if self.preferences["DEBUG_GEOMETRY"] and not doc.getObject("DEBUG_GEOMETRY"): + _log(f"Creating DEBUG_GEOMETRY group ...") + doc.addObject("App::DocumentObjectGroup", "DEBUG_GEOMETRY") def _setup_project(self, elm): """Create the Arch::Project and Arch::Site for this import @@ -605,27 +661,37 @@ class SH3DImporter: return self.handlers['level'].create_default_floor() def _create_ground_mesh(self, elm): - bb = self.building.Shape.BoundBox - dx = bb.XLength/2 - dy = bb.YLength/2 - SO = App.Vector(bb.XMin-dx, bb.YMin-dy, 0) - NO = App.Vector(bb.XMin-dx, bb.YMax+dy, 0) - NE = App.Vector(bb.XMax+dx, bb.YMax+dy, 0) - SE = App.Vector(bb.XMax+dx, bb.YMin-dy, 0) - edge0 = Part.makeLine(SO, NO) - edge1 = Part.makeLine(NO, NE) - edge2 = Part.makeLine(NE, SE) - edge3 = Part.makeLine(SE, SO) - # ground = App.ActiveDocument.addObject("Part::Feature", "Ground") - ground_face = Part.makeFace([ Part.Wire([edge0, edge1, edge2, edge3]) ]) + self.building.recompute(True) + + ground = None + if self.preferences["MERGE"]: + ground = self.get_fc_object('ground', 'ground') + + if not ground: + bb = self.building.Shape.BoundBox + dx = bb.XLength/2 + dy = bb.YLength/2 + SO = App.Vector(bb.XMin-dx, bb.YMin-dy, 0) + NO = App.Vector(bb.XMin-dx, bb.YMax+dy, 0) + NE = App.Vector(bb.XMax+dx, bb.YMax+dy, 0) + SE = App.Vector(bb.XMax+dx, bb.YMin-dy, 0) + edge0 = Part.makeLine(SO, NO) + edge1 = Part.makeLine(NO, NE) + edge2 = Part.makeLine(NE, SE) + edge3 = Part.makeLine(SE, SO) + ground_face = Part.makeFace([ Part.Wire([edge0, edge1, edge2, edge3]) ]) + + ground = App.ActiveDocument.addObject("Mesh::Feature", "Ground") + ground.Mesh = MeshPart.meshFromShape(Shape=ground_face, LinearDeflection=0.1, AngularDeflection=0.523599, Relative=False) + ground.Label = "Ground" + self.set_property(ground, "App::PropertyString", "shType", "The element type", 'ground') + self.set_property(ground, "App::PropertyString", "id", "The ground's id", 'ground') - ground = App.ActiveDocument.addObject("Mesh::Feature", "Ground") - ground.Mesh = MeshPart.meshFromShape(Shape=ground_face, LinearDeflection=0.1, AngularDeflection=0.523599, Relative=False) - ground.Label = "Ground" set_color_and_transparency(ground, self.site.groundColor) ground.ViewObject.Transparency = 50 - # TODO: apply possible within the element + + self.add_fc_objects(ground) self.site.addObject(ground) @@ -644,34 +710,36 @@ class SH3DImporter: progress. Set to false when importing a group of elements. Defaults to True. """ - xpaths = list(self.handlers.keys()) elements = parent.findall(xpath) + # Is it a real tag name or an xpath expression? tag_name = xpath[3:] if xpath.startswith('.') else xpath - - total_steps, current_step, total_elements = self._get_progress_info(xpath, elements) + total_steps, current_step = self._get_progress_info(xpath) + total_elements = len(elements) if self.progress_bar: self.progress_bar.stop() 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 ...") + _msg(f"Importing {total_elements} '{tag_name}' elements ...") + handler = self.handlers[xpath] def _process(tuple): (i, elm) = tuple _msg(f"Importing {tag_name}#{i} ({self.current_object_count + 1}/{self.total_object_count}) ...") try: - self.handlers[xpath].process(parent, i, elm) + # with Transaction(f"Importing {tag_name}#{i}"): + handler.process(parent, i, elm) except Exception as e: - _err(f"Failed to import <{tag_name}>#{i} ({elm.get('id', elm.get('name'))}):") + _err(f"Importing {tag_name}#{i} failed") _err(str(e)) _err(traceback.format_exc()) - if self.progress_bar: - self.progress_bar.next() + + 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): + def _get_progress_info(self, xpath): xpaths = list(self.handlers.keys()) total_steps = len(xpaths) current_step = xpaths.index(xpath)+1 - return total_steps, current_step, len(elements) + return total_steps, current_step def _set_site_properties(self, elm): # All information in environment?, backgroundImage?, print?, compass @@ -733,7 +801,7 @@ class SH3DImporter: self.set_property(self.site, "App::PropertyFloat", "latitude", "The compass's latitude", compass) self.set_property(self.site, "App::PropertyString", "timeZone", "The compass's TimeZone", compass) self.set_property(self.site, "App::PropertyBool", "visible", "Whether the compass is visible", compass) - self.site.Declination = ang_sh2fc(math.degrees(float(self.site.northDirection))) + self.site.Declination = math.degrees(ang_sh2fc(float(self.site.northDirection))) self.site.Longitude = math.degrees(float(self.site.longitude)) self.site.Latitude = math.degrees(float(self.site.latitude)) self.site.EPWFile = '' # https://www.ladybug.tools/epwmap/ or https://climate.onebuilding.org @@ -742,31 +810,32 @@ class SH3DImporter: def _create_slabs(self): floors = self.floors.values() - total_steps, current_step, total_elements = self._get_progress_info(ET_XPATH_DUMMY_SLAB, floors) + all_walls = self.get_all_walls() + all_spaces = self.get_all_spaces() + total_steps, current_step = self._get_progress_info(ET_XPATH_DUMMY_SLAB) + total_elements = len(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 ...") + self.progress_bar.start(f"Step {current_step}/{total_steps}: Creating {total_elements} 'slab' elements. Please wait ...", len(all_walls) + len(all_spaces)) + + _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) + # with Transaction(f"Creating slab#{i} for floor '{floor.Label}'"): + handler.create_slabs(floor, self.progress_bar) except Exception as e: - _err(f"Failed to create slab#{i} for floor '{floor.Label}':") + _err(f"Creating slab#{i} for floor '{floor.Label}' failed") _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)) + all_walls = self.get_all_walls() + all_spaces = self.get_all_spaces() total_elements = len(all_spaces)+len(all_walls) @@ -856,6 +925,77 @@ class BaseHandler: def get_walls(self, floor): return self.importer.get_walls(floor) + def get_wall_spine(self, wall): + if not hasattr(wall, 'BaseObjects'): + _err(f"Wall {wall.Label} has no BaseObjects to get the Spine from...") + return wall.BaseObjects[2] + + def get_faces(self, wall): + """Returns the name of the left and right face for `wall` + + 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_spine = self.get_wall_spine(wall) + wall_start = wall_spine.Start + wall_end = wall_spine.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 + def _ps(self, section, print_z: bool = False): # Pretty print a Section in a condensed way if hasattr(section, 'Shape'): @@ -870,14 +1010,59 @@ class BaseHandler: v = edge.Vertexes return f"[{self._pv(v[0].Point, print_z)}, {self._pv(v[1].Point, print_z)}]" + def _pes(self, edges, print_z: bool = False): + return '->'.join(list(map(lambda e: self._pe(e, print_z), edges))) + def _pv(self, v, print_z: bool = False, ndigits: None = None): # Print an Vector in a condensed way + if not v: + return "NaN" 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 ''})" + 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 ''})" + 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)}") + def _debug_point(self, coord, label, color=RED): + part = Draft.make_point(coord) + part.Label = label + part.ViewObject.PointSize = 5 + part.ViewObject.PointColor = color + App.ActiveDocument.DEBUG_GEOMETRY.addObject(part) + return part + + def _debug_vector(self, vector, label, color=RED, placement=None): + part = Draft.make_line(ORIGIN, vector) + if placement: + part.Placement = placement + part.Label = label + part.ViewObject.LineWidth = 5 + part.ViewObject.LineColor = color + App.ActiveDocument.DEBUG_GEOMETRY.addObject(part) + return part + + def _debug_shape(self, shape, label, color=GREEN, transparency=.75, placement=None): + part = Part.show(shape) + if placement: + part.Placement = placement + part.Label = label + part.ViewObject.LineColor = color + part.ViewObject.PointSize = 5 + material = part.ViewObject.ShapeAppearance[0] + material.DiffuseColor = color + material.Transparency = transparency + part.ViewObject.ShapeAppearance = (material) + App.ActiveDocument.DEBUG_GEOMETRY.addObject(part) + return part + + def _debug_mesh(self, mesh, label, transform=None, color=GREEN, transparency=.75, placement=None): + shape = Part.Shape() + new_mesh = mesh.copy() + if transform: + new_mesh.transform(transform) + shape.makeShapeFromMesh(new_mesh.Topology, 1) + self._debug_shape(shape, label, color, transparency, placement) + class LevelHandler(BaseHandler): """A helper class to import a SH3D `` object.""" @@ -928,31 +1113,43 @@ class LevelHandler(BaseHandler): 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 + group = self._create_group(floor, "ReferenceFacesGroupName", f"References-{floor.Label}") + if not self.importer.preferences["DEBUG_GEOMETRY"]: + group.Visibility = False + group.ViewObject.ShowInTree = False + + group = self._create_group(floor, "SlabObjectsGroupName", f"SlabObjects-{floor.Label}") + if not self.importer.preferences["DEBUG_GEOMETRY"]: + group.Visibility = False + group.ViewObject.ShowInTree = False 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) + self._create_group(floor, "DecorationWallsGroupName", f"Decoration-{floor.Label}-Walls") + self._create_group(floor, "DecorationCeilingsGroupName", f"Decoration-{floor.Label}-Ceilings") + self._create_group(floor, "DecorationFloorsGroupName", f"Decoration-{floor.Label}-Floors") + self._create_group(floor, "DecorationBaseboardsGroupName", f"Decoration-{floor.Label}-Baseboards") if self.importer.preferences["IMPORT_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) + self._create_group(floor, "FurnitureGroupName", f"Furnitures-{floor.Label}") - def create_slabs(self, floor): + if self.importer.preferences["IMPORT_LIGHTS"]: + self._create_group(floor, "LightGroupName", f"Lights-{floor.Label}") + + def _create_group(self, floor, prop_group_name, group_label): + group = None + if self.importer.preferences["MERGE"]: + if hasattr(floor, prop_group_name): + group_name = getattr(floor, prop_group_name) + group = floor.getObject(group_name) + + if not group: + group = floor.newObject("App::DocumentObjectGroup") + group.Label = group_label + self.setp(floor, "App::PropertyString", prop_group_name, "The DocumentObjectGroup name for the group on this floor", group.Name) + + return group + + def create_slabs(self, floor, progress_bar): """Creates a Arch.Slab for the given floor. Creating a slab consists in projecting all the structures of that @@ -962,86 +1159,78 @@ class LevelHandler(BaseHandler): 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 = None + if self.importer.preferences["MERGE"]: + slab = self.get_fc_object(f"{floor.id}-slab", 'slab') - 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 _extrude(obj_to_extrude): + """Return the Part.Extrude suitable for fusion by the make_multi_fuse tool. - 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 + Args: + floor (Arch.Floor): the Arch Floor for which to create the Slab + obj_to_extrude (Part): the space or wall to project onto the XY + plane to create the slab - 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 enough - 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) + Returns: + Part.Feature: the extrusion used to later to fuse. + """ + if self.importer.preferences["DEBUG_GEOMETRY"]: + _log(f"Extruding {obj_to_extrude.Label} ...") + obj_to_extrude.recompute(True) + projection = TechDraw.project(obj_to_extrude.Shape, Z_NORM)[0] + face = Part.Face(Part.Wire(projection.Edges)) 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 = Part.show(extrude, "Extrusion") + # part.Placement.Base.z = floor.Placement.Base.z + part.Label = f"{floor.Label}-{obj_to_extrude.Label}-extrusion" + part.recompute(True) 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. + if progress_bar: + progress_bar.next() - :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 = [] + return part - 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 + if not slab: + # Take the spaces whose floor is actually visible, and all the walls + projections = list(map(lambda s: s.ReferenceFace, filter(lambda s: s.floorVisible, self.get_spaces(floor)))) + projections.extend(list(map(lambda w: w.ReferenceFace, self.get_walls(floor)))) + extrusions = list(map(_extrude, projections)) + extrusions = list(filter(lambda o: o is not None, extrusions)) + if len(extrusions) > 0: + if len(extrusions) > 1: + bf = BOPTools.BOPFeatures.BOPFeatures(App.ActiveDocument) + slab_base = bf.make_multi_fuse([ o.Name for o in extrusions]) + slab_base.Label = f"{floor.Label}-footprint" + slab_base.recompute() + else: + slab_base = extrusions[0] + slab_base.Label = f"{floor.Label}-footprint" + + slab = Arch.makeStructure(slab_base) + slab.Placement.Base.z = floor.Placement.Base.z + slab.Normal = -Z_NORM + slab.setExpression('Height', f"{slab_base.Name}.Shape.BoundBox.ZLength") + else: + _wrn(f"No object found for floor {floor.Label}.") + self.setp(floor, "App::PropertyString", "ReferenceSlabName", "The name of the Slab used on this floor", None) + return + + slab.Label = f"{floor.Label}-slab" + + if self.importer.preferences["DEBUG_GEOMETRY"]: + slab.ViewObject.DisplayMode = 'Wireframe' + slab.ViewObject.DrawStyle = 'Dotted' + slab.ViewObject.LineColor = ORANGE + slab.ViewObject.LineWidth = 2 + + self.setp(slab, "App::PropertyString", "shType", "The element type", 'slab') + self.setp(slab, "App::PropertyString", "id", "The slab's id", f"{floor.id}-slab") + self.setp(slab, "App::PropertyString", "ReferenceFloorName", "The name of the Arch.Floor this slab belongs to", floor.Name) + self.setp(floor, "App::PropertyString", "ReferenceSlabName", "The name of the Slab used on this floor", slab.Name) + + floor.addObject(slab) class RoomHandler(BaseHandler): @@ -1060,6 +1249,8 @@ class RoomHandler(BaseHandler): i (int): the ordinal of the imported element elm (Element): the xml element """ + debug_geometry = self.importer.preferences["DEBUG_GEOMETRY"] + level_id = elm.get('level', None) floor = self.get_floor(level_id) assert floor != None, f"Missing floor '{level_id}' for '{elm.get('id')}' ..." @@ -1070,33 +1261,42 @@ class RoomHandler(BaseHandler): # A Room is composed of a space with a Face as the base object if not space: + name = elm.get('name', 'Room') + 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... + # and close the wire + points.append(points[0]) + # Offset to avoid self-intersecting wires + reference_wire = Part.makePolygon(points) + if debug_geometry: self._debug_shape(reference_wire, f"{name}-reference-wire", RED) + reference_wire = self._get_offset_wire(reference_wire) + if debug_geometry: self._debug_shape(reference_wire, f"{name}-reference-wire-offset", RED) + points = [v.Point for v in reference_wire.Vertexes] reference_face = Draft.make_wire(points, closed=True, face=True, support=None) - reference_face.Label = elm.get('name', 'Room') + '-reference' + reference_face.Label = f"{name}-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' + footprint.Label = f"{name}-footprint" + self.setp(footprint, "App::PropertyLink", "ReferenceFace", "The Reference Part.Wire", reference_face) space = Arch.makeSpace(footprint) space.IfcType = "Space" - space.Label = elm.get('name', 'Room') + space.Label = name self._set_properties(space, elm) 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) + self.setp(space, "App::PropertyLink", "ReferenceFace", "The Reference Part.Wire", reference_face) self.importer.add_space(floor, space) @@ -1125,6 +1325,81 @@ class RoomHandler(BaseHandler): 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 _get_offset_wire(self, wire, inward=True): + """Return an inward (or outward) offset wire to avoid self intersection. + + This will return a non self-intersecting wire offseted either inward + or outward from the original wire. + + Args: + wire (Part.Wire): the original self-intersecting wire. + + Returns: + Part.Wire: a non self intersecting wire + """ + edges = wire.Edges + self_intersect = self._self_intersect(edges) + if not self_intersect: + return wire + + offset_wire = wire.copy() + offset_vector = self._get_offset_vector(edges[0], inward) + multiplier = 1 + while self_intersect and multiplier < 5: + # Self intersecting wire can not be properly extruded to + # create rooms. We offset the wire inward until it stop + # self intersecting. + offset_wire = DraftGeomUtils.offsetWire(wire, offset_vector*multiplier) + self_intersect = self._self_intersect(offset_wire.Edges) + multiplier += 1 + else: + if self_intersect: + return self._get_offset_wire(wire, False) + return offset_wire + + def _self_intersect(self, edges): + """Returns whether a list of edges self intersect. + + Returns True if at least one pair of edge intersect. + + Args: + edges (list): list of Part.Edge to test + + Returns: + bool: True if at least one pair of edge intersect. + list(tuple): a list of tuple of v1, e1, v2, e2 where v1 is the + intersection on the first edge e1, and v2 is the intersection + on the second edge e2. + """ + for i in range(len(edges)): + for j in range(i + 1, len(edges)): # Avoid duplicate checks + e1 = edges[i] + e2 = edges[j] + (dist, vectors, _) = e1.distToShape(e2) + if dist > 0: + continue + for (v1, v2) in vectors: + # Check that the intersections are not extremities + # If both v1 and v2 are extremities then the edges + # are connected which is not really a self-intersecting + # situation. + if v1 not in [v.Point for v in e1.Vertexes] or v2 not in [v.Point for v in e2.Vertexes]: + return True + return False + + def _get_offset_vector(self, edge, inward): + """Returns the normal vector at start, either inward facing or outward. + + Args: + edge (Part.Edge): The edge for which to find the normal at + inward (bool): whether to get take the cross or the inverse. + + Returns: + Vector: the normal vector + """ + tangent = edge.tangentAt(edge.FirstParameter).normalize() + return App.Rotation(Z_NORM, -90 if inward else 90).multVec(tangent) + def post_process(self, obj): if self.importer.preferences["DECORATE_SURFACES"]: floor = App.ActiveDocument.getObject(obj.ReferenceFloorName) @@ -1189,7 +1464,10 @@ class WallHandler(BaseHandler): wall.IfcType = "Wall" wall.Label = f"wall{i}" - wall.Base.Label = f"wall{i}-wallshape" + wall.Base.Label = f"wall{i}-volume" + wall.BaseObjects[0].Label = f"wall{i}-start" + wall.BaseObjects[1].Label = f"wall{i}-end" + wall.BaseObjects[2].Label = f"wall{i}-spine" 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) @@ -1234,7 +1512,7 @@ class WallHandler(BaseHandler): 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 + # the wall elements to be created (such as doorOrWndows), their # creation is delayed until the for baseboard in elm.findall('baseboard'): side = baseboard.get('attribute') @@ -1257,6 +1535,8 @@ class WallHandler(BaseHandler): Returns: Arch::Wall: the newly created wall """ + debug_geometry = self.importer.preferences["DEBUG_GEOMETRY"] + wall_details = self._get_wall_details(floor, elm) assert wall_details is not None, f"Fail to get details of wall {elm.get('id')}. Bailing out! {elm} / {wall_details}" @@ -1279,6 +1559,10 @@ class WallHandler(BaseHandler): next_wall_details) base_object = None + App.ActiveDocument.recompute([section_start, section_end, spine]) + if debug_geometry: + _log(f"_create_wall(): wall => section_start={self._ps(section_start)}, section_end={self._ps(section_end)}") + sweep = self._make_sweep(section_start, section_end, spine) # Sometimes the Part::Sweep creates a "twisted" sweep which # result in a broken wall. The solution is to use a compound @@ -1297,6 +1581,20 @@ class WallHandler(BaseHandler): else: wall = Arch.makeWall(sweep) + if debug_geometry: + wall.ViewObject.DisplayMode = 'Wireframe' + wall.ViewObject.DrawStyle = 'Dotted' + wall.ViewObject.LineColor = ORANGE + wall.ViewObject.LineWidth = 2 + self._debug_point(spine.Start, f"{wall.Name}-start") + + reference_face = self._get_reference_face(wall, is_wall_straight) + if reference_face: + self.setp(wall, "App::PropertyLink", "ReferenceFace", "The Reference Part.Wire", reference_face) + floor.getObject(floor.ReferenceFacesGroupName).addObject(reference_face) + else: + _err(f"Failed to get the reference face for wall {wall.Name}. Slab might fail!") + # 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]) @@ -1318,7 +1616,6 @@ class WallHandler(BaseHandler): Returns: Part::Sweep: the Part::Sweep """ - App.ActiveDocument.recompute([section_start, section_end, spine]) sweep = App.ActiveDocument.addObject('Part::Sweep', "WallShape") sweep.Sections = [section_start, section_end] sweep.Spine = spine @@ -1343,7 +1640,6 @@ class WallHandler(BaseHandler): Returns: Compound: the compound """ - App.ActiveDocument.recompute([section_start, section_end, spine]) ruled_surface = App.ActiveDocument.addObject('Part::RuledSurface') ruled_surface.Curve1 = section_start ruled_surface.Curve2 = section_end @@ -1403,14 +1699,12 @@ class WallHandler(BaseHandler): """ (start, end, _, _, _, _) = wall_details - section_start = self._get_section(wall_details, True, prev_wall_details) - section_end = self._get_section(wall_details, False, next_wall_details) + a1, a2, _ = self._get_normal_angles(wall_details) + + section_start = self._get_section(wall_details, True, prev_wall_details, a1, a2) + section_end = self._get_section(wall_details, False, next_wall_details, a1, a2) 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)}") return section_start, section_end, spine @@ -1425,25 +1719,27 @@ class WallHandler(BaseHandler): Returns: Rectangle, Rectangle, spine: both section and the arc for the wall # """ - (start, end, _, _, _, arc_extent) = wall_details - - section_start = self._get_section(wall_details, True, prev_wall_details) - section_end = self._get_section(wall_details, False, next_wall_details) + (start, end, _, _, _, _) = wall_details a1, a2, (invert_angle, center, radius) = self._get_normal_angles(wall_details) + section_start = self._get_section(wall_details, True, prev_wall_details, a1, a2) + section_end = self._get_section(wall_details, False, next_wall_details, a1, a2) + + if self.importer.preferences["DEBUG_GEOMETRY"]: + self._debug_vector(start-center, "start-center", GREEN, center) + self._debug_vector(end-center, "end-center", BLUE, center) + placement = App.Placement(center, App.Rotation()) # BEWARE: makeCircle always draws counter-clockwise (i.e. in positive # direction in xYz coordinate system). We therefore need to invert # the start and end angle (as in SweetHome the wall is drawn in # clockwise fashion). - length = 0 + length = abs(radius * math.radians(a2 - a1)) if invert_angle: spine = Draft.makeCircle(radius, placement, False, a1, a2) - length = abs(radius * math.radians(a2 - a1)) else: spine = Draft.makeCircle(radius, placement, False, a2, a1) - length = abs(radius * math.radians(a1 - a2)) # The Length property is used in the Wall to calculate volume, etc... # Since make Circle does not calculate this Length I calculate it here... @@ -1453,14 +1749,9 @@ class WallHandler(BaseHandler): 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)}") - return section_start, section_end, spine - def _get_section(self, wall_details, at_start, sibling_details): + def _get_section(self, wall_details, at_start, sibling_details, a1, a2): """Returns a rectangular section at the specified coordinate. Returns a Rectangle that is then used as a section in the Part::Sweep @@ -1478,6 +1769,7 @@ class WallHandler(BaseHandler): Returns: Rectangle: the section properly positioned """ + debug_geometry = self.importer.preferences["DEBUG_GEOMETRY"] if self.importer.preferences["JOIN_ARCH_WALL"] and sibling_details: # In case the walls are to be joined we determine the intersection # of both wall which depends on their respective thickness. @@ -1493,19 +1785,18 @@ class WallHandler(BaseHandler): i_start_z = i_start + App.Vector(0, 0, height) i_end_z = i_end + App.Vector(0, 0, height) - if self.importer.preferences["DEBUG"]: + if debug_geometry: _log(f"Joining wall {self._pv(end-start)}@{self._pv(start)} and wall {self._pv(s_end-s_start)}@{self._pv(s_start)}") _log(f" wall: {self._pe(lside)},{self._pe(rside)}") _log(f" sibling: {self._pe(s_lside)},{self._pe(s_rside)}") _log(f"intersec: {self._pv(i_start)},{self._pv(i_end)}") section = Draft.makeRectangle([i_start, i_end, i_end_z, i_start_z], face=True) - if self.importer.preferences["DEBUG"]: + if debug_geometry: _log(f"section: {section}") else: (start, end, thickness, height_start, height_end, _) = wall_details height = height_start if at_start else height_end center = start if at_start else end - a1, a2, _ = self._get_normal_angles(wall_details) z_rotation = a1 if at_start else a2 section = Draft.makeRectangle(thickness, height, face=True) Draft.move([section], App.Vector(-thickness/2, 0, 0)) @@ -1513,11 +1804,10 @@ class WallHandler(BaseHandler): Draft.rotate([section], z_rotation, ORIGIN, Z_NORM) Draft.move([section], center) - if self.importer.preferences["DEBUG"]: - section.recompute() + section.recompute() + if debug_geometry: _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): @@ -1559,13 +1849,14 @@ class WallHandler(BaseHandler): Vector: the center of the circle for a curved wall section float: the radius of said circle """ - (start, end, thickness, height_start, height_end, arc_extent) = wall_details + (start, end, _, _, _, arc_extent) = wall_details angle_start = angle_end = 0 invert_angle = False center = radius = None if arc_extent == 0: - angle_start = angle_end = 90-math.degrees(DraftVecUtils.angle(end-start, X_NORM)) + # Straight Wall... + angle_start = angle_end = 90 - norm_deg_ang(DraftVecUtils.angle(end-start, X_NORM)) else: # Calculate the circle that pases through the center of both rectangle # and has the correct angle between p1 and p2 @@ -1576,7 +1867,10 @@ 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 np.sign(arc_extent) != np.sign(DraftVecUtils.angle(start-center, end-center, Z_NORM)): + angle = norm_deg_ang(DraftVecUtils.angle(start-center, end-center, Z_NORM)) + if self.importer.preferences["DEBUG_GEOMETRY"]: + _msg(f"arc_extent={norm_deg_ang(arc_extent)}, angle={angle}") + if norm_deg_ang(arc_extent) != angle: invert_angle = True center = circles[1].Center @@ -1584,8 +1878,8 @@ class WallHandler(BaseHandler): radius1 = start - center radius2 = end - center - angle_start = math.degrees(DraftVecUtils.angle(X_NORM, radius1, Z_NORM)) - angle_end = math.degrees(DraftVecUtils.angle(X_NORM, radius2, Z_NORM)) + angle_start = norm_deg_ang(DraftVecUtils.angle(X_NORM, radius1, Z_NORM)) + angle_end = norm_deg_ang(DraftVecUtils.angle(X_NORM, radius2, Z_NORM)) return angle_start, angle_end, (invert_angle, center, radius) @@ -1607,7 +1901,7 @@ class WallHandler(BaseHandler): edge = DraftGeomUtils.edg(start, end) lside = DraftGeomUtils.offset(edge, loffset) rside = DraftGeomUtils.offset(edge, roffset) - if self.importer.preferences["DEBUG"]: + if self.importer.preferences["DEBUG_GEOMETRY"]: _log(f"_get_sides(): wall {self._pv(end-start)}@{self._pv(start)} => normal={self._pv(normal)}, lside={self._pe(lside)}, rside={self._pe(rside)}") return lside, rside @@ -1627,11 +1921,54 @@ class WallHandler(BaseHandler): """ return (b - a).cross(c - a).normalize() + def _get_reference_face(self, wall, is_wall_straight): + """Returns the reference face for a wall. + + There are some strange situation when the bottom face is self-intersecting. + This will result in an invalid extrude and will therefore fail the slab + creation. This solved by creating a convex hull. Note that this mitigation + is only used for straight walls. Curved walls do not seem to be affected + by the problem. + + Args: + wall (Arch.Wall): the wall for which to create the referene face + + Returns: + Part.Wire: the wire for the reference face + """ + # Extract the reference face for later use (when creating the slab) + bottom_faces = list(filter(lambda f: Z_NORM.isEqual(-f.normalAt(0,0),1e-6), wall.Base.Shape.Faces)) + + if len(bottom_faces) == 0: + return None + + if len(bottom_faces) > 1: + _wrn(f"Base object for wall {wall.Name} has several bottom facing reference faces! Defaulting to 1st one.") + + face = bottom_faces.pop(0) + + if is_wall_straight: + # In order to make sure that the edges are not self-intersecting + # create a convex hull and use these points instead. Maybe + # overkill for a 4 point wall, however not sure how to invert + # edges. + points = list(map(lambda v: v.Point, face.Vertexes)) + new_points = convex_hull(points) + reference_face = Draft.make_wire(new_points, closed=True, face=True, support=None) + else: + reference_face = App.ActiveDocument.addObject("Part::Feature", "Face") + reference_face.Shape = face + + reference_face.Label = f"{wall.Name}-reference" + reference_face.Visibility = False + reference_face.recompute() + return reference_face + def post_process(self, obj): if self.importer.preferences["DECORATE_SURFACES"]: floor = App.ActiveDocument.getObject(obj.ReferenceFloorName) - (left_face_name, left_face, right_face_name, right_face) = self._get_faces(obj) + (left_face_name, left_face, right_face_name, right_face) = self.get_faces(obj) self._create_facebinders(floor, obj, left_face_name, right_face_name) @@ -1697,6 +2034,9 @@ class WallHandler(BaseHandler): for side in ["leftSideBaseboard", "rightSideBaseboard"]: if hasattr(wall, f"{side}Height"): face = left_face if side == "leftSideBaseboard" else right_face + if not face: + _err(f"Weird: Invalid {side} face for wall {wall.Label}. Skipping baseboard creation") + continue self._create_baseboard(floor, wall, side, face) def _create_baseboard(self, floor, wall, side, face): @@ -1709,16 +2049,16 @@ class WallHandler(BaseHandler): bottom_edge = None for edge in face.Edges: - if edge and edge.CenterOfMass and edge.CenterOfMass.z < lowest_z: - lowest_z = edge.CenterOfMass.z + if edge and edge.CenterOfGravity and edge.CenterOfGravity.z < lowest_z: + lowest_z = edge.CenterOfGravity.z bottom_edge = edge - p_normal = face.normalAt(bottom_edge.CenterOfMass.x, bottom_edge.CenterOfMass.y) + p_normal = face.normalAt(bottom_edge.CenterOfGravity.x, bottom_edge.CenterOfGravity.y) p_normal.z = 0 offset_vector = p_normal.normalize().multiply(baseboard_width) offset_bottom_edge = bottom_edge.translated(offset_vector) - if self.importer.preferences["DEBUG"]: + if self.importer.preferences["DEBUG_GEOMETRY"]: _log(f"Creating {side} for {wall.Label} from edge {self._pe(bottom_edge, True)} to {self._pe(offset_bottom_edge, True)} (normal={self._pv(p_normal, True, 4)})") edge0 = bottom_edge.copy() @@ -1727,7 +2067,7 @@ class WallHandler(BaseHandler): edge3 = Part.makeLine(offset_bottom_edge.Vertexes[0].Point, bottom_edge.Vertexes[0].Point) # make sure all edges are coplanar... - ref_z = bottom_edge.CenterOfMass.z + ref_z = bottom_edge.CenterOfGravity.z for edge in [edge0, edge1, edge2, edge3]: edge.Vertexes[0].Point.z = edge.Vertexes[1].Point.z = ref_z @@ -1763,71 +2103,6 @@ class WallHandler(BaseHandler): baseboard.recompute(True) floor.getObject(floor.DecorationBaseboardsGroupName).addObject(baseboard) - def _get_faces(self, wall): - """Returns the name of the left and right face for `wall` - - 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 completely constructed. 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 through 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): """The base class for importing different class of furnitures.""" @@ -1927,7 +2202,8 @@ class DoorOrWindowHandler(BaseFurnitureHandler): if not feature: feature = self._create_door(floor, elm) - assert feature != None, f"Missing feature for {door_id} ..." + if not feature: + return self._set_properties(feature, elm) self.set_furniture_common_properties(feature, elm) @@ -1936,77 +2212,138 @@ class DoorOrWindowHandler(BaseFurnitureHandler): def _set_properties(self, obj, elm): self.setp(obj, "App::PropertyString", "shType", "The element type", 'doorOrWindow') - self.setp(obj, "App::PropertyFloat", "wallThickness", "", float(elm.get('wallThickness', 1))) - self.setp(obj, "App::PropertyFloat", "wallDistance", "", elm) - self.setp(obj, "App::PropertyFloat", "wallWidth", "", float(elm.get('wallWidth', 1))) - self.setp(obj, "App::PropertyFloat", "wallLeft", "", elm) - self.setp(obj, "App::PropertyFloat", "wallHeight", "", float(elm.get('wallHeight', 1))) - self.setp(obj, "App::PropertyFloat", "wallTop", "", elm) + self.setp(obj, "App::PropertyFloat", "wallThickness", "", dim_sh2fc(elm.get('wallThickness', 1))) + self.setp(obj, "App::PropertyFloat", "wallDistance", "", dim_sh2fc(elm.get('wallDistance', 0))) + self.setp(obj, "App::PropertyFloat", "wallWidth", "", dim_sh2fc(elm.get('wallWidth', 1))) + self.setp(obj, "App::PropertyFloat", "wallLeft", "", dim_sh2fc(elm.get('wallLeft', 0))) + self.setp(obj, "App::PropertyFloat", "wallHeight", "", dim_sh2fc(elm.get('wallHeight', 1))) + self.setp(obj, "App::PropertyFloat", "wallTop", "", dim_sh2fc(elm.get('wallTop', 0))) self.setp(obj, "App::PropertyBool", "wallCutOutOnBothSides", "", elm) self.setp(obj, "App::PropertyBool", "widthDepthDeformable", "", elm) self.setp(obj, "App::PropertyString", "cutOutShape", "", elm) self.setp(obj, "App::PropertyBool", "boundToWall", "", elm) def _create_door(self, floor, elm): - # The window in SweetHome3D is defined with a width, depth, height. + debug_geometry = self.importer.preferences["DEBUG_GEOMETRY"] + # The doorOrWndow in SH3D is defined with a width, depth, height. # Furthermore the (x.y.z) is the center point of the lower face of the # window. In FC the placement is defined on the face of the wall that - # contains the windows. The makes this calculation rather cumbersome. + # contains the windows and it references the corner of said face. + # Therefore translating the n arbitrary volume in SH3D into a face in + # FreeCAD is rather confusing and tricky to get right. x_center = float(elm.get('x')) y_center = float(elm.get('y')) z_center = float(elm.get('elevation', 0)) - # This is the FC coordinate of the center point of the lower face of the - # window. This then needs to be moved to the proper face on the wall and - # offset properly with respect to the wall's face. - center = coord_sh2fc(App.Vector(x_center, y_center, z_center)) - center.z += floor.Placement.Base.z + label_prefix = f"dow-{elm.get('id')}" - # First create a solid representing the window contour and find the - # walls containing that window + # The absolute coordinate of the center of the doorOrWndow's lower face + dow_abs_center = coord_sh2fc(App.Vector(x_center, y_center, z_center)) + dow_abs_center.z += floor.Placement.Base.z width = dim_sh2fc(elm.get('width')) depth = dim_sh2fc(elm.get('depth')) height = dim_sh2fc(elm.get('height')) - angle = float(elm.get('angle', 0)) + angle = norm_deg_ang(ang_sh2fc(elm.get('angle', 0))) - corner = center.add(App.Vector(-width/2, -depth/2, -height/2)) + # Note that we only move on the XY plane since we assume that + # only the right and left face will be used for supporting the + # doorOrWndow. It might not be correct for roof windows and floor + # windows... + # The absolute coordinate of the corner of the doorOrWindow + dow_abs_corner = dow_abs_center.add(App.Vector(-width/2, -depth/2, 0)) - # 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) + # Create a solid representing the BoundingBox of the windows + # to find out which walls contains the window... + dow_bounding_box = Part.makeBox(width, depth, height, dow_abs_corner) + dow_bounding_box = dow_bounding_box.rotate(dow_bounding_box.CenterOfGravity, Z_NORM, angle) + if debug_geometry: + self._debug_shape(dow_bounding_box, f"{label_prefix}-bb", BLUE) + self._debug_point(dow_bounding_box.CenterOfGravity, f"{label_prefix}-bb-cog") - # Get all the walls hosting that door/window... - wall_width = -DEFAULT_WALL_WIDTH - 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} ...") + # Indicate whether the bounding box CoG is inscribed in the wall + is_opened = False + wall_width = depth + # Get all the walls hosting that doorOrWndow. + # + # The main wall is used to determine the projection of the + # doorOrWindow bounding_box, and thus the placement of the + # resulting Arch element. The main wall is the one containing + # the CenterOfGravity of the bounding_box. Note that for opened + # windows the CenterOfGravity might be outside any wall. In which + # case we only take the first wall hosting the window. + main_wall, extra_walls = self._get_containing_walls(floor, dow_bounding_box) + if main_wall: + wall_width = main_wall.Width.Value else: - # 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) + if len(extra_walls) == 0: + _err(f"No hosting wall for doorOrWindow#{elm.get('id')}. Bailing out!") + if debug_geometry: self._debug_shape(dow_bounding_box, f"{label_prefix}-no-hosting-wall", RED) + return None + # Hum probably open doorOrWndow? + is_opened = True + main_wall = extra_walls.pop(0) + wall_width = main_wall.Width.Value + if len(extra_walls) > 0: + _wrn(f"No main hosting wall for doorOrWindow#{elm.get('id')}. Defaulting to first hosting wall#{main_wall.Label} (w/ width {wall_width}) ...") - center2corner = App.Vector(-width/2, -wall_width/2, 0) - rotation = App.Rotation(Z_NORM, math.degrees(ang_sh2fc(angle))) - center2corner = rotation.multVec(center2corner) - corner = center.add(center2corner) + # Get the left and right face for the main_wall + (_, wall_lface, _, wall_rface) = self.get_faces(main_wall) + + # The general process is as follow: + # 1- Find the bounding box face whose normal is properly oriented + # with respect to the doorOrWindow (+90º) + # 2- Find the wall face with the same orientation. + # 3- Project the bounding box face onto the wall face. + # 4- From projection extract the placement of the window. + + # Determine the bounding box face + bb_face, bb_face_normal = self._get_bb_face(dow_bounding_box, angle, label_prefix) + if not bb_face: + _err(f"Weird: None of BoundingBox's faces for doorOrWindow#{elm.get('id')} has the expected angle ({angle}º). Can't create window.") + if debug_geometry: self._debug_shape(dow_bounding_box, f"{label_prefix}-missing-bb-face#{main_wall.Label}", RED) + return None + elif debug_geometry: + self._debug_shape(bb_face, f"{label_prefix}-bb-face", MAGENTA) + + # Determine the wall's face with the same orientation. Note that + # if the window is ever so slightly twisted with respect to the wall + # this will probably fail. + # In order to get the proper wall's Face, we calculate the normal to the + # wall's face at the bb_face CenterOfGravity. This is to avoid problems + # with curved walls + # First get the u,v parameter of the bb_face CoG onto each of the wall faces + # Then get the normal at these parameter on each of the wall faces + wall_rface_normal = wall_rface.normalAt(*(wall_rface.Surface.parameter(bb_face.CenterOfGravity))) + wall_lface_normal = wall_lface.normalAt(*(wall_lface.Surface.parameter(bb_face.CenterOfGravity))) + wall_face = wall_rface + is_on_right = True + if not self._same_dir(bb_face_normal, wall_rface_normal, 1): + is_on_right = False + wall_face = wall_lface + if not self._same_dir(bb_face_normal, wall_lface_normal, 1): + _err(f"Weird: the extracted bb_normal {self._pv(bb_face_normal, True)} does not match neither the right face normal ({self._pv(wall_rface_normal, True)}) nor the left face normal ({self._pv(wall_lface_normal, True)}) of the wall {main_wall.Label}... The doorOrWindow might be slightly skewed. Defaulting to left face.") + + # Project the bounding_box face onto the wall + projected_face = wall_face.makeParallelProjection(bb_face.OuterWire, bb_face_normal) + if debug_geometry: + self._debug_shape(wall_face, f"{label_prefix}-bb-projected-onto#{main_wall.Label}", MAGENTA) + self._debug_shape(projected_face, f"{label_prefix}-bb-projection#{main_wall.Label}", RED) + + # Determine the base vertex that I later use for the doorOrWindow + # placement + base_vertex = self._get_base_vertex(main_wall, is_on_right, projected_face) pl = App.Placement( - corner, # translation - App.Rotation(math.degrees(ang_sh2fc(angle)), 0, 90), # rotation - ORIGIN # rotation@coordinate + base_vertex, # move + App.Rotation(angle, 0, 90), # Yaw, pitch, roll + ORIGIN # rotation@point ) + if debug_geometry: self._debug_point(pl.Base, f"{label_prefix}-pl-base", MAGENTA) + # Then prepare the windows characteristics # NOTE: the windows are not imported as meshes, but we use a simple # correspondence between a catalog ID and a specific window preset from - # the parts library. + # the parts library. Only using Opening / Fixed / Simple Door catalog_id = elm.get('catalogId') (windowtype, ifc_type) = DOOR_MODELS.get(catalog_id, (None, None)) if not windowtype: @@ -2014,45 +2351,150 @@ class DoorOrWindowHandler(BaseFurnitureHandler): (windowtype, ifc_type) = ('Simple door', 'Door') # See the https://wiki.freecad.org/Arch_Window for details about these values - # Only using Opening / Fixed / Simple Door - h1 = min(50,height*.025) # 2.5% of frame - h2 = h1 + # NOTE: These are simple heuristic to get reasonable windows + h1 = min(50, height*.025) # frame is 2.5% of whole height... + h2 = h1 # panel's frame is the same as frame h3 = 0 - w1 = wall_width - w2 = min(20.0,wall_width*.2) # 20% of width - o1 = 0 - o2 = (wall_width-w2)/2 + w1 = wall_width # frame is 100% of wall width... + w2 = min(20.0, wall_width*.2) # panel is 20% of wall width + o1 = (wall_width-w1)/2 # frame is centered + o2 = (wall_width-w2)/2 # panel is centered window = Arch.makeWindowPreset(windowtype, width, height, h1, h2, h3, w1, w2, o1, o2, pl) + window.Label = elm.get('name') window.IfcType = ifc_type - - mirrored = bool(elm.get('modelMirrored', False)) - if ifc_type == 'Door' and mirrored: - window.OperationType = "SINGLE_SWING_RIGHT" + if is_opened: window.Opening = 30 # Adjust symbol plan, Sweet Home has the opening in the opposite side by default window.ViewObject.Proxy.invertOpening() + mirrored = bool(elm.get('modelMirrored', False)) if mirrored: window.ViewObject.Proxy.invertHinge() - window.Hosts = walls + # Finally make sure all the walls are properly cut by the doorOrWndow. + window.Hosts = [main_wall, *extra_walls] if main_wall else extra_walls return window - def _get_containing_walls(self, floor, solid): - """Returns the wall(s) intersecting with the door/window. + def _get_containing_walls(self, floor, dow_bounding_box): + """Returns the wall(s) and slab(s) intersecting with the doorOrWindow + bounding_box. + + The main wall is the one that contains the doorOrWndow bounding_box + CenterOfGravity. Note that this will not work for open doorOrWindow + (i.e.whose bounding_box is a lot greater than the containing wall). + The _create_door, has a mitigation process for that case. + + The main_wall is used to get the face on which to project the + doorOrWindows bounding_box, and from there the placement of the + element on the wall's face. + + The general process is as follow: + - find out whether the doorOrWindow span several floors, if so + add all the walls (and slab) for that floor to the list of elements + to check. + - once the list of elements to check is complete we check if the + doorOrWindow bounding_box has a volume in common with the wall. Args: floor (Arch.Level): the level the solid must belongs to - solid (Part.Solid): the solid to test against each wall's + dow_bounding_box (Part.Solid): the solid to test against each wall's bounding box Returns: - list(Arch::Wall): the wall(s) containing the given solid + tuple(Arch::Wall, list(Arch::Wall)): a tuple of the main wall (if + any could be found and a list of any other Arch element that + might be host of that """ + relevant_walls = [*self.importer.get_walls(floor)] + # First find out which floor the window might be have an impact on. + solid_zmin = dow_bounding_box.BoundBox.ZMin + solid_zmax = dow_bounding_box.BoundBox.ZMax + if solid_zmin < floor.Placement.Base.z or solid_zmax > (floor.Placement.Base.z + floor.Height.Value): + # determine the impacted floors + for other_floor in self.importer.get_all_floors(): + if other_floor.id == floor.id: + continue + floor_zmin = other_floor.Placement.Base.z + floor_zmax = other_floor.Placement.Base.z + other_floor.Height.Value + if (floor_zmin < solid_zmin and solid_zmin < floor_zmax) or ( + floor_zmin < solid_zmax and solid_zmax < floor_zmax) or ( + solid_zmin < floor_zmin and floor_zmax < solid_zmax): + # Add floor and slabs + relevant_walls.extend(self.importer.get_walls(other_floor)) + if other_floor.ReferenceSlabName: + relevant_walls.append(App.ActiveDocument.getObject(other_floor.ReferenceSlabName)) + main_wall = None host_walls = [] - for wall in self.importer.get_walls(floor): - if solid.common(wall.Shape).Volume > 0: + # Taking the CoG projection on the lower face. + solid_cog = dow_bounding_box.CenterOfGravity + solid_cog.z = solid_zmin + for wall in relevant_walls: + if wall.Shape.isNull(): + continue + if wall.Shape.isInside(solid_cog, 1, True): + main_wall = wall + continue + if dow_bounding_box.common(wall.Shape).Volume > 0: host_walls.append(wall) - return host_walls + return main_wall, host_walls + + def _get_bb_face(self, dow_bounding_box, angle, label_prefix=None): + """Returns the bounding box face with the correct normal. + + Returns the bounding box face whose normal has the same orientation + as the window itself. Note that we round and modulo 360 to avoid + problems. + + Args: + dow_bounding_box (Part.Solid): The window bounding box + angle (float): the window angle in degree + + Returns: + Part.Face: the correct face or None + """ + debug_geometry = self.importer.preferences["DEBUG_GEOMETRY"] + # Note that the 'angle' refers to the angle of the face, not its normal. + # we therefore add a '+90º' in SH3D coordinate (i.e. -90º in FC + # coordinate). + # XXX: Can it be speed up by assuming that left and right are always + # Face2 and Face4??? + angle = (angle - 90) % 360 # make sure positive ccw angle + for i, face in enumerate(dow_bounding_box.Faces): + face_normal = face.normalAt(0,0) # The face is flat. can use u = v = 0 + normal_angle = norm_deg_ang(DraftVecUtils.angle(X_NORM, face_normal)) + if debug_geometry: _msg(f"#{i}/{label_prefix} {normal_angle}º <=> {angle}º") + if normal_angle == angle: + if debug_geometry: _msg(f"Found bb#{i}/{label_prefix} (@{normal_angle}º)") + return face, face_normal + return None, None + + def _same_dir(self, v1, v2, tol=1e-6): + return (v1.normalize() - v2.normalize()).Length < tol + + def _get_base_vertex(self, wall, is_on_right: bool, projected_face): + """Return the base vertex used to place a doorOrWindow. + + Returns the vertex of the projected_face that serves as the + base for the Placement when creating the doorOrWindow. It is + the lowest vertex and closest to the wall reference point. + The wall reference point depends on whether we are on the + right or left side of the wall. + + Args: + wall (Arch::Wall): the wall + is_on_right (bool): indicate whether the projected face is + on the left or on the right + projected_face (Part.Face): the bounding box projection + on the wall + + Returns: + App.Vector: the vector to be used as the base of the Placement + """ + wall_spine = self.get_wall_spine(wall) + wall_ref = wall_spine.Start if is_on_right else wall_spine.End + lowest_z = round(projected_face.BoundBox.ZMin) + lower_vertexes = list(filter(lambda v: round(v.Point.z) == lowest_z, projected_face.Vertexes)) + base_vertex = min(lower_vertexes, key=lambda v: v.Point.distanceToPoint(wall_ref)) + return base_vertex.Point class FurnitureHandler(BaseFurnitureHandler): @@ -2068,104 +2510,196 @@ class FurnitureHandler(BaseFurnitureHandler): i (int): the ordinal of the imported element elm (Element): the xml element """ - furniture_id = f"{elm.get('id', elm.get('name'))}-{i}" + furniture_id = self._get_furniture_id(i, elm) level_id = elm.get('level', None) floor = self.get_floor(level_id) assert floor != None, f"Missing floor '{level_id}' for '{furniture_id}' ..." - feature = None + furniture = None if self.importer.preferences["MERGE"]: - feature = self.get_fc_object(furniture_id, 'pieceOfFurniture') + furniture = self.get_fc_object(furniture_id, 'pieceOfFurniture') - if not feature: - feature = self._create_equipment(floor, elm) + if not furniture: + furniture = self._create_furniture(floor, elm) + if not furniture: + return - color = elm.get('color', self.importer.preferences["DEFAULT_FLOOR_COLOR"]) - set_color_and_transparency(feature, color) + color = elm.get('color', self.importer.preferences["DEFAULT_FURNITURE_COLOR"]) + set_color_and_transparency(furniture, color) - self.setp(feature, "App::PropertyString", "shType", "The element type", 'pieceOfFurniture') - self.set_furniture_common_properties(feature, elm) - self.set_piece_of_furniture_common_properties(feature, elm) - self.set_piece_of_furniture_horizontal_rotation_properties(feature, elm) - self.setp(feature, "App::PropertyString", "id", "The furniture's id", furniture_id) + furniture.ViewObject.DisplayMode = 'Flat Lines' + self.setp(furniture, "App::PropertyString", "shType", "The element type", 'pieceOfFurniture') + self.set_furniture_common_properties(furniture, elm) + self.set_piece_of_furniture_common_properties(furniture, elm) + self.set_piece_of_furniture_horizontal_rotation_properties(furniture, elm) + self.setp(furniture, "App::PropertyString", "id", "The furniture's id", furniture_id) if 'FurnitureGroupName' not in floor.PropertiesList: group = floor.newObject("App::DocumentObjectGroup", "Furnitures") self.setp(floor, "App::PropertyString", "FurnitureGroupName", "The DocumentObjectGroup name for all furnitures on this floor", group.Name) - if self.importer.preferences["CREATE_ARCH_EQUIPMENT"]: - p = feature.Shape.BoundBox.Center - else: - p = feature.Mesh.BoundBox.Center + # if self.importer.preferences["CREATE_ARCH_EQUIPMENT"]: + # p = feature.Shape.BoundBox.Center + # else: + # p = feature.Mesh.BoundBox.Center - space = self.get_space(floor, p) - if space: - space.Group = space.Group + [feature] - else: - _log(f"No space found to enclose {feature.Label}. Adding to generic group.") - floor.getObject(floor.FurnitureGroupName).addObject(feature) + # XXX: Furniture should be grouped in a space, but the Visibility + # setting is not propagated and therefore makes it not so user friendly. + # space = self.get_space(floor, p) + # if space: + # space.Group = space.Group + [feature] + # else: + # _log(f"No space found to enclose {feature.Label}. Adding to generic group.") + # floor.getObject(floor.FurnitureGroupName).addObject(feature) + floor.getObject(floor.FurnitureGroupName).addObject(furniture) # 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.add_fc_objects(feature) + self.importer.add_fc_objects(furniture) - def _create_equipment(self, floor, elm): - width = dim_sh2fc(float(elm.get('width'))) - depth = dim_sh2fc(float(elm.get('depth'))) - height = dim_sh2fc(float(elm.get('height'))) - x = float(elm.get('x', 0)) - y = float(elm.get('y', 0)) + def _create_furniture(self, floor, elm): + debug_geometry = self.importer.preferences["DEBUG_GEOMETRY"] + # REF: + # - SweetHome3D/src/com/eteks/sweethome3d/model/HomePieceOfFurniture#readObject() + # - SweetHome3D/src/com/eteks/sweethome3d/j3d/ModelManager#getNormalizedTransform() + # - SweetHome3D/src/com/eteks/sweethome3d/j3d/ModelManager#getPieceOfFurnitureNormalizedModelTransformation() + + name = elm.get('name', elm.get('id', "NA")) + + # The general process is as follow: + # - we load the mesh and center it properly. + # - we apply the modelRotation, pitch and roll + # - we scale + # - we apply the yaw + # - then we workout the Placement + x = float(elm.get('x', 0.0)) + y = float(elm.get('y', 0.0)) z = float(elm.get('elevation', 0.0)) - height_in_plan = elm.get('heightInPlan', 0.0) - pitch = float(elm.get('pitch', 0.0)) # X SH3D Axis - roll = float(elm.get('roll', 0.0)) # Y SH3D Axis - angle = float(elm.get('angle', 0.0)) # Z SH3D Axis - name = elm.get('name') - model_rotation = elm.get('modelRotation', None) - mirrored = bool(elm.get('modelMirrored', "false") == "true") - # The meshes are normalized, centered, facing up. - # Center, Scale, X Rotation && Z Rotation (in FC axes), Move + width = dim_sh2fc(elm.get('width', 0.0)) + depth = dim_sh2fc(elm.get('depth', 0.0)) + height = dim_sh2fc(elm.get('height', 0.0)) + + pitch = norm_rad_ang(elm.get('pitch', 0.0)) + roll = norm_rad_ang(elm.get('roll', 0.0)) + angle = ang_sh2fc(elm.get('angle', 0.0)) + + model_rotation = elm.get('modelRotation', None) + model_mirrored = elm.get('modelMirrored', "false") == "true" + model_centered_at_origin = elm.get('modelCenteredAtOrigin', "false") == "true" + mesh = self._get_mesh(elm) - bb = mesh.BoundBox - transform = App.Matrix() - # In FC the reference is the "upper left" corner - transform.move(-bb.Center) + + if len(mesh.Points) == 0: + # Until https://github.com/FreeCAD/FreeCAD/issues/19456 is solved... + _wrn(f"Import of pieceOfFurniture#{name} resulted in an empty Mesh. Skipping.") + return None + + model_bb = mesh.BoundBox + if debug_geometry: self._debug_mesh(mesh, f"{name}-original", None, MAGENTA) + + mesh_transform = App.Matrix() + + mesh_transform.move(-model_bb.Center) + if debug_geometry: self._debug_mesh(mesh, f"{name}-centered", mesh_transform, MAGENTA) + + # The model rotation is necessary to get the scaling right if model_rotation: rij = [ float(v) for v in model_rotation.split() ] - rotation = App.Rotation( - App.Vector(rij[0], rij[1], rij[2]), - App.Vector(rij[3], rij[4], rij[5]), - App.Vector(rij[6], rij[7], rij[8]) + rotation = App.Matrix( + App.Vector(rij[0], rij[3], rij[6]), + App.Vector(rij[1], rij[4], rij[7]), + App.Vector(rij[2], rij[5], rij[8]) ) - _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) - transform.rotateX(-pitch) - transform.rotateY(roll) - transform.rotateZ(ang_sh2fc(angle)) + mesh_transform = rotation.multiply(mesh_transform) + if debug_geometry: self._debug_mesh(mesh, f"{name}-rotated", mesh_transform, MAGENTA) - mesh.transform(transform) + if model_mirrored: + mesh_transform.scale(-1, 1, 1) # Mirror along X + if debug_geometry: self._debug_mesh(mesh, f"{name}-mirrored", mesh_transform) + # We add an initial 90º in order for a yaw-pitch-roll-rotation free + # model to appear properly in FC + mesh_transform.rotateX(math.pi/2) + if debug_geometry: self._debug_mesh(mesh, f"{name}-x90", mesh_transform) + + # The scaling is calculated using the models coordinate system. + # We use a simple box to calculate the scale factors for each axis. + # Note that we use the absolute value since the orientation will + # be handled by the Placement. + # Note that we do that before the model has had any ypr angles applied + normalized_model = Part.makeBox(model_bb.XLength, model_bb.YLength, model_bb.ZLength) + normalized_model = normalized_model.transformGeometry(mesh_transform) + normilized_bb = normalized_model.BoundBox + x_scale = width / normilized_bb.XLength + y_scale = depth / normilized_bb.YLength + z_scale = height / normilized_bb.ZLength + + mesh_transform.scale(x_scale, y_scale, z_scale) + if debug_geometry: + model_size = App.Vector(model_bb.XLength, model_bb.YLength, model_bb.ZLength) + normalized_size = App.Vector(normilized_bb.XLength, normilized_bb.YLength, normilized_bb.ZLength) + final_size = App.Vector(width, depth, height) + factors = App.Vector(x_scale, y_scale, z_scale) + _msg(f"{name}-size_model={self._pv(model_size, True, 1)} -> {self._pv(normalized_size, True, 1)} (x{self._pv(factors, True, 1)}) -> {self._pv(final_size, True, 1)}") + self._debug_mesh(mesh, f"{name}-scaled", mesh_transform, MAGENTA) + + # At that point the mesh has the proper scale. We determine the placement. + # In order to do that, we need to apply the different rotation (ypr) and + # also the translation from the origin to the final point. + if pitch != 0: + r_pitch = App.Rotation(X_NORM, Radian=-pitch) + mesh_transform = r_pitch.toMatrix().multiply(mesh_transform) + if debug_geometry: self._debug_mesh(mesh, f"{name}-pitch", mesh_transform) + elif roll != 0: + r_roll = App.Rotation(Y_NORM, Radian=roll) + mesh_transform = r_roll.toMatrix().multiply(mesh_transform) + if debug_geometry: self._debug_mesh(mesh, f"{name}-roll", mesh_transform) + if angle != 0: + r_yaw = App.Rotation(Z_NORM, Radian=angle) + mesh_transform = r_yaw.toMatrix().multiply(mesh_transform) + if debug_geometry: self._debug_mesh(mesh, f"{name}-yaw", mesh_transform) + + mesh.transform(mesh_transform) + + # SH(x,y,z) refer to the projection of the CenterOfGravity on the + # bottom face of the model bounding box + translation = coord_sh2fc(App.Vector(x, y, z)) + if debug_geometry: self._debug_mesh(mesh, f"{name}-xyz", color=MAGENTA, placement=App.Placement(translation, NO_ROT)) + + # Note that the SH coordinates represent the CenterOfGravity of the + # lower face of the scaled model bounding box. + translation.z += abs(mesh.BoundBox.ZMin) + if debug_geometry: self._debug_mesh(mesh, f"{name}-+zmin", color=MAGENTA_LIGHT, placement=App.Placement(translation, NO_ROT)) + + # Finally we add the placement of the floor itself. + # XXX: strange that is not simply added when we add the object to the floor + translation.z += floor.Placement.Base.z + + # The placement is ready. Note that the rotations have the origin + # in the center of the bounding box of the scaled mesh. + placement = App.Placement(translation, NO_ROT) + + # Ok, everything is ready to create the equipment if self.importer.preferences["CREATE_ARCH_EQUIPMENT"]: shape = Part.Shape() shape.makeShapeFromMesh(mesh.Topology, 1) - equipment = Arch.makeEquipment(name=name) - equipment.Shape = shape + furniture = Arch.makeEquipment(name="Furniture") + furniture.Shape = shape else: - equipment = App.ActiveDocument.addObject("Mesh::Feature", name) - equipment.Mesh = mesh + furniture = App.ActiveDocument.addObject("Mesh::Feature", "Furniture") + furniture.Mesh = mesh - equipment.Placement.Base = coord_sh2fc(App.Vector(x, y, z)) - equipment.Placement.Base.z += floor.Placement.Base.z - equipment.Placement.Base.z += mesh.BoundBox.ZLength / 2 + furniture.Placement = placement + furniture.Label = elm.get('name') + return furniture - return equipment + def _get_furniture_id(self, i, elm): + return f"{elm.get('id', elm.get('name'))}-{i}" class LightHandler(FurnitureHandler): - """A helper class to import a SH3D `` object.""" + """A helper class to import a SH3D `` object.""" def __init__(self, importer: SH3DImporter): super().__init__(importer) @@ -2177,43 +2711,45 @@ class LightHandler(FurnitureHandler): i (int): the ordinal of the imported element elm (Element): the xml element """ - light_id = f"{elm.get('id', elm.get('name'))}-{i}" + light_id = super()._get_furniture_id(i, elm) level_id = elm.get('level', None) floor = self.get_floor(level_id) assert floor != None, f"Missing floor '{level_id}' for '{light_id}' ..." if self.importer.preferences["IMPORT_FURNITURES"]: - super().process(i, elm) + super().process(parent, i, elm) light_apppliance = self.get_fc_object(light_id, 'pieceOfFurniture') assert light_apppliance != None, f"Missing furniture {light_id} ..." - self.setp(light_apppliance, "App::PropertyFloat", "power", "The power of the light", float(elm.get('power', 0.5))) + self.setp(light_apppliance, "App::PropertyFloat", "power", "The power of the light. In percent???", float(elm.get('power', 0.5))) - # Import the lightSource sub-elments - for j, sub_elm in enumerate(elm.findall('lightSource')): - light_source = None - light_source_id = f"{light_id}-{j}" - if self.importer.preferences["MERGE"]: - light_source = self.get_fc_object(light_source_id, 'lightSource') + if self.importer.preferences["IMPORT_LIGHTS"]: + # Import the lightSource sub-elments + for j, sub_elm in enumerate(elm.findall('lightSource')): + light_source = None + light_source_id = f"{light_id}-{j}" + if self.importer.preferences["MERGE"]: + light_source = self.get_fc_object(light_source_id, 'lightSource') - if not light_source: - _, light_source, _ = PointLight.create() + if not light_source: + _, light_source, _ = PointLight.create() - x = float(sub_elm.get('x')) - y = float(sub_elm.get('y')) - z = float(sub_elm.get('z')) - diameter = float(sub_elm.get('diameter')) - color = sub_elm.get('color') + x = float(sub_elm.get('x')) + y = float(sub_elm.get('y')) + z = float(sub_elm.get('z')) + diameter = float(sub_elm.get('diameter')) + color = sub_elm.get('color') - light_source.Label = elm.get('name') - light_source.Placement.Base = coord_sh2fc(App.Vector(x, y, z)) - light_source.Radius = dim_sh2fc(diameter / 2) - light_source.Color = hex2rgb(color) + light_source.Label = elm.get('name') + light_source.Placement.Base = coord_sh2fc(App.Vector(x, y, z)) + light_source.Radius = dim_sh2fc(diameter / 2) + light_source.Color = hex2rgb(color) - self.setp(light_source, "App::PropertyString", "shType", "The element type", 'lightSource') - self.setp(light_source, "App::PropertyString", "id", "The elment's id", light_source_id) - self.setp(light_source, "App::PropertyLink", "lightAppliance", "The furniture", light_apppliance) + self.setp(light_source, "App::PropertyString", "shType", "The element type", 'lightSource') + self.setp(light_source, "App::PropertyString", "id", "The elment's id", light_source_id) + if self.importer.preferences["IMPORT_FURNITURES"]: + self.setp(light_source, "App::PropertyLink", "lightAppliance", "The light apppliance", light_apppliance) - App.ActiveDocument.Lights.addObject(light_source) + floor.getObject(floor.LightGroupName).addObject(light_source) class CameraHandler(BaseHandler): @@ -2243,20 +2779,18 @@ class CameraHandler(BaseHandler): _log(translate("BIM", f"Type of <{elm.tag}> #{i} is not supported: '{attribute}'. Skipping!")) return - camera_id = f"{attribute}-{i}" - camera = None + camera_id = f"{elm.get('id', attribute)}-{i}" if self.importer.preferences["MERGE"]: camera = self.get_fc_object(camera_id, attribute) if not camera: _, camera, _ = Camera.create() - App.ActiveDocument.Cameras.addObject(camera) # ¿How to convert fov to FocalLength? fieldOfView = float(elm.get('fieldOfView')) fieldOfView = math.degrees(fieldOfView) - camera.Label = elm.get('name', attribute.title()) + camera.Label = elm.get('name', attribute) camera.Placement.Base = coord_sh2fc(App.Vector(x, y, z)) # NOTE: the coordinate system is screen like, thus roll & picth are inverted ZY'X'' camera.Placement.Rotation.setYawPitchRoll( @@ -2264,11 +2798,13 @@ class CameraHandler(BaseHandler): camera.Projection = "Perspective" camera.AspectRatio = 1.33333333 # /home/environment/@photoAspectRatio + self.setp(camera, "App::PropertyString", "shType", "The element type", 'camera') + self.setp(camera, "App::PropertyString", "id", "The object ID", camera_id) self._set_properties(camera, elm) + App.ActiveDocument.Cameras.addObject(camera) + def _set_properties(self, obj, elm): - self.setp(obj, "App::PropertyString", "shType", "The element type", 'camera') - self.setp(obj, "App::PropertyString", "id", "The object ID", elm) self.setp(obj, "App::PropertyEnumeration", "attribute", "The type of camera", elm.get('attribute'), valid_values=["topCamera", "observerCamera", "storedCamera", "cameraPath"]) self.setp(obj, "App::PropertyBool", "fixedSize", "Whether the object is fixed size", bool(elm.get('fixedSize', False))) self.setp(obj, "App::PropertyEnumeration", "lens", "The object's lens (PINHOLE | NORMAL | FISHEYE | SPHERICAL)", str(elm.get('lens', "PINHOLE")), valid_values=["PINHOLE", "NORMAL", "FISHEYE", "SPHERICAL"]) @@ -2319,7 +2855,8 @@ def ang_sh2fc(angle:float): """Convert SweetHome angle (º) to FreeCAD angle (º) SweetHome angles are clockwise positive while FreeCAD are anti-clockwise - positive + positive. Further more angle in FreeCAD are always positive between 0º and + 360º. Args: angle (float): The angle in SweetHome @@ -2327,16 +2864,51 @@ def ang_sh2fc(angle:float): Returns: float: the FreeCAD angle """ - return -float(angle) + return norm_rad_ang(-float(angle)) + + +def norm_deg_ang(angle:float): + """Normalize a radian angle into a degree angle.. + + Args: + angle (float): The angle in radian + + Returns: + float: a normalized angle + """ + return round(math.degrees(float(angle)) % 360) + + +def norm_rad_ang(angle:float): + """Normalize a radian angle into a radian angle.. + + Args: + angle (float): The angle in radian + + Returns: + float: a normalized angle + """ + return (float(angle) % TWO_PI + TWO_PI) % TWO_PI def set_color_and_transparency(obj, color): if not App.GuiUp or not color: return - if hasattr(obj.ViewObject, "ShapeColor"): - obj.ViewObject.ShapeColor = hex2rgb(color) - if hasattr(obj.ViewObject, "Transparency"): - obj.ViewObject.Transparency = _hex2transparency(color) + + view_object = obj.ViewObject + if hasattr(view_object, "ShapeAppearance"): + mat = view_object.ShapeAppearance[0] + rgb_color = hex2rgb(color) + mat.DiffuseColor = rgb_color + mat.AmbientColor = rgb_color + mat.SpecularColor = rgb_color + mat.EmissiveColor = (0.0,0.0,0.0,1.0) + obj.ViewObject.ShapeAppearance = (mat) + return + if hasattr(view_object, "ShapeColor"): + view_object.ShapeColor = hex2rgb(color) + if hasattr(view_object, "Transparency"): + view_object.Transparency = _hex2transparency(color) def color_fc2sh(hexcode): @@ -2381,15 +2953,62 @@ def _color_section(section): def set_shininess(obj, shininess): # TODO: it seems a shininess of 0 means the wall looses its # color. We'll leave it at the default setting until a later time - return if not App.GuiUp or not shininess: return if hasattr(obj.ViewObject, "ShapeAppearance"): - mat = obj.ViewObject.ShapeAppearance[0] - mat.Shininess = float(shininess)/100 - obj.ViewObject.ShapeAppearance = mat + obj.ViewObject.ShapeAppearance[0].Shininess = float(shininess) + # 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) + + +def cross_product(o, a, b): + """Computes the cross product of vectors OA and OB.""" + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + +def convex_hull(points, tol=1e-6): + """Return the convex hull of a series of Point + + Computes the convex hull using Andrew's monotone chain algorithm (NumPy version). + + Args: + points (list): the list of point for which to find the convex hull + + Returns: + list: the point forming the convex hull + """ + default_z = points[0].z + point_coords = np.array([[p.x, p.y] for p in points], dtype=np.float64) + point_coords = point_coords[np.lexsort((point_coords[:, 1], point_coords[:, 0]))] # Sort by x, then y + + def build_half_hull(sorted_points): + hull = [] + for p in sorted_points: + while len(hull) >= 2 and cross_product(hull[-2], hull[-1], p) <= tol: + hull.pop() + hull.append(tuple(p)) + return hull + + lower = build_half_hull(point_coords) + upper = build_half_hull(point_coords[::-1]) + + # Remove duplicates + new_points = [App.Vector(p[0], p[1], default_z) for p in np.array(lower[:-1] + upper[:-1])] + return new_points + +# def _convex_hull(points): +# """Return the convex hull of a series of Point +# +# Args: +# points (list): the list of point +# +# Returns: +# list: the point forming the convex hull +# """ +# point_coords = np.array([[p.x, p.y] for p in points]) +# new_points = [points[i] for i in scipy.spatial.ConvexHull(point_coords).vertices] +# return new_points[0] +