1638 lines
58 KiB
Python
1638 lines
58 KiB
Python
# Copyright (c) 2021-2022, Manfred Moitzi
|
|
# License: MIT License
|
|
from __future__ import annotations
|
|
from typing import (
|
|
Any,
|
|
Iterable,
|
|
Iterator,
|
|
Optional,
|
|
TYPE_CHECKING,
|
|
Union,
|
|
cast,
|
|
)
|
|
import abc
|
|
import math
|
|
import logging
|
|
import enum
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
|
|
from ezdxf import colors
|
|
from ezdxf.math import (
|
|
BoundingBox,
|
|
Matrix44,
|
|
NULLVEC,
|
|
OCS,
|
|
UCS,
|
|
UVec,
|
|
Vec2,
|
|
Vec3,
|
|
X_AXIS,
|
|
Z_AXIS,
|
|
fit_points_to_cad_cv,
|
|
is_point_left_of_line,
|
|
)
|
|
from ezdxf.entities import factory
|
|
from ezdxf.lldxf import const
|
|
from ezdxf.proxygraphic import ProxyGraphic
|
|
from ezdxf.render.arrows import ARROWS, arrow_length
|
|
from ezdxf.tools import text_size, text as text_tools
|
|
from ezdxf.entities.mleader import (
|
|
AttribData,
|
|
BlockData,
|
|
LeaderData,
|
|
LeaderLine,
|
|
MLeaderContext,
|
|
MLeaderStyle,
|
|
MTextData,
|
|
MultiLeader,
|
|
acdb_mleader_style,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from ezdxf.document import Drawing
|
|
from ezdxf.layouts import BlockLayout
|
|
from ezdxf.entities import (
|
|
DXFGraphic,
|
|
Insert,
|
|
MText,
|
|
Spline,
|
|
Textstyle,
|
|
)
|
|
|
|
__all__ = [
|
|
"virtual_entities",
|
|
"MultiLeaderBuilder",
|
|
"MultiLeaderMTextBuilder",
|
|
"MultiLeaderBlockBuilder",
|
|
"ConnectionSide",
|
|
"HorizontalConnection",
|
|
"VerticalConnection",
|
|
"LeaderType",
|
|
"TextAlignment",
|
|
"BlockAlignment",
|
|
]
|
|
|
|
logger = logging.getLogger("ezdxf")
|
|
|
|
# How to render MLEADER: https://atlight.github.io/formats/dxf-leader.html
|
|
# DXF reference:
|
|
# http://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-72D20B8C-0F5E-4993-BEB7-0FCF94F32BE0
|
|
|
|
# AutoCAD and BricsCAD always (?) use values stored in the MULTILEADER
|
|
# entity, even if the override flag isn't set!
|
|
IGNORE_OVERRIDE_FLAGS = True
|
|
|
|
# Distance between baseline and underline as factor of cap-height
|
|
# Just guessing but the descender height is too much!
|
|
UNDERLINE_DISTANCE_FACTOR = 0.20
|
|
|
|
|
|
class ConnectionTypeError(const.DXFError):
|
|
pass
|
|
|
|
|
|
OVERRIDE_FLAG = {
|
|
"leader_type": 1 << 0,
|
|
"leader_line_color": 1 << 1,
|
|
"leader_linetype_handle": 1 << 2,
|
|
"leader_lineweight": 1 << 3,
|
|
"has_landing": 1 << 4,
|
|
"landing_gap": 1 << 5, # ??? not in MultiLeader
|
|
"has_dogleg": 1 << 6,
|
|
"dogleg_length": 1 << 7,
|
|
"arrow_head_handle": 1 << 8,
|
|
"arrow_head_size": 1 << 9,
|
|
"content_type": 1 << 10,
|
|
"text_style_handle": 1 << 11,
|
|
"text_left_attachment_type": 1 << 12,
|
|
"text_angle_type": 1 << 13,
|
|
"text_alignment_type": 1 << 14,
|
|
"text_color": 1 << 15,
|
|
"char_height": 1 << 16, # stored in MultiLeader.context.char_height
|
|
"has_text_frame": 1 << 17,
|
|
# 1 << 18 # use default content stored in MultiLeader.mtext.default_content
|
|
"block_record_handle": 1 << 19,
|
|
"block_color": 1 << 20,
|
|
"block_scale_vector": 1 << 21, # 3 values in MLeaderStyle
|
|
"block_rotation": 1 << 22,
|
|
"block_connection_type": 1 << 23,
|
|
"scale": 1 << 24,
|
|
"text_right_attachment_type": 1 << 25,
|
|
"text_switch_alignment": 1 << 26, # ??? not in MultiLeader/MLeaderStyle
|
|
"text_attachment_direction": 1 << 27, # this flag is not set by BricsCAD
|
|
"text_top_attachment_type": 1 << 28,
|
|
"text_bottom_attachment_type": 1 << 29,
|
|
}
|
|
|
|
|
|
class MLeaderStyleOverride:
|
|
def __init__(self, style: MLeaderStyle, mleader: MultiLeader):
|
|
self._style_dxf = style.dxf
|
|
self._mleader_dxf = mleader.dxf
|
|
self._context = mleader.context
|
|
self._property_override_flags = mleader.dxf.get(
|
|
"property_override_flags", 0
|
|
)
|
|
self._block_scale_vector = Vec3(
|
|
(
|
|
style.dxf.get("block_scale_x", 1.0),
|
|
style.dxf.get("block_scale_y", 1.0),
|
|
style.dxf.get("block_scale_z", 1.0),
|
|
)
|
|
)
|
|
self.use_mtext_default_content = bool(
|
|
self._property_override_flags & (1 << 18)
|
|
) # if False, what MTEXT content is used?
|
|
|
|
def get(self, attrib_name: str) -> Any:
|
|
# Set MLEADERSTYLE value as default value:
|
|
if attrib_name == "block_scale_vector":
|
|
value = self._block_scale_vector
|
|
else:
|
|
value = self._style_dxf.get_default(attrib_name)
|
|
if IGNORE_OVERRIDE_FLAGS or self.is_overridden(attrib_name):
|
|
# Get overridden value from MULTILEADER:
|
|
if attrib_name == "char_height":
|
|
value = self._context.char_height
|
|
else:
|
|
value = self._mleader_dxf.get(attrib_name, value)
|
|
return value
|
|
|
|
def is_overridden(self, attrib_name: str) -> bool:
|
|
flag = OVERRIDE_FLAG.get(attrib_name, 0)
|
|
return bool(flag & self._property_override_flags)
|
|
|
|
|
|
def virtual_entities(
|
|
mleader: MultiLeader, proxy_graphic=False
|
|
) -> Iterator[DXFGraphic]:
|
|
doc = mleader.doc
|
|
assert doc is not None, "valid DXF document required"
|
|
if proxy_graphic and mleader.proxy_graphic is not None:
|
|
return ProxyGraphic(mleader.proxy_graphic, doc).virtual_entities()
|
|
else:
|
|
return iter(RenderEngine(mleader, doc).run())
|
|
|
|
|
|
def get_style(mleader: MultiLeader, doc: Drawing) -> MLeaderStyleOverride:
|
|
handle = mleader.dxf.style_handle
|
|
style = doc.entitydb.get(handle)
|
|
if style is None:
|
|
logger.warning(
|
|
f"referenced MLEADERSTYLE(#{handle}) does not exist, "
|
|
f"replaced by 'Standard'"
|
|
)
|
|
style = doc.mleader_styles.get("Standard")
|
|
assert style is not None, "mandatory MLEADERSTYLE 'Standard' does not exist"
|
|
return MLeaderStyleOverride(cast(MLeaderStyle, style), mleader)
|
|
|
|
|
|
def get_text_style(handle: str, doc: Drawing) -> Textstyle:
|
|
text_style = doc.entitydb.get(handle)
|
|
if text_style is None:
|
|
logger.warning(
|
|
f"referenced STYLE(#{handle}) does not exist, "
|
|
f"replaced by 'Standard'"
|
|
)
|
|
text_style = doc.styles.get("Standard")
|
|
assert text_style is not None, "mandatory STYLE 'Standard' does not exist"
|
|
return text_style # type: ignore
|
|
|
|
|
|
def get_arrow_direction(vertices: list[Vec3]) -> Vec3:
|
|
if len(vertices) < 2:
|
|
return X_AXIS
|
|
direction = vertices[1] - vertices[0]
|
|
return direction.normalize()
|
|
|
|
|
|
ACI_COLOR_TYPES = {
|
|
colors.COLOR_TYPE_BY_BLOCK,
|
|
colors.COLOR_TYPE_BY_LAYER,
|
|
colors.COLOR_TYPE_ACI,
|
|
}
|
|
|
|
|
|
def decode_raw_color(raw_color: int) -> tuple[int, Optional[int]]:
|
|
aci_color = colors.BYBLOCK
|
|
true_color: Optional[int] = None
|
|
color_type, color = colors.decode_raw_color_int(raw_color)
|
|
if color_type in ACI_COLOR_TYPES:
|
|
aci_color = color
|
|
elif color_type == colors.COLOR_TYPE_RGB:
|
|
true_color = color
|
|
# COLOR_TYPE_WINDOW_BG: not supported as entity color
|
|
return aci_color, true_color
|
|
|
|
|
|
def copy_mtext_data(mtext: MText, mtext_data: MTextData, scale: float) -> None:
|
|
# MLEADERSTYLE has a flag "use_mtext_default_content", what else should be
|
|
# used as content if this flag is false?
|
|
mtext.text = mtext_data.default_content
|
|
dxf = mtext.dxf
|
|
aci_color, true_color = decode_raw_color(mtext_data.color)
|
|
dxf.color = aci_color
|
|
if true_color is not None:
|
|
dxf.true_color = true_color
|
|
dxf.insert = mtext_data.insert
|
|
assert mtext.doc is not None
|
|
mtext.dxf.style = get_text_style(
|
|
mtext_data.style_handle, mtext.doc
|
|
).dxf.name
|
|
if not mtext_data.extrusion.isclose(Z_AXIS):
|
|
dxf.extrusion = mtext_data.extrusion
|
|
dxf.text_direction = mtext_data.text_direction
|
|
# ignore rotation!
|
|
dxf.width = mtext_data.width * scale
|
|
dxf.line_spacing_factor = mtext_data.line_spacing_factor
|
|
dxf.line_spacing_style = mtext_data.line_spacing_style
|
|
dxf.flow_direction = mtext_data.flow_direction
|
|
# alignment=attachment_point: 1=top left, 2=top center, 3=top right
|
|
dxf.attachment_point = mtext_data.alignment
|
|
|
|
|
|
def make_mtext(mleader: MultiLeader) -> MText:
|
|
mtext = cast("MText", factory.new("MTEXT", doc=mleader.doc))
|
|
mtext.dxf.layer = mleader.dxf.layer
|
|
context = mleader.context
|
|
mtext_data = context.mtext
|
|
if mtext_data is None:
|
|
raise TypeError(f"MULTILEADER has no MTEXT content")
|
|
scale = context.scale
|
|
# !char_height is the final already scaled value!
|
|
mtext.dxf.char_height = context.char_height
|
|
if mtext_data is not None:
|
|
copy_mtext_data(mtext, mtext_data, scale)
|
|
if mtext_data.has_bg_fill:
|
|
set_mtext_bg_fill(mtext, mtext_data)
|
|
set_mtext_columns(mtext, mtext_data, scale)
|
|
return mtext
|
|
|
|
|
|
def set_mtext_bg_fill(mtext: MText, mtext_data: MTextData) -> None:
|
|
# Note: the "text frame" flag (16) in "bg_fill" is never set by BricsCAD!
|
|
# Set required DXF attributes:
|
|
mtext.dxf.box_fill_scale = mtext_data.bg_scale_factor
|
|
mtext.dxf.bg_fill = 1
|
|
mtext.dxf.bg_fill_color = colors.BYBLOCK
|
|
mtext.dxf.bg_fill_transparency = mtext_data.bg_transparency
|
|
color_type, color = colors.decode_raw_color_int(mtext_data.bg_color)
|
|
if color_type in ACI_COLOR_TYPES:
|
|
mtext.dxf.bg_fill_color = color
|
|
elif color_type == colors.COLOR_TYPE_RGB:
|
|
mtext.dxf.bg_fill_true_color = color
|
|
|
|
if (
|
|
mtext_data.use_window_bg_color
|
|
or color_type == colors.COLOR_TYPE_WINDOW_BG
|
|
):
|
|
# override fill mode, but keep stored colors
|
|
mtext.dxf.bg_fill = 3
|
|
|
|
|
|
def set_mtext_columns(
|
|
mtext: MText, mtext_data: MTextData, scale: float
|
|
) -> None:
|
|
# BricsCAD does not support columns for MTEXT content, so exploring
|
|
# MLEADER with columns was not possible!
|
|
pass
|
|
|
|
|
|
def _get_insert(entity: MultiLeader) -> Vec3:
|
|
context = entity.context
|
|
if context.mtext is not None:
|
|
return context.mtext.insert
|
|
elif context.block is not None:
|
|
return context.block.insert
|
|
return context.plane_origin
|
|
|
|
|
|
def _get_extrusion(entity: MultiLeader) -> Vec3:
|
|
context = entity.context
|
|
if context.mtext is not None:
|
|
return context.mtext.extrusion
|
|
elif context.block is not None:
|
|
return context.block.extrusion
|
|
return context.plane_z_axis
|
|
|
|
|
|
def _get_dogleg_vector(leader: LeaderData, default: Vec3 = X_AXIS) -> Vec3:
|
|
# All leader vertices and directions in WCS!
|
|
try:
|
|
if leader.has_dogleg_vector: # what else?
|
|
return leader.dogleg_vector.normalize(leader.dogleg_length)
|
|
except ZeroDivisionError: # dogleg_vector is NULL
|
|
pass
|
|
return default.normalize(leader.dogleg_length)
|
|
|
|
|
|
def _get_block_name(handle: str, doc: Drawing) -> Optional[str]:
|
|
block_record = doc.entitydb.get(handle)
|
|
if block_record is None:
|
|
logger.error(f"required BLOCK_RECORD entity #{handle} does not exist")
|
|
return None
|
|
return block_record.dxf.get("name") # has no default value
|
|
|
|
|
|
class RenderEngine:
|
|
def __init__(self, mleader: MultiLeader, doc: Drawing):
|
|
self.entities: list[DXFGraphic] = [] # result container
|
|
self.mleader = mleader
|
|
self.doc = doc
|
|
self.style: MLeaderStyleOverride = get_style(mleader, doc)
|
|
self.context = mleader.context
|
|
self.insert = _get_insert(mleader)
|
|
self.extrusion: Vec3 = _get_extrusion(mleader)
|
|
self.ocs: Optional[OCS] = None
|
|
if not self.extrusion.isclose(Z_AXIS):
|
|
self.ocs = OCS(self.extrusion)
|
|
self.elevation: float = self.insert.z
|
|
|
|
# Gather final parameters from various places:
|
|
# This is the actual entity scaling, ignore scale in MLEADERSTYLE!
|
|
self.scale: float = self.context.scale
|
|
self.layer = mleader.dxf.layer
|
|
self.linetype = self.linetype_name()
|
|
self.lineweight = self.style.get("leader_lineweight")
|
|
aci_color, true_color = decode_raw_color(
|
|
self.style.get("leader_line_color")
|
|
)
|
|
self.leader_aci_color: int = aci_color
|
|
self.leader_true_color: Optional[int] = true_color
|
|
self.leader_type: int = self.style.get("leader_type")
|
|
self.has_text_frame = False
|
|
self.has_dogleg: bool = bool(self.style.get("has_dogleg"))
|
|
self.arrow_heads: dict[int, str] = {
|
|
head.index: head.handle for head in mleader.arrow_heads
|
|
}
|
|
self.arrow_head_handle = self.style.get("arrow_head_handle")
|
|
self.dxf_mtext_entity: Optional["MText"] = None
|
|
self._dxf_mtext_extents: Optional[tuple[float, float]] = None
|
|
self.has_horizontal_attachment = bool(
|
|
self.style.get("text_attachment_direction")
|
|
)
|
|
self.left_attachment_type = self.style.get("text_left_attachment_type")
|
|
self.right_attachment_type = self.style.get(
|
|
"text_right_attachment_type"
|
|
)
|
|
self.top_attachment_type = self.style.get("text_top_attachment_type")
|
|
self.bottom_attachment_type = self.style.get(
|
|
"text_bottom_attachment_type"
|
|
)
|
|
|
|
@property
|
|
def has_extrusion(self) -> bool:
|
|
return self.ocs is not None
|
|
|
|
@property
|
|
def has_text_content(self) -> bool:
|
|
return self.context.mtext is not None
|
|
|
|
@property
|
|
def has_block_content(self) -> bool:
|
|
return self.context.block is not None
|
|
|
|
@property
|
|
def mtext_extents(self) -> tuple[float, float]:
|
|
"""Calculate MTEXT width on demand."""
|
|
|
|
if self._dxf_mtext_extents is not None:
|
|
return self._dxf_mtext_extents
|
|
if self.dxf_mtext_entity is not None:
|
|
# TODO: this is very inaccurate if using inline codes, better
|
|
# solution is required like a text layout engine with column width
|
|
# calculation from the MTEXT content.
|
|
width, height = text_size.estimate_mtext_extents(
|
|
self.dxf_mtext_entity
|
|
)
|
|
else:
|
|
width, height = 0.0, 0.0
|
|
self._dxf_mtext_extents = (width, height)
|
|
return self._dxf_mtext_extents
|
|
|
|
def run(self) -> list[DXFGraphic]:
|
|
"""Entry point to render MLEADER entities."""
|
|
self.entities.clear()
|
|
if abs(self.scale) > 1e-9:
|
|
self.add_content()
|
|
self.add_leaders()
|
|
# otherwise it vanishes by scaling down to almost "nothing"
|
|
return self.entities
|
|
|
|
def linetype_name(self) -> str:
|
|
handle = self.style.get("leader_linetype_handle")
|
|
ltype = self.doc.entitydb.get(handle)
|
|
if ltype is not None:
|
|
return ltype.dxf.name
|
|
logger.warning(f"invalid linetype handle #{handle} in {self.mleader}")
|
|
return "Continuous"
|
|
|
|
def arrow_block_name(self, index: int) -> str:
|
|
closed_filled = "_CLOSED_FILLED"
|
|
handle = self.arrow_heads.get(index, self.arrow_head_handle)
|
|
if handle is None or handle == "0":
|
|
return closed_filled
|
|
block_record = self.doc.entitydb.get(handle)
|
|
if block_record is None:
|
|
logger.warning(
|
|
f"arrow block #{handle} in {self.mleader} does not exist, "
|
|
f"replaced by closed filled arrow"
|
|
)
|
|
return closed_filled
|
|
return block_record.dxf.name
|
|
|
|
def leader_line_attribs(self, raw_color: Optional[int] = None) -> dict:
|
|
aci_color = self.leader_aci_color
|
|
true_color = self.leader_true_color
|
|
|
|
# Ignore color override value BYBLOCK!
|
|
if raw_color and raw_color is not colors.BY_BLOCK_RAW_VALUE:
|
|
aci_color, true_color = decode_raw_color(raw_color)
|
|
|
|
attribs = {
|
|
"layer": self.layer,
|
|
"color": aci_color,
|
|
"linetype": self.linetype,
|
|
"lineweight": self.lineweight,
|
|
}
|
|
if true_color is not None:
|
|
attribs["true_color"] = true_color
|
|
return attribs
|
|
|
|
def add_content(self) -> None:
|
|
# also check self.style.get("content_type") ?
|
|
if self.has_text_content:
|
|
self.add_mtext_content()
|
|
elif self.has_block_content:
|
|
self.add_block_content()
|
|
|
|
def add_mtext_content(self) -> None:
|
|
mtext = make_mtext(self.mleader)
|
|
self.entities.append(mtext)
|
|
self.dxf_mtext_entity = mtext
|
|
if self.has_text_frame:
|
|
self.add_text_frame()
|
|
|
|
def add_text_frame(self) -> None:
|
|
# not supported - yet?
|
|
# 1. This requires a full MTEXT height calculation.
|
|
# 2. Only the default rectangle and the rounded rectangle are
|
|
# acceptable every other text frame is just ugly, especially
|
|
# when the MTEXT gets more complex.
|
|
pass
|
|
|
|
def add_block_content(self) -> None:
|
|
block = self.context.block
|
|
assert block is not None
|
|
block_name = _get_block_name(block.block_record_handle, self.doc) # type: ignore
|
|
if block_name is None:
|
|
return
|
|
location = block.insert # in WCS, really funny for an OCS entity!
|
|
rotation = math.degrees(block.rotation)
|
|
if self.ocs is not None:
|
|
location = self.ocs.from_wcs(location)
|
|
aci_color, true_color = decode_raw_color(block.color)
|
|
scale = block.scale
|
|
|
|
attribs = {
|
|
"name": block_name,
|
|
"insert": location,
|
|
"rotation": rotation,
|
|
"color": aci_color,
|
|
"extrusion": block.extrusion,
|
|
"xscale": scale.x,
|
|
"yscale": scale.y,
|
|
"zscale": scale.z,
|
|
}
|
|
if true_color is not None:
|
|
attribs["true_color"] = true_color
|
|
insert = cast(
|
|
"Insert", factory.new("INSERT", dxfattribs=attribs, doc=self.doc)
|
|
)
|
|
self.entities.append(insert)
|
|
if self.mleader.block_attribs:
|
|
self.add_block_attributes(insert)
|
|
|
|
def add_block_attributes(self, insert: Insert):
|
|
entitydb = self.doc.entitydb
|
|
values: dict[str, str] = dict()
|
|
for attrib in self.mleader.block_attribs:
|
|
attdef = entitydb.get(attrib.handle)
|
|
if attdef is None:
|
|
logger.error(
|
|
f"required ATTDEF entity #{attrib.handle} does not exist"
|
|
)
|
|
continue
|
|
tag = attdef.dxf.tag
|
|
values[tag] = attrib.text
|
|
if values:
|
|
insert.add_auto_attribs(values)
|
|
|
|
def add_leaders(self) -> None:
|
|
if self.leader_type == 0:
|
|
return
|
|
|
|
for leader in self.context.leaders:
|
|
for line in leader.lines:
|
|
self.add_leader_line(leader, line)
|
|
|
|
if self.has_text_content:
|
|
if leader.has_horizontal_attachment:
|
|
# add text underlines for these horizontal attachment styles:
|
|
# 5 = bottom of bottom text line & underline bottom text line
|
|
# 6 = bottom of top text line & underline top text line
|
|
self.add_text_underline(leader)
|
|
else:
|
|
# text with vertical attachment may have an extra "overline"
|
|
# across the text
|
|
self.add_overline(leader)
|
|
|
|
def add_text_underline(self, leader: LeaderData):
|
|
mtext = self.context.mtext
|
|
if mtext is None:
|
|
return
|
|
has_left_underline = self.left_attachment_type in (5, 6, 8)
|
|
has_right_underline = self.right_attachment_type in (5, 6, 8)
|
|
if not (has_left_underline or has_right_underline):
|
|
return
|
|
connection_point = leader.last_leader_point + _get_dogleg_vector(leader)
|
|
width, height = self.mtext_extents
|
|
length = width + self.context.landing_gap_size
|
|
if length < 1e-9:
|
|
return
|
|
# The connection point is on the "left" or "right" side of the
|
|
# detection line, which is a "vertical" line through the text
|
|
# insertion point.
|
|
start = mtext.insert
|
|
if self.ocs is None: # text plane is parallel to the xy-plane
|
|
start2d = start.vec2
|
|
up2d = mtext.text_direction.vec2.orthogonal()
|
|
cp2d = connection_point.vec2
|
|
else: # project points into the text plane
|
|
from_wcs = self.ocs.from_wcs
|
|
start2d = Vec2(from_wcs(start))
|
|
up2d = Vec2(from_wcs(mtext.text_direction)).orthogonal()
|
|
cp2d = Vec2(from_wcs(connection_point))
|
|
is_left = is_point_left_of_line(cp2d, start2d, start2d + up2d)
|
|
is_right = not is_left
|
|
line = mtext.text_direction.normalize(length if is_left else -length)
|
|
if (is_left and has_left_underline) or (
|
|
is_right and has_right_underline
|
|
):
|
|
self.add_dxf_line(connection_point, connection_point + line)
|
|
|
|
def add_overline(self, leader: LeaderData):
|
|
mtext = self.context.mtext
|
|
if mtext is None:
|
|
return
|
|
has_bottom_line = self.bottom_attachment_type == 10
|
|
has_top_line = self.top_attachment_type == 10
|
|
if not (has_bottom_line or has_top_line):
|
|
return
|
|
length, height = self.mtext_extents
|
|
if length < 1e-9:
|
|
return
|
|
|
|
# The end of the leader is the center of the "overline".
|
|
# The leader is on the bottom of the text if the insertion
|
|
# point of the text is left of "overline" (start -> end).
|
|
center = leader.last_leader_point
|
|
insert = mtext.insert
|
|
line2 = mtext.text_direction.normalize(length / 2)
|
|
start = center - line2
|
|
end = center + line2
|
|
|
|
if self.ocs is None: # z-axis is ignored
|
|
bottom = is_point_left_of_line(insert, start, end)
|
|
else: # project points into the text plane, z-axis is ignored
|
|
from_wcs = self.ocs.from_wcs
|
|
bottom = is_point_left_of_line(
|
|
from_wcs(insert), from_wcs(start), from_wcs(end)
|
|
)
|
|
top = not bottom
|
|
if (bottom and has_bottom_line) or (top and has_top_line):
|
|
self.add_dxf_line(start, end)
|
|
|
|
def leader_vertices(
|
|
self, leader: LeaderData, line_vertices: list[Vec3], has_dogleg=False
|
|
) -> list[Vec3]:
|
|
# All leader vertices and directions in WCS!
|
|
vertices = list(line_vertices)
|
|
end_point = leader.last_leader_point
|
|
if leader.has_horizontal_attachment:
|
|
if has_dogleg:
|
|
vertices.append(end_point)
|
|
vertices.append(end_point + _get_dogleg_vector(leader))
|
|
else:
|
|
vertices.append(end_point)
|
|
return vertices
|
|
|
|
def add_leader_line(self, leader: LeaderData, line: LeaderLine):
|
|
# All leader vertices and directions in WCS!
|
|
leader_type: int = self.leader_type
|
|
if leader_type == 0: # invisible leader lines
|
|
return
|
|
has_dogleg: bool = self.has_dogleg
|
|
if leader_type == 2: # splines do not have a dogleg!
|
|
has_dogleg = False
|
|
vertices: list[Vec3] = self.leader_vertices(
|
|
leader, line.vertices, has_dogleg
|
|
)
|
|
if len(vertices) < 2: # at least 2 vertices required
|
|
return
|
|
|
|
arrow_direction: Vec3 = get_arrow_direction(vertices)
|
|
raw_color: int = line.color
|
|
index: int = line.index
|
|
block_name: str = self.create_arrow_block(self.arrow_block_name(index))
|
|
arrow_size: float = self.context.arrow_head_size
|
|
self.add_arrow(
|
|
name=block_name,
|
|
location=vertices[0],
|
|
direction=arrow_direction,
|
|
scale=arrow_size,
|
|
color=raw_color,
|
|
)
|
|
arrow_offset: Vec3 = arrow_direction * arrow_length(
|
|
block_name, arrow_size
|
|
)
|
|
vertices[0] += arrow_offset
|
|
if leader_type == 1: # add straight lines
|
|
for s, e in zip(vertices, vertices[1:]):
|
|
self.add_dxf_line(s, e, raw_color)
|
|
elif leader_type == 2: # add spline
|
|
if leader.has_horizontal_attachment:
|
|
end_tangent = _get_dogleg_vector(leader)
|
|
else:
|
|
end_tangent = vertices[-1] - vertices[-2]
|
|
self.add_dxf_spline(
|
|
vertices,
|
|
# tangent normalization is not required
|
|
tangents=[arrow_direction, end_tangent],
|
|
color=raw_color,
|
|
)
|
|
|
|
def create_arrow_block(self, name: str) -> str:
|
|
if name not in self.doc.blocks:
|
|
# create predefined arrows
|
|
arrow_name = ARROWS.arrow_name(name)
|
|
if arrow_name not in ARROWS:
|
|
arrow_name = ARROWS.closed_filled
|
|
name = ARROWS.create_block(self.doc.blocks, arrow_name)
|
|
return name
|
|
|
|
def add_dxf_spline(
|
|
self,
|
|
fit_points: list[Vec3],
|
|
tangents: Optional[Iterable[UVec]] = None,
|
|
color: Optional[int] = None,
|
|
):
|
|
attribs = self.leader_line_attribs(color)
|
|
spline = cast(
|
|
"Spline",
|
|
factory.new("SPLINE", dxfattribs=attribs, doc=self.doc),
|
|
)
|
|
spline.apply_construction_tool(
|
|
fit_points_to_cad_cv(fit_points, tangents=tangents)
|
|
)
|
|
self.entities.append(spline)
|
|
|
|
def add_dxf_line(self, start: Vec3, end: Vec3, color: Optional[int] = None):
|
|
attribs = self.leader_line_attribs(color)
|
|
attribs["start"] = start
|
|
attribs["end"] = end
|
|
self.entities.append(
|
|
factory.new("LINE", dxfattribs=attribs, doc=self.doc) # type: ignore
|
|
)
|
|
|
|
def add_arrow(
|
|
self,
|
|
name: str,
|
|
location: Vec3,
|
|
direction: Vec3,
|
|
scale: float,
|
|
color: int,
|
|
):
|
|
attribs = self.leader_line_attribs(color)
|
|
attribs["name"] = name
|
|
if self.ocs is not None:
|
|
location = self.ocs.from_wcs(location)
|
|
direction = self.ocs.from_wcs(direction)
|
|
attribs["extrusion"] = self.extrusion
|
|
|
|
attribs["insert"] = location
|
|
attribs["rotation"] = direction.angle_deg + 180.0
|
|
attribs["xscale"] = scale
|
|
attribs["yscale"] = scale
|
|
attribs["zscale"] = scale
|
|
self.entities.append(
|
|
factory.new("INSERT", dxfattribs=attribs, doc=self.doc) # type: ignore
|
|
)
|
|
|
|
|
|
class LeaderType(enum.IntEnum):
|
|
"""The leader type."""
|
|
|
|
none = 0
|
|
straight_lines = 1
|
|
splines = 2
|
|
|
|
|
|
# noinspection PyArgumentList
|
|
class ConnectionSide(enum.Enum):
|
|
"""
|
|
The leader connection side.
|
|
|
|
Vertical (top, bottom) and horizontal attachment sides (left, right)
|
|
can not be mixed in a single entity - this is a limitation of the
|
|
MULTILEADER entity.
|
|
"""
|
|
|
|
left = enum.auto()
|
|
right = enum.auto()
|
|
top = enum.auto()
|
|
bottom = enum.auto()
|
|
|
|
|
|
DOGLEG_DIRECTIONS = {
|
|
ConnectionSide.left: X_AXIS,
|
|
ConnectionSide.right: -X_AXIS,
|
|
ConnectionSide.top: -X_AXIS, # ???
|
|
ConnectionSide.bottom: X_AXIS, # ???
|
|
}
|
|
|
|
|
|
class HorizontalConnection(enum.IntEnum):
|
|
"""The horizontal leader connection type."""
|
|
|
|
by_style = -1
|
|
top_of_top_line = 0
|
|
middle_of_top_line = 1
|
|
middle_of_text = 2
|
|
middle_of_bottom_line = 3
|
|
bottom_of_bottom_line = 4
|
|
bottom_of_bottom_line_underline = 5
|
|
bottom_of_top_line_underline = 6
|
|
bottom_of_top_line = 7
|
|
bottom_of_top_line_underline_all = 8
|
|
|
|
|
|
class VerticalConnection(enum.IntEnum):
|
|
"""The vertical leader connection type."""
|
|
|
|
by_style = 0
|
|
center = 9
|
|
center_overline = 10
|
|
|
|
|
|
class TextAlignment(enum.IntEnum):
|
|
"""The :class:`MText` alignment type."""
|
|
|
|
left = 1
|
|
center = 2
|
|
right = 3
|
|
|
|
|
|
class BlockAlignment(enum.IntEnum):
|
|
"""The :class:`Block` alignment type."""
|
|
|
|
center_extents = 0
|
|
insertion_point = 1
|
|
|
|
|
|
@dataclass
|
|
class ConnectionBox:
|
|
"""Contains the connection points for all 4 sides of the content, the
|
|
landing gap is included.
|
|
"""
|
|
|
|
left: Vec2
|
|
right: Vec2
|
|
top: Vec2
|
|
bottom: Vec2
|
|
|
|
def get(self, side: ConnectionSide) -> Vec2:
|
|
if side == ConnectionSide.left:
|
|
return self.left
|
|
elif side == ConnectionSide.right:
|
|
return self.right
|
|
elif side == ConnectionSide.top:
|
|
return self.top
|
|
return self.bottom
|
|
|
|
|
|
def ocs_rotation(ucs: UCS) -> float:
|
|
"""Returns the ocs rotation angle of the UCS"""
|
|
if not Z_AXIS.isclose(ucs.uz):
|
|
ocs = OCS(ucs.uz)
|
|
x_axis_in_ocs = Vec2(ocs.from_wcs(ucs.ux))
|
|
return x_axis_in_ocs.angle # in radians!
|
|
return 0.0
|
|
|
|
|
|
class MultiLeaderBuilder(abc.ABC):
|
|
def __init__(self, multileader: MultiLeader):
|
|
doc = multileader.doc
|
|
assert doc is not None, "valid DXF document required"
|
|
handle = multileader.dxf.style_handle
|
|
style: MLeaderStyle = doc.entitydb.get(handle) # type: ignore
|
|
if style is None:
|
|
raise ValueError(f"invalid MLEADERSTYLE handle #{handle}")
|
|
self._doc: "Drawing" = doc
|
|
self._mleader_style: MLeaderStyle = style
|
|
self._multileader = multileader
|
|
self._leaders: dict[ConnectionSide, list[list[Vec2]]] = defaultdict(
|
|
list
|
|
)
|
|
self.set_mleader_style(style)
|
|
self._multileader.context.landing_gap_size = (
|
|
self._mleader_style.dxf.landing_gap_size
|
|
)
|
|
|
|
@abc.abstractmethod
|
|
def _init_content(self):
|
|
...
|
|
|
|
def _reset_cache(self):
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def _build_connection_box(self) -> ConnectionBox:
|
|
"""Returns the connection box with the connection points on all 4 sides
|
|
in build UCS coordinates. The origin of the build ucs is the insertion
|
|
or attachment point of the content.
|
|
"""
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def _transform_content_to_render_ucs(
|
|
self, insert: Vec2, rotation: float
|
|
) -> None:
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def _apply_conversion_factor(self, conversion_factor: float) -> None:
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def _set_base_point(self, left: Vec3, bottom: Vec3):
|
|
...
|
|
|
|
@property
|
|
def multileader(self) -> MultiLeader:
|
|
"""Returns the :class:`~ezdxf.entities.MultiLeader` entity."""
|
|
return self._multileader
|
|
|
|
@property
|
|
def context(self) -> MLeaderContext:
|
|
"""Returns the context entity :class:`~ezdxf.entities.MLeaderContext`."""
|
|
return self._multileader.context
|
|
|
|
@property
|
|
def _landing_gap_size(self) -> float:
|
|
return self._multileader.context.landing_gap_size
|
|
|
|
def set_mleader_style(self, style: MLeaderStyle):
|
|
"""Reset base properties by :class:`~ezdxf.entities.MLeaderStyle`
|
|
properties. This also resets the content!
|
|
"""
|
|
|
|
def copy_style_to_context():
|
|
self.context.char_height = style_dxf.char_height
|
|
self.context.landing_gap_size = style_dxf.landing_gap_size
|
|
|
|
self._mleader_style = style
|
|
multileader_dxf = self._multileader.dxf
|
|
style_dxf = style.dxf
|
|
keys = list(acdb_mleader_style.attribs.keys())
|
|
for key in keys:
|
|
if multileader_dxf.is_supported(key):
|
|
multileader_dxf.set(key, style_dxf.get_default(key))
|
|
copy_style_to_context()
|
|
multileader_dxf.block_scale_vector = Vec3(
|
|
style_dxf.block_scale_x,
|
|
style_dxf.block_scale_y,
|
|
style_dxf.block_scale_z,
|
|
)
|
|
# MLEADERSTYLE contains unscaled values
|
|
self.context.set_scale(multileader_dxf.scale)
|
|
self._init_content()
|
|
|
|
def set_connection_properties(
|
|
self,
|
|
landing_gap: float = 0.0, # unscaled value!
|
|
dogleg_length: float = 0.0, # unscaled value!
|
|
):
|
|
"""Set the properties how to connect the leader line to the content.
|
|
|
|
The landing gap is the space between the content and the start of the
|
|
leader line. The "dogleg" is the first line segment of the leader
|
|
in the "horizontal" direction of the content.
|
|
|
|
"""
|
|
multileader = self.multileader
|
|
scale = multileader.dxf.scale
|
|
if dogleg_length:
|
|
multileader.dxf.has_dogleg = 1
|
|
multileader.dxf.dogleg_length = dogleg_length * scale
|
|
else:
|
|
multileader.dxf.has_dogleg = 0
|
|
multileader.context.landing_gap_size * landing_gap * scale
|
|
|
|
def set_connection_types(
|
|
self,
|
|
left=HorizontalConnection.by_style,
|
|
right=HorizontalConnection.by_style,
|
|
top=VerticalConnection.by_style,
|
|
bottom=VerticalConnection.by_style,
|
|
):
|
|
"""Set the connection type for each connection side."""
|
|
context = self.context
|
|
style = self._mleader_style
|
|
if left == HorizontalConnection.by_style:
|
|
context.left_attachment = style.dxf.text_left_attachment_type
|
|
else:
|
|
context.left_attachment = int(left)
|
|
|
|
if right == HorizontalConnection.by_style:
|
|
context.right_attachment = style.dxf.text_right_attachment_type
|
|
else:
|
|
context.right_attachment = int(right)
|
|
|
|
if top == VerticalConnection.by_style:
|
|
context.top_attachment = style.dxf.text_top_attachment_type
|
|
else:
|
|
context.top_attachment = int(top)
|
|
|
|
if bottom == VerticalConnection.by_style:
|
|
context.bottom_attachment = style.dxf.text_bottom_attachment_type
|
|
else:
|
|
context.bottom_attachment = int(bottom)
|
|
|
|
dxf = self._multileader.dxf
|
|
dxf.text_left_attachment_type = context.left_attachment
|
|
dxf.text_right_attachment_type = context.right_attachment
|
|
dxf.text_top_attachment_type = context.top_attachment
|
|
dxf.text_bottom_attachment_type = context.bottom_attachment
|
|
|
|
def set_overall_scaling(self, scale: float):
|
|
"""Set the overall scaling factor for the whole entity,
|
|
except for the leader line vertices!
|
|
|
|
Args:
|
|
scale: scaling factor > 0.0
|
|
|
|
"""
|
|
new_scale = float(scale)
|
|
if new_scale <= 0.0:
|
|
raise ValueError(f"invalid scaling factor: {scale}")
|
|
context = self.context
|
|
multileader = self.multileader
|
|
old_scale = multileader.dxf.scale
|
|
try:
|
|
# convert from existing scaling to new scale factor
|
|
conversion_factor = new_scale / old_scale
|
|
except ZeroDivisionError:
|
|
conversion_factor = new_scale
|
|
|
|
multileader.dxf.scale = new_scale
|
|
multileader.dxf.dogleg_length *= conversion_factor
|
|
context.set_scale(new_scale)
|
|
self._apply_conversion_factor(conversion_factor)
|
|
|
|
def set_leader_properties(
|
|
self,
|
|
color: Union[int, colors.RGB] = colors.BYBLOCK,
|
|
linetype: str = "BYBLOCK",
|
|
lineweight: int = const.LINEWEIGHT_BYBLOCK,
|
|
leader_type=LeaderType.straight_lines,
|
|
):
|
|
"""Set leader line properties.
|
|
|
|
Args:
|
|
color: line color as :ref:`ACI` or RGB tuple
|
|
linetype: as name string, e.g. "BYLAYER"
|
|
lineweight: as integer value, see: :ref:`lineweights`
|
|
leader_type: straight lines of spline type
|
|
|
|
"""
|
|
mleader = self._multileader
|
|
mleader.dxf.leader_line_color = colors.encode_raw_color(color)
|
|
linetype_ = self._doc.linetypes.get(linetype)
|
|
if linetype_ is None:
|
|
raise ValueError(f"invalid linetype name '{linetype}'")
|
|
mleader.dxf.leader_linetype_handle = linetype_.dxf.handle
|
|
mleader.dxf.leader_lineweight = lineweight
|
|
mleader.dxf.leader_type = int(leader_type)
|
|
|
|
def set_arrow_properties(
|
|
self,
|
|
name: str = "",
|
|
size: float = 0.0, # 0=by style
|
|
):
|
|
"""Set leader arrow properties all leader lines have the same arrow
|
|
type.
|
|
|
|
The MULTILEADER entity is able to support multiple arrows, but this
|
|
seems to be unsupported by CAD applications and is therefore also not
|
|
supported by the builder classes.
|
|
|
|
"""
|
|
if size == 0.0:
|
|
size = self._mleader_style.dxf.arrow_head_size
|
|
self._multileader.dxf.arrow_head_size = size
|
|
self.context.arrow_head_size = size
|
|
if name:
|
|
self._multileader.dxf.arrow_head_handle = ARROWS.arrow_handle(
|
|
self._doc.blocks, name
|
|
)
|
|
else:
|
|
# empty string is the default "closed filled" arrow,
|
|
# no handle needed
|
|
del self._multileader.dxf.arrow_head_handle
|
|
|
|
def add_leader_line(
|
|
self, side: ConnectionSide, vertices: Iterable[Vec2]
|
|
) -> None:
|
|
"""Add leader as iterable of vertices in render UCS coordinates
|
|
(:ref:`WCS` by default).
|
|
|
|
.. note::
|
|
|
|
Vertical (top, bottom) and horizontal attachment sides (left, right)
|
|
can not be mixed in a single entity - this is a limitation of the
|
|
MULTILEADER entity.
|
|
|
|
Args:
|
|
side: connection side where to attach the leader line
|
|
vertices: leader vertices
|
|
|
|
"""
|
|
self._leaders[side].append(list(vertices))
|
|
|
|
def build(
|
|
self, insert: Vec2, rotation: float = 0.0, ucs: Optional[UCS] = None
|
|
) -> None:
|
|
"""Compute the required geometry data. The construction plane is
|
|
the xy-plane of the given render :class:`~ezdxf.math.UCS`.
|
|
|
|
Args:
|
|
insert: insert location for the content in render UCS coordinates
|
|
rotation: content rotation angle around the render UCS z-axis in degrees
|
|
ucs: the render :class:`~ezdxf.math.UCS`, default is the :ref:`WCS`
|
|
|
|
"""
|
|
assert isinstance(insert, Vec2), "insert has to be a Vec2() object"
|
|
rotation = math.radians(rotation)
|
|
# transformation matrix from build ucs to render ucs
|
|
m = Matrix44.z_rotate(rotation)
|
|
connection_box = self._build_connection_box()
|
|
m.set_row(3, (insert.x, insert.y)) # fast translation
|
|
|
|
self._set_required_multileader_attributes()
|
|
self._transform_content_to_render_ucs(insert, rotation)
|
|
self._set_attachment_direction()
|
|
self._set_base_point( # MTEXT requires "text_attachment_direction"
|
|
left=m.transform(connection_box.left.vec3),
|
|
bottom=m.transform(connection_box.bottom.vec3),
|
|
)
|
|
self.context.leaders.clear()
|
|
|
|
for side, leader_lines in self._leaders.items():
|
|
self._build_leader(leader_lines, side, connection_box.get(side), m)
|
|
if ucs is not None:
|
|
self._transform_to_wcs(ucs)
|
|
self.multileader.update_proxy_graphic()
|
|
|
|
def _set_attachment_direction(self):
|
|
leaders = self._leaders
|
|
if leaders:
|
|
horizontal = (ConnectionSide.left in leaders) or (
|
|
ConnectionSide.right in leaders
|
|
)
|
|
vertical = (ConnectionSide.top in leaders) or (
|
|
ConnectionSide.bottom in leaders
|
|
)
|
|
if horizontal and vertical:
|
|
raise ConnectionTypeError(
|
|
"invalid mix of horizontal and vertical connection types"
|
|
)
|
|
self._multileader.dxf.text_attachment_direction = (
|
|
0 if horizontal else 1
|
|
)
|
|
if vertical:
|
|
self._multileader.dxf.has_dogleg = False
|
|
# else MULTILEADER without any leader lines!
|
|
|
|
def _transform_to_wcs(self, ucs: UCS) -> None:
|
|
# transformation from render UCS into WCS
|
|
self.multileader.transform(ucs.matrix)
|
|
|
|
def _set_required_multileader_attributes(self):
|
|
dxf = self.multileader.dxf
|
|
doc = self._doc
|
|
handle = dxf.get("leader_linetype_handle")
|
|
if handle is None or handle not in doc.entitydb:
|
|
linetype = doc.linetypes.get("BYLAYER")
|
|
if linetype is None:
|
|
raise ValueError(f"required linetype 'BYLAYER' does not exist")
|
|
dxf.leader_linetype_handle = linetype.dxf.handle
|
|
dxf.property_override_flags = 0x7FFFFFFF
|
|
|
|
def _build_leader(
|
|
self,
|
|
leader_lines: list[list[Vec2]],
|
|
side: ConnectionSide,
|
|
connection_point: Vec2,
|
|
m: Matrix44,
|
|
):
|
|
# The content is always aligned to the x- and y-axis of the build UCS!
|
|
|
|
# The dogleg vector points to the content:
|
|
dogleg_direction = DOGLEG_DIRECTIONS[side]
|
|
leader = LeaderData()
|
|
leader.index = len(self.context.leaders)
|
|
|
|
# dogleg_length is the already scaled length!
|
|
leader.dogleg_length = float(self._multileader.dxf.dogleg_length)
|
|
leader.has_dogleg_vector = 1 # ignored by AutoCAD/BricsCAD
|
|
leader.has_last_leader_line = (
|
|
1 # leader is invisible in AutoCAD if 0 ...
|
|
)
|
|
# flag is ignored by BricsCAD
|
|
leader.dogleg_vector = dogleg_direction
|
|
if side == ConnectionSide.left or side == ConnectionSide.right:
|
|
leader.attachment_direction = 0
|
|
else:
|
|
leader.attachment_direction = 1
|
|
|
|
# setting last leader point:
|
|
# landing gap is already included in connection box
|
|
if self.multileader.dxf.has_dogleg:
|
|
leader.last_leader_point = connection_point.vec3 + (
|
|
dogleg_direction * -leader.dogleg_length
|
|
)
|
|
else:
|
|
leader.last_leader_point = connection_point.vec3
|
|
|
|
# transform leader from build ucs to user UCS
|
|
leader.last_leader_point = m.transform(leader.last_leader_point)
|
|
leader.dogleg_vector = m.transform_direction(leader.dogleg_vector)
|
|
|
|
# leader line vertices in user UCS coordinates!
|
|
self._append_leader_lines(leader, leader_lines)
|
|
self.context.leaders.append(leader)
|
|
|
|
@staticmethod
|
|
def _append_leader_lines(
|
|
leader: LeaderData, leader_lines: list[list[Vec2]]
|
|
) -> None:
|
|
for index, vertices in enumerate(leader_lines):
|
|
line = LeaderLine()
|
|
line.index = index
|
|
line.vertices = Vec3.list(vertices)
|
|
leader.lines.append(line)
|
|
|
|
|
|
# TODO: MultiLeaderBuilder & MText horizontal connection type 6 issue in BricsCAD
|
|
# 6 = "underline the bottom of the top line"
|
|
# This connection is not rendered correct in BricsCAD, the rendering in AutoCAD
|
|
# is OK, also the rendering by the ezdxf multileader RenderEngine.
|
|
# Maybe an error in BricsCAD.
|
|
# BricsCAD can create correct MULTILEADER entities with this connection type but
|
|
# I couldn't find the difference in the DXF tags which causes this issue.
|
|
|
|
|
|
class MultiLeaderMTextBuilder(MultiLeaderBuilder):
|
|
def _init_content(self):
|
|
self._reset_cache()
|
|
context = self.context
|
|
style = self._mleader_style
|
|
mleader = self._multileader
|
|
|
|
# reset content type
|
|
mleader.dxf.content_type = 2
|
|
context.block = None
|
|
|
|
# create default mtext content
|
|
mtext = MTextData()
|
|
context.mtext = mtext
|
|
|
|
mtext.default_content = style.dxf.default_text_content
|
|
mtext.style_handle = mleader.dxf.text_style_handle
|
|
mtext.color = mleader.dxf.text_color
|
|
mtext.alignment = mleader.dxf.text_attachment_point
|
|
|
|
# The char height is stored in MLeader Context()!
|
|
# The content dimensions (width, height) are not calculated yet,
|
|
# therefore scaling is not necessary!
|
|
|
|
def set_content(
|
|
self,
|
|
content: str,
|
|
color: Optional[
|
|
Union[int, colors.RGB]
|
|
] = None, # None = uses MLEADERSTYLE value
|
|
char_height: float = 0.0, # unscaled char height, 0.0 is by style
|
|
alignment: TextAlignment = TextAlignment.left,
|
|
style: str = "",
|
|
):
|
|
"""Set MTEXT content.
|
|
|
|
Args:
|
|
content: MTEXT content as string
|
|
color: block color as :ref:`ACI` or RGB tuple
|
|
char_height: initial char height in drawing units
|
|
alignment: :class:`TextAlignment` - left, center, right
|
|
style: name of :class:`~ezdxf.entities.Textstyle` as string
|
|
|
|
"""
|
|
mleader = self._multileader
|
|
context = self.context
|
|
# update MULTILEADER DXF namespace
|
|
if color is not None:
|
|
mleader.dxf.text_color = colors.encode_raw_color(color)
|
|
mleader.dxf.text_attachment_point = int(alignment)
|
|
self._init_content()
|
|
# following attributes are not stored in the MULTILEADER DXF namespace
|
|
assert context.mtext is not None
|
|
context.mtext.default_content = text_tools.escape_dxf_line_endings(
|
|
content
|
|
)
|
|
if char_height:
|
|
context.char_height = char_height * self.multileader.dxf.scale
|
|
if style:
|
|
self._set_mtext_style(style)
|
|
|
|
def _set_mtext_style(self, name: str):
|
|
style = self._doc.styles.get(name)
|
|
if style is not None:
|
|
self._multileader.dxf.text_style_handle = style.dxf.handle
|
|
assert self.context.mtext is not None
|
|
self.context.mtext.style_handle = style.dxf.handle
|
|
else:
|
|
raise ValueError(f"text style '{name}' does not exist")
|
|
|
|
def _build_connection_box(self) -> ConnectionBox:
|
|
"""Returns the connection box with the connection points on all 4 sides
|
|
in build UCS coordinates. The origin of the build ucs is the attachment
|
|
point of the MTEXT content.
|
|
"""
|
|
|
|
def get_left_x() -> float:
|
|
assert mtext is not None # shut-up mypy!!!!
|
|
# relative to the attachment point
|
|
x = -gap # left
|
|
if mtext.alignment == 2: # center
|
|
x = -width * 0.5 - gap
|
|
elif mtext.alignment == 3: # right
|
|
x = -width - gap
|
|
return x
|
|
|
|
def vertical_connection_height(
|
|
connection: HorizontalConnection,
|
|
) -> float:
|
|
underline_distance = char_height * UNDERLINE_DISTANCE_FACTOR
|
|
if connection == HorizontalConnection.middle_of_top_line:
|
|
return -char_height * 0.5
|
|
elif connection == HorizontalConnection.middle_of_text:
|
|
return -height * 0.5
|
|
elif connection == HorizontalConnection.middle_of_bottom_line:
|
|
return -height + char_height * 0.5
|
|
elif connection in (
|
|
HorizontalConnection.bottom_of_bottom_line,
|
|
HorizontalConnection.bottom_of_bottom_line_underline,
|
|
):
|
|
return -height - underline_distance
|
|
elif connection in (
|
|
HorizontalConnection.bottom_of_top_line,
|
|
HorizontalConnection.bottom_of_top_line_underline,
|
|
HorizontalConnection.bottom_of_top_line_underline_all,
|
|
):
|
|
return -char_height - underline_distance
|
|
return 0.0
|
|
|
|
context = self.context
|
|
mtext = context.mtext
|
|
assert isinstance(mtext, MTextData), "undefined MTEXT content"
|
|
left_attachment = HorizontalConnection(context.left_attachment)
|
|
right_attachment = HorizontalConnection(context.right_attachment)
|
|
char_height = context.char_height # scaled value!
|
|
gap = context.landing_gap_size # scaled value!
|
|
|
|
width: float
|
|
height: float
|
|
# required data: context.scale, context.char_height
|
|
entity = make_mtext(self._multileader)
|
|
if text_tools.has_inline_formatting_codes(entity.text):
|
|
size = text_size.mtext_size(entity)
|
|
width = size.total_width
|
|
height = size.total_height
|
|
else:
|
|
width, height = text_size.estimate_mtext_extents(entity)
|
|
|
|
left_x = get_left_x()
|
|
right_x = left_x + width + 2.0 * gap
|
|
center_x = (left_x + right_x) * 0.5
|
|
|
|
# Connection box in build UCS coordinates:
|
|
return ConnectionBox(
|
|
left=Vec2(left_x, vertical_connection_height(left_attachment)),
|
|
right=Vec2(right_x, vertical_connection_height(right_attachment)),
|
|
top=Vec2(center_x, gap),
|
|
bottom=Vec2(center_x, -height - gap),
|
|
)
|
|
|
|
def _transform_content_to_render_ucs(
|
|
self, insert: Vec2, rotation: float
|
|
) -> None:
|
|
mtext = self.context.mtext
|
|
assert mtext is not None
|
|
mtext.extrusion = Z_AXIS
|
|
mtext.insert = Vec3(insert)
|
|
mtext.text_direction = X_AXIS.rotate(rotation)
|
|
# todo: text_angle_type?
|
|
mtext.rotation = rotation
|
|
|
|
def _apply_conversion_factor(self, conversion_factor: float) -> None:
|
|
mtext = self.context.mtext
|
|
assert mtext is not None
|
|
mtext.apply_conversion_factor(conversion_factor)
|
|
|
|
def quick_leader(
|
|
self,
|
|
content: str,
|
|
target: Vec2,
|
|
segment1: Vec2,
|
|
segment2: Optional[Vec2] = None,
|
|
connection_type: Union[
|
|
HorizontalConnection, VerticalConnection
|
|
] = HorizontalConnection.middle_of_top_line,
|
|
ucs: Optional[UCS] = None,
|
|
) -> None:
|
|
"""Creates a quick MTEXT leader. The `target` point defines where the
|
|
leader points to.
|
|
The `segment1` is the first segment of the leader line relative to the
|
|
`target` point, `segment2` is an optional second line segment relative
|
|
to the first line segment.
|
|
The `connection_type` defines the type of connection (horizontal or
|
|
vertical) and the MTEXT alignment (left, center or right).
|
|
Horizontal connections are always left or right aligned, vertical
|
|
connections are always center aligned.
|
|
|
|
Args:
|
|
content: MTEXT content string
|
|
target: leader target point as :class:`Vec2`
|
|
segment1: first leader line segment as relative distance to `insert`
|
|
segment2: optional second leader line segment as relative distance to
|
|
first line segment
|
|
connection_type: one of :class:`HorizontalConnection` or
|
|
:class:`VerticalConnection`
|
|
ucs: the rendering :class:`~ezdxf.math.UCS`, default is the :ref:`WCS`
|
|
|
|
"""
|
|
offset = segment1
|
|
if segment2 is not None:
|
|
offset += segment2
|
|
if isinstance(connection_type, VerticalConnection):
|
|
alignment = TextAlignment.center
|
|
else:
|
|
alignment = (
|
|
TextAlignment.right if offset.x <= 0.0 else TextAlignment.left
|
|
)
|
|
# Use text color and char height defined by the associated MLEADERSTYLE.
|
|
self.set_content(content, alignment=alignment)
|
|
|
|
# Build a connection box to compute the required MTEXT movement.
|
|
move_text_x: float # relative x-movement of MTEXT from insert location
|
|
move_text_y: float # relative y-movement of MTEXT from insert location
|
|
side: ConnectionSide # left, right, top, bottom
|
|
has_dogleg = bool(self.multileader.dxf.has_dogleg)
|
|
connection_box = self._build_connection_box()
|
|
if isinstance(connection_type, HorizontalConnection):
|
|
move_text_x = 0.0 # MTEXT is aligned to the insert location
|
|
if offset.x <= 0.0:
|
|
side = ConnectionSide.right
|
|
move_text_y = -connection_box.right.y # opposite direction
|
|
else:
|
|
side = ConnectionSide.left
|
|
move_text_y = -connection_box.left.y # opposite direction
|
|
self.set_connection_types(
|
|
left=connection_type,
|
|
right=connection_type,
|
|
)
|
|
elif isinstance(connection_type, VerticalConnection):
|
|
# vertical connection never have doglegs!
|
|
has_dogleg = False
|
|
move_text_x = 0.0 # MTEXT is centered to the insert location
|
|
if offset.y >= 0.0:
|
|
side = ConnectionSide.bottom
|
|
move_text_y = -connection_box.bottom.y # opposite direction
|
|
else:
|
|
side = ConnectionSide.top
|
|
move_text_y = -connection_box.top.y # opposite direction
|
|
self.set_connection_types(
|
|
top=connection_type,
|
|
bottom=connection_type,
|
|
)
|
|
else:
|
|
raise ValueError("invalid connection type")
|
|
|
|
# Build the leader lines.
|
|
points = [target]
|
|
if segment2 is not None:
|
|
points.append(target + segment1)
|
|
self.add_leader_line(side, points)
|
|
|
|
# The dogleg_length and gap are already scaled!
|
|
dogleg_length = float(self._multileader.dxf.dogleg_length)
|
|
gap = self._landing_gap_size
|
|
last_leader_point = target + offset
|
|
dogleg_direction = DOGLEG_DIRECTIONS[side]
|
|
if has_dogleg:
|
|
last_segment = dogleg_direction * (dogleg_length + gap)
|
|
else:
|
|
last_segment = Vec2()
|
|
insert = (
|
|
last_leader_point + last_segment + Vec2(move_text_x, move_text_y)
|
|
)
|
|
self.build(insert, ucs=ucs)
|
|
|
|
def _set_base_point(self, left: Vec3, bottom: Vec3):
|
|
if self._multileader.dxf.text_attachment_direction == 0: # horizontal
|
|
self.context.base_point = left
|
|
else: # vertical
|
|
self.context.base_point = bottom
|
|
|
|
|
|
class MultiLeaderBlockBuilder(MultiLeaderBuilder):
|
|
def __init__(self, multileader: MultiLeader):
|
|
super().__init__(multileader)
|
|
self._block_layout: Optional["BlockLayout"] = None # cache
|
|
self._extents = BoundingBox() # cache
|
|
|
|
def _init_content(self):
|
|
self._reset_cache()
|
|
|
|
context = self.context
|
|
multileader = self._multileader
|
|
|
|
# set content type
|
|
multileader.dxf.content_type = 1
|
|
context.mtext = None
|
|
|
|
# create default block content
|
|
block = BlockData()
|
|
context.block = block
|
|
|
|
block.block_record_handle = multileader.dxf.block_record_handle
|
|
# final scaling factors for the INSERT entity:
|
|
block.scale = multileader.dxf.block_scale_vector * context.scale
|
|
block.rotation = multileader.dxf.block_rotation
|
|
block.color = multileader.dxf.block_color
|
|
|
|
def _reset_cache(self):
|
|
super()._reset_cache()
|
|
self._block_layout = None
|
|
self._extents = BoundingBox()
|
|
|
|
@property
|
|
def block_layout(self) -> "BlockLayout":
|
|
"""Returns the block layout."""
|
|
if self._block_layout is not None:
|
|
return self._block_layout
|
|
|
|
block = self.context.block
|
|
assert isinstance(block, BlockData), "undefined BLOCK content"
|
|
|
|
handle = block.block_record_handle
|
|
block_record = self._doc.entitydb.get(handle) # type: ignore
|
|
if block_record is None:
|
|
raise ValueError(f"invalid BLOCK_RECORD handle #{handle}")
|
|
name = block_record.dxf.name
|
|
block_layout = self._doc.blocks.get(name)
|
|
if block_layout is None:
|
|
raise ValueError(
|
|
f"BLOCK '{name}' defined by {str(block_record)} not found"
|
|
)
|
|
self._block_layout = block_layout
|
|
return block_layout
|
|
|
|
@property
|
|
def extents(self) -> BoundingBox:
|
|
"""Returns the bounding box of the block."""
|
|
if not self._extents.has_data:
|
|
from ezdxf import bbox
|
|
|
|
block_layout = self.block_layout
|
|
extents = bbox.extents(
|
|
e for e in block_layout if e.dxftype() != "ATTDEF"
|
|
)
|
|
if not extents.has_data:
|
|
extents.extend([NULLVEC])
|
|
self._extents = extents
|
|
return self._extents
|
|
|
|
def _build_connection_box(self) -> ConnectionBox:
|
|
"""Returns the connection box with the connection points on all 4 sides
|
|
in build UCS coordinates. The origin of the build ucs is the insertion
|
|
point of the BLOCK content.
|
|
"""
|
|
# ignore the landing gap for BLOCK content
|
|
block = self.context.block
|
|
assert isinstance(block, BlockData), "undefined BLOCK content"
|
|
|
|
block_connection_type = self._multileader.dxf.block_connection_type
|
|
block_layout = self.block_layout
|
|
extents = self.extents
|
|
sx = block.scale.x
|
|
sy = block.scale.y
|
|
width2 = extents.size.x * 0.5 * sx
|
|
height2 = extents.size.y * 0.5 * sx
|
|
base_x = block_layout.base_point.x * sx
|
|
base_y = block_layout.base_point.y * sy
|
|
center_x = extents.center.x * sx - base_x
|
|
center_y = extents.center.y * sy - base_y
|
|
|
|
if block_connection_type == BlockAlignment.center_extents:
|
|
# adjustment of the insert point is required!
|
|
block.insert = Vec3(-center_x, -center_y, 0)
|
|
center_x = 0.0
|
|
center_y = 0.0
|
|
return ConnectionBox(
|
|
left=Vec2(center_x - width2, center_y),
|
|
right=Vec2(center_x + width2, center_y),
|
|
top=Vec2(center_x, center_y + height2),
|
|
bottom=Vec2(center_x, center_y - height2),
|
|
)
|
|
|
|
def _transform_content_to_render_ucs(self, insert: Vec2, rotation: float):
|
|
block = self.context.block
|
|
assert isinstance(block, BlockData), "undefined BLOCK content"
|
|
block.extrusion = Z_AXIS
|
|
block.insert = block.insert.rotate(rotation) + Vec3(insert)
|
|
block.rotation = rotation
|
|
|
|
def _apply_conversion_factor(self, conversion_factor: float) -> None:
|
|
block = self.context.block
|
|
assert isinstance(block, BlockData), "undefined BLOCK content"
|
|
block.apply_conversion_factor(conversion_factor)
|
|
|
|
def set_content(
|
|
self,
|
|
name: str, # block name
|
|
color: Union[int, colors.RGB] = colors.BYBLOCK,
|
|
scale: float = 1.0,
|
|
alignment=BlockAlignment.center_extents,
|
|
):
|
|
"""Set BLOCK content.
|
|
|
|
Args:
|
|
name: the block name as string
|
|
color: block color as :ref:`ACI` or RGB tuple
|
|
scale: the block scaling, not to be confused with overall scaling
|
|
alignment: the block insertion point or the center of extents
|
|
|
|
"""
|
|
mleader = self._multileader
|
|
# update MULTILEADER DXF namespace
|
|
block = self._doc.blocks.get(name)
|
|
if block is None:
|
|
raise ValueError(f"undefined BLOCK '{name}'")
|
|
# All angles in MultiLeader are radians!
|
|
mleader.dxf.block_record_handle = block.block_record_handle
|
|
mleader.dxf.block_color = colors.encode_raw_color(color)
|
|
mleader.dxf.block_scale_vector = Vec3(scale, scale, scale)
|
|
mleader.dxf.block_connection_type = int(alignment)
|
|
self._init_content()
|
|
|
|
def set_attribute(self, tag: str, text: str, width: float = 1.0):
|
|
"""Add BLOCK attributes based on an ATTDEF entity in the block
|
|
definition. All properties of the new created ATTRIB entity are
|
|
defined by the template ATTDEF entity including the location.
|
|
|
|
Args:
|
|
tag: attribute tag name
|
|
text: attribute content string
|
|
width: width factor
|
|
|
|
"""
|
|
block_layout = self.block_layout
|
|
block_attribs = self._multileader.block_attribs
|
|
for index, attdef in enumerate(block_layout.attdefs()):
|
|
if tag == attdef.dxf.tag:
|
|
block_attribs.append(
|
|
AttribData(
|
|
handle=attdef.dxf.handle,
|
|
index=index,
|
|
width=float(width), # width factor, do not scale!
|
|
text=str(text),
|
|
)
|
|
)
|
|
break
|
|
|
|
def _set_base_point(self, left: Vec3, bottom: Vec3):
|
|
# BricsCAD does not support vertical attached leaders
|
|
self.context.base_point = left
|