This commit is contained in:
Christian Anetzberger
2026-01-22 20:23:51 +01:00
commit a197de9456
4327 changed files with 1235205 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2018-2022, Manfred Moitzi
# License: MIT License
from .arrows import ARROWS
from .r12spline import R12Spline
from .curves import Bezier, EulerSpiral, Spline, random_2d_path, random_3d_path
from .mesh import (
MeshBuilder,
MeshVertexMerger,
MeshTransformer,
MeshAverageVertexMerger,
MeshDiagnose,
FaceOrientationDetector,
MeshBuilderError,
NonManifoldMeshError,
MultipleMeshesError,
NodeMergingError,
DegeneratedPathError,
)
from .trace import TraceBuilder
from .mleader import (
MultiLeaderBuilder,
MultiLeaderMTextBuilder,
MultiLeaderBlockBuilder,
ConnectionSide,
HorizontalConnection,
VerticalConnection,
LeaderType,
TextAlignment,
BlockAlignment,
)

View File

@@ -0,0 +1,76 @@
# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Tuple, Iterable, Sequence
from typing_extensions import TypeAlias
import math
from ezdxf.math import Vec3, UVec
LineSegment: TypeAlias = Tuple[Vec3, Vec3]
class _LineTypeRenderer:
"""Renders a line segment as multiple short segments according to the given
line pattern.
In contrast to the DXF line pattern is this pattern simplified and follow
the rule line-gap-line-gap-... a line of length 0 is a point.
The pattern should end with a gap (even count) and the dash length is in
drawing units.
Args:
dashes: sequence of floats, line-gap-line-gap-...
"""
# Get the simplified line pattern by LineType.simplified_line_pattern()
def __init__(self, dashes: Sequence[float]):
self._dashes = dashes
self._dash_count: int = len(dashes)
self.is_solid: bool = True
self._current_dash: int = 0
self._current_dash_length: float = 0.0
if self._dash_count > 1:
self.is_solid = False
self._current_dash_length = self._dashes[0]
self._is_dash = True
def line_segment(self, start: UVec, end: UVec) -> Iterable[LineSegment]:
"""Yields the line from `start` to `end` according to stored line
pattern as short segments. Yields only the lines and points not the
gaps.
"""
_start = Vec3(start)
_end = Vec3(end)
if self.is_solid or _start.isclose(_end):
yield _start, _end
return
segment_vec = _end - _start
segment_length = segment_vec.magnitude
segment_dir = segment_vec / segment_length # normalize
for is_dash, dash_length in self._render_dashes(segment_length):
_end = _start + segment_dir * dash_length
if is_dash:
yield _start, _end
_start = _end
def _render_dashes(self, length: float) -> Iterable[tuple[bool, float]]:
if length <= self._current_dash_length:
self._current_dash_length -= length
yield self._is_dash, length
if math.isclose(self._current_dash_length, 0.0):
self._cycle_dashes()
else:
# Avoid deep recursions!
while length > self._current_dash_length:
length -= self._current_dash_length
yield from self._render_dashes(self._current_dash_length)
if length > 0.0:
yield from self._render_dashes(length)
def _cycle_dashes(self):
self._current_dash = (self._current_dash + 1) % self._dash_count
self._current_dash_length = self._dashes[self._current_dash]
self._is_dash = not self._is_dash

View File

@@ -0,0 +1,296 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
# This is the abstract link between the text layout engine implemented in
# ezdxf.tools.text_layout and a concrete MTEXT renderer implementation like
# MTextExplode or ComplexMTextRenderer.
from __future__ import annotations
from typing import Sequence, Optional
import abc
from ezdxf.lldxf import const
from ezdxf import colors
from ezdxf.entities.mtext import MText, MTextColumns
from ezdxf.enums import (
MTextParagraphAlignment,
)
from ezdxf.fonts import fonts
from ezdxf.tools import text_layout as tl
from ezdxf.tools.text import (
MTextParser,
MTextContext,
TokenType,
ParagraphProperties,
estimate_mtext_extents,
)
__all__ = ["AbstractMTextRenderer"]
ALIGN = {
MTextParagraphAlignment.LEFT: tl.ParagraphAlignment.LEFT,
MTextParagraphAlignment.RIGHT: tl.ParagraphAlignment.RIGHT,
MTextParagraphAlignment.CENTER: tl.ParagraphAlignment.CENTER,
MTextParagraphAlignment.JUSTIFIED: tl.ParagraphAlignment.JUSTIFIED,
MTextParagraphAlignment.DISTRIBUTED: tl.ParagraphAlignment.JUSTIFIED,
MTextParagraphAlignment.DEFAULT: tl.ParagraphAlignment.LEFT,
}
ATTACHMENT_POINT_TO_ALIGN = {
const.MTEXT_TOP_LEFT: tl.ParagraphAlignment.LEFT,
const.MTEXT_MIDDLE_LEFT: tl.ParagraphAlignment.LEFT,
const.MTEXT_BOTTOM_LEFT: tl.ParagraphAlignment.LEFT,
const.MTEXT_TOP_CENTER: tl.ParagraphAlignment.CENTER,
const.MTEXT_MIDDLE_CENTER: tl.ParagraphAlignment.CENTER,
const.MTEXT_BOTTOM_CENTER: tl.ParagraphAlignment.CENTER,
const.MTEXT_TOP_RIGHT: tl.ParagraphAlignment.RIGHT,
const.MTEXT_MIDDLE_RIGHT: tl.ParagraphAlignment.RIGHT,
const.MTEXT_BOTTOM_RIGHT: tl.ParagraphAlignment.RIGHT,
}
STACKING = {
"^": tl.Stacking.OVER,
"/": tl.Stacking.LINE,
"#": tl.Stacking.SLANTED,
}
def make_default_tab_stops(cap_height: float, width: float) -> list[tl.TabStop]:
tab_stops = []
step = 4.0 * cap_height
pos = step
while pos < width:
tab_stops.append(tl.TabStop(pos, tl.TabStopType.LEFT))
pos += step
return tab_stops
def append_default_tab_stops(
tab_stops: list[tl.TabStop], default_stops: Sequence[tl.TabStop]
) -> None:
last_pos = 0.0
if tab_stops:
last_pos = tab_stops[-1].pos
tab_stops.extend(stop for stop in default_stops if stop.pos > last_pos)
def make_tab_stops(
cap_height: float,
width: float,
tab_stops: Sequence,
default_stops: Sequence[tl.TabStop],
) -> list[tl.TabStop]:
_tab_stops = []
for stop in tab_stops:
if isinstance(stop, str):
value = float(stop[1:])
if stop[0] == "c":
kind = tl.TabStopType.CENTER
else:
kind = tl.TabStopType.RIGHT
else:
kind = tl.TabStopType.LEFT
value = float(stop)
pos = value * cap_height
if pos < width:
_tab_stops.append(tl.TabStop(pos, kind))
append_default_tab_stops(_tab_stops, default_stops)
return _tab_stops
def get_stroke(ctx: MTextContext) -> int:
stroke = 0
if ctx.underline:
stroke += tl.Stroke.UNDERLINE
if ctx.strike_through:
stroke += tl.Stroke.STRIKE_THROUGH
if ctx.overline:
stroke += tl.Stroke.OVERLINE
if ctx.continue_stroke:
stroke += tl.Stroke.CONTINUE
return stroke
def new_paragraph(
cells: list,
ctx: MTextContext,
cap_height: float,
line_spacing: float = 1,
width: float = 0,
default_stops: Optional[Sequence[tl.TabStop]] = None,
):
if cells:
p = ctx.paragraph
align = ALIGN.get(p.align, tl.ParagraphAlignment.LEFT)
left = p.left * cap_height
right = p.right * cap_height
first = left + p.indent * cap_height # relative to left
_default_stops: Sequence[tl.TabStop] = default_stops or []
tab_stops = _default_stops
if p.tab_stops:
tab_stops = make_tab_stops(cap_height, width, p.tab_stops, _default_stops)
paragraph = tl.Paragraph(
align=align,
indent=(first, left, right),
line_spacing=line_spacing,
tab_stops=tab_stops,
)
paragraph.append_content(cells)
else:
paragraph = tl.EmptyParagraph( # type: ignore
cap_height=ctx.cap_height, line_spacing=line_spacing
)
return paragraph
def super_glue():
return tl.NonBreakingSpace(width=0, min_width=0, max_width=0)
def defined_width(mtext: MText) -> float:
width = mtext.dxf.get("width", 0.0)
if width < 1e-6:
width, height = estimate_mtext_extents(mtext)
return width
def column_heights(columns: MTextColumns) -> list[Optional[float]]:
heights: list[Optional[float]]
if columns.heights: # dynamic manual
heights = list(columns.heights)
# last height has to be auto height = None
heights[-1] = None
return heights
# static, dynamic auto
defined_height = abs(columns.defined_height)
if defined_height < 1e-6:
return [None]
return [defined_height] * columns.count
class AbstractMTextRenderer(abc.ABC):
def __init__(self) -> None:
self._font_cache: dict[tuple[str, float, float], fonts.AbstractFont] = {}
@abc.abstractmethod
def word(self, test: str, ctx: MTextContext) -> tl.ContentCell:
...
@abc.abstractmethod
def fraction(self, data: tuple[str, str, str], ctx: MTextContext) -> tl.ContentCell:
...
@abc.abstractmethod
def get_font_face(self, mtext: MText) -> fonts.FontFace:
...
@abc.abstractmethod
def make_bg_renderer(self, mtext: MText) -> tl.ContentRenderer:
...
def make_mtext_context(self, mtext: MText) -> MTextContext:
ctx = MTextContext()
ctx.paragraph = ParagraphProperties(
align=ATTACHMENT_POINT_TO_ALIGN.get( # type: ignore
mtext.dxf.attachment_point, tl.ParagraphAlignment.LEFT
)
)
ctx.font_face = self.get_font_face(mtext)
ctx.cap_height = mtext.dxf.char_height
ctx.aci = mtext.dxf.color
rgb = mtext.rgb
if rgb is not None:
ctx.rgb = colors.RGB(*rgb)
return ctx
def get_font(self, ctx: MTextContext) -> fonts.AbstractFont:
ttf = fonts.find_font_file_name(ctx.font_face) # 1st call is very slow
key = (ttf, ctx.cap_height, ctx.width_factor)
font = self._font_cache.get(key)
if font is None:
font = fonts.make_font(ttf, ctx.cap_height, ctx.width_factor)
self._font_cache[key] = font
return font
def get_stroke(self, ctx: MTextContext) -> int:
return get_stroke(ctx)
def get_stacking(self, type_: str) -> tl.Stacking:
return STACKING.get(type_, tl.Stacking.LINE)
def space_width(self, ctx: MTextContext) -> float:
return self.get_font(ctx).space_width()
def space(self, ctx: MTextContext):
return tl.Space(width=self.space_width(ctx))
def tabulator(self, ctx: MTextContext):
return tl.Tabulator(width=self.space_width(ctx))
def non_breaking_space(self, ctx: MTextContext):
return tl.NonBreakingSpace(width=self.space_width(ctx))
def layout_engine(self, mtext: MText) -> tl.Layout:
initial_cap_height = mtext.dxf.char_height
line_spacing = mtext.dxf.line_spacing_factor
def append_paragraph():
paragraph = new_paragraph(
cells,
ctx,
initial_cap_height,
line_spacing,
width,
default_stops,
)
layout.append_paragraphs([paragraph])
cells.clear()
bg_renderer = self.make_bg_renderer(mtext)
width = defined_width(mtext)
default_stops = make_default_tab_stops(initial_cap_height, width)
layout = tl.Layout(width=width)
if mtext.has_columns:
columns = mtext.columns
assert columns is not None
for height in column_heights(columns):
layout.append_column(
width=columns.width,
height=height,
gutter=columns.gutter_width,
renderer=bg_renderer,
)
else:
# column with auto height and default width
layout.append_column(renderer=bg_renderer)
content = mtext.all_columns_raw_content()
ctx = self.make_mtext_context(mtext)
cells: list[tl.Cell] = []
for token in MTextParser(content, ctx):
ctx = token.ctx
if token.type == TokenType.NEW_PARAGRAPH:
append_paragraph()
elif token.type == TokenType.NEW_COLUMN:
append_paragraph()
layout.next_column()
elif token.type == TokenType.SPACE:
cells.append(self.space(ctx))
elif token.type == TokenType.NBSP:
cells.append(self.non_breaking_space(ctx))
elif token.type == TokenType.TABULATOR:
cells.append(self.tabulator(ctx))
elif token.type == TokenType.WORD:
if cells and isinstance(cells[-1], (tl.Text, tl.Fraction)):
# Create an unbreakable connection between those two parts.
cells.append(super_glue())
cells.append(self.word(token.data, ctx))
elif token.type == TokenType.STACK:
if cells and isinstance(cells[-1], (tl.Text, tl.Fraction)):
# Create an unbreakable connection between those two parts.
cells.append(super_glue())
cells.append(self.fraction(token.data, ctx))
if cells:
append_paragraph()
return layout

View File

@@ -0,0 +1,626 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Iterator
from ezdxf.math import Vec2, Shape2d, NULLVEC, UVec
from .forms import open_arrow, arrow2
if TYPE_CHECKING:
from ezdxf.entities import DXFGraphic
from ezdxf.sections.blocks import BlocksSection
from ezdxf.eztypes import GenericLayoutType
DEFAULT_ARROW_ANGLE = 18.924644
DEFAULT_BETA = 45.0
# The base arrow is oriented for the right hand side ->| of the dimension line,
# reverse is the left hand side |<-.
class BaseArrow:
def __init__(self, vertices: Iterable[UVec]):
self.shape = Shape2d(vertices)
def render(self, layout: GenericLayoutType, dxfattribs=None):
pass
def place(self, insert: UVec, angle: float):
self.shape.rotate(angle)
self.shape.translate(insert)
class NoneStroke(BaseArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
super().__init__([Vec2(insert)])
class ObliqueStroke(BaseArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
self.size = size
s2 = size / 2
# shape = [center, lower left, upper right]
super().__init__([Vec2((-s2, -s2)), Vec2((s2, s2))])
self.place(insert, angle)
def render(self, layout: GenericLayoutType, dxfattribs=None):
layout.add_line(
start=self.shape[0], end=self.shape[1], dxfattribs=dxfattribs
)
class ArchTick(ObliqueStroke):
def render(self, layout: GenericLayoutType, dxfattribs=None):
width = self.size * 0.15
dxfattribs = dxfattribs or {}
if layout.dxfversion > "AC1009":
dxfattribs["const_width"] = width
layout.add_lwpolyline(
self.shape, format="xy", dxfattribs=dxfattribs # type: ignore
)
else:
dxfattribs["default_start_width"] = width
dxfattribs["default_end_width"] = width
layout.add_polyline2d(self.shape, dxfattribs=dxfattribs) # type: ignore
class ClosedArrowBlank(BaseArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
super().__init__(open_arrow(size, angle=DEFAULT_ARROW_ANGLE))
self.place(insert, angle)
def render(self, layout: GenericLayoutType, dxfattribs=None):
if layout.dxfversion > "AC1009":
polyline = layout.add_lwpolyline(
points=self.shape, dxfattribs=dxfattribs # type: ignore
)
else:
polyline = layout.add_polyline2d( # type: ignore
points=self.shape, dxfattribs=dxfattribs # type: ignore
)
polyline.close(True)
class ClosedArrow(ClosedArrowBlank):
def render(self, layout: GenericLayoutType, dxfattribs=None):
super().render(layout, dxfattribs)
end_point = self.shape[0].lerp(self.shape[2])
layout.add_line(
start=self.shape[1], end=end_point, dxfattribs=dxfattribs
)
class ClosedArrowFilled(ClosedArrow):
def render(self, layout: GenericLayoutType, dxfattribs=None):
layout.add_solid(
points=self.shape, # type: ignore
dxfattribs=dxfattribs,
)
class _OpenArrow(BaseArrow):
def __init__(
self,
arrow_angle: float,
insert: UVec,
size: float = 1.0,
angle: float = 0,
):
points = list(open_arrow(size, angle=arrow_angle))
points.append((-1, 0))
super().__init__(points)
self.place(insert, angle)
def render(self, layout: GenericLayoutType, dxfattribs=None):
if layout.dxfversion > "AC1009":
layout.add_lwpolyline(points=self.shape[:-1], dxfattribs=dxfattribs)
else:
layout.add_polyline2d(points=self.shape[:-1], dxfattribs=dxfattribs)
layout.add_line(
start=self.shape[1], end=self.shape[-1], dxfattribs=dxfattribs
)
class OpenArrow(_OpenArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
super().__init__(DEFAULT_ARROW_ANGLE, insert, size, angle)
class OpenArrow30(_OpenArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
super().__init__(30, insert, size, angle)
class OpenArrow90(_OpenArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
super().__init__(90, insert, size, angle)
class Circle(BaseArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
self.radius = size / 2
# shape = [center point, connection point]
super().__init__(
[
Vec2((0, 0)),
Vec2((-self.radius, 0)),
Vec2((-size, 0)),
]
)
self.place(insert, angle)
def render(self, layout: GenericLayoutType, dxfattribs=None):
layout.add_circle(
center=self.shape[0], radius=self.radius, dxfattribs=dxfattribs
)
class Origin(Circle):
def render(self, layout: GenericLayoutType, dxfattribs=None):
super().render(layout, dxfattribs)
layout.add_line(
start=self.shape[0], end=self.shape[2], dxfattribs=dxfattribs
)
class CircleBlank(Circle):
def render(self, layout: GenericLayoutType, dxfattribs=None):
super().render(layout, dxfattribs)
layout.add_line(
start=self.shape[1], end=self.shape[2], dxfattribs=dxfattribs
)
class Origin2(Circle):
def render(self, layout: GenericLayoutType, dxfattribs=None):
layout.add_circle(
center=self.shape[0], radius=self.radius, dxfattribs=dxfattribs
)
layout.add_circle(
center=self.shape[0], radius=self.radius / 2, dxfattribs=dxfattribs
)
layout.add_line(
start=self.shape[1], end=self.shape[2], dxfattribs=dxfattribs
)
class DotSmall(Circle):
def render(self, layout: GenericLayoutType, dxfattribs=None):
center = self.shape[0]
d = Vec2((self.radius / 2, 0))
p1 = center - d
p2 = center + d
dxfattribs = dxfattribs or {}
if layout.dxfversion > "AC1009":
dxfattribs["const_width"] = self.radius
layout.add_lwpolyline(
[(p1, 1), (p2, 1)],
format="vb",
close=True,
dxfattribs=dxfattribs,
)
else:
dxfattribs["default_start_width"] = self.radius
dxfattribs["default_end_width"] = self.radius
polyline = layout.add_polyline2d(
points=[p1, p2], close=True, dxfattribs=dxfattribs
)
polyline[0].dxf.bulge = 1
polyline[1].dxf.bulge = 1
class Dot(DotSmall):
def render(self, layout: GenericLayoutType, dxfattribs=None):
layout.add_line(
start=self.shape[1], end=self.shape[2], dxfattribs=dxfattribs
)
super().render(layout, dxfattribs)
class Box(BaseArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
# shape = [lower_left, lower_right, upper_right, upper_left, connection point]
s2 = size / 2
super().__init__(
[
Vec2((-s2, -s2)),
Vec2((+s2, -s2)),
Vec2((+s2, +s2)),
Vec2((-s2, +s2)),
Vec2((-s2, 0)),
Vec2((-size, 0)),
]
)
self.place(insert, angle)
def render(self, layout: GenericLayoutType, dxfattribs=None):
if layout.dxfversion > "AC1009":
polyline = layout.add_lwpolyline(
points=self.shape[0:4], dxfattribs=dxfattribs
)
else:
polyline = layout.add_polyline2d( # type: ignore
points=self.shape[0:4], dxfattribs=dxfattribs
)
polyline.close(True)
layout.add_line(
start=self.shape[4], end=self.shape[5], dxfattribs=dxfattribs
)
class BoxFilled(Box):
def render(self, layout: GenericLayoutType, dxfattribs=None):
def solid_order():
v = self.shape.vertices
return [v[0], v[1], v[3], v[2]]
layout.add_solid(points=solid_order(), dxfattribs=dxfattribs)
layout.add_line(
start=self.shape[4], end=self.shape[5], dxfattribs=dxfattribs
)
class Integral(BaseArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
self.radius = size * 0.3535534
self.angle = angle
# shape = [center, left_center, right_center]
super().__init__(
[
Vec2((0, 0)),
Vec2((-self.radius, 0)),
Vec2((self.radius, 0)),
]
)
self.place(insert, angle)
def render(self, layout: GenericLayoutType, dxfattribs=None):
angle = self.angle
layout.add_arc(
center=self.shape[1],
radius=self.radius,
start_angle=-90 + angle,
end_angle=angle,
dxfattribs=dxfattribs,
)
layout.add_arc(
center=self.shape[2],
radius=self.radius,
start_angle=90 + angle,
end_angle=180 + angle,
dxfattribs=dxfattribs,
)
class DatumTriangle(BaseArrow):
REVERSE_ANGLE = 180
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
d = 0.577350269 * size # tan(30)
# shape = [upper_corner, lower_corner, connection_point]
super().__init__(
[
Vec2((0, d)),
Vec2((0, -d)),
Vec2((-size, 0)),
]
)
self.place(insert, angle)
def render(self, layout: GenericLayoutType, dxfattribs=None):
if layout.dxfversion > "AC1009":
polyline = layout.add_lwpolyline(
points=self.shape, dxfattribs=dxfattribs # type: ignore
)
else:
polyline = layout.add_polyline2d( # type: ignore
points=self.shape, dxfattribs=dxfattribs # type: ignore
)
polyline.close(True)
class DatumTriangleFilled(DatumTriangle):
def render(self, layout: GenericLayoutType, dxfattribs=None):
layout.add_solid(points=self.shape, dxfattribs=dxfattribs) # type: ignore
class _EzArrow(BaseArrow):
def __init__(self, insert: UVec, size: float = 1.0, angle: float = 0):
points = list(arrow2(size, angle=DEFAULT_ARROW_ANGLE))
points.append((-1, 0))
super().__init__(points)
self.place(insert, angle)
def render(self, layout: GenericLayoutType, dxfattribs=None):
if layout.dxfversion > "AC1009":
polyline = layout.add_lwpolyline(
self.shape[:-1], dxfattribs=dxfattribs
)
else:
polyline = layout.add_polyline2d( # type: ignore
self.shape[:-1], dxfattribs=dxfattribs
)
polyline.close(True)
class EzArrowBlank(_EzArrow):
def render(self, layout: GenericLayoutType, dxfattribs=None):
super().render(layout, dxfattribs)
layout.add_line(
start=self.shape[-2], end=self.shape[-1], dxfattribs=dxfattribs
)
class EzArrow(_EzArrow):
def render(self, layout: GenericLayoutType, dxfattribs=None):
super().render(layout, dxfattribs)
layout.add_line(
start=self.shape[1], end=self.shape[-1], dxfattribs=dxfattribs
)
class EzArrowFilled(_EzArrow):
def render(self, layout: GenericLayoutType, dxfattribs=None):
points = self.shape.vertices
layout.add_solid(
[points[0], points[1], points[3], points[2]], dxfattribs=dxfattribs
)
layout.add_line(
start=self.shape[-2], end=self.shape[-1], dxfattribs=dxfattribs
)
class _Arrows:
closed_filled = ""
dot = "DOT"
dot_small = "DOTSMALL"
dot_blank = "DOTBLANK"
origin_indicator = "ORIGIN"
origin_indicator_2 = "ORIGIN2"
open = "OPEN"
right_angle = "OPEN90"
open_30 = "OPEN30"
closed = "CLOSED"
dot_smallblank = "SMALL"
none = "NONE"
oblique = "OBLIQUE"
box_filled = "BOXFILLED"
box = "BOXBLANK"
closed_blank = "CLOSEDBLANK"
datum_triangle_filled = "DATUMFILLED"
datum_triangle = "DATUMBLANK"
integral = "INTEGRAL"
architectural_tick = "ARCHTICK"
# ezdxf special arrows
ez_arrow = "EZ_ARROW"
ez_arrow_blank = "EZ_ARROW_BLANK"
ez_arrow_filled = "EZ_ARROW_FILLED"
CLASSES = {
closed_filled: ClosedArrowFilled,
dot: Dot,
dot_small: DotSmall,
dot_blank: CircleBlank,
origin_indicator: Origin,
origin_indicator_2: Origin2,
open: OpenArrow,
right_angle: OpenArrow90,
open_30: OpenArrow30,
closed: ClosedArrow,
dot_smallblank: Circle,
none: NoneStroke,
oblique: ObliqueStroke,
box_filled: BoxFilled,
box: Box,
closed_blank: ClosedArrowBlank,
datum_triangle: DatumTriangle,
datum_triangle_filled: DatumTriangleFilled,
integral: Integral,
architectural_tick: ArchTick,
ez_arrow: EzArrow,
ez_arrow_blank: EzArrowBlank,
ez_arrow_filled: EzArrowFilled,
}
# arrows with origin at dimension line start/end
ORIGIN_ZERO = {
architectural_tick,
oblique,
dot_small,
dot_smallblank,
integral,
none,
}
__acad__ = {
closed_filled,
dot,
dot_small,
dot_blank,
origin_indicator,
origin_indicator_2,
open,
right_angle,
open_30,
closed,
dot_smallblank,
none,
oblique,
box_filled,
box,
closed_blank,
datum_triangle,
datum_triangle_filled,
integral,
architectural_tick,
}
__ezdxf__ = {
ez_arrow,
ez_arrow_blank,
ez_arrow_filled,
}
__all_arrows__ = __acad__ | __ezdxf__
EXTENSIONS_ALLOWED = {
architectural_tick,
oblique,
none,
dot_smallblank,
integral,
dot_small,
}
def is_acad_arrow(self, item: str) -> bool:
"""Returns ``True`` if `item` is a standard AutoCAD arrow."""
return item.upper() in self.__acad__
def is_ezdxf_arrow(self, item: str) -> bool:
"""Returns ``True`` if `item` is a special `ezdxf` arrow."""
return item.upper() in self.__ezdxf__
def has_extension_line(self, name):
"""Returns ``True`` if the arrow `name` supports extension lines."""
return name in self.EXTENSIONS_ALLOWED
def __contains__(self, item: str) -> bool:
"""Returns `True` if `item` is an arrow managed by this class."""
if item is None:
return False
return item.upper() in self.__all_arrows__
def create_block(self, blocks: BlocksSection, name: str) -> str:
"""Creates the BLOCK definition for arrow `name`."""
block_name = self.block_name(name)
if block_name not in blocks:
block = blocks.new(block_name)
arrow = self.arrow_shape(name, insert=(0, 0), size=1, rotation=0)
arrow.render(block, dxfattribs={"color": 0, "linetype": "BYBLOCK"})
return block_name
def arrow_handle(self, blocks: BlocksSection, name: str) -> str:
"""Returns the BLOCK_RECORD handle for arrow `name`."""
arrow_name = self.arrow_name(name)
block_name = self.create_block(blocks, arrow_name)
block = blocks.get(block_name)
return block.block_record_handle
def block_name(self, name: str) -> str:
"""Returns the block name."""
if not self.is_acad_arrow(name): # common BLOCK definition
# e.g. Dimension.dxf.bkl = 'EZ_ARROW' == Insert.dxf.name
return name.upper()
elif name == "":
# special AutoCAD arrow symbol 'CLOSED_FILLED' has no name
# ezdxf uses blocks for ALL arrows, but '_' (closed filled) as block name?
return "_CLOSEDFILLED" # Dimension.dxf.bkl = '' != Insert.dxf.name = '_CLOSED_FILLED'
else:
# add preceding '_' to AutoCAD arrow symbol names
# Dimension.dxf.bkl = 'DOT' != Insert.dxf.name = '_DOT'
return "_" + name.upper()
def arrow_name(self, block_name: str) -> str:
"""Returns the arrow name."""
if block_name.startswith("_"):
name = block_name[1:].upper()
if name == "CLOSEDFILLED":
return ""
elif self.is_acad_arrow(name):
return name
return block_name
def insert_arrow(
self,
layout: GenericLayoutType,
name: str,
insert: UVec = NULLVEC,
size: float = 1.0,
rotation: float = 0,
*,
dxfattribs=None,
) -> Vec2:
"""Insert arrow as block reference into `layout`."""
block_name = self.create_block(layout.doc.blocks, name)
dxfattribs = dict(dxfattribs or {})
dxfattribs["rotation"] = rotation
dxfattribs["xscale"] = size
dxfattribs["yscale"] = size
layout.add_blockref(block_name, insert=insert, dxfattribs=dxfattribs)
return connection_point(
name, insert=insert, scale=size, rotation=rotation
)
def render_arrow(
self,
layout: GenericLayoutType,
name: str,
insert: UVec = NULLVEC,
size: float = 1.0,
rotation: float = 0,
*,
dxfattribs=None,
) -> Vec2:
"""Render arrow as basic DXF entities into `layout`."""
dxfattribs = dict(dxfattribs or {})
arrow = self.arrow_shape(name, insert, size, rotation)
arrow.render(layout, dxfattribs)
return connection_point(
name, insert=insert, scale=size, rotation=rotation
)
def virtual_entities(
self,
name: str,
insert: UVec = NULLVEC,
size: float = 0.625,
rotation: float = 0,
*,
dxfattribs=None,
) -> Iterator[DXFGraphic]:
"""Returns all arrow components as virtual DXF entities."""
from ezdxf.layouts import VirtualLayout
if name in self:
layout = VirtualLayout()
ARROWS.render_arrow(
layout,
name,
insert=insert,
size=size,
rotation=rotation,
dxfattribs=dxfattribs,
)
yield from iter(layout)
def arrow_shape(
self, name: str, insert: UVec, size: float, rotation: float
) -> BaseArrow:
"""Returns an instance of the shape management class for arrow `name`."""
# size depending shapes
name = name.upper()
if name == self.dot_small:
size *= 0.25
elif name == self.dot_smallblank:
size *= 0.5
cls = self.CLASSES[name]
return cls(insert, size, rotation)
def connection_point(
arrow_name: str, insert: UVec, scale: float = 1.0, rotation: float = 0.0
) -> Vec2:
"""Returns the connection point for `arrow_name`."""
insert = Vec2(insert)
if ARROWS.arrow_name(arrow_name) in _Arrows.ORIGIN_ZERO:
return insert
else:
return insert - Vec2.from_deg_angle(rotation, scale)
def arrow_length(arrow_name: str, scale: float = 1.0) -> float:
"""Returns the scaled arrow length of `arrow_name`."""
if ARROWS.arrow_name(arrow_name) in _Arrows.ORIGIN_ZERO:
return 0.0
else:
return scale
ARROWS: _Arrows = _Arrows()

View File

@@ -0,0 +1,504 @@
# Copyright (c) 2010-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional
import random
import math
from ezdxf.math import (
Vec3,
Vec2,
UVec,
Matrix44,
perlin,
Bezier4P,
global_bspline_interpolation,
BSpline,
open_uniform_bspline,
closed_uniform_bspline,
EulerSpiral as _EulerSpiral,
)
if TYPE_CHECKING:
from ezdxf.layouts import BaseLayout
def rnd(max_value):
return max_value / 2.0 - random.random() * max_value
def rnd_perlin(max_value, walker):
r = perlin.snoise2(walker.x, walker.y)
return max_value / 2.0 - r * max_value
def random_2d_path(
steps: int = 100,
max_step_size: float = 1.0,
max_heading: float = math.pi / 2,
retarget: int = 20,
) -> Iterable[Vec2]:
"""Returns a random 2D path as iterable of :class:`~ezdxf.math.Vec2`
objects.
Args:
steps: count of vertices to generate
max_step_size: max step size
max_heading: limit heading angle change per step to ± max_heading/2 in
radians
retarget: specifies steps before changing global walking target
"""
max_ = max_step_size * steps
def next_global_target():
return Vec2((rnd(max_), rnd(max_)))
walker = Vec2(0, 0)
target = next_global_target()
for i in range(steps):
if i % retarget == 0:
target = target + next_global_target()
angle = (target - walker).angle
heading = angle + rnd_perlin(max_heading, walker)
length = max_step_size * random.random()
walker = walker + Vec2.from_angle(heading, length)
yield walker
def random_3d_path(
steps: int = 100,
max_step_size: float = 1.0,
max_heading: float = math.pi / 2.0,
max_pitch: float = math.pi / 8.0,
retarget: int = 20,
) -> Iterable[Vec3]:
"""Returns a random 3D path as iterable of :class:`~ezdxf.math.Vec3`
objects.
Args:
steps: count of vertices to generate
max_step_size: max step size
max_heading: limit heading angle change per step to ± max_heading/2,
rotation about the z-axis in radians
max_pitch: limit pitch angle change per step to ± max_pitch/2, rotation
about the x-axis in radians
retarget: specifies steps before changing global walking target
"""
max_ = max_step_size * steps
def next_global_target():
return Vec3((rnd(max_), rnd(max_), rnd(max_)))
walker = Vec3()
target = next_global_target()
for i in range(steps):
if i % retarget == 0:
target = target + next_global_target()
angle = (target - walker).angle
length = max_step_size * random.random()
heading_angle = angle + rnd_perlin(max_heading, walker)
next_step = Vec3.from_angle(heading_angle, length)
pitch_angle = rnd_perlin(max_pitch, walker)
walker += Matrix44.x_rotate(pitch_angle).transform(next_step)
yield walker
class Bezier:
"""Render a bezier curve as 2D/3D :class:`~ezdxf.entities.Polyline`.
The :class:`Bezier` class is implemented with multiple segments, each
segment is an optimized 4 point bezier curve, the 4 control points of the
curve are: the start point (1) and the end point (4), point (2) is start
point + start vector and point (3) is end point + end vector. Each segment
has its own approximation count.
.. seealso::
The new :mod:`ezdxf.path` package provides many advanced construction tools
based on the :class:`~ezdxf.path.Path` class.
"""
class Segment:
def __init__(
self,
start: UVec,
end: UVec,
start_tangent: UVec,
end_tangent: UVec,
segments: int,
):
self.start = Vec3(start)
self.end = Vec3(end)
self.start_tangent = Vec3(
start_tangent
) # as vector, from start point
self.end_tangent = Vec3(end_tangent) # as vector, from end point
self.segments = segments
def approximate(self) -> Iterable[Vec3]:
control_points = [
self.start,
self.start + self.start_tangent,
self.end + self.end_tangent,
self.end,
]
bezier = Bezier4P(control_points)
return bezier.approximate(self.segments)
def __init__(self) -> None:
# fit point, first control vector, second control vector, segment count
self.points: list[
tuple[Vec3, Optional[Vec3], Optional[Vec3], Optional[int]]
] = []
def start(self, point: UVec, tangent: UVec) -> None:
"""Set start point and start tangent.
Args:
point: start point
tangent: start tangent as vector, example: (5, 0, 0) means a
horizontal tangent with a length of 5 drawing units
"""
self.points.append((Vec3(point), None, tangent, None))
def append(
self,
point: UVec,
tangent1: UVec,
tangent2: Optional[UVec] = None,
segments: int = 20,
):
"""Append a control point with two control tangents.
Args:
point: control point
tangent1: first tangent as vector "left" of the control point
tangent2: second tangent as vector "right" of the control point,
if omitted `tangent2` = `-tangent1`
segments: count of line segments for the polyline approximation,
count of line segments from the previous control point to the
appended control point.
"""
tangent1 = Vec3(tangent1)
if tangent2 is None:
tangent2 = -tangent1
else:
tangent2 = Vec3(tangent2)
self.points.append((Vec3(point), tangent1, tangent2, int(segments)))
def _build_bezier_segments(self) -> Iterable[Segment]:
if len(self.points) > 1:
for from_point, to_point in zip(self.points[:-1], self.points[1:]):
start_point = from_point[0]
start_tangent = from_point[2] # tangent2
end_point = to_point[0]
end_tangent = to_point[1] # tangent1
count = to_point[3]
yield Bezier.Segment(
start_point, end_point, start_tangent, end_tangent, count # type: ignore
)
else:
raise ValueError("Two or more points needed!")
def render(
self,
layout: BaseLayout,
force3d: bool = False,
dxfattribs=None,
) -> None:
"""Render Bezier curve as 2D/3D :class:`~ezdxf.entities.Polyline`.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
force3d: force 3D polyline rendering
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
points: list[Vec3] = []
for segment in self._build_bezier_segments():
points.extend(segment.approximate())
if force3d or any(p[2] for p in points):
layout.add_polyline3d(points, dxfattribs=dxfattribs)
else:
layout.add_polyline2d(points, dxfattribs=dxfattribs)
class Spline:
"""This class can be used to render B-splines into DXF R12 files as
approximated :class:`~ezdxf.entities.Polyline` entities.
The advantage of this class over the :class:`R12Spline` class is,
that this is a real 3D curve, which means that the B-spline vertices do
have to be located in a flat plane, and no :ref:`UCS` class is needed to
place the curve in 3D space.
.. seealso::
The newer :class:`~ezdxf.math.BSpline` class provides the
advanced vertex interpolation method :meth:`~ezdxf.math.BSpline.flattening`.
"""
def __init__(
self, points: Optional[Iterable[UVec]] = None, segments: int = 100
):
"""
Args:
points: spline definition points
segments: count of line segments for approximation, vertex count is
`segments` + 1
"""
if points is None:
points = []
self.points: list[Vec3] = Vec3.list(points)
self.segments = int(segments)
def subdivide(self, segments: int = 4) -> None:
"""Calculate overall segment count, where segments is the sub-segment
count, `segments` = 4, means 4 line segments between two definition
points e.g. 4 definition points and 4 segments = 12 overall segments,
useful for fit point rendering.
Args:
segments: sub-segments count between two definition points
"""
self.segments = (len(self.points) - 1) * segments
def render_as_fit_points(
self,
layout: BaseLayout,
degree: int = 3,
method: str = "chord",
dxfattribs: Optional[dict] = None,
) -> None:
"""Render a B-spline as 2D/3D :class:`~ezdxf.entities.Polyline`, where
the definition points are fit points.
- 2D spline vertices uses: :meth:`~ezdxf.layouts.BaseLayout.add_polyline2d`
- 3D spline vertices uses: :meth:`~ezdxf.layouts.BaseLayout.add_polyline3d`
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
degree: degree of B-spline (order = `degree` + 1)
method: "uniform", "distance"/"chord", "centripetal"/"sqrt_chord" or
"arc" calculation method for parameter t
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
spline = global_bspline_interpolation(
self.points, degree=degree, method=method
)
vertices = list(spline.approximate(self.segments))
if any(vertex.z != 0.0 for vertex in vertices):
layout.add_polyline3d(vertices, dxfattribs=dxfattribs)
else:
layout.add_polyline2d(vertices, dxfattribs=dxfattribs)
render = render_as_fit_points
def render_open_bspline(
self, layout: BaseLayout, degree: int = 3, dxfattribs=None
) -> None:
"""Render an open uniform B-spline as 3D :class:`~ezdxf.entities.Polyline`.
Definition points are control points.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
degree: degree of B-spline (order = `degree` + 1)
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
spline = BSpline(self.points, order=degree + 1)
layout.add_polyline3d(
list(spline.approximate(self.segments)), dxfattribs=dxfattribs
)
def render_uniform_bspline(
self, layout: BaseLayout, degree: int = 3, dxfattribs=None
) -> None:
"""Render a uniform B-spline as 3D :class:`~ezdxf.entities.Polyline`.
Definition points are control points.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
degree: degree of B-spline (order = `degree` + 1)
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
spline = open_uniform_bspline(self.points, order=degree + 1)
layout.add_polyline3d(
list(spline.approximate(self.segments)), dxfattribs=dxfattribs
)
def render_closed_bspline(
self, layout: BaseLayout, degree: int = 3, dxfattribs=None
) -> None:
"""Render a closed uniform B-spline as 3D :class:`~ezdxf.entities.Polyline`.
Definition points are control points.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
degree: degree of B-spline (order = `degree` + 1)
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
spline = closed_uniform_bspline(self.points, order=degree + 1)
layout.add_polyline3d(
list(spline.approximate(self.segments)), dxfattribs=dxfattribs
)
def render_open_rbspline(
self,
layout: BaseLayout,
weights: Iterable[float],
degree: int = 3,
dxfattribs=None,
) -> None:
"""Render a rational open uniform BSpline as 3D :class:`~ezdxf.entities.Polyline`.
Definition points are control points.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
weights: list of weights, requires a weight value (float) for each
definition point.
degree: degree of B-spline (order = `degree` + 1)
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
spline = BSpline(self.points, order=degree + 1, weights=weights)
layout.add_polyline3d(
list(spline.approximate(self.segments)), dxfattribs=dxfattribs
)
def render_uniform_rbspline(
self,
layout: BaseLayout,
weights: Iterable[float],
degree: int = 3,
dxfattribs=None,
) -> None:
"""Render a rational uniform B-spline as 3D :class:`~ezdxf.entities.Polyline`.
Definition points are control points.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
weights: list of weights, requires a weight value (float) for each
definition point.
degree: degree of B-spline (order = `degree` + 1)
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
spline = closed_uniform_bspline(
self.points, order=degree + 1, weights=weights
)
layout.add_polyline3d(
list(spline.approximate(self.segments)), dxfattribs=dxfattribs
)
def render_closed_rbspline(
self,
layout: BaseLayout,
weights: Iterable[float],
degree: int = 3,
dxfattribs=None,
) -> None:
"""Render a rational B-spline as 3D :class:`~ezdxf.entities.Polyline`.
Definition points are control points.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
weights: list of weights, requires a weight value (float) for each
definition point.
degree: degree of B-spline (order = `degree` + 1)
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
spline = closed_uniform_bspline(
self.points, order=degree + 1, weights=weights
)
layout.add_polyline3d(
list(spline.approximate(self.segments)), dxfattribs=dxfattribs
)
class EulerSpiral:
"""Render an `euler spiral <https://en.wikipedia.org/wiki/Euler_spiral>`_
as a 3D :class:`~ezdxf.entities.Polyline` or a
:class:`~ezdxf.entities.Spline` entity.
This is a parametric curve, which always starts at the origin (0, 0).
"""
def __init__(self, curvature: float = 1):
"""
Args:
curvature: Radius of curvature
"""
self.spiral = _EulerSpiral(float(curvature))
def render_polyline(
self,
layout: BaseLayout,
length: float = 1,
segments: int = 100,
matrix: Optional[Matrix44] = None,
dxfattribs=None,
):
"""Render curve as :class:`~ezdxf.entities.Polyline`.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
length: length measured along the spiral curve from its initial position
segments: count of line segments to use, vertex count is `segments` + 1
matrix: transformation matrix as :class:`~ezdxf.math.Matrix44`
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
Returns:
:class:`~ezdxf.entities.Polyline`
"""
points = self.spiral.approximate(length, segments)
if matrix is not None:
points = matrix.transform_vertices(points)
return layout.add_polyline3d(list(points), dxfattribs=dxfattribs)
def render_spline(
self,
layout: BaseLayout,
length: float = 1,
fit_points: int = 10,
degree: int = 3,
matrix: Optional[Matrix44] = None,
dxfattribs=None,
):
"""
Render curve as :class:`~ezdxf.entities.Spline`.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
length: length measured along the spiral curve from its initial position
fit_points: count of spline fit points to use
degree: degree of B-spline
matrix: transformation matrix as :class:`~ezdxf.math.Matrix44`
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Spline`
Returns:
:class:`~ezdxf.entities.Spline`
"""
spline = self.spiral.bspline(length, fit_points, degree=degree)
points = spline.control_points
if matrix is not None:
points = matrix.transform_vertices(points)
return layout.add_open_spline(
control_points=points,
degree=spline.degree,
knots=spline.knots(),
dxfattribs=dxfattribs,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,977 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from abc import abstractmethod
import logging
import math
from ezdxf.math import (
Vec2,
Vec3,
NULLVEC,
UCS,
decdeg2dms,
arc_angle_span_rad,
xround,
)
from ezdxf.entities import DimStyleOverride, Dimension, DXFEntity
from .dim_base import (
BaseDimensionRenderer,
get_required_defpoint,
format_text,
apply_dimpost,
Tolerance,
Measurement,
LengthMeasurement,
compile_mtext,
order_leader_points,
get_center_leader_points,
)
from ezdxf.render.arrows import ARROWS, arrow_length
from ezdxf.tools.text import is_upside_down_text_angle
from ezdxf.math import intersection_line_line_2d
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
__all__ = ["AngularDimension", "Angular3PDimension", "ArcLengthDimension"]
logger = logging.getLogger("ezdxf")
ARC_PREFIX = "( "
def has_required_attributes(entity: DXFEntity, attrib_names: list[str]):
has = entity.dxf.hasattr
return all(has(attrib_name) for attrib_name in attrib_names)
GRAD = 200.0 / math.pi
DEG = 180.0 / math.pi
def format_angular_text(
value: float,
angle_units: int,
dimrnd: Optional[float],
dimdec: int,
dimzin: int,
dimdsep: str,
) -> str:
def decimal_format(_value: float) -> str:
return format_text(
_value,
dimrnd=dimrnd,
dimdec=dimdec,
dimzin=dimzin,
dimdsep=dimdsep,
)
def dms_format(_value: float) -> str:
if dimrnd is not None:
_value = xround(_value, dimrnd)
d, m, s = decdeg2dms(_value)
if dimdec > 4:
places = dimdec - 5
s = round(s, places)
return f"{d:.0f}°{m:.0f}'{decimal_format(s)}\""
if dimdec > 2:
return f"{d:.0f}°{m:.0f}'{s:.0f}\""
if dimdec > 0:
return f"{d:.0f}°{m:.0f}'"
return f"{d:.0f}°"
# angular_unit:
# 0 = Decimal degrees
# 1 = Degrees/minutes/seconds
# 2 = Grad
# 3 = Radians
text = ""
if angle_units == 0:
text = decimal_format(value * DEG) + "°"
elif angle_units == 1:
text = dms_format(value * DEG)
elif angle_units == 2:
text = decimal_format(value * GRAD) + "g"
elif angle_units == 3:
text = decimal_format(value) + "r"
return text
_ANGLE_UNITS = [
DEG,
DEG,
GRAD,
1.0,
]
def to_radians(value: float, dimaunit: int) -> float:
try:
return value / _ANGLE_UNITS[dimaunit]
except IndexError:
return value / DEG
class AngularTolerance(Tolerance):
def __init__(
self,
dim_style: DimStyleOverride,
cap_height: float = 1.0,
width_factor: float = 1.0,
dim_scale: float = 1.0,
angle_units: int = 0,
):
self.angular_units = angle_units
super().__init__(dim_style, cap_height, width_factor, dim_scale)
# Tolerance values are interpreted in dimaunit:
# dimtp 1 means 1 degree for dimaunit = 0 or 1, but 1 radians for
# dimaunit = 3
# format_text() requires radians as input:
self.update_tolerance_text(
to_radians(self.maximum, angle_units),
to_radians(self.minimum, angle_units),
)
def format_text(self, value: float) -> str:
"""Rounding and text formatting of tolerance `value`, removes leading
and trailing zeros if necessary.
"""
return format_angular_text(
value=value,
angle_units=self.angular_units,
dimrnd=None,
dimdec=self.decimal_places,
dimzin=self.suppress_zeros,
dimdsep=self.text_decimal_separator,
)
def update_limits(self, measurement: float) -> None:
# measurement is in radians, tolerance values are interpreted in
# dimaunit: dimtp 1 means 1 degree for dimaunit = 0 or 1,
# but 1 radians for dimaunit = 3
# format_text() requires radians as input:
upper_limit = measurement + to_radians(self.maximum, self.angular_units)
lower_limit = measurement - to_radians(self.minimum, self.angular_units)
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 AngleMeasurement(Measurement):
def update(self, raw_measurement_value: float) -> None:
self.raw_value = raw_measurement_value
self.value = raw_measurement_value
self.text = self.text_override(raw_measurement_value)
def format_text(self, value: float) -> str:
text = format_angular_text(
value=value,
angle_units=self.angle_units,
dimrnd=None,
dimdec=self.angular_decimal_places,
dimzin=self.angular_suppress_zeros << 2, # convert to dimzin value
dimdsep=self.decimal_separator,
)
if self.text_post_process_format:
text = apply_dimpost(text, self.text_post_process_format)
return text
def fits_into_arc_span(length: float, radius: float, arc_span: float) -> bool:
required_arc_span: float = length / radius
return arc_span > required_arc_span
class _CurvedDimensionLine(BaseDimensionRenderer):
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
super().__init__(dimension, ucs, override)
# Common parameters for all sub-classes:
# Use hidden line detection for dimension line:
# Disable expensive hidden line calculation if possible!
self.remove_hidden_lines_of_dimline = True
self.center_of_arc: Vec2 = self.get_center_of_arc()
self.dim_line_radius: float = self.get_dim_line_radius()
self.ext1_dir: Vec2 = self.get_ext1_dir()
self.start_angle_rad: float = self.ext1_dir.angle
self.ext2_dir: Vec2 = self.get_ext2_dir()
self.end_angle_rad: float = self.ext2_dir.angle
# Angle between extension lines for all curved dimensions:
# equal to the angle measurement of angular dimensions
self.arc_angle_span_rad: float = arc_angle_span_rad(
self.start_angle_rad, self.end_angle_rad
)
self.center_angle_rad = (
self.start_angle_rad + self.arc_angle_span_rad / 2.0
)
# Additional required parameters but calculated later by sub-classes:
self.ext1_start = Vec2() # start of 1st extension line
self.ext2_start = Vec2() # start of 2nd extension line
# Class specific setup:
self.update_measurement()
if self.tol.has_limits:
self.tol.update_limits(self.measurement.value)
# Text width and -height is required first, text location and -rotation
# are not valid yet:
self.text_box = self.init_text_box()
# Place arrows outside?
self.arrows_outside = False
self.setup_text_and_arrow_fitting()
self.setup_text_location()
# update text box location and -rotation:
self.text_box.center = self.measurement.text_location
self.text_box.angle = self.measurement.text_rotation
self.geometry.set_text_box(self.text_box)
# Update final text location in the DIMENSION entity:
self.dimension.dxf.text_midpoint = self.measurement.text_location
@property
def ocs_center_of_arc(self) -> Vec3:
return self.geometry.ucs.to_ocs(Vec3(self.center_of_arc))
@property
def dim_midpoint(self) -> Vec2:
"""Return the midpoint of the dimension line."""
return self.center_of_arc + Vec2.from_angle(
self.center_angle_rad, self.dim_line_radius
)
@abstractmethod
def update_measurement(self) -> None:
"""Setup measurement text."""
...
@abstractmethod
def get_ext1_dir(self) -> Vec2:
"""Return the direction of the 1st extension line == start angle."""
...
@abstractmethod
def get_ext2_dir(self) -> Vec2:
"""Return the direction of the 2nd extension line == end angle."""
...
@abstractmethod
def get_center_of_arc(self) -> Vec2:
"""Return the center of the arc."""
...
@abstractmethod
def get_dim_line_radius(self) -> float:
"""Return the distance from the center of the arc to the dimension line
location
"""
...
@abstractmethod
def get_defpoints(self) -> list[Vec2]:
...
def transform_ucs_to_wcs(self) -> None:
"""Transforms dimension definition points into WCS or if required into
OCS.
"""
def from_ucs(attr, func):
if dxf.is_supported(attr):
point = dxf.get(attr, NULLVEC)
dxf.set(attr, func(point))
dxf = self.dimension.dxf
ucs = self.geometry.ucs
from_ucs("defpoint", ucs.to_wcs)
from_ucs("defpoint2", ucs.to_wcs)
from_ucs("defpoint3", ucs.to_wcs)
from_ucs("defpoint4", ucs.to_wcs)
from_ucs("defpoint5", ucs.to_wcs)
from_ucs("text_midpoint", ucs.to_ocs)
def default_location(self, shift: float = 0.0) -> Vec2:
radius = (
self.dim_line_radius
+ self.measurement.text_vertical_distance()
+ shift
)
text_radial_dir = Vec2.from_angle(self.center_angle_rad)
return self.center_of_arc + text_radial_dir * radius
def setup_text_and_arrow_fitting(self) -> None:
# self.text_box.width includes the gaps between text and dimension line
# Is the measurement text without the arrows too wide to fit between the
# extension lines?
self.measurement.is_wide_text = not fits_into_arc_span(
self.text_box.width, self.dim_line_radius, self.arc_angle_span_rad
)
required_text_and_arrows_space: float = (
# The suppression of the arrows is not taken into account:
self.text_box.width
+ 2.0 * self.arrows.arrow_size
)
# dimatfit: measurement text fitting rule is ignored!
# Place arrows outside?
self.arrows_outside = not fits_into_arc_span(
required_text_and_arrows_space,
self.dim_line_radius,
self.arc_angle_span_rad,
)
# Place measurement text outside?
self.measurement.text_is_outside = not fits_into_arc_span(
required_text_and_arrows_space * 1.1, # add some extra space
self.dim_line_radius,
self.arc_angle_span_rad,
)
if (
self.measurement.text_is_outside
and self.measurement.user_text_rotation is None
):
# Intersection of the measurement text with the dimension line is
# not possible:
self.remove_hidden_lines_of_dimline = False
def setup_text_location(self) -> None:
"""Setup geometric text properties (location, rotation) and the TextBox
object.
"""
# dimtix: measurement.force_text_inside is ignored
# dimtih: measurement.text_inside_horizontal is ignored
# dimtoh: measurement.text_outside_horizontal is ignored
# text radial direction = center -> text
text_radial_dir: Vec2 # text "vertical" direction
measurement = self.measurement
# determine text location:
at_default_location: bool = measurement.user_location is None
has_text_shifting: bool = bool(
measurement.text_shift_h or measurement.text_shift_v
)
if at_default_location:
# place text in the "horizontal" center of the dimension line at the
# default location defined by measurement.text_valign (dimtad):
text_radial_dir = Vec2.from_angle(self.center_angle_rad)
shift_text_upwards: float = 0.0
if measurement.text_is_outside:
# reset vertical alignment to "above"
measurement.text_valign = 1
if measurement.is_wide_text:
# move measurement text "above" the extension line endings:
shift_text_upwards = self.extension_lines.extension_above
measurement.text_location = self.default_location(
shift=shift_text_upwards
)
if (
measurement.text_valign > 0 and not has_text_shifting
): # not in the center and no text shifting is applied
# disable expensive hidden line calculation
self.remove_hidden_lines_of_dimline = False
else:
# apply dimtmove: measurement.text_movement_rule
user_location = measurement.user_location
assert isinstance(user_location, Vec2)
if measurement.relative_user_location:
user_location += self.dim_midpoint
measurement.text_location = user_location
if measurement.text_movement_rule == 0:
# Moves the dimension line with dimension text and
# aligns the text direction perpendicular to the connection
# line from the arc center to the text center:
self.dim_line_radius = (
self.center_of_arc - user_location
).magnitude
# Attributes about the text and arrow fitting have to be
# updated now:
self.setup_text_and_arrow_fitting()
elif measurement.text_movement_rule == 1:
# Adds a leader when dimension text, text direction is
# "horizontal" or user text rotation if given.
# Leader location is defined by dimtad (text_valign):
# "center" - connects to the left or right center of the text
# "below" - add a line below the text
if measurement.user_text_rotation is None:
# override text rotation
measurement.user_text_rotation = 0.0
measurement.text_is_outside = True # by definition
elif measurement.text_movement_rule == 2:
# Allows text to be moved freely without a leader and
# aligns the text direction perpendicular to the connection
# line from the arc center to the text center:
measurement.text_is_outside = True # by definition
text_radial_dir = (
measurement.text_location - self.center_of_arc
).normalize()
# set text "horizontal":
text_tangential_dir = text_radial_dir.orthogonal(ccw=False)
if at_default_location and has_text_shifting:
# Apply text relative shift (ezdxf only feature)
if measurement.text_shift_h:
measurement.text_location += (
text_tangential_dir * measurement.text_shift_h
)
if measurement.text_shift_v:
measurement.text_location += (
text_radial_dir * measurement.text_shift_v
)
# apply user text rotation; rotation in degrees:
if measurement.user_text_rotation is None:
rotation = text_tangential_dir.angle_deg
else:
rotation = measurement.user_text_rotation
if not self.geometry.requires_extrusion:
# todo: extrusion vector (0, 0, -1)?
# Practically all DIMENSION entities are 2D entities,
# where OCS == WCS, check WCS text orientation:
wcs_angle = self.geometry.ucs.to_ocs_angle_deg(rotation)
if is_upside_down_text_angle(wcs_angle):
measurement.has_upside_down_correction = True
rotation += 180.0 # apply to UCS rotation!
measurement.text_rotation = rotation
def get_leader_points(self) -> tuple[Vec2, Vec2]:
# Leader location is defined by dimtad (text_valign):
# "center":
# - connects to the left or right vertical center of the text
# - distance between text and leader line is measurement.text_gap (dimgap)
# and is already included in the text_box corner points
# - length of "leg": arrows.arrow_size
# "below" - add a line below the text
if self.measurement.text_valign == 0: # "center"
return get_center_leader_points(
self.dim_midpoint, self.text_box, self.arrows.arrow_size
)
else: # "below"
c0, c1, c2, c3 = self.text_box.corners
if self.measurement.has_upside_down_correction:
p1, p2 = c2, c3
else:
p1, p2 = c0, c1
return order_leader_points(self.dim_midpoint, p1, p2)
def render(self, block: GenericLayoutType) -> None:
"""Main method to create dimension geometry of basic DXF entities in the
associated BLOCK layout.
Args:
block: target BLOCK for rendering
"""
super().render(block)
self.add_extension_lines()
adjust_start_angle, adjust_end_angle = self.add_arrows()
measurement = self.measurement
if measurement.text:
if self.geometry.supports_dxf_r2000:
text = compile_mtext(measurement, self.tol)
else:
text = measurement.text
self.add_measurement_text(
text, measurement.text_location, measurement.text_rotation
)
if measurement.has_leader:
p1, p2 = self.get_leader_points()
self.add_leader(self.dim_midpoint, p1, p2)
self.add_dimension_line(adjust_start_angle, adjust_end_angle)
self.geometry.add_defpoints(self.get_defpoints())
def add_extension_lines(self) -> None:
ext_lines = self.extension_lines
if not ext_lines.suppress1:
self._add_ext_line(
self.ext1_start, self.ext1_dir, ext_lines.dxfattribs(1)
)
if not ext_lines.suppress2:
self._add_ext_line(
self.ext2_start, self.ext2_dir, ext_lines.dxfattribs(2)
)
def _add_ext_line(self, start: Vec2, direction: Vec2, dxfattribs) -> None:
ext_lines = self.extension_lines
center = self.center_of_arc
radius = self.dim_line_radius
ext_above = ext_lines.extension_above
is_inside = (start - center).magnitude > radius
if ext_lines.has_fixed_length:
ext_below = ext_lines.length_below
if is_inside:
ext_below, ext_above = ext_above, ext_below
start = center + direction * (radius - ext_below)
else:
offset = ext_lines.offset
if is_inside:
ext_above = -ext_above
offset = -offset
start += direction * offset
end = center + direction * (radius + ext_above)
self.add_line(start, end, dxfattribs=dxfattribs)
def add_arrows(self) -> tuple[float, float]:
"""Add arrows or ticks to dimension.
Returns: dimension start- and end angle offsets to adjust the
dimension line
"""
arrows = self.arrows
attribs = arrows.dxfattribs()
radius = self.dim_line_radius
if abs(radius) < 1e-12:
return 0.0, 0.0
start = self.center_of_arc + self.ext1_dir * radius
end = self.center_of_arc + self.ext2_dir * radius
angle1 = self.ext1_dir.orthogonal().angle_deg
angle2 = self.ext2_dir.orthogonal().angle_deg
outside = self.arrows_outside
arrow1 = not arrows.suppress1
arrow2 = not arrows.suppress2
start_angle_offset = 0.0
end_angle_offset = 0.0
if arrows.tick_size > 0.0: # oblique stroke, but double the size
if arrow1:
self.add_blockref(
ARROWS.oblique,
insert=start,
rotation=angle1,
scale=arrows.tick_size * 2.0,
dxfattribs=attribs,
)
if arrow2:
self.add_blockref(
ARROWS.oblique,
insert=end,
rotation=angle2,
scale=arrows.tick_size * 2.0,
dxfattribs=attribs,
)
else:
arrow_size = arrows.arrow_size
# Note: The arrow blocks are correct as they are!
# The arrow head is tilted to match the connection point of the
# dimension line (even for datum arrows).
# tilting angle = 1/2 of the arc angle defined by the arrow length
arrow_tilt: float = arrow_size / radius * 0.5 * DEG
start_angle = angle1 + 180.0
end_angle = angle2
if outside:
start_angle += 180.0
end_angle += 180.0
arrow_tilt = -arrow_tilt
scale = arrow_size
if arrow1:
self.add_blockref(
arrows.arrow1_name,
insert=start,
scale=scale,
rotation=start_angle + arrow_tilt,
dxfattribs=attribs,
) # reverse
if arrow2:
self.add_blockref(
arrows.arrow2_name,
insert=end,
scale=scale,
rotation=end_angle - arrow_tilt,
dxfattribs=attribs,
)
if not outside:
# arrows inside extension lines:
# adjust angles for the remaining dimension line
if arrow1:
start_angle_offset = (
arrow_length(arrows.arrow1_name, arrow_size) / radius
)
if arrow2:
end_angle_offset = (
arrow_length(arrows.arrow2_name, arrow_size) / radius
)
return start_angle_offset, end_angle_offset
def add_dimension_line(
self,
start_offset: float,
end_offset: float,
) -> None:
# Start- and end angle adjustments have to be limited between the
# extension lines.
# Negative offset extends the dimension line outside!
start_angle: float = self.start_angle_rad
end_angle: float = self.end_angle_rad
arrows = self.arrows
size = arrows.arrow_size
radius = self.dim_line_radius
max_adjustment: float = abs(self.arc_angle_span_rad) / 2.0
if start_offset > max_adjustment:
start_offset = 0.0
if end_offset > max_adjustment:
end_offset = 0.0
self.add_arc(
self.center_of_arc,
radius,
start_angle + start_offset,
end_angle - end_offset,
dxfattribs=self.dimension_line.dxfattribs(),
# hidden line detection if text is not placed outside:
remove_hidden_lines=self.remove_hidden_lines_of_dimline,
)
if self.arrows_outside and not arrows.has_ticks:
# add arrow extension lines
start_offset, end_offset = arrow_offset_angles(
arrows.arrow1_name, size, radius
)
self.add_arrow_extension_line(
start_angle - end_offset,
start_angle - start_offset,
)
start_offset, end_offset = arrow_offset_angles(
arrows.arrow1_name, size, radius
)
self.add_arrow_extension_line(
end_angle + start_offset,
end_angle + end_offset,
)
def add_arrow_extension_line(self, start_angle: float, end_angle: float):
self.add_arc(
self.center_of_arc,
self.dim_line_radius,
start_angle=start_angle,
end_angle=end_angle,
dxfattribs=self.dimension_line.dxfattribs(),
)
def add_measurement_text(
self, dim_text: str, pos: Vec2, rotation: float
) -> None:
"""Add measurement text to dimension BLOCK.
Args:
dim_text: dimension text
pos: text location
rotation: text rotation in degrees
"""
attribs = self.measurement.dxfattribs()
self.add_text(dim_text, pos=pos, rotation=rotation, dxfattribs=attribs)
class _AngularCommonBase(_CurvedDimensionLine):
def init_tolerance(
self, scale: float, measurement: Measurement
) -> Tolerance:
return AngularTolerance(
self.dim_style,
cap_height=measurement.text_height,
width_factor=measurement.text_width_factor,
dim_scale=scale,
angle_units=measurement.angle_units,
)
def init_measurement(self, color: int, scale: float) -> Measurement:
return AngleMeasurement(
self.dim_style, self.default_color, self.dim_scale
)
def update_measurement(self) -> None:
self.measurement.update(self.arc_angle_span_rad)
class AngularDimension(_AngularCommonBase):
"""
Angular dimension line renderer. The dimension line is defined by two lines.
Supported render types:
- default location above
- default location center
- user defined location, text aligned with dimension line
- user defined location horizontal text
Args:
dimension: DIMENSION entity
ucs: user defined coordinate system
override: dimension style override management object
"""
# Required defpoints:
# defpoint = start point of 1st leg (group code 10)
# defpoint4 = end point of 1st leg (group code 15)
# defpoint3 = start point of 2nd leg (group code 14)
# defpoint2 = end point of 2nd leg (group code 13)
# defpoint5 = location of dimension line (group code 16)
# unsupported or ignored features (at least by BricsCAD):
# dimtih: text inside horizontal
# dimtoh: text outside horizontal
# dimjust: text position horizontal
# dimdle: dimline extension
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
self.leg1_start = get_required_defpoint(dimension, "defpoint")
self.leg1_end = get_required_defpoint(dimension, "defpoint4")
self.leg2_start = get_required_defpoint(dimension, "defpoint3")
self.leg2_end = get_required_defpoint(dimension, "defpoint2")
self.dim_line_location = get_required_defpoint(dimension, "defpoint5")
super().__init__(dimension, ucs, override)
# The extension line parameters depending on the location of the
# dimension line related to the definition point.
# Detect the extension start point.
# Which definition point is closer to the dimension line:
self.ext1_start = detect_closer_defpoint(
direction=self.ext1_dir,
base=self.dim_line_location,
p1=self.leg1_start,
p2=self.leg1_end,
)
self.ext2_start = detect_closer_defpoint(
direction=self.ext2_dir,
base=self.dim_line_location,
p1=self.leg2_start,
p2=self.leg2_end,
)
def get_defpoints(self) -> list[Vec2]:
return [
self.leg1_start,
self.leg1_end,
self.leg2_start,
self.leg2_end,
self.dim_line_location,
]
def get_center_of_arc(self) -> Vec2:
center = intersection_line_line_2d(
(self.leg1_start, self.leg1_end),
(self.leg2_start, self.leg2_end),
)
if center is None:
logger.warning(
f"Invalid colinear or parallel angle legs found in {self.dimension})"
)
# This case can not be created by the GUI in BricsCAD, but DXF
# files can contain any shit!
# The interpolation of the end-points is an arbitrary choice and
# maybe not the best choice!
center = self.leg1_end.lerp(self.leg2_end)
return center
def get_dim_line_radius(self) -> float:
return (self.dim_line_location - self.center_of_arc).magnitude
def get_ext1_dir(self) -> Vec2:
center = self.center_of_arc
start = (
self.leg1_end
if self.leg1_start.isclose(center)
else self.leg1_start
)
return (start - center).normalize()
def get_ext2_dir(self) -> Vec2:
center = self.center_of_arc
start = (
self.leg2_end
if self.leg2_start.isclose(center)
else self.leg2_start
)
return (start - center).normalize()
class Angular3PDimension(_AngularCommonBase):
"""
Angular dimension line renderer. The dimension line is defined by three
points.
Supported render types:
- default location above
- default location center
- user defined location, text aligned with dimension line
- user defined location horizontal text
Args:
dimension: DIMENSION entity
ucs: user defined coordinate system
override: dimension style override management object
"""
# Required defpoints:
# defpoint = location of dimension line (group code 10)
# defpoint2 = 1st leg (group code 13)
# defpoint3 = 2nd leg (group code 14)
# defpoint4 = center of angle (group code 15)
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
self.dim_line_location = get_required_defpoint(dimension, "defpoint")
self.leg1_start = get_required_defpoint(dimension, "defpoint2")
self.leg2_start = get_required_defpoint(dimension, "defpoint3")
self.center_of_arc = get_required_defpoint(dimension, "defpoint4")
super().__init__(dimension, ucs, override)
self.ext1_start = self.leg1_start
self.ext2_start = self.leg2_start
def get_defpoints(self) -> list[Vec2]:
return [
self.dim_line_location,
self.leg1_start,
self.leg2_start,
self.center_of_arc,
]
def get_center_of_arc(self) -> Vec2:
return self.center_of_arc
def get_dim_line_radius(self) -> float:
return (self.dim_line_location - self.center_of_arc).magnitude
def get_ext1_dir(self) -> Vec2:
return (self.leg1_start - self.center_of_arc).normalize()
def get_ext2_dir(self) -> Vec2:
return (self.leg2_start - self.center_of_arc).normalize()
class ArcLengthMeasurement(LengthMeasurement):
def format_text(self, value: float) -> str:
text = format_text(
value=value,
dimrnd=self.text_round,
dimdec=self.decimal_places,
dimzin=self.suppress_zeros,
dimdsep=self.decimal_separator,
)
if self.has_arc_length_prefix:
text = ARC_PREFIX + text
if self.text_post_process_format:
text = apply_dimpost(text, self.text_post_process_format)
return text
class ArcLengthDimension(_CurvedDimensionLine):
"""Arc length dimension line renderer.
Requires DXF R2004.
Supported render types:
- default location above
- default location center
- user defined location, text aligned with dimension line
- user defined location horizontal text
Args:
dimension: DXF entity DIMENSION
ucs: user defined coordinate system
override: dimension style override management object
"""
# Required defpoints:
# defpoint = location of dimension line (group code 10)
# defpoint2 = 1st arc point (group code 13)
# defpoint3 = 2nd arc point (group code 14)
# defpoint4 = center of arc (group code 15)
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
self.dim_line_location = get_required_defpoint(dimension, "defpoint")
self.leg1_start = get_required_defpoint(dimension, "defpoint2")
self.leg2_start = get_required_defpoint(dimension, "defpoint3")
self.center_of_arc = get_required_defpoint(dimension, "defpoint4")
self.arc_radius = (self.leg1_start - self.center_of_arc).magnitude
super().__init__(dimension, ucs, override)
self.ext1_start = self.leg1_start
self.ext2_start = self.leg2_start
def get_defpoints(self) -> list[Vec2]:
return [
self.dim_line_location,
self.leg1_start,
self.leg2_start,
self.center_of_arc,
]
def init_measurement(self, color: int, scale: float) -> Measurement:
return ArcLengthMeasurement(
self.dim_style, self.default_color, self.dim_scale
)
def get_center_of_arc(self) -> Vec2:
return self.center_of_arc
def get_dim_line_radius(self) -> float:
return (self.dim_line_location - self.center_of_arc).magnitude
def get_ext1_dir(self) -> Vec2:
return (self.leg1_start - self.center_of_arc).normalize()
def get_ext2_dir(self) -> Vec2:
return (self.leg2_start - self.center_of_arc).normalize()
def update_measurement(self) -> None:
angle = arc_angle_span_rad(self.start_angle_rad, self.end_angle_rad)
arc_length = angle * self.arc_radius
self.measurement.update(arc_length)
def detect_closer_defpoint(
direction: Vec2, base: Vec2, p1: Vec2, p2: Vec2
) -> Vec2:
# Calculate the projected distance onto the (normalized) direction vector:
d0 = direction.dot(base)
d1 = direction.dot(p1)
d2 = direction.dot(p2)
# Which defpoint is closer to the base point (d0)?
if abs(d1 - d0) <= abs(d2 - d0):
return p1
return p2
def arrow_offset_angles(
arrow_name: str, size: float, radius: float
) -> tuple[float, float]:
start_offset: float = 0.0
end_offset: float = size / radius
length = arrow_length(arrow_name, size)
if length > 0.0:
start_offset = length / radius
end_offset *= 2.0
return start_offset, end_offset

View File

@@ -0,0 +1,175 @@
# Copyright (c) 2018-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.math import Vec2, UCS
from ezdxf.entities.dimstyleoverride import DimStyleOverride
from .dim_radius import (
RadiusDimension,
add_center_mark,
Measurement,
RadiusMeasurement,
)
if TYPE_CHECKING:
from ezdxf.entities import Dimension
PREFIX = "Ø"
class DiameterDimension(RadiusDimension):
"""
Diameter dimension line renderer.
Supported render types:
- default location inside, text aligned with diameter dimension line
- default location inside horizontal text
- default location outside, text aligned with diameter dimension line
- default location outside horizontal text
- user defined location, text aligned with diameter dimension line
- user defined location horizontal text
Args:
dimension: DXF entity DIMENSION
ucs: user defined coordinate system
override: dimension style override management object
"""
def init_measurement(self, color: int, scale: float) -> Measurement:
return RadiusMeasurement(self.dim_style, color, scale, PREFIX)
def _center(self):
return Vec2(self.dimension.dxf.defpoint).lerp(
self.dimension.dxf.defpoint4
)
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
# Diameter dimension has the same styles for inside text as radius dimension, except for the
# measurement text
super().__init__(dimension, ucs, override)
self.point_on_circle2 = Vec2(self.dimension.dxf.defpoint)
def add_text(
self, text: str, pos: Vec2, rotation: float, dxfattribs
) -> None:
# escape diameter sign
super().add_text(text.replace(PREFIX, "%%c"), pos, rotation, dxfattribs)
def get_default_text_location(self) -> Vec2:
"""Returns default text midpoint based on `text_valign` and
`text_outside`.
"""
measurement = self.measurement
if measurement.text_is_outside and measurement.text_outside_horizontal:
return super().get_default_text_location()
text_direction = Vec2.from_deg_angle(measurement.text_rotation)
vertical_direction = text_direction.orthogonal(ccw=True)
vertical_distance = measurement.text_vertical_distance()
if measurement.text_is_inside:
text_midpoint = self.center
else:
hdist = (
self._total_text_width / 2.0
+ self.arrows.arrow_size
+ measurement.text_gap
)
text_midpoint = self.point_on_circle + (self.dim_line_vec * hdist)
return text_midpoint + (vertical_direction * vertical_distance)
def _add_arrow_1(self, rotate=False):
if not self.arrows.suppress1:
return self.add_arrow(self.point_on_circle, rotate=rotate)
else:
return self.point_on_circle
def _add_arrow_2(self, rotate=True):
if not self.arrows.suppress2:
return self.add_arrow(self.point_on_circle2, rotate=rotate)
else:
return self.point_on_circle2
def render_default_location(self) -> None:
"""Create dimension geometry at the default dimension line locations."""
measurement = self.measurement
if measurement.text_is_outside:
connection_point1 = self._add_arrow_1(rotate=True)
if self.outside_text_force_dimline:
self.add_diameter_dim_line(
connection_point1, self._add_arrow_2()
)
else:
add_center_mark(self)
if measurement.text_outside_horizontal:
self.add_horiz_ext_line_default(connection_point1)
else:
self.add_radial_ext_line_default(connection_point1)
else:
connection_point1 = self._add_arrow_1(rotate=False)
if measurement.text_movement_rule == 1:
# move text, add leader -> dimline from text to point on circle
self.add_radial_dim_line_from_text(
self.center, connection_point1
)
add_center_mark(self)
else:
# dimline from center to point on circle
self.add_diameter_dim_line(
connection_point1, self._add_arrow_2()
)
def render_user_location(self) -> None:
"""Create dimension geometry at user defined dimension locations."""
measurement = self.measurement
preserve_outside = measurement.text_is_outside
leader = measurement.text_movement_rule != 2
if not leader:
# render dimension line like text inside
measurement.text_is_outside = False
# add arrow symbol (block references)
connection_point1 = self._add_arrow_1(
rotate=measurement.text_is_outside
)
if measurement.text_is_outside:
if self.outside_text_force_dimline:
self.add_radial_dim_line(self.point_on_circle)
else:
add_center_mark(self)
if measurement.text_outside_horizontal:
self.add_horiz_ext_line_user(connection_point1)
else:
self.add_radial_ext_line_user(connection_point1)
else:
if measurement.text_inside_horizontal:
self.add_horiz_ext_line_user(connection_point1)
else:
if measurement.text_movement_rule == 2: # move text, no leader!
# dimline across the circle
connection_point2 = self._add_arrow_2(rotate=True)
self.add_line(
connection_point1,
connection_point2,
dxfattribs=self.dimension_line.dxfattribs(),
remove_hidden_lines=True,
)
else:
# move text, add leader -> dimline from text to point on circle
self.add_radial_dim_line_from_text(
measurement.user_location, connection_point1
)
add_center_mark(self)
measurement.text_is_outside = preserve_outside
def add_diameter_dim_line(self, start: Vec2, end: Vec2) -> None:
"""Add diameter dimension line."""
attribs = self.dimension_line.dxfattribs()
self.add_line(start, end, dxfattribs=attribs, remove_hidden_lines=True)

View File

@@ -0,0 +1,649 @@
# Copyright (c) 2018-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, cast, Optional
import math
from ezdxf.math import Vec3, Vec2, UVec, ConstructionRay, UCS
from ezdxf.render.arrows import ARROWS, connection_point
from ezdxf.entities.dimstyleoverride import DimStyleOverride
from .dim_base import (
BaseDimensionRenderer,
LengthMeasurement,
Measurement,
compile_mtext,
order_leader_points,
)
if TYPE_CHECKING:
from ezdxf.entities import Dimension
from ezdxf.eztypes import GenericLayoutType
class LinearDimension(BaseDimensionRenderer):
"""Linear dimension line renderer, used for horizontal, vertical, rotated
and aligned DIMENSION entities.
Args:
dimension: DXF entity DIMENSION
ucs: user defined coordinate system
override: dimension style override management object
"""
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
super().__init__(dimension, ucs, override)
measurement = self.measurement
if measurement.text_movement_rule == 0:
# moves the dimension line with dimension text, this makes no sense
# for ezdxf (just set `base` argument)
measurement.text_movement_rule = 2
self.oblique_angle: float = self.dimension.get_dxf_attrib(
"oblique_angle", 90
)
self.dim_line_angle: float = self.dimension.get_dxf_attrib("angle", 0)
self.dim_line_angle_rad: float = math.radians(self.dim_line_angle)
self.ext_line_angle: float = self.dim_line_angle + self.oblique_angle
self.ext_line_angle_rad: float = math.radians(self.ext_line_angle)
# text is aligned to dimension line
measurement.text_rotation = self.dim_line_angle
# text above extension line, is always aligned with extension lines
if measurement.text_halign in (3, 4):
measurement.text_rotation = self.ext_line_angle
self.ext1_line_start = Vec2(self.dimension.dxf.defpoint2)
self.ext2_line_start = Vec2(self.dimension.dxf.defpoint3)
ext1_ray = ConstructionRay(
self.ext1_line_start, angle=self.ext_line_angle_rad
)
ext2_ray = ConstructionRay(
self.ext2_line_start, angle=self.ext_line_angle_rad
)
dim_line_ray = ConstructionRay(
self.dimension.dxf.defpoint, angle=self.dim_line_angle_rad
)
self.dim_line_start: Vec2 = dim_line_ray.intersect(ext1_ray)
self.dim_line_end: Vec2 = dim_line_ray.intersect(ext2_ray)
self.dim_line_center: Vec2 = self.dim_line_start.lerp(self.dim_line_end)
if self.dim_line_start == self.dim_line_end:
self.dim_line_vec = Vec2.from_angle(self.dim_line_angle_rad)
else:
self.dim_line_vec = (
self.dim_line_end - self.dim_line_start
).normalize()
# set dimension defpoint to expected location - 3D vertex required!
self.dimension.dxf.defpoint = Vec3(self.dim_line_start)
raw_measurement = (self.dim_line_end - self.dim_line_start).magnitude
measurement.update(raw_measurement)
# only for linear dimension in multi point mode
self.multi_point_mode = self.dim_style.pop("multi_point_mode", False)
# 1 .. move wide text up
# 2 .. move wide text down
# None .. ignore
self.move_wide_text: Optional[bool] = self.dim_style.pop(
"move_wide_text", None
)
# actual text width in drawing units
self._total_text_width: float = 0
# arrows
self.required_arrows_space: float = (
2 * self.arrows.arrow_size + measurement.text_gap
)
self.arrows_outside: bool = self.required_arrows_space > raw_measurement
# text location and rotation
if measurement.text:
# text width and required space
self._total_text_width = self.total_text_width()
if self.tol.has_limits:
# limits show the upper and lower limit of the measurement as
# stacked values and with the size of tolerances
self.tol.update_limits(self.measurement.value)
if self.multi_point_mode:
# ezdxf has total control about vertical text position in multi
# point mode
measurement.text_vertical_position = 0.0
if (
measurement.text_valign == 0
and abs(measurement.text_vertical_position) < 0.7
):
# vertical centered text needs also space for arrows
required_space = (
self._total_text_width + 2 * self.arrows.arrow_size
)
else:
required_space = self._total_text_width
measurement.is_wide_text = required_space > raw_measurement
if not measurement.force_text_inside:
# place text outside if wide text and not forced inside
measurement.text_is_outside = measurement.is_wide_text
elif measurement.is_wide_text and measurement.text_halign < 3:
# center wide text horizontal
measurement.text_halign = 0
# use relative text shift to move wide text up or down in multi
# point mode
if (
self.multi_point_mode
and measurement.is_wide_text
and self.move_wide_text
):
shift_value = measurement.text_height + measurement.text_gap
if self.move_wide_text == 1: # move text up
measurement.text_shift_v = shift_value
if (
measurement.vertical_placement == -1
): # text below dimension line
# shift again
measurement.text_shift_v += shift_value
elif self.move_wide_text == 2: # move text down
measurement.text_shift_v = -shift_value
if (
measurement.vertical_placement == 1
): # text above dimension line
# shift again
measurement.text_shift_v -= shift_value
# get final text location - no altering after this line
measurement.text_location = self.get_text_location()
# text rotation override
rotation: float = measurement.text_rotation
if measurement.user_text_rotation is not None:
rotation = measurement.user_text_rotation
elif (
measurement.text_is_outside
and measurement.text_outside_horizontal
):
rotation = 0.0
elif (
measurement.text_is_inside
and measurement.text_inside_horizontal
):
rotation = 0.0
measurement.text_rotation = rotation
text_box = self.init_text_box()
self.geometry.set_text_box(text_box)
if measurement.has_leader:
p1, p2, *_ = text_box.corners
self.leader1, self.leader2 = order_leader_points(
self.dim_line_center, p1, p2
)
# not exact what BricsCAD (AutoCAD) expect, but close enough
self.dimension.dxf.text_midpoint = self.leader1
else:
# write final text location into DIMENSION entity
self.dimension.dxf.text_midpoint = measurement.text_location
def init_measurement(self, color: int, scale: float) -> Measurement:
return LengthMeasurement(
self.dim_style, self.default_color, self.dim_scale
)
def render(self, block: GenericLayoutType) -> None:
"""Main method to create dimension geometry of basic DXF entities in the
associated BLOCK layout.
Args:
block: target BLOCK for rendering
"""
# call required to setup some requirements
super().render(block)
# add extension line 1
ext_lines = self.extension_lines
measurement = self.measurement
if not ext_lines.suppress1:
above_ext_line1 = measurement.text_halign == 3
start, end = self.extension_line_points(
self.ext1_line_start, self.dim_line_start, above_ext_line1
)
self.add_line(start, end, dxfattribs=ext_lines.dxfattribs(1))
# add extension line 2
if not ext_lines.suppress2:
above_ext_line2 = measurement.text_halign == 4
start, end = self.extension_line_points(
self.ext2_line_start, self.dim_line_end, above_ext_line2
)
self.add_line(start, end, dxfattribs=ext_lines.dxfattribs(2))
# add arrow symbols (block references), also adjust dimension line start
# and end point
dim_line_start, dim_line_end = self.add_arrows()
# add dimension line
self.add_dimension_line(dim_line_start, dim_line_end)
# add measurement text as last entity to see text fill properly
if measurement.text:
if self.geometry.supports_dxf_r2000:
text = compile_mtext(measurement, self.tol)
else:
text = measurement.text
self.add_measurement_text(
text, measurement.text_location, measurement.text_rotation
)
if measurement.has_leader:
self.add_leader(
self.dim_line_center, self.leader1, self.leader2
)
# add POINT entities at definition points
self.geometry.add_defpoints(
[self.dim_line_start, self.ext1_line_start, self.ext2_line_start]
)
def get_text_location(self) -> Vec2:
"""Get text midpoint in UCS from user defined location or default text
location.
"""
# apply relative text shift as user location override without leader
measurement = self.measurement
if measurement.has_relative_text_movement:
location = self.default_text_location()
location = measurement.apply_text_shift(
location, measurement.text_rotation
)
self.location_override(location)
if measurement.user_location is not None:
location = measurement.user_location
if measurement.relative_user_location:
location = self.dim_line_center + location
# define overridden text location as outside
measurement.text_is_outside = True
else:
location = self.default_text_location()
return location
def default_text_location(self) -> Vec2:
"""Calculate default text location in UCS based on `self.text_halign`,
`self.text_valign` and `self.text_outside`
"""
start = self.dim_line_start
end = self.dim_line_end
measurement = self.measurement
halign = measurement.text_halign
# positions the text above and aligned with the first/second extension line
ext_lines = self.extension_lines
if halign in (3, 4):
# horizontal location
hdist = measurement.text_gap + measurement.text_height / 2.0
hvec = self.dim_line_vec * hdist
location = (start if halign == 3 else end) - hvec
# vertical location
vdist = ext_lines.extension_above + self._total_text_width / 2.0
location += Vec2.from_deg_angle(self.ext_line_angle).normalize(
vdist
)
else:
# relocate outside text to center location
if measurement.text_is_outside:
halign = 0
if halign == 0:
location = self.dim_line_center # center of dimension line
else:
hdist = (
self._total_text_width / 2.0
+ self.arrows.arrow_size
+ measurement.text_gap
)
if (
halign == 1
): # positions the text next to the first extension line
location = start + (self.dim_line_vec * hdist)
else: # positions the text next to the second extension line
location = end - (self.dim_line_vec * hdist)
if measurement.text_is_outside: # move text up
vdist = (
ext_lines.extension_above
+ measurement.text_gap
+ measurement.text_height / 2.0
)
else:
# distance from extension line to text midpoint
vdist = measurement.text_vertical_distance()
location += self.dim_line_vec.orthogonal().normalize(vdist)
return location
def add_arrows(self) -> tuple[Vec2, Vec2]:
"""
Add arrows or ticks to dimension.
Returns: dimension line connection points
"""
arrows = self.arrows
attribs = arrows.dxfattribs()
start = self.dim_line_start
end = self.dim_line_end
outside = self.arrows_outside
arrow1 = not arrows.suppress1
arrow2 = not arrows.suppress2
if arrows.tick_size > 0.0: # oblique stroke, but double the size
if arrow1:
self.add_blockref(
ARROWS.oblique,
insert=start,
rotation=self.dim_line_angle,
scale=arrows.tick_size * 2.0,
dxfattribs=attribs,
)
if arrow2:
self.add_blockref(
ARROWS.oblique,
insert=end,
rotation=self.dim_line_angle,
scale=arrows.tick_size * 2.0,
dxfattribs=attribs,
)
else:
scale = arrows.arrow_size
start_angle = self.dim_line_angle + 180.0
end_angle = self.dim_line_angle
if outside:
start_angle, end_angle = end_angle, start_angle
if arrow1:
self.add_blockref(
arrows.arrow1_name,
insert=start,
scale=scale,
rotation=start_angle,
dxfattribs=attribs,
) # reverse
if arrow2:
self.add_blockref(
arrows.arrow2_name,
insert=end,
scale=scale,
rotation=end_angle,
dxfattribs=attribs,
)
if not outside:
# arrows inside extension lines: adjust connection points for
# the remaining dimension line
if arrow1:
start = connection_point(
arrows.arrow1_name, start, scale, start_angle
)
if arrow2:
end = connection_point(
arrows.arrow2_name, end, scale, end_angle
)
else:
# add additional extension lines to arrows placed outside of
# dimension extension lines
self.add_arrow_extension_lines()
return start, end
def add_arrow_extension_lines(self):
"""Add extension lines to arrows placed outside of dimension extension
lines. Called by `self.add_arrows()`.
"""
def has_arrow_extension(name: str) -> bool:
return (
(name is not None)
and (name in ARROWS)
and (name not in ARROWS.ORIGIN_ZERO)
)
attribs = self.dimension_line.dxfattribs()
arrows = self.arrows
start = self.dim_line_start
end = self.dim_line_end
arrow_size = arrows.arrow_size
if not arrows.suppress1 and has_arrow_extension(arrows.arrow1_name):
self.add_line(
start - self.dim_line_vec * arrow_size,
start - self.dim_line_vec * (2 * arrow_size),
dxfattribs=attribs,
)
if not arrows.suppress2 and has_arrow_extension(arrows.arrow2_name):
self.add_line(
end + self.dim_line_vec * arrow_size,
end + self.dim_line_vec * (2 * arrow_size),
dxfattribs=attribs,
)
def add_measurement_text(
self, dim_text: str, pos: Vec2, rotation: float
) -> None:
"""Add measurement text to dimension BLOCK.
Args:
dim_text: dimension text
pos: text location
rotation: text rotation in degrees
"""
self.add_text(dim_text, pos, rotation, dict())
def add_dimension_line(self, start: Vec2, end: Vec2) -> None:
"""Add dimension line to dimension BLOCK, adds extension DIMDLE if
required, and uses DIMSD1 or DIMSD2 to suppress first or second part of
dimension line. Removes line parts hidden by dimension text.
Args:
start: dimension line start
end: dimension line end
"""
dim_line = self.dimension_line
arrows = self.arrows
extension = self.dim_line_vec * dim_line.extension
ticks = arrows.has_ticks
if ticks or ARROWS.has_extension_line(arrows.arrow1_name):
start = start - extension
if ticks or ARROWS.has_extension_line(arrows.arrow2_name):
end = end + extension
attribs = dim_line.dxfattribs()
if dim_line.suppress1 or dim_line.suppress2:
# TODO: results not as expected, but good enough
# center should take into account text location
center = start.lerp(end)
if not dim_line.suppress1:
self.add_line(
start, center, dxfattribs=attribs, remove_hidden_lines=True
)
if not dim_line.suppress2:
self.add_line(
center, end, dxfattribs=attribs, remove_hidden_lines=True
)
else:
self.add_line(
start, end, dxfattribs=attribs, remove_hidden_lines=True
)
def extension_line_points(
self, start: Vec2, end: Vec2, text_above_extline=False
) -> tuple[Vec2, Vec2]:
"""Adjust start and end point of extension line by dimension variables
DIMEXE, DIMEXO, DIMEXFIX, DIMEXLEN.
Args:
start: start point of extension line (measurement point)
end: end point at dimension line
text_above_extline: True if text is above and aligned with extension line
Returns: adjusted start and end point
"""
if start == end:
direction = Vec2.from_deg_angle(self.ext_line_angle)
else:
direction = (end - start).normalize()
if self.extension_lines.has_fixed_length:
start = end - (direction * self.extension_lines.length_below)
else:
start = start + direction * self.extension_lines.offset
extension = self.extension_lines.extension_above
if text_above_extline:
extension += self._total_text_width
end = end + direction * extension
return start, end
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.
"""
def from_ucs(attr, func):
point = self.dimension.get_dxf_attrib(attr)
self.dimension.set_dxf_attrib(attr, func(point))
ucs = self.geometry.ucs
from_ucs("defpoint", ucs.to_wcs)
from_ucs("defpoint2", ucs.to_wcs)
from_ucs("defpoint3", ucs.to_wcs)
from_ucs("text_midpoint", ucs.to_ocs)
self.dimension.dxf.angle = ucs.to_ocs_angle_deg(
self.dimension.dxf.angle
)
CAN_SUPPRESS_ARROW1 = {
ARROWS.dot,
ARROWS.dot_small,
ARROWS.dot_blank,
ARROWS.origin_indicator,
ARROWS.origin_indicator_2,
ARROWS.dot_smallblank,
ARROWS.none,
ARROWS.oblique,
ARROWS.box_filled,
ARROWS.box,
ARROWS.integral,
ARROWS.architectural_tick,
}
def sort_projected_points(
points: Iterable[UVec], angle: float = 0
) -> list[Vec2]:
direction = Vec2.from_deg_angle(angle)
projected_vectors = [(direction.project(Vec2(p)), p) for p in points]
return [p for projection, p in sorted(projected_vectors)]
def multi_point_linear_dimension(
layout: GenericLayoutType,
base: UVec,
points: Iterable[UVec],
angle: float = 0,
ucs: Optional[UCS] = None,
avoid_double_rendering: bool = True,
dimstyle: str = "EZDXF",
override: Optional[dict] = None,
dxfattribs=None,
discard=False,
) -> None:
"""Creates multiple DIMENSION entities for each point pair in `points`.
Measurement points will be sorted by appearance on the dimension line
vector.
Args:
layout: target layout (model space, paper space or block)
base: base point, any point on the dimension line vector will do
points: iterable of measurement points
angle: dimension line rotation in degrees (0=horizontal, 90=vertical)
ucs: user defined coordinate system
avoid_double_rendering: removes first extension line and arrow of
following DIMENSION entity
dimstyle: dimension style name
override: dictionary of overridden dimension style attributes
dxfattribs: DXF attributes for DIMENSION entities
discard: discard rendering result for friendly CAD applications like
BricsCAD to get a native and likely better rendering result.
(does not work with AutoCAD)
"""
def suppress_arrow1(dimstyle_override) -> bool:
arrow_name1, arrow_name2 = dimstyle_override.get_arrow_names()
if (arrow_name1 is None) or (arrow_name1 in CAN_SUPPRESS_ARROW1):
return True
else:
return False
points = sort_projected_points(points, angle)
base = Vec2(base)
override = override or {}
override["dimtix"] = 1 # do not place measurement text outside
override["dimtvp"] = 0 # do not place measurement text outside
override["multi_point_mode"] = True
# 1 .. move wide text up; 2 .. move wide text down; None .. ignore
# moving text down, looks best combined with text fill bg: DIMTFILL = 1
move_wide_text = 1
_suppress_arrow1 = False
first_run = True
for p1, p2 in zip(points[:-1], points[1:]):
_override = dict(override)
_override["move_wide_text"] = move_wide_text
if avoid_double_rendering and not first_run:
_override["dimse1"] = 1
_override["suppress_arrow1"] = _suppress_arrow1
style = layout.add_linear_dim(
Vec3(base),
Vec3(p1),
Vec3(p2),
angle=angle,
dimstyle=dimstyle,
override=_override,
dxfattribs=dxfattribs,
)
if first_run:
_suppress_arrow1 = suppress_arrow1(style)
renderer = cast(LinearDimension, style.render(ucs, discard=discard))
if renderer.measurement.is_wide_text:
# after wide text switch moving direction
if move_wide_text == 1:
move_wide_text = 2
else:
move_wide_text = 1
else: # reset to move text up
move_wide_text = 1
first_run = False

View File

@@ -0,0 +1,209 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import logging
import math
from ezdxf.math import Vec2, UCS, NULLVEC
from ezdxf.lldxf import const
from ezdxf.entities import DimStyleOverride, Dimension
from .dim_base import (
BaseDimensionRenderer,
get_required_defpoint,
compile_mtext,
)
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
__all__ = ["OrdinateDimension"]
logger = logging.getLogger("ezdxf")
class OrdinateDimension(BaseDimensionRenderer):
# Required defpoints:
# defpoint = origin (group code 10)
# defpoint2 = feature location (group code 13)
# defpoint3 = end of leader (group code 14)
# user text location is ignored (group code 11) and replaced by default
# location calculated by the ezdxf renderer:
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
# The local coordinate system is defined by origin and the
# horizontal_direction in OCS:
self.origin_ocs: Vec2 = get_required_defpoint(dimension, "defpoint")
self.feature_location_ocs: Vec2 = get_required_defpoint(
dimension, "defpoint2"
)
self.end_of_leader_ocs: Vec2 = get_required_defpoint(
dimension, "defpoint3"
)
# Horizontal direction in clockwise orientation, see DXF reference
# for group code 51:
self.horizontal_dir = -dimension.dxf.get("horizontal_direction", 0.0)
self.rotation = math.radians(self.horizontal_dir)
self.local_x_axis = Vec2.from_angle(self.rotation)
self.local_y_axis = self.local_x_axis.orthogonal()
self.x_type = bool( # x-type is set!
dimension.dxf.get("dimtype", 0) & const.DIM_ORDINATE_TYPE
)
super().__init__(dimension, ucs, override)
# Measurement directions can be opposite to local x- or y-axis
self.leader_vec_ocs = self.end_of_leader_ocs - self.feature_location_ocs
leader_x_vec = self.local_x_axis.project(self.leader_vec_ocs)
leader_y_vec = self.local_y_axis.project(self.leader_vec_ocs)
try:
self.measurement_direction: Vec2 = leader_x_vec.normalize()
except ZeroDivisionError:
self.measurement_direction = Vec2(1, 0)
try:
self.measurement_orthogonal: Vec2 = leader_y_vec.normalize()
except ZeroDivisionError:
self.measurement_orthogonal = Vec2(0, 1)
if not self.x_type:
self.measurement_direction, self.measurement_orthogonal = (
self.measurement_orthogonal,
self.measurement_direction,
)
self.update_measurement()
if self.tol.has_limits:
self.tol.update_limits(self.measurement.value)
# Text width and -height is required first, text location and -rotation
# are not valid yet:
self.text_box = self.init_text_box()
# Set text location and rotation:
self.measurement.text_location = self.get_default_text_location()
self.measurement.text_rotation = self.get_default_text_rotation()
# Update text box location and -rotation:
self.text_box.center = self.measurement.text_location
self.text_box.angle = self.measurement.text_rotation
self.geometry.set_text_box(self.text_box)
# Update final text location in the DIMENSION entity:
self.dimension.dxf.text_midpoint = self.measurement.text_location
def get_default_text_location(self) -> Vec2:
if self.x_type:
text_vertical_shifting_dir = -self.local_x_axis
else:
text_vertical_shifting_dir = self.local_y_axis
# user text location is not supported and ignored:
return (
self.end_of_leader_ocs
+ self.measurement_orthogonal * (self.text_box.width * 0.5)
+ text_vertical_shifting_dir
* self.measurement.text_vertical_distance()
)
def get_default_text_rotation(self) -> float:
# user text rotation is not supported and ignored:
return (90.0 if self.x_type else 0.0) + self.horizontal_dir
def update_measurement(self) -> None:
feature_location_vec: Vec2 = self.feature_location_ocs - self.origin_ocs
# ordinate measurement is always absolute:
self.measurement.update(
self.local_x_axis.project(feature_location_vec).magnitude
if self.x_type
else self.local_y_axis.project(feature_location_vec).magnitude
)
def get_defpoints(self) -> list[Vec2]:
return [
self.origin_ocs,
self.feature_location_ocs,
self.end_of_leader_ocs,
]
def transform_ucs_to_wcs(self) -> None:
"""Transforms dimension definition points into WCS or if required into
OCS.
"""
def from_ucs(attr, func):
point = dxf.get(attr, NULLVEC)
dxf.set(attr, func(point))
dxf = self.dimension.dxf
ucs = self.geometry.ucs
from_ucs("defpoint", ucs.to_wcs)
from_ucs("defpoint2", ucs.to_wcs)
from_ucs("defpoint3", ucs.to_wcs)
from_ucs("text_midpoint", ucs.to_ocs)
# Horizontal direction in clockwise orientation, see DXF reference
# for group code 51:
dxf.horizontal_direction = -ucs.to_ocs_angle_deg(self.horizontal_dir)
def render(self, block: GenericLayoutType) -> None:
"""Main method to create dimension geometry of basic DXF entities in the
associated BLOCK layout.
Args:
block: target BLOCK for rendering
"""
super().render(block)
self.add_ordinate_leader()
measurement = self.measurement
if measurement.text:
if self.geometry.supports_dxf_r2000:
text = compile_mtext(measurement, self.tol)
else:
text = measurement.text
self.add_measurement_text(
text, measurement.text_location, measurement.text_rotation
)
self.geometry.add_defpoints(self.get_defpoints())
def add_ordinate_leader(self) -> None:
# DXF attributes from first extension line not from dimension line!
attribs = self.extension_lines.dxfattribs(1)
# The ordinate leader is normal to the measurement direction.
# leader direction and text direction:
direction = self.measurement_orthogonal
leg_size = self.arrows.arrow_size * 2.0
# /---1---TEXT
# x----0----/
# d0 = distance from feature location (x) to 1st upward junction
d0 = direction.project(self.leader_vec_ocs).magnitude - 2.0 * leg_size
start0 = (
self.feature_location_ocs + direction * self.extension_lines.offset
)
end0 = self.feature_location_ocs + direction * max(leg_size, d0)
start1 = self.end_of_leader_ocs - direction * leg_size
end1 = self.end_of_leader_ocs
if self.measurement.vertical_placement != 0:
end1 += direction * self.text_box.width
self.add_line(start0, end0, dxfattribs=attribs)
self.add_line(end0, start1, dxfattribs=attribs)
self.add_line(start1, end1, dxfattribs=attribs)
def add_measurement_text(
self, dim_text: str, pos: Vec2, rotation: float
) -> None:
"""Add measurement text to dimension BLOCK.
Args:
dim_text: dimension text
pos: text location
rotation: text rotation in degrees
"""
attribs = self.measurement.dxfattribs()
self.add_text(dim_text, pos=pos, rotation=rotation, dxfattribs=attribs)

View File

@@ -0,0 +1,488 @@
# Copyright (c) 2018-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.math import Vec2, UCS, UVec
from ezdxf.tools import normalize_text_angle
from ezdxf.render.arrows import ARROWS, connection_point
from ezdxf.entities.dimstyleoverride import DimStyleOverride
from ezdxf.lldxf.const import DXFInternalEzdxfError
from ezdxf.render.dim_base import (
BaseDimensionRenderer,
Measurement,
LengthMeasurement,
compile_mtext,
)
if TYPE_CHECKING:
from ezdxf.entities import Dimension
from ezdxf.eztypes import GenericLayoutType
class RadiusMeasurement(LengthMeasurement):
def __init__(
self, dim_style: DimStyleOverride, color: int, scale: float, prefix: str
):
super().__init__(dim_style, color, scale)
self.text_prefix = prefix
def text_override(self, measurement: float) -> str:
text = super().text_override(measurement)
if text and text[0] != self.text_prefix:
text = self.text_prefix + text
return text
class RadiusDimension(BaseDimensionRenderer):
"""
Radial dimension line renderer.
Supported render types:
- default location inside, text aligned with radial dimension line
- default location inside horizontal text
- default location outside, text aligned with radial dimension line
- default location outside horizontal text
- user defined location, text aligned with radial dimension line
- user defined location horizontal text
Args:
dimension: DXF entity DIMENSION
ucs: user defined coordinate system
override: dimension style override management object
"""
# Super class of DiameterDimension
def _center(self):
return Vec2(self.dimension.dxf.defpoint)
def __init__(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
super().__init__(dimension, ucs, override)
dimtype = self.dimension.dimtype
measurement = self.measurement
if dimtype == 3:
self.is_diameter_dim = True
elif dimtype == 4:
self.is_radius_dim = True
else:
raise DXFInternalEzdxfError(f"Invalid dimension type {dimtype}")
self.center: Vec2 = self._center() # override in diameter dimension
self.point_on_circle: Vec2 = Vec2(self.dimension.dxf.defpoint4)
# modify parameters for special scenarios
if measurement.user_location is None:
if (
measurement.text_is_inside
and measurement.text_inside_horizontal
and measurement.text_movement_rule == 1
): # move text, add leader
# use algorithm for user define dimension line location
measurement.user_location = self.center.lerp(
self.point_on_circle
)
measurement.text_valign = 0 # text vertical centered
direction = self.point_on_circle - self.center
self.dim_line_vec = direction.normalize()
self.dim_line_angle = self.dim_line_vec.angle_deg
self.radius = direction.magnitude
# get_measurement() works for radius and diameter dimension
measurement.update(self.dimension.get_measurement())
self.outside_default_distance = self.radius + 2 * self.arrows.arrow_size
self.outside_default_defpoint = self.center + (
self.dim_line_vec * self.outside_default_distance
)
self.outside_text_force_dimline = bool(self.dim_style.get("dimtofl", 1))
# final dimension text (without limits or tolerance)
# default location is outside, if not forced to be inside
measurement.text_is_outside = not measurement.force_text_inside
# text_outside: user defined location, overrides default location
if measurement.user_location is not None:
measurement.text_is_outside = self.is_location_outside(
measurement.user_location
)
self._total_text_width: float = 0.0
if measurement.text:
# text width and required space
self._total_text_width = self.total_text_width()
if self.tol.has_limits:
# limits show the upper and lower limit of the measurement as
# stacked values and with the size of tolerances
self.tol.update_limits(measurement.value)
# default rotation is angle of dimension line, from center to point on circle.
rotation = self.dim_line_angle
if measurement.text_is_outside and measurement.text_outside_horizontal:
rotation = 0.0
elif measurement.text_is_inside and measurement.text_inside_horizontal:
rotation = 0.0
# final absolute text rotation (x-axis=0)
measurement.text_rotation = normalize_text_angle(
rotation, fix_upside_down=True
)
# final text location
measurement.text_location = self.get_text_location()
self.geometry.set_text_box(self.init_text_box())
# write final text location into DIMENSION entity
if measurement.user_location:
self.dimension.dxf.text_midpoint = measurement.user_location
# default locations
elif (
measurement.text_is_outside and measurement.text_outside_horizontal
):
self.dimension.dxf.text_midpoint = self.outside_default_defpoint
else:
self.dimension.dxf.text_midpoint = measurement.text_location
def init_measurement(self, color: int, scale: float) -> Measurement:
return RadiusMeasurement(self.dim_style, color, scale, "R")
def get_text_location(self) -> Vec2:
"""Returns text midpoint from user defined location or default text
location.
"""
if self.measurement.user_location is not None:
return self.get_user_defined_text_location()
else:
return self.get_default_text_location()
def get_default_text_location(self) -> Vec2:
"""Returns default text midpoint based on `text_valign` and
`text_outside`
"""
measurement = self.measurement
if measurement.text_is_outside and measurement.text_outside_horizontal:
hdist = self._total_text_width / 2.0
if (
measurement.vertical_placement == 0
): # shift text horizontal if vertical centered
hdist += self.arrows.arrow_size
angle = self.dim_line_angle % 360.0 # normalize 0 .. 360
if 90.0 < angle <= 270.0:
hdist = -hdist
return self.outside_default_defpoint + Vec2(
(hdist, measurement.text_vertical_distance())
)
text_direction = Vec2.from_deg_angle(measurement.text_rotation)
vertical_direction = text_direction.orthogonal(ccw=True)
vertical_distance = measurement.text_vertical_distance()
if measurement.text_is_inside:
hdist = (self.radius - self.arrows.arrow_size) / 2.0
text_midpoint = self.center + (self.dim_line_vec * hdist)
else:
hdist = (
self._total_text_width / 2.0
+ self.arrows.arrow_size
+ measurement.text_gap
)
text_midpoint = self.point_on_circle + (self.dim_line_vec * hdist)
return text_midpoint + (vertical_direction * vertical_distance)
def get_user_defined_text_location(self) -> Vec2:
"""Returns text midpoint for user defined dimension location."""
measurement = self.measurement
assert isinstance(measurement.user_location, Vec2)
text_outside_horiz = (
measurement.text_is_outside and measurement.text_outside_horizontal
)
text_inside_horiz = (
measurement.text_is_inside and measurement.text_inside_horizontal
)
if text_outside_horiz or text_inside_horiz:
hdist = self._total_text_width / 2.0
if (
measurement.vertical_placement == 0
): # shift text horizontal if vertical centered
hdist += self.arrows.arrow_size
if measurement.user_location.x <= self.point_on_circle.x:
hdist = -hdist
vdist = measurement.text_vertical_distance()
return measurement.user_location + Vec2((hdist, vdist))
else:
text_normal_vec = Vec2.from_deg_angle(
measurement.text_rotation
).orthogonal()
return (
measurement.user_location
+ text_normal_vec * measurement.text_vertical_distance()
)
def is_location_outside(self, location: Vec2) -> bool:
radius = (location - self.center).magnitude
return radius > self.radius
def render(self, block: GenericLayoutType) -> None:
"""Create dimension geometry of basic DXF entities in the associated
BLOCK layout.
"""
# call required to setup some requirements
super().render(block)
measurement = self.measurement
if not self.dimension_line.suppress1:
if measurement.user_location is not None:
self.render_user_location()
else:
self.render_default_location()
# add measurement text as last entity to see text fill properly
if measurement.text:
if self.geometry.supports_dxf_r2000:
text = compile_mtext(self.measurement, self.tol)
else:
text = measurement.text
self.add_measurement_text(
text, measurement.text_location, measurement.text_rotation
)
# add POINT entities at definition points
self.geometry.add_defpoints([self.center, self.point_on_circle])
def render_default_location(self) -> None:
"""Create dimension geometry at the default dimension line locations."""
measurement = self.measurement
if not self.arrows.suppress1:
arrow_connection_point = self.add_arrow(
self.point_on_circle, rotate=measurement.text_is_outside
)
else:
arrow_connection_point = self.point_on_circle
if measurement.text_is_outside:
if self.outside_text_force_dimline:
self.add_radial_dim_line(self.point_on_circle)
else:
add_center_mark(self)
if measurement.text_outside_horizontal:
self.add_horiz_ext_line_default(arrow_connection_point)
else:
self.add_radial_ext_line_default(arrow_connection_point)
else:
if measurement.text_movement_rule == 1:
# move text, add leader -> dimline from text to point on circle
self.add_radial_dim_line_from_text(
self.center.lerp(self.point_on_circle),
arrow_connection_point,
)
add_center_mark(self)
else:
# dimline from center to point on circle
self.add_radial_dim_line(arrow_connection_point)
def render_user_location(self) -> None:
"""Create dimension geometry at user defined dimension locations."""
measurement = self.measurement
preserve_outside = measurement.text_is_outside
leader = measurement.text_movement_rule != 2
if not leader:
measurement.text_is_outside = (
False # render dimension line like text inside
)
# add arrow symbol (block references)
if not self.arrows.suppress1:
arrow_connection_point = self.add_arrow(
self.point_on_circle, rotate=measurement.text_is_outside
)
else:
arrow_connection_point = self.point_on_circle
if measurement.text_is_outside:
if self.outside_text_force_dimline:
self.add_radial_dim_line(self.point_on_circle)
else:
add_center_mark(self)
if measurement.text_outside_horizontal:
self.add_horiz_ext_line_user(arrow_connection_point)
else:
self.add_radial_ext_line_user(arrow_connection_point)
else:
if measurement.text_inside_horizontal:
self.add_horiz_ext_line_user(arrow_connection_point)
else:
if measurement.text_movement_rule == 2: # move text, no leader!
# dimline from center to point on circle
self.add_radial_dim_line(arrow_connection_point)
else:
# move text, add leader -> dimline from text to point on circle
self.add_radial_dim_line_from_text(
measurement.user_location, arrow_connection_point
)
add_center_mark(self)
measurement.text_is_outside = preserve_outside
def add_arrow(self, location, rotate: bool) -> Vec2:
"""Add arrow or tick to dimension line, returns dimension line connection point."""
arrows = self.arrows
attribs = arrows.dxfattribs()
arrow_name = arrows.arrow1_name
if arrows.tick_size > 0.0: # oblique stroke, but double the size
self.add_blockref(
ARROWS.oblique,
insert=location,
rotation=self.dim_line_angle,
scale=arrows.tick_size * 2.0,
dxfattribs=attribs,
)
else:
scale = arrows.arrow_size
angle = self.dim_line_angle
if rotate:
angle += 180.0
self.add_blockref(
arrow_name,
insert=location,
scale=scale,
rotation=angle,
dxfattribs=attribs,
)
location = connection_point(arrow_name, location, scale, angle)
return location
def add_radial_dim_line(self, end: UVec) -> None:
"""Add radial dimension line."""
attribs = self.dimension_line.dxfattribs()
self.add_line(
self.center, end, dxfattribs=attribs, remove_hidden_lines=True
)
def add_radial_dim_line_from_text(self, start, end: UVec) -> None:
"""Add radial dimension line, starting point at the measurement text."""
attribs = self.dimension_line.dxfattribs()
hshift = self._total_text_width / 2
if self.measurement.vertical_placement != 0: # not center
hshift = -hshift
self.add_line(
start + self.dim_line_vec * hshift,
end,
dxfattribs=attribs,
remove_hidden_lines=False,
)
def add_horiz_ext_line_default(self, start: UVec) -> None:
"""Add horizontal outside extension line from start for default
locations.
"""
attribs = self.dimension_line.dxfattribs()
self.add_line(start, self.outside_default_defpoint, dxfattribs=attribs)
if self.measurement.vertical_placement == 0:
hdist = self.arrows.arrow_size
else:
hdist = self._total_text_width
angle = self.dim_line_angle % 360.0 # normalize 0 .. 360
if 90 < angle <= 270:
hdist = -hdist
end = self.outside_default_defpoint + Vec2((hdist, 0))
self.add_line(self.outside_default_defpoint, end, dxfattribs=attribs)
def add_horiz_ext_line_user(self, start: UVec) -> None:
"""Add horizontal extension line from start for user defined locations."""
measurement = self.measurement
assert isinstance(measurement.user_location, Vec2)
attribs = self.dimension_line.dxfattribs()
self.add_line(start, measurement.user_location, dxfattribs=attribs)
if measurement.vertical_placement == 0:
hdist = self.arrows.arrow_size
else:
hdist = self._total_text_width
if measurement.user_location.x <= self.point_on_circle.x:
hdist = -hdist
end = measurement.user_location + Vec2((hdist, 0))
self.add_line(measurement.user_location, end, dxfattribs=attribs)
def add_radial_ext_line_default(self, start: UVec) -> None:
"""Add radial outside extension line from start for default locations."""
attribs = self.dimension_line.dxfattribs()
length = self.measurement.text_gap + self._total_text_width
end = start + self.dim_line_vec * length
self.add_line(start, end, dxfattribs=attribs, remove_hidden_lines=True)
def add_radial_ext_line_user(self, start: UVec) -> None:
"""Add radial outside extension line from start for user defined location."""
attribs = self.dimension_line.dxfattribs()
length = self._total_text_width / 2.0
if self.measurement.vertical_placement == 0:
length = -length
end = self.measurement.user_location + self.dim_line_vec * length
self.add_line(start, end, dxfattribs=attribs)
def add_measurement_text(
self, dim_text: str, pos: Vec2, rotation: float
) -> None:
"""Add measurement text to dimension BLOCK."""
attribs = self.measurement.dxfattribs()
self.add_text(dim_text, pos=pos, rotation=rotation, dxfattribs=attribs)
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.
"""
def from_ucs(attr, func):
point = self.dimension.get_dxf_attrib(attr)
self.dimension.set_dxf_attrib(attr, func(point))
ucs = self.geometry.ucs
from_ucs("defpoint", ucs.to_wcs)
from_ucs("defpoint4", ucs.to_wcs)
from_ucs("text_midpoint", ucs.to_ocs)
def add_center_mark(dim: RadiusDimension) -> None:
"""Add center mark/lines to radius and diameter dimensions.
Args:
dim: RadiusDimension or DiameterDimension renderer
"""
dim_type = dim.dimension.dimtype
if dim_type == 4: # Radius Dimension
radius = dim.measurement.raw_value
elif dim_type == 3: # Diameter Dimension
radius = dim.measurement.raw_value / 2.0
else:
raise TypeError(f"Invalid dimension type: {dim_type}")
mark_size = dim.dim_style.get("dimcen", 0)
if mark_size == 0:
return
center_lines = False
if mark_size < 0:
mark_size = abs(mark_size)
center_lines = True
center = Vec2(dim.center)
# draw center mark
mark_x_vec = Vec2((mark_size, 0))
mark_y_vec = Vec2((0, mark_size))
# use only color and ignore linetype!
dxfattribs = {"color": dim.dimension_line.color}
dim.add_line(center - mark_x_vec, center + mark_x_vec, dxfattribs)
dim.add_line(center - mark_y_vec, center + mark_y_vec, dxfattribs)
if center_lines:
size = mark_size + radius
if size < 2 * mark_size:
return # not enough space for center lines
start_x_vec = mark_x_vec * 2
start_y_vec = mark_y_vec * 2
end_x_vec = Vec2((size, 0))
end_y_vec = Vec2((0, size))
dim.add_line(center + start_x_vec, center + end_x_vec, dxfattribs)
dim.add_line(center - start_x_vec, center - end_x_vec, dxfattribs)
dim.add_line(center + start_y_vec, center + end_y_vec, dxfattribs)
dim.add_line(center - start_y_vec, center - end_y_vec, dxfattribs)

View File

@@ -0,0 +1,118 @@
# Copyright (c) 2018-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.math import UCS
from ezdxf.lldxf.const import DXFValueError
from ezdxf.entities.dimstyleoverride import DimStyleOverride
from .dim_base import BaseDimensionRenderer
from .dim_curved import AngularDimension, Angular3PDimension, ArcLengthDimension
from .dim_diameter import DiameterDimension
from .dim_linear import LinearDimension
from .dim_ordinate import OrdinateDimension
from .dim_radius import RadiusDimension
if TYPE_CHECKING:
from ezdxf.entities import Dimension
class DimensionRenderer:
def dispatch(
self, override: DimStyleOverride, ucs: Optional[UCS] = None
) -> BaseDimensionRenderer:
dimension = override.dimension
dim_type = dimension.dimtype
dxf_type = dimension.dxftype()
if dxf_type == "ARC_DIMENSION":
return self.arc_length(dimension, ucs, override)
elif dxf_type == "LARGE_RADIAL_DIMENSION":
return self.large_radial(dimension, ucs, override)
elif dim_type in (0, 1):
return self.linear(dimension, ucs, override)
elif dim_type == 2:
return self.angular(dimension, ucs, override)
elif dim_type == 3:
return self.diameter(dimension, ucs, override)
elif dim_type == 4:
return self.radius(dimension, ucs, override)
elif dim_type == 5:
return self.angular3p(dimension, ucs, override)
elif dim_type == 6:
return self.ordinate(dimension, ucs, override)
else:
raise DXFValueError(f"Unknown DIMENSION type: {dim_type}")
def linear(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
"""Call renderer for linear dimension lines: horizontal, vertical and rotated"""
return LinearDimension(dimension, ucs, override)
def angular(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
"""Call renderer for angular dimension defined by two lines."""
return AngularDimension(dimension, ucs, override)
def diameter(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
"""Call renderer for diameter dimension"""
return DiameterDimension(dimension, ucs, override)
def radius(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
"""Call renderer for radius dimension"""
return RadiusDimension(dimension, ucs, override)
def large_radial(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
"""Call renderer for large radial dimension"""
raise NotImplementedError()
def angular3p(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
"""Call renderer for angular dimension defined by three points."""
return Angular3PDimension(dimension, ucs, override)
def ordinate(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
"""Call renderer for ordinate dimension."""
return OrdinateDimension(dimension, ucs, override)
def arc_length(
self,
dimension: Dimension,
ucs: Optional[UCS] = None,
override: Optional[DimStyleOverride] = None,
):
"""Call renderer for arc length dimension."""
return ArcLengthDimension(dimension, ucs, override)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,700 @@
# Copyright (c) 2022-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Iterator,
Sequence,
TYPE_CHECKING,
Callable,
Any,
Union,
Optional,
Tuple,
)
from typing_extensions import TypeAlias
from collections import defaultdict
import enum
import math
import dataclasses
import random
from ezdxf.math import (
Vec2,
Vec3,
Bezier3P,
Bezier4P,
intersection_ray_cubic_bezier_2d,
quadratic_to_cubic_bezier,
)
from ezdxf import const
from ezdxf.path import Path, LineTo, MoveTo, Curve3To, Curve4To
if TYPE_CHECKING:
from ezdxf.entities.polygon import DXFPolygon
MIN_HATCH_LINE_DISTANCE = 1e-4 # ??? what's a good choice
NONE_VEC2 = Vec2(math.nan, math.nan)
KEY_NDIGITS = 4
SORT_NDIGITS = 10
class IntersectionType(enum.IntEnum):
NONE = 0
REGULAR = 1
START = 2
END = 3
COLLINEAR = 4
class HatchingError(Exception):
"""Base exception class of the :mod:`hatching` module."""
pass
class HatchLineDirectionError(HatchingError):
"""Hatching direction is undefined or a (0, 0) vector."""
pass
class DenseHatchingLinesError(HatchingError):
"""Very small hatching distance which creates too many hatching lines."""
pass
@dataclasses.dataclass(frozen=True)
class Line:
start: Vec2
end: Vec2
distance: float # normal distance to the hatch baseline
@dataclasses.dataclass(frozen=True)
class Intersection:
"""Represents an intersection."""
type: IntersectionType = IntersectionType.NONE
p0: Vec2 = NONE_VEC2
p1: Vec2 = NONE_VEC2
def side_of_line(distance: float, abs_tol=1e-12) -> int:
if abs(distance) < abs_tol:
return 0
if distance > 0.0:
return +1
return -1
@dataclasses.dataclass(frozen=True)
class HatchLine:
"""Represents a single hatch line.
Args:
origin: the origin of the hatch line as :class:`~ezdxf.math.Vec2` instance
direction: the hatch line direction as :class:`~ezdxf.math.Vec2` instance, must not (0, 0)
distance: the normal distance to the base hatch line as float
"""
origin: Vec2
direction: Vec2
distance: float
def intersect_line(
self,
a: Vec2,
b: Vec2,
dist_a: float,
dist_b: float,
) -> Intersection:
"""Returns the :class:`Intersection` of this hatch line and the line
defined by the points `a` and `b`.
The arguments `dist_a` and `dist_b` are the signed normal distances of
the points `a` and `b` from the hatch baseline.
The normal distances from the baseline are easy to calculate by the
:meth:`HatchBaseLine.signed_distance` method and allow a fast
intersection calculation by a simple point interpolation.
Args:
a: start point of the line as :class:`~ezdxf.math.Vec2` instance
b: end point of the line as :class:`~ezdxf.math.Vec2` instance
dist_a: normal distance of point `a` to the hatch baseline as float
dist_b: normal distance of point `b` to the hatch baseline as float
"""
# all distances are normal distances to the hatch baseline
line_distance = self.distance
side_a = side_of_line(dist_a - line_distance)
side_b = side_of_line(dist_b - line_distance)
if side_a == 0:
if side_b == 0:
return Intersection(IntersectionType.COLLINEAR, a, b)
else:
return Intersection(IntersectionType.START, a)
elif side_b == 0:
return Intersection(IntersectionType.END, b)
elif side_a != side_b:
factor = abs((dist_a - line_distance) / (dist_a - dist_b))
return Intersection(IntersectionType.REGULAR, a.lerp(b, factor))
return Intersection() # no intersection
def intersect_cubic_bezier_curve(self, curve: Bezier4P) -> Sequence[Intersection]:
"""Returns 0 to 3 :class:`Intersection` points of this hatch line with
a cubic Bèzier curve.
Args:
curve: the cubic Bèzier curve as :class:`ezdxf.math.Bezier4P` instance
"""
return [
Intersection(IntersectionType.REGULAR, p, NONE_VEC2)
for p in intersection_ray_cubic_bezier_2d(
self.origin, self.origin + self.direction, curve
)
]
class PatternRenderer:
"""
The hatch pattern of a DXF entity has one or more :class:`HatchBaseLine`
instances with an origin, direction, offset and line pattern.
The :class:`PatternRenderer` for a certain distance from the
baseline has to be acquired from the :class:`HatchBaseLine` by the
:meth:`~HatchBaseLine.pattern_renderer` method.
The origin of the hatch line is the starting point of the line
pattern. The offset defines the origin of the adjacent
hatch line and doesn't have to be orthogonal to the hatch line direction.
**Line Pattern**
The line pattern is a sequence of floats, where a value > 0.0 is a dash, a
value < 0.0 is a gap and value of 0.0 is a point.
Args:
hatch_line: :class:`HatchLine`
pattern: the line pattern as sequence of float values
"""
def __init__(self, hatch_line: HatchLine, pattern: Sequence[float]):
self.origin = hatch_line.origin
self.direction = hatch_line.direction
self.pattern = pattern
self.pattern_length = math.fsum([abs(e) for e in pattern])
def sequence_origin(self, index: float) -> Vec2:
return self.origin + self.direction * (self.pattern_length * index)
def render(self, start: Vec2, end: Vec2) -> Iterator[tuple[Vec2, Vec2]]:
"""Yields the pattern lines as pairs of :class:`~ezdxf.math.Vec2`
instances from the start- to the end point on the hatch line.
For points the start- and end point are the same :class:`~ezdxf.math.Vec2`
instance and can be tested by the ``is`` operator.
The start- and end points should be located collinear at the hatch line
of this instance, otherwise the points a projected onto this hatch line.
"""
if start.isclose(end):
return
length = self.pattern_length
if length < 1e-9:
yield start, end
return
direction = self.direction
if direction.dot(end - start) < 0.0:
# Line direction is reversed to the pattern line direction!
start, end = end, start
origin = self.origin
s_dist = direction.dot(start - origin)
e_dist = direction.dot(end - origin)
s_index, s_offset = divmod(s_dist, length)
e_index, e_offset = divmod(e_dist, length)
if s_index == e_index:
yield from self.render_offset_to_offset(s_index, s_offset, e_offset)
return
# line crosses pattern border
if s_offset > 0.0:
yield from self.render_offset_to_offset(
s_index,
s_offset,
length,
)
s_index += 1
while s_index < e_index:
yield from self.render_full_pattern(s_index)
s_index += 1
if e_offset > 0.0:
yield from self.render_offset_to_offset(
s_index,
0.0,
e_offset,
)
def render_full_pattern(self, index: float) -> Iterator[tuple[Vec2, Vec2]]:
# fast pattern rendering
direction = self.direction
start_point = self.sequence_origin(index)
for dash in self.pattern:
if dash == 0.0:
yield start_point, start_point
else:
end_point = start_point + direction * abs(dash)
if dash > 0.0:
yield start_point, end_point
start_point = end_point
def render_offset_to_offset(
self, index: float, s_offset: float, e_offset: float
) -> Iterator[tuple[Vec2, Vec2]]:
direction = self.direction
origin = self.sequence_origin(index)
start_point = origin + direction * s_offset
distance = 0.0
for dash in self.pattern:
distance += abs(dash)
if distance < s_offset:
continue
if dash == 0.0:
yield start_point, start_point
else:
end_point = origin + direction * min(distance, e_offset)
if dash > 0.0:
yield start_point, end_point
if distance >= e_offset:
return
start_point = end_point
class HatchBaseLine:
"""A hatch baseline defines the source line for hatching a geometry.
A complete hatch pattern of a DXF entity can consist of one or more hatch
baselines.
Args:
origin: the origin of the hatch line as :class:`~ezdxf.math.Vec2` instance
direction: the hatch line direction as :class:`~ezdxf.math.Vec2` instance, must not (0, 0)
offset: the offset of the hatch line origin to the next or to the previous hatch line
line_pattern: line pattern as sequence of floats, see also :class:`PatternRenderer`
min_hatch_line_distance: minimum hatch line distance to render, raises an
:class:`DenseHatchingLinesError` exception if the distance between hatch
lines is smaller than this value
Raises:
HatchLineDirectionError: hatch baseline has no direction, (0, 0) vector
DenseHatchingLinesError: hatching lines are too narrow
"""
def __init__(
self,
origin: Vec2,
direction: Vec2,
offset: Vec2,
line_pattern: Optional[list[float]] = None,
min_hatch_line_distance=MIN_HATCH_LINE_DISTANCE,
):
self.origin = origin
try:
self.direction = direction.normalize()
except ZeroDivisionError:
raise HatchLineDirectionError("hatch baseline has no direction")
self.offset = offset
self.normal_distance: float = (-offset).det(self.direction - offset)
if abs(self.normal_distance) < min_hatch_line_distance:
raise DenseHatchingLinesError("hatching lines are too narrow")
self._end = self.origin + self.direction
self.line_pattern: list[float] = line_pattern if line_pattern else []
def __repr__(self):
return (
f"{self.__class__.__name__}(origin={self.origin!r}, "
f"direction={self.direction!r}, offset={self.offset!r})"
)
def hatch_line(self, distance: float) -> HatchLine:
"""Returns the :class:`HatchLine` at the given signed `distance`."""
factor = distance / self.normal_distance
return HatchLine(self.origin + self.offset * factor, self.direction, distance)
def signed_distance(self, point: Vec2) -> float:
"""Returns the signed normal distance of the given `point` from this
hatch baseline.
"""
# denominator (_end - origin).magnitude is 1.0 !!!
return (self.origin - point).det(self._end - point)
def pattern_renderer(self, distance: float) -> PatternRenderer:
"""Returns the :class:`PatternRenderer` for the given signed `distance`."""
return PatternRenderer(self.hatch_line(distance), self.line_pattern)
def hatch_line_distances(
point_distances: Sequence[float], normal_distance: float
) -> list[float]:
"""Returns all hatch line distances in the range of the given point
distances.
"""
assert normal_distance != 0.0
normal_factors = [d / normal_distance for d in point_distances]
max_line_number = int(math.ceil(max(normal_factors)))
min_line_number = int(math.ceil(min(normal_factors)))
return [normal_distance * num for num in range(min_line_number, max_line_number)]
def intersect_polygon(
baseline: HatchBaseLine, polygon: Sequence[Vec2]
) -> Iterator[tuple[Intersection, float]]:
"""Yields all intersection points of the hatch defined by the `baseline` and
the given `polygon`.
Returns the intersection point and the normal-distance from the baseline,
intersection points with the same normal-distance lay on the same hatch
line.
"""
count = len(polygon)
if count < 3:
return
if polygon[0].isclose(polygon[-1]):
count -= 1
if count < 3:
return
prev_point = polygon[count - 1] # last point
dist_prev = baseline.signed_distance(prev_point)
for index in range(count):
point = polygon[index]
dist_point = baseline.signed_distance(point)
for hatch_line_distance in hatch_line_distances(
(dist_prev, dist_point), baseline.normal_distance
):
hatch_line = baseline.hatch_line(hatch_line_distance)
ip = hatch_line.intersect_line(
prev_point,
point,
dist_prev,
dist_point,
)
if (
ip.type != IntersectionType.NONE
and ip.type != IntersectionType.COLLINEAR
):
yield ip, hatch_line_distance
prev_point = point
dist_prev = dist_point
def hatch_polygons(
baseline: HatchBaseLine,
polygons: Sequence[Sequence[Vec2]],
terminate: Optional[Callable[[], bool]] = None,
) -> Iterator[Line]:
"""Yields all pattern lines for all hatch lines generated by the given
:class:`HatchBaseLine`, intersecting the given 2D polygons as :class:`Line`
instances.
The `polygons` should represent a single entity with or without holes, the
order of the polygons and their winding orientation (cw or ccw) is not
important. Entities which do not intersect or overlap should be handled
separately!
Each polygon is a sequence of :class:`~ezdxf.math.Vec2` instances, they are
treated as closed polygons even if the last vertex is not equal to the
first vertex.
The hole detection is done by a simple inside/outside counting algorithm and
far from perfect, but is able to handle ordinary polygons well.
The terminate function WILL BE CALLED PERIODICALLY AND should return
``True`` to terminate execution. This can be used to implement a timeout,
which can be required if using a very small hatching distance, especially
if you get the data from untrusted sources.
Args:
baseline: :class:`HatchBaseLine`
polygons: multiple sequences of :class:`~ezdxf.path.Vec2` instances of
a single entity, the order of exterior- and hole paths and the
winding orientation (cw or ccw) of paths is not important
terminate: callback function which is called periodically and should
return ``True`` to terminate the hatching function
"""
yield from _hatch_geometry(baseline, polygons, intersect_polygon, terminate)
def intersect_path(
baseline: HatchBaseLine, path: Path
) -> Iterator[tuple[Intersection, float]]:
"""Yields all intersection points of the hatch defined by the `baseline` and
the given single `path`.
Returns the intersection point and the normal-distance from the baseline,
intersection points with the same normal-distance lay on the same hatch
line.
"""
for path_element in _path_elements(path):
if isinstance(path_element, Bezier4P):
distances = [
baseline.signed_distance(p) for p in path_element.control_points
]
for hatch_line_distance in hatch_line_distances(
distances, baseline.normal_distance
):
hatch_line = baseline.hatch_line(hatch_line_distance)
for ip in hatch_line.intersect_cubic_bezier_curve(path_element):
yield ip, hatch_line_distance
else: # line
a, b = Vec2.generate(path_element)
dist_a = baseline.signed_distance(a)
dist_b = baseline.signed_distance(b)
for hatch_line_distance in hatch_line_distances(
(dist_a, dist_b), baseline.normal_distance
):
hatch_line = baseline.hatch_line(hatch_line_distance)
ip = hatch_line.intersect_line(a, b, dist_a, dist_b)
if (
ip.type != IntersectionType.NONE
and ip.type != IntersectionType.COLLINEAR
):
yield ip, hatch_line_distance
def _path_elements(path: Path) -> Union[Bezier4P, tuple[Vec2, Vec2]]:
if len(path) == 0:
return
start = path.start
path_start = start
for command in path.commands():
end = command.end
if isinstance(command, MoveTo):
if not path_start.isclose(start):
yield start, path_start # close sub-path
path_start = end
elif isinstance(command, LineTo) and not start.isclose(end):
yield start, end
elif isinstance(command, Curve4To):
yield Bezier4P((start, command.ctrl1, command.ctrl2, end))
elif isinstance(command, Curve3To):
curve3 = Bezier3P((start, command.ctrl, end))
yield quadratic_to_cubic_bezier(curve3)
start = end
if not path_start.isclose(start): # close path
yield start, path_start
def hatch_paths(
baseline: HatchBaseLine,
paths: Sequence[Path],
terminate: Optional[Callable[[], bool]] = None,
) -> Iterator[Line]:
"""Yields all pattern lines for all hatch lines generated by the given
:class:`HatchBaseLine`, intersecting the given 2D :class:`~ezdxf.path.Path`
instances as :class:`Line` instances. The paths are handled as projected
into the xy-plane the z-axis of path vertices will be ignored if present.
Same as the :func:`hatch_polygons` function, but for :class:`~ezdxf.path.Path`
instances instead of polygons build of vertices. This function **does not
flatten** the paths into vertices, instead the real intersections of the
Bézier curves and the hatch lines are calculated.
For more information see the docs of the :func:`hatch_polygons` function.
Args:
baseline: :class:`HatchBaseLine`
paths: sequence of :class:`~ezdxf.path.Path` instances of a single
entity, the order of exterior- and hole paths and the winding
orientation (cw or ccw) of the paths is not important
terminate: callback function which is called periodically and should
return ``True`` to terminate the hatching function
"""
yield from _hatch_geometry(baseline, paths, intersect_path, terminate)
IFuncType: TypeAlias = Callable[
[HatchBaseLine, Any], Iterator[Tuple[Intersection, float]]
]
def _hatch_geometry(
baseline: HatchBaseLine,
geometries: Sequence[Any],
intersection_func: IFuncType,
terminate: Optional[Callable[[], bool]] = None,
) -> Iterator[Line]:
"""Returns all pattern lines intersecting the given geometries.
The intersection_func() should yield all intersection points between a
HatchBaseLine() and as given geometry.
The terminate function should return ``True`` to terminate execution
otherwise ``False``. Can be used to implement a timeout.
"""
points: dict[float, list[Intersection]] = defaultdict(list)
for geometry in geometries:
if terminate and terminate():
return
for ip, distance in intersection_func(baseline, geometry):
assert ip.type != IntersectionType.NONE
points[round(distance, KEY_NDIGITS)].append(ip)
for distance, vertices in points.items():
if terminate and terminate():
return
start = NONE_VEC2
end = NONE_VEC2
for line in _line_segments(vertices, distance):
if start is NONE_VEC2:
start = line.start
end = line.end
continue
if line.start.isclose(end):
end = line.end
else:
yield Line(start, end, distance)
start = line.start
end = line.end
if start is not NONE_VEC2:
yield Line(start, end, distance)
def _line_segments(vertices: list[Intersection], distance: float) -> Iterator[Line]:
if len(vertices) < 2:
return
vertices.sort(key=lambda p: p.p0.round(SORT_NDIGITS))
inside = False
prev_point = NONE_VEC2
for ip in vertices:
if ip.type == IntersectionType.NONE or ip.type == IntersectionType.COLLINEAR:
continue
# REGULAR, START, END
point = ip.p0
if prev_point is NONE_VEC2:
inside = True
prev_point = point
continue
if inside:
yield Line(prev_point, point, distance)
inside = not inside
prev_point = point
def hatch_entity(
polygon: DXFPolygon,
filter_text_boxes=True,
jiggle_origin: bool = True,
) -> Iterator[tuple[Vec3, Vec3]]:
"""Yields the hatch pattern of the given HATCH or MPOLYGON entity as 3D lines.
Each line is a pair of :class:`~ezdxf.math.Vec3` instances as start- and end
vertex, points are represented as lines of zero length, which means the
start vertex is equal to the end vertex.
The function yields nothing if `polygon` has a solid- or gradient filling
or does not have a usable pattern assigned.
Args:
polygon: :class:`~ezdxf.entities.Hatch` or :class:`~ezdxf.entities.MPolygon`
entity
filter_text_boxes: ignore text boxes if ``True``
jiggle_origin: move pattern line origins a small amount to avoid intersections
in corner points which causes errors in patterns
"""
if polygon.pattern is None or polygon.dxf.solid_fill:
return
if len(polygon.pattern.lines) == 0:
return
ocs = polygon.ocs()
elevation = polygon.dxf.elevation.z
paths = hatch_boundary_paths(polygon, filter_text_boxes)
# todo: MPOLYGON offset
# All paths in OCS!
for baseline in pattern_baselines(polygon, jiggle_origin=jiggle_origin):
for line in hatch_paths(baseline, paths):
line_pattern = baseline.pattern_renderer(line.distance)
for s, e in line_pattern.render(line.start, line.end):
if ocs.transform:
yield ocs.to_wcs((s.x, s.y, elevation)), ocs.to_wcs(
(e.x, e.y, elevation)
)
yield Vec3(s), Vec3(e)
def hatch_boundary_paths(polygon: DXFPolygon, filter_text_boxes=True) -> list[Path]:
"""Returns the hatch boundary paths as :class:`ezdxf.path.Path` instances
of HATCH and MPOLYGON entities. Ignores text boxes if argument
`filter_text_boxes` is ``True``.
"""
from ezdxf.path import from_hatch_boundary_path
loops = []
for boundary in polygon.paths.rendering_paths(polygon.dxf.hatch_style):
if filter_text_boxes and boundary.path_type_flags & const.BOUNDARY_PATH_TEXTBOX:
continue
path = from_hatch_boundary_path(boundary)
for sub_path in path.sub_paths():
if len(sub_path):
sub_path.close()
loops.append(sub_path)
return loops
def _jiggle_factor():
# range 0.0003 .. 0.0010
return random.random() * 0.0007 + 0.0003
def pattern_baselines(
polygon: DXFPolygon,
min_hatch_line_distance: float = MIN_HATCH_LINE_DISTANCE,
*,
jiggle_origin: bool = False,
) -> Iterator[HatchBaseLine]:
"""Yields the hatch pattern baselines of HATCH and MPOLYGON entities as
:class:`HatchBaseLine` instances. Set `jiggle_origin` to ``True`` to move pattern
line origins a small amount to avoid intersections in corner points which causes
errors in patterns.
"""
pattern = polygon.pattern
if not pattern:
return
# The hatch pattern parameters are already scaled and rotated for direct
# usage!
# The stored scale and angle is just for reconstructing the base pattern
# when applying a new scaling or rotation.
jiggle_offset = Vec2()
if jiggle_origin:
# move origin of base pattern lines a small amount to avoid intersections with
# boundary corner points
offsets: list[float] = [line.offset.magnitude for line in pattern.lines]
if len(offsets):
# calculate the same random jiggle offset for all pattern base lines
mean = sum(offsets) / len(offsets)
x = _jiggle_factor() * mean
y = _jiggle_factor() * mean
jiggle_offset = Vec2(x, y)
for line in pattern.lines:
direction = Vec2.from_deg_angle(line.angle)
yield HatchBaseLine(
origin=line.base_point + jiggle_offset,
direction=direction,
offset=line.offset,
line_pattern=line.dash_length_items,
min_hatch_line_distance=min_hatch_line_distance,
)

View File

@@ -0,0 +1,126 @@
# Copyright (c) 2020-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator, cast
from ezdxf import ARROWS
from ezdxf.entities import factory
from ezdxf.lldxf.const import BYBLOCK
from ezdxf.math import Vec3, fit_points_to_cad_cv
if TYPE_CHECKING:
from ezdxf.entities import DXFGraphic, Leader, Insert, Spline, Dimension, Line
def virtual_entities(leader: Leader) -> Iterator[DXFGraphic]:
# Source: https://atlight.github.io/formats/dxf-leader.html
# GDAL: DXF LEADER implementation:
# https://github.com/OSGeo/gdal/blob/master/gdal/ogr/ogrsf_frmts/dxf/ogrdxf_leader.cpp
# LEADER DXF Reference:
# http://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-396B2369-F89F-47D7-8223-8B7FB794F9F3
assert leader.dxftype() == "LEADER"
vertices = Vec3.list(leader.vertices) # WCS
if len(vertices) < 2:
# This LEADER entities should be removed by the auditor if loaded or
# ignored at exporting, if created by an ezdxf-user (log).
raise ValueError("More than 1 vertex required.")
dxf = leader.dxf
doc = leader.doc
# Some default values depend on the measurement system
# 0/1 = imperial/metric
if doc:
measurement = doc.header.get("$MEASUREMENT", 0)
else:
measurement = 0
# Set default styling attributes values:
dimtad = 1
dimgap = 0.625 if measurement else 0.0625
dimscale = 1.0
dimclrd = dxf.color
dimltype = dxf.linetype
dimlwd = dxf.lineweight
override = None
if doc:
# get styling attributes from associated DIMSTYLE and/or XDATA override
override = leader.override()
dimtad = override.get("dimtad", dimtad)
dimgap = override.get("dimgap", dimgap)
dimscale = override.get("dimscale", dimscale)
if dimscale == 0.0: # special but unknown meaning
dimscale = 1.0
dimclrd = override.get("dimclrd", dimclrd)
dimltype = override.get("dimltype", dimltype)
dimlwd = override.get("dimlwd", dimlwd)
text_width = dxf.text_width
hook_line_vector = Vec3(dxf.horizontal_direction)
has_text_annotation = dxf.annotation_type == 0
if has_text_annotation and dxf.has_hookline:
if dxf.hookline_direction == 1:
hook_line_vector = -hook_line_vector
if dimtad != 0 and text_width > 0:
hook_line = hook_line_vector * (dimgap * dimscale + text_width)
vertices.append(vertices[-1] + hook_line)
dxfattribs = leader.graphic_properties()
dxfattribs["color"] = dimclrd
dxfattribs["linetype"] = dimltype
dxfattribs["lineweight"] = dimlwd
if dxfattribs.get("color") == BYBLOCK:
dxfattribs["color"] = dxf.block_color
if dxf.path_type == 1: # Spline
start_tangent = vertices[1] - vertices[0]
end_tangent = vertices[-1] - vertices[-2]
bspline = fit_points_to_cad_cv(vertices, tangents=[start_tangent, end_tangent])
spline = cast("Spline", factory.new("SPLINE", doc=doc))
spline.apply_construction_tool(bspline)
yield spline
else:
attribs = dict(dxfattribs)
prev = vertices[0]
for vertex in vertices[1:]:
attribs["start"] = prev
attribs["end"] = vertex
yield cast(
"Line",
factory.new(dxftype="LINE", dxfattribs=attribs, doc=doc),
)
prev = vertex
if dxf.has_arrowhead and override:
arrow_name = override.get("dimldrblk", "")
if arrow_name is None:
return
size = override.get("dimasz", 2.5 if measurement else 0.1875) * dimscale
rotation = (vertices[0] - vertices[1]).angle_deg
if doc and arrow_name in doc.blocks:
dxfattribs.update(
{
"name": arrow_name,
"insert": vertices[0],
"rotation": rotation,
"xscale": size,
"yscale": size,
"zscale": size,
}
)
# create a virtual block reference
insert = cast(
"Insert", factory.new("INSERT", dxfattribs=dxfattribs, doc=doc)
)
yield from insert.virtual_entities()
else: # render standard arrows
yield from ARROWS.virtual_entities(
name=arrow_name,
insert=vertices[0],
size=size,
rotation=rotation,
dxfattribs=dxfattribs,
)

View File

@@ -0,0 +1,21 @@
# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from typing import Iterable, Iterator
import ezdxf
from ezdxf.math import UVec
from ._linetypes import _LineTypeRenderer, LineSegment
if ezdxf.options.use_c_ext:
try:
from ezdxf.acc.linetypes import _LineTypeRenderer # type: ignore
except ImportError:
pass
class LineTypeRenderer(_LineTypeRenderer):
def line_segments(self, vertices: Iterable[UVec]) -> Iterator[LineSegment]:
last = None
for vertex in vertices:
if last is not None:
yield from self.line_segment(last, vertex)
last = vertex

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,233 @@
# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, cast, Sequence, Any
from itertools import chain
from ezdxf.entities import factory, MLineStyle
from ezdxf.math import Vec3, OCS
import logging
if TYPE_CHECKING:
from ezdxf.entities import MLine, DXFGraphic, Hatch, Line, Arc
__all__ = ["virtual_entities"]
logger = logging.getLogger("ezdxf")
# The MLINE geometry stored in vertices, is the final geometry,
# scaling factor, justification and MLineStyle settings are already
# applied.
def _dxfattribs(mline) -> dict[str, Any]:
attribs = mline.graphic_properties()
# True color value of MLINE is ignored by CAD applications:
if "true_color" in attribs:
del attribs["true_color"]
return attribs
def virtual_entities(mline: MLine) -> list[DXFGraphic]:
"""Yields 'virtual' parts of MLINE as LINE, ARC and HATCH entities.
These entities are located at the original positions, but are not stored
in the entity database, have no handle and are not assigned to any
layout.
"""
def filling() -> Hatch:
attribs = _dxfattribs(mline)
attribs["color"] = style.dxf.fill_color
attribs["elevation"] = Vec3(ocs.from_wcs(bottom_border[0])).replace(
x=0.0, y=0.0
)
attribs["extrusion"] = mline.dxf.extrusion
hatch = cast("Hatch", factory.new("HATCH", dxfattribs=attribs, doc=doc))
bulges: list[float] = [0.0] * (len(bottom_border) * 2)
points = chain(
Vec3.generate(ocs.points_from_wcs(bottom_border)),
Vec3.generate(ocs.points_from_wcs(reversed(top_border))),
)
if not closed:
if style.get_flag_state(style.END_ROUND):
bulges[len(bottom_border) - 1] = 1.0
if style.get_flag_state(style.START_ROUND):
bulges[-1] = 1.0
lwpoints = ((v.x, v.y, bulge) for v, bulge in zip(points, bulges))
hatch.paths.add_polyline_path(lwpoints, is_closed=True)
return hatch
def start_cap() -> list[DXFGraphic]:
entities: list[DXFGraphic] = []
if style.get_flag_state(style.START_SQUARE):
entities.extend(create_miter(miter_points[0]))
if style.get_flag_state(style.START_ROUND):
entities.extend(round_caps(0, top_index, bottom_index))
if (
style.get_flag_state(style.START_INNER_ARC)
and len(style.elements) > 3
):
start_index = ordered_indices[-2]
end_index = ordered_indices[1]
entities.extend(round_caps(0, start_index, end_index))
return entities
def end_cap() -> list[DXFGraphic]:
entities: list[DXFGraphic] = []
if style.get_flag_state(style.END_SQUARE):
entities.extend(create_miter(miter_points[-1]))
if style.get_flag_state(style.END_ROUND):
entities.extend(round_caps(-1, bottom_index, top_index))
if (
style.get_flag_state(style.END_INNER_ARC)
and len(style.elements) > 3
):
start_index = ordered_indices[1]
end_index = ordered_indices[-2]
entities.extend(round_caps(-1, start_index, end_index))
return entities
def round_caps(miter_index: int, start_index: int, end_index: int):
color1 = style.elements[start_index].color
color2 = style.elements[end_index].color
start = ocs.from_wcs(miter_points[miter_index][start_index])
end = ocs.from_wcs(miter_points[miter_index][end_index])
return _arc_caps(start, end, color1, color2)
def _arc_caps(
start: Vec3, end: Vec3, color1: int, color2: int
) -> Sequence[Arc]:
attribs = _dxfattribs(mline)
center = start.lerp(end)
radius = (end - start).magnitude / 2.0
angle = (start - center).angle_deg
attribs["center"] = center
attribs["radius"] = radius
attribs["color"] = color1
attribs["start_angle"] = angle
attribs["end_angle"] = angle + (180 if color1 == color2 else 90)
arc1 = cast("Arc", factory.new("ARC", dxfattribs=attribs, doc=doc))
if color1 == color2:
return (arc1,)
attribs["start_angle"] = angle + 90
attribs["end_angle"] = angle + 180
attribs["color"] = color2
arc2 = cast("Arc", factory.new("ARC", dxfattribs=attribs, doc=doc))
return arc1, arc2
def lines() -> list[Line]:
prev = None
_lines: list[Line] = []
attribs = _dxfattribs(mline)
for miter in miter_points:
if prev is not None:
for index, element in enumerate(style.elements):
attribs["start"] = prev[index]
attribs["end"] = miter[index]
attribs["color"] = element.color
attribs["linetype"] = element.linetype
_lines.append(
cast(
"Line",
factory.new("LINE", dxfattribs=attribs, doc=doc),
)
)
prev = miter
return _lines
def display_miter():
_lines = []
skip = set()
skip.add(len(miter_points) - 1)
if not closed:
skip.add(0)
for index, miter in enumerate(miter_points):
if index not in skip:
_lines.extend(create_miter(miter))
return _lines
def create_miter(miter) -> list[Line]:
_lines: list[Line] = []
attribs = _dxfattribs(mline)
top = miter[top_index]
bottom = miter[bottom_index]
zero = bottom.lerp(top)
element = style.elements[top_index]
attribs["start"] = top
attribs["end"] = zero
attribs["color"] = element.color
attribs["linetype"] = element.linetype
_lines.append(
cast("Line", factory.new("LINE", dxfattribs=attribs, doc=doc))
)
element = style.elements[bottom_index]
attribs["start"] = bottom
attribs["end"] = zero
attribs["color"] = element.color
attribs["linetype"] = element.linetype
_lines.append(
cast("Line", factory.new("LINE", dxfattribs=attribs, doc=doc))
)
return _lines
entities: list[DXFGraphic] = []
if not mline.is_alive or mline.doc is None or len(mline.vertices) < 2:
return entities
style: MLineStyle = mline.style # type: ignore
if style is None:
return entities
doc = mline.doc
ocs = OCS(mline.dxf.extrusion)
element_count = len(style.elements)
closed = mline.is_closed
ordered_indices = style.ordered_indices()
bottom_index = ordered_indices[0]
top_index = ordered_indices[-1]
bottom_border: list[Vec3] = []
top_border: list[Vec3] = []
miter_points: list[list[Vec3]] = []
for vertex in mline.vertices:
offsets = vertex.line_params
if len(offsets) != element_count:
logger.debug(
f"Invalid line parametrization for vertex {len(miter_points)} "
f"in {str(mline)}."
)
return entities
location = vertex.location
miter_direction = vertex.miter_direction
miter = []
for offset in offsets:
try:
length = offset[0]
except IndexError: # DXFStructureError?
length = 0
miter.append(location + miter_direction * length)
miter_points.append(miter)
top_border.append(miter[top_index])
bottom_border.append(miter[bottom_index])
if closed:
miter_points.append(miter_points[0])
top_border.append(top_border[0])
bottom_border.append(bottom_border[0])
if not closed:
entities.extend(start_cap())
entities.extend(lines())
if style.get_flag_state(style.MITER):
entities.extend(display_miter())
if not closed:
entities.extend(end_cap())
if style.get_flag_state(style.FILL):
entities.insert(0, filling())
return entities

View File

@@ -0,0 +1,88 @@
# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import cast
import math
from ezdxf.entities import factory, Point, DXFGraphic
from ezdxf.math import Vec3, UCS, NULLVEC
def virtual_entities(
point: Point, pdsize: float = 1, pdmode: int = 0
) -> list[DXFGraphic]:
"""Yields point graphic as DXF primitives LINE and CIRCLE entities.
The dimensionless point is rendered as zero-length line!
Check for this condition::
e.dxftype() == 'LINE' and e.dxf.start.isclose(e.dxf.end)
if the rendering engine can't handle zero-length lines.
Args:
point: DXF POINT entity
pdsize: point size in drawing units
pdmode: point styling mode, see :class:`~ezdxf.entities.Point` class
"""
def add_line_symmetrical(offset: Vec3):
dxfattribs["start"] = ucs.to_wcs(-offset)
dxfattribs["end"] = ucs.to_wcs(offset)
entities.append(cast(DXFGraphic, factory.new("LINE", dxfattribs)))
def add_line(s: Vec3, e: Vec3):
dxfattribs["start"] = ucs.to_wcs(s)
dxfattribs["end"] = ucs.to_wcs(e)
entities.append(cast(DXFGraphic, factory.new("LINE", dxfattribs)))
center = point.dxf.location
# This is not a real OCS! Defines just the point orientation,
# location is in WCS!
ocs = point.ocs()
ucs = UCS(origin=center, ux=ocs.ux, uz=ocs.uz)
# The point angle is clockwise oriented:
ucs = ucs.rotate_local_z(math.radians(-point.dxf.angle))
entities: list[DXFGraphic] = []
gfx = point.graphic_properties()
radius = pdsize * 0.5
has_circle = bool(pdmode & 32)
has_square = bool(pdmode & 64)
style = pdmode & 7
dxfattribs = dict(gfx)
if style == 0: # . dimensionless point as zero-length line
add_line_symmetrical(NULLVEC)
# style == 1: no point symbol
elif style == 2: # + cross
add_line_symmetrical(Vec3(pdsize, 0))
add_line_symmetrical(Vec3(0, pdsize))
elif style == 3: # x cross
add_line_symmetrical(Vec3(pdsize, pdsize))
add_line_symmetrical(Vec3(pdsize, -pdsize))
elif style == 4: # ' tick
add_line(NULLVEC, Vec3(0, radius))
if has_square:
x1 = -radius
x2 = radius
y1 = -radius
y2 = radius
add_line(Vec3(x1, y1), Vec3(x2, y1))
add_line(Vec3(x2, y1), Vec3(x2, y2))
add_line(Vec3(x2, y2), Vec3(x1, y2))
add_line(Vec3(x1, y2), Vec3(x1, y1))
if has_circle:
dxfattribs = dict(gfx)
if point.dxf.hasattr("extrusion"):
dxfattribs["extrusion"] = ocs.uz
dxfattribs["center"] = ocs.from_wcs(center)
else:
dxfattribs["center"] = center
dxfattribs["radius"] = radius
entities.append(cast(DXFGraphic, factory.new("CIRCLE", dxfattribs)))
return entities

View File

@@ -0,0 +1,248 @@
# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Union
import logging
import math
from ezdxf.entities import factory
from ezdxf.lldxf.const import VERTEXNAMES
from ezdxf.math import Vec3, bulge_to_arc, OCS
if TYPE_CHECKING:
from ezdxf.entities import LWPolyline, Polyline, Line, Arc, Face3d, Polymesh
logger = logging.getLogger("ezdxf")
def virtual_lwpolyline_entities(
lwpolyline: LWPolyline,
) -> Iterable[Union[Line, Arc]]:
"""Yields 'virtual' entities of LWPOLYLINE as LINE or ARC objects.
These entities are located at the original positions, but are not stored in
the entity database, have no handle and are not assigned to any layout.
(internal API)
"""
assert lwpolyline.dxftype() == "LWPOLYLINE"
points = lwpolyline.get_points("xyb")
if len(points) < 2:
return
if lwpolyline.closed:
points.append(points[0])
yield from _virtual_polyline_entities(
points=points,
elevation=lwpolyline.dxf.elevation,
extrusion=lwpolyline.dxf.get("extrusion", None),
dxfattribs=lwpolyline.graphic_properties(),
doc=lwpolyline.doc,
)
def virtual_polyline_entities(
polyline: Polyline,
) -> Iterable[Union[Line, Arc, Face3d]]:
"""Yields 'virtual' entities of POLYLINE as LINE, ARC or 3DFACE objects.
These entities are located at the original positions, but are not stored in
the entity database, have no handle and are not assigned to any layout.
(internal API)
"""
assert polyline.dxftype() == "POLYLINE"
if polyline.is_2d_polyline:
return virtual_polyline2d_entities(polyline)
elif polyline.is_3d_polyline:
return virtual_polyline3d_entities(polyline)
elif polyline.is_polygon_mesh:
return virtual_polymesh_entities(polyline)
elif polyline.is_poly_face_mesh:
return virtual_polyface_entities(polyline)
return []
def virtual_polyline2d_entities(
polyline: Polyline,
) -> Iterable[Union[Line, Arc]]:
"""Yields 'virtual' entities of 2D POLYLINE as LINE or ARC objects.
These entities are located at the original positions, but are not stored in
the entity database, have no handle and are not assigned to any layout.
(internal API)
"""
assert polyline.dxftype() == "POLYLINE"
assert polyline.is_2d_polyline
if len(polyline.vertices) < 2:
return
points = [
(v.dxf.location.x, v.dxf.location.y, v.dxf.bulge)
for v in polyline.vertices
]
if polyline.is_closed:
points.append(points[0])
yield from _virtual_polyline_entities(
points=points,
elevation=Vec3(polyline.dxf.get("elevation", (0, 0, 0))).z,
extrusion=polyline.dxf.get("extrusion", None),
dxfattribs=polyline.graphic_properties(),
doc=polyline.doc,
)
def _virtual_polyline_entities(
points, elevation: float, extrusion: Vec3, dxfattribs: dict, doc
) -> Iterable[Union[Line, Arc]]:
ocs = OCS(extrusion) if extrusion else OCS()
prev_point = None
prev_bulge = None
for x, y, bulge in points:
point = Vec3(x, y, elevation)
if prev_point is None:
prev_point = point
prev_bulge = bulge
continue
attribs = dict(dxfattribs)
if prev_bulge != 0:
center, start_angle, end_angle, radius = bulge_to_arc(
prev_point, point, prev_bulge
)
if radius > 0:
attribs["center"] = Vec3(center.x, center.y, elevation)
attribs["radius"] = radius
attribs["start_angle"] = math.degrees(start_angle)
attribs["end_angle"] = math.degrees(end_angle)
if extrusion:
attribs["extrusion"] = extrusion
yield factory.new(dxftype="ARC", dxfattribs=attribs, doc=doc)
else:
attribs["start"] = ocs.to_wcs(prev_point)
attribs["end"] = ocs.to_wcs(point)
yield factory.new(dxftype="LINE", dxfattribs=attribs, doc=doc)
prev_point = point
prev_bulge = bulge
def virtual_polyline3d_entities(polyline: Polyline) -> Iterable[Line]:
"""Yields 'virtual' entities of 3D POLYLINE as LINE objects.
This entities are located at the original positions, but are not stored in
the entity database, have no handle and are not assigned to any layout.
(internal API)
"""
assert polyline.dxftype() == "POLYLINE"
assert polyline.is_3d_polyline
if len(polyline.vertices) < 2:
return
doc = polyline.doc
vertices = polyline.vertices
dxfattribs = polyline.graphic_properties()
start = -1 if polyline.is_closed else 0
for index in range(start, len(vertices) - 1):
dxfattribs["start"] = vertices[index].dxf.location
dxfattribs["end"] = vertices[index + 1].dxf.location
yield factory.new(dxftype="LINE", dxfattribs=dxfattribs, doc=doc) # type: ignore
def virtual_polymesh_entities(polyline: Polyline) -> Iterable[Face3d]:
"""Yields 'virtual' entities of POLYMESH as 3DFACE objects.
This entities are located at the original positions, but are not stored in
the entity database, have no handle and are not assigned to any layout.
(internal API)
"""
polymesh: "Polymesh" = polyline # type: ignore
assert polymesh.dxftype() == "POLYLINE"
assert polymesh.is_polygon_mesh
doc = polymesh.doc
mesh = polymesh.get_mesh_vertex_cache()
dxfattribs = polymesh.graphic_properties()
m_count = polymesh.dxf.m_count
n_count = polymesh.dxf.n_count
m_range = m_count - int(not polymesh.is_m_closed)
n_range = n_count - int(not polymesh.is_n_closed)
for m in range(m_range):
for n in range(n_range):
next_m = (m + 1) % m_count
next_n = (n + 1) % n_count
dxfattribs["vtx0"] = mesh[m, n]
dxfattribs["vtx1"] = mesh[next_m, n]
dxfattribs["vtx2"] = mesh[next_m, next_n]
dxfattribs["vtx3"] = mesh[m, next_n]
yield factory.new(dxftype="3DFACE", dxfattribs=dxfattribs, doc=doc) # type: ignore
def virtual_polyface_entities(polyline: Polyline) -> Iterable[Face3d]:
"""Yields 'virtual' entities of POLYFACE as 3DFACE objects.
This entities are located at the original positions, but are not stored in
the entity database, have no handle and are not assigned to any layout.
(internal API)
"""
assert polyline.dxftype() == "POLYLINE"
assert polyline.is_poly_face_mesh
doc = polyline.doc
vertices = polyline.vertices
base_attribs = polyline.graphic_properties()
face_records = (v for v in vertices if v.is_face_record)
for face in face_records:
# check if vtx0, vtx1 and vtx2 exist
for name in VERTEXNAMES[:-1]:
if not face.dxf.hasattr(name):
logger.info(
f"skipped face {str(face)} with less than 3 vertices"
f"in PolyFaceMesh(#{str(polyline.dxf.handle)})"
)
continue
# Alternate solutions: return a face with less than 3 vertices
# as LINE (breaks the method signature) or as degenerated 3DFACE
# (vtx0, vtx1, vtx1, vtx1)
face3d_attribs = dict(base_attribs)
face3d_attribs.update(face.graphic_properties())
invisible = 0
pos = 1
indices = (
(face.dxf.get(name), name)
for name in VERTEXNAMES
if face.dxf.hasattr(name)
)
for index, name in indices:
# vertex indices are 1-based, negative indices indicate invisible edges
if index < 0:
index = abs(index)
invisible += pos
# python list `vertices` is 0-based
face3d_attribs[name] = vertices[index - 1].dxf.location
# vertex index bit encoded: 1=0b0001, 2=0b0010, 3=0b0100, 4=0b1000
pos <<= 1
if "vtx3" not in face3d_attribs:
# A triangle face ends with two identical vertices vtx2 and vtx3.
# This is a requirement defined by AutoCAD.
face3d_attribs["vtx3"] = face3d_attribs["vtx2"]
face3d_attribs["invisible"] = invisible
yield factory.new(dxftype="3DFACE", dxfattribs=face3d_attribs, doc=doc) # type: ignore

View File

@@ -0,0 +1,236 @@
# Copyright (c) 2018-2022 Manfred Moitzi
# License: MIT License
"""
DXF R12 Splines
===============
DXF R12 supports 2d B-splines, but Autodesk do not document the usage in the
DXF Reference. The base entity for splines in DXF R12 is the POLYLINE entity.
Transformed Into 3D Space
-------------------------
The spline itself is always in a plane, but as any 2D entity, the spline can be
transformed into the 3D object by elevation, extrusion and thickness/width.
Open Quadratic Spline with Fit Vertices
-------------------------------------
Example: 2D_SPLINE_QUADRATIC.dxf
expected knot vector: open uniform
degree: 2
order: 3
POLYLINE:
flags (70): 4 = SPLINE_FIT_VERTICES_ADDED
smooth type (75): 5 = QUADRATIC_BSPLINE
Sequence of VERTEX
flags (70): SPLINE_VERTEX_CREATED = 8 # Spline vertex created by spline-fitting
This vertices are the curve vertices of the spline (fitted).
Frame control vertices appear after the curve vertices.
Sequence of VERTEX
flags (70): SPLINE_FRAME_CONTROL_POINT = 16
No control point at the starting point, but a control point at the end point,
last control point == last fit vertex
Closed Quadratic Spline with Fit Vertices
-----------------------------------------
Example: 2D_SPLINE_QUADRATIC_CLOSED.dxf
expected knot vector: closed uniform
degree: 2
order: 3
POLYLINE:
flags (70): 5 = CLOSED | SPLINE_FIT_VERTICES_ADDED
smooth type (75): 5 = QUADRATIC_BSPLINE
Sequence of VERTEX
flags (70): SPLINE_VERTEX_CREATED = 8 # Spline vertex created by spline-fitting
Frame control vertices appear after the curve vertices.
Sequence of VERTEX
flags (70): SPLINE_FRAME_CONTROL_POINT = 16
Open Cubic Spline with Fit Vertices
-----------------------------------
Example: 2D_SPLINE_CUBIC.dxf
expected knot vector: open uniform
degree: 3
order: 4
POLYLINE:
flags (70): 4 = SPLINE_FIT_VERTICES_ADDED
smooth type (75): 6 = CUBIC_BSPLINE
Sequence of VERTEX
flags (70): SPLINE_VERTEX_CREATED = 8 # Spline vertex created by spline-fitting
This vertices are the curve vertices of the spline (fitted).
Frame control vertices appear after the curve vertices.
Sequence of VERTEX
flags (70): SPLINE_FRAME_CONTROL_POINT = 16
No control point at the starting point, but a control point at the end point,
last control point == last fit vertex
Closed Curve With Extra Vertices Created
----------------------------------------
Example: 2D_FIT_CURVE_CLOSED.dxf
POLYLINE:
flags (70): 3 = CLOSED | CURVE_FIT_VERTICES_ADDED
Vertices with bulge values:
flags (70): 1 = EXTRA_VERTEX_CREATED
Vertex 70=0, Vertex 70=1, Vertex 70=0, Vertex 70=1
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional
from ezdxf.lldxf import const
from ezdxf.math import BSpline, closed_uniform_bspline, Vec3, UCS, UVec
if TYPE_CHECKING:
from ezdxf.layouts import BaseLayout
from ezdxf.entities import Polyline
class R12Spline:
"""DXF R12 supports 2D B-splines, but Autodesk do not document the usage
in the DXF Reference. The base entity for splines in DXF R12 is the POLYLINE
entity. The spline itself is always in a plane, but as any 2D entity, the
spline can be transformed into the 3D object by elevation and extrusion
(:ref:`OCS`, :ref:`UCS`).
This way it was possible to store the spline parameters in the DXF R12 file,
to allow CAD applications to modify the spline parameters and rerender the
B-spline afterward again as polyline approximation. Therefore, the result is
not better than an approximation by the :class:`Spline` class, it is also
just a POLYLINE entity, but maybe someone need exact this tool in the
future.
"""
def __init__(
self,
control_points: Iterable[UVec],
degree: int = 2,
closed: bool = True,
):
"""
Args:
control_points: B-spline control frame vertices
degree: degree of B-spline, only 2 and 3 is supported
closed: ``True`` for closed curve
"""
self.control_points = Vec3.list(control_points)
self.degree = degree
self.closed = closed
def approximate(
self, segments: int = 40, ucs: Optional[UCS] = None
) -> list[UVec]:
"""Approximate the B-spline by a polyline with `segments` line segments.
If `ucs` is not ``None``, ucs defines an :class:`~ezdxf.math.UCS`, to
transform the curve into :ref:`OCS`. The control points are placed
xy-plane of the UCS, don't use z-axis coordinates, if so make sure all
control points are in a plane parallel to the OCS base plane
(UCS xy-plane), else the result is unpredictable and depends on the CAD
application used to open the DXF file - it may crash.
Args:
segments: count of line segments for approximation, vertex count is
`segments` + 1
ucs: :class:`~ezdxf.math.UCS` definition, control points in ucs
coordinates
Returns:
list of vertices in :class:`~ezdxf.math.OCS` as
:class:`~ezdxf.math.Vec3` objects
"""
if self.closed:
spline = closed_uniform_bspline(
self.control_points, order=self.degree + 1
)
else:
spline = BSpline(self.control_points, order=self.degree + 1)
vertices = spline.approximate(segments)
if ucs is not None:
vertices = (ucs.to_ocs(vertex) for vertex in vertices)
return list(vertices)
def render(
self,
layout: BaseLayout,
segments: int = 40,
ucs: Optional[UCS] = None,
dxfattribs=None,
) -> Polyline:
"""Renders the B-spline into `layout` as 2D :class:`~ezdxf.entities.Polyline`
entity. Use an :class:`~ezdxf.math.UCS` to place the 2D spline in the
3D space, see :meth:`approximate` for more information.
Args:
layout: :class:`~ezdxf.layouts.BaseLayout` object
segments: count of line segments for approximation, vertex count is
`segments` + 1
ucs: :class:`~ezdxf.math.UCS` definition, control points in ucs
coordinates.
dxfattribs: DXF attributes for :class:`~ezdxf.entities.Polyline`
"""
polyline = layout.add_polyline2d(points=[], dxfattribs=dxfattribs)
flags = polyline.SPLINE_FIT_VERTICES_ADDED
if self.closed:
flags |= polyline.CLOSED
polyline.dxf.flags = flags
if self.degree == 2:
smooth_type = polyline.QUADRATIC_BSPLINE
elif self.degree == 3:
smooth_type = polyline.CUBIC_BSPLINE
else:
raise ValueError("invalid degree of spline")
polyline.dxf.smooth_type = smooth_type
# set OCS extrusion vector
if ucs is not None:
polyline.dxf.extrusion = ucs.uz
# add fit points in OCS
polyline.append_vertices(
self.approximate(segments, ucs),
dxfattribs={
"layer": polyline.dxf.layer,
"flags": const.VTX_SPLINE_VERTEX_CREATED,
},
)
# add control frame points in OCS
control_points = self.control_points
if ucs is not None:
control_points = list(ucs.points_to_ocs(control_points))
polyline.dxf.elevation = (0, 0, control_points[0].z)
polyline.append_vertices(
control_points,
dxfattribs={
"layer": polyline.dxf.layer,
"flags": const.VTX_SPLINE_FRAME_CONTROL_POINT,
},
)
return polyline

View File

@@ -0,0 +1,608 @@
# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Iterable,
Tuple,
Union,
cast,
Sequence,
Optional,
)
from typing_extensions import TypeAlias
from abc import abstractmethod
from collections import namedtuple
import math
import numpy as np
from ezdxf.math import (
Vec2,
Vec3,
UVec,
BSpline,
ConstructionRay,
OCS,
ParallelRaysError,
bulge_to_arc,
ConstructionArc,
)
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFGraphic, Solid, Trace, Face3d, LWPolyline, Polyline
__all__ = ["TraceBuilder", "LinearTrace", "CurvedTrace"]
LinearStation = namedtuple("LinearStation", ("vertex", "start_width", "end_width"))
# start_width of the next (following) segment
# end_width of the next (following) segment
CurveStation = namedtuple("CurveStation", ("vertex0", "vertex1"))
Face: TypeAlias = Tuple[Vec2, Vec2, Vec2, Vec2]
Polygon: TypeAlias = Sequence[Vec2]
Quadrilateral: TypeAlias = Union["Solid", "Trace", "Face3d"]
class AbstractTrace:
@abstractmethod
def faces(self) -> Iterable[Face]:
# vertex order: up1, down1, down2, up2
# faces connections:
# up2 -> next up1
# down2 -> next down1
pass
def polygon(self) -> Polygon:
def merge(vertices: Polygon) -> Iterable[UVec]:
if not len(vertices):
return
_vertices = iter(vertices)
prev = next(_vertices)
yield prev
for vertex in _vertices:
if not prev.isclose(vertex):
yield vertex
prev = vertex
forward_contour: list[Vec2] = []
backward_contour: list[Vec2] = []
for up1, down1, down2, up2 in self.faces():
forward_contour.extend((down1, down2))
backward_contour.extend((up1, up2))
contour = list(merge(forward_contour))
contour.extend(reversed(list(merge(backward_contour))))
return contour
def virtual_entities(
self, dxftype="TRACE", dxfattribs=None, doc: Optional[Drawing] = None
) -> Iterable[Quadrilateral]:
"""
Yields faces as SOLID, TRACE or 3DFACE entities with DXF attributes
given in `dxfattribs`.
If a document is given, the doc attribute of the new entities will be
set and the new entities will be automatically added to the entity
database of that document.
Args:
dxftype: DXF type as string, "SOLID", "TRACE" or "3DFACE"
dxfattribs: DXF attributes for SOLID, TRACE or 3DFACE entities
doc: associated document
"""
from ezdxf.entities.factory import new
if dxftype not in {"SOLID", "TRACE", "3DFACE"}:
raise TypeError(f"Invalid dxftype {dxftype}.")
dxfattribs = dict(dxfattribs or {})
for face in self.faces():
for i in range(4):
dxfattribs[f"vtx{i}"] = face[i]
if dxftype != "3DFACE":
# weird vertex order for SOLID and TRACE
dxfattribs["vtx2"] = face[3]
dxfattribs["vtx3"] = face[2]
entity = new(dxftype, dxfattribs, doc)
if doc:
doc.entitydb.add(entity)
yield entity # type: ignore
class LinearTrace(AbstractTrace):
"""Linear 2D banded lines like polylines with start- and end width.
Accepts 3D input, but z-axis is ignored.
"""
def __init__(self) -> None:
self._stations: list[LinearStation] = []
self.abs_tol = 1e-12
def __len__(self):
return len(self._stations)
def __getitem__(self, item):
return self._stations[item]
@property
def is_started(self) -> bool:
"""`True` if at least one station exist."""
return bool(self._stations)
def add_station(
self, point: UVec, start_width: float, end_width: Optional[float] = None
) -> None:
"""Add a trace station (like a vertex) at location `point`,
`start_width` is the width of the next segment starting at this station,
`end_width` is the end width of the next segment.
Adding the last location again, replaces the actual last location e.g.
adding lines (a, b), (b, c), creates only 3 stations (a, b, c), this is
very important to connect to/from splines.
Args:
point: 2D location (vertex), z-axis of 3D vertices is ignored.
start_width: start width of next segment
end_width: end width of next segment
"""
if end_width is None:
end_width = start_width
point = Vec2(point)
stations = self._stations
if bool(stations) and stations[-1].vertex.isclose(point, abs_tol=self.abs_tol):
# replace last station
stations.pop()
stations.append(LinearStation(point, float(start_width), float(end_width)))
def faces(self) -> Iterable[Face]:
"""Yields all faces as 4-tuples of :class:`~ezdxf.math.Vec2` objects.
First and last miter is 90 degrees if the path is not closed, otherwise
the intersection of first and last segment is taken into account,
a closed path has to have explicit the same last and first vertex.
"""
stations = self._stations
count = len(stations)
if count < 2: # Two or more stations required to create faces
return
def offset_rays(
segment: int,
) -> tuple[ConstructionRay, ConstructionRay]:
"""Create offset rays from segment offset vertices."""
def ray(v1, v2):
if v1.isclose(v2):
# vertices too close to define a ray, offset ray is parallel to segment:
angle = (
stations[segment].vertex - stations[segment + 1].vertex
).angle
return ConstructionRay(v1, angle)
else:
return ConstructionRay(v1, v2)
left1, left2, right1, right2 = segments[segment]
return ray(left1, left2), ray(right1, right2)
def intersect(
ray1: ConstructionRay, ray2: ConstructionRay, default: Vec2
) -> Vec2:
"""Intersect two rays but take parallel rays into account."""
# check for nearly parallel rays pi/100 ~1.8 degrees
angle = abs(ray1.direction.angle_between(ray2.direction))
if angle < 0.031415 or abs(math.pi - angle) < 0.031415:
return default
try:
return ray1.intersect(ray2)
except ParallelRaysError:
return default
# Path has to be explicit closed by vertices:
is_closed = stations[0].vertex.isclose(stations[-1].vertex)
segments = []
# Each segment has 4 offset vertices normal to the line from start- to
# end vertex
# 1st vertex left of line at the start, distance = start_width/2
# 2nd vertex left of line at the end, distance = end_width/2
# 3rd vertex right of line at the start, distance = start_width/2
# 4th vertex right of line at the end, distance = end_width/2
for station in range(count - 1):
start_vertex, start_width, end_width = stations[station]
end_vertex = stations[station + 1].vertex
# Start- and end vertex are never to close together, close stations
# will be merged in method LinearTrace.add_station().
segments.append(
_normal_offset_points(start_vertex, end_vertex, start_width, end_width)
)
# offset rays:
# 1 is the upper or left of line
# 2 is the lower or right of line
offset_ray1, offset_ray2 = offset_rays(0)
prev_offset_ray1 = None
prev_offset_ray2 = None
# Store last vertices explicit, they get modified for closed paths.
last_up1, last_up2, last_down1, last_down2 = segments[-1]
for i in range(len(segments)):
up1, up2, down1, down2 = segments[i]
if i == 0:
# Set first vertices of the first face.
if is_closed:
# Compute first two vertices as intersection of first and
# last segment
last_offset_ray1, last_offset_ray2 = offset_rays(len(segments) - 1)
vtx0 = intersect(last_offset_ray1, offset_ray1, up1)
vtx1 = intersect(last_offset_ray2, offset_ray2, down1)
# Store last vertices for the closing face.
last_up2 = vtx0
last_down2 = vtx1
else:
# Set first two vertices of the first face for an open path.
vtx0 = up1
vtx1 = down1
prev_offset_ray1 = offset_ray1
prev_offset_ray2 = offset_ray2
else:
# Compute first two vertices for the actual face.
vtx0 = intersect(prev_offset_ray1, offset_ray1, up1) # type: ignore
vtx1 = intersect(prev_offset_ray2, offset_ray2, down1) # type: ignore
if i < len(segments) - 1:
# Compute last two vertices for the actual face.
next_offset_ray1, next_offset_ray2 = offset_rays(i + 1)
vtx2 = intersect(next_offset_ray2, offset_ray2, down2)
vtx3 = intersect(next_offset_ray1, offset_ray1, up2)
prev_offset_ray1 = offset_ray1
prev_offset_ray2 = offset_ray2
offset_ray1 = next_offset_ray1
offset_ray2 = next_offset_ray2
else:
# Pickup last two vertices for the last face.
vtx2 = last_down2
vtx3 = last_up2
yield vtx0, vtx1, vtx2, vtx3
def _normal_offset_points(
start: Vec2, end: Vec2, start_width: float, end_width: float
) -> Face:
dir_vector = (end - start).normalize()
ortho = dir_vector.orthogonal(True)
offset_start = ortho.normalize(start_width / 2)
offset_end = ortho.normalize(end_width / 2)
return (
start + offset_start,
end + offset_end,
start - offset_start,
end - offset_end,
)
_NULLVEC2 = Vec2((0, 0))
class CurvedTrace(AbstractTrace):
"""2D banded curves like arcs or splines with start- and end width.
Represents always only one curved entity and all miter of curve segments
are perpendicular to curve tangents.
Accepts 3D input, but z-axis is ignored.
"""
def __init__(self) -> None:
self._stations: list[CurveStation] = []
def __len__(self):
return len(self._stations)
def __getitem__(self, item):
return self._stations[item]
@classmethod
def from_spline(
cls,
spline: BSpline,
start_width: float,
end_width: float,
segments: int,
) -> CurvedTrace:
"""
Create curved trace from a B-spline.
Args:
spline: :class:`~ezdxf.math.BSpline` object
start_width: start width
end_width: end width
segments: count of segments for approximation
"""
curve_trace = cls()
count = segments + 1
t = np.linspace(0, spline.max_t, count)
for (point, derivative), width in zip(
spline.derivatives(t, n=1), np.linspace(start_width, end_width, count)
):
normal = Vec2(derivative).orthogonal(True)
curve_trace._append(Vec2(point), normal, width)
return curve_trace
@classmethod
def from_arc(
cls,
arc: ConstructionArc,
start_width: float,
end_width: float,
segments: int = 64,
) -> CurvedTrace:
"""
Create curved trace from an arc.
Args:
arc: :class:`~ezdxf.math.ConstructionArc` object
start_width: start width
end_width: end width
segments: count of segments for full circle (360 degree)
approximation, partial arcs have proportional less segments,
but at least 3
Raises:
ValueError: if arc.radius <= 0
"""
if arc.radius <= 0:
raise ValueError(f"Invalid radius: {arc.radius}.")
curve_trace = cls()
count = max(math.ceil(arc.angle_span / 360.0 * segments), 3) + 1
center = Vec2(arc.center)
for point, width in zip(
arc.vertices(arc.angles(count)),
np.linspace(start_width, end_width, count),
):
curve_trace._append(point, point - center, width)
return curve_trace
def _append(self, point: Vec2, normal: Vec2, width: float) -> None:
"""
Add a curve trace station (like a vertex) at location `point`.
Args:
point: 2D curve location (vertex), z-axis of 3D vertices is ignored.
normal: curve normal
width: width of station
"""
if _NULLVEC2.isclose(normal):
normal = _NULLVEC2
else:
normal = normal.normalize(width / 2)
self._stations.append(CurveStation(point + normal, point - normal))
def faces(self) -> Iterable[Face]:
"""Yields all faces as 4-tuples of :class:`~ezdxf.math.Vec2` objects."""
count = len(self._stations)
if count < 2: # Two or more stations required to create faces
return
vtx0 = None
vtx1 = None
for vtx2, vtx3 in self._stations:
if vtx0 is None:
vtx0 = vtx3
vtx1 = vtx2
continue
yield vtx0, vtx1, vtx2, vtx3
vtx0 = vtx3
vtx1 = vtx2
class TraceBuilder(Sequence):
"""Sequence of 2D banded lines like polylines with start- and end width or
curves with start- and end width.
.. note::
Accepts 3D input, but z-axis is ignored. The :class:`TraceBuilder` is a
2D only object and uses only the :ref:`OCS` coordinates!
"""
def __init__(self) -> None:
self._traces: list[AbstractTrace] = []
self.abs_tol = 1e-12
def __len__(self):
return len(self._traces)
def __getitem__(self, item):
return self._traces[item]
def append(self, trace: AbstractTrace) -> None:
"""Append a new trace."""
self._traces.append(trace)
def faces(self) -> Iterable[Face]:
"""Yields all faces as 4-tuples of :class:`~ezdxf.math.Vec2` objects
in :ref:`OCS`.
"""
for trace in self._traces:
yield from trace.faces()
def faces_wcs(self, ocs: OCS, elevation: float) -> Iterable[Sequence[Vec3]]:
"""Yields all faces as 4-tuples of :class:`~ezdxf.math.Vec3` objects
in :ref:`WCS`.
"""
for face in self.faces():
yield tuple(ocs.points_to_wcs(Vec3(v.x, v.y, elevation) for v in face))
def polygons(self) -> Iterable[Polygon]:
"""Yields for each sub-trace a single polygon as sequence of
:class:`~ezdxf.math.Vec2` objects in :ref:`OCS`.
"""
for trace in self._traces:
yield trace.polygon()
def polygons_wcs(self, ocs: OCS, elevation: float) -> Iterable[Sequence[Vec3]]:
"""Yields for each sub-trace a single polygon as sequence of
:class:`~ezdxf.math.Vec3` objects in :ref:`WCS`.
"""
for trace in self._traces:
yield tuple(
ocs.points_to_wcs(Vec3(v.x, v.y, elevation) for v in trace.polygon())
)
def virtual_entities(
self, dxftype="TRACE", dxfattribs=None, doc: Optional[Drawing] = None
) -> Iterable[Quadrilateral]:
"""Yields faces as SOLID, TRACE or 3DFACE entities with DXF attributes
given in `dxfattribs`.
If a document is given, the doc attribute of the new entities will be
set and the new entities will be automatically added to the entity
database of that document.
.. note::
The :class:`TraceBuilder` is a 2D only object and uses only the
:ref:`OCS` coordinates!
Args:
dxftype: DXF type as string, "SOLID", "TRACE" or "3DFACE"
dxfattribs: DXF attributes for SOLID, TRACE or 3DFACE entities
doc: associated document
"""
for trace in self._traces:
yield from trace.virtual_entities(dxftype, dxfattribs, doc)
def close(self):
"""Close multi traces by merging first and last trace, if linear traces."""
traces = self._traces
if len(traces) < 2:
return
if isinstance(traces[0], LinearTrace) and isinstance(traces[-1], LinearTrace):
first = cast(LinearTrace, traces.pop(0))
last = cast(LinearTrace, traces[-1])
for point, start_width, end_width in first:
last.add_station(point, start_width, end_width)
@classmethod
def from_polyline(cls, polyline: DXFGraphic, segments: int = 64) -> TraceBuilder:
"""
Create a complete trace from a LWPOLYLINE or a 2D POLYLINE entity, the
trace consist of multiple sub-traces if :term:`bulge` values are
present. Uses only the :ref:`OCS` coordinates!
Args:
polyline: :class:`~ezdxf.entities.LWPolyline` or 2D
:class:`~ezdxf.entities.Polyline`
segments: count of segments for bulge approximation, given count is
for a full circle, partial arcs have proportional less segments,
but at least 3
"""
dxftype = polyline.dxftype()
if dxftype == "LWPOLYLINE":
polyline = cast("LWPolyline", polyline)
const_width = polyline.dxf.const_width
points = []
for x, y, start_width, end_width, bulge in polyline.lwpoints:
location = Vec2(x, y)
if const_width:
# This is AutoCAD behavior, BricsCAD uses const width
# only for missing width values.
start_width = const_width
end_width = const_width
points.append((location, start_width, end_width, bulge))
closed = polyline.closed
elif dxftype == "POLYLINE":
polyline = cast("Polyline", polyline)
if not polyline.is_2d_polyline:
raise TypeError("2D POLYLINE required")
closed = polyline.is_closed
default_start_width = polyline.dxf.default_start_width
default_end_width = polyline.dxf.default_end_width
points = []
for vertex in polyline.vertices:
location = Vec2(vertex.dxf.location)
if vertex.dxf.hasattr("start_width"):
start_width = vertex.dxf.start_width
else:
start_width = default_start_width
if vertex.dxf.hasattr("end_width"):
end_width = vertex.dxf.end_width
else:
end_width = default_end_width
bulge = vertex.dxf.bulge
points.append((location, start_width, end_width, bulge))
else:
raise TypeError(f"Invalid DXF type {dxftype}")
if closed and not points[0][0].isclose(points[-1][0]):
# close polyline explicit
points.append(points[0])
trace = cls()
store_bulge = None
store_start_width = None
store_end_width = None
store_point = None
linear_trace = LinearTrace()
for point, start_width, end_width, bulge in points:
if store_bulge:
center, start_angle, end_angle, radius = bulge_to_arc(
store_point, point, store_bulge
)
if radius > 0:
arc = ConstructionArc(
center,
radius,
math.degrees(start_angle),
math.degrees(end_angle),
is_counter_clockwise=True,
)
if arc.start_point.isclose(point):
sw = store_end_width
ew = store_start_width
else:
ew = store_end_width
sw = store_start_width
trace.append(CurvedTrace.from_arc(arc, sw, ew, segments))
store_bulge = None
if bulge != 0: # arc from prev_point to point
if linear_trace.is_started:
linear_trace.add_station(point, start_width, end_width)
trace.append(linear_trace)
linear_trace = LinearTrace()
store_bulge = bulge
store_start_width = start_width
store_end_width = end_width
store_point = point
continue
linear_trace.add_station(point, start_width, end_width)
if linear_trace.is_started:
trace.append(linear_trace)
if closed and len(trace) > 1:
# This is required for traces with multiple paths to create the correct
# miter at the closing point. (only linear to linear trace).
trace.close()
return trace