Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/render/dim_base.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

1351 lines
49 KiB
Python

# Copyright (c) 2018-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Iterable,
Optional,
Any,
cast,
)
import math
import abc
from ezdxf.math import (
Vec3,
Vec2,
UVec,
ConstructionLine,
ConstructionBox,
ConstructionArc,
)
from ezdxf.math import UCS, PassTroughUCS, xround, Z_AXIS
from ezdxf.lldxf import const
from ezdxf.enums import TextEntityAlignment
from ezdxf._options import options
from ezdxf.lldxf.const import DXFValueError, DXFUndefinedBlockError
from ezdxf.tools import suppress_zeros
from ezdxf.render.arrows import ARROWS
from ezdxf.entities import DimStyleOverride, Dimension
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import Textstyle
from ezdxf.eztypes import GenericLayoutType
class TextBox(ConstructionBox):
"""Text boundaries representation."""
def __init__(
self,
center: Vec2 = Vec2(0, 0),
width: float = 0.0,
height: float = 0.0,
angle: float = 0.0,
hgap: float = 0.0, # horizontal gap - width
vgap: float = 0.0, # vertical gap - height
):
super().__init__(center, width + 2.0 * hgap, height + 2.0 * vgap, angle)
PLUS_MINUS = "±"
_TOLERANCE_COMMON = r"\A{align};{txt}{{\H{fac:.2f}x;"
TOLERANCE_TEMPLATE1 = _TOLERANCE_COMMON + r"{tol}}}"
TOLERANCE_TEMPLATE2 = _TOLERANCE_COMMON + r"\S{upr}^ {lwr};}}"
LIMITS_TEMPLATE = r"{{\H{fac:.2f}x;\S{upr}^ {lwr};}}"
def OptionalVec2(v) -> Optional[Vec2]:
if v is not None:
return Vec2(v)
else:
return None
def sign_char(value: float) -> str:
if value < 0.0:
return "-"
elif value > 0:
return "+"
else:
return " "
def format_text(
value: float,
dimrnd: Optional[float] = None,
dimdec: int = 2,
dimzin: int = 0,
dimdsep: str = ".",
) -> str:
if dimrnd is not None:
value = xround(value, dimrnd)
if dimdec is None:
fmt = "{:f}"
# Remove pending zeros for undefined decimal places:
# '{:f}'.format(0) -> '0.000000'
dimzin = dimzin | 8
else:
fmt = "{:." + str(dimdec) + "f}"
text = fmt.format(value)
leading = bool(dimzin & 4)
pending = bool(dimzin & 8)
text = suppress_zeros(text, leading, pending)
if dimdsep != ".":
text = text.replace(".", dimdsep)
return text
def apply_dimpost(text: str, dimpost: str) -> str:
if "<>" in dimpost:
fmt = dimpost.replace("<>", "{}", 1)
return fmt.format(text)
else:
raise DXFValueError(f'Invalid dimpost string: "{dimpost}"')
class Tolerance: # and Limits
def __init__(
self,
dim_style: DimStyleOverride,
cap_height: float = 1.0,
width_factor: float = 1.0,
dim_scale: float = 1.0,
):
self.text_width_factor = width_factor
self.dim_scale = dim_scale
get = dim_style.get
# Appends tolerances to dimension text.
# enabling DIMTOL disables DIMLIM.
self.has_tolerance = bool(get("dimtol", 0))
self.has_limits = False
if not self.has_tolerance:
# Limits generates dimension limits as the default text.
self.has_limits = bool(get("dimlim", 0))
# Scale factor for the text height of fractions and tolerance values
# relative to the dimension text height
self.text_scale_factor: float = get("dimtfac", 0.5)
self.text_decimal_separator = dim_style.get_decimal_separator()
# Default MTEXT line spacing for tolerances (BricsCAD)
self.line_spacing: float = 1.35
# Sets the minimum (or lower) tolerance limit for dimension text when
# DIMTOL or DIMLIM is on.
# DIMTM accepts signed values.
# If DIMTOL is on and DIMTP and DIMTM are set to the same value, a
# tolerance value is drawn.
# If DIMTM and DIMTP values differ, the upper tolerance is drawn above
# the lower, and a plus sign is added to the DIMTP value if it is
# positive.
# For DIMTM, the program uses the negative of the value you enter
# (adding a minus sign if you specify a positive number and a plus sign
# if you specify a negative number).
self.minimum: float = get("dimtm", 0.0)
# Sets the maximum (or upper) tolerance limit for dimension text when
# DIMTOL or DIMLIM is on.
# DIMTP accepts signed values.
# If DIMTOL is on and DIMTP and DIMTM are set to the same value, a
# tolerance value is drawn.
# If DIMTM and DIMTP values differ, the upper tolerance is drawn above
# the lower and a plus sign is added to the DIMTP value if it is
# positive.
self.maximum: float = get("dimtp", 0.0)
# Number of decimal places to display in tolerance values
# Same value for linear and angular measurements!
self.decimal_places: int = get("dimtdec", 2)
# Vertical justification for tolerance values relative to the nominal dimension text
# 0 = Bottom
# 1 = Middle
# 2 = Top
self.valign: int = get("dimtolj", 0)
# Same as DIMZIN for tolerances (self.text_suppress_zeros)
# Same value for linear and angular measurements!
self.suppress_zeros: int = get("dimtzin", 0)
self.text: str = ""
self.text_height: float = 0.0
self.text_width: float = 0.0
self.text_upper: str = ""
self.text_lower: str = ""
self.char_height: float = cap_height * self.text_scale_factor * self.dim_scale
if self.has_tolerance:
self.init_tolerance()
elif self.has_limits:
self.init_limits()
@property
def enabled(self) -> bool:
return self.has_tolerance or self.has_limits
def disable(self):
self.has_tolerance = False
self.has_limits = False
def init_tolerance(self):
# The tolerance values are stored in the dimension style, they are
# independent from the actual measurement:
# Single tolerance value +/- value
if self.minimum == self.maximum:
self.text_height = self.char_height
self.text_width = self.get_text_width(self.text, self.text)
else: # 2 stacked values: +upper tolerance <above> -lower tolerance
# requires 2 text lines
self.text_height = self.char_height + (self.text_height * self.line_spacing)
self.text_width = self.get_text_width(self.text_upper, self.text_lower)
self.update_tolerance_text(self.maximum, self.minimum)
def update_tolerance_text(self, tol_upper: float, tol_lower: float):
if tol_upper == tol_lower:
self.text = PLUS_MINUS + self.format_text(abs(tol_upper))
else:
self.text_upper = sign_char(tol_upper) + self.format_text(abs(tol_upper))
self.text_lower = sign_char(tol_lower * -1) + self.format_text(
abs(tol_lower)
)
def init_limits(self):
# self.text is always an empty string (default value)
# Limit text are always 2 stacked numbers and requires the actual
# measurement!
self.text_height = self.char_height + (self.text_height * self.line_spacing)
def format_text(self, value: float) -> str:
"""Rounding and text formatting of tolerance `value`, removes leading
and trailing zeros if necessary.
"""
# dimpost is not applied to limits or tolerances!
return format_text(
value=value,
dimrnd=None,
dimdec=self.decimal_places,
dimzin=self.suppress_zeros,
dimdsep=self.text_decimal_separator,
)
def get_text_width(self, upr: str, lwr: str) -> float:
"""Returns the text width of the tolerance (upr/lwr) in drawing units."""
# todo: use matplotlib support
count = max(len(upr), len(lwr))
return self.text_height * self.text_width_factor * count
def compile_mtext(self, text: str) -> str:
if self.has_tolerance:
align = max(int(self.valign), 0)
align = min(align, 2)
if not self.text:
text = TOLERANCE_TEMPLATE2.format(
align=align,
txt=text,
fac=self.text_scale_factor,
upr=self.text_upper,
lwr=self.text_lower,
)
else:
text = TOLERANCE_TEMPLATE1.format(
align=align,
txt=text,
fac=self.text_scale_factor,
tol=self.text,
)
elif self.has_limits:
text = LIMITS_TEMPLATE.format(
upr=self.text_upper,
lwr=self.text_lower,
fac=self.text_scale_factor,
)
return text
def update_limits(self, measurement: float) -> None:
upper_limit = measurement + self.maximum
lower_limit = measurement - self.minimum
self.text_upper = self.format_text(upper_limit)
self.text_lower = self.format_text(lower_limit)
self.text_width = self.get_text_width(self.text_upper, self.text_lower)
class ExtensionLines:
default_lineweight: int = const.LINEWEIGHT_BYBLOCK
def __init__(self, dim_style: DimStyleOverride, default_color: int, scale: float):
get = dim_style.get
self.color: int = get("dimclre", default_color) # ACI
self.linetype1: str = get("dimltex1", "")
self.linetype2: str = get("dimltex2", "")
self.lineweight: int = get("dimlwe", self.default_lineweight)
self.suppress1: bool = bool(get("dimse1", 0))
self.suppress2: bool = bool(get("dimse2", 0))
# Extension of extension line above the dimension line, in extension
# line direction in most cases perpendicular to dimension line
# (oblique!)
self.extension_above: float = get("dimexe", 0.0) * scale
# Distance of extension line from the measurement point in extension
# line direction
self.offset: float = get("dimexo", 0.0) * scale
# Fixed length extension line, length above dimension line is still
# self.ext_line_extension
self.has_fixed_length: bool = bool(get("dimfxlon", 0))
# Length below the dimension line:
self.length_below: float = get("dimfxl", self.extension_above) * scale
def dxfattribs(self, num: int = 1) -> Any:
"""Returns default dimension line DXF attributes as dict."""
attribs: dict[str, Any] = {"color": self.color}
if num == 1:
linetype = self.linetype1
elif num == 2:
linetype = self.linetype2
else:
raise ValueError(f"invalid argument num:{num}")
if linetype:
attribs["linetype"] = linetype
if self.lineweight != self.default_lineweight:
attribs["lineweight"] = self.lineweight
return attribs
class DimensionLine:
default_lineweight: int = const.LINEWEIGHT_BYBLOCK
def __init__(self, dim_style: DimStyleOverride, default_color: int, scale: float):
get = dim_style.get
self.color: int = get("dimclrd", default_color) # ACI
# Dimension line extension, along the dimension line direction ('left'
# and 'right')
self.extension: float = get("dimdle", 0.0) * scale
self.linetype: str = get("dimltype", "")
self.lineweight: int = get("dimlwd", self.default_lineweight)
# Suppress first part of the dimension line
self.suppress1: bool = bool(get("dimsd1", 0))
# Suppress second part of the dimension line
self.suppress2: bool = bool(get("dimsd2", 0))
# Controls whether a dimension line is drawn between the extension lines
# even when the text is placed outside.
# For radius and diameter dimensions (when DIMTIX is off), draws a
# dimension line inside the circle or arc and places the text,
# arrowheads, and leader outside.
# 0 = no dimension line
# 1 = draw dimension line
# not supported yet - ezdxf behaves like option 1
self.has_dim_line_if_text_outside: bool = bool(get("dimtofl", 1))
def dxfattribs(self) -> Any:
"""Returns default dimension line DXF attributes as dict."""
attribs: dict[str, Any] = {"color": self.color}
if self.linetype:
attribs["linetype"] = self.linetype
if self.lineweight != self.default_lineweight:
attribs["lineweight"] = self.lineweight
return attribs
class Arrows:
def __init__(self, dim_style: DimStyleOverride, color: int, scale: float):
get = dim_style.get
self.color: int = get("dimclrd", color)
self.tick_size: float = get("dimtsz", 0.0) * scale
self.arrow1_name: str = "" # empty string is a closed filled arrow
self.arrow2_name: str = "" # empty string is a closed filled arrow
self.arrow_size: float = get("dimasz", 0.25) * scale
self.suppress1 = False # ezdxf only
self.suppress2 = False # ezdxf only
if self.tick_size > 0.0:
# Use oblique strokes as 'arrows', disables usual 'arrows' and user
# defined blocks tick size is per definition double the size of
# arrow size adjust arrow size to reuse the 'oblique' arrow block
self.arrow_size = self.tick_size * 2.0
else:
# Arrow name or block name if user defined arrow
(
self.arrow1_name,
self.arrow2_name,
) = dim_style.get_arrow_names()
@property
def has_ticks(self) -> bool:
return self.tick_size > 0.0
def dxfattribs(self) -> Any:
return {"color": self.color}
class Measurement:
def __init__(
self,
dim_style: DimStyleOverride,
color: int,
scale: float,
):
# update this values in method Measurement.update()
# raw measured value
self.raw_value: float = 0.0
# scaled measured value
self.value: float = 0.0
# Final formatted dimension text
self.text: str = ""
dimension = dim_style.dimension
doc = dimension.doc
assert doc is not None, "valid DXF document required"
# ezdxf specific attributes beyond DXF reference, therefore not stored
# in the DXF file (DSTYLE).
# Some of these are just an rendering effect, which will be ignored by
# CAD applications if they modify the DIMENSION entity
# User location override as UCS coordinates, stored as text_midpoint in
# the DIMENSION entity
self.user_location: Optional[Vec2] = OptionalVec2(
dim_style.pop("user_location", None)
)
# User location override relative to dimline center if True
self.relative_user_location: bool = dim_style.pop(
"relative_user_location", False
)
# Shift text away from default text location - implemented as user
# location override without leader
# Shift text along in text direction:
self.text_shift_h: float = dim_style.pop("text_shift_h", 0.0)
# Shift text perpendicular to text direction:
self.text_shift_v: float = dim_style.pop("text_shift_v", 0.0)
# End of ezdxf specific attributes
get = dim_style.get
# ezdxf locates attachment points always in the text center.
# Fixed predefined value for ezdxf rendering:
self.text_attachment_point: int = 5
# Ignored by ezdxf:
self.horizontal_direction: Optional[float] = dimension.get_dxf_attrib(
"horizontal_direction", None
)
# Dimension measurement factor:
self.measurement_factor: float = get("dimlfac", 1.0)
# Text style
style_name: str = get("dimtxsty", options.default_dimension_text_style)
if style_name not in doc.tables.styles:
style_name = "Standard"
self.text_style_name: str = style_name
text_style = get_text_style(doc, style_name)
self.text_height: float = get_char_height(dim_style, text_style) * scale
self.text_width_factor: float = text_style.get_dxf_attrib("width", 1.0)
self.stored_dim_text = dimension.dxf.text
# text_gap: gap between dimension line an dimension text
self.text_gap: float = get("dimgap", 0.625) * scale
# User defined text rotation - overrides everything:
self.user_text_rotation: float = dimension.get_dxf_attrib("text_rotation", None)
# calculated text rotation
self.text_rotation: float = self.user_text_rotation
self.text_color: int = get("dimclrt", color) # ACI
self.text_round: Optional[float] = get("dimrnd", None)
self.decimal_places: int = get("dimdec", 2)
self.angular_decimal_places: int = get("dimadec", 2)
# Controls the suppression of zeros in the primary unit value.
# Values 0-3 affect feet-and-inch dimensions only and are not supported
# 4 (Bit 3) = Suppresses leading zeros in decimal dimensions,
# e.g. 0.5000 becomes .5000
# 8 (Bit 4) = Suppresses trailing zeros in decimal dimensions,
# e.g. 12.5000 becomes 12.5
# 12 (Bit 3+4) = Suppresses both leading and trailing zeros,
# e.g. 0.5000 becomes .5)
self.suppress_zeros: int = get("dimzin", 8)
# special setting for angular dimensions (dimzin << 2) & 3
# 0 = Displays all leading and trailing zeros
# 1 = Suppresses leading zeros (for example, 0.5000 becomes .5000)
# 2 = Suppresses trailing zeros (for example, 12.5000 becomes 12.5)
# 3 = Suppresses leading and trailing zeros (for example, 0.5000 becomes .5)
self.angular_suppress_zeros: int = get("dimazin", 2)
# decimal separator char, default is ",":
self.decimal_separator: str = dim_style.get_decimal_separator()
self.text_post_process_format: str = get("dimpost", "")
# text_fill:
# 0 = None
# 1 = Background
# 2 = DIMTFILLCLR
self.text_fill: int = get("dimtfill", 0)
self.text_fill_color: int = get("dimtfillclr", 1) # ACI
self.text_box_fill_scale: float = 1.1
# text_halign:
# 0 = center
# 1 = left
# 2 = right
# 3 = above ext1
# 4 = above ext2
self.text_halign: int = get("dimjust", 0)
# text_valign:
# 0 = center
# 1 = above
# 2 = farthest away?
# 3 = JIS;
# 4 = below
# Options 2, 3 are ignored by ezdxf
self.text_valign: int = get("dimtad", 0)
# Controls the vertical position of dimension text above or below the
# dimension line, when DIMTAD = 0.
# The magnitude of the vertical offset of text is the product of the
# text height (+gap?) and DIMTVP.
# Setting DIMTVP to 1.0 is equivalent to setting DIMTAD = 1.
self.text_vertical_position: float = get("dimtvp", 0.0)
# Move text freely:
# 0 = Moves the dimension line with dimension text
# 1 = Adds a leader when dimension text is moved
# 2 = Allows text to be moved freely without a leader
self.text_movement_rule: int = get("dimtmove", 2)
self.has_leader: bool = (
self.user_location is not None and self.text_movement_rule == 1
)
# text_rotation is 0 if dimension text is 'inside', ezdxf defines
# 'inside' as at the default text location:
self.text_inside_horizontal: bool = get("dimtih", 0)
# text_rotation is 0 if dimension text is 'outside', ezdxf defines
# 'outside' as NOT at the default text location:
self.text_outside_horizontal: bool = get("dimtoh", 0)
# Force text location 'inside', even if the text should be moved
# 'outside':
self.force_text_inside: bool = bool(get("dimtix", 0))
# How dimension text and arrows are arranged when space is not
# sufficient to place both 'inside':
# 0 = Places both text and arrows outside extension lines
# 1 = Moves arrows first, then text
# 2 = Moves text first, then arrows
# 3 = Moves either text or arrows, whichever fits best
# not supported - ezdxf behaves like 2
self.text_fitting_rule: int = get("dimatfit", 2)
# Units for all dimension types except Angular.
# 1 = Scientific
# 2 = Decimal
# 3 = Engineering
# 4 = Architectural (always displayed stacked)
# 5 = Fractional (always displayed stacked)
# not supported - ezdxf behaves like 2
self.length_unit: int = get("dimlunit", 2)
# Fraction format when DIMLUNIT is set to 4 (Architectural) or
# 5 (Fractional).
# 0 = Horizontal stacking
# 1 = Diagonal stacking
# 2 = Not stacked (for example, 1/2)
self.fraction_format: int = get("dimfrac", 0) # not supported
# Units format for angular dimensions
# 0 = Decimal degrees
# 1 = Degrees/minutes/seconds
# 2 = Grad
# 3 = Radians
self.angle_units: int = get("dimaunit", 0)
self.has_arc_length_prefix: bool = False
if get("dimarcsym", 2) == 0:
self.has_arc_length_prefix = True
# Text_outside is only True if really placed outside of default text
# location
# remark: user defined text location is always outside per definition
# (not by real location)
self.text_is_outside: bool = False
# Final calculated or overridden dimension text location
self.text_location: Vec2 = Vec2(0, 0)
# True if dimension text doesn't fit between extension lines
self.is_wide_text: bool = False
# Text rotation was corrected to make upside down text better readable
self.has_upside_down_correction: bool = False
@property
def text_is_inside(self):
return not self.text_is_outside
@property
def has_relative_text_movement(self):
return bool(self.text_shift_h or self.text_shift_v)
def apply_text_shift(self, location: Vec2, text_rotation: float) -> Vec2:
"""Add `self.text_shift_h` and `sel.text_shift_v` to point `location`,
shifting along and perpendicular to text orientation defined by
`text_rotation`.
Args:
location: location point
text_rotation: text rotation in degrees
Returns: new location
"""
shift_vec = Vec2((self.text_shift_h, self.text_shift_v))
location += shift_vec.rotate_deg(text_rotation)
return location
@property
def vertical_placement(self) -> float:
"""Returns vertical placement of dimension text as 1 for above, 0 for
center and -1 for below dimension line.
"""
if self.text_valign == 0:
return 0
elif self.text_valign == 4:
return -1
else:
return 1
def text_vertical_distance(self) -> float:
"""Returns the vertical distance for dimension line to text midpoint.
Positive values are above the line, negative values are below the line.
"""
if self.text_valign == 0:
return self.text_height * self.text_vertical_position
else:
return (self.text_height / 2.0 + self.text_gap) * self.vertical_placement
def text_width(self, text: str) -> float:
"""
Return width of `text` in drawing units.
"""
# todo: use matplotlib support
char_width = self.text_height * self.text_width_factor
return len(text) * char_width
def text_override(self, measurement: float) -> str:
"""Create dimension text for `measurement` in drawing units and applies
text overriding properties.
"""
text = self.stored_dim_text
if text == " ": # suppresses text
return ""
elif text == "" or text == "<>": # measured distance
return self.format_text(measurement)
else: # user override
return text
def location_override(self, location: UVec, leader=False, relative=False) -> None:
"""Set user defined dimension text location. ezdxf defines a user
defined location per definition as 'outside'.
Args:
location: text midpoint
leader: use leader or not (movement rules)
relative: is location absolute (in UCS) or relative to dimension
line center.
"""
self.user_location = Vec2(location)
self.text_movement_rule = 1 if leader else 2
self.relative_user_location = relative
self.text_is_outside = True
def dxfattribs(self) -> Any:
return {"color": self.text_color}
@abc.abstractmethod
def update(self, raw_measurement_value: float) -> None:
"""Update raw measurement value, scaled measurement value and
dimension text.
"""
@abc.abstractmethod
def format_text(self, value: float) -> str:
"""Rounding and text formatting of `value`, removes leading and
trailing zeros if necessary.
"""
class LengthMeasurement(Measurement):
def update(self, raw_measurement_value: float) -> None:
"""Update raw measurement value, scaled measurement value and
dimension text.
"""
self.raw_value = raw_measurement_value
self.value = raw_measurement_value * self.measurement_factor
self.text = self.text_override(self.value)
def format_text(self, value: float) -> str:
"""Rounding and text formatting of `value`, removes leading and
trailing zeros if necessary.
"""
text = format_text(
value,
self.text_round,
self.decimal_places,
self.suppress_zeros,
self.decimal_separator,
)
if self.text_post_process_format:
text = apply_dimpost(text, self.text_post_process_format)
return text
class Geometry:
"""
Geometry layout entities are located in the OCS defined by the extrusion
vector of the DIMENSION entity and the z-axis of the OCS
point 'text_midpoint' (group code 11).
"""
def __init__(
self,
dimension: Dimension,
ucs: UCS,
layout: "GenericLayoutType",
):
assert dimension.doc is not None, "valid DXF document required"
self.dimension: Dimension = dimension
self.doc: Drawing = dimension.doc
self.dxfversion: str = self.doc.dxfversion
self.supports_dxf_r2000: bool = self.dxfversion >= "AC1015"
self.supports_dxf_r2007: bool = self.dxfversion >= "AC1021"
self.ucs: UCS = ucs
self.extrusion: Vec3 = ucs.uz
self.requires_extrusion: bool = not self.extrusion.isclose(Z_AXIS)
self.layout: GenericLayoutType = layout
self._text_box: TextBox = TextBox()
@property
def has_text_box(self) -> bool:
return self._text_box.width > 0.0 and self._text_box.height > 0.0
def set_layout(self, layout: GenericLayoutType) -> None:
self.layout = layout
def set_text_box(self, text_box: TextBox) -> None:
self._text_box = text_box
def has_block(self, name: str) -> bool:
return name in self.doc.blocks
def add_arrow_blockref(
self,
name: str,
insert: Vec2,
size: float,
rotation: float,
dxfattribs,
) -> None:
# OCS of the arrow blocks is defined by the DIMENSION entity!
# Therefore remove OCS elevation, the elevation is defined by the
# DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
# either!
insert = self.ucs.to_ocs(Vec3(insert)).vec2
rotation = self.ucs.to_ocs_angle_deg(rotation)
self.layout.add_arrow_blockref(name, insert, size, rotation, dxfattribs)
def add_blockref(
self,
name: str,
insert: Vec2,
rotation: float,
dxfattribs,
) -> None:
# OCS of the arrow blocks is defined by the DIMENSION entity!
# Therefore remove OCS elevation, the elevation is defined by the
# DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
# either!
insert = self.ucs.to_ocs(Vec3(insert)).vec2
dxfattribs["rotation"] = self.ucs.to_ocs_angle_deg(rotation)
self.layout.add_blockref(name, insert, dxfattribs)
def add_text(self, text: str, pos: Vec2, rotation: float, dxfattribs) -> None:
dxfattribs["rotation"] = self.ucs.to_ocs_angle_deg(rotation)
entity = self.layout.add_text(text, dxfattribs=dxfattribs)
# OCS of the measurement text is defined by the DIMENSION entity!
# Therefore remove OCS elevation, the elevation is defined by the
# DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
# either!
entity.set_placement(
self.ucs.to_ocs(Vec3(pos)).vec2,
align=TextEntityAlignment.MIDDLE_CENTER,
)
def add_mtext(self, text: str, pos: Vec2, rotation: float, dxfattribs) -> None:
# OCS of the measurement text is defined by the DIMENSION entity!
# Therefore remove OCS elevation, the elevation is defined by the
# DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
# either!
dxfattribs["rotation"] = self.ucs.to_ocs_angle_deg(rotation)
dxfattribs["insert"] = self.ucs.to_ocs(Vec3(pos)).vec2
self.layout.add_mtext(text, dxfattribs)
def add_defpoints(self, points: Iterable[Vec2]) -> None:
attribs = {
"layer": "Defpoints",
}
for point in points:
# Despite the fact that the POINT entity has WCS coordinates,
# the coordinates of defpoints in DIMENSION entities have OCS
# coordinates.
location = self.ucs.to_ocs(Vec3(point)).replace(z=0.0)
self.layout.add_point(location, dxfattribs=attribs)
def add_line(
self,
start: Vec2,
end: Vec2,
dxfattribs,
remove_hidden_lines=False,
) -> None:
"""Add a LINE entity to the geometry layout. Removes parts of the line
hidden by dimension text if `remove_hidden_lines` is True.
Args:
start: start point of line
end: end point of line
dxfattribs: additional or overridden DXF attributes
remove_hidden_lines: removes parts of the line hidden by dimension
text if ``True``
"""
def add_line_to_block(start, end):
# LINE is handled like an OCS entity !?
self.layout.add_line(
to_ocs(Vec3(start)).vec2,
to_ocs(Vec3(end)).vec2,
dxfattribs=dxfattribs,
)
def order(a: Vec2, b: Vec2) -> tuple[Vec2, Vec2]:
if (start - a).magnitude < (start - b).magnitude:
return a, b
else:
return b, a
to_ocs = self.ucs.to_ocs
if remove_hidden_lines and self.has_text_box:
text_box = self._text_box
start_inside = int(text_box.is_inside(start))
end_inside = int(text_box.is_inside(end))
inside = start_inside + end_inside
if inside == 2: # start and end inside text_box
return # do not draw line
elif inside == 1: # one point inside text_box or on a border line
intersection_points = text_box.intersect(ConstructionLine(start, end))
if len(intersection_points) == 1:
# one point inside one point outside -> one intersection point
p1 = intersection_points[0]
else:
# second point on a text box border line
p1, _ = order(*intersection_points)
p2 = start if end_inside else end
add_line_to_block(p1, p2)
return
else:
intersection_points = text_box.intersect(ConstructionLine(start, end))
if len(intersection_points) == 2:
# sort intersection points by distance to start point
p1, p2 = order(intersection_points[0], intersection_points[1])
# line[start-p1] - gap - line[p2-end]
add_line_to_block(start, p1)
add_line_to_block(p2, end)
return
# else: fall through
add_line_to_block(start, end)
def add_arc(
self,
center: Vec2,
radius: float,
start_angle: float,
end_angle: float,
dxfattribs=None,
remove_hidden_lines=False,
) -> None:
"""Add a ARC entity to the geometry layout. Removes parts of the arc
hidden by dimension text if `remove_hidden_lines` is True.
Args:
center: center of arc
radius: radius of arc
start_angle: start angle in radians
end_angle: end angle in radians
dxfattribs: additional or overridden DXF attributes
remove_hidden_lines: removes parts of the arc hidden by dimension
text if ``True``
"""
def add_arc(s: float, e: float) -> None:
"""Add ARC entity to geometry block."""
self.layout.add_arc(
center=ocs_center,
radius=radius,
start_angle=math.degrees(ocs_angle(s)),
end_angle=math.degrees(ocs_angle(e)),
dxfattribs=dxfattribs,
)
# OCS of the ARC is defined by the DIMENSION entity!
# Therefore remove OCS elevation, the elevation is defined by the
# DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
# either!
ocs_center = self.ucs.to_ocs(Vec3(center)).vec2
ocs_angle = self.ucs.to_ocs_angle_rad
if remove_hidden_lines and self.has_text_box:
for start, end in visible_arcs(
center,
radius,
start_angle,
end_angle,
self._text_box,
):
add_arc(start, end)
else:
add_arc(start_angle, end_angle)
class BaseDimensionRenderer:
"""Base rendering class for DIMENSION entities."""
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
self.dimension: Dimension = dimension
self.geometry = self.init_geometry(dimension, ucs)
# DimStyleOverride object, manages dimension style overriding
self.dim_style: DimStyleOverride
if override:
self.dim_style = override
else:
self.dim_style = DimStyleOverride(dimension)
# ---------------------------------------------
# GENERAL PROPERTIES
# ---------------------------------------------
self.default_color: int = self.dimension.dxf.color # ACI
self.default_layer: str = self.dimension.dxf.layer
get = self.dim_style.get
# Overall scaling of DIMENSION entity:
self.dim_scale: float = get("dimscale", 1.0)
if abs(self.dim_scale) < 1e-9:
self.dim_scale = 1.0
# Controls drawing of circle or arc center marks and center lines, for
# DIMDIAMETER and DIMRADIUS, the center mark is drawn only if you place
# the dimension line outside the circle or arc.
# 0 = No center marks or lines are drawn
# <0 = Center lines are drawn
# >0 = Center marks are drawn
self.dim_center_marks: int = get("dimcen", 0)
self.measurement = self.init_measurement(self.default_color, self.dim_scale)
self.dimension_line: DimensionLine = self.init_dimension_line(
self.default_color, self.dim_scale
)
self.arrows: Arrows = self.init_arrows(self.default_color, self.dim_scale)
# Suppress arrow rendering - only rendering is suppressed (rendering
# effect).
# All placing related calculations are done without this settings.
# Used for multi point linear dimensions to avoid double rendering of
# non arrow ticks. These are ezdxf specific attributes!
self.arrows.suppress1 = self.dim_style.pop("suppress_arrow1", False)
self.arrows.suppress2 = self.dim_style.pop("suppress_arrow2", False)
self.extension_lines: ExtensionLines = self.init_extension_lines(
self.default_color, self.dim_scale
)
# tolerances have to be initialized after measurement:
self.tol: Tolerance = self.init_tolerance(self.dim_scale, self.measurement)
# Update text height
self.measurement.text_height = max(
self.measurement.text_height, self.tol.text_height
)
def init_geometry(self, dimension: Dimension, ucs: Optional[UCS] = None):
from ezdxf.layouts import VirtualLayout
return Geometry(dimension, ucs or PassTroughUCS(), VirtualLayout())
def init_tolerance(self, scale: float, measurement: Measurement) -> Tolerance:
return Tolerance(
self.dim_style,
cap_height=measurement.text_height,
width_factor=measurement.text_width_factor,
dim_scale=scale,
)
def init_extension_lines(self, color: int, scale: float) -> ExtensionLines:
return ExtensionLines(self.dim_style, color, scale)
def init_dimension_line(self, color: int, scale: float) -> DimensionLine:
return DimensionLine(self.dim_style, color, scale)
def init_arrows(self, color: int, scale: float) -> Arrows:
return Arrows(self.dim_style, color, scale)
def init_measurement(self, color: int, scale: float) -> Measurement:
return LengthMeasurement(self.dim_style, color, scale)
def init_text_box(self) -> TextBox:
measurement = self.measurement
return TextBox(
center=measurement.text_location,
width=self.total_text_width(),
height=measurement.text_height,
angle=measurement.text_rotation or 0.0,
# The currently used monospaced abstract font, returns a too large
# text width.
# Therefore, the horizontal text gap is ignored at all - yet!
hgap=0.0,
# Arbitrary choice to reduce the too large vertical gap!
vgap=measurement.text_gap * 0.75,
)
def get_required_defpoint(self, name: str) -> Vec2:
return get_required_defpoint(self.dimension, name)
def render(self, block: GenericLayoutType):
# Block entities are located in the OCS defined by the extrusion vector
# of the DIMENSION entity and the z-axis of the OCS point
# 'text_midpoint' (group code 11).
self.geometry.set_layout(block)
# Tolerance requires MTEXT support, switch off rendering of tolerances
# and limits
if not self.geometry.supports_dxf_r2000:
self.tol.disable()
def total_text_width(self) -> float:
width = 0.0
text = self.measurement.text
if text:
if self.tol.has_limits: # only limits are displayed
width = self.tol.text_width
else:
width = self.measurement.text_width(text)
if self.tol.has_tolerance:
width += self.tol.text_width
return width
def default_attributes(self) -> dict[str, Any]:
"""Returns default DXF attributes as dict."""
return {
"layer": self.default_layer,
"color": self.default_color,
}
def location_override(self, location: UVec, leader=False, relative=False) -> None:
"""Set user defined dimension text location. ezdxf defines a user
defined location per definition as 'outside'.
Args:
location: text midpoint
leader: use leader or not (movement rules)
relative: is location absolute (in UCS) or relative to dimension
line center.
"""
self.dim_style.set_location(location, leader, relative)
self.measurement.location_override(location, leader, relative)
def add_line(
self,
start: Vec2,
end: Vec2,
dxfattribs,
remove_hidden_lines=False,
) -> None:
"""Add a LINE entity to the dimension BLOCK. Remove parts of the line
hidden by dimension text if `remove_hidden_lines` is True.
Args:
start: start point of line
end: end point of line
dxfattribs: additional or overridden DXF attributes
remove_hidden_lines: removes parts of the line hidden by dimension
text if ``True``
"""
attribs = self.default_attributes()
if dxfattribs:
attribs.update(dxfattribs)
self.geometry.add_line(start, end, dxfattribs, remove_hidden_lines)
def add_arc(
self,
center: Vec2,
radius: float,
start_angle: float,
end_angle: float,
dxfattribs=None,
remove_hidden_lines=False,
) -> None:
"""Add a ARC entity to the geometry layout. Remove parts of the arc
hidden by dimension text if `remove_hidden_lines` is True.
Args:
center: center of arc
radius: radius of arc
start_angle: start angle in radians
end_angle: end angle in radians
dxfattribs: additional or overridden DXF attributes
remove_hidden_lines: removes parts of the arc hidden by dimension
text if ``True``
"""
attribs = self.default_attributes()
if dxfattribs:
attribs.update(dxfattribs)
self.geometry.add_arc(
center, radius, start_angle, end_angle, attribs, remove_hidden_lines
)
def add_blockref(
self,
name: str,
insert: Vec2,
rotation: float,
scale: float,
dxfattribs,
) -> None:
"""
Add block references and standard arrows to the dimension BLOCK.
Args:
name: block or arrow name
insert: insertion point in UCS
rotation: rotation angle in degrees in UCS (x-axis is 0 degrees)
scale: scaling factor for x- and y-direction
dxfattribs: additional or overridden DXF attributes
"""
attribs = self.default_attributes()
if dxfattribs:
attribs.update(dxfattribs)
if name in ARROWS:
# generates automatically BLOCK definitions for arrows if needed
self.geometry.add_arrow_blockref(name, insert, scale, rotation, attribs)
else:
if name is None or not self.geometry.has_block(name):
raise DXFUndefinedBlockError(f'Undefined block: "{name}"')
if scale != 1.0:
attribs["xscale"] = scale
attribs["yscale"] = scale
self.geometry.add_blockref(name, insert, rotation, attribs)
def add_text(self, text: str, pos: Vec2, rotation: float, dxfattribs) -> None:
"""
Add TEXT (DXF R12) or MTEXT (DXF R2000+) entity to the dimension BLOCK.
Args:
text: text as string
pos: insertion location in UCS
rotation: rotation angle in degrees in UCS (x-axis is 0 degrees)
dxfattribs: additional or overridden DXF attributes
"""
geometry = self.geometry
measurement = self.measurement
attribs = self.default_attributes()
attribs["style"] = measurement.text_style_name
attribs["color"] = measurement.text_color
if geometry.supports_dxf_r2000: # use MTEXT entity
attribs["char_height"] = measurement.text_height
attribs["attachment_point"] = measurement.text_attachment_point
if measurement.text_fill:
attribs["box_fill_scale"] = measurement.text_box_fill_scale
attribs["bg_fill_color"] = measurement.text_fill_color
attribs["bg_fill"] = 3 if measurement.text_fill == 1 else 1
if dxfattribs:
attribs.update(dxfattribs)
geometry.add_mtext(text, pos, rotation, dxfattribs=attribs)
else: # use TEXT entity
attribs["height"] = measurement.text_height
if dxfattribs:
attribs.update(dxfattribs)
geometry.add_text(text, pos, rotation, dxfattribs=attribs)
def add_leader(self, p1: Vec2, p2: Vec2, p3: Vec2):
"""
Add simple leader line from p1 to p2 to p3.
Args:
p1: target point
p2: first text point
p3: second text point
"""
# use only color and ignore linetype!
dxfattribs = {"color": self.dimension_line.color}
self.add_line(p1, p2, dxfattribs)
self.add_line(p2, p3, dxfattribs)
def transform_ucs_to_wcs(self) -> None:
"""Transforms dimension definition points into WCS or if required into
OCS.
Can not be called in __init__(), because inherited classes may be need
unmodified values.
"""
pass
def finalize(self) -> None:
self.transform_ucs_to_wcs()
if self.geometry.requires_extrusion:
self.dimension.dxf.extrusion = self.geometry.extrusion
def order_leader_points(p1: Vec2, p2: Vec2, p3: Vec2) -> tuple[Vec2, Vec2]:
if (p1 - p2).magnitude > (p1 - p3).magnitude:
return p3, p2
else:
return p2, p3
def get_center_leader_points(
target_point: Vec2, text_box: TextBox, leg_length: float
) -> tuple[Vec2, Vec2]:
"""Returns the leader points of the "leg" for a vertical centered leader."""
c0, c1, c2, c3 = text_box.corners
# c3-------c2
# left leg /---x text x---\ right leg
# / c0-------c1 \
left_center = c0.lerp(c3)
right_center = c1.lerp(c2)
connection_point = left_center
leg_vector = (c0 - c1).normalize(leg_length)
if target_point.distance(left_center) > target_point.distance(right_center):
connection_point = right_center
leg_vector = -leg_vector
# leader line: target_point -> leg_point -> connection_point
# The text gap between the text and the connection point is already included
# in the text_box corners!
# Do not order leader points!
return connection_point + leg_vector, connection_point
def get_required_defpoint(dim: Dimension, name: str) -> Vec2:
dxf = dim.dxf
if dxf.hasattr(name): # has to exist, ignore default value!
return Vec2(dxf.get(name))
raise const.DXFMissingDefinitionPoint(name)
def visible_arcs(
center: Vec2,
radius: float,
start_angle: float,
end_angle: float,
box: ConstructionBox,
) -> list[tuple[float, float]]:
"""Returns the visible parts of an arc intersecting with a construction box
as (start angle, end angle) tuples.
Args:
center: center of the arc
radius: radius of the arc
start_angle: start angle of arc in radians
end_angle: end angle of arc in radians
box: construction box which may intersect the arc
"""
intersection_angles: list[float] = [] # angles are in the range 0 to 2pi
start_angle %= math.tau
end_angle %= math.tau
arc = ConstructionArc(
center, radius, math.degrees(start_angle), math.degrees(end_angle)
)
for line in box.border_lines():
for intersection_point in arc.intersect_line(line):
angle = (intersection_point - center).angle % math.tau
if not intersection_angles:
intersection_angles.append(angle)
# new angle should be different than the last added angle:
elif not math.isclose(intersection_angles[-1], angle):
intersection_angles.append(angle)
# Arc has to intersect the box in exact two locations!
if len(intersection_angles) == 2:
if start_angle > end_angle: # arc passes 0 degrees
intersection_angles = [
(a if a >= start_angle else a + math.tau) for a in intersection_angles
]
intersection_angles.sort()
return [
(start_angle, intersection_angles[0]),
(intersection_angles[1], end_angle),
]
else:
# Ignore cases where the start- or the end point is inside the box.
# Ignore cases where the box touches the arc in one point.
return [(start_angle, end_angle)]
def get_text_style(doc: "Drawing", name: str) -> Textstyle:
assert doc is not None, "valid DXF document required"
get_style = doc.tables.styles.get
try:
style = get_style(name)
except const.DXFTableEntryError:
style = get_style("Standard")
return cast("Textstyle", style)
def get_char_height(dim_style: DimStyleOverride, text_style: Textstyle) -> float:
"""Unscaled character height defined by text style or DIMTXT."""
height: float = text_style.dxf.get("height", 0.0)
if height == 0.0: # variable text height (not fixed)
height = dim_style.get("dimtxt", 1.0)
return height
def compile_mtext(measurement: Measurement, tol: Tolerance) -> str:
text = measurement.text
if tol.enabled:
text = tol.compile_mtext(text)
return text