From a11c7535d80f66c363e2fd7a6803c3bd46c984b2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 30 Aug 2024 20:31:59 +1000 Subject: [PATCH 01/30] Turn fill modes (shapely / svgfill) into a dropdown to prevent selecting both and crashing --- src/bonsai/bonsai/bim/module/drawing/operator.py | 7 ++----- src/bonsai/bonsai/bim/module/drawing/prop.py | 7 +++++-- src/bonsai/bonsai/bim/module/drawing/ui.py | 3 +-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 37e8a67837..5a530990dd 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -17,7 +17,6 @@ # along with Bonsai. If not, see . import os -import re import bpy import json import time @@ -43,8 +42,6 @@ import bonsai.core.drawing as core import bonsai.bim.module.drawing.svgwriter as svgwriter import bonsai.bim.module.drawing.annotation as annotation import bonsai.bim.module.drawing.sheeter as sheeter -import bonsai.bim.module.drawing.scheduler as scheduler -import bonsai.bim.module.drawing.helper as helper import bonsai.bim.export_ifc from bonsai.bim.module.drawing.decoration import CutDecorator from bonsai.bim.module.drawing.data import DecoratorData, DrawingsData @@ -635,7 +632,7 @@ class CreateDrawing(bpy.types.Operator): self.move_projection_to_bottom(root) self.merge_linework_and_add_metadata(root) - if self.camera.data.BIMCameraProperties.calculate_shapely_surfaces: + if self.camera.data.BIMCameraProperties.fill_mode == "SHAPELY": # shapely variant group = root.find("{http://www.w3.org/2000/svg}g") nm = group.attrib["{http://www.ifcopenshell.org/ns}name"] @@ -720,7 +717,7 @@ class CreateDrawing(bpy.types.Operator): path.set("class", " ".join(list(classes))) group.insert(0, path) - if self.camera.data.BIMCameraProperties.calculate_svgfill_surfaces: + if self.camera.data.BIMCameraProperties.fill_mode == "SVGFILL": results = etree.tostring(root).decode("utf8") svg_data_1 = results from xml.dom.minidom import parseString diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 0ae5e45aac..9a631377d4 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -390,8 +390,11 @@ class DocProperties(PropertyGroup): class BIMCameraProperties(PropertyGroup): - calculate_shapely_surfaces: BoolProperty(name="Calculate Shapely Surfaces", default=False) - calculate_svgfill_surfaces: BoolProperty(name="Calculate SVGFill Surfaces", default=False) + fill_mode: EnumProperty( + items=[("NONE", "None", ""), ("SHAPELY", "Shapely", ""), ("SVGFILL", "SVGFill", "")], + default="NONE", + name="Fill Mode", + ) has_underlay: BoolProperty(name="Underlay", default=False, update=update_has_underlay) has_linework: BoolProperty(name="Linework", default=True, update=update_has_linework) has_annotation: BoolProperty(name="Annotation", default=True, update=update_has_annotation) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 9b26ad0f5b..2c2242e950 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -64,9 +64,8 @@ class BIM_PT_camera(Panel): row.prop(dprops, "should_use_annotation_cache", text="", icon="FILE_REFRESH") row = self.layout.row() - row.prop(props, "calculate_shapely_surfaces") + row.prop(props, "fill_mode") row = self.layout.row() - row.prop(props, "calculate_svgfill_surfaces") row = self.layout.row() row.prop(props, "width") From b4b2b6093543550a4dcdf7a934f0321acd3c7157 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 30 Aug 2024 20:32:54 +1000 Subject: [PATCH 02/30] New bisect-based drawing cut mode. This replaces OCC's cut (but keeps the projection HLR) with a blender bisect based cut --- .../bonsai/bim/module/drawing/operator.py | 59 ++++++++++++++++++- src/bonsai/bonsai/bim/module/drawing/prop.py | 5 ++ src/bonsai/bonsai/bim/module/drawing/ui.py | 1 + 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 5a530990dd..6a8bfe0229 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -265,8 +265,13 @@ class CreateDrawing(bpy.types.Operator): underlay_svg = self.generate_underlay(context) with profile("Generate linework"): + self.projection_mode = "FREESTYLE" + self.projection_mode = "OPENCASCADE" if tool.Drawing.is_camera_orthographic(): - linework_svg = self.generate_linework(context) + if self.projection_mode == "OPENCASCADE": + linework_svg = self.generate_linework(context) + elif self.projection_mode == "FREESTYLE": + pass with profile("Generate annotation"): if tool.Drawing.is_camera_orthographic(): @@ -543,6 +548,44 @@ class CreateDrawing(bpy.types.Operator): self.file.createIfcDirection(forward.tolist()), ) + def generate_bisect_linework(self, context: bpy.types.Context, root): + camera_matrix_i = context.scene.camera.matrix_world.inverted() + + group = root.find("{http://www.w3.org/2000/svg}g") + raw_width, raw_height = self.get_camera_dimensions() + x_offset = raw_width / 2 + y_offset = raw_height / 2 + svg_scale = self.scale * 1000 # IFC is in meters, SVG is in mm + + for obj in context.visible_objects: + if obj.type != "MESH": + continue + if not (element := tool.Ifc.get_entity(obj)): + continue + if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera): + continue + verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera) + + g = etree.SubElement(root, "{http://www.w3.org/2000/svg}g") + g.attrib["{http://www.ifcopenshell.org/ns}guid"] = element.GlobalId + g.attrib["{http://www.ifcopenshell.org/ns}name"] = element.Name or "" + + lines = [] + for edge in edges: + start = [round(o, 5) for o in (camera_matrix_i @ Vector(verts[edge[0]])).xy] + end = [round(o, 5) for o in (camera_matrix_i @ Vector(verts[edge[1]])).xy] + coords = [start, end] + d = " ".join( + [ + "L{},{}".format((x_offset + p[0]) * svg_scale, (y_offset - p[1]) * svg_scale) + for p in coords + ] + ) + d = "M{}".format(d[1:]) + path = etree.SubElement(g, "{http://www.w3.org/2000/svg}path") + path.attrib["d"] = d + group.append(g) + def generate_linework(self, context: bpy.types.Context) -> Union[str, None]: if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"): return @@ -629,8 +672,13 @@ class CreateDrawing(bpy.types.Operator): return svg_path - self.move_projection_to_bottom(root) - self.merge_linework_and_add_metadata(root) + if self.camera.data.BIMCameraProperties.cut_mode == "BISECT": + self.remove_cut_linework(root) + self.generate_bisect_linework(context, root) + self.merge_linework_and_add_metadata(root) + elif self.camera.data.BIMCameraProperties.cut_mode == "OPENCASCADE": + self.move_projection_to_bottom(root) + self.merge_linework_and_add_metadata(root) if self.camera.data.BIMCameraProperties.fill_mode == "SHAPELY": # shapely variant @@ -1007,6 +1055,11 @@ class CreateDrawing(bpy.types.Operator): except: continue + def remove_cut_linework(self, root): + for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"): + if "projection" not in el.get("class", "").split(): + el.getparent().remove(el) + def merge_linework_and_add_metadata(self, root): join_criteria = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinCriteria") if join_criteria: diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 9a631377d4..9258de76ff 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -395,6 +395,11 @@ class BIMCameraProperties(PropertyGroup): default="NONE", name="Fill Mode", ) + cut_mode: EnumProperty( + items=[("BISECT", "Bisect", ""), ("OPENCASCADE", "OpenCASCADE", "")], + default="BISECT", + name="Cut Mode", + ) has_underlay: BoolProperty(name="Underlay", default=False, update=update_has_underlay) has_linework: BoolProperty(name="Linework", default=True, update=update_has_linework) has_annotation: BoolProperty(name="Annotation", default=True, update=update_has_annotation) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 2c2242e950..33f24a235b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -66,6 +66,7 @@ class BIM_PT_camera(Panel): row = self.layout.row() row.prop(props, "fill_mode") row = self.layout.row() + row.prop(props, "cut_mode") row = self.layout.row() row.prop(props, "width") From 37baa9235aed011bb68b830331b6c67d3f634130 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 30 Aug 2024 23:51:47 +1000 Subject: [PATCH 03/30] Fix bug where a voided element by OCC will be considered non-manifold. Remove doubles with a very small distance fixes it. --- src/bonsai/bonsai/bim/module/drawing/operator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 6a8bfe0229..c237807a78 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -572,8 +572,8 @@ class CreateDrawing(bpy.types.Operator): lines = [] for edge in edges: - start = [round(o, 5) for o in (camera_matrix_i @ Vector(verts[edge[0]])).xy] - end = [round(o, 5) for o in (camera_matrix_i @ Vector(verts[edge[1]])).xy] + start = [o for o in (camera_matrix_i @ Vector(verts[edge[0]])).xy] + end = [o for o in (camera_matrix_i @ Vector(verts[edge[1]])).xy] coords = [start, end] d = " ".join( [ @@ -1035,6 +1035,7 @@ class CreateDrawing(bpy.types.Operator): bm = bmesh.new() bm.from_mesh(obj.data) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001) for edge in bm.edges: if not edge.is_manifold: bm.free() From 03935a91dae656a07285f9f25bbbd0add1fe83b7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 30 Aug 2024 23:53:02 +1000 Subject: [PATCH 04/30] Fix bug where linework merging may create polygons where there should be interior holes instead. --- .../bonsai/bim/module/drawing/operator.py | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index c237807a78..ab50ec3c9a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1124,10 +1124,34 @@ class CreateDrawing(bpy.types.Operator): if results: for path in old_paths: path.getparent().remove(path) + + # polygonize_full will create polygons for everything, including + # interior "holes". As a result we do two passes. The first pass + # records polygon interior rings. The second pass uses this to + # check if the exterior ring matches an interior ring. If it does, + # it's a hole. Skip it! + + interior_hashes = set() for result in results: for geom in result.geoms: - path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") if isinstance(geom, shapely.Polygon): + for interior in geom.interiors: + # Sorted because coordinate ordering may differ, + # and frozenset because shapely sometimes emits + # duplicate coordinates. + interior_hashes.add(hash(frozenset(sorted(interior.coords)))) + elif isinstance(geom, shapely.LineString): + path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") + d = "M" + " L".join([",".join([str(o) for o in co]) for co in geom.coords]) + " Z" + path.attrib["d"] = d + + for result in results: + for geom in result.geoms: + if isinstance(geom, shapely.Polygon): + path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") + if hash(frozenset(sorted(geom.exterior.coords))) in interior_hashes: + # This is a "hole", as its exterior perfectly matches an interior. + continue d = ( "M" + " L".join([",".join([str(o) for o in co]) for co in geom.exterior.coords[0:-1]]) @@ -1139,9 +1163,8 @@ class CreateDrawing(bpy.types.Operator): + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z" ) - elif isinstance(geom, shapely.LineString): - d = "M" + " L".join([",".join([str(o) for o in co]) for co in geom.coords]) + " Z" - path.attrib["d"] = d + path.attrib["d"] = d + # Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge. if not element.is_a("IfcWall") and not element.is_a("IfcSlab"): From 799cf473164a472b4b7129c95da02389228c1204 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 31 Aug 2024 12:58:53 +0500 Subject: [PATCH 05/30] Fix error removing a cost schedule (after 7a44f9e) #5264 It wasn't really leaving orphaned data and interrupting the process of the ifc object deletion but it was leading to confusing errors --- src/bonsai/bonsai/core/cost.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/core/cost.py b/src/bonsai/bonsai/core/cost.py index 30240792a1..90c0e6c6e6 100644 --- a/src/bonsai/bonsai/core/cost.py +++ b/src/bonsai/bonsai/core/cost.py @@ -41,8 +41,8 @@ def disable_editing_cost_schedule(cost: tool.Cost) -> None: def remove_cost_schedule(ifc: tool.Ifc, cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> None: - ifc.run("cost.remove_cost_schedule", cost_schedule=cost_schedule) cost.remove_stored_schedule_columns(cost_schedule) + ifc.run("cost.remove_cost_schedule", cost_schedule=cost_schedule) def enable_editing_cost_schedule_attributes(cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> None: From 1d62f6f73677233808bc1194a7415f773821d418 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 31 Aug 2024 18:09:08 +1000 Subject: [PATCH 06/30] Support experimental freestyle linework generation --- .../bonsai/bim/module/drawing/operator.py | 132 ++++++++++++++++-- src/bonsai/bonsai/bim/module/drawing/prop.py | 19 ++- src/bonsai/bonsai/bim/module/drawing/ui.py | 9 +- 3 files changed, 144 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index ab50ec3c9a..af3dd87bdc 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -47,6 +47,7 @@ from bonsai.bim.module.drawing.decoration import CutDecorator from bonsai.bim.module.drawing.data import DecoratorData, DrawingsData from typing import NamedTuple, List, Union, Optional, Literal from lxml import etree +from math import radians from mathutils import Vector, Color, Matrix from timeit import default_timer as timer from bonsai.bim.module.drawing.prop import RasterStyleProperty, RASTER_STYLE_PROPERTIES_EXCLUDE @@ -254,6 +255,7 @@ class CreateDrawing(bpy.types.Operator): self.svg_writer.camera_projection = tuple( self.camera.matrix_world.to_quaternion() @ Vector((0, 0, -1)) ) + self.svg_writer.calculate_scale() self.svg_writer.setup_drawing_resource_paths(self.camera_element) @@ -265,13 +267,11 @@ class CreateDrawing(bpy.types.Operator): underlay_svg = self.generate_underlay(context) with profile("Generate linework"): - self.projection_mode = "FREESTYLE" - self.projection_mode = "OPENCASCADE" if tool.Drawing.is_camera_orthographic(): - if self.projection_mode == "OPENCASCADE": + if self.camera.data.BIMCameraProperties.linework_mode == "OPENCASCADE": linework_svg = self.generate_linework(context) - elif self.projection_mode == "FREESTYLE": - pass + elif self.camera.data.BIMCameraProperties.linework_mode == "FREESTYLE": + linework_svg = self.generate_freestyle_linework(context) with profile("Generate annotation"): if tool.Drawing.is_camera_orthographic(): @@ -576,16 +576,128 @@ class CreateDrawing(bpy.types.Operator): end = [o for o in (camera_matrix_i @ Vector(verts[edge[1]])).xy] coords = [start, end] d = " ".join( - [ - "L{},{}".format((x_offset + p[0]) * svg_scale, (y_offset - p[1]) * svg_scale) - for p in coords - ] + ["L{},{}".format((x_offset + p[0]) * svg_scale, (y_offset - p[1]) * svg_scale) for p in coords] ) d = "M{}".format(d[1:]) path = etree.SubElement(g, "{http://www.w3.org/2000/svg}path") path.attrib["d"] = d group.append(g) + def generate_freestyle_linework(self, context: bpy.types.Context) -> str | None: + if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"): + return + svg_path = self.get_svg_path(cache_type="linework") + if os.path.isfile(svg_path) and self.props.should_use_linework_cache: + return svg_path + + context.scene.render.engine = "BLENDER_WORKBENCH" + context.scene.render.use_freestyle = True + context.scene.svg_export.use_svg_export = True + + linesets = context.view_layer.freestyle_settings.linesets + if len(linesets) == 1 and linesets[0].name == "LineSet": + context.view_layer.freestyle_settings.crease_angle = radians(140) + context.view_layer.freestyle_settings.use_culling = True + lineset = linesets[0] + lineset.edge_type_negation = "EXCLUSIVE" + lineset.select_silhouette = False + lineset.select_crease = False + lineset.select_border = False + lineset.select_edge_mark = False + lineset.select_contour = False + lineset.select_external_contour = False + lineset.select_material_boundary = False + lineset.select_suggestive_contour = True + lineset.select_ridge_valley = True + + edge_mesh = bpy.data.meshes.new("Temp Merged Edges") + edge_obj = bpy.data.objects.new("Temp Merged Edges", edge_mesh) + context.scene.collection.objects.link(edge_obj) + edge_bm = bmesh.new() + + visible_object_names = {obj.name for obj in bpy.context.visible_objects} + for obj in bpy.context.view_layer.objects: + is_visible = obj.name in visible_object_names + obj.hide_render = not is_visible + if ( + is_visible + and obj.type == "MESH" + and len(obj.data.edges) + and not len(obj.data.polygons) + and not obj.name.startswith("IfcAnnotation") + ): + tmp_mesh = None + try: + tmp_mesh = obj.data.copy() + tmp_mesh.transform(obj.matrix_world) + edge_bm.from_mesh(tmp_mesh) + finally: + if tmp_mesh: + bpy.data.meshes.remove(tmp_mesh) + + ret = bmesh.ops.extrude_edge_only(edge_bm, edges=edge_bm.edges) + verts_extruded = [e for e in ret["geom"] if isinstance(e, bmesh.types.BMVert)] + + cam_z = self.camera.matrix_world.to_3x3() @ self.camera.data.view_frame(scene=None)[-1].normalized() + cam_z *= 0.001 + + for v in verts_extruded: + v.co += cam_z + + edge_bm.to_mesh(edge_mesh) + edge_bm.free() + + actual_path = svg_path[0:-4] + "0001.svg" + context.scene.render.filepath = svg_path[0:-4] + bpy.ops.render.render(write_still=False) + + os.rename(actual_path, svg_path) + + bpy.data.objects.remove(edge_obj) + bpy.data.meshes.remove(edge_mesh) + + context.scene.render.use_freestyle = False + context.scene.svg_export.use_svg_export = False + + tree = etree.parse(svg_path) + root = tree.getroot() + + freestyle_width = float(root.attrib["width"]) + freestyle_height = float(root.attrib["height"]) + svg_width = self.svg_writer.width + svg_height = self.svg_writer.height + + group = root.find(".//{http://www.w3.org/2000/svg}g") + group.attrib["class"] = "projection" + + # Resize Freestyle to our proper width / height and purge all other attributes + for path in root.findall(".//{http://www.w3.org/2000/svg}path"): + for key in path.attrib: + if key == "fill": + continue + elif key != "d": + del path.attrib[key] + continue + d = path.attrib[key] + coords = d.strip().split()[1:] + new_d = "M" + for i in range(0, len(coords), 2): + x = float(coords[i][:-1]) + y = float(coords[i + 1]) + x = x / freestyle_width * svg_width + y = y / freestyle_height * svg_height + new_d += f" {x},{y}" + path.attrib["d"] = new_d + pass + + self.generate_bisect_linework(context, root) + self.merge_linework_and_add_metadata(root) + + with open(svg_path, "wb") as svg: + svg.write(etree.tostring(root)) + + return svg_path + def generate_linework(self, context: bpy.types.Context) -> Union[str, None]: if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"): return @@ -930,7 +1042,6 @@ class CreateDrawing(bpy.types.Operator): group = root.findall(".//{http://www.w3.org/2000/svg}g")[0] - self.svg_writer.calculate_scale() x_offset = self.svg_writer.raw_width / 2 y_offset = self.svg_writer.raw_height / 2 @@ -1165,7 +1276,6 @@ class CreateDrawing(bpy.types.Operator): ) path.attrib["d"] = d - # Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge. if not element.is_a("IfcWall") and not element.is_a("IfcSlab"): continue diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 9258de76ff..e67c8a908f 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -390,13 +390,28 @@ class DocProperties(PropertyGroup): class BIMCameraProperties(PropertyGroup): + linework_mode: EnumProperty( + items=[ + ("OPENCASCADE", "OpenCASCADE", "Slower, more accurate, with more features"), + ("FREESTYLE", "Freestyle", "Faster, less accurate, no fill support"), + ], + default="OPENCASCADE", + name="Linework Mode", + ) fill_mode: EnumProperty( - items=[("NONE", "None", ""), ("SHAPELY", "Shapely", ""), ("SVGFILL", "SVGFill", "")], + items=[ + ("NONE", "None", "Disable filling areas seen in projection"), + ("SHAPELY", "Shapely", "Recommended"), + ("SVGFILL", "SVGFill", "Experimental"), + ], default="NONE", name="Fill Mode", ) cut_mode: EnumProperty( - items=[("BISECT", "Bisect", ""), ("OPENCASCADE", "OpenCASCADE", "")], + items=[ + ("BISECT", "Bisect", "Faster, more forgiving to bad geometry"), + ("OPENCASCADE", "OpenCASCADE", "More technically correct"), + ], default="BISECT", name="Cut Mode", ) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 33f24a235b..bc0dc22744 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -64,9 +64,12 @@ class BIM_PT_camera(Panel): row.prop(dprops, "should_use_annotation_cache", text="", icon="FILE_REFRESH") row = self.layout.row() - row.prop(props, "fill_mode") - row = self.layout.row() - row.prop(props, "cut_mode") + row.prop(props, "linework_mode") + if props.linework_mode == "OPENCASCADE": + row = self.layout.row() + row.prop(props, "fill_mode") + row = self.layout.row() + row.prop(props, "cut_mode") row = self.layout.row() row.prop(props, "width") From eed603bbcd38e59db4236e8974420f1d2e4a1132 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 31 Aug 2024 18:09:54 +1000 Subject: [PATCH 07/30] Bump IOS --- src/bonsai/Makefile | 2 +- src/ifcopenshell-python/Makefile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 608af5a235..524ac9f91c 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -81,7 +81,7 @@ BLENDER_PLATFORM:=windows-x64 endif # Current build commit hash. -OLD:=7e6607a +OLD:=03935a9 .PHONY: bump bump: cd . && $(SED) -b "s/$(OLD)/$(NEW)/" Makefile diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 9dcbebc906..7cfadb02dd 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -52,8 +52,8 @@ ifeq ($(PLATFORM), win64) PLATFORMTAG:=win_amd64 endif -IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v0.7.11-7e6607a-$(PLATFORM).zip -IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.7.11-7e6607a-$(PLATFORM).zip +IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v0.7.11-03935a9-$(PLATFORM).zip +IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.7.11-03935a9-$(PLATFORM).zip .PHONY: test test: From dac1aaa7f342c1120f912731d6534b2644d449f9 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 31 Aug 2024 13:25:11 +0500 Subject: [PATCH 08/30] Errors adding roof, stair, railing types #5261 It wasn't considering that type object can be hidden and can't be active. Removed obj property since it can be covered with just temp_override. --- src/bonsai/bonsai/bim/module/model/door.py | 10 +++++----- src/bonsai/bonsai/bim/module/model/window.py | 10 +++++----- src/bonsai/bonsai/bim/module/type/operator.py | 20 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 4e9a826104..1483b47482 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -40,7 +40,8 @@ import json import collections -def update_door_modifier_representation(context: bpy.types.Context, obj: bpy.types.Object) -> None: +def update_door_modifier_representation(context: bpy.types.Context) -> None: + obj = context.active_object props = obj.BIMDoorProperties element = tool.Ifc.get_entity(obj) ifc_file = tool.Ifc.get() @@ -534,10 +535,9 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_door" bl_label = "Add Door" bl_options = {"REGISTER"} - obj: bpy.props.StringProperty() def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMDoorProperties @@ -558,7 +558,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Data": tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))}, ) - update_door_modifier_representation(context, obj) + update_door_modifier_representation(context) return {"FINISHED"} @@ -612,7 +612,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = False - update_door_modifier_representation(context, obj) + update_door_modifier_representation(context) pset = tool.Pset.get_element_pset(element, "BBIM_Door") door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list)) diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index 257a2e1349..6e35896814 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -36,7 +36,8 @@ from bmesh.types import BMVert from mathutils import Vector -def update_window_modifier_representation(context, obj): +def update_window_modifier_representation(context): + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMWindowProperties ifc_file = tool.Ifc.get() @@ -427,10 +428,9 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_window" bl_label = "Add Window" bl_options = {"REGISTER"} - obj: bpy.props.StringProperty() def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMWindowProperties @@ -450,7 +450,7 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Data": tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))}, ) - update_window_modifier_representation(context, obj) + update_window_modifier_representation(context) return {"FINISHED"} @@ -502,7 +502,7 @@ class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = False - update_window_modifier_representation(context, obj) + update_window_modifier_representation(context) pset = tool.Pset.get_element_pset(element, "BBIM_Window") window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list)) diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 05f4d0ff54..b072f0fd67 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -414,8 +414,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class="IfcWindowType" if tool.Ifc.get_schema() != "IFC2X3" else "IfcWindowStyle", should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_window(obj=obj.name) + with context.temp_override(active_object=obj): + bpy.ops.bim.add_window() elif template == "DOOR": mesh = bpy.data.meshes.new(name) @@ -429,8 +429,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class="IfcDoorType" if tool.Ifc.get_schema() != "IFC2X3" else "IfcDoorStyle", should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_door(obj=obj.name) + with context.temp_override(active_object=obj): + bpy.ops.bim.add_door() elif template == "STAIR": mesh = bpy.data.meshes.new(name) @@ -444,8 +444,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class=ifc_class, should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_stair() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_stair() elif template == "RAILING": mesh = bpy.data.meshes.new(name) @@ -460,8 +460,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): should_add_representation=True, context=body, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_railing() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_railing() elif template == "ROOF": mesh = bpy.data.meshes.new(name) @@ -476,8 +476,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): should_add_representation=True, context=body, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_roof() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_roof() bpy.ops.bim.load_type_thumbnails(ifc_class=ifc_class) props.type_class = props.type_class From 50cb09aac233ab8cdc447c219578b58f966cf834 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 31 Aug 2024 20:56:10 +1000 Subject: [PATCH 09/30] See #3880. Freestyle support for perspective vector linework. --- src/bonsai/bonsai/bim/module/drawing/operator.py | 8 ++++++-- src/bonsai/bonsai/bim/module/drawing/ui.py | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index af3dd87bdc..f0aaa538e6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -272,6 +272,8 @@ class CreateDrawing(bpy.types.Operator): linework_svg = self.generate_linework(context) elif self.camera.data.BIMCameraProperties.linework_mode == "FREESTYLE": linework_svg = self.generate_freestyle_linework(context) + elif self.camera.data.BIMCameraProperties.linework_mode == "FREESTYLE": + linework_svg = self.generate_freestyle_linework(context) with profile("Generate annotation"): if tool.Drawing.is_camera_orthographic(): @@ -651,7 +653,7 @@ class CreateDrawing(bpy.types.Operator): context.scene.render.filepath = svg_path[0:-4] bpy.ops.render.render(write_still=False) - os.rename(actual_path, svg_path) + os.replace(actual_path, svg_path) bpy.data.objects.remove(edge_obj) bpy.data.meshes.remove(edge_mesh) @@ -1813,7 +1815,9 @@ class ActivateDrawing(bpy.types.Operator): if ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "HasUnderlay"): bpy.ops.bim.reload_drawing_styles() bpy.ops.bim.activate_drawing_style() - core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing)) + + if tool.Drawing.is_camera_orthographic(): + core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing)) CutDecorator.install(context) tool.Drawing.show_decorations() diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index bc0dc22744..7f7651ea5b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -70,6 +70,9 @@ class BIM_PT_camera(Panel): row.prop(props, "fill_mode") row = self.layout.row() row.prop(props, "cut_mode") + elif not hasattr(context.scene, "svg_export"): + row = self.layout.row() + row.label(text="Freestyle SVG Exporter Not Installed", icon="ERROR") row = self.layout.row() row.prop(props, "width") From 76ba3947f5a44d725b79033b9d2a6d7f72002bff Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 30 Aug 2024 21:10:01 +0500 Subject: [PATCH 10/30] Expose header properties with invalid values #5246 --- src/ifcparse/IfcSpfHeader.h | 4 ++++ src/ifcwrap/IfcParseWrapper.i | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/ifcparse/IfcSpfHeader.h b/src/ifcparse/IfcSpfHeader.h index f07faeacf9..f71dacfa68 100644 --- a/src/ifcparse/IfcSpfHeader.h +++ b/src/ifcparse/IfcSpfHeader.h @@ -52,6 +52,10 @@ class IFC_PARSE_API HeaderEntity { return data_.size(); } + AttributeValue getArgument(size_t index) const { + return data_.get_attribute_value(index); + } + std::string toString(bool upper = false) const { std::stringstream stream; stream << datatype_; diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i index 3a30620670..117041a6e7 100644 --- a/src/ifcwrap/IfcParseWrapper.i +++ b/src/ifcwrap/IfcParseWrapper.i @@ -520,6 +520,26 @@ static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClas } } +// Expose FileDescription and FileName header entities +// to make them readable even if they were not filled properly before. +// Though it is invalid IFC, technically. +// FileSchema is not exposed as IFC file won't load if it's invalid. + +%extend IfcParse::FileDescription { + AttributeValue description() const { return $self->getArgument(0); } + AttributeValue implementation_level() const { return $self->getArgument(1); } +}; + +%extend IfcParse::FileName { + AttributeValue name() const { return $self->getArgument(0); } + AttributeValue time_stamp() const { return $self->getArgument(1); } + AttributeValue author() const { return $self->getArgument(2); } + AttributeValue organization() const { return $self->getArgument(3); } + AttributeValue preprocessor_version() const { return $self->getArgument(4); } + AttributeValue originating_system() const { return $self->getArgument(5); } + AttributeValue authorization() const { return $self->getArgument(6); } +}; + %extend IfcParse::IfcSpfHeader { %pythoncode %{ # Hide the getters with read-only property implementations From c93e52089be0652f1e6b4f3a21dd63d2054b0f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Thu, 29 Aug 2024 17:41:59 -0300 Subject: [PATCH 11/30] Fix input calculation and conversion for wall polytool --- .../bonsai/bim/module/model/decorator.py | 53 ++++++++----------- src/bonsai/bonsai/bim/module/model/wall.py | 19 ++++++- src/bonsai/bonsai/tool/snap.py | 11 ++-- 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 5c810a247c..c4ff7b8f5e 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -375,9 +375,8 @@ class PolylineDecorator: default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z last_point_data = polyline_data[len(polyline_data) - 1] except: - last_point_data = None - second_to_last_point_data = None default_container_elevation = 0 + last_point_data = None snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] @@ -411,12 +410,12 @@ class PolylineDecorator: if distance > 0: angle = tool.Cad.angle_3_vectors(snap_vector, last_point, second_to_last_point, degrees=True) if cls.input_panel: - cls.input_panel["X"] = str(round(snap_vector.x, 4)) - cls.input_panel["Y"] = str(round(snap_vector.y, 4)) + cls.input_panel["X"] = str(round(snap_vector.x, 3)) + cls.input_panel["Y"] = str(round(snap_vector.y, 3)) if "Z" in list(cls.input_panel.keys()): - cls.input_panel["Z"] = str(round(snap_vector.z, 4)) - cls.input_panel["D"] = str(round(distance, 4)) - cls.input_panel["A"] = str(round(angle, 4)) + cls.input_panel["Z"] = str(round(snap_vector.z, 3)) + cls.input_panel["D"] = str(round(distance, 3)) + cls.input_panel["A"] = str(round(angle, 3)) return cls.input_panel @@ -507,10 +506,10 @@ class PolylineDecorator: y = coords[1] z = coords[2] if cls.input_panel: - cls.input_panel["X"] = str(round(x, 4)) - cls.input_panel["Y"] = str(round(y, 4)) + cls.input_panel["X"] = str(round(x, 3)) + cls.input_panel["Y"] = str(round(y, 3)) if "Z" in list(cls.input_panel.keys()): - cls.input_panel["Z"] = str(round(z, 4)) + cls.input_panel["Z"] = str(round(z, 3)) return cls.input_panel @@ -522,9 +521,8 @@ class PolylineDecorator: shader.uniform_float("color", color) batch.draw(shader) - def draw_input_panel(self, context): - texts = {"D": "Distance:", "A": "Angle:", "X": "X coord:", "Y": "Y coord:", "Z": "Z coord:", "AREA": "Area:"} - + @classmethod + def format_input_panel_units(cls, context, value): unit_system = tool.Drawing.get_unit_system() if unit_system == "IMPERIAL": precision = context.scene.DocProperties.imperial_precision @@ -532,6 +530,15 @@ class PolylineDecorator: else: precision = None factor = 1 + if context.scene.unit_settings.length_unit == "MILLIMETERS": + factor = 1000 + + return format_distance( + value * factor, precision=precision, suppress_zero_inches=True, in_unit_length=True + ) + + def draw_input_panel(self, context): + texts = {"D": "Distance: ", "A": "Angle: ", "X": "X coord: ", "Y": "Y coord: ", "Z": "Z coord:", "AREA": "Area: "} self.addon_prefs = tool.Blender.get_addon_preferences() self.font_id = 0 @@ -546,11 +553,7 @@ class PolylineDecorator: if key != "A" and key != self.input_type: value = float(value) - if context.scene.unit_settings.length_unit == "MILLIMETERS": - value = value * 1000 - formatted_value = format_distance( - value * factor, precision=precision, suppress_zero_inches=True, in_unit_length=True - ) + formatted_value = self.format_input_panel_units(context, value) else: formatted_value = value @@ -582,21 +585,9 @@ class PolylineDecorator: pos_dim = (Vector(measurement_prop[i].position) + Vector(measurement_prop[i - 1].position)) / 2 coords_dim = view3d_utils.location_3d_to_region_2d(region, rv3d, pos_dim) - unit_system = tool.Drawing.get_unit_system() - if unit_system == "IMPERIAL": - precision = context.scene.DocProperties.imperial_precision - factor = 3.28084 - else: - precision = None - factor = 1 - value = measurement_prop[i].dim value = float(value) - if context.scene.unit_settings.length_unit == "MILLIMETERS": - value = value * 1000 - formatted_value = format_distance( - value * factor, precision=precision, suppress_zero_inches=True, in_unit_length=True - ) + formatted_value = self.format_input_panel_units(context, value) blf.position(self.font_id, coords_dim[0], coords_dim[1], 0) blf.draw(self.font_id, "d: " + formatted_value) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 1c54a0ab34..83c594c177 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -429,7 +429,15 @@ class DrawPolylineWall(bpy.types.Operator): index = self.input_options.index(self.input_type) size = len(self.input_options) self.input_type = self.input_options[((index + 1) % size)] - self.number_input = [] + + self.number_input = self.input_panel[self.input_type] + self.number_input = list(self.number_input) + self.number_output = "".join(self.number_input) + if self.input_type != "A": + self.number_output = PolylineDecorator.format_input_panel_units(context, float(self.number_output)) + + self.input_panel[self.input_type] = self.number_output + PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() @@ -437,7 +445,13 @@ class DrawPolylineWall(bpy.types.Operator): self.recalculate_inputs(context) self.is_input_on = True self.input_type = "D" - self.number_input = [] + + self.number_input = self.input_panel[self.input_type] + self.number_input = list(self.number_input) + self.number_output = "".join(self.number_input) + self.number_output = PolylineDecorator.format_input_panel_units(context, float(self.number_output)) + self.input_panel[self.input_type] = self.number_output + PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() @@ -507,6 +521,7 @@ class DrawPolylineWall(bpy.types.Operator): if self.is_input_on: if event.value == "RELEASE" and event.type in {"ESC"}: + self.recalculate_inputs(context) self.is_input_on = False self.input_type = "OFF" PolylineDecorator.set_input_panel(self.input_panel, self.input_type) diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 64bc3e9e17..0248d4d0ab 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -524,14 +524,14 @@ class Snap(bonsai.core.tool.Snap): def validate_input(cls, input_number, input_type): grammar_imperial = """ - start: FORMULA? dim expr? + start: (FORMULA dim expr) | dim dim: imperial FORMULA: "=" imperial: feet? "-"? inches? - feet: NUMBER? " "? fraction? "'" - inches: NUMBER? " "? fraction? "\\"" + feet: NUMBER? "-"? fraction? "'" + inches: NUMBER? "-"? fraction? "\\"" fraction: NUMBER "/" NUMBER expr: (ADD | SUB) dim | (MUL | DIV) NUMBER @@ -583,7 +583,10 @@ class Snap(bonsai.core.tool.Snap): def imperial(self, args): if len(args) > 1: - result = args[0] + args[1] + if args[0] <= 0: + result = args[0] - args[1] + else: + result = args[0] + args[1] else: result = args[0] return result From b6f80eabb42037d14f43ed2a2bf3a00db7164281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Fri, 30 Aug 2024 11:16:36 -0300 Subject: [PATCH 12/30] Fix input calculation and conversion for measure tool --- .../bonsai/bim/module/project/operator.py | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index d6de9a4dac..caec2a0c6f 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2352,10 +2352,13 @@ class MeasureTool(bpy.types.Operator): if self.input_type in {"X", "Y", "Z"}: self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) elif self.input_type in {"D", "A"}: + print("!") self.input_panel = PolylineDecorator.calculate_x_y_and_z(context) - - self.input_panel[self.input_type] = self.number_output - + print("!", self.input_panel) + self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) + print("!2", self.input_panel) + else: + self.input_panel[self.input_type] = self.number_output tool.Blender.update_viewport() return is_valid @@ -2382,7 +2385,6 @@ class MeasureTool(bpy.types.Operator): self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) PolylineDecorator.set_mouse_position(event) self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() return {"RUNNING_MODAL"} @@ -2416,7 +2418,15 @@ class MeasureTool(bpy.types.Operator): index = self.input_options.index(self.input_type) size = len(self.input_options) self.input_type = self.input_options[((index + 1) % size)] - self.number_input = [] + + self.number_input = self.input_panel[self.input_type] + self.number_input = list(self.number_input) + self.number_output = "".join(self.number_input) + if self.input_type != "A": + self.number_output = PolylineDecorator.format_input_panel_units(context, float(self.number_output)) + + self.input_panel[self.input_type] = self.number_output + PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() @@ -2424,7 +2434,13 @@ class MeasureTool(bpy.types.Operator): self.recalculate_inputs(context) self.is_input_on = True self.input_type = "D" - self.number_input = [] + + self.number_input = self.input_panel[self.input_type] + self.number_input = list(self.number_input) + self.number_output = "".join(self.number_input) + self.number_output = PolylineDecorator.format_input_panel_units(context, float(self.number_output)) + self.input_panel[self.input_type] = self.number_output + PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() @@ -2436,11 +2452,12 @@ class MeasureTool(bpy.types.Operator): PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() - if event.value == "PRESS" and event.type in {"D", "A"} and not event.shift: + if event.value == "RELEASE" and event.type in {"D", "A"}: self.recalculate_inputs(context) self.is_input_on = True self.input_type = event.type self.number_input = [] + self.input_panel[self.input_type] = "" PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() @@ -2464,6 +2481,12 @@ class MeasureTool(bpy.types.Operator): PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() + if not self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: + PolylineDecorator.uninstall() + tool.Snap.clear_polyline() + tool.Blender.update_viewport() + return {"FINISHED"} + if self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: is_valid = self.recalculate_inputs(context) if is_valid: @@ -2507,6 +2530,7 @@ class MeasureTool(bpy.types.Operator): if self.is_input_on: if event.value == "RELEASE" and event.type in {"ESC"}: + self.recalculate_inputs(context) self.is_input_on = False self.input_type = "OFF" PolylineDecorator.set_input_panel(self.input_panel, self.input_type) From 2e80363bacf303f720101207be8c3fbdf2577b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Fri, 30 Aug 2024 11:16:58 -0300 Subject: [PATCH 13/30] improved general input calculations --- .../bonsai/bim/module/model/decorator.py | 43 ++++++++++++------- src/bonsai/bonsai/tool/cad.py | 16 +++---- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index c4ff7b8f5e..61c52a9967 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -386,9 +386,15 @@ class PolylineDecorator: last_point = Vector((0, 0, 0)) if is_input_on: - snap_vector = Vector( - (float(cls.input_panel["X"]), float(cls.input_panel["Y"]), default_container_elevation) - ) + if cls.use_default_container: + print("FOI") + snap_vector = Vector( + (float(cls.input_panel["X"]), float(cls.input_panel["Y"]), default_container_elevation) + ) + else: + snap_vector = Vector( + (float(cls.input_panel["X"]), float(cls.input_panel["Y"]), float(cls.input_panel["Z"])) + ) else: if cls.use_default_container: snap_vector = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) @@ -404,7 +410,7 @@ class PolylineDecorator: else: # Creates a fake "second to last" point away from the first point but in the same x axis # this allows to calculate the angle relative to x axis when there is only one point - second_to_last_point = Vector((last_point.x - 1000, last_point.y, last_point.z)) + second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) distance = (snap_vector - last_point).length if distance > 0: @@ -475,7 +481,7 @@ class PolylineDecorator: snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) - second_to_last_point = None + if len(polyline_data) > 1: second_to_last_point_data = polyline_data[len(polyline_data) - 2] second_to_last_point = Vector( @@ -484,23 +490,27 @@ class PolylineDecorator: else: # Creates a fake "second to last" point away from the first point but in the same x axis # this allows to calculate the angle relative to x axis when there is only one point - second_to_last_point = Vector((last_point.x - 10, last_point.y, last_point.z)) + second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) distance = float(cls.input_panel["D"]) if distance < 0 or distance > 0: - angle_rad = radians(180 - float(cls.input_panel["A"])) - ref_vec = second_to_last_point - last_point - dir_vec = last_point - snap_vector + new_angle = radians(float(cls.input_panel["A"])) + print(cls.input_panel["A"]) + # new_angle = radians(30) + v1 = second_to_last_point - last_point + v2 = last_point - snap_vector + + # Calculate the axis of rotation + axis = v1.cross(v2).normalized() - rot_axis = ref_vec.cross(dir_vec) - rot_axis.normalize() - rot_axis = Vector((abs(rot_axis.x), abs(rot_axis.y), abs(rot_axis.z))) - rot_mat = Matrix.Rotation(angle_rad, 3, rot_axis) - - ref_vec.normalize() - coords = ((ref_vec @ rot_mat) * distance) + last_point + # Calculate the rotated vector + # rotated_vector = v2 * math.cos(new_angle) + axis * math.sin(new_angle) * v1.length + v1 * (1 - math.cos(new_angle)) * (dot_product / v1.length**2) + # dir_vec = snap_vector - last_point + # coords = dir_vec.normalized() * distance + last_point + rot_mat = Matrix.Rotation(new_angle, 3, axis) + coords = (v1.normalized() @ rot_mat) * distance + last_point x = coords[0] y = coords[1] @@ -511,6 +521,7 @@ class PolylineDecorator: if "Z" in list(cls.input_panel.keys()): cls.input_panel["Z"] = str(round(z, 3)) + print(cls.input_panel) return cls.input_panel return cls.input_panel diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 090df007f3..2d5a93b434 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -91,25 +91,21 @@ class Cad: < returns the potentially signed angle as degrees or radians """ d1 = v1 - v2 - d2 = v2 - v3 + d2 = v3 - v2 axis = d1.cross(d2) axis.normalize() - axis = Vector((abs(axis.x), abs(axis.y), abs(axis.z))) - rotation_axis = d1.cross(d2) - - # Calculate the unsigned angle between the "from" and "to" vectors + # Calculate the unsigned angle between the "d1" and "d2" vectors a = d1.angle(d2) + # Determine the sign of the angle based on the provided axis + parameter = axis.z < 0 + sign = -1 if parameter <= 0 else 1 + if degrees: a = math.degrees(a) - - parameter = rotation_axis.dot(axis) - - sign = 1 if parameter <= 0 else -1 - return a * sign else: return a From 90e83d77a77d29538645f73bef0275e2601f2fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Fri, 30 Aug 2024 16:55:02 -0300 Subject: [PATCH 14/30] more improvement on distance and angle calculations --- .../bonsai/bim/module/model/decorator.py | 26 +++++++++---------- .../bonsai/bim/module/project/operator.py | 13 ++++------ src/bonsai/bonsai/tool/cad.py | 8 +++--- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 61c52a9967..56e8ab07d1 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -387,7 +387,6 @@ class PolylineDecorator: if is_input_on: if cls.use_default_container: - print("FOI") snap_vector = Vector( (float(cls.input_panel["X"]), float(cls.input_panel["Y"]), default_container_elevation) ) @@ -414,7 +413,7 @@ class PolylineDecorator: distance = (snap_vector - last_point).length if distance > 0: - angle = tool.Cad.angle_3_vectors(snap_vector, last_point, second_to_last_point, degrees=True) + angle = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, degrees=True) if cls.input_panel: cls.input_panel["X"] = str(round(snap_vector.x, 3)) cls.input_panel["Y"] = str(round(snap_vector.y, 3)) @@ -495,22 +494,24 @@ class PolylineDecorator: distance = float(cls.input_panel["D"]) if distance < 0 or distance > 0: - new_angle = radians(float(cls.input_panel["A"])) - print(cls.input_panel["A"]) - # new_angle = radians(30) + angle = radians(float(cls.input_panel["A"])) + + # TODO This is basically the reverse process of tool.Cad.angle_3_vectors + # Combine them into a single function v1 = second_to_last_point - last_point - v2 = last_point - snap_vector + v2 = snap_vector - last_point + + v1.normalize() + v2.normalize() # Calculate the axis of rotation axis = v1.cross(v2).normalized() + rot_mat = Matrix.Rotation(angle, 3, axis) + parameter = round(axis.z, 2) < 0 or (round(axis.y, 2) == 0 and round(axis.x < 0)) or (round(axis.x, 2) == 0 and round(axis.y < 0)) + rot_vector = (v1 @ rot_mat) if parameter else (rot_mat @ v1) - # Calculate the rotated vector - # rotated_vector = v2 * math.cos(new_angle) + axis * math.sin(new_angle) * v1.length + v1 * (1 - math.cos(new_angle)) * (dot_product / v1.length**2) - # dir_vec = snap_vector - last_point - # coords = dir_vec.normalized() * distance + last_point - rot_mat = Matrix.Rotation(new_angle, 3, axis) - coords = (v1.normalized() @ rot_mat) * distance + last_point + coords = rot_vector * distance + last_point x = coords[0] y = coords[1] @@ -521,7 +522,6 @@ class PolylineDecorator: if "Z" in list(cls.input_panel.keys()): cls.input_panel["Z"] = str(round(z, 3)) - print(cls.input_panel) return cls.input_panel return cls.input_panel diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index caec2a0c6f..caff816278 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2327,7 +2327,7 @@ class MeasureTool(bpy.types.Operator): self.number_is_negative = False self.is_input_on = False self.input_options = ["D", "A", "X", "Y", "Z"] - self.input_type = "OFF" + self.input_type = None self.input_value_xy = [None, None] self.input_panel = {"D": "", "A": "", "X": "", "Y": "", "Z": ""} self.snap_angle = None @@ -2352,11 +2352,8 @@ class MeasureTool(bpy.types.Operator): if self.input_type in {"X", "Y", "Z"}: self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) elif self.input_type in {"D", "A"}: - print("!") self.input_panel = PolylineDecorator.calculate_x_y_and_z(context) - print("!", self.input_panel) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - print("!2", self.input_panel) + # self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) else: self.input_panel[self.input_type] = self.number_output tool.Blender.update_viewport() @@ -2368,7 +2365,7 @@ class MeasureTool(bpy.types.Operator): if event.type == "MOUSEMOVE" or event.type == "INBETWEEN_MOUSEMOVE": self.mousemove_count += 1 self.is_input_on = False - self.input_type = "OFF" + self.input_type = None PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Snap.clear_snapping_ref() tool.Blender.update_viewport() @@ -2492,7 +2489,7 @@ class MeasureTool(bpy.types.Operator): if is_valid: tool.Snap.insert_polyline_point(self.input_panel) self.is_input_on = False - self.input_type = "OFF" + self.input_type = None self.number_input = [] self.number_output = "" PolylineDecorator.set_input_panel(self.input_panel, self.input_type) @@ -2532,7 +2529,7 @@ class MeasureTool(bpy.types.Operator): if event.value == "RELEASE" and event.type in {"ESC"}: self.recalculate_inputs(context) self.is_input_on = False - self.input_type = "OFF" + self.input_type = None PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() else: diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 2d5a93b434..419edc49d6 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -88,12 +88,14 @@ class Cad: def angle_3_vectors(cls, v1, v2, v3, degrees=False): """ > takes 3 vectors. The order matters, v2 is the center point. - < returns the potentially signed angle as degrees or radians + < returns the signed angle as degrees or radians """ d1 = v1 - v2 d2 = v3 - v2 axis = d1.cross(d2) + print(axis) + print(round(axis.z, 2)) axis.normalize() # Calculate the unsigned angle between the "d1" and "d2" vectors @@ -101,8 +103,8 @@ class Cad: # Determine the sign of the angle based on the provided axis - parameter = axis.z < 0 - sign = -1 if parameter <= 0 else 1 + parameter = round(axis.z, 2) < 0 or (round(axis.y, 2) == 0 and round(axis.x < 0)) or (round(axis.x, 2) == 0 and round(axis.y < 0)) + sign = -1 if parameter else 1 if degrees: a = math.degrees(a) From bdcadfd1000cc0032d6215557c46ae1da6c240a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Fri, 30 Aug 2024 17:23:48 -0300 Subject: [PATCH 15/30] Refactor tool.Cad.angle_3_vectors This functions was built to work with the input calculations for the polytool. Part of its code was being duplicated in another part of the code. The refactor keeps most of the calculation in the same place. --- .../bonsai/bim/module/model/decorator.py | 17 ++--------- src/bonsai/bonsai/tool/cad.py | 29 ++++++++++++------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 56e8ab07d1..69fa8a7aee 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -413,7 +413,7 @@ class PolylineDecorator: distance = (snap_vector - last_point).length if distance > 0: - angle = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, degrees=True) + angle = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, new_angle=None, degrees=True) if cls.input_panel: cls.input_panel["X"] = str(round(snap_vector.x, 3)) cls.input_panel["Y"] = str(round(snap_vector.y, 3)) @@ -496,20 +496,7 @@ class PolylineDecorator: if distance < 0 or distance > 0: angle = radians(float(cls.input_panel["A"])) - # TODO This is basically the reverse process of tool.Cad.angle_3_vectors - # Combine them into a single function - v1 = second_to_last_point - last_point - v2 = snap_vector - last_point - - v1.normalize() - v2.normalize() - - # Calculate the axis of rotation - axis = v1.cross(v2).normalized() - rot_mat = Matrix.Rotation(angle, 3, axis) - parameter = round(axis.z, 2) < 0 or (round(axis.y, 2) == 0 and round(axis.x < 0)) or (round(axis.x, 2) == 0 and round(axis.y < 0)) - - rot_vector = (v1 @ rot_mat) if parameter else (rot_mat @ v1) + rot_vector = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, angle, degrees=True) coords = rot_vector * distance + last_point diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 419edc49d6..bd735cda98 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -85,32 +85,39 @@ class Cad: return math.degrees(a) if degrees else a @classmethod - def angle_3_vectors(cls, v1, v2, v3, degrees=False): + def angle_3_vectors(cls, v1, v2, v3, new_angle=None, degrees=False): """ > takes 3 vectors. The order matters, v2 is the center point. < returns the signed angle as degrees or radians + < if a new angle is provided, return the rotation vector """ d1 = v1 - v2 d2 = v3 - v2 - axis = d1.cross(d2) - print(axis) - print(round(axis.z, 2)) - axis.normalize() + d1.normalize() + d2.normalize() + + axis = d1.cross(d2).normalized() # Calculate the unsigned angle between the "d1" and "d2" vectors a = d1.angle(d2) # Determine the sign of the angle based on the provided axis + # If new_angle, determine the direction of the rotation parameter = round(axis.z, 2) < 0 or (round(axis.y, 2) == 0 and round(axis.x < 0)) or (round(axis.x, 2) == 0 and round(axis.y < 0)) - sign = -1 if parameter else 1 - - if degrees: - a = math.degrees(a) - return a * sign + if new_angle: + rot_mat = Matrix.Rotation(new_angle, 3, axis) + rot_vector = (d1 @ rot_mat) if parameter else (rot_mat @ d1) + return rot_vector else: - return a + sign = -1 if parameter else 1 + + if degrees: + a = math.degrees(a) + return a * sign + else: + return a @classmethod def is_x(cls, value: float, x: float, tolerance: float | None = None) -> bool: From 8c94ef6e54c94d91725438e35cb2e83dd5e3e72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Sat, 31 Aug 2024 13:13:30 -0300 Subject: [PATCH 16/30] A better fix for dabb85c. --- src/bonsai/bonsai/bim/module/model/decorator.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 69fa8a7aee..8a0d4f4352 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -474,12 +474,12 @@ class PolylineDecorator: try: polyline_data = context.scene.BIMModelProperties.polyline_point last_point_data = polyline_data[len(polyline_data) - 1] + last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) except: - return + last_point = Vector((0, 0, 0)) snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) if len(polyline_data) > 1: second_to_last_point_data = polyline_data[len(polyline_data) - 2] @@ -511,6 +511,11 @@ class PolylineDecorator: return cls.input_panel + cls.input_panel["X"] = str(round(last_point.x, 3)) + cls.input_panel["Y"] = str(round(last_point.y, 3)) + if "Z" in list(cls.input_panel.keys()): + cls.input_panel["Z"] = str(round(last_point.z, 3)) + return cls.input_panel def draw_batch(self, shader_type, content_pos, color, indices=None): From 48b630696ffa4b68c71fc7f63e8b36d1f0c2c5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Sat, 31 Aug 2024 13:50:01 -0300 Subject: [PATCH 17/30] Fix 3ceeaa7 --- src/bonsai/bonsai/bim/module/drawing/helper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 05cf85b8a5..8f8df7410b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -204,6 +204,8 @@ def format_distance( tx_dist = "" if feet is not None: tx_dist += str(feet) + "'" + if not feet and not add_inches: + tx_dist += str(feet) + "'" if feet and add_inches: tx_dist += " - " if not feet and value < 0: From b552665dd92c75072aaedf641e3ed4a47829792c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Sat, 31 Aug 2024 14:34:03 -0300 Subject: [PATCH 18/30] Round angle in polytool input numbers calculations. --- src/bonsai/bonsai/bim/module/model/decorator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 8a0d4f4352..736c9b3ea7 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -414,6 +414,10 @@ class PolylineDecorator: distance = (snap_vector - last_point).length if distance > 0: angle = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, new_angle=None, degrees=True) + + # Round angle to the nearest 0.05 + angle = round(angle / 0.05) * 0.05 + if cls.input_panel: cls.input_panel["X"] = str(round(snap_vector.x, 3)) cls.input_panel["Y"] = str(round(snap_vector.y, 3)) From ec21574be54ed85ce9637919f81d73ab20c95a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Sat, 31 Aug 2024 14:39:09 -0300 Subject: [PATCH 19/30] Fix measure tool to now show the angle for the first point. --- src/bonsai/bonsai/bim/module/model/decorator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 736c9b3ea7..e3b15e8327 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -599,6 +599,8 @@ class PolylineDecorator: blf.position(self.font_id, coords_dim[0], coords_dim[1], 0) blf.draw(self.font_id, "d: " + formatted_value) + if i == 1: + continue pos_angle = measurement_prop[i - 1].position coords_angle = view3d_utils.location_3d_to_region_2d(region, rv3d, pos_angle) blf.position(self.font_id, coords_angle[0], coords_angle[1], 0) From b476cb825cc700b3721dcfea2a78fd86d81ce073 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 07:53:42 +1000 Subject: [PATCH 20/30] Fix #5265. Accommodate invalid material sets. --- src/bonsai/bonsai/bim/module/project/operator.py | 2 +- src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index caff816278..caf57879e2 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1712,7 +1712,7 @@ class LoadLinkedProject(bpy.types.Operator): mesh.polygons.foreach_set("loop_total", loop_total) mesh.polygons.foreach_set("use_smooth", [0] * total_faces) - if material_ids.size > 0: + if material_ids.size > 0 and len(mesh.polygons) == len(material_ids): mesh.polygons.foreach_set("material_index", material_ids) mesh.update() diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py index 3b7f49de9c..f9fdc9727a 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py @@ -136,21 +136,21 @@ class Patcher: if material.is_a("IfcMaterial"): materials = [] elif material.is_a("IfcMaterialLayerSet"): - for idx, item in enumerate(material.MaterialLayers): + for idx, item in enumerate(material.MaterialLayers or []): material = item.Material properties.append([i, "IFC Material", f"Layer {idx + 1} Name", getattr(item, "Name", None)]) properties.append([i, "IFC Material", f"Layer {idx + 1} Material", material.Name]) if category := getattr(material, "Category", None): properties.append([i, "IFC Material", f"Layer {idx + 1} Category", category]) elif material.is_a("IfcMaterialProfileSet"): - for idx, item in enumerate(material.MaterialProfiles): + for idx, item in enumerate(material.MaterialProfiles or []): material = item.Material properties.append([i, "IFC Material", f"Profile {idx + 1} Name", item.Name]) properties.append([i, "IFC Material", f"Profile {idx + 1} Material", material.Name]) if category := getattr(material, "Category", None): properties.append([i, "IFC Material", f"Profile {idx + 1} Category", category]) elif material.is_a("IfcMaterialConstituentSet"): - for idx, item in enumerate(material.MaterialConstituents): + for idx, item in enumerate(material.MaterialConstituents or []): material = item.Material properties.append([i, "IFC Material", f"Constituent {idx + 1} Name", item.Name]) properties.append([i, "IFC Material", f"Constituent {idx + 1} Material", material.Name]) From 53a78f38e5406cff872c1786b42a43b6fc9d2f9a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 08:13:29 +1000 Subject: [PATCH 21/30] You can now assign objects to the selected container in the spatial decomposition panel --- src/bonsai/bonsai/bim/module/spatial/operator.py | 14 +++++++++----- src/bonsai/bonsai/bim/module/spatial/ui.py | 6 ++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 932b105acf..b00d64f77b 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -78,14 +78,18 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Assign Container" bl_description = "Assign current default container to the selected objects" bl_options = {"REGISTER", "UNDO"} + container: bpy.props.IntProperty(options={"SKIP_SAVE"}) def _execute(self, context): props = context.active_object.BIMObjectSpatialProperties - if (container_obj := props.container_obj) and (container := tool.Ifc.get_entity(container_obj)): - for element_obj in context.selected_objects: - core.assign_container( - tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj - ) + if self.container: + container = tool.Ifc.get().by_id(self.container) + elif (container_obj := props.container_obj) and (container := tool.Ifc.get_entity(container_obj)): + pass + for element_obj in context.selected_objects: + core.assign_container( + tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj + ) class EnableEditingContainer(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py index 164ab89c01..8340eb2bb5 100644 --- a/src/bonsai/bonsai/bim/module/spatial/ui.py +++ b/src/bonsai/bonsai/bim/module/spatial/ui.py @@ -159,16 +159,18 @@ class BIM_PT_spatial_decomposition(Panel): if not self.props.total_elements: row = self.layout.row(align=True) - row.label(text=f"{self.props.active_container.ifc_class} > No Contained Elements", icon="FILE_3D") + row.label(text=f"{self.props.active_container.ifc_class} > No Elements", icon="FILE_3D") row.prop(self.props, "should_include_children", text="", icon="OUTLINER") + row.operator("bim.assign_container", icon="FOLDER_REDIRECT", text="").container = ifc_definition_id return row = self.layout.row(align=True) row.label( - text=f"{self.props.active_container.ifc_class} > {self.props.total_elements} Contained Elements", + text=f"{self.props.active_container.ifc_class} > {self.props.total_elements} Elements", icon="FILE_3D", ) row.prop(self.props, "should_include_children", text="", icon="OUTLINER") + row.operator("bim.assign_container", icon="FOLDER_REDIRECT", text="").container = ifc_definition_id op = row.operator("bim.select_decomposed_elements", icon="RESTRICT_SELECT_OFF", text="") op.container = ifc_definition_id From f2554bc9688790e7574075bc77456c03ca807a5d Mon Sep 17 00:00:00 2001 From: Yassine Oualid Date: Sun, 1 Sep 2024 01:40:53 +0100 Subject: [PATCH 22/30] - Add button to generate cost schedule - Feature to view, edit and add cost item values. ( Right Click - Edit ). --- src/bonsai/bonsai/bim/data/webui/sioserver.py | 10 + .../static/css/components/contextMenu.css | 89 ++++ .../bim/data/webui/static/css/gantt.css | 1 + .../bonsai/bim/data/webui/static/js/cost.js | 48 +- .../bonsai/bim/data/webui/static/js/gantt.js | 7 - .../data/webui/static/js/utilities/costui.js | 458 ++++++++++++++---- .../bim/data/webui/templates/costing.html | 3 +- src/bonsai/bonsai/bim/module/cost/__init__.py | 1 + src/bonsai/bonsai/bim/module/cost/data.py | 10 +- src/bonsai/bonsai/bim/module/cost/operator.py | 11 + src/bonsai/bonsai/bim/module/cost/ui.py | 3 +- src/bonsai/bonsai/core/cost.py | 5 + src/bonsai/bonsai/tool/cost.py | 11 +- src/bonsai/bonsai/tool/web.py | 72 ++- .../ifcopenshell/util/cost.py | 14 + 15 files changed, 616 insertions(+), 127 deletions(-) create mode 100644 src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css diff --git a/src/bonsai/bonsai/bim/data/webui/sioserver.py b/src/bonsai/bonsai/bim/data/webui/sioserver.py index fe49b03bdd..9d1651b0a9 100644 --- a/src/bonsai/bonsai/bim/data/webui/sioserver.py +++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py @@ -166,6 +166,16 @@ class BlenderNamespace(socketio.AsyncNamespace): blender_messages[sid]["cost_schedules"] = data await sio.emit("cost_schedules", {"blenderId": sid, "data": data}, namespace="/web") + async def on_cost_values(self, sid, data): + print(f"Cost values from Blender client {sid}") + blender_messages[sid]["cost_values"] = data + await sio.emit("cost_values", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_cost_value(self, sid, data): + print(f"Cost values from Blender client {sid}") + blender_messages[sid]["cost_value"] = data + await sio.emit("cost_value", {"blenderId": sid, "data": data}, namespace="/web") + async def schedules(request): with open("templates/index.html", "r") as f: template = f.read() diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css new file mode 100644 index 0000000000..79ba11cc68 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css @@ -0,0 +1,89 @@ + +th { + position: relative; +} +.resizer { + position: absolute; + right: 0; + top: 0; + width: 5px; + height: 100%; + cursor: col-resize; + user-select: none; +} +.context-menu { + display: none; + position: absolute; + background-color: rgb(51, 51, 51); + border: 1px solid #ccc; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1000; + color: white; +} +.context-menu button { + display: block; + width: 100%; + padding: 8px; + border: none; + background: none; + text-align: left; + cursor: pointer; +} +.context-menu button:hover { + background-color: #f0f0f0; + color: black; +} + +/* Context menu button is clicked, should zoom into object and change color */ +.context-menu button:active { + background-color: #ff9634; + color: rgba(37, 51, 77, 0.466); + /* zoom */ + transform: scale(0.9); +} + +#cost-items tr:hover { + background-color: #f0f0f0; + color : black; +} + +.form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + background-color: #363636; + padding: 10px; +} + +.cost-values-form { + position: absolute; /* Set initial position to absolute */ + background-color: white; + border: 1px solid black; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + z-index: 9999; + cursor: move; /* Change cursor to move */ +} + +.cost-values-table { + width: 100%; + border-collapse: collapse; +} + +.cost-values-table th, .cost-values-table td { + border: 1px solid #ddd; + padding: 8px; +} + +.cost-values-table th { + background-color: #f2f2f2; + text-align: left; +} + +.close-button { + position: absolute; + top: 10px; + right: 10px; + font-size: 20px; + cursor: pointer; +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css b/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css index 035fd68f0b..36575b8c74 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css @@ -1,4 +1,5 @@ @import url("./components/card.css"); +@import url("./components/contextMenu.css"); :root { --font-family: Arial, sans-serif; diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js index 1ea91590ed..5a10017b89 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js @@ -23,6 +23,8 @@ function connectSocket() { socket.on("connect", handleWebConnect); socket.on("cost_schedules", handleCostSchedulesData); socket.on("cost_items", handleCostItemsData); + socket.on("cost_values", handleCostValuesData); + socket.on("cost_value", handleCostValueData); } function handleBlenderConnect(blenderId) { @@ -46,8 +48,20 @@ function handleBlenderDisconnect(blenderId) { }); } +function removeTableElement(blenderId) { + $("#cost-items-" + blenderId).remove(); +} +function handleCostValueData(data) { + + const costItemId = data.data["cost_value"]["cost_item_id"]; + const costValueId = data.data["cost_value"]["cost_value_id"]; + console.log("Handling cost value data", costItemId, costValueId); + CostUI.addNewCostValueRow(costItemId, costValueId); + +} + function handleConnectedClients(data) { $("#blender-count").text(data.length); @@ -56,6 +70,29 @@ function handleConnectedClients(data) { }); } +function handleCostValuesData(data) { + CostUI.createCostValuesForm({ + costValues: data.data["cost_values"]["cost_values"], + costItemId: data.data["cost_values"]["cost_item_id"], + callbacks: { + 'editCostValues': editCostValues, + 'addCostValue': addCostValue, + }, + }); +} + +function addCostValue(costItemId) { + executeOperator({ type: "addCostValue", costItemId: costItemId }); +} + +function editCostValues(costItemId, costValues) { + executeOperator({ + type: "editCostValues", + costItemId: costItemId, + costValues: costValues + }); +} + function handleThemeData(themeData) { function arrayToRgbString(arr) { const [r, g, b, a] = arr.map((num) => Math.round(num * 255)); @@ -99,7 +136,6 @@ function setTheme(theme) { } function addCostItem(costItemId) { - console.log("addCostItem", costItemId); executeOperator({ type: "addCostItem", costItemId: costItemId }); } @@ -119,11 +155,7 @@ function handleCostSchedulesData(data) { const blenderId = data.blenderId; const costSchedules = data.data["cost_schedules"]["cost_schedules"]; const currency = data.data["cost_schedules"]["currency"]["name"]; - - console.log(data.data["cost_schedules"]); - const costScheduleDiv = $("#cost-schedules"); - costSchedules.forEach((costSchedule) => { costSchedule.UpdateDate = new Date(costSchedule.UpdateDate); const mainContainer = CostUI.text("Updated On: " + costSchedule.UpdateDate); @@ -136,7 +168,6 @@ function handleCostSchedulesData(data) { } function handleCostItemsData(data) { - console.log(data); CostUI.createCostSchedule({ data: data.data["cost_items"], blenderID: data.blenderId, @@ -144,6 +175,7 @@ function handleCostItemsData(data) { "addCostItem": addCostItem, "selectAssignedElements": selectAssignedElements, 'editCostItemName': editCostItemName, + 'enableEditingCostValues': enableEditingCostValues, } }); } @@ -166,3 +198,7 @@ function loadCostSchedule(costScheduleId, blenderId) { function getCostSchedules(blenderId) { executeOperator({ type: "getCostSchedules" }, blenderId); } + +function enableEditingCostValues(costItemId) { + executeOperator({ type: "enableEditingCostValues", costItemId: costItemId}); +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js b/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js index 5942765dfd..473cab82d9 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js @@ -82,7 +82,6 @@ function handleBlenderDisconnect(blenderId) { function handleConnectedClients(data) { $("#blender-count").text(data.length); - // console.log(data); data.forEach(function (id) { connectedClients[id] = { shown: false, @@ -115,7 +114,6 @@ function handleThemeData(themeData) { } const cssRule = generateCssVariableRule(themeData.theme); - console.log(cssRule); var styleElement = $("#gantt-stylesheet")[0]; if (styleElement) { @@ -133,7 +131,6 @@ function handleWorkScheduleData(data) { const workSchedules = data["data"]["work_schedule_info"]; workSchedules.forEach((workSchedule) => { - console.log(workSchedule); const mainContainer = CostUI.text(new Date(workSchedule.CreationDate).toLocaleDateString()); const callback = () => loadWorkSchedule(workSchedule.id); const card = CostUI.createCard(workSchedule.Name,mainContainer, callback); @@ -142,11 +139,8 @@ function handleWorkScheduleData(data) { } function handleGanttData(data) { - console.log("running handleGanttData"); const blenderId = data["blenderId"]; - console.log(data); - const filename = data["data"]["ifc_file"]; const ganttTasks = data["data"]["gantt_data"]["tasks"]; const ganttWorkSched = data["data"]["gantt_data"]["work_schedule"]; @@ -183,7 +177,6 @@ function handleDefaultData(data) { const blenderId = data["blenderId"]; const isDirty = data["data"]["is_dirty"]; showWarning(blenderId, isDirty); - console.log('default data',data); } // Function to add a new gantt with data and filename diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js index 61779592bd..7ec2d6f582 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js @@ -1,14 +1,6 @@ export class CostUI { constructor() {} - createButton() { - console.log("Button created"); - } - - createInput() { - console.log("Input created"); - } - static isCostScheduleLoaded(id) { const existingTable = document.getElementById('cost-items-' + id); return existingTable !== null; @@ -17,7 +9,7 @@ export class CostUI { static removeCostSchedule(id) { document.getElementById("cost-items-" + id).remove(); } - static createTable(id) { + static createTable(id, callbacks) { CostUI.isCostScheduleLoaded(id) ? CostUI.removeCostSchedule(id) : null; const table = document.createElement("table"); @@ -51,7 +43,7 @@ export class CostUI { CostUI.addTableStyles(id); // Create context menu - CostUI.createContextMenu(); + CostUI.createContextMenu(callbacks); table.get_blender_id = function() { return this.getAttribute("id").split("-")[2]; @@ -71,52 +63,19 @@ export class CostUI { #cost-items-${id} td:not(:nth-child(1)) { width: 100px; /* Set a fixed width for other columns */ } - th { - position: relative; - } - .resizer { - position: absolute; - right: 0; - top: 0; - width: 5px; - height: 100%; - cursor: col-resize; - user-select: none; - } - .context-menu { - display: none; - position: absolute; - background-color: white; - border: 1px solid #ccc; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); - z-index: 1000; - } - .context-menu button { - display: block; - width: 100%; - padding: 8px; - border: none; - background: none; - text-align: left; - cursor: pointer; - } - .context-menu button:hover { - background-color: #f0f0f0; - } - #cost-items tr:hover { - background-color: #f0f0f0; /* Change this color to your desired hover color */ - } + `; document.head.appendChild(style); } - static createContextMenu() { + static createContextMenu(callbacks) { // Create context menu const contextMenu = document.createElement("div"); contextMenu.id = "context-menu"; contextMenu.classList.add("context-menu"); contextMenu.innerHTML = ` - + + `; @@ -126,6 +85,7 @@ export class CostUI { document.addEventListener("contextmenu", function(event) { event.preventDefault(); const targetRow = event.target.closest("tr"); + const targetCell = event.target.closest("td"); if (targetRow && targetRow.parentElement.id === "cost-items") { const contextMenu = document.getElementById("context-menu"); contextMenu.style.display = "block"; @@ -134,6 +94,7 @@ export class CostUI { // Store the target row in the context menu for later use contextMenu.targetRow = targetRow; + contextMenu.targetCell = targetCell; } else { document.getElementById("context-menu").style.display = "none"; } @@ -146,14 +107,32 @@ export class CostUI { } }); - document.getElementById("edit-button").addEventListener("click", function() { + const editCostValuesButton = document.getElementById("edit-cost-values-button"); + editCostValuesButton.addEventListener("click", function() { + console.log("Edit cost values executed"); const targetRow = document.getElementById("context-menu").targetRow; + const targetCell = document.getElementById("context-menu").targetCell; if (targetRow) { - // Implement your edit action here - console.log("Edit row:", targetRow.getAttribute("id")); + const costItemId = parseInt(targetRow.getAttribute("id")); + callbacks.enableEditingCostValues ? callbacks.enableEditingCostValues(costItemId) : null; } }); + const addButton = document.getElementById("add-cost-item-button"); + if (!addButton.dataset.listenerAdded) { + function addCostItemHandler(e) { + e.stopPropagation(); + const targetRow = document.getElementById("context-menu").targetRow; + if (targetRow) { + const costItemId = parseInt(targetRow.getAttribute("id")); + callbacks.addCostItem ? callbacks.addCostItem(costItemId) : null; + } + document.getElementById("context-menu").style.display = "none"; + } + addButton.addEventListener("click", addCostItemHandler); + addButton.dataset.listenerAdded = "true"; + } + document.getElementById("delete-button").addEventListener("click", function() { const targetRow = document.getElementById("context-menu").targetRow; if (targetRow) { @@ -246,7 +225,7 @@ export class CostUI { } static createCostSchedule({ data, blenderID, title, callbacks = {} }) { - const [table, tbody] = CostUI.createTable(blenderID); + const [table, tbody] = CostUI.createTable(blenderID, callbacks); CostUI.createCostItem(data, tbody, 0, null, callbacks); CostUI.applyExpandedState(); } @@ -263,6 +242,35 @@ export class CostUI { } static createRow(obj, nestingLevel, parentID, callbacks = {}) { + const row = CostUI.createTableRow(obj, nestingLevel, parentID); + const expandButton = CostUI.createExpandButton(obj); + const nameCell = CostUI.createNameCell(obj, nestingLevel, expandButton, callbacks); + const totalCostQuantityCell = CostUI.createTableCell(obj.TotalCostQuantity); + const unitSymbolCell = CostUI.createTableCell(obj.UnitSymbol); + const totalAppliedValueCell = CostUI.createTableCell(obj.TotalAppliedValue); + const totalCostCell = CostUI.createTotalCostCell(obj); + const flexContainerCell = CostUI.createFlexContainerCell(obj, callbacks); + + row.appendChild(nameCell); + row.appendChild(totalCostQuantityCell); + row.appendChild(unitSymbolCell); + row.appendChild(totalAppliedValueCell); + row.appendChild(totalCostCell); + row.appendChild(flexContainerCell); + + row.get_id = function() { + return this.getAttribute("id"); + }; + + row.get_parent = function() { + const parentId = this.getAttribute("parent-id"); + return parentId ? document.getElementById(parentId) : null; + }; + + return row; + } + + static createTableRow(obj, nestingLevel, parentID) { const row = document.createElement("tr"); row.setAttribute("id", obj.id); row.setAttribute("parent-id", parentID); @@ -270,6 +278,10 @@ export class CostUI { row.classList.add("nested"); row.classList.add(`level-${nestingLevel}`); } + return row; + } + + static createExpandButton(obj) { const expandButton = document.createElement("button"); expandButton.classList.add("toggle-button"); if (obj.is_nested_by && obj.is_nested_by.length > 0) { @@ -277,51 +289,62 @@ export class CostUI { } else { expandButton.style.visibility = "hidden"; } - //row.appendChild(expandButton); - expandButton.addEventListener("click", function() { CostUI.contractExpandRow.call(this, obj.id); }); - + return expandButton; + } + + static createNameCell(obj, nestingLevel, expandButton, callbacks) { const nameCell = document.createElement("td"); const nameInput = document.createElement("input"); nameInput.value = obj.name ? obj.name : "Unnamed"; - + nameInput.addEventListener("change", function() { callbacks.editCostItemName ? callbacks.editCostItemName(obj.id, this.value) : null; }); - + nameInput.addEventListener("keydown", function(event) { if (event.key === "Enter") { callbacks.editCostItemName ? callbacks.editCostItemName(obj.id, this.value) : null; } }); + nameCell.style.paddingLeft = nestingLevel * 20 + "px"; nameCell.appendChild(expandButton); nameCell.appendChild(nameInput); - row.appendChild(nameCell); - - const totalCostQuantityCell = document.createElement("td"); - totalCostQuantityCell.textContent = obj.TotalCostQuantity; - row.appendChild(totalCostQuantityCell); - - const unitSymbolCell = document.createElement("td"); - unitSymbolCell.textContent = obj.UnitSymbol; - row.appendChild(unitSymbolCell); - - const totalAppliedValueCell = document.createElement("td"); - totalAppliedValueCell.textContent = obj.TotalAppliedValue; - row.appendChild(totalAppliedValueCell); - + return nameCell; + } + + static createTableCell(content) { + const cell = document.createElement("td"); + cell.textContent = content; + return cell; + } + + static createTotalCostCell(obj) { const totalCostCell = document.createElement("td"); const totalCost = parseFloat(obj.TotalCost).toFixed(2); - totalCostCell.textContent = obj.is_sum ? totalCost + " (Σ)" : totalCost; - - row.appendChild(totalCostCell); - + return totalCostCell; + } + + static createFlexContainerCell(obj, callbacks) { const divFlex = document.createElement("div"); divFlex.classList.add("flex-container"); + + const addButton = CostUI.createAddButton(obj, callbacks); + const selectButton = CostUI.createSelectButton(obj, callbacks); + + divFlex.appendChild(addButton); + divFlex.appendChild(selectButton); + + const flexContainerCell = document.createElement("td"); + flexContainerCell.appendChild(divFlex); + return flexContainerCell; + } + + static createAddButton(obj, callbacks) { const addButton = document.createElement("button"); addButton.textContent = "+"; addButton.classList.add("add-button"); @@ -329,31 +352,17 @@ export class CostUI { e.stopPropagation(); callbacks.addCostItem ? callbacks.addCostItem(obj.id) : null; }); - + return addButton; + } + + static createSelectButton(obj, callbacks) { const selectButton = document.createElement("button"); selectButton.textContent = "Select"; selectButton.addEventListener("click", function(e) { e.stopPropagation(); callbacks.selectAssignedElements ? callbacks.selectAssignedElements(obj.id) : null; }); - - divFlex.appendChild(addButton); - divFlex.appendChild(selectButton); - - const flexContainerCell = document.createElement("td"); - flexContainerCell.appendChild(divFlex); - row.appendChild(flexContainerCell); - - row.get_id = function() { - return this.getAttribute("id"); - }; - - row.get_parent = function() { - const parentId = this.getAttribute("parent-id"); - return parentId ? document.getElementById(parentId) : null; - }; - - return row; + return selectButton; } static hideNestedRows(parentId) { @@ -461,4 +470,267 @@ export class CostUI { return card; } + + static createCostValuesForm({ costItemId, costValues, callbacks }) { + // Check if the form already exists and remove it if it does + const formId = "cost-values-form-" + costItemId; + let existingForm = document.getElementById(formId); + if (existingForm) { + existingForm.remove(); + } + + const form = CostUI.createFormElement(formId, "cost-values-form"); + + const header = CostUI.createHeader("Cost Values Form"); + form.appendChild(header); + + const table = CostUI.createCostValuesTable(costItemId, ["Type", "Category", "Value"]); + + costValues.forEach(costValue => { + const tr = CostUI.createCostvaluesRow(costItemId, costValue); + table.appendChild(tr); + }); + + form.appendChild(table); + + const addButton = CostUI.createAddCostValueButton(costItemId, callbacks); + form.appendChild(addButton); + + const submitButton = CostUI.createSubmitButton(callbacks); + form.appendChild(submitButton); + + const closeButton = CostUI.createCloseButton(form); + form.appendChild(closeButton); + + CostUI.makeHeaderDraggable(header, form); + + document.body.appendChild(form); + + return form; + } + + + static getCostValuesTable(costItemId) { + return document.getElementById("cost-values-table-" + costItemId); + } + + static addNewCostValueRow(costItemId, costValueId) { + const table = CostUI.getCostValuesTable(costItemId); + // check if table exists + if (!table) { + console.log("Cost values table not found for cost item ID:", costItemId); + return; + } + console.log("Adding new cost value row", costValueId); + console.log(table) + const tr = CostUI.createCostvaluesRow(costItemId, {"category": "", "name": "", "applied_value": 0, "id": costValueId, "parent": costItemId}); + table.appendChild(tr); + } + + static createCostValuesTable(costItemId, headers) { + const table = document.createElement("table"); + table.classList.add("cost-values-table"); + table.id = "cost-values-table-" + costItemId; + + const headerRow = document.createElement("tr"); + headers.forEach(headerText => { + const th = document.createElement("th"); + th.textContent = headerText; + headerRow.appendChild(th); + }); + table.appendChild(headerRow); + + return table; + } + + static createCostvaluesRow(costItemId,costValue) { + + function cleanLabel(value) { + // remove anything which is not a dot or a digit + return value.replace(/[^0-9.]/g, ''); + + } + + + if (document.getElementById(costValue.id)) { + return document.getElementById(costValue.id); + } + + const tr = document.createElement("tr"); + tr.isCostValue = true; + tr.parent = costItemId; + tr.id = costValue.id; + + let costType = "FIXED"; + if (costValue.category === "*") { + costType = "SUM"; + } + else if (costValue.category && costValue.category !== "*") { + costType = "CATEGORY"; + } + else if (costValue.applied_value) { + costType = "FIXED"; + } + + const typeCell = CostUI.createTableDropdown("type", costType); + const dropdown = typeCell.querySelector("select"); + dropdown.addEventListener("change", function() { + const selectedType = this.value; + CostUI.updateRowBasedOnType(selectedType, categoryCell, valueCell1); + }); + tr.appendChild(typeCell); + + const categoryCell = CostUI.createTableInput("text", "category", costValue.category); + tr.appendChild(categoryCell); + + let value + + if (costValue.category === "*"){ + value = cleanLabel(costValue.label) + } + else { + value = costValue.applied_value + } + const valueCell1 = CostUI.createTableInput("number", "value", value); + tr.appendChild(valueCell1); + // Apply initial state based on costType + CostUI.updateRowBasedOnType(costType, categoryCell, valueCell1); + + return tr; + } + + static updateRowBasedOnType(type, categoryCell, valueCell1) { + if (type === "FIXED") { + categoryCell.querySelector("input").disabled = true; + categoryCell.querySelector("input").style.backgroundColor = "lightgrey"; + valueCell1.querySelector("input").disabled = false; + valueCell1.querySelector("input").style.backgroundColor = ""; + } else if (type === "CATEGORY") { + categoryCell.querySelector("input").disabled = false; + categoryCell.querySelector("input").style.backgroundColor = ""; + valueCell1.querySelector("input").disabled = false; + valueCell1.querySelector("input").style.backgroundColor = ""; + } else if (type === "SUM") { + categoryCell.querySelector("input").disabled = true; + categoryCell.querySelector("input").style.backgroundColor = "lightgrey"; + valueCell1.querySelector("input").disabled = true; + valueCell1.querySelector("input").style.backgroundColor = "lightgrey"; + } + } + + static createTableDropdown(name, value="FIXED") { + const cell = document.createElement("td"); + const dropdown = document.createElement("select"); + dropdown.name = name; + + const options = ["FIXED", "CATEGORY", "SUM"]; + options.forEach(optionValue => { + const option = document.createElement("option"); + option.value = optionValue; + option.textContent = optionValue; + if (optionValue === value) { + option.selected = true; + } + dropdown.appendChild(option); + }); + + cell.appendChild(dropdown); + return cell; + } + + static createTableInput(type, name, value) { + const cell = document.createElement("td"); + const input = document.createElement("input"); + input.type = type; + input.name = name; + input.value = value; + cell.appendChild(input); + return cell; + } + + static createAddCostValueButton(costItemId, callbacks) { + const addButton = document.createElement("button"); + addButton.textContent = "+"; + addButton.classList.add("add-button"); + addButton.addEventListener("click", function(e) { + e.preventDefault(); + callbacks.addCostValue ? callbacks.addCostValue(costItemId) : null; + }); + return addButton; + } + + static createSubmitButton(callbacks) { + const submitButton = document.createElement("button"); + submitButton.type = "submit"; + submitButton.textContent = "Save"; + + submitButton.addEventListener("click", function(e) { + e.preventDefault(); + const form = this.closest("form"); + const costValues = []; + const rows = Array.from(form.querySelectorAll("tr")).filter(row => row.isCostValue); + + rows.forEach(row => { + const costCategory = row.querySelector("input[name='category']").value; + const appliedValue = parseFloat(row.querySelector("input[name='value']").value); + const costType = row.querySelector("select[name='type']").value; + const id = parseInt(row.id); + costValues.push({ costType, costCategory, appliedValue, id: id, costItemId: row.parent }); + }); + const costItemId = parseInt(form.id.split("-")[3]); + console.log(form.id) + console.log("Cost values to be saved:", costItemId); + callbacks.editCostValues ? callbacks.editCostValues(costItemId, costValues) : null; + form.remove(); + }); + return submitButton; + } + + static createFormElement(id, className) { + const form = document.createElement("form"); + form.id = id; + form.classList.add(className); + form.style.position = "absolute"; // Set initial position to absolute + form.style.top = "50%"; + form.style.left = "50%"; + form.style.transform = "translate(-50%, -50%)"; + return form; + } + + static createHeader(text) { + const header = document.createElement("div"); + header.classList.add("form-header"); + header.textContent = text; + return header; + } + + static createCloseButton(form) { + const closeButton = document.createElement("span"); + closeButton.classList.add("close-button"); + closeButton.innerHTML = "×"; + closeButton.addEventListener("click", function() { + form.remove(); + }); + return closeButton; + } + + static makeHeaderDraggable(header, form) { + header.addEventListener("mousedown", function(e) { + let offsetX = e.clientX - form.getBoundingClientRect().left; + let offsetY = e.clientY - form.getBoundingClientRect().top; + + function mouseMoveHandler(e) { + form.style.left = `${e.clientX - offsetX}px`; + form.style.top = `${e.clientY - offsetY}px`; + } + + function mouseUpHandler() { + document.removeEventListener("mousemove", mouseMoveHandler); + document.removeEventListener("mouseup", mouseUpHandler); + } + + document.addEventListener("mousemove", mouseMoveHandler); + document.addEventListener("mouseup", mouseUpHandler); + }); + } } diff --git a/src/bonsai/bonsai/bim/data/webui/templates/costing.html b/src/bonsai/bonsai/bim/data/webui/templates/costing.html index 6bd6ddeee7..c0297bfac4 100644 --- a/src/bonsai/bonsai/bim/data/webui/templates/costing.html +++ b/src/bonsai/bonsai/bim/data/webui/templates/costing.html @@ -4,8 +4,7 @@ BlenderBIM Web UI - - + ifcopenshell.entity_instance ifc.run("unit.edit_monetary_unit", unit=unit, attributes=attributes) ifc.run("unit.assign_unit", units=[unit]) return unit + + +def generate_cost_schedule_browser(cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> bpy.types.Panel: + cost_schedule_data = cost.create_cost_schedule_json(cost_schedule) + return cost.generate_cost_schedule_browser(cost_schedule_data) \ No newline at end of file diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 5aeb827ae9..5fa98c32e7 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -811,9 +811,8 @@ class Cost(bonsai.core.tool.Cost): @classmethod def create_cost_schedule_json(cls, cost_schedule: ifcopenshell.entity_instance) -> dict: from bonsai.bim.module.cost.data import CostSchedulesData - if not CostSchedulesData.is_loaded: - CostSchedulesData.load() - cost_items = CostSchedulesData.data["cost_items"] + CostSchedulesData.load() + cost_items = CostSchedulesData.data["cost_items"] data = [] for rel in cost_schedule.Controls or []: for cost_item in rel.RelatedObjects or []: @@ -852,3 +851,9 @@ class Cost(bonsai.core.tool.Cost): unit = tool.Unit.get_project_currency_unit() if unit: return {"id": unit.id(), "name": unit.Currency} + + @classmethod + def generate_cost_schedule_browser(cls, cost_schedule_data: list[dict[str, Any]]) -> None: + if not bpy.context.scene.WebProperties.is_connected: + bpy.ops.bim.connect_websocket_server(page="costing") + tool.Web.send_webui_data(data=cost_schedule_data, data_key="cost_items", event="cost_items") \ No newline at end of file diff --git a/src/bonsai/bonsai/tool/web.py b/src/bonsai/bonsai/tool/web.py index 816cae029d..bfc1e8c6dc 100644 --- a/src/bonsai/bonsai/tool/web.py +++ b/src/bonsai/bonsai/tool/web.py @@ -21,6 +21,7 @@ from bonsai.bim.module.web.data import WebData import bonsai.core.tool import bonsai.tool as tool import ifcopenshell.api.sequence +import ifcopenshell.api.cost from typing import Any, Dict, Optional import time import socket @@ -354,17 +355,15 @@ class Web(bonsai.core.tool.Web): if operator_data["type"] == "loadCostSchedule": cost_schedule = ifc_file.by_id(operator_data["costScheduleId"]) bonsai.core.cost.enable_editing_cost_items(tool.Cost, cost_schedule=cost_schedule) - json_data = tool.Cost.create_cost_schedule_json(cost_schedule) - cls.send_webui_data(data=json_data, data_key="cost_items", event="cost_items") - if operator_data["type"] == "addCostItem": - bpy.ops.bim.add_cost_item(cost_item=operator_data["costItemId"]) - cost_schedule = tool.Cost.get_cost_schedule(cost_item=tool.Ifc.get().by_id(operator_data["costItemId"])) - json_data = tool.Cost.create_cost_schedule_json(cost_schedule) - cls.send_webui_data(data=json_data, data_key="cost_items", event="cost_items") + cls.load_cost_schedule_web_ui(cost_schedule) if operator_data["type"] == "selectAssignedElements": cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) products = tool.Cost.get_cost_item_products(cost_item, is_deep=True) tool.Spatial.select_products(products, unhide=True) + if operator_data["type"] == "addCostItem": + bpy.ops.bim.add_cost_item(cost_item=operator_data["costItemId"]) + cost_schedule = tool.Cost.get_cost_schedule(cost_item=tool.Ifc.get().by_id(operator_data["costItemId"])) + cls.load_cost_schedule_web_ui(cost_schedule) if operator_data["type"] == "editCostItemName": cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) tool.Ifc.run( @@ -373,6 +372,65 @@ class Web(bonsai.core.tool.Web): attributes = {"Name": operator_data["name"]} ) tool.Cost.load_cost_schedule_tree() + if operator_data["type"] == "enableEditingCostValues": + cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) + cost_values = ifcopenshell.util.cost.get_cost_values(cost_item) + cls.send_webui_data(data={ + "cost_values": cost_values, + "cost_item_id": operator_data["costItemId"] + }, data_key="cost_values", event="cost_values") + if operator_data["type"] == "addCostValue": + value = ifcopenshell.api.cost.add_cost_value( + ifc_file, + parent=ifc_file.by_id(operator_data["costItemId"]), + ) + cls.send_webui_data( + data={ + "cost_value_id" : value.id(), + "cost_item_id":operator_data["costItemId"]}, + data_key="cost_value", + event="cost_value" + ) + if operator_data["type"] == "editCostValues": + cost_item_id = operator_data["costItemId"] + print('Editing cost values', cost_item_id) + print(cost_item_id) + print(type(operator_data["costValues"])) + for value_data in operator_data["costValues"] or []: + print(value_data) + value = ifc_file.by_id(value_data["id"]) + print(value.get_info()) + if value_data["costType"] == "FIXED": + attributes= { + "AppliedValue": value_data["appliedValue"], + } + elif value_data["costType"] == "CATEGORY": + attributes= { + "AppliedValue": value_data["appliedValue"], + "Category": value_data["costCategory"], + } + elif value_data["costType"] == "SUM": + attributes= { + "Category": '*' + } + ifcopenshell.api.cost.edit_cost_value( + file=ifc_file, + cost_value=value, + attributes= attributes + ) + tool.Cost.load_cost_schedule_tree() + cost_item = ifc_file.by_id(operator_data["costItemId"]) + if not cost_item: + print("Cost item not found") + return + cost_schedule = tool.Cost.get_cost_schedule(cost_item=cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + + + @classmethod + def load_cost_schedule_web_ui(cls, cost_schedule): + json_data = tool.Cost.create_cost_schedule_json(cost_schedule) + cls.send_webui_data(data=json_data, data_key="cost_items", event="cost_items") @classmethod def handle_gantt_operator(cls, operator_data: dict) -> None: diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index d9ce6f874f..bb3df756bb 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -245,6 +245,20 @@ def get_cost_item_assignments( for product in get_cost_assignments_by_type(nested_cost_item, filter_by_type) ] +def get_cost_values(cost_item: ifcopenshell.entity_instance) -> list[dict[str, str]]: + results = [] + for cost_value in cost_item.CostValues or []: + label = "{0:.2f}".format(calculate_applied_value(cost_item, cost_value)) + label += " = {}".format(serialise_cost_value(cost_value)) + results.append({ + "id": cost_value.id(), + "label": label, + "name": cost_value.Name, + "category": cost_value.Category, + "applied_value": get_primitive_applied_value(cost_value.AppliedValue) if cost_value.AppliedValue else None, + }) + print(results) + return results class CostValueUnserialiser: def parse(self, formula: str): From 90f9ebd25ec1db516592b4023a76faa88d5c175d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 13:23:31 +1000 Subject: [PATCH 23/30] Add dev install instructins to official docs --- .../images/unstable-auto-update.png | Bin 0 -> 29164 bytes .../development/images/unstable-drag-drop.png | Bin 0 -> 20458 bytes .../development/images/unstable-icon.png | Bin 0 -> 8959 bytes .../development/images/unstable-repo.png | Bin 0 -> 28817 bytes .../development/images/unstable-restart.png | Bin 0 -> 16059 bytes src/bonsai/docs/guides/development/index.rst | 1 + .../docs/guides/development/installation.rst | 99 +++++++++++++++--- src/bonsai/docs/guides/troubleshooting.rst | 8 +- 8 files changed, 88 insertions(+), 20 deletions(-) create mode 100644 src/bonsai/docs/guides/development/images/unstable-auto-update.png create mode 100644 src/bonsai/docs/guides/development/images/unstable-drag-drop.png create mode 100644 src/bonsai/docs/guides/development/images/unstable-icon.png create mode 100644 src/bonsai/docs/guides/development/images/unstable-repo.png create mode 100644 src/bonsai/docs/guides/development/images/unstable-restart.png diff --git a/src/bonsai/docs/guides/development/images/unstable-auto-update.png b/src/bonsai/docs/guides/development/images/unstable-auto-update.png new file mode 100644 index 0000000000000000000000000000000000000000..7262145b579e51c44ffe62bf006c9966f380aa2f GIT binary patch literal 29164 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 Rf>+p4O7g05`PWTD{ue_C4!-~Z literal 0 HcmV?d00001 diff --git a/src/bonsai/docs/guides/development/images/unstable-icon.png b/src/bonsai/docs/guides/development/images/unstable-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b92fc7d46326c7863bf8134aedead5daad673ea9 GIT binary patch literal 8959 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±&6+q7vDiG-q&P+ysuA?9GpDgWW_gnjKyiZBHD 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 D)1HXB literal 0 HcmV?d00001 diff --git a/src/bonsai/docs/guides/development/images/unstable-restart.png b/src/bonsai/docs/guides/development/images/unstable-restart.png new file mode 100644 index 0000000000000000000000000000000000000000..cb4f13a46bbcacb80a2df5d04e21beb51d037bd2 GIT binary patch literal 16059 zcmeHtby$?$_9!JOf`}j@DTqi6G13ev-5?zfFu;I>Fm#HvgfxzHN(e|dA}Z3ILkYsr z-FaX5zVn^${O-Na@1EzmzyA(A@4P$KUTg2Q*Is+Y8>*%vOH6p55Ca2)SYA$A0|Nt- z9w}Zx#E~A7Rd|&1Nq#GGBmLD?+wsYv?vNL^q}} z4YM+>v~?czAy7&zcO?0$p9C1+#*H!#H`CQ%H&Y?k+@di##4p|F{84t+doFk~)R2RL z`C6d(UaoS3gh)A@I{YxyJ%=g(8~WRwM@H|CqL<6s?8D!3I%tno>lO~hjfjpDJ#_bmyN^7s+uP-EX3qXsw)*<>j<@v;=Vr z3JQX_ctAWn8~_A|le--P>c(N`^zaJBA2_68PG*j92L#;Sj{XWK)YRS?AiL=ncG#*^>Y3`5n%XVc>e|ZA8Wq`2B4Icg{1AxoUf)QFD=S=MPJC=-VAOo zbY0{UfI`817BCJTC=WLWSkTOrL(r5L%<+^D$_+M$S(x&3@%;^zyqyyQYG($!LIoh_ zgadfMJc2ynr!X)F%#6pJ0}STo<$wx;p&b1Dru<+tL4Gipi}!CRR2|`fR6<|=eO6bf z%mGx~W_(7v3o~IVP9AHzRWXjKH%E8CQWo~B1&1(kZ;=e{^ZYCsS?`R7Jwi9j( zwS<8j>@2T4t{5&Pp(ZcN$ivC?&la_pP=p0AK$KAlZs+Xw&kilPEld*uz2YV}KhINc zfD)eoH@6@+?;nJ&@oB>xod8L^!g;MbuZLf4ix99FfU(dkIRy}0w*zYtl5&JW5%!K+ z_VzDD8UNC{e|0MZ$H^RufJ#FVFaYUadRXfpdYFxyONi@_>0hB!wl{}cxc_(1SBHmQ z1b_#&Tn_F8(09LX`r|}t!e0GR`=k02etnea>8}rk5Y+6CB{)G{VCL8J1aSS)Wo8An zvxEWK<4)ge1OFEAf28aG8@mYq=}y7y04vB9aF*_*MS}oG3)fUpRvKd*V-|xU ziNhivXd!Tr({sYWxYc^~V7A$RGzS{-5c0~;@RkTLvGKT;K6An`Fz7Mlr6si7Ce~Bj zlgNi_FU9f-JPsmYFZ`&2FmF>;VH)~TC9shyg%I7|X2i*Q^vif2#J~6`tUaQ3ngyAJ z{&6p>)|xac6PHW7>h)P<*o{KcXxNN0bu@*7>q{yF|y-29$wY5 zSScTq6UO_I6Mh$?`B6(w=0}Rm@}8iJE; zbDt!FTDG@K$s;>&w{Pt8J*0j+D|r~vJHJN<8_GaCJL~6cCJ=9IXrpJL-aE+*fo1}^ zQSb))6UjUN>br~FbG7Kw?BB&1QoV4BAqLjar>;}qJ$Wr~E<^<^x^5_2n0X%j-iHrz zx?zc$tjp{!KcA}Ktp=5L8|&#ch&e3^`bJ18v|}?fQ%)YDCpHp?P2&B|lpa4$x3sVr z+jdI1p_d!V%sju?16!M{?b5AzXXCM#&IH=@zcGi*l08m?b(XTP`cGq^3#a5DG{aeV z32?@oGecWK@jIEeMI1yBRc{TRT6>dsJ71@Vt_jSPaJ}*+%(c1idW$CP(WH@YTVDn{ z2U{yD@+sIuDgiJ3 z{yuSS4KlgVI5GxiUR+{A>-qix4W2u9vQ7>>Zy6|((A{Fv${eCJM^W3;2Z$@QJMmA4 z$d2lPZSO~w=(TpV2ET_`y<1I6TOA8HS@l&^G(r(aM3`_I$f&ERq|-#@G6djry8aI7 zpr^FP1!tuDc_ofVDPIWo_y>@(zZZMG9T0nB$!$4b>hb)1kw!ZjUJ8!PQYTA0YH)}ko!6Id z=rl{k=XWn5UIJ21j!F8uKUJr_6YU$euxQBcL~EELE&9Tx6hh&8a-Cnxio^lV*_hiE z7us$gA?%He`krE!mA6hLi6S0bkk8y&9pU&n0+vPfuQ*LufJU?9x(q8qw9}&On~oSe zZ_#E0bVs=<(E@yS7Ycmgr7ov6-+ojcA4_$Ze^F7QTzVza1(t1ZCwI% zOI{o~4;k4<_{+1G+N1*|$R9F%4}^nqSHn9xvosY5NmP^kaAI!0Ia#y_TKHX>RfqgE zJZx3hGvl&0i)?79+LS%E#MMwB#e(a-&VDm*C}Nvp2ZyREM-7!xUkv{&LH%lb0w&E54Vn5`On{e78}(o9_%n=n+d>`~zp-84QS^+dDc=J%92{j@KAs z_qef?@o!$nv^g+gYj&j5?@PPSXcSeg`H@tv&m^#CG&DhSRq+;n<1o`_-?Wj!ZwYA- z(V3Yj=i@gp8@5C(ddj6~D_3 ztW30L^eQ#+1(LM5JngRQq{MQHYY@$)iu!F2VLX-=E;%K+bQ=$$^Lj)^?&s*c3k!zP zDaz_9w~(Zhf-mo(Y^?n)bYACuoWFbvDB25dGdHtQPFmxtn`!WYha3^WIVlAMB-N-n zHH(;dZ+VP+gPHNm%0tv_4uIUsYwJEY|z8$X%Anme9)Qr+5|`rzLl5U^70raV%b6V^(cahvj~1o{5UuJ5zwpl zK)U>C>d7P*$6LGQW&9Tl8B*MBXK%L>%Z;+bsi=FJgik+YuPV_AykpvDY~JO^M)uXa zj;-On7;13W)hJsmuJ?~o12+57-bVN7t1kbwg*MM}iyJXhxnYFHQ&kZ`2^3drF^QxO zmL1I(@rcKVJ0)M(m2}|=Ko5-#CEeBDXPkIbQc89$I6Hrq{gWZhvI_`UnNwk&3CXsGmL-r8nCA#!NcY4yu%i7Ia`?U5>xkY9tx236?< zE#o8F7hrdu8G{Caq6m``U5SFW`4s~OoHT81-0_nrY`oGl^yJB!w#ib&`LB!f1}+%k zmDV+=ca?px>k_3*?u+@_#aBnaBXb=!A4Y8Ho2FZzP!`Mb{;4^BnzmLci+PseZ8YuH zQhyfA+1dFUQEKzH3}JbBYwG0p-9@OvvJlaPkwK^Hv={GIs1lPGgznR)PZFwBP}Oa? zr*l^0Z?cQZ>f+Vc^Fh9Sk4H*Li~Yxe#$6*QIW z-8D|fAgv}wR91G>Ih3#D_A5|)X3c4!8C-g526A3&o`-i<4MR4z-Tc==7BxMcCXFw2 zFNnAMn4{Mf`DcPA9tO}eFfgno8+d1woIO>(Q!+}u<(tjT|G-mrorap&LhrUsSi-ktB_yJ> zQL5{8DSSayw^!)+XX?i1T{tV6N*5YuRM7kVKh6w)Jc?;fUY~IO5f;&RArxDx071iX z!Jl32eGuJG5eo95gQQ4 zVw?TjpCq?$*h#WYqy=d0s6U>Q@_sLoq4p6cTkWwHmd2kZTcCnCU=(JM0rr&|fn3I) zT~9Ds$qE1HVx|YWue+{Cl3#aS8GC_@E?6g)Beu`h8jhi^rDn&WWxdJ9lK5~q~GBQ>9JR&uNEN9f+)Ku`M0roGxDZnn@(?^Xn1 zd$ZPUnrI~x(F;G_kJz{>I32{*9XVNyxeD(eyZaCO%Hv}f~kr;fhHyAx0sk^Y&;$zv9$UbpAUted-&n1G0?5N{ng{~rGE7`Hsr?n$jZvr zXNQ$0*-(_+MHB@(;}t16n&BekaC&8?mbcf~S9HdAmBZyEkP>t(n*R2AwYR$-Rp9t| zQf~WCT2TQFliuN}I;bASxv*>G$3xK!osy)Jm2K;&URpkOrI|)im$pK62t-UQ6SwvQ zZT$yhZ{%B_hVyr!d6z0P0;gy4?t&T$jst`4?z|*XPTHMg!&Kp}& z1DF*qOzNY0&P$}o+`!@Ysqel_WXYPX6t)!-*}u2w>&m`fN$UHgpWD2ahHZ~xXirP= z?{C<3M(UoE`~=@AM%y)eHJ|Lk6A}|2f+INMm$t;%|3}7Z+IE``5!0vnCOR* z`UA!}{vI*IyM((;;>!=x4#E>ZL{wEoe%Ppw8*n~b`GCB~{X%>axwo&Ze0yH3G`X^f z%lDg;y*w-T>!^-SP_1iBn-}Z(_gs{njj(M&k-3r&Bt=8xXm#XM*W)LqKTGm`# z%pox{;%GF}^!of(wa^W<5AR=z zNTi%B^>@_nZ=&aa+%Tdn!1L`L)^XYlxxH3cm?h=N^QPW~d7SI<#yD?RP~X8EO^FDT9*sF)br;o;Bw!=ax z^6!p*GH^%CNh7@=fL$7sg1rB^WSyo&jXK5U{MD4u^mwzF16@tT3`_7WsQfsm@=4ck z^W4Cp^0Qfq;+1}EHh;0X+G>m)US5!V%1KDaN6JFGdhh9xZ} zhaaFjKB}v~lTq;5<%1oc^gss5`f6>sPIglr$E&py#t0M7r^`}N!4va~QM!i-yT-L~ zd>Llet!#iVe~iS;pYaDg^0$YYT>rN_K=S%}xV}C9n~UYY-X3!ecb+F$io5U~Qc}jI z#C%-_43`=^8aBW^BqLH9aGpj&Xu7wjmxcmxE@p%`ON%oZKQE`i zH>VGLH_W9oQ*1yw&YQGq!*M2GoHK5&3Td6Fp zHg`!VeMO|%T;-YRS((LX1Y?GSNp{`fto$VRzNpGXnxB6*0}C4``;4r@x}17%OhZpA zA>K#JOZ_j4KOQMxQ1Mtn&>L?)HU-xukqTo6{w7uUsB4 z^*7WyvF!5MnV-C45K+_k8h3*TOG0g^k`3@~YIpZXvAshUd&`YMVhtzO6n3Z|iXZrS zx63-+tj^pL)~4PDBsEbbrF6%!70=3UHb@orQuZ(Qz6lF{D@M!S8K%2oI!yF7$qzWW z%c;>VgR3J9;D}Mc&q6(W7T(oO{FrM(Rp7MyVxX+XdyLB%_4!S?vAwuo5A8>HYW}iM zWifg74czpZl&gl}OJVsrKF)_5lO~2;zfL>=e{pL`=(5^Jl$E!X zI7UU%G&DJ}Ad59Uj<_!EzlHEU!!p8Stwkek++Gbn@-R1l7at=zp0BBXUc2`NQ-$wo zbZSxv^qx5r^Q*bF@v0BHAs?H#17`ESe`lYpiZH6Igl2kdPp11eU3gHePqvGlL870~ za<;p@(~x1$Gs^aW>Sw33|Ln-j{d~J54s#qC7KB@umvh5tHBSHiP&Ekf)*Bj}ZIW6f z@eh&(UT?fJ4@Db-Ip1dF(DsS+{+vB;^gJHv1o7Ip;sRo3K8uvATeNdlWq*t@8*huU zijMgffU`lx?SD7;){Th|+*YJOTtKJV4tA0*p|mzdJ-3CPB&JT{Hpge%sCz4D_G1f8 zOpT>lCD#(4S?-b~ zYG27(`#R?M4xH=;rx=~6kb{{> z{i}!eYvZlc0u@6!amm)!rrR4+fG53L@3KvoC8Iu8%j;KITnp2?y;_yFg>}^OT&KTU zlf|O>_z~GsDzf7fHlE|<>89_aUMvZS)2?M^@oxF6`J>&F{e}h@kxBEJ;|2$(g{Lh? z+kNVXCo&4{*`1v8XhqyfyuMiO!U$Xo?vzaq(yr=yAHH zBhT)+YQ3Clv6U*g+DSt}J1&uT`^#(kUr&_>YqrDK<_0vX-$)NSE2WVrhkTh3j43{@ zCM0?|!r_Msz6tf(oIZ1CCtE>&=yy)(KRyPcIA_oua}1u?7>vHa*-c^Bx|3ZiZ&ASF zVxqe}vg)5K?mTy|yk_#$2>lb~e? z6I+hwrZx>ksSL{HJ^RJ)R%Hdd#Vy*dOd8>>ljp+gs?y1$aLM$*5P}KLP(;s#!RY=M ztzjSJx2&97@1o7Hwai0}OdP5@lz&N4|50Cq*jxXj+jq8pr$}}~Pf5k9&9nWF| z_iUkCO~4!`s~}>>e8@4e>*i0smFR`ezP*o(Hs`U!V6 z70=u4z23N|`6xV-!UiIo^Co!2Un|&s>P?>+&;^EkL~m$U?^U0^DJ=QXS5c)bF6P>N z?$YewJO6^r$59(x5sL2sAAPwa}H+_YCt>vpqRl z5W8q;y~DEe>8s>@eSNpBRucI3@z(?qJ4l5k`F*a_Zhk?BIcf15V6A7*zqfv;A!$BZ zdI$uwC_X3eV*9-f1q_8Mmzqyv>v3Ny#Re*{TSkg*#h+A0p;Qe9!EeBw>v>6{;&y1s z9IvSpw8-L+2Hw5*iEX`YkqaL{O(BS%sEL>#(G^vIxsx*bK9>srkwN)neukYMg>lJY zxhR%zt(vdU{+E=xEtiy@14(bQ)1gm2Zeqmc>})SAm%8M>x8~d>QE3iL7zBM)ZOOE5 z#T#kA*kl*T^Gxv7rzv>0ar0I+G2dyOTEvE1&-WFRrJ->jNBJ-QRjS02hfrsT->USaOfHQX9Wrka9yvR&}UsZe+&C>U4&CL@SEX^(Q;!+d@;mf^{!MHZ^2Q=sF5mux#C zBw;sa1o>mSP6#l8V-llVwtxKa8>!mr>{a1l|CY51_#Hc_0gZ#9kveO*r*bS_TO>@k z+Ab;K`wEbdm|7Xeq(YT?JV43-)=&?S*wA{)7c;l*aJpK;>$Ei+Vk?X~ni2ar zsq4vv54w4E-`A#vtWC6KCMSyv(_ShzzUiP5BwJ*OGU+N-)W4q)rv(PTi;p88R*gzX zhymO*@9*CqA)dyY~0!YiYg)PPo|rpccA>xk7=}LM`nOE_QL8u35Q7}GqWrCUG?O|Hdji}VpYqV z)|DwuowjsNBexfOR!x;JBoL>P7UXsw8+3pZn1iT0nV_5vxv67LLnS? zUo3&5k#{?Af_53=AnXG>+~{Iug|2)J)Yaheo)wotqn!>>_7CY-MDLCK$f>}KdvTA+ zO(cA2JeIAjeBArYt?w9fe0-A6iqr{m#n zJ_~m(*!2S#aPn!RY(!m6++j(@t}fP3R7(un!k@i;yf!J=u>Pyn2qnjN6Y!^{3wzu6 zO}wIEcqDZP+Y@JAO0vS`#-0%zNj>vDj18-Rr-~MjWNzESp$NX z%CVDsh}4;yXlit?rK~dXAR!T8Mid$F=TcDY#JbKbP=gx8ky0pDeQy;y!E;J$Unsg~ zQeEqtFNaY_fcx&4u~D~gMxRWI-nv zBXfdMC@Ulz4WSoCRdO(np;Ea^+1WluE-WlaoJZw6+dNRK?gc{jPK_j!k$LxI`ic}3 z#&dtKB;Iyx#Ctf2mrwZ~qC4a&NyOlWM$O1zbVKNTXFk7)>gc!^ok)ovdAqh22wna9 zMR4a`#jspV6OKCLjQuLUK(7+7xhTcYJ);>7M4F&bqE>x6ci1gGO-$ zd1bSQ)R8n&gU&Jw3xV;1Qv+X9k;qcM`v&@0b52!7?GVbw&F}ym>-*H$>q|kHxl~%5 zN_a*Jk3=s;H1Ih$heE}aQoDd-^+Y8tawQMQHn0z7mwk?oxO*30+3aw8KAi3G7qD^= z`tKIb zGH*{69ZD5!9rZZ0KyT0ghFR<^ZcKHtKMB(fx-M)M$*>w zGDS~ME1l4|IFmpKpU@h`n)X2CCrKhZyuL@wil5}c&%@#d9yvt=dhrAS==3Z%# z@SmM#7V5WWymysHJ%C}Fh>F0ufXjtBJ6HfI)bHMW0D-MwIqg3BS*~#*U z(?{gUfu4?@c~7{`;jod=n5wC$s9}Zu&b#gIp2n<)D!KSlmMvYrpC9nWfoaZa|HjD% zeuY{$r>EW{tHE?KpIWms+xcc~h2?_U;?vb5Sv{)DfkMhg9$r1C-}sgd(_d2G+BEjO zSH?MI6ul|6Kk1ztTERYAl`Te*d{E_jbFIwI43zUb_u_nhAk#H#vFm;5y%Zpi5|<)y z+vV)^Ev4B+dtVfb47+qaNyq9cD1|v`QOBs_5{?Y&EyL1B0YO6O3zb`~j!uA8bEGa$ zAVwpK>=bgjIXFNe{>le(7)8}e4H(EUc){WN%Qig~X}y9+kC^(Nil$r`)u|1R-5XQ|5Z-}Ey(&Fe zQYQqmCXZS0l=1CWX2kk@MD}ZcRhYG2`h5Yu6HvPP768ak0QnS}kC#2r)-hwvHy|eU zn&WmAtCG!0`R1OK+DD)$X3qoqfENqDTDeyvU?z{F_a=3|mA1e)Yr`e7KPRfsv1q-W z=I{ZsFNlbKbGw|yIh+y`cY$=Uu13)CF6w!q|0xdUx*Y=!5FY#5ab|IymiRbD zwmR{H%j7suN6%5^Y6N-K=M56iP^$ImSM?;x=Ywa8BOt|W?gAd`Y+{|$5W|;O6yJQCd}9(TsHgR2ANrPqxDF+R9&RQ3iA=`cpkFlex=v#du_r+2;vXK7_Wy zT2UfH5X%(1D&bWk1C`)0=%$VIj(gvNabpK4C@R0Rr%m(b+Z|Rd|3?t3YG-h6HgmO8I`o}m`Nk|Adx zc$+_CKBZTy#H4pwD!67X!QkF@pA?hx+D8j1AQQtmvCo>c1*%TIM5-pg7{(@1d#0s@9%WgxS0Zl>#a{3gOg#}ofvBM&? zJUhFnxLeuT3%})&t1Da@8glC+5tuXQoK%gT<;UA?&&h;(llKeFA>1&+pg6<^DltC_ z(y={NpRZTNo+%Pe&5`iz;%K|bcZD_P{Jv&dwV~A|Po1;bNvvuN_se^h$Pe5(+3jF? z;n9F!Zi%k3CaRy>!OyWyo-os!Xx^cw^(GIXuhnD;nsGHj#go95|M@e$ zGUK%hHZIp*o7khx2^;vQ5=(T0`|SnmyJ=3iOw7i<{;DJ30Yr)>45qa?qv~i(`$=QK zzJvTkDK&g?+o7r|q1*T54lxXN^SnRmCoLEsj?}W9{F#uKwm8v7D|P5rv+>??4C2Z; zygJIeCo7iO`Hlf&XnndnFG^IV?W<(aloN?%BpqIJdRn>R`pJICyNjIrxU5JSYZtx* zn7YC9XBslNWLxr~H^?UunrbHeqWk0oR8%(`PwiCbgk|(qZVi-;?fs4guT4kboT#L! zw2U?djZY{X2NBatcbx>Co^Hr1n2t9dnttSX^VXLHp8@MsgocPn0^g-8MR9ndlKgU! zU&&*cx!8M=N~t7qKT5>Eg56`r0XvjN=&`B^1`pYt<)kFJw3au^j^i+`b(HMNLZZ`n z94ou~5i~WGFXExFOP~D+J5TVfoZZ=$1_yLXgV*8I5h7*e+qbIbM&revWO+R}Yv}PY z*t4-KP4t6%^^A}?ZGGP@uBFkh1;;c@}Qe)fyR{nyL|huX#qI6SE-u@7v~c*axR zut%mz=NazJrsMdvWY_|frg3^eylOM^o)^C7A>Y{!9WE^4**rkykCg|Xwa0GjQvm6@ z0rZC%^KIWte&1AXYz}7T^mU3)kowJ%^I1Hoe`wfS2VACr^vw$R-Usf7r7bUyy&f1e zB{x-1O152`OWI+aZw!~BS5SfYoDs;QhV5Xpm#IjlRUnVc0E17 zoSgE%@j$mK+{UgsCGHkY64+7&<;U`90C55iK3F%af$!{_ocR~5qsI4E3Uw`eV}6@d z0Wl~7)??y@g8o1L7(sCI&A8Vq;b`R6e!*Lk4Flia&2Aqv+Pwh@3X_ZT$t@nhSpV0Nbe={n^Cg1N&298L*!@}cm zf^ca#qM2>Q+y>nS?FS|ah49}dnl$m5f}#FS*s8nHYSo#Ik^Tk_V8 zAYtMYbrv8$#X3fTB{_i+&F37a6dZ(dyDlKk#jSyy2ggefy_A);?8m;OHebYt`(q6_ zI!WLot!FNyR7|;j-X!dZx%Zb3tLJ``k6gm{(g_{QewuY z7&-^&Np$0(?#a=75`<2Es@_aK6s)XKUcQY57xmO=^cAx~_o5Ln=9JxCu@BD2_Ex93 zRh{klr>s;21PCRiY@0+*#QJE|U}fczS5pAJByOf z9Ki_W9Be=VTAVoDnD5as&d6^{jlFxh&+@Sg9*T6M{3OVMqT_vRN z%=d>lNU@f&nb+I+5;3{`rh2v}gyjA>%2IdyrIlORn6b93^j~bss49K4)N=z9cQ==q zos^W9+^txyWI5_&S{6z$qhZZ$?x38cQ2ZL|D za_pszFe6C@=yHLpsV;l?O(u8C+|uNU9P<5Hf}g&=zQBunh7@g}IaNBruaUJ*@ho=< z1KD6f5`~wV18RnJiu?QfMaoL%&AtVNYLCg+pJCzMd@~#xI)pOm4z~JAl0>z;cCC@A=*B^?SDu{Q^jn1zuqdf`>P2$O)g--`__, -then follow the usual :doc:`installation -instructions`. +System requirements +------------------- Bonsai officially supports all major 64-bit platforms, as well as the Python version shipped by the Blender Foundation for the most recent three major @@ -41,6 +37,76 @@ Other system specifications match the `Blender Requirements Sometimes, a build may be delayed, or contain broken code. We try to avoid this, but it happens. +Unstable installation +--------------------- + +**Unstable installation** is almost the same as **Stable installation**, except +that they are typically updated every day. To install the **Unstable** version: + +1. Open up Blender, and click on ``Edit > Preferences``. + + .. image:: /quickstart/images/install-bonsai-1.png + +2. Select the **Get Extensions** tab, and press **Allow Online Access**. + + .. image:: /quickstart/images/install-bonsai-2.png + +3. Go to the `Bonsai Unstable Repository + `__, and drag and drop + from the appropriate link in the ``ID`` column of the table into Blender + depending on your operating system. + + .. image:: images/unstable-drag-drop.png + +4. Enable **Check for Updates on Startup** to get updates for daily Bonsai + builds automatically. + + .. image:: images/unstable-auto-update.png + +.. tip:: + + Instead of drag and drop, you can manually create the repository: + + Open :menuselection:`Topbar --> Edit --> Preferences --> Get Extensions + --> Repositories (Top Right) --> "+" Icon --> Add Remote Remository`. + You'll see a window similar to the one above. + + Use as URL: + ``https://raw.githubusercontent.com/IfcOpenShell/bonsai_unstable_repo/main/index.json`` + and enable **Check for Updates on Startup** if you want them. + +5. Search for **Bonsai** in the top left search bar, then press the **Install** + button. + + .. image:: /quickstart/images/install-bonsai-3.png + +.. warning:: + + Make sure the extension you install has ``raw.githubusercontent.com`` as + it's "Repository" (not ``extensions.blender.org``). + + .. image:: images/unstable-repo.png + +6. Whenever a new update is available, you'll see it in the bottom right + :menuselection:`Status Bar` + + .. image:: images/unstable-icon.png + +7. To update, click on the update button in :menuselection:`Topbar --> Edit --> + Preferences --> Get Extensions`. + + .. image:: /guides/images/update.png + +8. After an update, be sure to restart. + + .. image:: images/unstable-restart.png + +If you wish to install an **Unstable** version offline, you can download a +daily build from the `GitHub releases page +`__, +then go to :menuselection:`Topbar --> Edit --> Preferences --> Get Extensions +--> "V" Icon (top right) --> Install from Disk`. + Bundling for Blender -------------------- @@ -72,12 +138,13 @@ Live development environment ---------------------------- One option for developers who want to actively develop from source is to follow -the instructions from :ref:`devs/installation:Bundling for Blender`. However, +the instructions from :ref:`guides/development/installation:Bundling for Blender`. However, creating a build, uninstalling the old add-on, and installing a new build is a slow process. Although it works, it is very slow, so we do not recommend it. -A more rapid approach is to follow the :ref:`devs/installation:Unstable -installation` method, as this provides all dependencies for you out of the box. +A more rapid approach is to follow the +:ref:`guides/development/installation:Unstable installation` method, as this +provides all dependencies for you out of the box. Once you've done this, you can replace certain Python files that tend to be updated frequently with those from the Git repository. We're going to use diff --git a/src/bonsai/docs/guides/troubleshooting.rst b/src/bonsai/docs/guides/troubleshooting.rst index f8f0aa9290..40efd29306 100644 --- a/src/bonsai/docs/guides/troubleshooting.rst +++ b/src/bonsai/docs/guides/troubleshooting.rst @@ -30,13 +30,13 @@ Updating We always recommend to use the latest version. -Open up Blender, click on ``Edit > Preferences``, and select the **Get -Extensions** tab. If an update is available, you will see a button next to the +Open up Blender, click on :menuselection:`Topbar --> Edit --> Preferences --> +Get Extensions`. If an update is available, you will see a button next to the **Bonsai** add-on. Updates are typically available every 2 months. If you need something more -frequent, check out :ref:`devs/installation:unstable installation` which is -updated every day. +frequent, check out :ref:`guides/development/installation:Unstable +installation` which is updated every day. .. image:: images/update.png From eb3104367ab965c61b8ca98e81a2bd2a139c10ed Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 13:23:54 +1000 Subject: [PATCH 24/30] Bump version to v0.8.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b4d6d12101..a3df0a6959 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.11 +0.8.0 From af4475fe4639380cb6764909167afab6b082235f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 13:26:58 +1000 Subject: [PATCH 25/30] Run black --- .../ifcopenshell/util/cost.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index bb3df756bb..1844c76eb0 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -245,21 +245,27 @@ def get_cost_item_assignments( for product in get_cost_assignments_by_type(nested_cost_item, filter_by_type) ] + def get_cost_values(cost_item: ifcopenshell.entity_instance) -> list[dict[str, str]]: results = [] for cost_value in cost_item.CostValues or []: label = "{0:.2f}".format(calculate_applied_value(cost_item, cost_value)) label += " = {}".format(serialise_cost_value(cost_value)) - results.append({ - "id": cost_value.id(), - "label": label, - "name": cost_value.Name, - "category": cost_value.Category, - "applied_value": get_primitive_applied_value(cost_value.AppliedValue) if cost_value.AppliedValue else None, - }) + results.append( + { + "id": cost_value.id(), + "label": label, + "name": cost_value.Name, + "category": cost_value.Category, + "applied_value": ( + get_primitive_applied_value(cost_value.AppliedValue) if cost_value.AppliedValue else None + ), + } + ) print(results) return results + class CostValueUnserialiser: def parse(self, formula: str): l = lark.Lark( From e072870fd38139a8fb9050fc21727fad580e459c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 13:35:57 +1000 Subject: [PATCH 26/30] Fix bcf pypi build --- .github/workflows/ci-bcf-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-bcf-pypi.yml b/.github/workflows/ci-bcf-pypi.yml index 4b70c77e0a..75034acc2f 100644 --- a/.github/workflows/ci-bcf-pypi.yml +++ b/.github/workflows/ci-bcf-pypi.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v2 # https://github.com/actions/checkout - uses: actions/setup-python@v2 # https://github.com/actions/setup-python with: - python-version: '3.10' # Version range or exact version of a Python version to use, using SemVer's version range syntax + python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified - run: echo ${{ env.DATE }} - name: Get current date From 296ebf5253fc5cc5eee0e06fc621d800217e4054 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 14:08:52 +1000 Subject: [PATCH 27/30] Fix failing tests in Bonsai --- src/bonsai/bonsai/bim/module/light/prop.py | 39 ++++++++++------------ src/bonsai/test/bim/feature/light.feature | 3 +- src/bonsai/test/bim/feature/misc.feature | 2 +- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index 23dcf9228b..acc25f3b42 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -52,41 +52,28 @@ def get_sites(self, context): def update_latlong(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.latitude = self.latitude - sun_props.longitude = self.longitude - update_sun_path() + update_sun_path(self) def update_hourminute(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.time = self.hour + (self.minute / 60) - update_sun_path() + update_sun_path(self) def update_date(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.month = self.month - sun_props.day = self.day - update_sun_path() + update_sun_path(self) def update_true_north(self, context): - sun_props = context.scene.sun_pos_properties - # Preserve IFC sign convention - sun_props.north_offset = radians(self.true_north * -1) - update_sun_path() + update_sun_path(self) def update_sun_path_size(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.sun_distance = self.sun_path_size - update_sun_path() + update_sun_path(self) def update_display_shadows(self, context): if self.display_shadows: - update_sun_path() + update_sun_path(self) context.scene.render.engine = "BLENDER_WORKBENCH" context.scene.display.shading.light = "FLAT" context.scene.display.shading.show_shadows = True @@ -102,7 +89,7 @@ def update_display_shadows(self, context): def update_display_sun_path(self, context): if self.display_sun_path: - update_sun_path() + update_sun_path(self) SolarDecorator.install(bpy.context) else: SolarDecorator.uninstall() @@ -113,7 +100,7 @@ def update_resolution(self, context): context.scene.render.resolution_y = self.radiance_resolution_y -def update_sun_path(): +def update_sun_path(self): if not SolarData.is_loaded: SolarData.load() @@ -122,6 +109,16 @@ def update_sun_path(): props = bpy.context.scene.BIMSolarProperties sun_props = bpy.context.scene.sun_pos_properties + + sun_props.sun_distance = self.sun_path_size + sun_props.latitude = self.latitude + sun_props.longitude = self.longitude + sun_props.month = self.month + sun_props.day = self.day + sun_props.time = self.hour + (self.minute / 60) + # Preserve IFC sign convention + sun_props.north_offset = radians(self.true_north * -1) + props.timezone = tzfpy.get_tz(props.longitude, props.latitude) timezone = pytz.timezone(props.timezone) dt = datetime.datetime(sun_props.year, sun_props.month, sun_props.day, props.hour, props.minute) diff --git a/src/bonsai/test/bim/feature/light.feature b/src/bonsai/test/bim/feature/light.feature index 86dbdbf633..831d6f25bb 100644 --- a/src/bonsai/test/bim/feature/light.feature +++ b/src/bonsai/test/bim/feature/light.feature @@ -5,7 +5,6 @@ Scenario: Viewing default solar settings Given an empty IFC project And I look at the "Solar Access / Shadow" panel Then I see "Etc/GMT" - And I see "Sunrise: 06:02:50" Scenario: Changing the month Given an empty IFC project @@ -33,7 +32,7 @@ Scenario: Automatic timezone detection based on lat / long When I set the "Latitude" property to "10.0" And I set the "Longitude" property to "20.0" Then I see "Africa/Ndjamena" - And I see "Sunrise: 05:29:43" + And I see "Sunrise: 05:56:42" Scenario: Display the sun path Given an empty IFC project diff --git a/src/bonsai/test/bim/feature/misc.feature b/src/bonsai/test/bim/feature/misc.feature index b4c76c3401..d418ee3ea5 100644 --- a/src/bonsai/test/bim/feature/misc.feature +++ b/src/bonsai/test/bim/feature/misc.feature @@ -27,7 +27,7 @@ Scenario: Resize to storey And the object "IfcWall/Cube" is selected And the variable "storey" is "tool.Ifc.get().by_type('IfcBuildingStorey')[0].id()" And I press "bim.set_default_container(container={storey})" - And I press "bim.assign_container()" + And I press "bim.assign_container(container={storey})" When I press "bim.resize_to_storey(total_storeys=1)" Then nothing happens From 32f216a594d2100b26b92ff3aa1959eb21f69e88 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 14:23:29 +1000 Subject: [PATCH 28/30] Minor fix to bonsai-stable build --- .github/workflows/ci-bonsai.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-bonsai.yml b/.github/workflows/ci-bonsai.yml index 362f8ecbda..1bf217da89 100644 --- a/.github/workflows/ci-bonsai.yml +++ b/.github/workflows/ci-bonsai.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py310, py311, py312] + pyver: [py311, py312] config: - { name: "Windows Build", @@ -32,15 +32,15 @@ jobs: } - { name: "Linux Build", - short_name: linux + short_name: linux, } - { name: "MacOS Build", - short_name: macos + short_name: macos, } - { name: "MacOS ARM Build", - short_name: macosm1 + short_name: macosm1, } steps: - uses: actions/checkout@v2 @@ -53,8 +53,7 @@ jobs: run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT - name: Compile run: | - cd src/bonsai && - make dist PLATFORM=${{ matrix.config.short_name }} PYVERSION=${{ matrix.pyver }} IS_STABLE=TRUE + cd src/bonsai && make dist PLATFORM=${{ matrix.config.short_name }} PYVERSION=${{ matrix.pyver }} IS_STABLE=TRUE - name: Find zip file name id: find_zip run: | From 73e3ebd1f06eb25fc566421e3f0682d3395b19e2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 1 Sep 2024 16:08:45 +1000 Subject: [PATCH 29/30] Update README.md with new doc links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 619e403cd7..935cd7e94d 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ For more information, see: * [IfcOpenShell Python Hello World Tutorial](https://docs.ifcopenshell.org/ifcopenshell-python/hello_world.html) * [Bonsai Website](https://bonsaibim.org) * [Bonsai Documentation](https://docs.bonsaibim.org/index.html) - * [Add-on Installation](https://docs.bonsaibim.org/users/installation.html) - * [Exploring an IFC model](https://docs.bonsaibim.org/users/exploring_an_ifc_model.html) + * [Add-on Installation](https://docs.bonsaibim.org/quickstart/installation.html) + * [Exploring an IFC model](https://docs.bonsaibim.org/quickstart/explore_model.html) Development is sponsored through your generous donations! From 6bc31105bf9dd5d450f123442f0d5dc7672a1ab9 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sun, 1 Sep 2024 11:08:52 +0500 Subject: [PATCH 30/30] Mention common error in troubleshooting docs --- src/bonsai/docs/guides/troubleshooting.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bonsai/docs/guides/troubleshooting.rst b/src/bonsai/docs/guides/troubleshooting.rst index 40efd29306..9a87577b65 100644 --- a/src/bonsai/docs/guides/troubleshooting.rst +++ b/src/bonsai/docs/guides/troubleshooting.rst @@ -94,7 +94,8 @@ Blender geometry that represents what the model might've looked at at some point. At worst, you might be looking at a completely wrong model. If you continue to open and save ``.blend`` files, you will run the risk of -editing something that doesn't actually exist in your IFC model. This will +editing something that doesn't actually exist in your IFC model (e.g you can +meet an error similar to "RuntimeError: Instance #1234 not found"). This will create unpredictable, and sometimes unrecoverable errors. To avoid this issue, only open and save IFCs.