diff --git a/src/Mod/BIM/CMakeLists.txt b/src/Mod/BIM/CMakeLists.txt index 1e50d4be66..1607425e36 100644 --- a/src/Mod/BIM/CMakeLists.txt +++ b/src/Mod/BIM/CMakeLists.txt @@ -59,12 +59,14 @@ SET(importers_SRCS importers/importWebGL.py importers/importJSON.py importers/importSH3D.py + importers/importSH3DHelper.py importers/import3DS.py importers/importSHP.py importers/importGBXML.py importers/exportIFCStructuralTools.py importers/exportIFC.py importers/exportIFCHelper.py + importers/samples/Sample.sh3d ) SET(Dice3DS_SRCS @@ -198,6 +200,10 @@ SET(BIMGuiIcon_SVG Resources/icons/BIMWorkbench.svg ) +SET(ImportersSample_Files + importers/samples/Sample.sh3d +) + ADD_CUSTOM_TARGET(BIM ALL SOURCES ${Arch_SRCS} ${Arch_QRC_SRCS} @@ -210,6 +216,10 @@ ADD_CUSTOM_TARGET(BIM ALL ${BIMGuiIcon_SVG} ) +ADD_CUSTOM_TARGET(ImporterPythonTestData ALL + SOURCES ${ImportersSample_Files} +) + fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${Arch_SRCS}) fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${Dice3DS_SRCS}) fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${importers_SRCS}) @@ -223,6 +233,12 @@ fc_target_copy_resource(BIM ${Arch_presets} ) +fc_target_copy_resource(ImporterPythonTestData + ${CMAKE_SOURCE_DIR}/src/Mod/BIM + ${CMAKE_BINARY_DIR}/Mod/BIM + ${ImportersSample_Files}) + + IF (BUILD_GUI) fc_target_copy_resource(BIM ${CMAKE_CURRENT_BINARY_DIR} diff --git a/src/Mod/BIM/Init.py b/src/Mod/BIM/Init.py index 10f29ae49f..ee23f69fdf 100644 --- a/src/Mod/BIM/Init.py +++ b/src/Mod/BIM/Init.py @@ -33,5 +33,5 @@ FreeCAD.addExportType("JavaScript Object Notation (*.json)","importers.importJSO FreeCAD.addImportType("Collada (*.dae *.DAE)","importers.importDAE") FreeCAD.addExportType("Collada (*.dae)","importers.importDAE") FreeCAD.addImportType("3D Studio mesh (*.3ds *.3DS)","importers.import3DS") -FreeCAD.addImportType("SweetHome3D XML export (*.zip *.ZIP)","importers.importSH3D") +FreeCAD.addImportType("SweetHome3D (*.sh3d)","importers.importSH3D") FreeCAD.addImportType("Shapefile (*.shp *.SHP)","importers.importSHP") diff --git a/src/Mod/BIM/InitGui.py b/src/Mod/BIM/InitGui.py index fb104f377b..2add05b491 100644 --- a/src/Mod/BIM/InitGui.py +++ b/src/Mod/BIM/InitGui.py @@ -677,6 +677,7 @@ t = QT_TRANSLATE_NOOP("QObject", "Import-Export") FreeCADGui.addPreferencePage(":/ui/preferences-ifc.ui", t) FreeCADGui.addPreferencePage(":/ui/preferences-ifc-export.ui", t) FreeCADGui.addPreferencePage(":/ui/preferences-dae.ui", t) +FreeCADGui.addPreferencePage(":/ui/preferences-sh3d-import.ui", t) # Add unit tests FreeCAD.__unit_test__ += ["TestArch"] diff --git a/src/Mod/BIM/Resources/Arch.qrc b/src/Mod/BIM/Resources/Arch.qrc index 04568cfbba..ec428c2d43 100644 --- a/src/Mod/BIM/Resources/Arch.qrc +++ b/src/Mod/BIM/Resources/Arch.qrc @@ -268,6 +268,7 @@ ui/preferences-dae.ui ui/preferences-ifc-export.ui ui/preferences-ifc.ui + ui/preferences-sh3d-import.ui ui/preferencesNativeIFC.ui diff --git a/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui b/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui new file mode 100644 index 0000000000..79f891e788 --- /dev/null +++ b/src/Mod/BIM/Resources/ui/preferences-sh3d-import.ui @@ -0,0 +1,337 @@ + + + Gui::Dialog::DlgSettingsArch + + + + 0 + 0 + 555 + 729 + + + + SH3D import + + + + 6 + + + 9 + + + + + General options + + + + + + Show this dialog when importing + + + sh3dShowDialog + + + Mod/Arch + + + + + + + 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. + + + Show debug messages + + + sh3dDebug + + + Mod/Arch + + + + + + + + + + Import options + + + + + + Whether to import the model's doors and windows + + + Doors and Windows + + + sh3dImportDoorsAndWindows + + + Mod/Arch + + + + + + + Whether to import the model's furnitures + + + Furnitures + + + sh3dImportFurnitures + + + Mod/Arch + + + + + + + Whether to create Arch::Equipment for each furniture defined in the model (NOTE: this can negatively impact the import process speed) + + + Create Arch::Equipment + + + sh3dCreateArchEquipment + + + Mod/Arch + + + + + + + Whether to join the different Arch::Wall together + + + Join Arch::Wall + + + sh3dJoinArchWall + + + Mod/Arch + + + + + + + Whether to import the model's lights. Note that you also need to import + the model's furnitures. + + + Lights (requires Render) + + + sh3dImportLights + + + Mod/Arch + + + + + + + Whether to import the model's cameras + + + Cameras (requires Render) + + + sh3dImportCameras + + + Mod/Arch + + + + + + + Merge imported element with existing FC object + + + Merge into existing document + + + sh3dMerge + + + Mod/Arch + + + + + + + + + Default Floor Color + + + sh3dDefaultFloorColor + + + + + + + + 0 + 0 + + + + This color might be used when a room does not define its own color. + + + + 150 + 169 + 186 + + + + sh3dDefaultFloorColor + + + Mod/Arch + + + + + + + + + + + Default Ceiling Color + + + sh3dDefaultCeilingColor + + + + + + + + 0 + 0 + + + + This color might be used when a room does not define its own color. + + + + 255 + 255 + 255 + + + + sh3dDefaultCeilingColor + + + Mod/Arch + + + + + + + + + Create a default Render project with the newly Site + + + Create Render Project (requires Render) + + + sh3dCreateRenderProject + + + Mod/Arch + + + + + + + Fit view while importing. + + + Fit view while importing + + + sh3dFitView + + + Mod/Arch + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + qPixmapFromMimeSource + + + Gui::PrefSpinBox + QSpinBox +
Gui/PrefWidgets.h
+
+ + Gui::PrefCheckBox + QCheckBox +
Gui/PrefWidgets.h
+
+ + Gui::PrefComboBox + QComboBox +
Gui/PrefWidgets.h
+
+ + Gui::PrefLineEdit + QLineEdit +
Gui/PrefWidgets.h
+
+
+ + +
\ No newline at end of file diff --git a/src/Mod/BIM/TestArch.py b/src/Mod/BIM/TestArch.py index 27bbb9bff2..a8042084b1 100644 --- a/src/Mod/BIM/TestArch.py +++ b/src/Mod/BIM/TestArch.py @@ -759,6 +759,20 @@ class ArchTest(unittest.TestCase): App.ActiveDocument.recompute() assert wall.Visibility + def testImportSH3D(self): + """Import a SweetHome 3D file + """ + operation = "importers.importSH3D" + _msg(" Test '{}'".format(operation)) + import BIM.importers.importSH3DHelper + importer = BIM.importers.importSH3DHelper.SH3DImporter(None) + importer.import_sh3d_from_string(SH3D_HOME) + assert App.ActiveDocument.Project + assert App.ActiveDocument.Site + assert App.ActiveDocument.BuildingPart.Label == "Building" + assert App.ActiveDocument.BuildingPart001.Label == "Level" + assert App.ActiveDocument.Wall + def testViewGeneration(self): """Tests the whole TD view generation workflow""" @@ -804,8 +818,77 @@ class ArchTest(unittest.TestCase): view.Y = "15cm" App.ActiveDocument.recompute() assert True - def tearDown(self): App.closeDocument("ArchTest") pass + + +SH3D_HOME = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" diff --git a/src/Mod/BIM/importers/importSH3D.py b/src/Mod/BIM/importers/importSH3D.py index 01f8ef282b..5a1cc189e5 100644 --- a/src/Mod/BIM/importers/importSH3D.py +++ b/src/Mod/BIM/importers/importSH3D.py @@ -23,18 +23,13 @@ __title__ = "FreeCAD SweetHome3D Importer" __author__ = "Yorik van Havre" __url__ = "https://www.freecad.org" -import math import os -import tempfile import xml.sax import zipfile -from builtins import open as pyopen import FreeCAD -import Arch -import Draft -import Mesh -import Part +from FreeCAD import Base + ## @package importSH3D # \ingroup ARCH @@ -69,140 +64,16 @@ def insert(filename,docname): def read(filename): "reads the file and creates objects in the active document" - z = zipfile.ZipFile(filename) - homexml = z.read("Home.xml") - handler = SH3DHandler(z) - xml.sax.parseString(homexml,handler) + import BIM.importers.importSH3DHelper + if DEBUG: + from importlib import reload + reload(BIM.importers.importSH3DHelper) + + pi = Base.ProgressIndicator() + try: + importer = BIM.importers.importSH3DHelper.SH3DImporter(pi) + importer.import_sh3d_from_filename(filename) + finally: + pi.stop() + FreeCAD.ActiveDocument.recompute() - if not handler.makeIndividualWalls: - delete = [] - walls = [] - for k,lines in handler.lines.items(): - sk = FreeCAD.ActiveDocument.addObject("Sketcher::SketchObject","Walls_trace") - for l in lines: - for edge in l.Shape.Edges: - sk.addGeometry(edge.Curve) - delete.append(l.Name) - FreeCAD.ActiveDocument.recompute() - k = k.split(";") - walls.append(Arch.makeWall(baseobj=sk,width=float(k[0]),height=float(k[1]))) - for d in delete: - FreeCAD.ActiveDocument.removeObject(d) - w = walls.pop() - w.Additions = walls - w.Subtractions = handler.windows - g = FreeCAD.ActiveDocument.addObject("App::DocumentObjectGroup","Furniture") - g.Group = handler.furniture - FreeCAD.ActiveDocument.recompute() - - -class SH3DHandler(xml.sax.ContentHandler): - - def __init__(self,z): - - super().__init__() - self.makeIndividualWalls = False - self.z = z - self.windows = [] - self.furniture = [] - self.lines = {} - - def startElement(self, tag, attributes): - - if tag == "wall": - name = attributes["id"] - p1 = FreeCAD.Vector(float(attributes["xStart"])*10,float(attributes["yStart"])*10,0) - p2 = FreeCAD.Vector(float(attributes["xEnd"])*10,float(attributes["yEnd"])*10,0) - height = float(attributes["height"])*10 - thickness = float(attributes["thickness"])*10 - if DEBUG: print("Creating wall: ",name) - line = Draft.makeLine(p1,p2) - if self.makeIndividualWalls: - wall = Arch.makeWall(baseobj=line,width=thickness,height=height,name=name) - wall.Label = name - else: - self.lines.setdefault(str(thickness)+";"+str(height),[]).append(line) - - elif tag == "pieceOfFurniture": - name = attributes["name"] - data = self.z.read(attributes["model"]) - th,tf = tempfile.mkstemp(suffix=".obj") - f = pyopen(tf,"wb") - f.write(data) - f.close() - os.close(th) - m = Mesh.read(tf) - fx = (float(attributes["width"])/100)/m.BoundBox.XLength - fy = (float(attributes["height"])/100)/m.BoundBox.YLength - fz = (float(attributes["depth"])/100)/m.BoundBox.ZLength - mat = FreeCAD.Matrix() - mat.scale(1000*fx,1000*fy,1000*fz) - mat.rotateX(math.pi/2) - mat.rotateZ(math.pi) - if DEBUG: print("Creating furniture: ",name) - if "angle" in attributes: - mat.rotateZ(float(attributes["angle"])) - m.transform(mat) - os.remove(tf) - p = m.BoundBox.Center.negative() - p = p.add(FreeCAD.Vector(float(attributes["x"])*10,float(attributes["y"])*10,0)) - p = p.add(FreeCAD.Vector(0,0,m.BoundBox.Center.z-m.BoundBox.ZMin)) - m.Placement.Base = p - obj = FreeCAD.ActiveDocument.addObject("Mesh::Feature",name) - obj.Mesh = m - self.furniture.append(obj) - - elif tag == "doorOrWindow": - name = attributes["name"] - data = self.z.read(attributes["model"]) - th,tf = tempfile.mkstemp(suffix=".obj") - f = pyopen(tf,"wb") - f.write(data) - f.close() - os.close(th) - m = Mesh.read(tf) - fx = (float(attributes["width"])/100)/m.BoundBox.XLength - fy = (float(attributes["height"])/100)/m.BoundBox.YLength - fz = (float(attributes["depth"])/100)/m.BoundBox.ZLength - mat = FreeCAD.Matrix() - mat.scale(1000*fx,1000*fy,1000*fz) - mat.rotateX(math.pi/2) - m.transform(mat) - b = m.BoundBox - v1 = FreeCAD.Vector(b.XMin,b.YMin-500,b.ZMin) - v2 = FreeCAD.Vector(b.XMax,b.YMin-500,b.ZMin) - v3 = FreeCAD.Vector(b.XMax,b.YMax+500,b.ZMin) - v4 = FreeCAD.Vector(b.XMin,b.YMax+500,b.ZMin) - sub = Part.makePolygon([v1,v2,v3,v4,v1]) - sub = Part.Face(sub) - sub = sub.extrude(FreeCAD.Vector(0,0,b.ZLength)) - os.remove(tf) - shape = Arch.getShapeFromMesh(m) - if not shape: - shape=Part.Shape() - shape.makeShapeFromMesh(m.Topology,0.100000) - shape = shape.removeSplitter() - if shape: - if DEBUG: print("Creating window: ",name) - if "angle" in attributes: - shape.rotate(shape.BoundBox.Center,FreeCAD.Vector(0,0,1),math.degrees(float(attributes["angle"]))) - sub.rotate(shape.BoundBox.Center,FreeCAD.Vector(0,0,1),math.degrees(float(attributes["angle"]))) - p = shape.BoundBox.Center.negative() - p = p.add(FreeCAD.Vector(float(attributes["x"])*10,float(attributes["y"])*10,0)) - p = p.add(FreeCAD.Vector(0,0,shape.BoundBox.Center.z-shape.BoundBox.ZMin)) - if "elevation" in attributes: - p = p.add(FreeCAD.Vector(0,0,float(attributes["elevation"])*10)) - shape.translate(p) - sub.translate(p) - obj = FreeCAD.ActiveDocument.addObject("Part::Feature",name+"_body") - obj.Shape = shape - subobj = FreeCAD.ActiveDocument.addObject("Part::Feature",name+"_sub") - subobj.Shape = sub - if FreeCAD.GuiUp: - subobj.ViewObject.hide() - win = Arch.makeWindow(baseobj=obj,name=name) - win.Label = name - win.Subvolume = subobj - self.windows.append(win) - else: - print("importSH3D: Error creating shape for door/window "+name) diff --git a/src/Mod/BIM/importers/importSH3DHelper.py b/src/Mod/BIM/importers/importSH3DHelper.py new file mode 100644 index 0000000000..4d982fcfc7 --- /dev/null +++ b/src/Mod/BIM/importers/importSH3DHelper.py @@ -0,0 +1,1711 @@ +# *************************************************************************** +# * Copyright (c) 2024 Julien Masnada * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +"""Helper functions that are used by SH3D importer.""" +import math +import os +import re +import uuid +import xml.etree.ElementTree as ET +import zipfile + +import Arch +import Draft +import DraftGeomUtils +import DraftVecUtils +import draftutils.gui_utils as gui_utils +import Mesh +import numpy +import Part +from draftutils.messages import _err, _log, _msg, _wrn +from draftutils.params import get_param_arch + +import FreeCAD as App + +if App.GuiUp: + import FreeCADGui as Gui + from draftutils.translate import translate +else: + # \cond + def translate(_, text): + return text + # \endcond + +# 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"] + +try: + from Render import Camera, PointLight + from Render.project import Project + RENDER_IS_AVAILABLE = True +except : + RENDER_IS_AVAILABLE = False + +# Sometimes, the Part::Sweep creates a "twisted" sweep that +# impeeds the creation of the corresponding wall. +FIX_INVALID_SWEEP = False + +# SweetHome3D is in cm while FreeCAD is in mm +FACTOR = 10 +DEFAULT_WALL_WIDTH = 100 +TOLERANCE = float(.1) + +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) + +# The Windows lookup map. This is really brittle and a better system should +# be found. Arch.WindowPresets = ["Fixed", "Open 1-pane", "Open 2-pane", +# "Sash 2-pane", "Sliding 2-pane", "Simple door", "Glass door", +# "Sliding 4-pane", "Awning"] +# unzip -p all-windows.sh3d Home.xml | \ +# grep 'catalogId=' | \ +# sed -e 's/.*catalogId=//;s/ name=.*/: ("Open 2-pane","Window"),/' | sort -u +# unzip -p all-doors.sh3d Home.xml | \ +# grep 'catalogId=' | \ +# sed -e 's/.*catalogId=//;s/ name=.*/: ("Simple door","Door")/' | sort -u +DOOR_MODELS = { + 'eTeks#doorFrame': ("Opening only", "Opening Element"), + 'eTeks#door': ("Simple door","Door"), + 'eTeks#frontDoor': ("Simple door","Door"), + 'eTeks#garageDoor': ("Simple door","Door"), + 'eTeks#openDoor': ("Simple door","Door"), + 'eTeks#roundDoorFrame': ("Opening only", "Opening Element"), + 'eTeks#roundedDoor': ("Simple door","Door"), + 'Kator Legaz#exterior-door-01': ("Simple door","Door"), + 'Kator Legaz#exterior-door-02': ("Simple door","Door"), + 'Kator Legaz#exterior-door-03': ("Glass door","Door"), + 'Kator Legaz#exterior-door-05': ("Simple door","Door"), + 'Kator Legaz#exterior-door-07': ("Glass door","Door"), + 'Kator Legaz#screen-door': ("Simple door","Door"), + 'Scopia#door': ("Simple door","Door"), + 'Scopia#double_door_2': ("Simple door","Door"), + 'Scopia#double_door': ("Glass door","Door"), + 'Scopia#double_door_with_little_part': ("Glass door","Door"), + 'Scopia#elevator-door': ("Simple door","Door"), + 'Scopia#garage-door2': ("Simple door","Door"), + 'Scopia#garage-door': ("Simple door","Door"), + 'Scopia#glassDoor2': ("Glass door","Door"), + 'Scopia#glass_door': ("Glass door","Door"), + 'Scopia#puerta': ("Simple door","Door"), + + 'eTeks#doubleFrenchWindow126x200': ("Open 2-pane","Window"), + 'eTeks#doubleHungWindow80x122': ("Open 2-pane","Window"), + 'eTeks#doubleOutwardOpeningWindow': ("Open 2-pane","Window"), + 'eTeks#doubleWindow126x123': ("Open 2-pane","Window"), + 'eTeks#doubleWindow126x163': ("Open 2-pane","Window"), + 'eTeks#fixedTriangleWindow85x85': ("Open 2-pane","Window"), + 'eTeks#fixedWindow85x123': ("Open 2-pane","Window"), + 'eTeks#frenchWindow85x200': ("Open 2-pane","Window"), + 'eTeks#halfRoundWindow': ("Open 2-pane","Window"), + 'eTeks#roundWindow': ("Open 2-pane","Window"), + 'eTeks#sliderWindow126x200': ("Open 2-pane","Window"), + 'eTeks#window85x123': ("Open 2-pane","Window"), + 'eTeks#window85x163': ("Open 2-pane","Window"), + 'Kator Legaz#window-01': ("Open 2-pane","Window"), + 'Kator Legaz#window-08-02': ("Open 2-pane","Window"), + 'Kator Legaz#window-08': ("Open 2-pane","Window"), + 'Scopia#turn-window': ("Open 2-pane","Window"), + 'Scopia#window_2x1_medium_with_large_pane': ("Open 2-pane","Window"), + 'Scopia#window_2x1_with_sliders': ("Open 2-pane","Window"), + 'Scopia#window_2x3_arched': ("Open 2-pane","Window"), + 'Scopia#window_2x3': ("Open 2-pane","Window"), + 'Scopia#window_2x3_regular': ("Open 2-pane","Window"), + 'Scopia#window_2x4_arched': ("Open 2-pane","Window"), + 'Scopia#window_2x4': ("Open 2-pane","Window"), + 'Scopia#window_2x6': ("Open 2-pane","Window"), + 'Scopia#window_3x1': ("Open 2-pane","Window"), + 'Scopia#window_4x1': ("Open 2-pane","Window"), + 'Scopia#window_4x3_arched': ("Open 2-pane","Window"), + 'Scopia#window_4x3': ("Open 2-pane","Window"), + 'Scopia#window_4x5': ("Open 2-pane","Window"), + +} + +class SH3DImporter: + """The main class to import an SH3D file. + + As an implementation detail, note that we do not use an + xml.sax parser as the XML elements found in the SH3D file + do not follow a natural / dependency order (i.e. doors and + windows depend upon wall but are usually defined *before* + the different elements) + """ + + def __init__(self, progress_bar=None): + """Create a SH3DImporter instance to import the given SH3D file. + + Args: + progress_bar (ProgressIndicator,optional): a ProgressIndicator + called to let the User monitor the import process + """ + super().__init__() + self.filename = None + self.progress_bar = progress_bar + self.preferences = {} + self.handlers = {} + self.total_object_count = 0 + self.current_object_count = 0 + self.zip = None + self.fc_objects = {} + self.project = None + self.site = None + self.building = None + self.default_floor = None + self.floors = {} + self.walls = [] + + def import_sh3d_from_string(self, home:str): + """Import the SH3D Home from a String. + + Args: + home (str): the string containing the XML of the home + to be imported. + + Raises: + ValueError: if an invalid SH3D file is detected + """ + self._get_preferences() + self._setup_handlers() + + if self.progress_bar: + self.progress_bar.start(f"Importing SweetHome 3D Home. Please wait ...", -1) + self._import_home(ET.fromstring(home)) + + def import_sh3d_from_filename(self, filename:str): + """Import the SH3D file. + + Args: + filename (str): the filename of the SH3D file to be imported. + + Raises: + ValueError: if an invalid SH3D file is detected + """ + self.filename = filename + if App.GuiUp and get_param_arch("sh3dShowDialog"): + Gui.showPreferences("Import-Export", 7) + + self._get_preferences() + self._setup_handlers() + + if self.progress_bar: + self.progress_bar.start(f"Importing SweetHome 3D file '{self.filename}'. Please wait ...", -1) + with zipfile.ZipFile(self.filename, 'r') as zip: + self.zip = zip + entries = zip.namelist() + if "Home.xml" not in entries: + raise ValueError(f"Invalid SweetHome3D file {self.filename}: missing Home.xml") + self._import_home(ET.fromstring(zip.read("Home.xml"))) + + def _import_home(self, home): + doc = App.ActiveDocument + self.total_object_count = self._get_object_count(home) + _msg(f"Importing home '{home.get('name')}' ...") + # Create the groups to organize the different resources together + self._create_groups() + + # Get all the FreeCAD object in the active doc, in order to allow + # for merge of existing object + if self.preferences["MERGE"]: + for object in doc.Objects: + if hasattr(object, 'id'): + self.fc_objects[object.id] = object + + # Let's create the project and site for this import + self._setup_project(home) + + # Import the element if any. If none are defined + # create a default one. + if home.find(path='level') != None: + self._import_elements(home, 'level') + else: + # Has the default floor already been created from a + # previous import? + if self.preferences["DEBUG"]: _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() + self.add_floor(self.default_floor) + + # Importing elements ... + self._import_elements(home, 'room') + + # Importing elements ... + self._import_elements(home, 'wall') + + self._refresh() + if App.GuiUp and self.preferences["FIT_VIEW"]: + Gui.SendMsgToActiveView("ViewFit") + + # Importing elements ... + if self.preferences["IMPORT_DOORS_AND_WINDOWS"]: + self._import_elements(home, 'doorOrWindow') + self._refresh() + + # Importing && elements ... + if self.preferences["IMPORT_FURNITURES"]: + self._import_elements(home, 'pieceOfFurniture') + for furniture_group in home.findall('furnitureGroup'): + self._import_elements(furniture_group, 'pieceOfFurniture', False) + self._refresh() + + # Importing elements ... + if self.preferences["IMPORT_LIGHTS"]: + self._import_elements(home, 'light') + self._refresh() + + # Importing elements ... + if self.preferences["IMPORT_CAMERAS"]: + self._import_elements(home, 'observerCamera') + self._import_elements(home, 'camera') + self._refresh() + + if self.preferences["CREATE_RENDER_PROJECT"] and self.project: + Project.create(doc, renderer="Povray", template="povray_standard.pov") + Gui.Selection.clearSelection() + Gui.Selection.addSelection(self.project) + Gui.runCommand('Render_View', 0) + self._refresh() + + _msg(f"Successfully imported home '{home.get('name')}' ...") + + + def _get_object_count(self, home): + """Get an approximate count of object to be imported + """ + count = 0 + for tag in self.handlers.keys(): + count = count + len(list(home.findall(tag))) + return count + + 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, + 'IMPORT_CAMERAS': get_param_arch("sh3dImportCameras") and RENDER_IS_AVAILABLE, + 'MERGE': get_param_arch("sh3dMerge"), + 'CREATE_ARCH_EQUIPMENT': get_param_arch("sh3dCreateArchEquipment"), + 'JOIN_ARCH_WALL': get_param_arch("sh3dJoinArchWall"), + 'CREATE_RENDER_PROJECT': get_param_arch("sh3dCreateRenderProject") and RENDER_IS_AVAILABLE, + 'FIT_VIEW': get_param_arch("sh3dFitView"), + 'DEFAULT_FLOOR_COLOR': color_fc2sh(get_param_arch("sh3dDefaultFloorColor")), + 'DEFAULT_CEILING_COLOR': color_fc2sh(get_param_arch("sh3dDefaultCeilingColor")), + } + + def _setup_handlers(self): + self.handlers = { + 'level': LevelHandler(self), + 'room': RoomHandler(self), + 'wall': WallHandler(self), + } + if self.preferences["IMPORT_DOORS_AND_WINDOWS"]: + self.handlers['doorOrWindow'] = DoorOrWindowHandler(self) + + if self.preferences["IMPORT_FURNITURES"]: + self.handlers['pieceOfFurniture'] = FurnitureHandler(self) + self.handlers['furnitureGroup'] = None + + if self.preferences["IMPORT_LIGHTS"]: + self.handlers['light'] = LightHandler(self) + + if self.preferences["IMPORT_CAMERAS"]: + camera_handler = CameraHandler(self) + self.handlers['observerCamera'] = camera_handler + self.handlers['camera'] = camera_handler + + def _refresh(self): + App.ActiveDocument.recompute() + if App.GuiUp: + Gui.updateGui() + + def set_property(self, obj, type_, name, description, value, valid_values=None): + """Set the attribute of the given object as an FC property + + Note that the method has a default behavior when the value is not specified. + + Args: + obj (object): The FC object to add a property to + type_ (str): the type of property to add + name (str): the name of the property to add + description (str): a short description of the property to add + value (xml.etree.ElementTree.Element|str): The property's value. Defaults to None. + valid_values (list): an optional list of valid values + """ + + self._add_property(obj, type_, name, description) + if valid_values: + setattr(obj, name, valid_values) + if value is None: + if self.preferences["DEBUG"]:_log(f"Setting obj.{name}=None") + return + if type(value) is ET.Element: + if type_ == "App::PropertyString": + value = str(value.get(name, "")) + elif type_ == "App::PropertyFloat": + value = float(value.get(name, 0)) + elif type_ == "App::PropertyInteger": + value = int(value.get(name, 0)) + elif type_ == "App::PropertyBool": + value = bool(value.get(name, True)) + if self.preferences["DEBUG"]: + _log(f"Setting @{obj}.{name} = {value}") + setattr(obj, name, value) + + def _add_property(self, obj, property_type, name, description): + """Add an property to the FC object. + + All properties will be added under the 'SweetHome3D' group + + Args: + obj (object): TheFC object to add a property to + property_type (str): the type of property to add + name (str): the name of the property to add + description (str): a short description of the property to add + """ + if name not in obj.PropertiesList: + obj.addProperty(property_type, name, "SweetHome3D", description) + + def get_fc_object(self, id, sh_type): + """Returns the FC doc element corresponding to the imported id and sh_type + + Args: + id (str): the id of the element to lookup + sh_type (str, optional): The SweetHome type of the element to be imported. Defaults to None. + + Returns: + FCObject: The FC object that correspond to the imported SH element + """ + if self.preferences["MERGE"] and id in self.fc_objects: + 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"]: + _log(translate("BIM", f"Merging imported element '{id}' with existing element of type '{type(fc_object)}'")) + return fc_object + if self.preferences["DEBUG"]: + _log(translate("BIM", f"No element found with id '{id}' and type '{sh_type}'")) + return None + + def add_floor(self, floor): + self.floors[floor.id] = floor + self.building.addObject(floor) + + def get_floor(self, level_id): + """Returns the Floor associated with the level_id. + + Returns the first level if only one defined or level_id is None + + Args: + levels (list): The list of imported levels + level_id (string): the level @id + + Returns: + level: The level + """ + if self.default_floor or not level_id: + return self.default_floor + return self.floors.get(level_id, None) + + def add_wall(self, wall): + self.walls.append(wall) + + def _create_groups(self): + """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") + + def _setup_project(self, elm): + """Create the Arch::Project and Arch::Site for this import + + Args: + elm (str): the element + + """ + if 'Project' in self.fc_objects: + self.project = self.fc_objects.get('Project') + else: + self.project = self._create_project() + if 'Site' in self.fc_objects: + self.site = self.fc_objects.get('Site') + else: + self.site = self._create_site() + if elm.get('name') in self.fc_objects: + self.building = self.fc_objects.get(elm.get('name')) + else: + self.building = self._create_building(elm) + self.project.addObject(self.site) + self.site.addObject(self.building) + + def _create_project(self): + """Create a default Arch::Project object + """ + project = Arch.makeProject([]) + self.set_property(project, "App::PropertyString", "id", "The element's id", "Project") + return project + + def _create_site(self): + """Create a default Arch::Site object + """ + site = Arch.makeSite([]) + self.set_property(site, "App::PropertyString", "id", "The element's id", "Site") + return site + + def _create_building(self, elm): + """Create a default Arch::Building object + + Args: + elm (str): the element + + Returns: + the Arch::Building + """ + building = Arch.makeBuilding([]) + self.set_property(building, "App::PropertyString", "shType", "The element type", 'building') + self.set_property(building, "App::PropertyString", "id", "The element's id", elm.get('name')) + for property in elm.findall('property'): + name = re.sub('[^A-Za-z0-9]+', '', property.get('name')) + value = property.get('value') + self.set_property(building, "App::PropertyString", name, "", value) + return building + + def _create_default_floor(self): + """Create a default Arch::Floor object + """ + floor = Arch.makeFloor() + floor.Label = 'Level' + floor.Placement.Base.z = 0 + floor.Height = 2500 + + self.set_property(floor, "App::PropertyString", "shType", "The element type", 'level') + self.set_property(floor, "App::PropertyString", "id", "The element's id", 'Level') + self.set_property(floor, "App::PropertyFloat", "floorThickness", "The floor's slab thickness", dim_fc2sh(floor.Height)) + if self.preferences["IMPORT_FURNITURES"]: + group = floor.newObject("App::DocumentObjectGroup", "Furnitures") + self.set_property(floor, "App::PropertyString", "FurnitureGroupName", "The DocumentObjectGroup name for all furnitures in this floor", group.Name) + group = floor.newObject("App::DocumentObjectGroup", "Baseboards") + self.set_property(floor, "App::PropertyString", "BaseboardGroupName", "The DocumentObjectGroup name for all baseboards on this floor", group.Name) + + return floor + + def _import_elements(self, parent, tag, update_progress=True): + """Generic function to import a specific element. + + This function will lookup the handler registered for the elements + `tag` and then call it on each item. It also provides some update + on the whole process. + + Args: + parent (Element): the parent of the elements to be imported. + Usually the element. + tag (str): the tag of the elements to be imported. + update_progress (bool, optional): whether to update the + progress. Set to false when importing a group of elements. + Defaults to True. + """ + tags = list(self.handlers.keys()) + elements = parent.findall(tag) + if update_progress and self.progress_bar: + self.progress_bar.stop() + self.progress_bar.start(f"Step {tags.index(tag)+1}/{len(tags)}: importing {len(elements)} '{tag}' elements. Please wait ...", len(elements)) + _msg(f"Importing {len(elements)} '{tag}' elements ...") + def _process(tuple): + (i, elm) = tuple + _msg(f"Importing {tag}#{i} ({self.current_object_count + 1}/{self.total_object_count}) ...") + try: + self.handlers[tag].process(parent, i, elm) + except Exception as e: + _err(f"Failed to import <{tag}>#{i} ({elm.get('id', elm.get('name'))}):") + _err(str(e)) + if update_progress and self.progress_bar: + self.progress_bar.next() + self.current_object_count = self.current_object_count + 1 + list(map(_process, enumerate(elements))) + +class BaseHandler: + """The base class for all importers.""" + + def __init__(self, importer: SH3DImporter): + self.importer = importer + + def setp(self, obj, type_, name, description, value=None, valid_values=None): + """Set a property on the object + + Args: + obj (FreeCAD): the object on which to set the property + type_ (str): the property type + name (str): the property name + description (str): the property description + value (xml.etree.ElementTree.Element|str, optional): The + property's value. Defaults to None. + valid_values (list, optional): The property's enumerated values. + Defaults to None. + """ + self.importer.set_property(obj, type_, name, description, value, valid_values) + + def get_fc_object(self, id, sh_type): + """Returns the FC object with the specified id and sh_type + + Args: + id (str): the id of the element to lookup + sh_type (str, optional): The SweetHome type of the element to be + imported. Defaults to None. + + Returns: + FCObject: The FC object that correspond to the imported SH element + """ + return self.importer.get_fc_object(id, sh_type) + + def get_floor(self, level_id): + """Returns the Floor associated with the level_id. + + Returns the first level if there is just one level or if level_id is + None + + Args: + levels (list): The list of imported levels + level_id (string): the level @id + + Returns: + level: The level + """ + return self.importer.get_floor(level_id) + + +class LevelHandler(BaseHandler): + """A helper class to import a SH3D `` object.""" + + def __init__(self, importer: SH3DImporter): + super().__init__(importer) + + def process(self, parent, i, elm): + """Creates and returns a Arch::Floor + + Args: + i (int): the ordinal of the imported element + elm (Element): the xml element + """ + floor = None + if self.importer.preferences["MERGE"]: + floor = self.get_fc_object(elm.get("id"), 'level') + + if not floor: + floor = Arch.makeFloor() + + floor.Label = elm.get('name') + floor.Placement.Base.z = dim_sh2fc(float(elm.get('elevation'))) + floor.Height = dim_sh2fc(float(elm.get('height'))) + self._set_properties(floor, elm) + + floor.ViewObject.Visibility = elm.get('visible', 'true') == 'true' + + if self.importer.preferences["IMPORT_FURNITURES"]: + group = floor.newObject("App::DocumentObjectGroup", "Furnitures") + self.setp(floor, "App::PropertyString", "FurnitureGroupName", "The DocumentObjectGroup name for all furnitures on this floor", group.Name) + group = floor.newObject("App::DocumentObjectGroup", "Baseboards") + self.setp(floor, "App::PropertyString", "BaseboardGroupName", "The DocumentObjectGroup name for all baseboards on this floor", group.Name) + + self.importer.add_floor(floor) + + def _set_properties(self, obj, elm): + self.setp(obj, "App::PropertyString", "shType", "The element type", 'level') + self.setp(obj, "App::PropertyString", "id", "The floor's id", elm) + self.setp(obj, "App::PropertyFloat", "floorThickness", "The floor's slab thickness", dim_sh2fc(float(elm.get('floorThickness')))) + self.setp(obj, "App::PropertyInteger", "elevationIndex", "The floor number", elm) + self.setp(obj, "App::PropertyBool", "viewable", "Whether the floor is viewable", elm) + + +class RoomHandler(BaseHandler): + """A helper class to import a SH3D `` object. + + It also handles the elements found as children of the element. + """ + + def __init__(self, importer: SH3DImporter): + super().__init__(importer) + + def process(self, parent, i, elm): + """Creates and returns a Arch::Structure from the imported_room object + + Args: + i (int): the ordinal of the imported element + elm (Element): the xml element + """ + level_id = elm.get('level', None) + floor = self.get_floor(level_id) + assert floor != None, f"Missing floor '{level_id}' for '{elm.get('id')}' ..." + + points = [] + for point in elm.findall('point'): + x = float(point.get('x')) + y = float(point.get('y')) + z = dim_fc2sh(floor.Placement.Base.z) + points.append(coord_sh2fc(App.Vector(x, y, z))) + + slab = None + if self.importer.preferences["MERGE"]: + slab = self.get_fc_object(elm.get("id"), 'room') + + if not slab: + line = Draft.make_wire(points, placement=App.Placement(), closed=True, face=True, support=None) + slab = Arch.makeStructure(line, height=floor.floorThickness) + + slab.Label = elm.get('name', 'Room') + slab.IfcType = "Slab" + slab.Normal = -Z_NORM + + color = elm.get('floorColor', self.importer.preferences["DEFAULT_FLOOR_COLOR"]) + set_color_and_transparency(slab, color) + self._set_properties(slab, elm) + floor.addObject(slab) + + def _set_properties(self, obj, elm): + floor_color = elm.get('floorColor',self.importer.preferences["DEFAULT_FLOOR_COLOR"]) + ceiling_color = elm.get('ceilingColor', self.importer.preferences["DEFAULT_CEILING_COLOR"]) + + self.setp(obj, "App::PropertyString", "shType", "The element type", 'room') + self.setp(obj, "App::PropertyString", "id", "The slab's id", elm.get('id', str(uuid.uuid4()))) + self.setp(obj, "App::PropertyFloat", "nameAngle", "The room's name angle", elm) + self.setp(obj, "App::PropertyFloat", "nameXOffset", "The room's name x offset", elm) + self.setp(obj, "App::PropertyFloat", "nameYOffset", "The room's name y offset", elm) + self.setp(obj, "App::PropertyBool", "areaVisible", "Whether the area of the room is displayed in the plan view", elm) + self.setp(obj, "App::PropertyFloat", "areaAngle", "The room's area annotation angle", elm) + self.setp(obj, "App::PropertyFloat", "areaXOffset", "The room's area annotation x offset", elm) + self.setp(obj, "App::PropertyFloat", "areaYOffset", "The room's area annotation y offset", elm) + self.setp(obj, "App::PropertyBool", "floorVisible", "Whether the floor of the room is displayed", elm) + self.setp(obj, "App::PropertyString", "floorColor", "The room's floor color", floor_color) + self.setp(obj, "App::PropertyFloat", "floorShininess", "The room's floor shininess", elm) + self.setp(obj, "App::PropertyBool", "ceilingVisible", "Whether the ceiling of the room is displayed", elm) + self.setp(obj, "App::PropertyString", "ceilingColor", "The room's ceiling color", ceiling_color) + self.setp(obj, "App::PropertyFloat", "ceilingShininess", "The room's ceiling shininess", elm) + self.setp(obj, "App::PropertyBool", "ceilingFlat", "", elm) + + +class WallHandler(BaseHandler): + """A helper class to import a SH3D `` object.""" + + def __init__(self, importer: SH3DImporter): + super().__init__(importer) + self.wall_sections = {} + + def process(self, parent, i, elm): + """Creates and returns a Arch::Structure from the imported_wall object + + Args: + parent (Element): the parent Element of the wall to be imported + i (int): the ordinal of the imported element + elm (Element): the xml element + """ + level_id = elm.get('level', None) + floor = self.get_floor(level_id) + assert floor != None, f"Missing floor '{level_id}' for '{elm.get('id')}' ..." + + wall = None + if self.importer.preferences["MERGE"]: + wall = self.get_fc_object(elm.get("id"), 'wall') + + if not wall: + prev = self._get_sibling_wall(parent, elm, 'wallAtStart') + next = self._get_sibling_wall(parent, elm, 'wallAtEnd') + wall = self._create_wall(floor, prev, next, elm) + if not wall: + _log(f"No wall created for {elm.get('id')}. Skipping!") + return + + self._set_wall_colors(wall, elm) + + wall.IfcType = "Wall" + wall.Label = f"wall{i}" + + self._set_properties(wall, elm) + + floor.addObject(wall) + self.importer.add_wall(wall) + + if self.importer.preferences["IMPORT_FURNITURES"]: + App.ActiveDocument.recompute([wall]) + for baseboard in elm.findall('baseboard'): + self._import_baseboard(floor, wall, baseboard) + + def _get_sibling_wall(self, parent, wall, sibling_attribute_name): + sibling_wall_id = wall.get(sibling_attribute_name, None) + if not sibling_wall_id: + return None + sibling_wall = parent.find(f"./wall[@id='{sibling_wall_id}']") + if sibling_wall is None: + wall_id = wall.get('id') + raise ValueError(f"Invalid SweetHome3D file: wall {wall_id} reference an unknown wall {sibling_wall_id}") + return sibling_wall + + def _set_properties(self, obj, elm): + self.setp(obj, "App::PropertyString", "shType", "The element type", 'wall') + self.setp(obj, "App::PropertyString", "id", "The wall's id", elm) + self.setp(obj, "App::PropertyString", "wallAtStart", "The Id of the contiguous wall at the start of this wall", elm) + self.setp(obj, "App::PropertyString", "wallAtEnd", "The Id of the contiguous wall at the end of this wall", elm) + self.setp(obj, "App::PropertyString", "pattern", "The pattern of this wall in plan view", elm) + self.setp(obj, "App::PropertyFloat", "leftSideShininess", "The wall's left hand side shininess", elm) + self.setp(obj, "App::PropertyFloat", "rightSideShininess", "The wall's right hand side shininess", elm) + + def _create_wall(self, floor, prev, next, elm): + """Create an Arch::Structure from an SH3D Element. + + The constructed wall will either be a straight wall or a curved + wall depending on the `elm` attributes. + + Args: + floor (Arch::Structure): The floor the wall belongs to + prev (Element): the xml element for the previous sibling wall + next (Element): the xml element for the next sibling wall + elm (Element): the xml element for the wall to be imported + + Returns: + Arch::Wall: the newly created wall + """ + 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}" + + # Both the wall at start or the wall at end can be None. + prev_wall_details = self._get_wall_details(floor, prev) + next_wall_details = self._get_wall_details(floor, next) + + # Is the wall curved (i.e. arc_extent != 0) ? + if wall_details[5] != 0: + section_start, section_end, spine = self._create_curved_segment( + wall_details, + prev_wall_details, + next_wall_details) + else: + section_start, section_end, spine = self._create_straight_segment( + wall_details, + prev_wall_details, + next_wall_details) + + sweep = App.ActiveDocument.addObject('Part::Sweep') + sweep.Sections = [section_start, section_end] + sweep.Spine = spine + sweep.Solid = True + sweep.Frenet = False + section_start.Visibility = False + section_end.Visibility = False + spine.Visibility = False + App.ActiveDocument.recompute([sweep]) + # Sometimes the Part::Sweep creates a "twisted" sweep which + # result in a broken wall. The solution is to use a compound + # object based on ruled surface instead. + if FIX_INVALID_SWEEP and (sweep.Shape.isNull() or not sweep.Shape.isValid()): + _log(f"Part::Sweep for wall#{elm.get('id')} is invalid. Using ruled surface instead ...") + ruled_surface = App.ActiveDocument.addObject('Part::RuledSurface') + ruled_surface.Curve1 = section_start + ruled_surface.Curve2 = section_end + App.ActiveDocument.recompute([ruled_surface]) + _log(f"Creating compound object ...") + compound = App.ActiveDocument.addObject('Part::Compound') + compound.Links = [ruled_surface, section_start, section_end] + App.ActiveDocument.recompute([compound]) + _log(f"Creating solid ...") + solid = App.ActiveDocument.addObject("Part::Feature") + solid.Shape = Part.Solid(Part.Shell(compound.Shape.Faces)) + doc = App.ActiveDocument + doc.removeObject(compound.Label) + doc.recompute() + doc.removeObject(ruled_surface.Label) + doc.recompute() + doc.removeObject(sweep.Label) + doc.recompute() + doc.removeObject(spine.Label) + doc.recompute() + doc.removeObject(section_start.Label) + doc.removeObject(section_end.Label) + wall = Arch.makeWall(solid) + else: + wall = Arch.makeWall(sweep) + return wall + + def _get_wall_details(self, floor, elm): + """Returns the relevant element for the given wall. + + Args: + floor (Slab): the Slab the wall belongs to + elm (Element): the wall being imported + + Returns: + Vector: the wall's starting point + vector: the wall's ending point + float: the thickness + float: the wall's height at the starting point + float: the wall's height at the ending point + float: the wall's arc in degrees + """ + if elm is None: + return None + x_start = float(elm.get('xStart')) + y_start = float(elm.get('yStart')) + x_end = float(elm.get('xEnd')) + y_end = float(elm.get('yEnd')) + z = dim_fc2sh(floor.Placement.Base.z) + + thickness = dim_sh2fc(elm.get('thickness')) + arc_extent = ang_sh2fc(elm.get('arcExtent', 0)) + height_start = dim_sh2fc(elm.get('height', dim_fc2sh(floor.Height))) + height_end = dim_sh2fc(elm.get('heightAtEnd', dim_fc2sh(height_start))) + + start = coord_sh2fc(App.Vector(x_start, y_start, z)) + end = coord_sh2fc(App.Vector(x_end, y_end, z)) + + return (start, end, thickness, height_start, height_end, arc_extent) + + def _create_straight_segment(self, wall_details, prev_wall_details, next_wall_details): + """Returns the sections and spine for a straight wall. + + Args: + wall_details (tuple): the wall details for the wall being imported + prev_wall_details (tuple): the details for the previous sibling + next_wall_details (tuple): the details for the next sibling + + Returns: + Rectangle, Rectangle, spine: both section and the line for the wall + """ + (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) + + spine = Draft.makeLine(start, end) + 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 + + def _create_curved_segment(self, wall_details, prev_wall_details, next_wall_details): + """Returns the sections and spine for a curved wall. + + Args: + wall_details (tuple): the wall details for the wall being imported + prev_wall_details (tuple): the details for the previous sibling + next_wall_details (tuple): the details for the next sibling + + 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) + + a1, a2, (invert_angle, center, radius) = self._get_normal_angles(wall_details) + + 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). + if invert_angle: + spine = Draft.makeCircle(radius, placement, False, a1, a2) + else: + spine = Draft.makeCircle(radius, placement, False, a2, a1) + + 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): + """Returns a rectangular section at the specified coordinate. + + Returns a Rectangle that is then used as a section in the Part::Sweep + used to construct a wall. Depending whether the wall should be joined + with its siblings, the rectangle is either created and rotated around + the endpoint of the line that will be used as the spline of the sweep + or it is calculated as the intersection profile of the 2 walls. + + Args: + wall_details (tuple): The details of the wall + at_start (bool): indicate whether the section is for the start + point or the end point of the wall. + sibling_details (tuple): The details of the sibling wall + + Returns: + Rectangle: the section properly positioned + """ + 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. + # Calculate the left and right side of each wall + (start, end, thickness, height_start, height_end, _) = wall_details + (s_start, s_end, s_thickness, _, _, _) = sibling_details + + lside, rside = self._get_sides(start, end, thickness) + s_lside, s_rside = self._get_sides(s_start, s_end, s_thickness) + i_start, i_end = self._get_intersection_edge(lside, rside, s_lside, s_rside) + + height = height_start if at_start else height_end + 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"]: + _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]) + if self.importer.preferences["DEBUG"]: + _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) + Draft.move([section], App.Vector(-thickness/2, 0, 0)) + Draft.rotate([section], 90, ORIGIN, X_NORM) + Draft.rotate([section], z_rotation, ORIGIN, Z_NORM) + Draft.move([section], center) + + if self.importer.preferences["DEBUG"]: + App.ActiveDocument.recompute() + view = section.ViewObject + line_colors = [view.LineColor] * len(section.Shape.Edges) + for i in range(0, len(line_colors)): + line_colors[i] = hex2rgb(DEBUG_EDGES_COLORS[i%len(DEBUG_EDGES_COLORS)]) + view.LineColorArray = line_colors + point_colors = [view.PointColor] * len(section.Shape.Vertexes) + for i in range(0, len(point_colors)): + point_colors[i] = hex2rgb(DEBUG_POINT_COLORS[i%len(DEBUG_POINT_COLORS)]) + view.PointColorArray = point_colors + view.PointSize = 5 + + return section + + def _get_intersection_edge(self, lside, rside, sibling_lside, sibling_rside): + """Returns the intersection edge of the 4 input edges. + + Args: + lside (Edge): the wall left handside + rside (Edge): the wall right handside + sibling_lside (Edge): the sibling wall left handside + sibling_rside (Edge): the sibling wall right handside + + Returns: + Edge: the Edge starting at the left handsides intersection and the + the right handsides intersection. + """ + points = DraftGeomUtils.findIntersection(lside, sibling_lside, True, True) + left = points[0] if len(points) else lside.Vertexes[0].Point + points = DraftGeomUtils.findIntersection(rside, sibling_rside, True, True) + right = points[0] if len(points) else rside.Vertexes[0].Point + edge = DraftGeomUtils.edg(left, right) + return edge.Vertexes[1].Point, edge.Vertexes[0].Point + + def _get_normal_angles(self, wall_details): + """Return the angles of the normal at the endpoints of the wall. + + This method returns the normal angle of the sections that constitute + the wall sweep. These angles can then be used to create the + corresponding sections. Depending on whether the wall section is + straight or curved, the section will be calculated slightly + differently. + + Args: + wall_details (tuple): The details of the wall + + Returns: + float: the angle of the normal at the starting point + float: the angle of the normal at the ending point + bool: the angle of the normal at the ending point + 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 + + 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)) + else: + # Calculate the circle that pases through the center of both rectangle + # and has the correct angle between p1 and p2 + chord = DraftVecUtils.dist(start, end) + radius = abs(chord / (2*math.sin(arc_extent/2))) + + circles = DraftGeomUtils.circleFrom2PointsRadius(start, end, radius) + # We take the center that preserve the arc_extent orientation (in FC + # coordinate). The orientation is calculated from start to end + center = circles[0].Center + if numpy.sign(arc_extent) != numpy.sign(DraftVecUtils.angle(start-center, end-center, Z_NORM)): + invert_angle = True + center = circles[1].Center + + # radius1 and radius2 are the vector from center to start and end respectively + 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)) + + return angle_start, angle_end, (invert_angle, center, radius) + + def _get_sides(self, start, end, thickness): + """Return 2 edges corresponding to the left and right side of the wall. + + Args: + start (Vector): the wall's starting point + end (Vector): the wall's ending point + thickness (float): the wall's thickness + + Returns: + Edge: the left handside edge of the wall + Edge: the right handside edge of the wall + """ + normal = self._get_normal(start, end, start+Z_NORM) + loffset = DraftVecUtils.scale(-normal, thickness/2) + roffset = DraftVecUtils.scale(normal, thickness/2) + edge = DraftGeomUtils.edg(start, end) + lside = DraftGeomUtils.offset(edge, loffset) + rside = DraftGeomUtils.offset(edge, roffset) + if self.importer.preferences["DEBUG"]: + _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 + + def _get_normal(self, a, b, c): + """Return the normal of a plane defined by 3 points. + + NOTE: the order of your point is important as the coordinate + will go from a to b to c + + Args: + a (Vector): the first point + b (Vector): the second point + c (Vector): the third point + + Returns: + Vector: the normalized vector of the plane's normal + """ + return (b - a).cross(c - a).normalize() + + def _ps(self, section): + # Pretty print a Section in a condensed way + v = section.Shape.Vertexes + return f"[{self._pv(v[0].Point)}, {self._pv(v[1].Point)}, {self._pv(v[2].Point)}, {self._pv(v[3].Point)}]" + + def _pe(self, edge): + # Print an Edge in a condensed way + v = edge.Vertexes + return f"[{self._pv(v[0].Point)}, {self._pv(v[1].Point)}]" + + def _pv(self, vect): + # Print an Vector in a condensed way + return f"({round(getattr(vect, 'X', getattr(vect,'x')))},{round(getattr(vect, 'Y', getattr(vect,'y')))})" + + def _set_wall_colors(self, wall, elm): + """Set the `wall`'s color taken from `elm`. + + Using `ViewObject.DiffuseColor` attribute to set the different + color faces. Note that when the faces are changing (i.e. when + adding doors & windows). This will generate the wrong color + """ + topColor = elm.get('topColor', self.importer.preferences["DEFAULT_FLOOR_COLOR"]) + set_color_and_transparency(wall, topColor) + leftSideColor = hex2rgb(elm.get('leftSideColor', topColor)) + rightSideColor = hex2rgb(elm.get('rightSideColor', topColor)) + topColor = hex2rgb(topColor) + diffuse_color = [topColor, leftSideColor, topColor, rightSideColor, topColor, topColor] + if ang_sh2fc(elm.get('arcExtent', 0)) > 0: + diffuse_color = [topColor, rightSideColor, topColor, leftSideColor, topColor, topColor] + + if hasattr(wall.ViewObject, "DiffuseColor"): + wall.ViewObject.DiffuseColor = diffuse_color + + def _import_baseboard(self, floor, wall, elm): + """Creates and returns a Part::Extrusion from the imported_baseboard object + + Args: + floor (Slab): the Slab the wall belongs to + wall (Wall): the Arch wall + elm (Element): the wall being imported + + Returns: + Part::Extrusion: the newly created object + """ + wall_width = float(wall.Width) + baseboard_width = dim_sh2fc(elm.get('thickness')) + baseboard_height = dim_sh2fc(elm.get('height')) + vertexes = wall.Shape.Vertexes + + # The left side is defined as the face on the left hand side when going + # from (xStart,yStart) to (xEnd,yEnd). Assume the points are always + # created in the same order. We then have on the lefthand side the points + # 1 and 2, while on the righthand side we have the points 4 and 6 + side = elm.get('attribute') + if side == 'leftSideBaseboard': + p_start = vertexes[0].Point + p_end = vertexes[2].Point + p_normal = vertexes[4].Point + elif side == 'rightSideBaseboard': + p_start = vertexes[4].Point + p_end = vertexes[6].Point + p_normal = vertexes[0].Point + else: + raise ValueError(f"Invalid SweetHome3D file: invalid baseboard with 'attribute'={side}") + + v_normal = p_normal - p_start + v_baseboard = v_normal * (baseboard_width/wall_width) + p0 = p_start + p1 = p_end + p2 = p_end - v_baseboard + p3 = p_start - v_baseboard + + baseboard_id = f"{wall.id}-{side}" + baseboard = None + if self.importer.preferences["MERGE"]: + baseboard = self.get_fc_object(baseboard_id, 'baseboard') + + if not baseboard: + # I first add a rectangle + base = Draft.makeRectangle([p0, p1, p2, p3], face=True, support=None) + base.Visibility = False + # and then I extrude + baseboard = App.ActiveDocument.addObject('Part::Extrusion', f"{wall.Label} {side}") + baseboard.Base = base + + baseboard.DirMode = "Custom" + baseboard.Dir = Z_NORM + baseboard.DirLink = None + baseboard.LengthFwd = baseboard_height + baseboard.LengthRev = 0 + baseboard.Solid = True + baseboard.Reversed = False + baseboard.Symmetric = False + baseboard.TaperAngle = 0 + baseboard.TaperAngleRev = 0 + + set_color_and_transparency(baseboard, elm.get('color')) + + self.setp(baseboard, "App::PropertyString", "shType", "The element type", 'baseboard') + self.setp(baseboard, "App::PropertyString", "id", "The element's id", baseboard_id) + self.setp(baseboard, "App::PropertyLink", "parent", "The element parent", wall) + + if 'BaseboardGroupName' not in floor.PropertiesList: + group = floor.newObject("App::DocumentObjectGroup", "Baseboards") + self.setp(floor, "App::PropertyString", "BaseboardGroupName", "The DocumentObjectGroup name for all baseboards on this floor", group.Name) + + floor.getObject(floor.BaseboardGroupName).addObject(baseboard) + + +class BaseFurnitureHandler(BaseHandler): + """The base class for importing different class of furnitures.""" + + def __init__(self, importer: SH3DImporter): + super().__init__(importer) + + def set_furniture_common_properties(self, obj, elm): + self.setp(obj, "App::PropertyString", "id", "The furniture's id", elm) + self.setp(obj, "App::PropertyString", "name", "The furniture's name", elm) + self.setp(obj, "App::PropertyFloat", "angle", "The angle of the furniture", elm) + self.setp(obj, "App::PropertyBool", "visible", "Whether the object is visible", elm) + self.setp(obj, "App::PropertyBool", "movable", "Whether the object is movable", elm) + self.setp(obj, "App::PropertyString", "description", "The object's description", elm) + self.setp(obj, "App::PropertyString", "information", "The object's information", elm) + self.setp(obj, "App::PropertyString", "license", "The object's license", elm) + self.setp(obj, "App::PropertyString", "creator", "The object's creator", elm) + self.setp(obj, "App::PropertyBool", "modelMirrored", "Whether the object is mirrored", bool(elm.get('modelMirrored', False))) + self.setp(obj, "App::PropertyBool", "nameVisible", "Whether the object's name is visible", bool(elm.get('nameVisible', False))) + self.setp(obj, "App::PropertyFloat", "nameAngle", "The object's name angle", elm) + self.setp(obj, "App::PropertyFloat", "nameXOffset", "The object's name X offset", elm) + self.setp(obj, "App::PropertyFloat", "nameYOffset", "The object's name Y offset", elm) + self.setp(obj, "App::PropertyFloat", "price", "The object's price", elm) + + def set_piece_of_furniture_common_properties(self, obj, elm): + self.setp(obj, "App::PropertyString", "level", "The furniture's level", elm) + self.setp(obj, "App::PropertyString", "catalogId", "The furniture's catalog id", elm) + self.setp(obj, "App::PropertyFloat", "dropOnTopElevation", "", elm) + self.setp(obj, "App::PropertyString", "model", "The object's mesh file", elm) + self.setp(obj, "App::PropertyString", "icon", "The object's icon", elm) + self.setp(obj, "App::PropertyString", "planIcon", "The object's icon for the plan view", elm) + self.setp(obj, "App::PropertyString", "modelRotation", "The object's model rotation", elm) + self.setp(obj, "App::PropertyString", "modelCenteredAtOrigin", "The object's center", elm) + self.setp(obj, "App::PropertyBool", "backFaceShown", "Whether the object's back face is shown", elm) + self.setp(obj, "App::PropertyString", "modelFlags", "The object's flags", elm) + self.setp(obj, "App::PropertyFloat", "modelSize", "The object's size", elm) + self.setp(obj, "App::PropertyBool", "doorOrWindow", "Whether the object is a door or Window", bool(elm.get('doorOrWindow', False))) + self.setp(obj, "App::PropertyBool", "resizable", "Whether the object is resizable", elm) + self.setp(obj, "App::PropertyBool", "deformable", "Whether the object is deformable", elm) + self.setp(obj, "App::PropertyBool", "texturable", "Whether the object is texturable", elm) + self.setp(obj, "App::PropertyString", "staircaseCutOutShape", "", elm) + self.setp(obj, "App::PropertyFloat", "shininess", "The object's shininess", elm) + self.setp(obj, "App::PropertyFloat", "valueAddedTaxPercentage", "The object's VAT percentage", elm) + self.setp(obj, "App::PropertyString", "currency", "The object's price currency", str(elm.get('currency', 'EUR'))) + + def set_piece_of_furniture_horizontal_rotation_properties(self, obj, elm): + self.setp(obj, "App::PropertyBool", "horizontallyRotatable", "Whether the object horizontally rotatable", elm) + self.setp(obj, "App::PropertyFloat", "pitch", "The object's pitch", elm) + self.setp(obj, "App::PropertyFloat", "roll", "The object's roll", elm) + self.setp(obj, "App::PropertyFloat", "widthInPlan", "The object's width in the plan view", elm) + self.setp(obj, "App::PropertyFloat", "depthInPlan", "The object's depth in the plan view", elm) + self.setp(obj, "App::PropertyFloat", "heightInPlan", "The object's height in the plan view", elm) + + + def _get_mesh(self, elm): + model = elm.get('model') + if model not in self.importer.zip.namelist(): + raise ValueError(f"Invalid SweetHome3D file: missing model {model} for furniture {elm.get('id')}") + model_path_obj = None + try: + # Since mesh.read(model_data) does not work on BytesIO extract it first + tmp_dir = App.ActiveDocument.TransientDir + if os.path.isdir(os.path.join(tmp_dir, model)): + tmp_dir = os.path.join(tmp_dir, str(uuid.uuid4())) + model_path = self.importer.zip.extract(member=model, path=tmp_dir) + model_path_obj = model_path+".obj" + os.rename(model_path, model_path_obj) + mesh = Mesh.Mesh() + mesh.read(model_path_obj) + finally: + os.remove(model_path_obj) + return mesh + + +class DoorOrWindowHandler(BaseFurnitureHandler): + """A helper class to import a SH3D `` object.""" + + def __init__(self, importer: SH3DImporter): + super().__init__(importer) + + def process(self, parent, i, elm): + """Creates and returns a Arch::Door from the imported_door object + + Args: + i (int): the ordinal of the imported element + elm (Element): the xml element + """ + door_id = f"{elm.get('id', elm.get('name'))}-{i}" + level_id = elm.get('level', None) + floor = self.get_floor(level_id) + assert floor != None, f"Missing floor '{level_id}' for '{door_id}' ..." + + + feature = None + if self.importer.preferences["MERGE"]: + feature = self.get_fc_object(door_id, 'doorOrWindow') + + if not feature: + feature = self._create_door(floor, elm) + + assert feature != None, f"Missing feature for {door_id} ..." + + self._set_properties(feature, elm) + self.set_furniture_common_properties(feature, elm) + self.set_piece_of_furniture_common_properties(feature, elm) + self.setp(feature, "App::PropertyString", "id", "The furniture's id", door_id) + + 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::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. + # 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 whole that + # will contain the windows. The makes this calculation rather + # cumbersome. + x_center = float(elm.get('x')) + y_center = float(elm.get('y')) + z_center = float(elm.get('elevation', 0)) + z_center += dim_fc2sh(floor.Placement.Base.z) + + # 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)) + + wall_width = -DEFAULT_WALL_WIDTH + wall = self._get_wall(center) + if wall: + wall_width = wall.Width + else: + _err(f"Missing wall for {elm.get('id')}. Defaulting to width {DEFAULT_WALL_WIDTH} ...") + + width = dim_sh2fc(elm.get('width')) + depth = dim_sh2fc(elm.get('depth')) + height = dim_sh2fc(elm.get('height')) + angle = float(elm.get('angle', 0)) + mirrored = bool(elm.get('modelMirrored', False)) + + # this is the vector that allow me to go from the center to the corner + # of the bounding box. Note that the angle of the rotation is negated + # because the y axis is reversed in SweetHome3D + center2corner = App.Vector(-width/2, -wall_width/2, 0) + rotation = App.Rotation(App.Vector(0, 0, 1), math.degrees(-angle)) + center2corner = rotation.multVec(center2corner) + + corner = center.add(center2corner) + pl = App.Placement( + corner, # translation + App.Rotation(math.degrees(-angle), 0, 90), # rotation + ORIGIN # rotation@coordinate + ) + + # 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. + catalog_id = elm.get('catalogId') + (windowtype, ifc_type) = DOOR_MODELS.get(catalog_id, (None, None)) + if not windowtype: + _wrn(f"Unknown catalogId {catalog_id} for element {elm.get('id')}. Defaulting to 'Simple Door'") + (windowtype, ifc_type) = ('Simple door', 'Door') + + h1 = 10 + h2 = 10 + h3 = 0 + w1 = min(depth, wall_width) + w2 = 10 + o1 = 0 + o2 = w1 / 2 + window = Arch.makeWindowPreset(windowtype, width, height, h1, h2, h3, w1, w2, o1, o2, pl) + window.IfcType = ifc_type + if ifc_type == 'Door' and mirrored: + window.OperationType = "SINGLE_SWING_RIGHT" + + # Adjust symbol plan, Sweet Home has the opening in the opposite side by default + window.ViewObject.Proxy.invertOpening() + if mirrored: + window.ViewObject.Proxy.invertHinge() + + if wall: + window.Hosts = [wall] + return window + + def _get_wall(self, point): + """Returns the wall that contains the given point. + + Args: + point (FreeCAD.Vector): the point to test for + + Returns: + Arch::Wall: the wall that contains the given point + """ + for wall in self.importer.walls: + try: + if wall.Shape.BoundBox.isInside(point): + return wall + except FloatingPointError: + pass + return None + + +class FurnitureHandler(BaseFurnitureHandler): + """A helper class to import a SH3D `` object.""" + + def __init__(self, importer: SH3DImporter): + super().__init__(importer) + + def process(self, parent, i, elm): + """Creates and returns a Mesh from the imported_furniture object + + Args: + i (int): the ordinal of the imported element + elm (Element): the xml element + """ + furniture_id = f"{elm.get('id', elm.get('name'))}-{i}" + 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 + if self.importer.preferences["MERGE"]: + feature = self.get_fc_object(furniture_id, 'pieceOfFurniture') + + if not feature: + feature = self._create_equipment(elm) + 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) + + 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) + + floor.getObject(floor.FurnitureGroupName).addObject(feature) + + # We add the object to the list of known object that can then + # be referenced elsewhere in the SH3D model (i.e. lights). + self.importer.fc_objects[feature.id] = feature + + def _create_equipment(self, elm): + + floor = self.get_floor(elm.get('level')) + + 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)) + z = float(elm.get('elevation', 0.0)) + angle = float(elm.get('angle', 0.0)) + pitch = float(elm.get('pitch', 0.0)) # X Axis + roll = float(elm.get('roll', 0.0)) # Y Axis + name = elm.get('name') + mirrored = bool(elm.get('modelMirrored', "false") == "true") + + # The meshes are normalized, facing up. + # Center, Scale, X Rotation && Z Rotation (in FC axes), Move + mesh = self._get_mesh(elm) + bb = mesh.BoundBox + transform = App.Matrix() + transform.move(-bb.Center) + # NOTE: the model is facing up, thus y and z are inverted + transform.scale(width/bb.XLength, height/bb.YLength, depth/bb.ZLength) + transform.rotateX(math.pi/2) + transform.rotateX(-pitch) + transform.rotateY(roll) + transform.rotateZ(-angle) + level_elevation = dim_fc2sh(floor.Placement.Base.z) + distance = App.Vector(x, y, level_elevation + z + (dim_fc2sh(height) / 2)) + transform.move(coord_sh2fc(distance)) + mesh.transform(transform) + + if self.importer.preferences["CREATE_ARCH_EQUIPMENT"]: + shape = Part.Shape() + shape.makeShapeFromMesh(mesh.Topology, 0.100000) + equipment = Arch.makeEquipment(name=name) + equipment.Shape = shape + equipment.purgeTouched() + else: + equipment = App.ActiveDocument.addObject("Mesh::Feature", name) + equipment.Mesh = mesh + + return equipment + + +class LightHandler(FurnitureHandler): + """A helper class to import a SH3D `` object.""" + + def __init__(self, importer: SH3DImporter): + super().__init__(importer) + + def process(self, parent, i, elm): + """_summary_ + + Args: + i (int): the ordinal of the imported element + elm (Element): the xml element + """ + light_id = f"{elm.get('id', elm.get('name'))}-{i}" + 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) + 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))) + + # 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() + + 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) + + 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) + + App.ActiveDocument.Lights.addObject(light_source) + + +class CameraHandler(BaseHandler): + """A helper class to import a SH3D `` or `` objects.""" + + def __init__(self, handler): + super().__init__(handler) + + def process(self, parent, i, elm): + """Creates and returns a Render Camera from the imported_camera object + + Args: + i (int): the ordinal of the imported element + elm (Element): the xml element + + Returns: + object: the newly created object + """ + x = float(elm.get('x')) + y = float(elm.get('y')) + z = float(elm.get('z')) + yaw = float(elm.get('yaw')) + pitch = float(elm.get('pitch')) + + attribute = elm.get('attribute') + if attribute != "storedCamera": + _log(translate("BIM", f"Type of <{elm.tag}> #{i} is not supported: '{attribute}'. Skipping!")) + return + + camera_id = f"{attribute}-{i}" + camera = None + 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.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( + math.degrees(math.pi-yaw), 0, math.degrees(math.pi/2-pitch)) + camera.Projection = "Perspective" + camera.AspectRatio = 1.33333333 # /home/environment/@photoAspectRatio + + self._set_properties(camera, elm) + + 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"]) + self.setp(obj, "App::PropertyFloat", "yaw", "The object's yaw", elm) + self.setp(obj, "App::PropertyFloat", "pitch", "The object's pitch", elm) + self.setp(obj, "App::PropertyFloat", "time", "Unknown", elm) + self.setp(obj, "App::PropertyFloat", "fieldOfView", "The object's FOV", elm) + self.setp(obj, "App::PropertyString", "renderer", "The object's renderer", elm) + + +def dim_sh2fc(dimension): + """Convert SweetHome dimension (cm) to FreeCAD dimension (mm) + + Args: + dimension (float): The dimension in SweetHome + + Returns: + float: the FreeCAD dimension + """ + return float(dimension)*FACTOR + + +def dim_fc2sh(dimension): + """Convert FreeCAD dimension (mm) to SweetHome dimension (cm) + + Args: + dimension (float): The dimension in FreeCAD + + Returns: + float: the SweetHome dimension + """ + return float(dimension)/FACTOR + + +def coord_sh2fc(vector): + """Converts SweetHome to FreeCAD coordinate + + Args: + FreeCAD.Vector (FreeCAD.Vector): The coordinate in SweetHome + + Returns: + FreeCAD.Vector: the FreeCAD coordinate + """ + return App.Vector(vector.x*FACTOR, -vector.y*FACTOR, vector.z*FACTOR) + + +def ang_sh2fc(angle): + """Convert SweetHome angle (º) to FreeCAD angle (º) + + SweetHome angles are clockwise positive while FreeCAD are anti-clockwise + positive + + Args: + angle (float): The angle in SweetHome + + Returns: + float: the FreeCAD angle + """ + return -float(angle) + + +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) + + +def color_fc2sh(hexcode): + # 0xRRGGBBAA => AARRGGBB + hex_str = hex(int(hexcode))[2:] + return ''.join([hex_str[6:], hex_str[0:6]]) + + +def hex2rgb(hexcode): + # We might have transparency as the first 2 digit + offset = 0 if len(hexcode) == 6 else 2 + return ( + int(hexcode[offset:offset+2], 16), # Red + int(hexcode[offset+2:offset+4], 16), # Green + int(hexcode[offset+4:offset+6], 16) # Blue + ) + + +def _hex2transparency(hexcode): + return 100 - int(int(hexcode[0:2], 16) * 100 / 255) diff --git a/src/Mod/BIM/importers/samples/Sample.sh3d b/src/Mod/BIM/importers/samples/Sample.sh3d new file mode 100644 index 0000000000..44ee921335 Binary files /dev/null and b/src/Mod/BIM/importers/samples/Sample.sh3d differ diff --git a/src/Mod/Draft/draftgeoutils/arcs.py b/src/Mod/Draft/draftgeoutils/arcs.py index f99fab623b..c27766e767 100644 --- a/src/Mod/Draft/draftgeoutils/arcs.py +++ b/src/Mod/Draft/draftgeoutils/arcs.py @@ -43,7 +43,23 @@ Part = lz.LazyLoader("Part", globals(), "Part") def isClockwise(edge, ref=None): - """Return True if a circle-based edge has a clockwise direction.""" + """Return True if a circle-based edge has a clockwise direction. + + Parameters + ---------- + edge : + The edge to be analyzed. + + ref : Vector, optional + The normal around which the direction of the edge is to be determined. + Defaults to the Z normal vector. + + Returns + ------- + bool + Returns True if the edge is clockwise oriented around the ref Vector + or not. + """ if not geomType(edge) == "Circle": return True diff --git a/src/Mod/Draft/draftgeoutils/general.py b/src/Mod/Draft/draftgeoutils/general.py index 90f3279517..0323c54852 100644 --- a/src/Mod/Draft/draftgeoutils/general.py +++ b/src/Mod/Draft/draftgeoutils/general.py @@ -242,7 +242,18 @@ def hasOnlyWires(shape): def geomType(edge): - """Return the type of geometry this edge is based on.""" + """Return the type of geometry this edge is based on. + + Parameters + ---------- + edge: the edge whose `Curve` attribute is to be checked. + + Returns + ------- + str + Return the type of the edge's Curve attribute or "Unknown", + if the parameter is missing. + """ try: if isinstance(edge.Curve, (Part.LineSegment, Part.Line)): return "Line" diff --git a/src/Mod/Draft/draftgeoutils/intersections.py b/src/Mod/Draft/draftgeoutils/intersections.py index d488d6730d..592ffac572 100644 --- a/src/Mod/Draft/draftgeoutils/intersections.py +++ b/src/Mod/Draft/draftgeoutils/intersections.py @@ -49,8 +49,43 @@ def findIntersection(edge1, edge2, You can also feed 4 points instead of `edge1` and `edge2`. If `dts` is used, `Shape.section()` is used. - """ + Parameters + ---------- + edge1 + Part.Edge, Circle, Line -> the first edge. + Base::Vector3 -> the starting point of the first line. In which case + `infinite1` must also be a point. + edge2 + Part.Edge, Circle, Line -> the second edge. + Base::Vector3 -> the ending point of the second line. In which case + `infinite2` must also be a point. + the second edge. In case of a point, `infinite2` must also be a point. + infinite1 + bool, optional -> whether `edge1` should be continued to infinity. + Default to `False`. + Base::Vector3 -> if `edge1` is a point, must also be a point. + infinite2 + bool, optional -> whether `edge2` should be continued to infinity. + Default to `False`. + Base::Vector3 -> if `edge2` is a point, must also be a point. + ex1: bool, optional + In case `edge1` is a point, indicate whether the line should be + continued to infinity. Default to `False` + ex2: bool, optional + In case `edge2` is a point, indicate whether the line should be + continued to infinity. Default to `False` + dts: bool, optional + NOT_DOCUMENTED. Default to `True` + findAll: bool, optional + In case either `edge1` or `edge2` is a circle, indicates whether + to find all intersection points. Default to `False` + + Returns + ------- + list + A list of intersection points + """ def getLineIntersections(pt1, pt2, pt3, pt4, infinite1, infinite2): if pt1: # first check if we don't already have coincident endpoints diff --git a/src/Mod/Draft/draftgeoutils/offsets.py b/src/Mod/Draft/draftgeoutils/offsets.py index 89a6543843..ec9e64eefe 100644 --- a/src/Mod/Draft/draftgeoutils/offsets.py +++ b/src/Mod/Draft/draftgeoutils/offsets.py @@ -135,6 +135,21 @@ def offset(edge, vector, trim=False): and a complete circle will be returned. None if there is a problem. + + Parameters + ---------- + edge: Part.Shape + the edge to offset + vector: Base::Vector3 + the vector by which the edge is to be offset + trim: bool, optional + If `edge` is an arc and `trim` is `True`, the resulting + arc will be trimmed to the proper angle + + Returns + ------- + Part.Shape + The offset shape """ if (not isinstance(edge, Part.Shape) or not isinstance(vector, App.Vector)): diff --git a/src/Mod/Draft/draftutils/params.py b/src/Mod/Draft/draftutils/params.py index 5b4814a787..69a9ee5c99 100644 --- a/src/Mod/Draft/draftutils/params.py +++ b/src/Mod/Draft/draftutils/params.py @@ -538,7 +538,8 @@ def _get_param_dictionary(): ":/ui/preferences-archdefaults.ui", ":/ui/preferences-dae.ui", ":/ui/preferences-ifc.ui", - ":/ui/preferences-ifc-export.ui"): + ":/ui/preferences-ifc-export.ui", + ":/ui/preferences-sh3d-import.ui",): # https://stackoverflow.com/questions/14750997/load-txt-file-from-resources-in-python fd = QtCore.QFile(fnm)