diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 12b63d9372..47dd5b3cb6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -42,6 +42,7 @@ classes = ( operator.AddScheduleToSheet, operator.AddSheet, operator.AddTextLiteral, + operator.ApplyMultilineTextLiteral, operator.AssignSelectedObjectAsProduct, operator.BuildSchedule, operator.CleanWireframes, @@ -64,6 +65,7 @@ classes = ( operator.EditElementFilter, operator.EditSheet, operator.EditText, + operator.EditTextLiteralMultiline, operator.EditTextPopup, operator.EnableAddAnnotationType, operator.EnableEditingAssignedProduct, @@ -197,6 +199,7 @@ def register(): bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler) bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button) bpy.types.VIEW3D_MT_object_context_menu.append(menu_func) + bpy.types.TEXT_HT_header.append(operator.draw_text_editor_header) def unregister(): @@ -212,3 +215,4 @@ def unregister(): bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler) bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button) bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func) + bpy.types.TEXT_HT_header.remove(operator.draw_text_editor_header) diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index eedc79e995..0165b5d446 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -618,7 +618,7 @@ class BaseDecorator: if newline_at != 0: text = helper.add_newline_between_words(text, newline_at) - multiple_lines = text.split("\n") + multiple_lines = text.replace("\\n", "\n").split("\n") for line in multiple_lines: self.draw_label( diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 35d93db7d2..3bf310beb3 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3382,6 +3382,93 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): tool.Blender.update_viewport() +MULTILINE_TEXT_DATABLOCK = "BonsaiAnnotationText" + + +def draw_text_editor_header(self: bpy.types.TEXT_HT_header, context: bpy.types.Context) -> None: + space = context.space_data + if not isinstance(space, bpy.types.SpaceTextEditor): + return + if space.text and space.text.name == MULTILINE_TEXT_DATABLOCK: + layout = self.layout + layout.separator() + layout.operator("bim.apply_multiline_text_literal", text="Apply Text", icon="CHECKMARK") + + +class EditTextLiteralMultiline(bpy.types.Operator): + bl_idname = "bim.edit_text_literal_multiline" + bl_label = "Edit Multiline Text" + bl_description = ( + "Edit this text literal in Blender's text editor,\n" + "where Enter and Tab insert real line breaks and tabs.\n" + "Click 'Apply Text' in the text editor header when done" + ) + bl_options = {"REGISTER", "UNDO"} + + literal_prop_id: bpy.props.IntProperty() + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Drawing.get_text_props(obj) + if not props.is_editing: + bpy.ops.bim.enable_editing_text() + if self.literal_prop_id >= len(props.literals): + self.report({"ERROR"}, "Text literal not found.") + return {"CANCELLED"} + + text = bpy.data.texts.get(MULTILINE_TEXT_DATABLOCK) or bpy.data.texts.new(MULTILINE_TEXT_DATABLOCK) + text.clear() + literal_value = props.literals[self.literal_prop_id].attributes["Literal"].string_value + text.write(tool.Drawing.unescape_literal_newlines(literal_value)) + text["bonsai_text_object"] = obj.name + text["bonsai_text_literal"] = self.literal_prop_id + + if bpy.app.background: + return {"FINISHED"} + + bpy.ops.wm.window_new() + new_area = context.window_manager.windows[-1].screen.areas[0] + new_area.type = "TEXT_EDITOR" + for space in new_area.spaces: + if space.type == "TEXT_EDITOR": + space.text = text + break + self.report({"INFO"}, "Text editor opened, click 'Apply Text' in the header when done.") + return {"FINISHED"} + + +class ApplyMultilineTextLiteral(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.apply_multiline_text_literal" + bl_label = "Apply Text" + bl_description = "Save the edited text back to the text annotation" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + space = context.space_data + if not isinstance(space, bpy.types.SpaceTextEditor): + return False + return bool(space.text and space.text.name == MULTILINE_TEXT_DATABLOCK) + + def _execute(self, context): + text = context.space_data.text + obj = bpy.data.objects.get(text.get("bonsai_text_object", "")) + if not obj: + self.report({"ERROR"}, "The edited text annotation is no longer available.") + return {"CANCELLED"} + core.edit_text_literal_value( + tool.Drawing, + obj=obj, + literal_index=text.get("bonsai_text_literal", 0), + value=text.as_string(), + ) + tool.Blender.update_viewport() + if len(context.window_manager.windows) > 1: + bpy.ops.wm.window_close() + + class CopyTextToSelection(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.copy_text_to_selection" bl_label = "Copy Text To Selection" diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 5c690e7282..37992ef7d1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -696,7 +696,15 @@ class BIM_PT_text(Panel): box = self.layout.box() if len(literal_props.attributes): row = box.row(align=True) - bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True) + literal_value = literal_props.attributes[0].string_value + if "\n" in literal_value: + column = row.column(align=True) + for line in literal_value.split("\n"): + column.label(text=line or " ") + else: + bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True) + op = row.operator("bim.edit_text_literal_multiline", icon="GREASEPENCIL", text="") + op.literal_prop_id = i if i > 0: row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i if i < len(props.literals) - 1: @@ -715,7 +723,9 @@ class BIM_PT_text(Panel): ) row = box.row(align=True) row.label(text="CurrentValue:") - row.label(text=str(resolved_value)) + column = row.column(align=True) + for line in str(resolved_value).split("\n"): + column.label(text=line or " ") # Show the element values panel if expanded if getattr(literal_props, "show_element_values", False): diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 2a1f760570..5b6c998eab 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -47,6 +47,10 @@ def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None: drawing.disable_editing_text(obj) +def edit_text_literal_value(drawing: type[tool.Drawing], obj: bpy.types.Object, literal_index: int, value: str) -> None: + drawing.edit_text_literal_value(obj, literal_index, drawing.sanitize_multiline_literal(value)) + + def copy_text_to_selection( drawing: type[tool.Drawing], attribute: Literal["FONT_SIZE", "ALIGNMENT", "WRAP_LENGTH", "SYMBOL", "LITERALS"], diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 51120ad088..22e22785f5 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -344,6 +344,7 @@ class Drawing: def does_file_exist(cls, uri): pass def edit_text_alignment(cls, obj, alignment): pass def edit_text_font_size(cls, obj, size): pass + def edit_text_literal_value(cls, obj, literal_index, value): pass def edit_text_literals(cls, obj, literals): pass def edit_text_symbol(cls, obj, symbol): pass def edit_text_wrap_length(cls, obj, wrap_length): pass @@ -417,6 +418,7 @@ class Drawing: def run_drawing_activate_model(cls): pass def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass def run_type_assign_type(cls, element=None, relating_type=None): pass + def sanitize_multiline_literal(cls, value): pass def select_assigned_product(cls, drawing): pass def set_camera_name(cls, drawing, name): pass def set_drawing_collection_name(cls, drawing, collection): pass @@ -425,6 +427,7 @@ class Drawing: def setup_shading_styles_path(cls, resource_path): pass def show_decorations(cls): pass def sync_object_placement(cls, obj): pass + def unescape_literal_newlines(cls, value): pass def update_embedded_svg_location(cls, uri, old_location, new_location): pass diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index aedc1219d9..d134731edd 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -929,6 +929,26 @@ class Drawing(bonsai.core.tool.Drawing): props = tool.Drawing.get_document_props() return props.is_editing_sheets + @classmethod + def unescape_literal_newlines(cls, value: str) -> str: + """Turn the legacy ``\\n`` escape sequence into a real line break.""" + return value.replace("\\n", "\n") + + @classmethod + def sanitize_multiline_literal(cls, value: str) -> str: + # Blender's Text.as_string() always terminates with a line break. + return value.replace("\r\n", "\n").rstrip("\n") + + @classmethod + def edit_text_literal_value(cls, obj: bpy.types.Object, literal_index: int, value: str) -> None: + props = cls.get_text_props(obj) + if props.is_editing and literal_index < len(props.literals): + props.literals[literal_index].attributes["Literal"].string_value = value + ifc_literals = cls.get_text_literal(obj, return_list=True) + assert isinstance(ifc_literals, list) + if literal_index < len(ifc_literals): + ifc_literals[literal_index].Literal = value + @classmethod def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None: if not literal_attributes: diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index 914ddbbb54..d3ffd55d02 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -47,6 +47,13 @@ class TestEditText: subject.edit_text(drawing, obj="obj") +class TestEditTextLiteralValue: + def test_run(self, drawing): + drawing.sanitize_multiline_literal("value\n").should_be_called().will_return("value") + drawing.edit_text_literal_value("obj", 0, "value").should_be_called() + subject.edit_text_literal_value(drawing, obj="obj", literal_index=0, value="value\n") + + class TestEnableEditingAssignedProduct: def test_run(self, drawing): drawing.enable_editing_assigned_product("obj").should_be_called() diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 580b326822..d8ded8d5fc 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -687,6 +687,33 @@ class TestGetTextLiteral(NewFile): assert subject.get_text_literal(obj) == item +class TestEditTextLiteralValue(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + obj = bpy.data.objects.new("Object", None) + element = ifc.createIfcAnnotation() + element.Representation = ifc.createIfcProductDefinitionShape() + context = ifc.createIfcGeometricRepresentationSubContext(ContextType="Plan", ContextIdentifier="Annotation") + item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left") + representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item]) + element.Representation.Representations = [representation] + element.ObjectType = "TEXT" + tool.Ifc.link(element, obj) + subject.edit_text_literal_value(obj, 0, "Line one\nLine two") + assert item.Literal == "Line one\nLine two" + + +class TestSanitizeMultilineLiteral(NewFile): + def test_run(self): + assert subject.sanitize_multiline_literal("Line one\r\nLine two\n") == "Line one\nLine two" + + +class TestUnescapeLiteralNewlines(NewFile): + def test_run(self): + assert subject.unescape_literal_newlines("Line one\\nLine two") == "Line one\nLine two" + + class TestGetAssignedProduct(NewFile): def test_run(self): ifc = ifcopenshell.file()