fix(task-11): hatch the cut face, not its chord, and take the scaffolding with it

draw_material_hatch's handles branch read only the traced loop's `points`
and dropped the parallel `bulges`, so a cut face bounded by an arc was
hatched as the chord polygon - exactly the failure README.md and the
comment at backends/ezdxf_backend.py:1172-1176 name. MEASURED headless on
a 40x10 box closed by a 180 degree r=20 arc: boundary_from_entities ->
area 1028.318531, draw_material_hatch -> {'ok': True, 'points': 4} and
entity_measure(hatch) -> {'area': 400.0, 'exact': True}. 628.3 mm2, 61% of
the face, silently unhatched, and the payload stated no accuracy.

entity_create_hatch takes a vertex list on both engines, so an arc edge has
to reach it as vertices; the bug was dropping the curve, not the fact of
vertices. Each bulged segment is now expanded onto its real arc by
_flatten_bulge, spaced so no chord departs from it by more than the
declared HATCH_FLATTEN_SAGITTA (0.01). Same repro after: points 53, area
1027.905195 against the exact 1028.318531 - an error of 0.413 (0.040%),
and the payload says so rather than claiming exactness: `accuracy`
"flatten_tolerance" or "exact", `flatten_tolerance`, `curved_edges`,
`area` (the polygon actually written) and `boundary_area` (the loop's own
exact area) so the two can be compared instead of trusted. `accuracy` is
decided by comparing those two numbers, so a backend that reports no
`bulges` at all is still reported approximate rather than assumed exact. A
straight-sided face reads `exact` / 400.0 as before.

The handles branch also had no area guard - the "at least three points"
refusal only ever covered `boundary` - so a LINE plus a 180 degree ARC
traced to a two-vertex loop and wrote a HATCH of area 0.0 with ok: True.
That case now hatches the real semicircle (37 points, 156.880337 of
157.079633), and a loop that genuinely encloses nothing (three collinear
segments, boundary_from_entities area 0.0) is refused by name before
anything is written: "the hatch boundary encloses no area (measured 0)".

Second: boundary_from_entities does not merely compute a loop, it *writes*
one. The traced LWPOLYLINE was left in the drawing on layer 0 and named
nowhere in the result, so the caller could neither find nor delete it, and
drawing_critique / drawing_finalize scored it against the drawing under the
layer-discipline rule. MEASURED headless on a 40x10 rectangle of four
LINEs: 4 entities before, 6 after, by_type {'LINE': 4, 'LWPOLYLINE': 1,
'HATCH': 1}, the polyline on layer '0'. After: 5 entities, {'LINE': 4,
'HATCH': 1}, and the result carries traced {'handle': '34', 'removed':
True} - the boolean is reported rather than assumed, so a delete that
failed is visible. The delete runs in a `finally:`, so the refusal paths
above do not leave scaffolding either (3 entities before and after).

Third: the module SOURCE block asserted the cutting-plane line's thin
middle was "the `center` role (CENTER, 0.25 mm)" and that the pair gave
"the ISO appearance" at 2:1. engineering/layers.py:19 defines CENTER at
0.18. MEASURED headless after draw_section_line on a fresh EzdxfBackend:
layer_list reports GEOMETRY lineweight 50.0 and CENTER 18.0, i.e. 0.50/0.18
= 2.78:1. A fabricated number in a standards SOURCE block is the one thing
these blocks exist to prevent, so the block now states the measured pair,
names the ratio as 2.78:1, and does *not* quote the standard's own
wide:narrow ratio, which this module cannot transcribe. The wrong figure in
the previous commit message (64a57d7) stands uncorrected in history; this
paragraph is the correction.

Tests (all four fail against the reviewed code, verified by re-running them
with _flatten_bulge and _discard_traced_loop stubbed back to the old
behaviour: 3 failed, and the SOURCE-block assertions checked against
HEAD:engineering/mech/marks.py -> [False, False, False]):
  - test_a_bulged_segment_is_expanded_onto_its_real_arc - every emitted
    vertex on the circle, every chord sagitta inside the tolerance
  - test_an_arc_bounded_cut_face_is_hatched_on_the_arc_not_its_chord -
    and entity_measure agrees with the payload
  - test_a_straight_sided_face_is_still_reported_exact
  - test_the_traced_outline_is_removed_and_reported_not_left_on_layer_0
  - test_a_loop_that_encloses_no_area_is_refused_and_leaves_nothing_behind
  - test_the_polygon_area_helper_is_orientation_blind
  - test_the_section_line_source_block_quotes_the_layer_set_it_draws_on -
    reads ENGINEERING_LAYERS, so the sentence cannot drift again
  - test_the_cutting_plane_line_really_lands_on_those_two_lineweights

No new ActiveX call was written, and nothing changed here can reach a live
seat: boundary_from_entities is capability-refused on COM
(backends/com_backend.py:1488, boundary_trace
FeatureCapability(False, "no_activex_member_command_only")), so the
handles branch is headless-only; the `boundary` branch is unchanged but for
the added payload keys. The live seat could not be reached to re-measure it
anyway - GetActiveObject("AutoCAD.Application") blocked without returning
on three attempts over ~370 s while acad.exe was running (sibling
contention), so the live engine was unavailable rather than absent.

server.py's hatch_material docstring now states the accuracy keys and the
traced-outline deletion; the tool summary is unchanged, and
scripts/generate_tool_inventory.py reproduces docs/tool-inventory.json
byte-for-byte (209 tools), so no registration moved.

tests/test_mech_marks.py: 25 passed (17 -> 25). Gates: ruff check clean
over the tree, ruff format clean, test_tool_registry, test_tool_inventory,
test_capability_contract, test_discovery_aliases, test_boundary_trace,
test_measure and test_release_consistency all pass. Full suite:
3351 passed, 24 skipped, 1 xfailed; the same 6 pre-existing failures
(test_benchmark_v4, test_capabilities, test_layouts_solids, test_page_setup,
test_sprint2_fixes, test_viewport_ops) verified identical with this change
stashed, and test_release_consistency.py::test_document_numbers_match_the_tree
deselected as Global Constraints instructs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 20:12:11 +03:00
co-authored by Claude Opus 5
parent 64a57d74a0
commit 34079ad0b4
3 changed files with 352 additions and 8 deletions
+182 -7
View File
@@ -18,9 +18,23 @@ principles of presentation - Basic conventions for cuts and sections": a
long-dash-dotted line that is *wide* at its ends and at every change of long-dash-dotted line that is *wide* at its ends and at every change of
direction, an arrow at each end pointing in the direction of viewing, and the direction, an arrow at each end pointing in the direction of viewing, and the
same capital letter at both ends. Line width is a layer property here, so the same capital letter at both ends. Line width is a layer property here, so the
wide ends are emitted on the `visible` role (GEOMETRY, 0.50 mm) and the thin wide ends are emitted on the `visible` role and the thin middle on the
middle on the `center` role (CENTER, 0.25 mm) - the ISO appearance, expressed `center` role - the wide/narrow contrast, expressed in the role vocabulary
in the role vocabulary this repository already has. this repository already has.
The two widths are not numbers this module picks: they are whatever
`engineering.layers.ENGINEERING_LAYERS` gives GEOMETRY and CENTER. MEASURED
on a drawing `drawing_new` bootstrapped:
GEOMETRY is 0.50 mm, CENTER is 0.18 mm, a ratio of 2.78:1.
That measured pair is what is stated here - not the standard's own
wide:narrow ratio, which this module does not transcribe.
A ratio quoted from memory beside a measured one is exactly the invented
number the SOURCE blocks above refuse to ship, and an earlier revision of
this block did ship one (it said CENTER was 0.25 mm and the pair 2:1; both
were wrong). `tests/test_mech_marks.py` now pins these three figures to
`ENGINEERING_LAYERS`, so the sentence cannot drift from the layer set again.
DECLARED, not transcribed: ISO 128-40 says the ends are thickened and that DECLARED, not transcribed: ISO 128-40 says the ends are thickened and that
arrows are placed there; it does not dimension them. The end-segment length, arrows are placed there; it does not dimension them. The end-segment length,
@@ -44,6 +58,7 @@ if TYPE_CHECKING: # pragma: no cover - typing only
__all__ = [ __all__ = [
"CENTRE_STYLES", "CENTRE_STYLES",
"HATCH_FLATTEN_SAGITTA",
"SECTION_STYLES", "SECTION_STYLES",
"centre_mark_prims", "centre_mark_prims",
"draw_centre_marks", "draw_centre_marks",
@@ -65,6 +80,17 @@ ARROW_SHAFT_FACTOR = 2.0 # arrow shaft length, perpendicular to the plane
ARROW_HEAD_FACTOR = 0.8 # arrowhead length (half-width is 0.35 of it, as elsewhere) ARROW_HEAD_FACTOR = 0.8 # arrowhead length (half-width is 0.35 of it, as elsewhere)
LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label
# DECLARED, not transcribed: the largest gap (drawing units) left between a
# curved boundary edge and the chords that stand in for it.
# `entity_create_hatch` takes a vertex list on both engines, so an arc edge
# has to reach it as vertices; what must not happen - and did - is dropping
# the curve and hatching the chord. MEASURED on the r=20 semicircular cut
# face in `tests/test_mech_marks.py`: 0.01 spaces that arc into 50 chords and
# hatches 1027.905195 of its real 1028.318531, an error of 0.413 (0.040%),
# against the 628.3 (61%) an earlier revision silently lost. The payload says
# `accuracy: "flatten_tolerance"` rather than claiming the area is exact.
HATCH_FLATTEN_SAGITTA = 0.01
def centre_mark_prims( def centre_mark_prims(
center: tuple[float, float], center: tuple[float, float],
@@ -335,6 +361,89 @@ async def draw_section_line(
return result return result
def _polygon_area(points: list[tuple[float, float]]) -> float:
"""The shoelace area of a closed straight-edged polygon.
Legitimate here and nowhere else in this module: it is applied to the
vertex list actually written into the HATCH, every edge of which really is
a straight line, so it measures what was drawn rather than approximating
what was meant. The curved input is handled by `_flatten_bulge` *before*
this sees it, and the residual is reported, never swallowed.
"""
total = 0.0
count = len(points)
for index in range(count):
x1, y1 = points[index]
x2, y2 = points[(index + 1) % count]
total += x1 * y2 - x2 * y1
return abs(total) / 2.0
def _flatten_bulge(
p1: tuple[float, float],
p2: tuple[float, float],
bulge: float,
tolerance: float = HATCH_FLATTEN_SAGITTA,
) -> list[tuple[float, float]]:
"""The interior vertices standing in for one bulged polyline segment.
DXF stores a circular arc between two LWPOLYLINE vertices as a *bulge*:
tan(theta/4) of the arc's included angle, positive counter-clockwise. The
points returned lie strictly between `p1` and `p2`, spaced so no chord
departs from the arc by more than `tolerance`; an empty list means the
segment is already straight.
"""
b = float(bulge)
if not math.isfinite(b) or abs(b) <= 1e-12:
return []
x1, y1 = float(p1[0]), float(p1[1])
x2, y2 = float(p2[0]), float(p2[1])
chord = math.hypot(x2 - x1, y2 - y1)
if chord <= 1e-12:
return []
theta = 4.0 * math.atan(b)
half = theta / 2.0
radius = (chord / 2.0) / abs(math.sin(half))
# Centre: off the chord's midpoint along its left normal, signed by the
# sweep, so a negative bulge puts it on the other side without a branch.
ux, uy = (x2 - x1) / chord, (y2 - y1) / chord
offset = -(chord / 2.0) / math.tan(half)
cx = (x1 + x2) / 2.0 + (-uy) * offset
cy = (y1 + y2) / 2.0 + ux * offset
tol = min(float(tolerance), radius)
step = 2.0 * math.acos(max(-1.0, min(1.0, 1.0 - tol / radius)))
count = max(2, math.ceil(abs(theta) / step)) if step > 1e-12 else 2
start = math.atan2(y1 - cy, x1 - cx)
return [
(
cx + radius * math.cos(start + theta * index / count),
cy + radius * math.sin(start + theta * index / count),
)
for index in range(1, count)
]
async def _discard_traced_loop(backend: AutoCADBackend, handle: str | None) -> bool:
"""Delete the outline `boundary_from_entities` drew, reporting whether it went.
`boundary_from_entities` does not merely compute a loop, it *writes* one:
an LWPOLYLINE on the current layer. `hatch_material` asked for a hatch, not
an outline, so leaving it behind drops a stray polyline on layer 0 that the
caller cannot find - and that `drawing_critique` / `drawing_finalize` then
score against the drawing under the layer-discipline rule. The boolean is
put in the result rather than assumed, so a delete that failed is visible.
"""
if not handle:
return False
try:
result = await backend.entity_delete(handle)
except Exception:
return False
if isinstance(result, dict):
return bool(result.get("ok", True))
return True
async def draw_material_hatch( async def draw_material_hatch(
backend: AutoCADBackend, backend: AutoCADBackend,
*, *,
@@ -350,6 +459,24 @@ async def draw_material_hatch(
`hatch_for` owns the map and its refusal, so an unknown material name is `hatch_for` owns the map and its refusal, so an unknown material name is
rejected here before anything is drawn. `scale` multiplies the material's rejected here before anything is drawn. `scale` multiplies the material's
own pattern scale; `angle` replaces the material's own angle when given. own pattern scale; `angle` replaces the material's own angle when given.
With `handles` the loop is chained by `boundary_from_entities`, and both of
that call's side effects are dealt with here rather than left to the
caller. Its traced LWPOLYLINE carries `points` **and** a parallel `bulges`
list; reading only `points` hatches the chord of every arc edge, which on a
semicircular cut face leaves 61% of the face unhatched while reporting
success. Each bulged segment is therefore expanded into chords no further
than `HATCH_FLATTEN_SAGITTA` from the real arc, and the payload states its
own accuracy the way `analysis_measure_entity` does: `accuracy` is
`"exact"` or `"flatten_tolerance"`, `area` is the polygon actually written
and `boundary_area` the exact area of the chained loop, so the two can be
compared instead of trusted. The traced outline itself is deleted - it was
scaffolding, and an orphan polyline on layer 0 is scored against the
drawing - and `traced` reports its handle and whether it went.
Refused: a loop that encloses no area, and a traced loop that flattens to
fewer than three vertices. Both used to write a HATCH of area 0.0 and
return `ok`.
""" """
from engineering.mech.annotate import ensure_annotation_layers from engineering.mech.annotate import ensure_annotation_layers
from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER
@@ -365,16 +492,58 @@ async def draw_material_hatch(
if boundary is not None and handles: if boundary is not None and handles:
raise ValueError("hatch_material takes boundary or handles, not both.") raise ValueError("hatch_material takes boundary or handles, not both.")
traced_report: dict | None = None
boundary_area: float | None = None
curved_edges = 0
if boundary is not None: if boundary is not None:
points = [[float(p[0]), float(p[1])] for p in boundary] points = [(float(p[0]), float(p[1])) for p in boundary]
if len(points) < 3: if len(points) < 3:
raise ValueError(f"a hatch boundary needs at least three points; got {len(points)}.") raise ValueError(f"a hatch boundary needs at least three points; got {len(points)}.")
else: else:
loop = await backend.boundary_from_entities(list(handles or [])) loop = await backend.boundary_from_entities(list(handles or []))
if not loop.get("ok"): if not loop.get("ok"):
raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}") raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}")
traced = await backend.entity_get(loop["handle"]) traced_handle = loop.get("handle")
points = [[float(p[0]), float(p[1])] for p in traced.properties["points"]] if loop.get("area") is not None:
boundary_area = float(loop["area"])
try:
traced = await backend.entity_get(traced_handle)
vertices = [(float(p[0]), float(p[1])) for p in traced.properties["points"]]
bulges = [float(b) for b in (traced.properties.get("bulges") or ())]
finally:
# In `finally:` so a drawing is never left with the scaffolding of
# a call that raised while reading it.
traced_report = {
"handle": traced_handle,
"removed": await _discard_traced_loop(backend, traced_handle),
}
points = []
for index, vertex in enumerate(vertices):
points.append(vertex)
bulge = bulges[index] if index < len(bulges) else 0.0
arc = _flatten_bulge(vertex, vertices[(index + 1) % len(vertices)], bulge)
if arc:
curved_edges += 1
points.extend(arc)
if len(points) < 3:
raise ValueError(
f"the chained loop flattens to {len(points)} vertices, which encloses nothing; "
"a hatch boundary needs at least three."
)
area = _polygon_area(points)
if area <= 1e-9:
raise ValueError(
f"the hatch boundary encloses no area (measured {area:g}); a zero-area HATCH "
"fills nothing while reporting success."
)
# The traced loop knows its own exact area, arcs and all. If the polygon
# written here does not match it, something was straightened - say so by
# name instead of reporting an area that is not the cut face's.
if boundary_area is None or abs(area - boundary_area) <= max(1e-9, 1e-9 * abs(boundary_area)):
accuracy, flatten_tolerance = "exact", None
else:
accuracy, flatten_tolerance = "flatten_tolerance", HATCH_FLATTEN_SAGITTA
pattern_scale = float(spec["scale"]) * factor pattern_scale = float(spec["scale"]) * factor
pattern_angle = float(spec["angle"]) if angle is None else float(angle) pattern_angle = float(spec["angle"]) if angle is None else float(angle)
@@ -389,7 +558,7 @@ async def draw_material_hatch(
created = await ensure_annotation_layers(backend, [target]) created = await ensure_annotation_layers(backend, [target])
info = await backend.entity_create_hatch( info = await backend.entity_create_hatch(
spec["pattern"], spec["pattern"],
points, [[x, y] for x, y in points],
pattern_scale, pattern_scale,
pattern_angle, pattern_angle,
target, target,
@@ -402,6 +571,12 @@ async def draw_material_hatch(
"scale": pattern_scale, "scale": pattern_scale,
"angle": pattern_angle, "angle": pattern_angle,
"points": len(points), "points": len(points),
"area": round(area, 6),
"boundary_area": boundary_area,
"accuracy": accuracy,
"flatten_tolerance": flatten_tolerance,
"curved_edges": curved_edges,
"layer": target, "layer": target,
"layers_created": list(created), "layers_created": list(created),
"traced": traced_report,
} }
+11 -1
View File
@@ -8445,10 +8445,20 @@ async def hatch_material(
is drawn), neither `boundary` nor `handles` (or both), a boundary with is drawn), neither `boundary` nor `handles` (or both), a boundary with
fewer than three points, and a non-positive `scale`. With `handles` the fewer than three points, and a non-positive `scale`. With `handles` the
loop is chained by `boundary_from_entities`, which the COM engine refuses loop is chained by `boundary_from_entities`, which the COM engine refuses
(capability `boundary_trace`) — pass `boundary` on a live seat, or run (capability `boundary_trace`) - pass `boundary` on a live seat, or run
headlessly. The HATCH layer is created first when the drawing lacks it: headlessly. The HATCH layer is created first when the drawing lacks it:
ActiveX refuses an entity on an absent layer and would otherwise leave the ActiveX refuses an entity on an absent layer and would otherwise leave the
hatch orphaned on layer 0. hatch orphaned on layer 0.
The payload states its own accuracy, as `analysis_measure_entity` does:
`area` is the polygon actually hatched, `boundary_area` the exact area of
the chained loop, and `accuracy` is `"exact"` or `"flatten_tolerance"` -
an arc edge reaches `entity_create_hatch` as chords, so a curved cut face
is hatched to within `HATCH_FLATTEN_SAGITTA` of itself rather than being
replaced by its chord polygon. With `handles`, the outline
`boundary_from_entities` draws is scaffolding and is deleted; `traced`
reports its handle and whether the delete succeeded. A loop enclosing no
area is refused instead of writing a HATCH of area 0.0.
""" """
from engineering.mech.marks import draw_material_hatch from engineering.mech.marks import draw_material_hatch
+159
View File
@@ -6,6 +6,8 @@ real ezdxf document through the `backend` fixture.
from __future__ import annotations from __future__ import annotations
import math
import pytest import pytest
from engineering.mech.marks import ( from engineering.mech.marks import (
@@ -334,3 +336,160 @@ async def test_a_drawing_that_already_has_the_hatch_layer_is_not_rewritten():
) )
assert result["layers_created"] == [] assert result["layers_created"] == []
assert not [c for c in backend.calls if c[0] == "layer_create"] assert not [c for c in backend.calls if c[0] == "layer_create"]
# ── the cut face is the face, not its chords (regression) ───────────────────
from engineering.layers import ENGINEERING_LAYERS # noqa: E402
from engineering.mech import marks as _marks # noqa: E402
from engineering.mech.marks import ( # noqa: E402
HATCH_FLATTEN_SAGITTA,
_flatten_bulge,
_polygon_area,
)
async def test_a_bulged_segment_is_expanded_onto_its_real_arc():
"""A bulge of 1 is a 180 degree arc; its chords must sit on that circle."""
interior = _flatten_bulge((40.0, 10.0), (0.0, 10.0), 1.0)
assert len(interior) >= 40 # a 0.01 sagitta on r=20 needs ~50 chords
for x, y in interior:
assert math.hypot(x - 20.0, y - 10.0) == pytest.approx(20.0, abs=1e-9)
assert y >= 10.0 # a positive bulge sweeps counter-clockwise, i.e. above
# Every chord's sagitta stays inside the declared tolerance.
walk = [(40.0, 10.0), *interior, (0.0, 10.0)]
for (x1, y1), (x2, y2) in zip(walk, walk[1:], strict=False):
chord = math.hypot(x2 - x1, y2 - y1)
sagitta = 20.0 - math.sqrt(max(0.0, 400.0 - (chord / 2.0) ** 2))
assert sagitta <= HATCH_FLATTEN_SAGITTA + 1e-12
assert _flatten_bulge((0.0, 0.0), (10.0, 0.0), 0.0) == []
async def _arc_topped_face(backend):
"""A 40x10 box closed by a 180 degree arc: 400 straight + 628.3 under the arc."""
lower = await backend.entity_create_line(0.0, 0.0, 40.0, 0.0, 0.0, 0.0, "GEOMETRY")
right = await backend.entity_create_line(40.0, 0.0, 40.0, 10.0, 0.0, 0.0, "GEOMETRY")
arc = await backend.entity_create_arc(20.0, 10.0, 20.0, 0.0, 180.0, "GEOMETRY")
left = await backend.entity_create_line(0.0, 10.0, 0.0, 0.0, 0.0, 0.0, "GEOMETRY")
return [lower.handle, right.handle, arc.handle, left.handle]
async def _rectangle(backend):
handles = []
for start, end in (
((0.0, 0.0), (40.0, 0.0)),
((40.0, 0.0), (40.0, 10.0)),
((40.0, 10.0), (0.0, 10.0)),
((0.0, 10.0), (0.0, 0.0)),
):
line = await backend.entity_create_line(start[0], start[1], end[0], end[1], 0.0, 0.0)
handles.append(line.handle)
return handles
async def test_an_arc_bounded_cut_face_is_hatched_on_the_arc_not_its_chord(backend):
"""The failure README.md names: reading `points` drops the parallel `bulges`.
Reading only `traced.properties["points"]` hatched the chord polygon -
400.0 of a 1028.3 cut face, 61% of it silently unhatched. The hatch must
now measure the face, and the payload must state its own accuracy.
"""
handles = await _arc_topped_face(backend)
exact = (await backend.boundary_from_entities(handles))["area"]
assert exact == pytest.approx(400.0 + 200.0 * math.pi, abs=1e-6)
result = await draw_material_hatch(backend, handles=handles, material="steel")
assert result["ok"] is True
assert result["curved_edges"] == 1
assert result["points"] > 4 # not the four-vertex chord polygon
assert result["boundary_area"] == pytest.approx(exact, abs=1e-6)
# Inside the declared flattening tolerance of the real face, not 61% short.
assert result["area"] == pytest.approx(exact, rel=1e-3)
assert result["accuracy"] == "flatten_tolerance"
assert result["flatten_tolerance"] == HATCH_FLATTEN_SAGITTA
# What the drawing holds agrees with what the payload claims.
measured = await backend.entity_measure(result["handle"])
assert measured["area"] == pytest.approx(result["area"], abs=1e-6)
async def test_a_straight_sided_face_is_still_reported_exact(backend):
handles = await _rectangle(backend)
result = await draw_material_hatch(backend, handles=handles, material="steel")
assert result["accuracy"] == "exact"
assert result["flatten_tolerance"] is None
assert result["curved_edges"] == 0
assert result["area"] == pytest.approx(400.0, abs=EPS)
assert result["boundary_area"] == pytest.approx(400.0, abs=EPS)
assert result["points"] == 4
async def test_the_traced_outline_is_removed_and_reported_not_left_on_layer_0(backend):
"""`boundary_from_entities` *draws* its loop; hatch_material asked for a hatch."""
handles = await _rectangle(backend)
before = await backend.analysis_stats()
result = await draw_material_hatch(backend, handles=handles, material="steel")
after = await backend.analysis_stats()
# Exactly one new entity: the hatch. No LWPOLYLINE scaffolding survives.
assert after["total_entities"] == before["total_entities"] + 1
assert after["by_type"].get("LWPOLYLINE", 0) == 0
assert result["traced"]["removed"] is True
assert result["traced"]["handle"]
live = {entity.dxf.handle for entity in backend._doc.modelspace()}
assert result["traced"]["handle"] not in live
async def test_a_loop_that_encloses_no_area_is_refused_and_leaves_nothing_behind(backend):
"""Three collinear segments chain into a closed loop of area 0.0.
The handles branch had no such guard - the 'at least three points' refusal
only ever covered `boundary` - so it wrote a HATCH of area 0.0 and returned
ok. The traced outline must still be cleaned up on the refusal path.
"""
handles = []
for start, end in (
((0.0, 0.0), (10.0, 0.0)),
((10.0, 0.0), (20.0, 0.0)),
((20.0, 0.0), (0.0, 0.0)),
):
line = await backend.entity_create_line(start[0], start[1], end[0], end[1], 0.0, 0.0)
handles.append(line.handle)
before = await backend.analysis_stats()
with pytest.raises(ValueError) as excinfo:
await draw_material_hatch(backend, handles=handles, material="steel")
assert "no area" in str(excinfo.value)
after = await backend.analysis_stats()
assert after["total_entities"] == before["total_entities"]
assert after["by_type"].get("LWPOLYLINE", 0) == 0
assert after["by_type"].get("HATCH", 0) == 0
async def test_the_polygon_area_helper_is_orientation_blind():
square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
assert _polygon_area(square) == pytest.approx(100.0, abs=EPS)
assert _polygon_area(list(reversed(square))) == pytest.approx(100.0, abs=EPS)
# ── the SOURCE block may not quote a lineweight the layer set does not have ──
async def test_the_section_line_source_block_quotes_the_layer_set_it_draws_on():
"""A SOURCE block asserting 0.25 mm while CENTER is 0.18 mm is a fabricated
number in the one place this repository refuses to fabricate one."""
widths = {name: weight for name, _c, _lt, weight, _d in ENGINEERING_LAYERS}
wide, narrow = widths["GEOMETRY"], widths["CENTER"]
doc = _marks.__doc__
assert f"GEOMETRY is {wide:.2f} mm" in doc
assert f"CENTER is {narrow:.2f} mm" in doc
assert f"{wide / narrow:.2f}:1" in doc
async def test_the_cutting_plane_line_really_lands_on_those_two_lineweights(backend):
result = await draw_section_line(
backend, (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0
)
widths = {layer.name: layer.lineweight for layer in await backend.layer_list()}
drawn = {(await backend.entity_get(handle)).layer for handle in result["handles"]}
assert {ROLE_LAYER["visible"], ROLE_LAYER["center"]} <= drawn
# lineweight is reported in 1/100 mm; the SOURCE block quotes millimetres.
assert widths[ROLE_LAYER["visible"]] == pytest.approx(50.0, abs=EPS)
assert widths[ROLE_LAYER["center"]] == pytest.approx(18.0, abs=EPS)