feat(mech): SECTION 23 completes with centre_marks, section_line, hatch_material
centre_marks draws ISO 128-23 centre marks or full centre lines and reads
each circle's real centre and radius back out of the drawing through
entity_get instead of being told them - a radius restated from memory is
the silent wrong number this repository keeps refusing to ship. A handle
that is not a CIRCLE or ARC, and a circle in a plane tilted out of WCS XY
(which has no radius in this frame), are both refused by handle before
anything is written. The dash proportions are not transcribed here: the
prims are plain lines on CENTER, whose linetype already carries them.
section_line draws the ISO 128-40 cutting-plane line - wide segments at
the ends on GEOMETRY, the thin long-dash-dot middle on CENTER, an arrow at
each end whose head sits on the line end and points the way the section is
viewed, the same letter beyond both arrows - and returns the five-key
plane dict {p1, p2, label, direction, style} that mech_view_add(kind=
"section") consumes, so the label on the plan and the label on the view
are the same string by construction. A viewing direction parallel to the
plane is refused rather than drawn.
hatch_material hatches through standards.materials.hatch_for, so the
ISO 128-50 map and its refusal stay in one place; scale multiplies the
material's own pattern scale and angle replaces its angle. An unknown
material is refused with the list before any entity is written.
DEVIATION - one defect in the plan's own draw_material_hatch, fixed here
with two tests that fail against the plan's code. It called
entity_create_hatch with ROLE_LAYER["hatch"] without creating that layer,
the same omission review found in Task 10's draw_annotation_prims.
MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(),
doc.Close(False) in a finally): on a scratch document whose layer table is
['0'], msp.AddHatch(0, "ANSI31", False) + AppendOuterLoop + Evaluate()
leaves the AcDbHatch in model space, and the very next statement
hatch.Layer = "HATCH" - which is the one line _apply_entity_attrs runs -
raises com_error (-2147352567, ..., 'Key not found', ..., -2145386476);
model space then still reports [('AcDbHatch', '0')] and the layer table is
still ['0'], so the refusal leaves an orphan hatch on layer 0. After
doc.Layers.Add("HATCH") the same assignment succeeds. drawing_open runs no
bootstrap, which is exactly when a cut face has no HATCH layer to land on.
draw_material_hatch now calls ensure_annotation_layers before creating the
hatch and reports layer / layers_created, as draw_annotation_prims does.
The whole task re-measured live afterwards through ComBackend on a fresh
scratch document (layer table ['0']): hatch_material -> {'ok': True,
'handle': '82', 'pattern': 'ANSI31', 'layer': 'HATCH', 'layers_created':
['HATCH']} with the entity read back as HATCH on HATCH; centre_marks on a
live CIRCLE r=12.5 -> sources [{'handle': '84', 'center': [80.0, 0.0],
'radius': 12.5}]; section_line -> 9 entities and the five-key plane; layer
table after ['0', 'CENTER', 'GEOMETRY', 'HATCH', 'TEXT']; document closed.
tests/test_mech_marks.py: 17 passed. Gates: ruff check clean, inventory
regenerated to 209 tools, test_tool_registry (engineering 12 -> 15, total
206 -> 209), test_discovery_aliases, test_tool_inventory,
test_capability_contract and test_release_consistency all pass. Full
suite: 3343 passed, the 6 pre-existing failures 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:
co-authored by
Claude Opus 5
parent
c607e75b75
commit
64a57d74a0
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 206 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
> **v1.5 release snapshot:** 209 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||||
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
|
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
|||||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
||||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||||
|
|
||||||
<sub>206 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
<sub>209 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||||
|
|
||||||
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
||||||
|
|
||||||
|
|||||||
@@ -372,6 +372,38 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"kose kaynagi",
|
"kose kaynagi",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
"centre_marks": ToolAliases(
|
||||||
|
acad=("CENTERMARK", "CENTERLINE", "DIMCENTER"),
|
||||||
|
synonyms=(
|
||||||
|
"centre mark",
|
||||||
|
"center mark",
|
||||||
|
"centre line",
|
||||||
|
"center line of a hole",
|
||||||
|
"cross at the centre",
|
||||||
|
"eksen cizgisi",
|
||||||
|
"merkez isareti",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"section_line": ToolAliases(
|
||||||
|
acad=("SECTIONPLANE",),
|
||||||
|
synonyms=(
|
||||||
|
"cutting plane line",
|
||||||
|
"section line",
|
||||||
|
"section a-a",
|
||||||
|
"where to cut the part",
|
||||||
|
"view direction arrows",
|
||||||
|
"kesit cizgisi",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"hatch_material": ToolAliases(
|
||||||
|
synonyms=(
|
||||||
|
"material hatch",
|
||||||
|
"hatch as cast iron",
|
||||||
|
"section hatching for steel",
|
||||||
|
"hatch pattern for a material",
|
||||||
|
"malzeme taramasi",
|
||||||
|
),
|
||||||
|
),
|
||||||
# ── Dimensions ──────────────────────────────────────────────────────────
|
# ── Dimensions ──────────────────────────────────────────────────────────
|
||||||
"dimension_aligned": ToolAliases(
|
"dimension_aligned": ToolAliases(
|
||||||
acad=("DIMALIGNED", "DAL"),
|
acad=("DIMALIGNED", "DAL"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 206,
|
"tools": 209,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 23
|
"groups": 23
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 11,
|
||||||
"engineering": 12,
|
"engineering": 15,
|
||||||
"entity_creation": 18,
|
"entity_creation": 18,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
@@ -343,6 +343,20 @@
|
|||||||
"destructiveHint": true
|
"destructiveHint": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "centre_marks",
|
||||||
|
"group": "engineering",
|
||||||
|
"cost": "mutate",
|
||||||
|
"summary": "Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.",
|
||||||
|
"tags": [
|
||||||
|
"engineering",
|
||||||
|
"mech"
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"title": "Centre Marks (ISO 128-23)",
|
||||||
|
"destructiveHint": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "construction_clear",
|
"name": "construction_clear",
|
||||||
"group": "premium",
|
"group": "premium",
|
||||||
@@ -1592,6 +1606,20 @@
|
|||||||
"destructiveHint": false
|
"destructiveHint": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "hatch_material",
|
||||||
|
"group": "engineering",
|
||||||
|
"cost": "mutate",
|
||||||
|
"summary": "Hatch a region with the ISO 128-50 section pattern for a named material.",
|
||||||
|
"tags": [
|
||||||
|
"engineering",
|
||||||
|
"mech"
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"title": "Material Hatch (ISO 128-50)",
|
||||||
|
"destructiveHint": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "hatch_set_gradient",
|
"name": "hatch_set_gradient",
|
||||||
"group": "entity_creation",
|
"group": "entity_creation",
|
||||||
@@ -2212,6 +2240,20 @@
|
|||||||
"readOnlyHint": true
|
"readOnlyHint": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "section_line",
|
||||||
|
"group": "engineering",
|
||||||
|
"cost": "mutate",
|
||||||
|
"summary": "Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.",
|
||||||
|
"tags": [
|
||||||
|
"engineering",
|
||||||
|
"mech"
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"title": "Section Line (ISO 128-40)",
|
||||||
|
"destructiveHint": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "selection_filter",
|
"name": "selection_filter",
|
||||||
"group": "entity_query",
|
"group": "entity_query",
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
"""Centre marks, the cutting-plane line and the material hatch.
|
||||||
|
|
||||||
|
Pure geometry: these functions return primitive descriptions from
|
||||||
|
`engineering.mech.primitives`; the `draw_*` functions are the only async part
|
||||||
|
and the only part that touches a backend.
|
||||||
|
|
||||||
|
SOURCE - centre marks and centre lines: ISO 128-23, "Technical drawings -
|
||||||
|
General principles of presentation - Lines on construction drawings", which
|
||||||
|
draws a centre line as a long-dash-dotted line that overruns the feature's
|
||||||
|
outline by a short amount. The *dash pattern itself is not written here*: the
|
||||||
|
prims are plain LINE entities on the CENTER layer, whose CENTER linetype
|
||||||
|
(bootstrapped by `drawing_new`) supplies the standard's dash proportions. That
|
||||||
|
is deliberate - transcribing a dash table this module cannot verify would put
|
||||||
|
an invented pattern next to a real one.
|
||||||
|
|
||||||
|
SOURCE - the cutting-plane line: ISO 128-40, "Technical drawings - General
|
||||||
|
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
|
||||||
|
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
|
||||||
|
wide ends are emitted on the `visible` role (GEOMETRY, 0.50 mm) and the thin
|
||||||
|
middle on the `center` role (CENTER, 0.25 mm) - the ISO appearance, expressed
|
||||||
|
in the role vocabulary this repository already has.
|
||||||
|
|
||||||
|
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,
|
||||||
|
the arrow shaft length, the arrowhead size and the label offset below are this
|
||||||
|
implementation's stated choices, expressed in text heights.
|
||||||
|
|
||||||
|
The material hatch is not authored here at all: it comes from
|
||||||
|
`engineering.mech.standards.materials.hatch_for`, which carries the ISO 128-50
|
||||||
|
map and its own refusal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from engineering.mech.primitives import Line, Poly, Prim, Text
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
|
from backends.base import AutoCADBackend
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CENTRE_STYLES",
|
||||||
|
"SECTION_STYLES",
|
||||||
|
"centre_mark_prims",
|
||||||
|
"draw_centre_marks",
|
||||||
|
"draw_material_hatch",
|
||||||
|
"draw_section_line",
|
||||||
|
"section_line_prims",
|
||||||
|
"section_plane",
|
||||||
|
]
|
||||||
|
|
||||||
|
CENTRE_STYLES: tuple[str, ...] = ("mark", "lines")
|
||||||
|
|
||||||
|
# The four styles `views.build_view(kind="section", style=...)` accepts; the
|
||||||
|
# dict this module returns is fed straight to it.
|
||||||
|
SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved")
|
||||||
|
|
||||||
|
# DECLARED layout constants for the cutting-plane line, in text heights.
|
||||||
|
END_SEGMENT_FACTOR = 2.0 # wide segment at each end
|
||||||
|
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)
|
||||||
|
LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label
|
||||||
|
|
||||||
|
|
||||||
|
def centre_mark_prims(
|
||||||
|
center: tuple[float, float],
|
||||||
|
radius: float,
|
||||||
|
*,
|
||||||
|
style: str = "mark",
|
||||||
|
extension: float = 3.0,
|
||||||
|
) -> tuple[Prim, ...]:
|
||||||
|
"""A centre mark or a pair of full centre lines for a circular feature.
|
||||||
|
|
||||||
|
`style="mark"` draws the short cross AutoCAD's DIMCEN draws: arms of
|
||||||
|
`extension` either side of the centre. `style="lines"` draws the two full
|
||||||
|
centre lines, overrunning the circle by `extension` at each end, which is
|
||||||
|
ISO 128-23's short overrun of the outline.
|
||||||
|
"""
|
||||||
|
if style not in CENTRE_STYLES:
|
||||||
|
raise ValueError(
|
||||||
|
f"centre style {style!r} is unknown; use one of {', '.join(CENTRE_STYLES)}."
|
||||||
|
)
|
||||||
|
ext = float(extension)
|
||||||
|
if not math.isfinite(ext) or ext <= 0.0:
|
||||||
|
raise ValueError(f"extension must be a positive finite number; got {extension!r}.")
|
||||||
|
cx, cy = float(center[0]), float(center[1])
|
||||||
|
if style == "mark":
|
||||||
|
arm = ext
|
||||||
|
else:
|
||||||
|
r = float(radius)
|
||||||
|
if not math.isfinite(r) or r <= 0.0:
|
||||||
|
raise ValueError(
|
||||||
|
f"style='lines' needs the feature's radius to know how far to overrun it; "
|
||||||
|
f"got radius={radius!r}."
|
||||||
|
)
|
||||||
|
arm = r + ext
|
||||||
|
return (
|
||||||
|
Line((cx - arm, cy), (cx + arm, cy), "center"),
|
||||||
|
Line((cx, cy - arm), (cx, cy + arm), "center"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(vx: float, vy: float, what: str) -> tuple[float, float]:
|
||||||
|
length = math.hypot(vx, vy)
|
||||||
|
if length <= 1e-12:
|
||||||
|
raise ValueError(f"{what} must be a non-zero vector; got ({vx:g}, {vy:g}).")
|
||||||
|
return vx / length, vy / length
|
||||||
|
|
||||||
|
|
||||||
|
def section_line_prims(
|
||||||
|
p1: tuple[float, float],
|
||||||
|
p2: tuple[float, float],
|
||||||
|
*,
|
||||||
|
label: str = "A",
|
||||||
|
direction: tuple[float, float] = (0.0, -1.0),
|
||||||
|
height: float = 5.0,
|
||||||
|
) -> tuple[Prim, ...]:
|
||||||
|
"""The ISO 128-40 cutting-plane line: wide ends, arrows, the letter at both ends.
|
||||||
|
|
||||||
|
`direction` is the direction of viewing and must not be parallel to the
|
||||||
|
plane. The arrowhead's tip sits on the end of the cutting-plane line and
|
||||||
|
its shaft extends away from the part, so the head reads as pointing the way
|
||||||
|
the section is viewed; the letter goes beyond the tail.
|
||||||
|
"""
|
||||||
|
if not str(label).strip():
|
||||||
|
raise ValueError("a cutting-plane line needs a label; ISO 128-40 letters both ends.")
|
||||||
|
h = float(height)
|
||||||
|
if not math.isfinite(h) or h <= 0.0:
|
||||||
|
raise ValueError(f"height must be a positive finite number; got {height!r}.")
|
||||||
|
x1, y1 = float(p1[0]), float(p1[1])
|
||||||
|
x2, y2 = float(p2[0]), float(p2[1])
|
||||||
|
ux, uy = _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)")
|
||||||
|
dx, dy = _unit(float(direction[0]), float(direction[1]), "direction")
|
||||||
|
if abs(ux * dy - uy * dx) < 1e-6:
|
||||||
|
raise ValueError(
|
||||||
|
f"direction ({dx:g}, {dy:g}) is parallel to the cutting plane; the viewing "
|
||||||
|
"direction must be perpendicular to it."
|
||||||
|
)
|
||||||
|
|
||||||
|
span = math.hypot(x2 - x1, y2 - y1)
|
||||||
|
end = END_SEGMENT_FACTOR * h
|
||||||
|
prims: list[Prim] = []
|
||||||
|
if span <= 2.0 * end + 1e-9:
|
||||||
|
prims.append(Line((x1, y1), (x2, y2), "visible"))
|
||||||
|
else:
|
||||||
|
a = (x1 + ux * end, y1 + uy * end)
|
||||||
|
b = (x2 - ux * end, y2 - uy * end)
|
||||||
|
prims.append(Line((x1, y1), a, "visible"))
|
||||||
|
prims.append(Line(b, (x2, y2), "visible"))
|
||||||
|
prims.append(Line(a, b, "center"))
|
||||||
|
|
||||||
|
shaft = ARROW_SHAFT_FACTOR * h
|
||||||
|
head = ARROW_HEAD_FACTOR * h
|
||||||
|
px, py = -dy, dx
|
||||||
|
text = str(label).strip()
|
||||||
|
for tip in ((x1, y1), (x2, y2)):
|
||||||
|
tail = (tip[0] - dx * shaft, tip[1] - dy * shaft)
|
||||||
|
prims.append(Line(tail, tip, "visible"))
|
||||||
|
base = (tip[0] - dx * head, tip[1] - dy * head)
|
||||||
|
prims.append(
|
||||||
|
Poly(
|
||||||
|
(
|
||||||
|
tip,
|
||||||
|
(base[0] + px * head * 0.35, base[1] + py * head * 0.35),
|
||||||
|
(base[0] - px * head * 0.35, base[1] - py * head * 0.35),
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
"visible",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for tip in ((x1, y1), (x2, y2)):
|
||||||
|
offset = shaft + LABEL_OFFSET_FACTOR * h
|
||||||
|
prims.append(Text((tip[0] - dx * offset, tip[1] - dy * offset), text, h))
|
||||||
|
return tuple(prims)
|
||||||
|
|
||||||
|
|
||||||
|
def section_plane(
|
||||||
|
p1: tuple[float, float],
|
||||||
|
p2: tuple[float, float],
|
||||||
|
*,
|
||||||
|
label: str = "A",
|
||||||
|
direction: tuple[float, float] = (0.0, -1.0),
|
||||||
|
style: str = "full",
|
||||||
|
) -> dict:
|
||||||
|
"""The plane definition `mech_view_add(kind="section")` consumes.
|
||||||
|
|
||||||
|
Exactly the five keys the plan pins as SECTION_PLANE, in that order, with
|
||||||
|
`direction` normalised to a unit vector. Returning it from the tool that
|
||||||
|
draws the cutting-plane line is what makes the label on the plan and the
|
||||||
|
label on the section view the same string by construction.
|
||||||
|
"""
|
||||||
|
if style not in SECTION_STYLES:
|
||||||
|
raise ValueError(
|
||||||
|
f"section style {style!r} is unknown; use one of {', '.join(SECTION_STYLES)}."
|
||||||
|
)
|
||||||
|
text = str(label).strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("a section plane needs a label; ISO 128-40 letters both ends.")
|
||||||
|
x1, y1 = float(p1[0]), float(p1[1])
|
||||||
|
x2, y2 = float(p2[0]), float(p2[1])
|
||||||
|
_unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)")
|
||||||
|
dx, dy = _unit(float(direction[0]), float(direction[1]), "direction")
|
||||||
|
return {
|
||||||
|
"p1": (x1, y1),
|
||||||
|
"p2": (x2, y2),
|
||||||
|
"label": text,
|
||||||
|
"direction": (dx, dy),
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── the async layer ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CIRCULAR_TYPES = ("CIRCLE", "ARC")
|
||||||
|
|
||||||
|
|
||||||
|
async def draw_centre_marks(
|
||||||
|
backend: AutoCADBackend,
|
||||||
|
*,
|
||||||
|
handles: list[str] | None = None,
|
||||||
|
centers: list[list[float]] | None = None,
|
||||||
|
style: str = "mark",
|
||||||
|
extension: float = 3.0,
|
||||||
|
layer: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Centre marks or centre lines for circular features.
|
||||||
|
|
||||||
|
With `handles`, the circles' real centre and radius are read back out of
|
||||||
|
the drawing through `entity_get` - the caller is never asked to restate a
|
||||||
|
radius it could get wrong. With `centers`, each row is `[x, y]` or
|
||||||
|
`[x, y, radius]`; `style="lines"` needs the radius, because the overrun is
|
||||||
|
measured from the outline.
|
||||||
|
"""
|
||||||
|
from engineering.mech.annotate import draw_annotation_prims
|
||||||
|
|
||||||
|
if not handles and not centers:
|
||||||
|
raise ValueError("centre_marks needs either handles or centers; both are empty.")
|
||||||
|
if handles and centers:
|
||||||
|
raise ValueError("centre_marks takes handles or centers, not both.")
|
||||||
|
|
||||||
|
sources: list[dict] = []
|
||||||
|
if handles:
|
||||||
|
for handle in handles:
|
||||||
|
info = await backend.entity_get(handle)
|
||||||
|
if info.type not in _CIRCULAR_TYPES:
|
||||||
|
raise ValueError(
|
||||||
|
f"handle {handle} is a {info.type}; a centre mark needs a circular "
|
||||||
|
f"feature ({', '.join(_CIRCULAR_TYPES)})."
|
||||||
|
)
|
||||||
|
radius = info.properties.get("radius")
|
||||||
|
if radius is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"handle {handle} lies in a plane tilted out of WCS XY, so it has no "
|
||||||
|
"radius in this frame (capability 'ocs_tilted_plane'); its centre mark "
|
||||||
|
"cannot be drawn in xy."
|
||||||
|
)
|
||||||
|
center = info.properties["center"]
|
||||||
|
sources.append(
|
||||||
|
{
|
||||||
|
"handle": info.handle,
|
||||||
|
"center": [float(center[0]), float(center[1])],
|
||||||
|
"radius": float(radius),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for index, row in enumerate(centers or []):
|
||||||
|
if len(row) < 2:
|
||||||
|
raise ValueError(f"centers[{index}] must be [x, y] or [x, y, radius].")
|
||||||
|
radius = float(row[2]) if len(row) > 2 else None
|
||||||
|
if style == "lines" and radius is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"centers[{index}] has no radius, and style='lines' measures its overrun "
|
||||||
|
"from the outline; pass [x, y, radius] or use style='mark'."
|
||||||
|
)
|
||||||
|
sources.append(
|
||||||
|
{
|
||||||
|
"handle": None,
|
||||||
|
"center": [float(row[0]), float(row[1])],
|
||||||
|
"radius": radius,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate every source before the first entity is written.
|
||||||
|
batches = [
|
||||||
|
centre_mark_prims(
|
||||||
|
(source["center"][0], source["center"][1]),
|
||||||
|
source["radius"] if source["radius"] is not None else 0.0,
|
||||||
|
style=style,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
for source in sources
|
||||||
|
]
|
||||||
|
handles_out: list[str] = []
|
||||||
|
for prims in batches:
|
||||||
|
drawn = await draw_annotation_prims(backend, prims, layer=layer)
|
||||||
|
handles_out.extend(drawn["handles"])
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"handles": handles_out,
|
||||||
|
"count": len(handles_out),
|
||||||
|
"style": style,
|
||||||
|
"sources": sources,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def draw_section_line(
|
||||||
|
backend: AutoCADBackend,
|
||||||
|
p1: tuple[float, float],
|
||||||
|
p2: tuple[float, float],
|
||||||
|
*,
|
||||||
|
label: str = "A",
|
||||||
|
direction: tuple[float, float] = (0.0, -1.0),
|
||||||
|
style: str = "full",
|
||||||
|
height: float = 5.0,
|
||||||
|
layer: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Draw the cutting-plane line and hand back the plane the view engine consumes."""
|
||||||
|
from engineering.mech.annotate import draw_annotation_prims
|
||||||
|
|
||||||
|
plane = section_plane(p1, p2, label=label, direction=direction, style=style)
|
||||||
|
prims = section_line_prims(
|
||||||
|
plane["p1"],
|
||||||
|
plane["p2"],
|
||||||
|
label=plane["label"],
|
||||||
|
direction=plane["direction"],
|
||||||
|
height=height,
|
||||||
|
)
|
||||||
|
result = await draw_annotation_prims(backend, prims, layer=layer)
|
||||||
|
result["plane"] = plane
|
||||||
|
result["label"] = plane["label"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def draw_material_hatch(
|
||||||
|
backend: AutoCADBackend,
|
||||||
|
*,
|
||||||
|
boundary: list[list[float]] | None = None,
|
||||||
|
handles: list[str] | None = None,
|
||||||
|
material: str = "steel",
|
||||||
|
scale: float = 1.0,
|
||||||
|
angle: float | None = None,
|
||||||
|
layer: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Hatch a closed region with the ISO 128-50 pattern for a material.
|
||||||
|
|
||||||
|
`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
|
||||||
|
own pattern scale; `angle` replaces the material's own angle when given.
|
||||||
|
"""
|
||||||
|
from engineering.mech.annotate import ensure_annotation_layers
|
||||||
|
from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER
|
||||||
|
from engineering.mech.standards.materials import hatch_for
|
||||||
|
|
||||||
|
spec = hatch_for(material) # refuses an unknown material, naming the list
|
||||||
|
factor = float(scale)
|
||||||
|
if not math.isfinite(factor) or factor <= 0.0:
|
||||||
|
raise ValueError(f"scale must be a positive finite number; got {scale!r}.")
|
||||||
|
|
||||||
|
if boundary is None and not handles:
|
||||||
|
raise ValueError("hatch_material needs either boundary points or handles; both are empty.")
|
||||||
|
if boundary is not None and handles:
|
||||||
|
raise ValueError("hatch_material takes boundary or handles, not both.")
|
||||||
|
|
||||||
|
if boundary is not None:
|
||||||
|
points = [[float(p[0]), float(p[1])] for p in boundary]
|
||||||
|
if len(points) < 3:
|
||||||
|
raise ValueError(f"a hatch boundary needs at least three points; got {len(points)}.")
|
||||||
|
else:
|
||||||
|
loop = await backend.boundary_from_entities(list(handles or []))
|
||||||
|
if not loop.get("ok"):
|
||||||
|
raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}")
|
||||||
|
traced = await backend.entity_get(loop["handle"])
|
||||||
|
points = [[float(p[0]), float(p[1])] for p in traced.properties["points"]]
|
||||||
|
|
||||||
|
pattern_scale = float(spec["scale"]) * factor
|
||||||
|
pattern_angle = float(spec["angle"]) if angle is None else float(angle)
|
||||||
|
target = layer or _ROLE_LAYER["hatch"]
|
||||||
|
# MEASURED on AutoCAD 2026: `msp.AddHatch(...)` + `Evaluate()` puts the
|
||||||
|
# AcDbHatch in model space, and the next statement - `entity.Layer = "HATCH"`
|
||||||
|
# inside `_apply_entity_attrs` - raises `('Key not found')` on a drawing whose
|
||||||
|
# layer table is `['0']`, leaving that hatch orphaned on layer 0. ActiveX never
|
||||||
|
# creates the layer; `EzdxfBackend._apply_attrs` does, so without this the same
|
||||||
|
# call succeeds headless and half-writes live. `drawing_open` runs no bootstrap,
|
||||||
|
# which is exactly when a cut face has no HATCH layer to land on.
|
||||||
|
created = await ensure_annotation_layers(backend, [target])
|
||||||
|
info = await backend.entity_create_hatch(
|
||||||
|
spec["pattern"],
|
||||||
|
points,
|
||||||
|
pattern_scale,
|
||||||
|
pattern_angle,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"handle": info.handle,
|
||||||
|
"material": material,
|
||||||
|
"pattern": spec["pattern"],
|
||||||
|
"scale": pattern_scale,
|
||||||
|
"angle": pattern_angle,
|
||||||
|
"points": len(points),
|
||||||
|
"layer": target,
|
||||||
|
"layers_created": list(created),
|
||||||
|
}
|
||||||
@@ -8124,7 +8124,7 @@ async def drawing_properties_set(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ── SECTION 23: Mechanical Annotation (2 tools) ─────────────────────────────
|
# ── SECTION 23: Mechanical Annotation (5 tools) ─────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ISO annotation symbols composed from LINE / ARC / CIRCLE / LWPOLYLINE / TEXT,
|
# ISO annotation symbols composed from LINE / ARC / CIRCLE / LWPOLYLINE / TEXT,
|
||||||
# so the same symbol lands on COM and ezdxf. The standards tables live in
|
# so the same symbol lands on COM and ezdxf. The standards tables live in
|
||||||
@@ -8304,6 +8304,166 @@ async def weld_symbol(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cad_tool(
|
||||||
|
summary="Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.",
|
||||||
|
cost="mutate",
|
||||||
|
)
|
||||||
|
@mcp.tool(
|
||||||
|
annotations={"title": "Centre Marks (ISO 128-23)", "destructiveHint": False},
|
||||||
|
tags={"engineering", "mech"},
|
||||||
|
)
|
||||||
|
async def centre_marks(
|
||||||
|
handles: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
"Handles of CIRCLEs/ARCs; their real centre and radius are read from the drawing.",
|
||||||
|
] = None,
|
||||||
|
centers: Annotated[
|
||||||
|
list[list[float]] | None,
|
||||||
|
"Explicit centres as [x, y] or [x, y, radius]; style='lines' needs the radius.",
|
||||||
|
] = None,
|
||||||
|
style: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
default="mark",
|
||||||
|
description="mark (short cross at the centre) | lines (full centre lines).",
|
||||||
|
),
|
||||||
|
] = "mark",
|
||||||
|
extension: Annotated[
|
||||||
|
float,
|
||||||
|
Field(
|
||||||
|
default=3.0,
|
||||||
|
gt=0,
|
||||||
|
description="Arm length (mark) or overrun past the outline (lines), mm.",
|
||||||
|
),
|
||||||
|
] = 3.0,
|
||||||
|
layer: Annotated[str | None, "Override the CENTER role layer."] = None,
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Centre marks on the CENTER layer, whose linetype carries ISO 128-23's dash pattern.
|
||||||
|
|
||||||
|
Pass `handles` and the radii are read back with `entity_get` rather than
|
||||||
|
restated — a radius from memory is the classic silent wrong number.
|
||||||
|
Refusals, all before the first entity is written: neither `handles` nor
|
||||||
|
`centers` given (or both), a handle that is not a CIRCLE or ARC, a circle
|
||||||
|
in a plane tilted out of WCS XY (capability `ocs_tilted_plane`, so it has
|
||||||
|
no radius in this frame), a `style` outside mark/lines, a non-positive
|
||||||
|
`extension`, and `style='lines'` on a centre given without a radius.
|
||||||
|
"""
|
||||||
|
from engineering.mech.marks import draw_centre_marks
|
||||||
|
|
||||||
|
await ctx.debug(f"Centre marks style={style}")
|
||||||
|
return await draw_centre_marks(
|
||||||
|
_backend(ctx),
|
||||||
|
handles=handles,
|
||||||
|
centers=centers,
|
||||||
|
style=style,
|
||||||
|
extension=extension,
|
||||||
|
layer=layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cad_tool(
|
||||||
|
summary="Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.",
|
||||||
|
cost="mutate",
|
||||||
|
)
|
||||||
|
@mcp.tool(
|
||||||
|
annotations={"title": "Section Line (ISO 128-40)", "destructiveHint": False},
|
||||||
|
tags={"engineering", "mech"},
|
||||||
|
)
|
||||||
|
async def section_line(
|
||||||
|
x1: Annotated[float, "Cutting plane start X (WCS)."],
|
||||||
|
y1: Annotated[float, "Cutting plane start Y (WCS)."],
|
||||||
|
x2: Annotated[float, "Cutting plane end X (WCS)."],
|
||||||
|
y2: Annotated[float, "Cutting plane end Y (WCS)."],
|
||||||
|
label: Annotated[str, "Capital letter drawn at both ends, e.g. 'A'."] = "A",
|
||||||
|
direction: Annotated[
|
||||||
|
list[float] | None,
|
||||||
|
"Viewing direction as [dx, dy]; must not be parallel to the cutting plane.",
|
||||||
|
] = None,
|
||||||
|
style: Annotated[
|
||||||
|
str, "full | half | offset | revolved — carried into the returned plane."
|
||||||
|
] = "full",
|
||||||
|
height: Annotated[float, Field(default=5.0, gt=0, description="Label height (mm).")] = 5.0,
|
||||||
|
layer: Annotated[str | None, "Override the role layers (GEOMETRY/CENTER/TEXT)."] = None,
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Draw the cutting-plane line and hand back the plane a section view consumes.
|
||||||
|
|
||||||
|
Wide segments at the ends on GEOMETRY, the thin long-dash-dot middle on
|
||||||
|
CENTER, an arrow at each end whose head sits on the line end and points the
|
||||||
|
way the section is viewed, and the same letter beyond both arrows.
|
||||||
|
`payload["plane"]` is exactly `{p1, p2, label, direction, style}` — feed it
|
||||||
|
to the section view so the label on the plan and the label on the view
|
||||||
|
cannot disagree. Refusals, before any entity is written: `p1 == p2`, a zero
|
||||||
|
or parallel viewing direction, an empty `label`, a `style` outside
|
||||||
|
full/half/offset/revolved, and a non-positive `height`.
|
||||||
|
"""
|
||||||
|
from engineering.mech.marks import draw_section_line
|
||||||
|
|
||||||
|
await ctx.debug(f"Section line {label} ({x1},{y1})-({x2},{y2})")
|
||||||
|
return await draw_section_line(
|
||||||
|
_backend(ctx),
|
||||||
|
(x1, y1),
|
||||||
|
(x2, y2),
|
||||||
|
label=label,
|
||||||
|
direction=tuple(direction) if direction else (0.0, -1.0),
|
||||||
|
style=style,
|
||||||
|
height=height,
|
||||||
|
layer=layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cad_tool(
|
||||||
|
summary="Hatch a region with the ISO 128-50 section pattern for a named material.",
|
||||||
|
cost="mutate",
|
||||||
|
)
|
||||||
|
@mcp.tool(
|
||||||
|
annotations={"title": "Material Hatch (ISO 128-50)", "destructiveHint": False},
|
||||||
|
tags={"engineering", "mech"},
|
||||||
|
)
|
||||||
|
async def hatch_material(
|
||||||
|
material: Annotated[str, "Material name; an unknown one is refused with the list."] = "steel",
|
||||||
|
boundary: Annotated[
|
||||||
|
list[list[float]] | None, "Closed boundary as [[x, y], ...] — at least three points."
|
||||||
|
] = None,
|
||||||
|
handles: Annotated[
|
||||||
|
list[str] | None, "Entities to chain into one closed loop instead of a boundary."
|
||||||
|
] = None,
|
||||||
|
scale: Annotated[
|
||||||
|
float,
|
||||||
|
Field(default=1.0, gt=0, description="Multiplies the material's own pattern scale."),
|
||||||
|
] = 1.0,
|
||||||
|
angle: Annotated[
|
||||||
|
float | None, "Replaces the material's own pattern angle (degrees) when given."
|
||||||
|
] = None,
|
||||||
|
layer: Annotated[str | None, "Override the HATCH role layer."] = None,
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Section-hatch a cut face with the pattern ISO 128-50 gives its material.
|
||||||
|
|
||||||
|
Refusals: an unknown material (named, with the full list, before anything
|
||||||
|
is drawn), neither `boundary` nor `handles` (or both), a boundary with
|
||||||
|
fewer than three points, and a non-positive `scale`. With `handles` the
|
||||||
|
loop is chained by `boundary_from_entities`, which the COM engine refuses
|
||||||
|
(capability `boundary_trace`) — pass `boundary` on a live seat, or run
|
||||||
|
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
|
||||||
|
hatch orphaned on layer 0.
|
||||||
|
"""
|
||||||
|
from engineering.mech.marks import draw_material_hatch
|
||||||
|
|
||||||
|
await ctx.debug(f"Material hatch {material}")
|
||||||
|
return await draw_material_hatch(
|
||||||
|
_backend(ctx),
|
||||||
|
boundary=boundary,
|
||||||
|
handles=handles,
|
||||||
|
material=material,
|
||||||
|
scale=scale,
|
||||||
|
angle=angle,
|
||||||
|
layer=layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"""Centre marks (ISO 128-23), the cutting-plane line (ISO 128-40) and material hatch.
|
||||||
|
|
||||||
|
Pure geometry asserted as emitted primitive tuples; the async layer against a
|
||||||
|
real ezdxf document through the `backend` fixture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engineering.mech.marks import (
|
||||||
|
CENTRE_STYLES,
|
||||||
|
centre_mark_prims,
|
||||||
|
)
|
||||||
|
from engineering.mech.primitives import Line
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
EPS = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
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_a_centre_mark_is_a_small_cross_on_the_centre_role():
|
||||||
|
prims = centre_mark_prims((10.0, 20.0), 5.0, style="mark", extension=3.0)
|
||||||
|
assert len(prims) == 2
|
||||||
|
horizontal, vertical = prims
|
||||||
|
assert isinstance(horizontal, Line) and isinstance(vertical, Line)
|
||||||
|
assert horizontal.p1 == pytest.approx((7.0, 20.0), abs=EPS)
|
||||||
|
assert horizontal.p2 == pytest.approx((13.0, 20.0), abs=EPS)
|
||||||
|
assert vertical.p1 == pytest.approx((10.0, 17.0), abs=EPS)
|
||||||
|
assert vertical.p2 == pytest.approx((10.0, 23.0), abs=EPS)
|
||||||
|
assert horizontal.role == "center" and vertical.role == "center"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_centre_lines_cross_the_circle_and_overrun_it_by_the_extension():
|
||||||
|
prims = centre_mark_prims((10.0, 20.0), 5.0, style="lines", extension=3.0)
|
||||||
|
horizontal, vertical = prims
|
||||||
|
assert horizontal.p1 == pytest.approx((2.0, 20.0), abs=EPS)
|
||||||
|
assert horizontal.p2 == pytest.approx((18.0, 20.0), abs=EPS)
|
||||||
|
assert vertical.p1 == pytest.approx((10.0, 12.0), abs=EPS)
|
||||||
|
assert vertical.p2 == pytest.approx((10.0, 28.0), abs=EPS)
|
||||||
|
assert horizontal.role == "center" and vertical.role == "center"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_style_enum_is_closed_and_a_bad_radius_is_refused():
|
||||||
|
assert CENTRE_STYLES == ("mark", "lines")
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
centre_mark_prims((0.0, 0.0), 5.0, style="cross")
|
||||||
|
assert "mark, lines" in str(excinfo.value)
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
centre_mark_prims((0.0, 0.0), 0.0, style="lines")
|
||||||
|
assert "radius" in str(excinfo.value)
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
centre_mark_prims((0.0, 0.0), 5.0, extension=0.0)
|
||||||
|
assert "extension" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── ISO 128-40 cutting-plane line ───────────────────────────────────────────
|
||||||
|
|
||||||
|
from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests
|
||||||
|
SECTION_STYLES,
|
||||||
|
section_line_prims,
|
||||||
|
section_plane,
|
||||||
|
)
|
||||||
|
from engineering.mech.primitives import Poly, Text # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_cutting_plane_line_is_thick_at_the_ends_and_thin_between():
|
||||||
|
prims = section_line_prims(
|
||||||
|
(0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0
|
||||||
|
)
|
||||||
|
head_end, tail_end, middle = prims[0], prims[1], prims[2]
|
||||||
|
assert head_end.p1 == pytest.approx((0.0, 0.0), abs=EPS)
|
||||||
|
assert head_end.p2 == pytest.approx((10.0, 0.0), abs=EPS)
|
||||||
|
assert head_end.role == "visible"
|
||||||
|
assert tail_end.p1 == pytest.approx((90.0, 0.0), abs=EPS)
|
||||||
|
assert tail_end.p2 == pytest.approx((100.0, 0.0), abs=EPS)
|
||||||
|
assert tail_end.role == "visible"
|
||||||
|
assert middle.p1 == pytest.approx((10.0, 0.0), abs=EPS)
|
||||||
|
assert middle.p2 == pytest.approx((90.0, 0.0), abs=EPS)
|
||||||
|
assert middle.role == "center"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_short_cutting_plane_is_one_wide_segment_with_no_thin_middle():
|
||||||
|
prims = section_line_prims((0.0, 0.0), (15.0, 0.0), height=5.0)
|
||||||
|
assert prims[0].p1 == pytest.approx((0.0, 0.0), abs=EPS)
|
||||||
|
assert prims[0].p2 == pytest.approx((15.0, 0.0), abs=EPS)
|
||||||
|
assert prims[0].role == "visible"
|
||||||
|
assert not [p for p in prims if isinstance(p, Line) and p.role == "center"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_each_end_gets_an_arrow_pointing_the_viewing_direction_and_the_same_letter():
|
||||||
|
prims = section_line_prims(
|
||||||
|
(0.0, 0.0), (100.0, 0.0), label="B", direction=(0.0, -1.0), height=5.0
|
||||||
|
)
|
||||||
|
shafts = [p for p in prims if isinstance(p, Line) and p.p1[0] == p.p2[0]]
|
||||||
|
assert len(shafts) == 2
|
||||||
|
# The shaft comes from outside and the head's tip sits on the line end.
|
||||||
|
assert shafts[0].p1 == pytest.approx((0.0, 10.0), abs=EPS)
|
||||||
|
assert shafts[0].p2 == pytest.approx((0.0, 0.0), abs=EPS)
|
||||||
|
assert shafts[1].p1 == pytest.approx((100.0, 10.0), abs=EPS)
|
||||||
|
assert shafts[1].p2 == pytest.approx((100.0, 0.0), abs=EPS)
|
||||||
|
heads = [p for p in prims if isinstance(p, Poly)]
|
||||||
|
assert len(heads) == 2 and all(h.closed for h in heads)
|
||||||
|
assert _flat(heads[0].points) == pytest.approx((0.0, 0.0, 1.4, 4.0, -1.4, 4.0), abs=EPS)
|
||||||
|
labels = [p for p in prims if isinstance(p, Text)]
|
||||||
|
assert [t.text for t in labels] == ["B", "B"]
|
||||||
|
assert labels[0].at == pytest.approx((0.0, 13.5), abs=EPS)
|
||||||
|
assert labels[1].at == pytest.approx((100.0, 13.5), abs=EPS)
|
||||||
|
assert all(t.height == 5.0 and t.role == "text" for t in labels)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_viewing_direction_parallel_to_the_plane_is_refused():
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
section_line_prims((0.0, 0.0), (100.0, 0.0), direction=(1.0, 0.0))
|
||||||
|
assert "perpendicular" in str(excinfo.value)
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
section_line_prims((0.0, 0.0), (0.0, 0.0), direction=(0.0, -1.0))
|
||||||
|
assert "distinct" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_section_plane_is_exactly_the_dict_the_view_engine_consumes():
|
||||||
|
plane = section_plane((0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -2.0))
|
||||||
|
assert plane == {
|
||||||
|
"p1": (0.0, 0.0),
|
||||||
|
"p2": (100.0, 0.0),
|
||||||
|
"label": "A",
|
||||||
|
"direction": (0.0, -1.0),
|
||||||
|
"style": "full",
|
||||||
|
}
|
||||||
|
assert list(plane) == ["p1", "p2", "label", "direction", "style"]
|
||||||
|
assert SECTION_STYLES == ("full", "half", "offset", "revolved")
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
section_plane((0.0, 0.0), (10.0, 0.0), style="broken")
|
||||||
|
assert "full, half, offset, revolved" in str(excinfo.value)
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
section_plane((0.0, 0.0), (10.0, 0.0), label="")
|
||||||
|
assert "label" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── the async layer ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests
|
||||||
|
draw_centre_marks,
|
||||||
|
draw_material_hatch,
|
||||||
|
draw_section_line,
|
||||||
|
)
|
||||||
|
from engineering.mech.primitives import ROLE_LAYER # noqa: E402
|
||||||
|
from engineering.mech.standards.materials import MATERIALS, hatch_for # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
async def test_centre_marks_read_the_circles_real_radius_from_the_drawing(backend):
|
||||||
|
small = await backend.entity_create_circle(0.0, 0.0, 4.0, "GEOMETRY")
|
||||||
|
large = await backend.entity_create_circle(50.0, 0.0, 12.0, "GEOMETRY")
|
||||||
|
result = await draw_centre_marks(
|
||||||
|
backend, handles=[small.handle, large.handle], style="lines", extension=2.0
|
||||||
|
)
|
||||||
|
assert result["ok"] is True and result["count"] == 4
|
||||||
|
assert result["sources"] == [
|
||||||
|
{"handle": small.handle, "center": [0.0, 0.0], "radius": 4.0},
|
||||||
|
{"handle": large.handle, "center": [50.0, 0.0], "radius": 12.0},
|
||||||
|
]
|
||||||
|
first = await backend.entity_get(result["handles"][0])
|
||||||
|
assert first.layer == ROLE_LAYER["center"]
|
||||||
|
assert first.properties["start"] == pytest.approx([-6.0, 0.0], abs=EPS)
|
||||||
|
assert first.properties["end"] == pytest.approx([6.0, 0.0], abs=EPS)
|
||||||
|
third = await backend.entity_get(result["handles"][2])
|
||||||
|
assert third.properties["start"] == pytest.approx([36.0, 0.0], abs=EPS)
|
||||||
|
assert third.properties["end"] == pytest.approx([64.0, 0.0], abs=EPS)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_centre_marks_refuse_a_handle_that_is_not_a_circular_feature(backend):
|
||||||
|
line = await backend.entity_create_line(0.0, 0.0, 10.0, 0.0, 0.0, 0.0, "GEOMETRY")
|
||||||
|
before = (await backend.analysis_stats())["total_entities"]
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
await draw_centre_marks(backend, handles=[line.handle])
|
||||||
|
assert line.handle in str(excinfo.value) and "LINE" in str(excinfo.value)
|
||||||
|
assert (await backend.analysis_stats())["total_entities"] == before
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explicit_centres_are_accepted_and_lines_need_a_radius(backend):
|
||||||
|
result = await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="mark", extension=3.0)
|
||||||
|
assert result["count"] == 2 and result["sources"] == [
|
||||||
|
{"handle": None, "center": [5.0, 5.0], "radius": None}
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="lines")
|
||||||
|
assert "centers[0]" in str(excinfo.value)
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
await draw_centre_marks(backend)
|
||||||
|
assert "handles" in str(excinfo.value) and "centers" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_draw_section_line_returns_the_plane_dict_the_view_engine_consumes(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
|
||||||
|
)
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["plane"] == {
|
||||||
|
"p1": (0.0, 0.0),
|
||||||
|
"p2": (100.0, 0.0),
|
||||||
|
"label": "A",
|
||||||
|
"direction": (0.0, -1.0),
|
||||||
|
"style": "full",
|
||||||
|
}
|
||||||
|
assert list(result["plane"]) == ["p1", "p2", "label", "direction", "style"]
|
||||||
|
layers = set()
|
||||||
|
for handle in result["handles"]:
|
||||||
|
layers.add((await backend.entity_get(handle)).layer)
|
||||||
|
assert layers == {ROLE_LAYER["visible"], ROLE_LAYER["center"], ROLE_LAYER["text"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_material_hatch_uses_the_iso_128_50_map_and_scales_on_top_of_it(backend):
|
||||||
|
steel = hatch_for("steel")
|
||||||
|
result = await draw_material_hatch(
|
||||||
|
backend,
|
||||||
|
boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]],
|
||||||
|
material="steel",
|
||||||
|
scale=2.0,
|
||||||
|
)
|
||||||
|
assert result["ok"] is True and result["material"] == "steel"
|
||||||
|
assert result["pattern"] == steel["pattern"]
|
||||||
|
assert result["scale"] == pytest.approx(steel["scale"] * 2.0, abs=EPS)
|
||||||
|
assert result["angle"] == pytest.approx(steel["angle"], abs=EPS)
|
||||||
|
hatch = backend._doc.entitydb.get(result["handle"])
|
||||||
|
assert hatch.dxftype() == "HATCH"
|
||||||
|
assert hatch.dxf.pattern_name == steel["pattern"]
|
||||||
|
assert hatch.dxf.pattern_scale == pytest.approx(steel["scale"] * 2.0, abs=EPS)
|
||||||
|
assert hatch.dxf.pattern_angle == pytest.approx(steel["angle"], abs=EPS)
|
||||||
|
assert (await backend.entity_get(result["handle"])).layer == ROLE_LAYER["hatch"]
|
||||||
|
# An explicit angle overrides the material's own.
|
||||||
|
turned = await draw_material_hatch(
|
||||||
|
backend,
|
||||||
|
boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]],
|
||||||
|
material="steel",
|
||||||
|
angle=90.0,
|
||||||
|
)
|
||||||
|
assert turned["angle"] == pytest.approx(90.0, abs=EPS)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_unknown_material_is_refused_with_the_list_before_any_write(backend):
|
||||||
|
assert "steel" in MATERIALS
|
||||||
|
before = (await backend.analysis_stats())["total_entities"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await draw_material_hatch(
|
||||||
|
backend,
|
||||||
|
boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]],
|
||||||
|
material="unobtainium",
|
||||||
|
)
|
||||||
|
assert (await backend.analysis_stats())["total_entities"] == before
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_boundary_that_is_not_a_closed_polygon_is_refused(backend):
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
await draw_material_hatch(backend, boundary=[[0.0, 0.0], [10.0, 0.0]], material="steel")
|
||||||
|
assert "at least three" in str(excinfo.value)
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
await draw_material_hatch(backend, material="steel")
|
||||||
|
assert "boundary" in str(excinfo.value) and "handles" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── the HATCH layer must exist before the hatch is created (live) ────────────
|
||||||
|
|
||||||
|
import types # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _StrictLayerHatchBackend:
|
||||||
|
"""A backend that refuses a hatch on a layer the drawing does not have.
|
||||||
|
|
||||||
|
MEASURED on AutoCAD 2026 (probe: GetActiveObject, `Documents.Add()`, a
|
||||||
|
scratch document whose layer table is `['0']`, `doc.Close(False)` in a
|
||||||
|
`finally:`): `msp.AddHatch(0, "ANSI31", False)` + `AppendOuterLoop` +
|
||||||
|
`Evaluate()` leaves the AcDbHatch in model space, and the very next
|
||||||
|
statement `hatch.Layer = "HATCH"` raises
|
||||||
|
`com_error (-2147352567, ..., 'Key not found', ..., -2145386476)`. Model
|
||||||
|
space then still reports `[('AcDbHatch', '0')]` and the layer table is
|
||||||
|
still `['0']` - the refusal leaves an orphan hatch behind. After
|
||||||
|
`doc.Layers.Add("HATCH")` the same assignment succeeds. `ComBackend`
|
||||||
|
reaches that assignment through `_apply_entity_attrs`, which is the one
|
||||||
|
line `entity.Layer = layer`, so this is the hatch spelling of the failure
|
||||||
|
`ensure_annotation_layers` already fixed for the annotation symbols.
|
||||||
|
|
||||||
|
The fake models that measured refusal and nothing else: every member below
|
||||||
|
exists on the real `AutoCADBackend` with this signature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, layers=("0",)):
|
||||||
|
self.layers = {name.lower(): name for name in layers}
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def entity_create_hatch(
|
||||||
|
self, pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None
|
||||||
|
):
|
||||||
|
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(("HATCH", layer, pattern))
|
||||||
|
return types.SimpleNamespace(handle="2F")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_hatch_layer_is_created_before_the_hatch_is():
|
||||||
|
backend = _StrictLayerHatchBackend()
|
||||||
|
result = await draw_material_hatch(
|
||||||
|
backend,
|
||||||
|
boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]],
|
||||||
|
material="steel",
|
||||||
|
)
|
||||||
|
assert result["ok"] is True and result["handle"] == "2F"
|
||||||
|
assert result["layers_created"] == [ROLE_LAYER["hatch"]]
|
||||||
|
# The layer is created strictly before the hatch entity.
|
||||||
|
kinds = [call[0] for call in backend.calls]
|
||||||
|
assert kinds.index("layer_create") < kinds.index("HATCH")
|
||||||
|
# ENGINEERING_LAYERS' own definition, not a bare default.
|
||||||
|
create = next(c for c in backend.calls if c[0] == "layer_create")
|
||||||
|
assert create[1] == ROLE_LAYER["hatch"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_drawing_that_already_has_the_hatch_layer_is_not_rewritten():
|
||||||
|
backend = _StrictLayerHatchBackend(layers=("0", "hatch"))
|
||||||
|
result = await draw_material_hatch(
|
||||||
|
backend, boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], material="steel"
|
||||||
|
)
|
||||||
|
assert result["layers_created"] == []
|
||||||
|
assert not [c for c in backend.calls if c[0] == "layer_create"]
|
||||||
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
|
|||||||
|
|
||||||
|
|
||||||
async def test_tool_group_sizes_are_unchanged():
|
async def test_tool_group_sizes_are_unchanged():
|
||||||
"""Frozen snapshot of the surface (206 tools, 23 groups).
|
"""Frozen snapshot of the surface (209 tools, 23 groups).
|
||||||
|
|
||||||
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
||||||
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
||||||
@@ -531,9 +531,10 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
since the snapshot was taken.
|
since the snapshot was taken.
|
||||||
|
|
||||||
`engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with
|
`engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with
|
||||||
`surface_texture` / `weld_symbol`; both are tagged `engineering` and `mech`,
|
`surface_texture` / `weld_symbol`, then 12 -> 15 when `centre_marks`,
|
||||||
and `mech` is not in `_GROUP_TAG_PRIORITY`, so they file under
|
`section_line` and `hatch_material` completed it; all five are tagged
|
||||||
`engineering` rather than opening a group of their own.
|
`engineering` and `mech`, and `mech` is not in `_GROUP_TAG_PRIORITY`, so
|
||||||
|
they file under `engineering` rather than opening a group of their own.
|
||||||
"""
|
"""
|
||||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
@@ -543,7 +544,7 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 11,
|
||||||
"engineering": 12,
|
"engineering": 15,
|
||||||
"entity_creation": 18,
|
"entity_creation": 18,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
@@ -561,4 +562,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 206
|
assert sum(sizes.values()) == 209
|
||||||
|
|||||||
Reference in New Issue
Block a user