diff --git a/.gitmodules b/.gitmodules index 9fbc1888bb..017cd2ae8e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -20,3 +20,6 @@ [submodule "src/ifcopenshell-python/ifcopenshell/simple_spf"] path = src/ifcopenshell-python/ifcopenshell/simple_spf url = https://github.com/IfcOpenShell/step-file-parser +[submodule "src/pyodide/demo-app/wheels"] + path = src/pyodide/demo-app/wheels + url = https://github.com/IfcOpenShell/wasm-wheels diff --git a/src/pyodide/demo-app/context.py b/src/pyodide/demo-app/context.py new file mode 100644 index 0000000000..3ad8b45464 --- /dev/null +++ b/src/pyodide/demo-app/context.py @@ -0,0 +1,339 @@ +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.api +import ifcopenshell.util.unit + +import numpy as np + +import propertygroups + +O = 0.0, 0.0, 0.0 +X = 1.0, 0.0, 0.0 +Y = 0.0, 1.0, 0.0 +Z = 0.0, 0.0, 1.0 + + +class Context: + model = None + body = None + storey = None + + def __init__(self): + self._create_empty_model() + + def clear(self): + self._create_empty_model() + + def open(self, file_content): + self.model = ifcopenshell.file.from_string(file_content) + body = [ctx for ctx in self.model.by_type('IfcGeometricRepresentationContext') if ctx.ContextIdentifier == 'Body'] + if body: + self.body = body[0] + else: + context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model") + self.body = ifcopenshell.api.run( + "context.add_context", + self.model, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=context, + ) + axis = [ctx for ctx in self.model.by_type('IfcGeometricRepresentationContext') if ctx.ContextIdentifier == 'Axis'] + if body: + self.axis = axis[0] + else: + context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model") + self.axis = ifcopenshell.api.run( + "context.add_context", + self.model, + context_type="Model", + context_identifier="Axis", + target_view="GRAPH_VIEW", + parent=context, + ) + self.storey = self.model.by_type('IfcBuildingStorey')[0] + + def _create_empty_model(self): + # Create a blank model + self.model = ifcopenshell.file() + # All projects must have one IFC Project element + project = ifcopenshell.api.run( + "root.create_entity", self.model, ifc_class="IfcProject", name="My Project" + ) + # Geometry is optional in IFC, but because we want to use geometry in this example, let's define units + # Assigning without arguments defaults to metric units + ifcopenshell.api.run("unit.assign_unit", self.model) + # Let's create a modeling geometry context, so we can store 3D geometry (note: IFC supports 2D too!) + context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model") + # In particular, in this example we want to store the 3D "body" geometry of objects, i.e. the body shape + self.body = ifcopenshell.api.run( + "context.add_context", + self.model, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=context, + ) + self.axis = ifcopenshell.api.run( + "context.add_context", + self.model, + context_type="Model", + context_identifier="Axis", + target_view="GRAPH_VIEW", + parent=context, + ) + # Create a site, building, and storey. Many hierarchies are possible. + site = ifcopenshell.api.run( + "root.create_entity", self.model, ifc_class="IfcSite", name="My Site" + ) + building = ifcopenshell.api.run( + "root.create_entity", self.model, ifc_class="IfcBuilding", name="Building A" + ) + self.storey = ifcopenshell.api.run( + "root.create_entity", + self.model, + ifc_class="IfcBuildingStorey", + name="Ground Floor", + ) + # Since the site is our top level location, assign it to the project + # Then place our building on the site, and our storey in the building + ifcopenshell.api.run( + "aggregate.assign_object", + self.model, + relating_object=project, + products=[site], + ) + ifcopenshell.api.run( + "aggregate.assign_object", + self.model, + relating_object=site, + products=[building], + ) + ifcopenshell.api.run( + "aggregate.assign_object", + self.model, + relating_object=building, + products=[self.storey], + ) + + def create_2pt_wall( + self, p1, p2, elevation, height, thickness, container, wall_type=None + ): + p1 = np.array([p1[0], p1[1]]) + p2 = np.array([p2[0], p2[1]]) + + wall = ifcopenshell.api.run("root.create_entity", self.model, ifc_class="IfcWall") + length = float(np.linalg.norm(p2 - p1)) + representation = ifcopenshell.api.run( + "geometry.add_wall_representation", + self.model, + context=self.body, + length=length, + height=height, + thickness=thickness, + ) + ifcopenshell.api.run( + "geometry.assign_representation", + self.model, + product=wall, + representation=representation, + ) + representation = ifcopenshell.api.run( + "geometry.add_axis_representation", + self.model, + context=self.axis, + axis=[(0.0, 0.0), (length, 0.0)], + ) + ifcopenshell.api.run( + "geometry.assign_representation", + self.model, + product=wall, + representation=representation, + ) + v = p2 - p1 + v = np.divide(v, float(np.linalg.norm(v)), casting="unsafe") + matrix = np.array( + [ + [v[0], -v[1], 0, p1[0]], + [v[1], v[0], 0, p1[1]], + [0, 0, 1, elevation], + [0, 0, 0, 1], + ] + ) + ifcopenshell.api.run("geometry.edit_object_placement", self.model, product=wall, matrix=matrix) + ifcopenshell.api.run( + "spatial.assign_container", + self.model, + relating_structure=container, + products=[wall], + ) + if wall_type: + ifcopenshell.api.run( + "type.assign_type", + self.model, + related_object=wall, + relating_type=wall_type, + ) + + return wall + + def get_element(self, guid): + return self.model[guid] + + def get_model(self): + return self.model + + def create_fill(self, ty, pt, wall): + if isinstance(wall, str): + wall = self.model[wall] + if not wall.is_a('IfcWall'): + raise ValueError("Only 'wall' hosts are supported") + if ty == 'door': + props = propertygroups.BIMDoorProperties() + elif ty == 'window': + props = propertygroups.BIMWindowProperties() + else: + raise ValueError("Only 'door' or 'window' fills are supported") + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.model) + body = ifcopenshell.util.representation.get_context( + self.model, "Model", "Body", "MODEL_VIEW" + ) + representation_data = props.to_dict(si_conversion=si_conversion) + representation_data["context"] = body + door_representation = ifcopenshell.api.run( + f"geometry.add_{ty}_representation", self.model, **representation_data + ) + door = ifcopenshell.api.run( + "root.create_entity", self.model, ifc_class=f"ifc{ty}" + ) + door.OverallWidth = props.overall_width / si_conversion + door.OverallHeight = props.overall_height / si_conversion + ifcopenshell.api.run( + "geometry.assign_representation", + self.model, + product=door, + representation=door_representation, + ) + ifcopenshell.api.run( + "spatial.assign_container", + self.model, + relating_structure=self.storey, + products=[door], + ) + + r = [ + r + for r in wall.Representation.Representations + if r.RepresentationIdentifier == "Axis" + ] + if not r: + raise ValueError("Axis representation is needed") + r = r[0] + axis_geometry = ifcopenshell.geom.create_shape( + ifcopenshell.geom.settings( + DIMENSIONALITY=ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS, + USE_WORLD_COORDS=True, + ), + wall, + r, + ) + vs = np.array(axis_geometry.geometry.verts).reshape((-1, 3)) + es = np.array(axis_geometry.geometry.edges).reshape((-1, 2)) + A, B = vs[es[0]] + v = B - A + P = np.zeros(3) + P[0 : len(pt)] = pt + AP = P - A + AP_dot_v = np.dot(AP, v) + v_dot_v = np.dot(v, v) + t = AP_dot_v / v_dot_v * np.linalg.norm(v) / si_conversion + + opening = ifcopenshell.api.run( + "root.create_entity", + self.model, + ifc_class="IfcOpeningElement", + predefined_type="OPENING", + name="Opening", + ) + + position_3d = None + if self.model.schema == "IFC2X3": + position_3d = self.model.createIfcAxis2Placement2D( + self.model.createIfcCartesianPoint([0.0, 0.0, 0.0]) + ) + position_2d = self.model.createIfcAxis2Placement2D( + self.model.createIfcCartesianPoint([door.OverallWidth / 2.0, 0.0]) + ) + + opening.Representation = self.model.createIfcProductDefinitionShape( + Representations=[ + self.model.createIfcRepresentation( + body, + "Body", + "SweptSolid", + Items=[ + self.model.createIfcExtrudedAreaSolid( + self.model.createIfcRectangleProfileDef( + "AREA", + None, + position_2d, + door.OverallWidth, + 1.2 / si_conversion, + ), + position_3d, + self.model.createIfcDirection((0.0, 0.0, 1.0)), + door.OverallHeight, + ) + ], + ) + ] + ) + + ifcopenshell.api.run( + "void.add_opening", self.model, opening=opening, element=wall + ) + ifcopenshell.api.run( + "void.add_filling", self.model, opening=opening, element=door + ) + + z_offsets = { + 'door': 0, + 'window': 1 + } + opening.ObjectPlacement = self.model.createIfcLocalPlacement( + wall.ObjectPlacement, + self.model.createIfcAxis2Placement3D( + self.model.createIfcCartesianPoint((float(t), 0.0, z_offsets[ty] / si_conversion)) + ), + ) + + door.ObjectPlacement = self.model.createIfcLocalPlacement( + opening.ObjectPlacement, + self.model.createIfcAxis2Placement3D( + self.model.createIfcCartesianPoint((0.0, 0.0, 0.0)) + ), + ) + + return door + + def to_obj_file(self, fn): + st = ifcopenshell.geom.settings(USE_WORLD_COORDS=True, WELD_VERTICES=False) + it = ifcopenshell.geom.iterator(st, self.model, exclude=("IfcOpeningElement",)) + sr = ifcopenshell.geom.serializers.obj( + fn, fn + ".mtl", st, ifcopenshell.geom.serializer_settings() + ) + if it.initialize(): + for el in ifcopenshell.geom.consume_iterator(it): + sr.write(el) + sr.finalize() + + +if __name__ == "__main__": + m = Context() + w1 = m.create_2pt_wall((0.0, 0.0), (4.0, 0.0), 0.0, 3.0, 0.1, m.storey) + w2 = m.create_2pt_wall((1.0, 3.0), (1.0, 0.0), 0.0, 3.0, 0.1, m.storey) + m.create_fill('door', [2, 0.0], w1) + m.create_fill('window', [1, 1.5], w2) + m.model.write("out.ifc") + m.to_obj_file("out.obj") diff --git a/src/pyodide/demo-app/index.html b/src/pyodide/demo-app/index.html new file mode 100644 index 0000000000..555b83bb99 --- /dev/null +++ b/src/pyodide/demo-app/index.html @@ -0,0 +1,405 @@ + + + + + + + + + + + +
+ +
+
+ +
+
Loading...
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/pyodide/demo-app/propertygroups.py b/src/pyodide/demo-app/propertygroups.py new file mode 100644 index 0000000000..4ba73b8efb --- /dev/null +++ b/src/pyodide/demo-app/propertygroups.py @@ -0,0 +1,152 @@ +from dataclasses import dataclass, field + +# From: src\bonsai\bonsai\bim\module\model\prop.py +# Adapted to use dataclasses instead of bpy props + +@dataclass +class BIMWindowProperties: + non_si_units_props = ("is_editing", "window_type") + window_types = ( + ("SINGLE_PANEL", "SINGLE_PANEL", ""), + ("DOUBLE_PANEL_HORIZONTAL", "DOUBLE_PANEL_HORIZONTAL", ""), + ("DOUBLE_PANEL_VERTICAL", "DOUBLE_PANEL_VERTICAL", ""), + ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_BOTTOM", ""), + ("TRIPLE_PANEL_TOP", "TRIPLE_PANEL_TOP", ""), + ("TRIPLE_PANEL_LEFT", "TRIPLE_PANEL_LEFT", ""), + ("TRIPLE_PANEL_RIGHT", "TRIPLE_PANEL_RIGHT", ""), + ("TRIPLE_PANEL_HORIZONTAL", "TRIPLE_PANEL_HORIZONTAL", ""), + ("TRIPLE_PANEL_VERTICAL", "TRIPLE_PANEL_VERTICAL", ""), + ) + + # number of panels and default mullion/transom values + window_types_panels = { + "SINGLE_PANEL": (1, ((0, 0 ), (0, 0 ))), + "DOUBLE_PANEL_HORIZONTAL": (2, ((0, 0 ), (0.45, 0 ))), + "DOUBLE_PANEL_VERTICAL": (2, ((0.3, 0 ), (0, 0 ))), + "TRIPLE_PANEL_BOTTOM": (3, ((0.3, 0 ), (0.45, 0 ))), + "TRIPLE_PANEL_TOP": (3, ((0.3, 0 ), (0.45, 0 ))), + "TRIPLE_PANEL_LEFT": (3, ((0.3, 0 ), (0.45, 0 ))), + "TRIPLE_PANEL_RIGHT": (3, ((0.3, 0 ), (0.45, 0 ))), + "TRIPLE_PANEL_HORIZONTAL": (3, ((0, 0 ), (0.3, 0.6))), + "TRIPLE_PANEL_VERTICAL": (3, ((0.2, 0.4), (0, 0 ))), + } + + is_editing: bool = False + window_type: str = "SINGLE_PANEL" + overall_height: float = 0.9 + overall_width: float = 0.6 + + # lining properties + lining_depth: float = 0.050 + lining_thickness: float = 0.050 + lining_offset: float = 0.050 + lining_to_panel_offset_x: float = 0.025 + lining_to_panel_offset_y: float = 0.025 + mullion_thickness: float = 0.050 + first_mullion_offset: float = 0.3 + second_mullion_offset: float = 0.45 + transom_thickness: float = 0.050 + first_transom_offset: float = 0.3 + second_transom_offset: float = 0.6 + + # panel properties + frame_depth: list = field(default_factory = lambda: [0.035] * 3) + frame_thickness: list = field(default_factory = lambda: [0.035] * 3) + + def to_dict(self, si_conversion=1.): + di = { + "partition_type": self.window_type, + "overall_height": self.overall_height / si_conversion, + "overall_width": self.overall_width / si_conversion, + "lining_properties": { + "LiningDepth": self.lining_depth / si_conversion, + "LiningThickness": self.lining_thickness / si_conversion, + "LiningOffset": self.lining_offset / si_conversion, + "LiningToPanelOffsetX": self.lining_to_panel_offset_x / si_conversion, + "LiningToPanelOffsetY": self.lining_to_panel_offset_y / si_conversion, + "MullionThickness": self.mullion_thickness / si_conversion, + "FirstMullionOffset": self.first_mullion_offset / si_conversion, + "SecondMullionOffset": self.second_mullion_offset / si_conversion, + "TransomThickness": self.transom_thickness / si_conversion, + "FirstTransomOffset": self.first_transom_offset / si_conversion, + "SecondTransomOffset": self.second_transom_offset / si_conversion, + }, + "panel_properties": [], + } + number_of_panels, panels_data = self.window_types_panels[self.window_type] + for panel_i in range(number_of_panels): + panel_data = { + "FrameDepth": self.frame_depth[panel_i] / si_conversion, + "FrameThickness": self.frame_thickness[panel_i] / si_conversion, + } + di["panel_properties"].append(panel_data) + return di + + +@dataclass +class BIMDoorProperties: + non_si_units_props = ("is_editing", "door_type", "panel_width_ratio") + door_types = ( + ("SINGLE_SWING_LEFT", "SINGLE_SWING_LEFT", ""), + ("SINGLE_SWING_RIGHT", "SINGLE_SWING_RIGHT", ""), + ("DOUBLE_SWING_LEFT", "DOUBLE_SWING_LEFT", ""), + ("DOUBLE_SWING_RIGHT", "DOUBLE_SWING_RIGHT", ""), + ("DOUBLE_DOOR_SINGLE_SWING", "DOUBLE_DOOR_SINGLE_SWING", ""), + ("SLIDING_TO_LEFT", "SLIDING_TO_LEFT", ""), + ("SLIDING_TO_RIGHT", "SLIDING_TO_RIGHT", ""), + ("DOUBLE_DOOR_SLIDING", "DOUBLE_DOOR_SLIDING", ""), + ) + + is_editing: bool = False + door_type: str = "SINGLE_SWING_LEFT" + overall_height: float = 2.0 + overall_width: float = 0.9 + + # lining properties + lining_depth: float = 0.050 + lining_thickness: float = 0.050 + lining_offset: float = 0.0 + lining_to_panel_offset_x: float = 0.025 + lining_to_panel_offset_y: float = 0.025 + transom_thickness: float = 0.000 + transom_offset: float = 1.525 + + casing_thickness: float = 0.075 + casing_depth: float = 0.005 + + threshold_thickness: float = 0.025 + threshold_depth: float = 0.1 + threshold_offset: float = 0.000 + + # panel properties + panel_depth: float = 0.035 + panel_width_ratio: float = 1.0 + frame_thickness: float = 0.035 + frame_depth: float = 0.035 + + def to_dict(self, si_conversion=1.): + return { + "operation_type": self.door_type, + "overall_height": self.overall_height / si_conversion, + "overall_width": self.overall_width / si_conversion, + "lining_properties": { + "LiningDepth": self.lining_depth / si_conversion, + "LiningThickness": self.lining_thickness / si_conversion, + "LiningOffset": self.lining_offset / si_conversion, + "LiningToPanelOffsetX": self.lining_to_panel_offset_x / si_conversion, + "LiningToPanelOffsetY": self.lining_to_panel_offset_y / si_conversion, + "TransomThickness": self.transom_thickness / si_conversion, + "TransomOffset": self.transom_offset / si_conversion, + "CasingThickness": self.casing_thickness / si_conversion, + "CasingDepth": self.casing_depth / si_conversion, + "ThresholdThickness": self.threshold_thickness / si_conversion, + "ThresholdDepth": self.threshold_depth / si_conversion, + "ThresholdOffset": self.threshold_offset / si_conversion, + }, + "panel_properties": { + "PanelDepth": self.panel_depth / si_conversion, + "PanelWidth": self.panel_width_ratio, + "FrameDepth": self.frame_depth / si_conversion, + "FrameThickness": self.frame_thickness / si_conversion, + }, + } diff --git a/src/pyodide/demo-app/style.css b/src/pyodide/demo-app/style.css new file mode 100644 index 0000000000..8746ab660e --- /dev/null +++ b/src/pyodide/demo-app/style.css @@ -0,0 +1,93 @@ +* { + padding: 0; + margin: 0; + box-sizing: border-box; +} + +body { + width: 100%; + height: 100vh; + + display: grid; + grid-template-columns: 80px 1fr; + grid-template-rows: 80px 1fr; + grid-template-areas: + "menu menu" + "toolbar main"; +} +.msg { + padding-top: 25vh; + text-align: center; +} +.msg { + display: none; +} +body.loading .msg { + display: block; +} +form { + display: inline-block; + padding: 1vw 5vw; + border: solid 1px #eee; + border-radius: 1vw; +} +form div { + margin-bottom: 3vw; +} +canvas { + display: block; +} +#status1 { + font-size: 150%; + font-weight: bold; +} +#branding { + position: absolute; + top: 16px; + right: 16px; + text-align: right; +} +input[type='button'] { + padding: 8px 64px; + margin-top: 3vw; +} +ul { + background: #fafafa; +} +ul, ul li { + padding: 0; + margin: 0; +} +ul li { + display: inline-block; + border: solid 1px #ccc; + cursor: pointer; + width: 64px; + height: 64px; + padding: 16px; + border-radius: 8px; + background: #eee; + font-size: 32px !important; + margin: 8px; +} +.tools li { + display: block; + float: left; +} +ul li:hover { + border-color: #aaa; + background: #ddd; +} +.menu { + grid-area: menu; +} +.toolbar { + grid-area: toolbar; +} +.toolbar li.active { + border-color: #888; + background: #ccc; +} +.main { + grid-area: main; +} \ No newline at end of file diff --git a/src/pyodide/demo-app/wheels b/src/pyodide/demo-app/wheels new file mode 160000 index 0000000000..33b437e5fd --- /dev/null +++ b/src/pyodide/demo-app/wheels @@ -0,0 +1 @@ +Subproject commit 33b437e5fd5425e606f34aff602c42034ff5e6dc