mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
Bonsai: real multiline entry for text annotations
Text annotations could only be edited through single line string fields, so the only ways to get more than one line were the Newline At wrap length or typing a literal \n escape. Both were called out as awkward in the Drawings and Documentation proposal. Add an "Edit Multiline Text" button next to each text literal. It opens the literal in Blender's own text editor (the same pattern the search module already uses for filter queries), where Enter and Tab behave normally, and an "Apply Text" button in the text editor header saves the result back to the IfcTextLiteral. Legacy \n escapes are converted to real line breaks when a literal is opened, and the Newline At wrap length still applies on top of any embedded line breaks. Multiline literals are shown one line per row in the Text panel instead of being truncated into a single line field, and the viewport decorator now resolves the \n escape the same way the SVG writer already did. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user