refactor: excel parse
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 format_text(self, value: float) -> str:
|
||||
text = super().format_text(value)
|
||||
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)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,245 @@
|
||||
# Copyright (c) 2020-2025, 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: Vec3 | None = None
|
||||
prev_bulge: float = 0.0
|
||||
|
||||
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.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) # type: ignore
|
||||
else:
|
||||
attribs["start"] = ocs.to_wcs(prev_point)
|
||||
attribs["end"] = ocs.to_wcs(point)
|
||||
yield factory.new(dxftype="LINE", dxfattribs=attribs, doc=doc) # type: ignore
|
||||
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
|
||||
@@ -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
|
||||
@@ -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) # type: ignore
|
||||
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) # type: ignore
|
||||
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 = 0.0
|
||||
store_start_width = 0.0
|
||||
store_end_width = 0.0
|
||||
store_point: UVec | None = None
|
||||
|
||||
linear_trace = LinearTrace()
|
||||
for point, start_width, end_width, bulge in points:
|
||||
if store_bulge != 0.0:
|
||||
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 = 0.0
|
||||
|
||||
if bulge != 0.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
|
||||
Reference in New Issue
Block a user