Three blocking review findings in engineering/mech/annotate.py.
1. draw_annotation_prims assigned ROLE_LAYER[role] without creating the
layer. EzdxfBackend._apply_attrs adds a missing layer; ActiveX does not.
MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(),
doc.Close(False) in finally): on a scratch document whose layer table is
['0'], draw_surface_texture(ComBackend(), at=(0,0), ra=3.2,
machining='required', lay='perpendicular', allowance=0.5) raised
"AutoCAD COM error (-0x7ffdfff7)" and left one orphan AcDbLine on layer 0
— a partial write with no rollback, on exactly the foreign-drawing path
the spec names (drawing_open runs no bootstrap). The same probe after the
fix: {'ok': True, count 6, 'layers_created': ['DIM','TEXT']}, model space
['AcDbLine' x4 on DIM, 'AcDbText' x2 on TEXT], layer table ['0','DIM',
'TEXT']. New helper ensure_annotation_layers() creates every target layer
up front, before the first entity, with its ENGINEERING_LAYERS colour /
linetype / lineweight; the result reports layers_created.
2. side='both' drew the two elementary symbols 0.4h apart — the other-side
one still stood on the (correctly omitted) identification line, so a
double-V butt weld came out as two detached halves that were not even
symmetric about the reference line. ISO 2553 places them back-to-back on
the continuous reference line: the other-side baseline is now 0.0 when
there is no identification line. tests/test_mech_annotate.py asserted the
wrong apex (-0.4*h), so the gate certified the defect; that test is
rewritten to assert the mirror pair, plus a double-fillet case.
3. For side='other' the size and length were written above the reference
line while the symbol hung below the identification line — 'a6' sat where
an arrow-side throat dimension sits, on a joint with no arrow-side weld.
Both texts now sit beside their own symbol (0.25h clear of the line it
stands on, on the symbol's side). New test covers side='other' with size
and length, and pins arrow/both unchanged.
All five new or rewritten tests fail against the pre-fix module (the layer
one with the measured 'Key not found' refusal, via a fake that models only
that measured behaviour) and pass after. tests/test_mech_annotate.py: 29
passed. Full suite: 3326 passed, 6 pre-existing failures unrelated to this
change (verified identical on the branch tip with the change stashed),
test_release_consistency.py::test_document_numbers_match_the_tree
deselected as instructed. No tool registration changed, so
docs/tool-inventory.json regenerates byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
544 lines
23 KiB
Python
544 lines
23 KiB
Python
"""ISO 21920-1 / ISO 1302 surface texture and ISO 2553 weld symbols.
|
|
|
|
Pure geometry is asserted as emitted primitive tuples (counts, roles,
|
|
coordinates to 1e-9); the async layer is asserted against a real ezdxf
|
|
document through the `backend` fixture. No screenshots anywhere.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from engineering.mech.annotate import (
|
|
ISO1302_SYMBOL_PROPORTIONS,
|
|
LAY_SYMBOLS,
|
|
MACHINING_KINDS,
|
|
SURFACE_HEIGHTS,
|
|
SURFACE_STANDARDS,
|
|
surface_texture_prims,
|
|
symbol_proportions,
|
|
)
|
|
from engineering.mech.primitives import Circle, Line, Text
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
EPS = 1e-9
|
|
K = 1.0 / math.sqrt(3.0) # tan(30 deg): the 60 deg legs' horizontal run per unit rise
|
|
|
|
|
|
def _flat(points):
|
|
"""Flatten a point sequence: pytest.approx refuses nested structures."""
|
|
return tuple(float(value) for point in points for value in point)
|
|
|
|
|
|
async def test_the_seven_iso1302_proportion_rows_are_the_transcribed_ones():
|
|
assert SURFACE_HEIGHTS == (2.5, 3.5, 5.0, 7.0, 10.0, 14.0, 20.0)
|
|
assert ISO1302_SYMBOL_PROPORTIONS[2.5] == (0.25, 3.5, 7.5)
|
|
assert ISO1302_SYMBOL_PROPORTIONS[3.5] == (0.35, 5.0, 10.5)
|
|
assert ISO1302_SYMBOL_PROPORTIONS[5.0] == (0.5, 7.0, 15.0)
|
|
assert ISO1302_SYMBOL_PROPORTIONS[7.0] == (0.7, 10.0, 21.0)
|
|
assert ISO1302_SYMBOL_PROPORTIONS[10.0] == (1.0, 14.0, 30.0)
|
|
assert ISO1302_SYMBOL_PROPORTIONS[14.0] == (1.4, 20.0, 42.0)
|
|
assert ISO1302_SYMBOL_PROPORTIONS[20.0] == (2.0, 28.0, 60.0)
|
|
# Every row: line width is h/10 and the minimum overall height is 3h.
|
|
for height, (width, _h1, h2) in ISO1302_SYMBOL_PROPORTIONS.items():
|
|
assert width == pytest.approx(height / 10.0, abs=EPS)
|
|
assert h2 == pytest.approx(3.0 * height, abs=EPS)
|
|
|
|
|
|
async def test_a_text_height_outside_the_table_is_refused_by_name():
|
|
with pytest.raises(ValueError) as excinfo:
|
|
symbol_proportions(4.0)
|
|
message = str(excinfo.value)
|
|
assert "ISO 1302" in message and "4 mm" in message
|
|
assert "2.5, 3.5, 5, 7, 10, 14, 20" in message
|
|
|
|
|
|
async def test_the_basic_symbol_is_a_60_degree_vee_on_the_dim_role():
|
|
prims = surface_texture_prims(height=3.5)
|
|
short, long_ = prims[0], prims[1]
|
|
assert isinstance(short, Line) and isinstance(long_, Line)
|
|
assert short.p1 == (0.0, 0.0) and long_.p1 == (0.0, 0.0)
|
|
assert short.p2 == pytest.approx((-5.0 * K, 5.0), abs=EPS)
|
|
assert long_.p2 == pytest.approx((10.5 * K, 10.5), abs=EPS)
|
|
assert short.role == "dim" and long_.role == "dim"
|
|
# The angle between the two legs is 60 degrees.
|
|
a = math.atan2(short.p2[1], short.p2[0])
|
|
b = math.atan2(long_.p2[1], long_.p2[0])
|
|
assert math.degrees(a - b) == pytest.approx(60.0, abs=1e-9)
|
|
# No bar, no circle: the basic symbol plus the extension line only.
|
|
assert [type(p).__name__ for p in prims] == ["Line", "Line", "Line"]
|
|
|
|
|
|
async def test_machining_required_closes_the_vee_with_the_bar():
|
|
prims = surface_texture_prims(height=3.5, machining="required")
|
|
bar = prims[2]
|
|
assert isinstance(bar, Line)
|
|
assert bar.p1 == pytest.approx((-5.0 * K, 5.0), abs=EPS)
|
|
assert bar.p2 == pytest.approx((5.0 * K, 5.0), abs=EPS)
|
|
assert bar.role == "dim"
|
|
|
|
|
|
async def test_machining_prohibited_inscribes_the_circle_in_the_vee():
|
|
prims = surface_texture_prims(height=3.5, machining="prohibited")
|
|
circle = prims[2]
|
|
assert isinstance(circle, Circle)
|
|
assert circle.center == pytest.approx((0.0, 10.0 / 3.0), abs=EPS)
|
|
assert circle.radius == pytest.approx(5.0 / 3.0, abs=EPS)
|
|
# Tangent to the bar height H1 and to both legs.
|
|
assert circle.center[1] + circle.radius == pytest.approx(5.0, abs=EPS)
|
|
|
|
|
|
async def test_ra_rz_process_lay_and_allowance_land_in_their_declared_positions():
|
|
prims = surface_texture_prims(
|
|
height=3.5, ra=3.2, rz=12.5, process="milled", lay="perpendicular", allowance=0.5
|
|
)
|
|
texts = [p for p in prims if isinstance(p, Text)]
|
|
assert [t.text for t in texts] == ["Ra 3.2", "Rz 12.5", "milled", "⊥ 0.5"]
|
|
x_leg = 10.5 * K
|
|
for t in texts:
|
|
assert t.at[0] == pytest.approx(x_leg + 0.5 * 3.5, abs=EPS)
|
|
assert t.height == 3.5 and t.role == "text"
|
|
# Above the extension line the three lines stack upward at 1.5h pitch.
|
|
assert texts[0].at[1] == pytest.approx(10.5 + 0.35 * 3.5, abs=EPS)
|
|
assert texts[1].at[1] == pytest.approx(10.5 + 0.35 * 3.5 + 1.5 * 3.5, abs=EPS)
|
|
assert texts[2].at[1] == pytest.approx(10.5 + 0.35 * 3.5 + 3.0 * 3.5, abs=EPS)
|
|
# Lay and allowance sit below it, on one line.
|
|
assert texts[3].at[1] == pytest.approx(10.5 - 0.35 * 3.5 - 3.5, abs=EPS)
|
|
|
|
|
|
async def test_the_extension_line_grows_with_the_widest_text():
|
|
prims = surface_texture_prims(height=3.5, ra=3.2)
|
|
extension = [p for p in prims if isinstance(p, Line)][-1]
|
|
x_leg = 10.5 * K
|
|
expected = 0.62 * 3.5 * len("Ra 3.2") + 3.5
|
|
assert extension.p1 == pytest.approx((x_leg, 10.5), abs=EPS)
|
|
assert extension.p2 == pytest.approx((x_leg + expected, 10.5), abs=EPS)
|
|
# With no text at all it falls back to the declared minimum of 2h.
|
|
bare = [p for p in surface_texture_prims(height=3.5) if isinstance(p, Line)][-1]
|
|
assert bare.p2[0] - bare.p1[0] == pytest.approx(2.0 * 3.5, abs=EPS)
|
|
|
|
|
|
async def test_all_around_puts_the_circle_on_the_kink():
|
|
prims = surface_texture_prims(height=3.5, all_around=True)
|
|
circle = [p for p in prims if isinstance(p, Circle)][0]
|
|
assert circle.center == pytest.approx((10.5 * K, 10.5), abs=EPS)
|
|
assert circle.radius == pytest.approx(0.35 * 3.5, abs=EPS)
|
|
|
|
|
|
async def test_iso1302_is_accepted_as_an_alias_designation_and_draws_the_same_symbol():
|
|
assert SURFACE_STANDARDS == ("ISO 21920-1", "ISO 1302")
|
|
new = surface_texture_prims(height=3.5, ra=3.2, standard="ISO 21920-1")
|
|
old = surface_texture_prims(height=3.5, ra=3.2, standard="ISO 1302")
|
|
assert new == old
|
|
with pytest.raises(ValueError) as excinfo:
|
|
surface_texture_prims(height=3.5, standard="ASME B46.1")
|
|
assert "ISO 21920-1, ISO 1302" in str(excinfo.value)
|
|
|
|
|
|
async def test_an_undefined_lay_and_an_undefined_machining_are_refused_with_the_list():
|
|
assert set(LAY_SYMBOLS) == {
|
|
"parallel",
|
|
"perpendicular",
|
|
"crossed",
|
|
"multidirectional",
|
|
"circular",
|
|
"radial",
|
|
"particulate",
|
|
}
|
|
assert MACHINING_KINDS == ("any", "required", "prohibited")
|
|
with pytest.raises(ValueError) as excinfo:
|
|
surface_texture_prims(lay="diagonal")
|
|
assert "circular, crossed, multidirectional" in str(excinfo.value)
|
|
with pytest.raises(ValueError) as excinfo:
|
|
surface_texture_prims(machining="maybe")
|
|
assert "any, required, prohibited" in str(excinfo.value)
|
|
|
|
|
|
# ── ISO 2553 weld symbols ───────────────────────────────────────────────────
|
|
|
|
from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tests
|
|
WELD_KINDS,
|
|
WELD_SIDES,
|
|
weld_symbol_prims,
|
|
)
|
|
from engineering.mech.primitives import Arc, Poly # noqa: E402
|
|
|
|
|
|
async def test_only_the_six_transcribed_elementary_symbols_are_offered():
|
|
assert WELD_KINDS == ("square", "v", "bevel", "u", "j", "fillet")
|
|
assert WELD_SIDES == ("arrow", "other", "both")
|
|
with pytest.raises(ValueError) as excinfo:
|
|
weld_symbol_prims(kind="spot")
|
|
message = str(excinfo.value)
|
|
assert "square, v, bevel, u, j, fillet" in message
|
|
assert "ISO 2553" in message
|
|
with pytest.raises(ValueError) as excinfo:
|
|
weld_symbol_prims(kind="fillet", side="both_sides")
|
|
assert "arrow, other, both" in str(excinfo.value)
|
|
|
|
|
|
async def test_the_reference_line_comes_first_and_the_identification_line_is_dashed():
|
|
prims = weld_symbol_prims(kind="fillet", height=3.5)
|
|
reference = prims[0]
|
|
assert isinstance(reference, Line)
|
|
assert reference.p1 == (0.0, 0.0)
|
|
assert reference.p2[1] == 0.0 and reference.p2[0] > 0.0
|
|
assert reference.role == "dim"
|
|
length = reference.p2[0]
|
|
dashes = [
|
|
p for p in prims if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS)
|
|
]
|
|
assert len(dashes) >= 2
|
|
assert all(d.p2[1] == pytest.approx(-0.4 * 3.5, abs=EPS) for d in dashes)
|
|
assert dashes[0].p1[0] == pytest.approx(0.0, abs=EPS)
|
|
assert dashes[0].p2[0] - dashes[0].p1[0] == pytest.approx(0.8 * 3.5, abs=EPS)
|
|
assert dashes[1].p1[0] - dashes[0].p2[0] == pytest.approx(0.4 * 3.5, abs=EPS)
|
|
assert dashes[-1].p2[0] <= length + EPS
|
|
|
|
|
|
async def test_a_symmetrical_weld_meets_back_to_back_on_the_reference_line():
|
|
"""ISO 2553: no identification line, and the two elementary symbols share it.
|
|
|
|
The earlier version of this test asserted the lower V apex at -0.4h, i.e.
|
|
on the (omitted) identification line, which drew a double-V butt weld as
|
|
two detached halves that were not even symmetric about the reference line.
|
|
"""
|
|
prims = weld_symbol_prims(kind="v", side="both", height=3.5)
|
|
assert not [
|
|
p for p in prims if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS)
|
|
]
|
|
# One V above the reference line, one below it, both standing on y = 0.
|
|
vees = [p for p in prims if isinstance(p, Poly) and len(p.points) == 3 and not p.closed]
|
|
assert len(vees) == 2
|
|
upper, lower = vees
|
|
assert upper.points[1][1] == pytest.approx(0.0, abs=EPS)
|
|
assert lower.points[1][1] == pytest.approx(0.0, abs=EPS)
|
|
# Mirror images of each other about y = 0, same x.
|
|
assert _flat(lower.points) == pytest.approx(
|
|
tuple(v if i % 2 == 0 else -v for i, v in enumerate(_flat(upper.points))), abs=EPS
|
|
)
|
|
ys = [y for _x, y in upper.points + lower.points]
|
|
assert (min(ys), max(ys)) == pytest.approx((-3.5, 3.5), abs=EPS)
|
|
|
|
|
|
async def test_a_symmetrical_fillet_weld_is_the_double_fillet_not_two_halves():
|
|
prims = weld_symbol_prims(kind="fillet", side="both", height=3.5)
|
|
triangles = [p for p in prims if isinstance(p, Poly) and p.closed]
|
|
assert len(triangles) == 2
|
|
x0 = triangles[0].points[0][0]
|
|
assert _flat(triangles[0].points) == pytest.approx((x0, 0.0, x0, 3.5, x0 + 3.5, 0.0), abs=EPS)
|
|
assert _flat(triangles[1].points) == pytest.approx((x0, 0.0, x0, -3.5, x0 + 3.5, 0.0), abs=EPS)
|
|
|
|
|
|
async def test_the_fillet_triangle_stands_on_the_line_with_its_leg_on_the_left():
|
|
prims = weld_symbol_prims(kind="fillet", height=3.5)
|
|
triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
|
|
x0 = triangle.points[0][0]
|
|
assert _flat(triangle.points) == pytest.approx((x0, 0.0, x0, 3.5, x0 + 3.5, 0.0), abs=EPS)
|
|
assert triangle.role == "dim"
|
|
|
|
|
|
async def test_the_other_side_symbol_hangs_below_the_identification_line():
|
|
prims = weld_symbol_prims(kind="fillet", side="other", height=3.5)
|
|
triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
|
|
x0 = triangle.points[0][0]
|
|
assert _flat(triangle.points) == pytest.approx(
|
|
(x0, -1.4, x0, -1.4 - 3.5, x0 + 3.5, -1.4), abs=EPS
|
|
)
|
|
|
|
|
|
async def test_other_side_dimensions_are_written_beside_the_other_side_symbol():
|
|
"""ISO 2553 writes the size left of the symbol and the length right of it.
|
|
|
|
For an other-side weld the symbol hangs from the identification line, so
|
|
both texts belong there too. Written above the reference line instead,
|
|
`a6` sits exactly where an arrow-side throat dimension sits - on a joint
|
|
that has no arrow-side weld.
|
|
"""
|
|
prims = weld_symbol_prims(kind="fillet", side="other", size=6, length=50, height=3.5)
|
|
triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
|
|
x_symbol = triangle.points[0][0]
|
|
texts = [p for p in prims if isinstance(p, Text)]
|
|
assert [t.text for t in texts] == ["a6", "50"]
|
|
# Beside the symbol: size to its left, length to its right.
|
|
assert texts[0].at[0] < x_symbol
|
|
assert texts[1].at[0] > x_symbol + 3.5
|
|
# Below the reference line, 0.25h clear of the identification line, so the
|
|
# whole text body lies between the identification line and the symbol's
|
|
# far edge - never above y = 0.
|
|
ident_y = -0.4 * 3.5
|
|
for t in texts:
|
|
assert t.at[1] == pytest.approx(ident_y - 1.25 * 3.5, abs=EPS)
|
|
assert t.at[1] + t.height < 0.0
|
|
# The arrow side is unchanged: its dimensions stay above the reference line.
|
|
arrow = [
|
|
p
|
|
for p in weld_symbol_prims(kind="fillet", side="arrow", size=6, length=50, height=3.5)
|
|
if isinstance(p, Text)
|
|
]
|
|
assert all(t.at[1] == pytest.approx(0.25 * 3.5, abs=EPS) for t in arrow)
|
|
# A symmetrical weld carries one set, beside its arrow-side symbol.
|
|
both = [
|
|
p
|
|
for p in weld_symbol_prims(kind="fillet", side="both", size=6, length=50, height=3.5)
|
|
if isinstance(p, Text)
|
|
]
|
|
assert all(t.at[1] == pytest.approx(0.25 * 3.5, abs=EPS) for t in both)
|
|
|
|
|
|
async def test_the_u_symbol_is_a_half_circle_between_two_uprights():
|
|
prims = weld_symbol_prims(kind="u", height=3.5)
|
|
arc = [p for p in prims if isinstance(p, Arc)][0]
|
|
assert arc.radius == pytest.approx(1.75, abs=EPS)
|
|
assert (arc.start_deg, arc.end_deg) == (180.0, 360.0)
|
|
assert arc.center[1] == pytest.approx(1.75, abs=EPS)
|
|
mirrored = [
|
|
p for p in weld_symbol_prims(kind="u", side="other", height=3.5) if isinstance(p, Arc)
|
|
][0]
|
|
assert (mirrored.start_deg, mirrored.end_deg) == (0.0, 180.0)
|
|
assert mirrored.center[1] == pytest.approx(-1.4 - 1.75, abs=EPS)
|
|
|
|
|
|
async def test_size_is_written_before_the_symbol_and_length_and_pitch_after_it():
|
|
prims = weld_symbol_prims(kind="fillet", size=5, length=50, pitch=100, height=3.5)
|
|
texts = [p for p in prims if isinstance(p, Text)]
|
|
assert [t.text for t in texts] == ["a5", "50 (100)"]
|
|
triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
|
|
x_symbol = triangle.points[0][0]
|
|
assert texts[0].at[0] < x_symbol
|
|
assert texts[1].at[0] > x_symbol + 3.5
|
|
# A string size is written verbatim, so z-notation stays available.
|
|
verbatim = weld_symbol_prims(kind="fillet", size="z7", height=3.5)
|
|
assert [p.text for p in verbatim if isinstance(p, Text)] == ["z7"]
|
|
with pytest.raises(ValueError) as excinfo:
|
|
weld_symbol_prims(kind="fillet", pitch=100, height=3.5)
|
|
assert "pitch needs length" in str(excinfo.value)
|
|
|
|
|
|
async def test_field_weld_flag_and_all_around_circle_sit_on_the_kink():
|
|
prims = weld_symbol_prims(kind="fillet", field_weld=True, all_around=True, height=3.5)
|
|
pole = [p for p in prims if isinstance(p, Line) and p.p1 == (0.0, 0.0) and p.p2[0] == 0.0][0]
|
|
assert pole.p2 == pytest.approx((0.0, 1.4 * 3.5), abs=EPS)
|
|
flag = [p for p in prims if isinstance(p, Poly) and p.closed and p.points[0][0] == 0.0][0]
|
|
assert _flat(flag.points) == pytest.approx(
|
|
(
|
|
0.0,
|
|
1.4 * 3.5,
|
|
0.7 * 3.5,
|
|
1.4 * 3.5 - 0.25 * 3.5,
|
|
0.0,
|
|
1.4 * 3.5 - 0.5 * 3.5,
|
|
),
|
|
abs=EPS,
|
|
)
|
|
circle = [p for p in prims if isinstance(p, Circle)][0]
|
|
assert circle.center == (0.0, 0.0)
|
|
assert circle.radius == pytest.approx(0.4 * 3.5, abs=EPS)
|
|
|
|
|
|
async def test_a_process_reference_gets_the_tail_fork_at_the_far_end():
|
|
prims = weld_symbol_prims(kind="fillet", process="ISO 4063 - 135", height=3.5)
|
|
reference = prims[0]
|
|
length = reference.p2[0]
|
|
fork = [
|
|
p for p in prims if isinstance(p, Line) and p.p1 == pytest.approx((length, 0.0), abs=EPS)
|
|
]
|
|
assert len(fork) == 2
|
|
assert fork[0].p2 == pytest.approx((length + 0.8 * 3.5, 0.5 * 3.5), abs=EPS)
|
|
assert fork[1].p2 == pytest.approx((length + 0.8 * 3.5, -0.5 * 3.5), abs=EPS)
|
|
tail_text = [p for p in prims if isinstance(p, Text)][-1]
|
|
assert tail_text.text == "ISO 4063 - 135"
|
|
assert tail_text.at[0] == pytest.approx(length + 3.5, abs=EPS)
|
|
|
|
|
|
# ── the async layer: primitives -> a real ezdxf document ────────────────────
|
|
|
|
from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tests
|
|
draw_annotation_prims,
|
|
draw_surface_texture,
|
|
draw_weld_symbol,
|
|
leader_prims,
|
|
)
|
|
from engineering.mech.primitives import ROLE_LAYER # noqa: E402
|
|
|
|
|
|
class _StrictLayerBackend:
|
|
"""A backend that refuses an entity on a layer the drawing does not have.
|
|
|
|
MEASURED on AutoCAD 2026: `Documents.Add()` gives a layer table of `['0']`,
|
|
and `entity.Layer = "DIM"` on it raises
|
|
`AutoCAD COM error (-0x7ffdfff7) ('Key not found')` - live ActiveX never
|
|
auto-creates the layer, while `EzdxfBackend._apply_attrs` does. This fake
|
|
models that measured refusal (and only it: it has no member the real
|
|
`AutoCADBackend` lacks), so the headless suite fails the way the live
|
|
engine fails. Before the fix it recorded one LINE and then raised; the
|
|
drawing kept that orphan, which is the partial write the fix removes.
|
|
"""
|
|
|
|
def __init__(self, layers=("0",)):
|
|
self.layers = {name.lower(): name for name in layers}
|
|
self.calls: list[tuple] = []
|
|
self._next = 0
|
|
|
|
async def layer_list(self):
|
|
self.calls.append(("layer_list",))
|
|
return [types.SimpleNamespace(name=name) for name in self.layers.values()]
|
|
|
|
async def layer_create(self, name, color=7, linetype="Continuous", lineweight=-3):
|
|
self.calls.append(("layer_create", name, color, linetype, lineweight))
|
|
self.layers[name.lower()] = name
|
|
return types.SimpleNamespace(name=name)
|
|
|
|
def _entity(self, kind, layer):
|
|
if layer is not None and layer.lower() not in self.layers:
|
|
raise RuntimeError(f"AutoCAD COM error (-0x7ffdfff7) ('Key not found'): {layer}")
|
|
self.calls.append((kind, layer))
|
|
self._next += 1
|
|
return types.SimpleNamespace(handle=f"{self._next:X}")
|
|
|
|
async def entity_create_line(self, x1, y1, x2, y2, z1=0.0, z2=0.0, layer=None, **kw):
|
|
return self._entity("LINE", layer)
|
|
|
|
async def entity_create_circle(self, cx, cy, radius, layer=None, **kw):
|
|
return self._entity("CIRCLE", layer)
|
|
|
|
async def entity_create_arc(self, cx, cy, radius, start_angle, end_angle, layer=None, **kw):
|
|
return self._entity("ARC", layer)
|
|
|
|
async def entity_create_polyline(self, points, closed=False, layer=None, **kw):
|
|
return self._entity("LWPOLYLINE", layer)
|
|
|
|
async def entity_create_text(self, text, x, y, height=2.5, rotation=0.0, layer=None, **kw):
|
|
return self._entity("TEXT", layer)
|
|
|
|
|
|
async def test_every_target_layer_is_created_before_the_first_entity():
|
|
backend = _StrictLayerBackend()
|
|
result = await draw_surface_texture(
|
|
backend, at=(0.0, 0.0), ra=3.2, machining="required", lay="perpendicular", allowance=0.5
|
|
)
|
|
assert result["ok"] is True and result["count"] == 6
|
|
assert result["layers_created"] == [ROLE_LAYER["dim"], ROLE_LAYER["text"]]
|
|
# Nothing is drawn until every layer the symbol needs exists.
|
|
first_entity = next(
|
|
i
|
|
for i, call in enumerate(backend.calls)
|
|
if call[0] != "layer_list" and call[0] != "layer_create"
|
|
)
|
|
assert {call[1] for call in backend.calls[:first_entity] if call[0] == "layer_create"} == {
|
|
ROLE_LAYER["dim"],
|
|
ROLE_LAYER["text"],
|
|
}
|
|
# Created with their ENGINEERING_LAYERS definition, not a bare default.
|
|
dim_create = next(c for c in backend.calls if c[0] == "layer_create" and c[1] == "DIM")
|
|
assert dim_create[2:] == (2, "Continuous", 0.18)
|
|
|
|
|
|
async def test_a_drawing_that_already_has_the_layers_is_not_rewritten():
|
|
backend = _StrictLayerBackend(layers=("0", "dim", "TEXT"))
|
|
result = await draw_weld_symbol(
|
|
backend, at=(0.0, 0.0), kind="fillet", size=5, length=50, leader_to=(-10.0, -10.0)
|
|
)
|
|
assert result["layers_created"] == []
|
|
assert not [c for c in backend.calls if c[0] == "layer_create"]
|
|
|
|
|
|
async def _types_and_layers(backend, handles):
|
|
rows = []
|
|
for handle in handles:
|
|
info = await backend.entity_get(handle)
|
|
rows.append((info.type, info.layer))
|
|
return rows
|
|
|
|
|
|
async def test_draw_annotation_prims_puts_every_role_on_its_own_layer(backend):
|
|
prims = (
|
|
Line((0.0, 0.0), (10.0, 0.0), "dim"),
|
|
Circle((5.0, 5.0), 2.0, "dim"),
|
|
Text((0.0, 12.0), "Ra 3.2", 3.5),
|
|
)
|
|
result = await draw_annotation_prims(backend, prims, at=(100.0, 50.0))
|
|
assert result["ok"] is True and result["count"] == 3
|
|
assert await _types_and_layers(backend, result["handles"]) == [
|
|
("LINE", ROLE_LAYER["dim"]),
|
|
("CIRCLE", ROLE_LAYER["dim"]),
|
|
("TEXT", ROLE_LAYER["text"]),
|
|
]
|
|
line = await backend.entity_get(result["handles"][0])
|
|
assert line.properties["start"] == pytest.approx([100.0, 50.0], abs=EPS)
|
|
assert line.properties["end"] == pytest.approx([110.0, 50.0], abs=EPS)
|
|
circle = await backend.entity_get(result["handles"][1])
|
|
assert circle.properties["center"] == pytest.approx([105.0, 55.0], abs=EPS)
|
|
|
|
|
|
async def test_an_explicit_layer_overrides_every_role(backend):
|
|
result = await draw_annotation_prims(
|
|
backend,
|
|
(Line((0.0, 0.0), (1.0, 0.0), "dim"), Text((0.0, 2.0), "x", 3.5)),
|
|
layer="ANNOT",
|
|
)
|
|
assert {layer for _type, layer in await _types_and_layers(backend, result["handles"])} == {
|
|
"ANNOT"
|
|
}
|
|
|
|
|
|
async def test_leader_prims_reuse_the_repository_arrowhead_proportions():
|
|
line, head = leader_prims((0.0, 0.0), (10.0, 0.0), arrow_size=2.5)
|
|
assert isinstance(line, Line) and isinstance(head, Poly) and head.closed
|
|
assert _flat(head.points) == pytest.approx(
|
|
(0.0, 0.0, 2.5, 2.5 * 0.35, 2.5, -2.5 * 0.35), abs=EPS
|
|
)
|
|
with pytest.raises(ValueError) as excinfo:
|
|
leader_prims((1.0, 1.0), (1.0, 1.0))
|
|
assert "distinct" in str(excinfo.value)
|
|
|
|
|
|
async def test_draw_surface_texture_draws_the_symbol_and_its_leader(backend):
|
|
result = await draw_surface_texture(
|
|
backend, at=(40.0, 60.0), leader_to=(20.0, 50.0), ra=3.2, machining="required"
|
|
)
|
|
assert result["ok"] is True and result["standard"] == "ISO 21920-1"
|
|
rows = await _types_and_layers(backend, result["handles"])
|
|
# 3 vee/bar lines + extension line + the Ra text + leader line + arrowhead.
|
|
assert [t for t, _ in rows] == [
|
|
"LINE",
|
|
"LINE",
|
|
"LINE",
|
|
"LINE",
|
|
"TEXT",
|
|
"LINE",
|
|
"LWPOLYLINE",
|
|
]
|
|
apex = await backend.entity_get(result["handles"][0])
|
|
assert apex.properties["start"] == pytest.approx([40.0, 60.0], abs=EPS)
|
|
leader = await backend.entity_get(result["handles"][5])
|
|
assert leader.properties["start"] == pytest.approx([20.0, 50.0], abs=EPS)
|
|
assert leader.properties["end"] == pytest.approx([40.0, 60.0], abs=EPS)
|
|
|
|
|
|
async def test_draw_weld_symbol_refuses_an_unknown_kind_before_touching_the_drawing(backend):
|
|
before = (await backend.analysis_stats())["total_entities"]
|
|
with pytest.raises(ValueError):
|
|
await draw_weld_symbol(backend, at=(0.0, 0.0), kind="seam")
|
|
assert (await backend.analysis_stats())["total_entities"] == before
|
|
|
|
|
|
async def test_draw_weld_symbol_places_the_kink_at_the_given_point(backend):
|
|
result = await draw_weld_symbol(
|
|
backend, at=(10.0, 20.0), kind="fillet", size=5, length=50, leader_to=(0.0, 10.0)
|
|
)
|
|
assert result["ok"] is True
|
|
reference = await backend.entity_get(result["handles"][0])
|
|
assert reference.properties["start"] == pytest.approx([10.0, 20.0], abs=EPS)
|
|
assert reference.properties["end"][1] == pytest.approx(20.0, abs=EPS)
|
|
texts = []
|
|
for handle in result["handles"]:
|
|
info = await backend.entity_get(handle)
|
|
if info.type == "TEXT":
|
|
texts.append(info.properties["text"])
|
|
assert texts == ["a5", "50"]
|