From f7181aae8a434ab8426f682bcb3f1c1346fcc2b1 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 20 Mar 2023 15:38:09 +0500 Subject: [PATCH] Multiple literals in 1 Ifc Text + aggregated text annotations Added option to store multiple literals inside 1 Ifc text object. If multiple literals share the same `BoxAlignment` then only the first one will be displayed in viewport to prevent visual clutter (although added small asterisk symbol to indicate that there are still multiple literals). Added option for aggregated text annotations. Example - "door-tag" symbol. If you create text, specify "door-tag" symbol in EPset_Annotation.Symbol \and add two literals to it then they'll automatically will fit to that door-tag in the svg drawing (https://i.imgur.com/OXaIlWe.png). The way it works - you define symbol in symbols.svg (or in your own symbols svg file) and then add the `` fields where text from literals will fit right in. The imporant note: `` tag must have attribute `data-type="text-template"` for it to work. For example: `` --- .../blenderbim/bim/data/templates/symbols.svg | 2 + .../blenderbim/bim/module/drawing/__init__.py | 3 + .../blenderbim/bim/module/drawing/data.py | 46 ++--- .../bim/module/drawing/decoration.py | 112 +++++----- .../blenderbim/bim/module/drawing/operator.py | 112 +++++++--- .../blenderbim/bim/module/drawing/prop.py | 35 +++- .../bim/module/drawing/svgwriter.py | 193 +++++++++++------- .../blenderbim/bim/module/drawing/ui.py | 73 ++++--- .../blenderbim/bim/module/model/workspace.py | 7 +- src/blenderbim/blenderbim/core/drawing.py | 8 +- src/blenderbim/blenderbim/core/tool.py | 3 + src/blenderbim/blenderbim/tool/drawing.py | 118 +++++++++-- src/blenderbim/blenderbim/tool/geometry.py | 1 + src/blenderbim/test/core/test_drawing.py | 6 +- src/blenderbim/test/tool/test_drawing.py | 20 +- 15 files changed, 480 insertions(+), 259 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/data/templates/symbols.svg b/src/blenderbim/blenderbim/bim/data/templates/symbols.svg index ea0e4b3630..8b09f0e441 100644 --- a/src/blenderbim/blenderbim/bim/data/templates/symbols.svg +++ b/src/blenderbim/blenderbim/bim/data/templates/symbols.svg @@ -5,7 +5,9 @@ + + diff --git a/src/blenderbim/blenderbim/bim/module/drawing/__init__.py b/src/blenderbim/blenderbim/bim/module/drawing/__init__.py index 8286edcba8..652647b2b7 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/__init__.py @@ -40,6 +40,8 @@ classes = ( operator.DisableEditingSchedules, operator.DisableEditingSheets, operator.DisableEditingText, + operator.AddTextLiteral, + operator.RemoveTextLiteral, operator.DuplicateDrawing, operator.EditAssignedProduct, operator.EditText, @@ -71,6 +73,7 @@ classes = ( prop.Sheet, prop.DocProperties, prop.BIMCameraProperties, + prop.Literal, prop.BIMTextProperties, prop.BIMAssignedProductProperties, ui.BIM_PT_camera, diff --git a/src/blenderbim/blenderbim/bim/module/drawing/data.py b/src/blenderbim/blenderbim/bim/module/drawing/data.py index 0d695ad672..5ce3aeab2b 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/data.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/data.py @@ -24,7 +24,6 @@ import blenderbim.tool as tool def refresh(): ProductAssignmentsData.is_loaded = False - TextData.is_loaded = False SheetsData.is_loaded = False SchedulesData.is_loaded = False DrawingsData.is_loaded = False @@ -51,27 +50,6 @@ class ProductAssignmentsData: return f"{rel.RelatingProduct.is_a()}/{name}" -class TextData: - data = {} - is_loaded = False - - @classmethod - def load(cls): - cls.data = {"attributes": cls.attributes()} - cls.is_loaded = True - - @classmethod - def attributes(cls): - element = tool.Ifc.get_entity(bpy.context.active_object) - if not element or not element.is_a("IfcAnnotation") or element.ObjectType not in ["TEXT", "TEXT_LEADER"]: - return [] - text_literal = tool.Drawing.get_text_literal(bpy.context.active_object) - return [ - {"name": "Literal", "value": text_literal.Literal}, - {"name": "BoxAlignment", "value": text_literal.BoxAlignment}, - ] - - class SheetsData: data = {} is_loaded = False @@ -199,8 +177,8 @@ class DecoratorData: return result element = tool.Ifc.get_entity(obj) - if not element: - return + if not element or not element.is_a("IfcAnnotation") or element.ObjectType not in ["TEXT", "TEXT_LEADER"]: + return None props = obj.BIMTextProperties # getting font size @@ -217,10 +195,22 @@ class DecoratorData: font_size = FONT_SIZES[font_size_type] # other attributes - literal = tool.Drawing.get_text_literal(obj) - box_alignment = literal.BoxAlignment - text = props.value + props_literals = props.literals + props_literals_n = len(props.literals) + literals = tool.Drawing.get_text_literal(obj, return_list=True) + literals_data = [] + for i, literal in enumerate(literals): + literal_data = { + "Literal": literal.Literal, + "BoxAlignment": literal.BoxAlignment, + } + if i < props_literals_n: + literal_data["CurrentValue"] = props_literals[i].value + else: + literal_data["CurrentValue"] = literal.Literal - text_data = {"text": text, "font_size": font_size, "box_alignment": box_alignment} + literals_data.append(literal_data) + + text_data = {"Literals": literals_data, "FontSize": font_size} cls.data[obj.name] = text_data return text_data diff --git a/src/blenderbim/blenderbim/bim/module/drawing/decoration.py b/src/blenderbim/blenderbim/bim/module/drawing/decoration.py index feb43be55c..4ab398f270 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/decoration.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/decoration.py @@ -2028,8 +2028,8 @@ class TextDecorator(BaseDecorator): DEF_GLSL = ( BaseDecorator.DEF_GLSL + """ - #define ARROW_ANGLE PI / 12.0 - #define ARROW_SIZE 16.0 + #define CIRCLE_SIZE 4.0 + #define CIRCLE_SEGS_ASTERISK 6 """ ) @@ -2040,54 +2040,34 @@ class TextDecorator(BaseDecorator): layout(lines) in; layout(line_strip, max_vertices=MAX_POINTS) out; + void circle_head_asterisk(in float size, out vec4 head[CIRCLE_SEGS_ASTERISK]) { + float angle_d = PI * 2 / CIRCLE_SEGS_ASTERISK; + for(int i = 0; i 1: + verts = [obj.location] + idxs = [(0, 0)] + self.draw_lines(context, obj, verts, idxs) + + box_alignment_used = [] + for literal_data in literals_data: + box_alignment = literal_data["BoxAlignment"] + # Skip literals with the same box alignment to prevent visual clutter in viewport + # User is still indicated by the asterisk symbol + if box_alignment in box_alignment_used: + continue + box_alignment_used.append(box_alignment) + + for line_i, line in enumerate(literal_data["CurrentValue"].split("\\n")): + self.draw_label( + context, + line, + pos, + dir, + gap=0, + center=False, + vcenter=False, + font_size_mm=text_data["FontSize"], + line_no=line_i, + box_alignment=box_alignment, + ) class DecorationsHandler: diff --git a/src/blenderbim/blenderbim/bim/module/drawing/operator.py b/src/blenderbim/blenderbim/bim/module/drawing/operator.py index 58289bcede..fdc15123d2 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/operator.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/operator.py @@ -39,12 +39,12 @@ import blenderbim.bim.module.drawing.annotation as annotation import blenderbim.bim.module.drawing.sheeter as sheeter import blenderbim.bim.module.drawing.scheduler as scheduler import blenderbim.bim.module.drawing.helper as helper -from blenderbim.bim.module.drawing.data import DecoratorData, TextData +from blenderbim.bim.module.drawing.data import DecoratorData import blenderbim.bim.export_ifc from lxml import etree from mathutils import Vector from timeit import default_timer as timer -from blenderbim.bim.module.drawing.prop import RasterStyleProperty +from blenderbim.bim.module.drawing.prop import RasterStyleProperty, Literal from blenderbim.bim.ifc import IfcStore cwd = os.path.dirname(os.path.realpath(__file__)) @@ -197,6 +197,14 @@ class CreateDrawing(bpy.types.Operator): self.svg_writer.camera_projection = tuple( self.camera.matrix_world.to_quaternion() @ Vector((0, 0, -1)) ) + pset = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"] + related_paths = { + "Stylesheet": pset.get("Stylesheet"), + "Markers": pset.get("Markers"), + "Symbols": pset.get("Symbols"), + "Patterns": pset.get("Patterns"), + } + self.svg_writer.define_related_paths(**related_paths) with profile("Generate underlay"): underlay_svg = self.generate_underlay(context) @@ -230,10 +238,7 @@ class CreateDrawing(bpy.types.Operator): # Hacky :) svg_path = os.path.join(context.scene.BIMProperties.data_dir, "diagrams", self.drawing_name + ".svg") with open(svg_path, "w") as outfile: - pset = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"] - self.svg_writer.create_blank_svg(svg_path).define_boilerplate( - pset.get("Stylesheet"), pset.get("Markers"), pset.get("Symbols"), pset.get("Patterns") - ) + self.svg_writer.create_blank_svg(svg_path).define_boilerplate() boilerplate = self.svg_writer.svg.tostring() outfile.write(boilerplate.replace("", "")) if underlay: @@ -1211,30 +1216,39 @@ class EditTextPopup(bpy.types.Operator): # shares most of the code with BIM_PT_text.draw() # need to keep them in sync or move to some common function - if not TextData.is_loaded: - TextData.load() props = context.active_object.BIMTextProperties - # skip BoxAlignment since we're going to format it ourselves - attributes = [a for a in props.attributes if a.name != "BoxAlignment"] - # set first attribute with text to be active by default - blenderbim.bim.helper.draw_attributes(attributes, self.layout, popup_active_attribute=attributes[0]) + row = self.layout.row(align=True) + row.operator("bim.add_text_literal", icon="ADD", text="Add literal") + row = self.layout.row(align=True) row.prop(props, "font_size") - # a bit hacky way to align box alignment widget - rows = [self.layout.row(align=True) for i in range(3)] - for i in range(9): - if i % 3 == 0: - split = rows[i // 3].split(factor=0.1, align=True) - split.column() - split.prop(props, "box_alignment", text="", index=i) + for i, literal_props in enumerate(props.literals): + box = self.layout.box() + row = self.layout.row(align=True) - text_lines = ["Text box alignment:", props.attributes["BoxAlignment"].string_value, ""] - for i in range(3): - split = rows[i].split(factor=0.1, align=False) - split.column() - split.label(text=text_lines[i]) + row = box.row(align=True) + row.label(text=f"Literal[{i}]:") + row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i + + # skip BoxAlignment since we're going to format it ourselves + attributes = [a for a in literal_props.attributes if a.name != "BoxAlignment"] + blenderbim.bim.helper.draw_attributes(attributes, box) + + # a bit hacky way to align box alignment widget + rows = [box.row(align=True) for i in range(3)] + for i in range(9): + if i % 3 == 0: + split = rows[i // 3].split(factor=0.1, align=True) + split.column() + split.prop(literal_props, "box_alignment", text="", index=i) + + text_lines = ["Text box alignment:", literal_props.attributes["BoxAlignment"].string_value, ""] + for i in range(3): + split = rows[i].split(factor=0.1, align=False) + split.column() + split.label(text=text_lines[i]) def cancel(self, context): # disable editing when dialog is closed @@ -1259,7 +1273,7 @@ class EditText(bpy.types.Operator, Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.edit_text(tool.Ifc, tool.Drawing, obj=context.active_object) + core.edit_text(tool.Drawing, obj=context.active_object) tool.Blender.update_viewport() @@ -1285,6 +1299,54 @@ class DisableEditingText(bpy.types.Operator, Operator): tool.Blender.update_viewport() +class AddTextLiteral(bpy.types.Operator): + bl_idname = "bim.add_text_literal" + bl_label = "Add text literal" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + + # similar to `tool.Drawing.import_text_attributes` + literal_props = obj.BIMTextProperties.literals.add() + literal_attributes = literal_props.attributes + literal_attr_values = { + "Literal": "Literal", + "Path": "RIGHT", + "BoxAlignment": "bottom_left", + } + # emulates `blenderbim.bim.helper.import_attributes2(ifc_literal, literal_props.attributes)` + for attr_name in literal_attr_values: + attr = literal_attributes.add() + attr.name = attr_name + if attr_name == "Path": + attr.data_type = "enum" + attr.enum_items = '["DOWN", "LEFT", "RIGHT", "UP"]' + attr.enum_value = literal_attr_values[attr_name] + + else: + attr.data_type = "string" + attr.string_value = literal_attr_values[attr_name] + + box_alignment_mask = [False] * 9 + box_alignment_mask[6] = True # bottom_left box_alignment + literal_props.box_alignment = box_alignment_mask + return {"FINISHED"} + + +class RemoveTextLiteral(bpy.types.Operator): + bl_idname = "bim.remove_text_literal" + bl_label = "Remove text literal" + bl_options = {"REGISTER", "UNDO"} + + literal_prop_id: bpy.props.IntProperty() + + def execute(self, context): + obj = context.active_object + obj.BIMTextProperties.literals.remove(self.literal_prop_id) + return {"FINISHED"} + + class EditAssignedProduct(bpy.types.Operator, Operator): bl_idname = "bim.edit_assigned_product" bl_label = "Edit Text Product" diff --git a/src/blenderbim/blenderbim/bim/module/drawing/prop.py b/src/blenderbim/blenderbim/bim/module/drawing/prop.py index d336053656..5f8d107b27 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/prop.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/prop.py @@ -392,7 +392,7 @@ BOX_ALIGNMENT_POSITIONS = [ ] -class BIMTextProperties(PropertyGroup): +class Literal(PropertyGroup): def set_box_alignment(self, new_value): markers = new_value.count(True) if not markers: @@ -416,13 +416,28 @@ class BIMTextProperties(PropertyGroup): def get_box_alignment(self): return self.get("box_alignment", DEFAULT_BOX_ALIGNMENT) - is_editing: BoolProperty(name="Is Editing", default=False) attributes: CollectionProperty(name="Attributes", type=Attribute) - test_prop: StringProperty(name="test_prop", default="TEXT") # Current text value with evaluated experessions stored in `value`. # The original (Literal) value stored in `attributes['Literal']` # and can be accessed with `get_text()` value: StringProperty(name="Value", default="TEXT") + box_alignment: BoolVectorProperty( + name="Box alignment", size=9, set=set_box_alignment, get=get_box_alignment, default=DEFAULT_BOX_ALIGNMENT + ) + ifc_definition_id: IntProperty(name="IFC definition ID", default=0) + + def get_literal_edited_data(self): + text_data = { + "CurrentValue": self.attributes["Literal"].string_value, + "Literal": self.attributes["Literal"].string_value, + "BoxAlignment": self.attributes["BoxAlignment"].string_value, + } + return text_data + + +class BIMTextProperties(PropertyGroup): + is_editing: BoolProperty(name="Is Editing", default=False) + literals: CollectionProperty(name="Literals", type=Literal) font_size: EnumProperty( items=[ ("1.8", "1.8 - Small", ""), @@ -434,19 +449,19 @@ class BIMTextProperties(PropertyGroup): default="2.5", name="Font Size", ) - box_alignment: BoolVectorProperty( - name="Box alignment", size=9, set=set_box_alignment, get=get_box_alignment, default=DEFAULT_BOX_ALIGNMENT - ) def get_text_edited_data(self): """should be called only if `is_editing` - etherwise should use `DecoratorData.get_ifc_text_data(obj)` instead + otherwise should use `DecoratorData.get_ifc_text_data(obj)` instead because this data could be out of date """ + literals_data = [] + for literal in self.literals: + literal_data = literal.get_literal_edited_data() + literals_data.append(literal_data) text_data = { - "text": self.attributes["Literal"].string_value, - "font_size": float(self.font_size), - "box_alignment": self.attributes["BoxAlignment"].string_value, + "Literals": literals_data, + "FontSize": float(self.font_size), } return text_data diff --git a/src/blenderbim/blenderbim/bim/module/drawing/svgwriter.py b/src/blenderbim/blenderbim/bim/module/drawing/svgwriter.py index 6366beb7a4..80e4698e71 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/svgwriter.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/svgwriter.py @@ -65,8 +65,10 @@ class SvgWriter: self.scale = 1 / 100 # 1:100 self.camera_width = None self.camera_height = None + self.related_paths = None def create_blank_svg(self, output_path): + self.define_related_paths() # making sure all paths are defined self.calculate_scale() self.svg = svgwrite.Drawing( output_path, @@ -92,11 +94,32 @@ class SvgWriter: ) return self - def define_boilerplate(self, stylesheet=None, markers=None, symbols=None, patterns=None): - self.add_stylesheet(stylesheet) - self.add_markers(markers) - self.add_symbols(symbols) - self.add_patterns(patterns) + def define_related_paths(self, **related_paths): + if not self.related_paths: + self.related_paths = {} + + if not related_paths: + related_paths = { + "Stylesheet": os.path.join(self.data_dir, "styles", f"default.css"), + "Markers": os.path.join(self.data_dir, "templates", "markers.svg"), + "Symbols": os.path.join(self.data_dir, "templates", "symbols.svg"), + "Patterns": os.path.join(self.data_dir, "templates", "patterns.svg"), + } + for path_name in list(related_paths.keys()): + if path_name in self.related_paths: + del related_paths[path_name] + + for path_name in related_paths: + uri = related_paths[path_name] + custom_path = tool.Ifc.resolve_uri(uri) + if custom_path: + self.related_paths[path_name] = custom_path + + def define_boilerplate(self): + self.add_stylesheet() + self.add_markers() + self.add_symbols() + self.add_patterns() return self def calculate_scale(self): @@ -108,25 +131,29 @@ class SvgWriter: self.width = self.raw_width * self.svg_scale self.height = self.raw_height * self.svg_scale - def add_stylesheet(self, uri): - default_stylesheet = os.path.join(self.data_dir, "styles", f"default.css") - with open(tool.Ifc.resolve_uri(uri) or default_stylesheet, "r") as stylesheet: + def add_stylesheet(self): + with open(self.related_paths["Stylesheet"], "r") as stylesheet: self.svg.defs.add(self.svg.style(stylesheet.read())) - def add_markers(self, uri): - tree = ET.parse(tool.Ifc.resolve_uri(uri) or os.path.join(self.data_dir, "templates", "markers.svg")) + def add_markers(self): + tree = ET.parse(self.related_paths["Markers"]) root = tree.getroot() for child in root: self.svg.defs.add(External(child)) - def add_symbols(self, uri): - tree = ET.parse(tool.Ifc.resolve_uri(uri) or os.path.join(self.data_dir, "templates", "symbols.svg")) + def add_symbols(self): + tree = ET.parse(self.related_paths["Symbols"]) root = tree.getroot() for child in root: self.svg.defs.add(External(child)) - def add_patterns(self, uri): - tree = ET.parse(tool.Ifc.resolve_uri(uri) or os.path.join(self.data_dir, "templates", "patterns.svg")) + def find_xml_symbol_by_id(self, id): + tree = ET.parse(self.related_paths["Symbols"]) + xml_symbol = tree.find(f'.//*[@id="{id}"]') + return External(xml_symbol) if xml_symbol else None + + def add_patterns(self): + tree = ET.parse(self.related_paths["Patterns"]) root = tree.getroot() for child in root: self.svg.defs.add(External(child)) @@ -530,7 +557,7 @@ class SvgWriter: edge_verts_svg.append(symbol_position_svg) edge_dir = (edge_verts_svg[1] - edge_verts_svg[0]).normalized() - angle = degrees( edge_dir.xy.angle_signed( Vector([1,0]) ) ) + angle = degrees(edge_dir.xy.angle_signed(Vector([1, 0]))) for v_i, symbol_position_svg in zip(edge.vertices, edge_verts_svg): current_marker_position = "start" if v_i == 0 else "end" @@ -562,10 +589,7 @@ class SvgWriter: ) self.svg.add( self.svg.text( - sheet_id, - insert=(text_position[0], text_position[1] + 2.5), - class_="SECTION", - **text_style + sheet_id, insert=(text_position[0], text_position[1] + 2.5), class_="SECTION", **text_style ) ) @@ -592,8 +616,14 @@ class SvgWriter: "alignment-baseline": "middle", "dominant-baseline": "middle", } - self.svg.add(self.svg.text(reference_id, insert=(text_position[0], text_position[1] - 2.5), class_="ELEVATION", **text_style)) - self.svg.add(self.svg.text(sheet_id, insert=(text_position[0], text_position[1] + 2.5), class_="ELEVATION", **text_style)) + self.svg.add( + self.svg.text( + reference_id, insert=(text_position[0], text_position[1] - 2.5), class_="ELEVATION", **text_style + ) + ) + self.svg.add( + self.svg.text(sheet_id, insert=(text_position[0], text_position[1] + 2.5), class_="ELEVATION", **text_style) + ) def get_reference_and_sheet_id_from_annotation(self, element): reference_id = "-" @@ -616,10 +646,12 @@ class SvgWriter: x_offset = self.raw_width / 2 y_offset = self.raw_height / 2 element = tool.Ifc.get_entity(text_obj) - text_literal = tool.Drawing.get_text_literal(text_obj) + text_literals = tool.Drawing.get_text_literal(text_obj, return_list=True) + product = tool.Drawing.get_assigned_product(element) text_position = self.project_point_onto_camera(position) text_position = Vector(((x_offset + text_position.x), (y_offset - text_position.y))) + text_position_svg = text_position * self.svg_scale local_x_axis = text_obj.matrix_world.to_quaternion() @ Vector((1, 0, 0)) projected_x_axis = self.project_point_onto_camera(position + local_x_axis) @@ -629,62 +661,79 @@ class SvgWriter: ) ) - transform = "rotate({}, {}, {})".format( - angle, - (text_position * self.svg_scale)[0], - (text_position * self.svg_scale)[1], - ) + transform = "rotate({}, {}, {})".format(angle, *text_position_svg) + classes_str = " ".join(self.get_attribute_classes(text_obj)) symbol = tool.Drawing.get_annotation_symbol(element) if symbol: - self.svg.add(self.svg.use(f"#{symbol}", insert=tuple(text_position * self.svg_scale))) + symbol_svg = self.find_xml_symbol_by_id(symbol) + if symbol_svg: + symbol_xml = symbol_svg.get_xml() + template_text_fields = symbol_xml.findall('.//text[@data-type="text-template"]') + # if there is a simple with template text fields + # then we just populate it's fields with the data from text literals + if template_text_fields: + symbol_xml.attrib["transform"] = f"translate({', '.join(map(str, text_position_svg))})" + symbol_xml.attrib.pop("id") + for i, field in enumerate(template_text_fields): + field.text = tool.Drawing.replace_text_literal_variables(text_literals[i].Literal, product) + field.attrib["class"] = classes_str + self.svg.add(symbol_svg) + return None + else: + self.svg.add(self.svg.use(f"#{symbol}", insert=text_position_svg)) - box_alignment = text_literal.BoxAlignment + def get_box_alignment_parameters(box_alignment): + # reference for alignment values: + # https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor + # https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/alignment-baseline + vertical_alignment = { + "top": "hanging", + "bottom": "baseline", + "center": "middle", + "middle": "middle", + } + alignment_baseline = vertical_alignment[ + next(align for align in vertical_alignment if align in box_alignment) + ] + horizontal_alignment = { + "left": "start", + "right": "end", + "center": "middle", + "middle": "middle", + } + text_anchor = horizontal_alignment[next(align for align in horizontal_alignment if align in box_alignment)] - # reference for alignment values: - # https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor - # https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/alignment-baseline - vertical_alignment = { - "top": "hanging", - "bottom": "baseline", - "center": "middle", - "middle": "middle", - } - alignment_baseline = vertical_alignment[ next(align for align in vertical_alignment if align in box_alignment) ] - horizontal_alignment = { - "left": "start", - "right": "end", - "center": "middle", - "middle": "middle", - } - text_anchor = horizontal_alignment[ next(align for align in horizontal_alignment if align in box_alignment) ] - - - # after pretty indentation some redundant spaces can occur in svg tags - # this is why we apply "font-size: 0;" to the text tag to remove those spaces - # and add clases to the tspan tags - # ref: https://github.com/IfcOpenShell/IfcOpenShell/issues/2833#issuecomment-1471584960 - product = tool.Drawing.get_assigned_product(element) - text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product) - text_tag = self.svg.text( - "", - **{ - "text-anchor": text_anchor, - # using dominant-baseline because we plan to use subtags - # otherwise alignment-baseline would be sufficient + # using dominant-baseline because we plan to use subtags + # otherwise alignment-baseline would be sufficient + return { "dominant-baseline": alignment_baseline, - "transform": transform, - "style": "font-size: 0;", - }, - ) - self.svg.add(text_tag) + "text-anchor": text_anchor, + } - classes = " ".join(self.get_attribute_classes(text_obj)) - for line_number, text_line in enumerate(text.replace("\\n", "\n").split("\n")): - t_span = self.svg.tspan(text_line, insert=(text_position * self.svg_scale), class_=classes) - # doing it here and not in tspan constructor because it adds unnecessary spaces - t_span.update({"dy": f"{line_number}em"}) - text_tag.add(t_span) + for text_literal in text_literals: + # after pretty indentation some redundant spaces can occur in svg tags + # this is why we apply "font-size: 0;" to the text tag to remove those spaces + # and add clases to the tspan tags + # ref: https://github.com/IfcOpenShell/IfcOpenShell/issues/2833#issuecomment-1471584960 + + text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product) + text_tag = self.svg.text( + "", + **{ + "transform": transform, + "style": "font-size: 0;", + "insert": text_position_svg, + }, + **get_box_alignment_parameters(text_literal.BoxAlignment), + ) + self.svg.add(text_tag) + + for line_number, text_line in enumerate(text.replace("\\n", "\n").split("\n")): + t_span = self.svg.tspan(text_line, class_=classes_str) + # doing it here and not in tspan constructor because constructor adds unnecessary spaces + t_span.update({"dy": f"{line_number}em"}) + text_tag.add(t_span) def draw_break_annotations(self, obj): x_offset = self.raw_width / 2 @@ -762,7 +811,7 @@ class SvgWriter: points = obj.data.splines[0].points region = bpy.context.region region_3d = bpy.context.area.spaces.active.region_3d - points_chunked = [points[i:i+3] for i in range(len(points)-2)] + points_chunked = [points[i : i + 3] for i in range(len(points) - 2)] for points_chunk in points_chunked: points_2d = [view3d_utils.location_3d_to_region_2d(region, region_3d, p.co.xyz) for p in points_chunk] diff --git a/src/blenderbim/blenderbim/bim/module/drawing/ui.py b/src/blenderbim/blenderbim/bim/module/drawing/ui.py index 99e06f19dc..8f8a6e34c3 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/ui.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/ui.py @@ -20,7 +20,13 @@ import bpy import blenderbim.bim.helper import blenderbim.tool as tool from bpy.types import Panel -from blenderbim.bim.module.drawing.data import ProductAssignmentsData, TextData, SheetsData, SchedulesData, DrawingsData +from blenderbim.bim.module.drawing.data import ( + ProductAssignmentsData, + SheetsData, + SchedulesData, + DrawingsData, + DecoratorData, +) class BIM_PT_camera(Panel): @@ -340,10 +346,8 @@ class BIM_PT_text(Panel): return element.is_a("IfcAnnotation") and element.ObjectType in ["TEXT", "TEXT_LEADER"] def draw(self, context): - if not TextData.is_loaded: - TextData.load() - - props = context.active_object.BIMTextProperties + obj = context.active_object + props = obj.BIMTextProperties if props.is_editing: # shares most of the code with EditTextPopup.draw() @@ -351,38 +355,53 @@ class BIM_PT_text(Panel): row = self.layout.row(align=True) row.operator("bim.edit_text", icon="CHECKMARK") + row.operator("bim.add_text_literal", icon="ADD", text="") row.operator("bim.disable_editing_text", icon="CANCEL", text="") - # skip BoxAlignment since we're going to format it ourselves - attributes = [a for a in props.attributes if a.name != "BoxAlignment"] - blenderbim.bim.helper.draw_attributes(attributes, self.layout) + row = self.layout.row(align=True) row.prop(props, "font_size") - # a bit hacky way to align box alignment widget - rows = [self.layout.row(align=True) for i in range(3)] - for i in range(9): - if i % 3 == 0: - split = rows[i // 3].split(factor=0.1, align=True) - split.column() - split.prop(props, "box_alignment", text="", index=i) + for i, literal_props in enumerate(props.literals): + box = self.layout.box() + row = self.layout.row(align=True) - text_lines = ["Text box alignment:", props.attributes["BoxAlignment"].string_value, ""] - for i in range(3): - split = rows[i].split(factor=0.1, align=False) - split.column() - split.label(text=text_lines[i]) + row = box.row(align=True) + row.label(text=f"Literal[{i}]:") + row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i + + # skip BoxAlignment since we're going to format it ourselves + attributes = [a for a in literal_props.attributes if a.name != "BoxAlignment"] + blenderbim.bim.helper.draw_attributes(attributes, box) + + # a bit hacky way to align box alignment widget + rows = [box.row(align=True) for i in range(3)] + for i in range(9): + if i % 3 == 0: + split = rows[i // 3].split(factor=0.1, align=True) + split.column() + split.prop(literal_props, "box_alignment", text="", index=i) + + text_lines = ["Text box alignment:", literal_props.attributes["BoxAlignment"].string_value, ""] + for i in range(3): + split = rows[i].split(factor=0.1, align=False) + split.column() + split.label(text=text_lines[i]) else: + text_data = DecoratorData.get_ifc_text_data(obj) + row = self.layout.row() row.operator("bim.enable_editing_text", icon="GREASEPENCIL") - for attribute in TextData.data["attributes"]: - row = self.layout.row(align=True) - row.label(text=attribute["name"]) - row.label(text=attribute["value"]) - row = self.layout.row(align=True) - row.label(text="Current value:") - row.label(text=props.value) + row.label(text="FontSize") + row.label(text=str(text_data["FontSize"])) + + for literal_data in text_data["Literals"]: + box = self.layout.box() + for attribute in literal_data: + row = box.row(align=True) + row.label(text=attribute) + row.label(text=literal_data[attribute]) class BIM_PT_annotation_utilities(Panel): diff --git a/src/blenderbim/blenderbim/bim/module/model/workspace.py b/src/blenderbim/blenderbim/bim/module/model/workspace.py index 14286d5426..9eb3ab72c0 100644 --- a/src/blenderbim/blenderbim/bim/module/model/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/model/workspace.py @@ -25,7 +25,7 @@ import blenderbim.bim.module.type.prop as type_prop from blenderbim.bim.helper import prop_with_search, close_operator_panel from bpy.types import WorkSpaceTool from blenderbim.bim.module.model.data import AuthoringData, RailingData, RoofData -from blenderbim.bim.module.drawing.data import TextData +from blenderbim.bim.module.drawing.data import DecoratorData from blenderbim.bim.module.model.prop import get_ifc_class @@ -334,7 +334,8 @@ class BimToolUI: row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_E") row.operator("bim.hotkey", text="Edit Roof Path").hotkey = "S_E" - elif (TextData.is_loaded or not TextData.load()) and TextData.data["attributes"]: + + elif DecoratorData.get_ifc_text_data(bpy.context.object): row = cls.layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_E") @@ -574,7 +575,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.context.object.select_set(True) bpy.ops.bim.enable_editing_roof_path() - elif (TextData.is_loaded or not TextData.load()) and TextData.data["attributes"]: + elif DecoratorData.get_ifc_text_data(bpy.context.object): bpy.context.object.select_set(True) bpy.ops.bim.edit_text_popup() diff --git a/src/blenderbim/blenderbim/core/drawing.py b/src/blenderbim/blenderbim/core/drawing.py index 1932132124..cf45f59e8b 100644 --- a/src/blenderbim/blenderbim/core/drawing.py +++ b/src/blenderbim/blenderbim/core/drawing.py @@ -26,12 +26,8 @@ def disable_editing_text(drawing, obj=None): drawing.disable_editing_text(obj) -def edit_text(ifc, drawing, obj=None): - ifc.run( - "drawing.edit_text_literal", - text_literal=drawing.get_text_literal(obj), - attributes=drawing.export_text_literal_attributes(obj), - ) +def edit_text(drawing, obj=None): + drawing.synchronise_ifc_and_text_attributes(obj) drawing.update_text_value(obj) drawing.update_text_size_pset(obj) drawing.disable_editing_text(obj) diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index e4b4444f58..5887e98e62 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -257,6 +257,9 @@ class Drawing: def get_name(cls, element): pass def get_schedule_location(cls, schedule): pass def get_text_literal(cls, obj): pass + def remove_literal_from_annotation(cls, obj, literal): pass + def synchronise_ifc_and_text_attributes(cls, obj): pass + def add_literal_to_annotation(cls, obj, Literal='Literal', Path='RIGHT', BoxAlignment='bottom-left'): pass def import_assigned_product(cls, obj): pass def import_drawings(cls): pass def import_schedules(cls): pass diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index 1491cc5c17..95717916c9 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -187,7 +187,11 @@ class Drawing(blenderbim.core.tool.Drawing): @classmethod def export_text_literal_attributes(cls, obj): - return blenderbim.bim.helper.export_attributes(obj.BIMTextProperties.attributes) + literals = [] + for literal_props in obj.BIMTextProperties.literals: + literal_data = blenderbim.bim.helper.export_attributes(literal_props.attributes) + literals.append(literal_data) + return literals @classmethod def get_annotation_context(cls, target_view): @@ -300,7 +304,7 @@ class Drawing(blenderbim.core.tool.Drawing): return "A" + str(number).zfill(2) @classmethod - def get_text_literal(cls, obj): + def get_text_literal(cls, obj, return_list=False): element = tool.Ifc.get_entity(obj) if not element: return @@ -310,8 +314,80 @@ class Drawing(blenderbim.core.tool.Drawing): if not rep: return items = [i for i in rep.Items if i.is_a("IfcTextLiteral")] - if items: - return items[0] + + if not items: + return [] if return_list else None + if return_list: + return items + return items[0] + + @classmethod + def remove_literal_from_annotation(cls, obj, literal): + element = tool.Ifc.get_entity(obj) + if not element: + return + + rep = ifcopenshell.util.representation.get_representation( + element, "Plan", "Annotation" + ) or ifcopenshell.util.representation.get_representation(element, "Model", "Annotation") + if not rep: + return + + ifc_file = tool.Ifc.get() + rep.Items = [l for l in rep.Items if l != literal] + ifcopenshell.util.element.remove_deep2(ifc_file, literal) + + @classmethod + def synchronise_ifc_and_text_attributes(cls, obj): + literals = cls.get_text_literal(obj, return_list=True) + literals_attributes = cls.export_text_literal_attributes(obj) + defined_ifc_ids = [l.ifc_definition_id for l in obj.BIMTextProperties.literals] + ifc_file = tool.Ifc.get() + + for ifc_definition_id, attributes in zip(defined_ifc_ids, literals_attributes): + # making sure all literals from text edit exist in ifc + if ifc_definition_id == 0: + literal = cls.add_literal_to_annotation(obj, **attributes) + else: + literal = ifc_file.by_id(ifc_definition_id) + tool.Ifc.run( + "drawing.edit_text_literal", + text_literal=literal, + attributes=attributes, + ) + + # remove from ifc the literals that were removed during the edit + for literal in literals: + if literal.id() not in defined_ifc_ids: + cls.remove_literal_from_annotation(obj, literal) + + @classmethod + def add_literal_to_annotation(cls, obj, Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left"): + element = tool.Ifc.get_entity(obj) + if not element: + return + + rep = ifcopenshell.util.representation.get_representation( + element, "Plan", "Annotation" + ) or ifcopenshell.util.representation.get_representation(element, "Model", "Annotation") + + if not rep: + return + + ifc_file = tool.Ifc.get() + + origin = ifc_file.createIfcAxis2Placement3D( + ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)), + ifc_file.createIfcDirection((0.0, 0.0, 1.0)), + ifc_file.createIfcDirection((1.0, 0.0, 0.0)), + ) + + ifc_literal = ifc_file.createIfcTextLiteralWithExtent( + Literal, origin, Path, ifc_file.createIfcPlanarExtent(1000, 1000), BoxAlignment + ) + + rep.Items = rep.Items + (ifc_literal,) + return ifc_literal @classmethod def get_assigned_product(cls, element): @@ -461,17 +537,20 @@ class Drawing(blenderbim.core.tool.Drawing): @classmethod def import_text_attributes(cls, obj): props = obj.BIMTextProperties - props.attributes.clear() - text = cls.get_text_literal(obj) - blenderbim.bim.helper.import_attributes2(text, props.attributes) + props.literals.clear() - box_alignment_mask = [False] * 9 - position_string = props.attributes["BoxAlignment"].string_value - box_alignment_mask[BOX_ALIGNMENT_POSITIONS.index(position_string)] = True - props.box_alignment = box_alignment_mask + for ifc_literal in cls.get_text_literal(obj, return_list=True): + literal_props = props.literals.add() + blenderbim.bim.helper.import_attributes2(ifc_literal, literal_props.attributes) + + box_alignment_mask = [False] * 9 + position_string = literal_props.attributes["BoxAlignment"].string_value + box_alignment_mask[BOX_ALIGNMENT_POSITIONS.index(position_string)] = True + literal_props.box_alignment = box_alignment_mask + literal_props.ifc_definition_id = ifc_literal.id() text_data = DecoratorData.get_ifc_text_data(obj) - props.font_size = str(text_data["font_size"]) + props.font_size = str(text_data["FontSize"]) @classmethod def import_assigned_product(cls, obj): @@ -537,11 +616,16 @@ class Drawing(blenderbim.core.tool.Drawing): def update_text_value(cls, obj): props = obj.BIMTextProperties - ifc_literal = cls.get_text_literal(obj) - if not ifc_literal: + literals = cls.get_text_literal(obj, return_list=True) + if not literals: return - product = cls.get_assigned_product(tool.Ifc.get_entity(obj)) - props.value = cls.replace_text_literal_variables(ifc_literal.Literal, product) + + if not props.literals: + cls.import_text_attributes(obj) + + for i, literal in enumerate(literals): + product = cls.get_assigned_product(tool.Ifc.get_entity(obj)) + props.literals[i].value = cls.replace_text_literal_variables(literal.Literal, product) @classmethod def update_text_size_pset(cls, obj): @@ -960,7 +1044,7 @@ class Drawing(blenderbim.core.tool.Drawing): for variable in re.findall("{{.*?}}", text): text = text.replace( variable, str(ifcopenshell.util.selector.get_element_value(product, variable[2:-2]) or "") - ) + ) return text @classmethod diff --git a/src/blenderbim/blenderbim/tool/geometry.py b/src/blenderbim/blenderbim/tool/geometry.py index 0a5adcba8c..8b9744e7ec 100644 --- a/src/blenderbim/blenderbim/tool/geometry.py +++ b/src/blenderbim/blenderbim/tool/geometry.py @@ -153,6 +153,7 @@ class Geometry(blenderbim.core.tool.Geometry): def get_styles(cls, obj): return [tool.Style.get_style(s.material) for s in obj.material_slots if s.material] + # TODO: multiple Literals? @classmethod def get_text_literal(cls, representation): texts = [i for i in representation.Items if i.is_a("IfcTextLiteral")] diff --git a/src/blenderbim/test/core/test_drawing.py b/src/blenderbim/test/core/test_drawing.py index 565ea30e2e..f66b13c596 100644 --- a/src/blenderbim/test/core/test_drawing.py +++ b/src/blenderbim/test/core/test_drawing.py @@ -35,13 +35,11 @@ class TestDisableEditingText: class TestEditText: def test_run(self, ifc, drawing): - drawing.get_text_literal("obj").should_be_called().will_return("text") - drawing.export_text_literal_attributes("obj").should_be_called().will_return("attributes") - ifc.run("drawing.edit_text_literal", text_literal="text", attributes="attributes").should_be_called() + drawing.synchronise_ifc_and_text_attributes("obj").should_be_called() drawing.update_text_value("obj").should_be_called() drawing.update_text_size_pset("obj").should_be_called() drawing.disable_editing_text("obj").should_be_called() - subject.edit_text(ifc, drawing, obj="obj") + subject.edit_text(drawing, obj="obj") class TestEnableEditingAssignedProduct: diff --git a/src/blenderbim/test/tool/test_drawing.py b/src/blenderbim/test/tool/test_drawing.py index 6c538a2565..f91d6cea25 100644 --- a/src/blenderbim/test/tool/test_drawing.py +++ b/src/blenderbim/test/tool/test_drawing.py @@ -204,11 +204,11 @@ class TestEnsureUniqueIdentification(NewFile): class TestExportTextLiteralAttributes(NewFile): def test_run(self): TestImportTextAttributes().test_run() - assert subject.export_text_literal_attributes(bpy.data.objects.get("Object")) == { + assert subject.export_text_literal_attributes(bpy.data.objects.get("Object")) == [{ "Literal": "Literal", "Path": "RIGHT", "BoxAlignment": "bottom-left", - } + }] class TestGetAnnotationContext(NewFile): @@ -433,6 +433,7 @@ class TestGetTextLiteral(NewFile): item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left") representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item]) element.Representation.Representations = [representation] + element.ObjectType = "TEXT" # TODO: double check if it's valid to set this tool.Ifc.link(element, obj) assert subject.get_text_literal(obj) == item @@ -520,12 +521,13 @@ class TestImportTextAttributes(NewFile): item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left") representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item]) element.Representation.Representations = [representation] + element.ObjectType = "TEXT" # TODO: double check if it's valid to set this tool.Ifc.link(element, obj) subject.import_text_attributes(obj) - props = obj.BIMTextProperties - assert props.attributes.get("Literal").string_value == "Literal" - assert props.attributes.get("Path").enum_value == "RIGHT" - assert props.attributes.get("BoxAlignment").string_value == "bottom-left" + literal_props = obj.BIMTextProperties.literals[0] + assert literal_props.attributes.get("Literal").string_value == "Literal" + assert literal_props.attributes.get("Path").enum_value == "RIGHT" + assert literal_props.attributes.get("BoxAlignment").string_value == "bottom-left" class TestImportAssignedProduct(NewFile): @@ -596,7 +598,7 @@ class TestUpdateTextValue(NewFile): TestGetTextLiteral().test_run() obj = bpy.data.objects.get("Object") subject.update_text_value(obj) - assert obj.BIMTextProperties.value == "Literal" + assert obj.BIMTextProperties.literals[0].value == "Literal" def test_using_attribute_variables(self): TestGetTextLiteral().test_run() @@ -610,7 +612,7 @@ class TestUpdateTextValue(NewFile): ifc.by_type("IfcTextLiteralWithExtent")[0].Literal = "Foo {{Name}} Bar" subject.update_text_value(obj) - assert obj.BIMTextProperties.value == "Foo Baz Bar" + assert obj.BIMTextProperties.literals[0].value == "Foo Baz Bar" def test_using_property_variables(self): TestGetTextLiteral().test_run() @@ -626,4 +628,4 @@ class TestUpdateTextValue(NewFile): ifc.by_type("IfcTextLiteralWithExtent")[0].Literal = "Foo {{Custom_Pset.Key}} Bar" subject.update_text_value(obj) - assert obj.BIMTextProperties.value == "Foo Baz Bar" + assert obj.BIMTextProperties.literals[0].value == "Foo Baz Bar"