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"