engineering/mech/stdparts.py turns the Task 8 tables into real blocks. catalogue() searches 83 transcribed parts by designation, standard, size or family; block_spec() emits the typed primitives and DESIG/STD/SIZE/MAT ATTDEFs block_define takes, in side/top for fasteners and ISO 8826-1 simplified / ISO 8826-2 detailed for bearings; insert_std_part() defines the block once, inserts it and writes the std_part ACADMCP_MECH payload the skeleton pins, so the parts list is a read of the drawing rather than a naming convention. Threads follow ISO 6410 - minor at 0.8 x major, a thread-length limit line to the major, a three-quarter minor arc in the end view. std_feature_draw() adds a thread, a DIN 509 undercut, a DIN 471/472 groove, a DIN 332 centre hole or an ISO 3601-2 O-ring groove to geometry that already exists. Its dimensions come from the shared standards registry, never from this module: an absent table, a size outside coverage or a row missing a dimension is refused by name, and the DIN 509 relief is drawn from the profile its table publishes rather than reconstructed here. DIN 332 form R is refused outright. An unknown designation is refused with the nearest catalogue entries; nothing is drawn approximately. SECTION 22 registers the three tools in the new mech pack, with alias records (INSERT plus shop-floor synonyms in English and Turkish), a regenerated tool inventory, the README release snapshot at 207 tools in 24 groups, and the frozen group-size snapshot gaining mech: 3. Two plan corrections, both verified by running the code: * the plan's @cad_tool summaries for std_part_insert (118 chars) and std_feature_draw (130) overran discovery.serialize.MAX_SUMMARY_CHARS = 110, so tests/test_tool_registry.py::test_no_authored_summary_is_long_enough_to_be_truncated failed - they would have shipped rendered as "...". Both are shortened to 99 and 104 characters with the same content. * the plan's file list omitted tests/test_tool_packs.py, which pins the pack registry, the tool_packs report and the group count. It now carries MECH_TOOLS and distinguishes ALL_PACKS (declaration order, what "available" reports) from ALL_PACKS_ENABLED (sorted, what "enabled" reports) - those two lists stopped being the same list when "mech" was appended after "settings". No new ActiveX member is called: the module drives block_list / block_define / block_insert / entity_set_xdata / entity_create_* only. The COM path was still executed live once on AutoCAD 2026 in a scratch document created and closed by the probe: the bolt block defined with layers CENTER+GEOMETRY, attributes read back as DESIG/STD/SIZE/MAT = ISO 4014 - M12x60 / ISO 4014 / M12x60 / 8.8, the ACADMCP_MECH std_part payload round-tripped through entity_get_xdata, STD_ISO_15_6205_SIMPLIFIED inserted, and the M20 thread's three primitives landing on GEOMETRY. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
1023 lines
38 KiB
Python
1023 lines
38 KiB
Python
"""The standard-parts catalogue: tables in, real blocks with attributes out.
|
|
|
|
Two halves live here.
|
|
|
|
**Standard parts.** :func:`catalogue` searches the authored tables of
|
|
``standards/fasteners.py`` and ``standards/bearings.py``; :func:`block_spec`
|
|
turns one designation and one view into the typed primitives and ATTDEFs
|
|
``block_define`` takes; :func:`insert_std_part` defines the block once, inserts
|
|
it and writes the ``std_part`` ACADMCP_MECH payload, so a parts list is a read
|
|
of the drawing rather than a naming convention. This is the Track A (P&ID)
|
|
pattern applied to fasteners and bearings.
|
|
|
|
**Standard features.** :func:`draw_std_feature` adds a thread, a DIN 509
|
|
undercut, a retaining-ring groove, a DIN 332 centre hole or an ISO 3601-2
|
|
O-ring groove to geometry that already exists - a foreign drawing being
|
|
finished. The thread is the ISO 6410 representation and its pitch comes from
|
|
ISO 261; every other feature takes its dimensions from the shared standards
|
|
registry and is refused by name when the table is absent, when the size is
|
|
outside its coverage, or when the row lacks a dimension the feature needs.
|
|
Nothing here reconstructs a standard profile it was not handed.
|
|
|
|
Primitives are the ``block_define`` spec dicts of ``backends/block_specs.py``
|
|
(``{"type": "line", "x1": ..., "layer": ...}``) with an explicit ``layer``, so
|
|
one vocabulary serves both the block definitions and the feature drawing, and
|
|
the pure half is testable by comparing emitted dicts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import math
|
|
import re
|
|
from typing import TYPE_CHECKING
|
|
|
|
from engineering.mech.standards import lookup as standards_lookup
|
|
from engineering.mech.standards.bearings import (
|
|
BALL_SYMBOL_FRACTION,
|
|
CROSS_FRACTION,
|
|
SOURCE_ISO_15,
|
|
bearing,
|
|
list_bearings,
|
|
)
|
|
from engineering.mech.standards.fasteners import (
|
|
SOURCE_ISO_4014,
|
|
SOURCE_ISO_4017,
|
|
SOURCE_ISO_4032,
|
|
SOURCE_ISO_4762,
|
|
SOURCE_ISO_7089,
|
|
across_corners,
|
|
hex_head,
|
|
hex_nut,
|
|
sizes,
|
|
socket_head,
|
|
thread_length,
|
|
thread_pitch,
|
|
washer,
|
|
)
|
|
from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, to_values
|
|
|
|
if TYPE_CHECKING:
|
|
from backends.base import AutoCADBackend
|
|
|
|
LAYER_VISIBLE = "GEOMETRY"
|
|
LAYER_CENTER = "CENTER"
|
|
LAYER_HIDDEN = "HIDDEN"
|
|
TEXT_HEIGHT = 2.5
|
|
AXIS_OVERRUN = 3.0
|
|
DEFAULT_VIEW = "side"
|
|
|
|
#: ISO 6410 draws the minor diameter of a thread at 0.8 x the major diameter.
|
|
THREAD_MINOR_RATIO = 0.8
|
|
|
|
SOURCE_ISO_6410 = (
|
|
"ISO 6410-1:1993 - technical drawings, screw threads and threaded parts, general conventions"
|
|
)
|
|
|
|
FAMILY_STANDARDS: dict[str, str] = {
|
|
"ISO 4014": "hex_bolt",
|
|
"ISO 4017": "hex_screw",
|
|
"ISO 4032": "hex_nut",
|
|
"ISO 7089": "washer",
|
|
"ISO 4762": "socket_head_screw",
|
|
"ISO 15": "deep_groove_bearing",
|
|
}
|
|
STANDARD_SOURCE: dict[str, str] = {
|
|
"ISO 4014": SOURCE_ISO_4014,
|
|
"ISO 4017": SOURCE_ISO_4017,
|
|
"ISO 4032": SOURCE_ISO_4032,
|
|
"ISO 7089": SOURCE_ISO_7089,
|
|
"ISO 4762": SOURCE_ISO_4762,
|
|
"ISO 15": SOURCE_ISO_15,
|
|
}
|
|
FAMILY_VIEWS: dict[str, tuple[str, ...]] = {
|
|
"hex_bolt": ("side", "top"),
|
|
"hex_screw": ("side", "top"),
|
|
"hex_nut": ("side", "top"),
|
|
"washer": ("side", "top"),
|
|
"socket_head_screw": ("side", "top"),
|
|
"deep_groove_bearing": ("simplified", "detailed"),
|
|
}
|
|
LENGTH_REQUIRED = frozenset({"hex_bolt", "hex_screw", "socket_head_screw"})
|
|
|
|
_THREAD_RE = re.compile(r"^(M\d{1,3})(?:\s*[X*]\s*(\d{1,4}(?:\.\d+)?))?$", re.IGNORECASE)
|
|
_BEARING_RE = re.compile(r"^\d{4}$")
|
|
|
|
|
|
# ── primitive helpers (block_define spec dicts, block-local mm) ─────────────
|
|
|
|
|
|
def _line(x1, y1, x2, y2, layer: str = LAYER_VISIBLE) -> dict:
|
|
return {
|
|
"type": "line",
|
|
"x1": float(x1),
|
|
"y1": float(y1),
|
|
"x2": float(x2),
|
|
"y2": float(y2),
|
|
"layer": layer,
|
|
}
|
|
|
|
|
|
def _circle(cx, cy, r, layer: str = LAYER_VISIBLE) -> dict:
|
|
return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r), "layer": layer}
|
|
|
|
|
|
def _arc(cx, cy, r, start_deg, end_deg, layer: str = LAYER_VISIBLE) -> dict:
|
|
return {
|
|
"type": "arc",
|
|
"cx": float(cx),
|
|
"cy": float(cy),
|
|
"r": float(r),
|
|
"start_deg": float(start_deg),
|
|
"end_deg": float(end_deg),
|
|
"layer": layer,
|
|
}
|
|
|
|
|
|
def _poly(points, closed: bool = True, layer: str = LAYER_VISIBLE) -> dict:
|
|
return {
|
|
"type": "polyline",
|
|
"points": [[float(x), float(y)] for x, y in points],
|
|
"closed": bool(closed),
|
|
"layer": layer,
|
|
}
|
|
|
|
|
|
def _hex_points(radius: float, start_deg: float = 0.0):
|
|
return [
|
|
(
|
|
radius * math.cos(math.radians(start_deg + 60.0 * i)),
|
|
radius * math.sin(math.radians(start_deg + 60.0 * i)),
|
|
)
|
|
for i in range(6)
|
|
]
|
|
|
|
|
|
def _centre_mark(cx: float, cy: float, extent: float) -> list[dict]:
|
|
return [
|
|
_line(cx - extent, cy, cx + extent, cy, LAYER_CENTER),
|
|
_line(cx, cy - extent, cx, cy + extent, LAYER_CENTER),
|
|
]
|
|
|
|
|
|
# ── designations and the catalogue ──────────────────────────────────────────
|
|
|
|
|
|
def _normalise_standard(text: str) -> str:
|
|
return re.sub(r"^(ISO)\s*", r"\1 ", " ".join(str(text).split()).upper())
|
|
|
|
|
|
def parse_designation(designation: str) -> dict:
|
|
"""Split a catalogue designation into standard, family, size, thread and length.
|
|
|
|
Accepts ``'ISO 4014 - M12x60'``, ``'iso4014-m12x60'`` and a bare four-digit
|
|
bearing number (``'6205'`` becomes ``'ISO 15 - 6205'``). Returns
|
|
``{"designation", "standard", "family", "size", "thread", "length"}`` with
|
|
a canonical designation. Every refusal names what was wrong.
|
|
"""
|
|
text = " ".join(str(designation).split())
|
|
if not text:
|
|
raise ValueError(
|
|
"std part designation must be a non-empty string, e.g. 'ISO 4014 - M12x60'"
|
|
)
|
|
if _BEARING_RE.match(text):
|
|
text = f"ISO 15 - {text}"
|
|
head, separator, tail = text.partition("-")
|
|
if not separator:
|
|
raise ValueError(
|
|
f"{designation!r} is not a catalogue designation: expected "
|
|
"'<standard> - <size>', e.g. 'ISO 4014 - M12x60' or 'ISO 15 - 6205'."
|
|
)
|
|
standard = _normalise_standard(head)
|
|
size = " ".join(tail.split())
|
|
if standard not in FAMILY_STANDARDS:
|
|
raise ValueError(
|
|
f"{designation!r}: {standard!r} is not a catalogue standard. "
|
|
f"The catalogue holds {', '.join(FAMILY_STANDARDS)}."
|
|
)
|
|
family = FAMILY_STANDARDS[standard]
|
|
if family == "deep_groove_bearing":
|
|
if not _BEARING_RE.match(size):
|
|
raise ValueError(
|
|
f"{designation!r}: an ISO 15 size is a four-digit bearing designation "
|
|
f"such as '6205', got {size!r}."
|
|
)
|
|
return {
|
|
"designation": f"{standard} - {size}",
|
|
"standard": standard,
|
|
"family": family,
|
|
"size": size,
|
|
"thread": None,
|
|
"length": None,
|
|
}
|
|
match = _THREAD_RE.match(size)
|
|
if not match:
|
|
shape = "'M12' or 'M12x60'" if family in LENGTH_REQUIRED else "'M12'"
|
|
raise ValueError(
|
|
f"{designation!r}: a {standard} size is a metric thread such as {shape}, got {size!r}."
|
|
)
|
|
thread = match.group(1).upper()
|
|
length = float(match.group(2)) if match.group(2) else None
|
|
if family in LENGTH_REQUIRED and length is None:
|
|
raise ValueError(
|
|
f"{designation!r}: {standard} needs a nominal length - write 'M12x60'. The "
|
|
"per-size length range is not transcribed, so any finite positive length is "
|
|
"accepted and the thread length follows the standard's own b rule."
|
|
)
|
|
if family not in LENGTH_REQUIRED and length is not None:
|
|
raise ValueError(f"{designation!r}: {standard} takes no length, got {size!r}.")
|
|
canonical = thread if length is None else f"{thread}x{length:g}"
|
|
return {
|
|
"designation": f"{standard} - {canonical}",
|
|
"standard": standard,
|
|
"family": family,
|
|
"size": canonical,
|
|
"thread": thread,
|
|
"length": length,
|
|
}
|
|
|
|
|
|
def catalogue(query: str | None = None, standard: str | None = None) -> tuple[dict, ...]:
|
|
"""Every transcribed standard part, optionally filtered.
|
|
|
|
``standard`` restricts to one catalogue standard (refused by name when it
|
|
is not one). ``query`` is matched, case-insensitively, as a substring of
|
|
the designation, standard, size and family, so ``'M12'``, ``'ISO 4014'``,
|
|
``'6205'`` and ``'washer'`` all work.
|
|
"""
|
|
rows: list[dict] = []
|
|
getters = {
|
|
"hex_bolt": hex_head,
|
|
"hex_screw": hex_head,
|
|
"hex_nut": hex_nut,
|
|
"washer": washer,
|
|
"socket_head_screw": socket_head,
|
|
}
|
|
for name, family in FAMILY_STANDARDS.items():
|
|
if family == "deep_groove_bearing":
|
|
for row in list_bearings():
|
|
rows.append(
|
|
{
|
|
"designation": f"{name} - {row['designation']}",
|
|
"standard": name,
|
|
"family": family,
|
|
"size": row["designation"],
|
|
"views": list(FAMILY_VIEWS[family]),
|
|
"length_required": False,
|
|
"dims": {key: row[key] for key in ("d", "D", "B")},
|
|
"source": row["source"],
|
|
}
|
|
)
|
|
continue
|
|
getter = getters[family]
|
|
for size in sizes(name):
|
|
dims = getter(size)
|
|
rows.append(
|
|
{
|
|
"designation": f"{name} - {size}",
|
|
"standard": name,
|
|
"family": family,
|
|
"size": size,
|
|
"views": list(FAMILY_VIEWS[family]),
|
|
"length_required": family in LENGTH_REQUIRED,
|
|
"dims": {key: value for key, value in dims.items() if key != "size"},
|
|
"source": STANDARD_SOURCE[name],
|
|
}
|
|
)
|
|
if standard is not None:
|
|
want = _normalise_standard(standard)
|
|
if want not in FAMILY_STANDARDS:
|
|
raise ValueError(
|
|
f"catalogue: {standard!r} is not a catalogue standard; "
|
|
f"have {', '.join(FAMILY_STANDARDS)}."
|
|
)
|
|
rows = [row for row in rows if row["standard"] == want]
|
|
if query:
|
|
needle = " ".join(str(query).split()).lower()
|
|
rows = [
|
|
row
|
|
for row in rows
|
|
if needle
|
|
in f"{row['designation']} {row['standard']} {row['size']} {row['family']}".lower()
|
|
]
|
|
return tuple(rows)
|
|
|
|
|
|
def _numeric_key(family: str, size: str) -> float | None:
|
|
if family == "deep_groove_bearing":
|
|
return float(size) if size.isdigit() else None
|
|
match = _THREAD_RE.match(size)
|
|
if not match:
|
|
return None
|
|
try:
|
|
return float(match.group(1)[1:])
|
|
except ValueError: # pragma: no cover - the regex already fixed the shape
|
|
return None
|
|
|
|
|
|
def _suggest(parsed: dict) -> str:
|
|
rows = list(catalogue(standard=parsed["standard"]))
|
|
target = _numeric_key(parsed["family"], parsed["thread"] or parsed["size"])
|
|
if target is not None:
|
|
ranked = sorted(
|
|
rows,
|
|
key=lambda row: abs((_numeric_key(row["family"], row["size"]) or 0.0) - target),
|
|
)
|
|
else:
|
|
names = [row["designation"] for row in rows]
|
|
close = difflib.get_close_matches(parsed["designation"], names, n=3, cutoff=0.3)
|
|
ranked = [row for row in rows if row["designation"] in close]
|
|
return ", ".join(row["designation"] for row in ranked[:3])
|
|
|
|
|
|
def _resolve_dims(parsed: dict) -> dict:
|
|
family = parsed["family"]
|
|
try:
|
|
if family in ("hex_bolt", "hex_screw"):
|
|
dims = hex_head(parsed["thread"])
|
|
elif family == "hex_nut":
|
|
dims = hex_nut(parsed["thread"])
|
|
elif family == "washer":
|
|
dims = washer(parsed["thread"])
|
|
elif family == "socket_head_screw":
|
|
dims = socket_head(parsed["thread"])
|
|
else:
|
|
dims = bearing(parsed["size"])
|
|
except ValueError as exc:
|
|
raise ValueError(
|
|
f"{parsed['designation']!r} is not in the standard-parts catalogue. {exc} "
|
|
f"Nearest entries: {_suggest(parsed)}. Nothing is drawn approximately."
|
|
) from exc
|
|
if family in ("hex_bolt", "hex_screw", "hex_nut"):
|
|
dims["e"] = across_corners(dims["s"])
|
|
if family in LENGTH_REQUIRED:
|
|
if family == "socket_head_screw":
|
|
dims["b_table"] = dims["b"]
|
|
dims["l"] = parsed["length"]
|
|
dims["b"] = thread_length(parsed["thread"], parsed["length"], parsed["standard"])
|
|
return dims
|
|
|
|
|
|
def _resolve_view(family: str, view) -> str:
|
|
valid = FAMILY_VIEWS[family]
|
|
name = " ".join(str(view).split()).lower()
|
|
if name in valid:
|
|
return name
|
|
if name == DEFAULT_VIEW:
|
|
return valid[0]
|
|
raise ValueError(f"{family}: view must be one of {', '.join(valid)}, got {view!r}.")
|
|
|
|
|
|
def _block_name(standard: str, size: str, view: str) -> str:
|
|
raw = f"STD_{standard}_{size}_{view}".upper()
|
|
return re.sub(r"[^A-Z0-9]+", "_", raw).strip("_")
|
|
|
|
|
|
def _attdefs(parsed: dict, bbox: tuple[float, float, float, float]) -> list[dict]:
|
|
top = bbox[1] - 2.0 * TEXT_HEIGHT
|
|
rows = (
|
|
("DESIG", "Designation", parsed["designation"], False),
|
|
("STD", "Standard", parsed["standard"], True),
|
|
("SIZE", "Size", parsed["size"], True),
|
|
("MAT", "Material or property class", "", True),
|
|
)
|
|
return [
|
|
{
|
|
"tag": tag,
|
|
"prompt": prompt,
|
|
"default": default,
|
|
"x": 0.0,
|
|
"y": top - index * TEXT_HEIGHT * 1.6,
|
|
"height": TEXT_HEIGHT,
|
|
"align": "center",
|
|
"invisible": invisible,
|
|
}
|
|
for index, (tag, prompt, default, invisible) in enumerate(rows)
|
|
]
|
|
|
|
|
|
# ── the view builders: (entities, bbox) in block-local mm ───────────────────
|
|
|
|
|
|
def _hex_fastener_side(dims: dict, *, threaded_to_head: bool):
|
|
d, s, k = dims["d"], dims["s"], dims["k"]
|
|
length, b = dims["l"], dims["b"]
|
|
r = across_corners(s) / 2.0
|
|
minor = THREAD_MINOR_RATIO * d / 2.0
|
|
x0 = length - b
|
|
ents = [
|
|
_poly([(-k, -r), (0.0, -r), (0.0, r), (-k, r)]),
|
|
_line(-k, r / 2.0, 0.0, r / 2.0),
|
|
_line(-k, -r / 2.0, 0.0, -r / 2.0),
|
|
_line(0.0, d / 2.0, length, d / 2.0),
|
|
_line(0.0, -d / 2.0, length, -d / 2.0),
|
|
_line(length, -d / 2.0, length, d / 2.0),
|
|
_line(x0, minor, length, minor),
|
|
_line(x0, -minor, length, -minor),
|
|
]
|
|
if not threaded_to_head and x0 > 1e-9:
|
|
ents.append(_line(x0, -d / 2.0, x0, d / 2.0))
|
|
ents.append(_line(-k - AXIS_OVERRUN, 0.0, length + AXIS_OVERRUN, 0.0, LAYER_CENTER))
|
|
return ents, (-k - AXIS_OVERRUN, -r, length + AXIS_OVERRUN, r)
|
|
|
|
|
|
def _hex_fastener_top(dims: dict):
|
|
d, s = dims["d"], dims["s"]
|
|
r = across_corners(s) / 2.0
|
|
ents = [
|
|
_poly(_hex_points(r)),
|
|
_circle(0.0, 0.0, d / 2.0),
|
|
_arc(0.0, 0.0, THREAD_MINOR_RATIO * d / 2.0, 90.0, 360.0),
|
|
]
|
|
ents += _centre_mark(0.0, 0.0, r + AXIS_OVERRUN)
|
|
extent = r + AXIS_OVERRUN
|
|
return ents, (-extent, -extent, extent, extent)
|
|
|
|
|
|
def _hex_nut_side(dims: dict):
|
|
d, s, m = dims["d"], dims["s"], dims["m"]
|
|
r = across_corners(s) / 2.0
|
|
minor = THREAD_MINOR_RATIO * d / 2.0
|
|
ents = [
|
|
_poly([(0.0, -r), (m, -r), (m, r), (0.0, r)]),
|
|
_line(0.0, r / 2.0, m, r / 2.0),
|
|
_line(0.0, -r / 2.0, m, -r / 2.0),
|
|
_line(0.0, d / 2.0, m, d / 2.0, LAYER_HIDDEN),
|
|
_line(0.0, -d / 2.0, m, -d / 2.0, LAYER_HIDDEN),
|
|
_line(0.0, minor, m, minor, LAYER_HIDDEN),
|
|
_line(0.0, -minor, m, -minor, LAYER_HIDDEN),
|
|
_line(-AXIS_OVERRUN, 0.0, m + AXIS_OVERRUN, 0.0, LAYER_CENTER),
|
|
]
|
|
return ents, (-AXIS_OVERRUN, -r, m + AXIS_OVERRUN, r)
|
|
|
|
|
|
def _hex_nut_top(dims: dict):
|
|
d, s = dims["d"], dims["s"]
|
|
r = across_corners(s) / 2.0
|
|
ents = [
|
|
_poly(_hex_points(r)),
|
|
_circle(0.0, 0.0, THREAD_MINOR_RATIO * d / 2.0),
|
|
_arc(0.0, 0.0, d / 2.0, 90.0, 360.0),
|
|
]
|
|
ents += _centre_mark(0.0, 0.0, r + AXIS_OVERRUN)
|
|
extent = r + AXIS_OVERRUN
|
|
return ents, (-extent, -extent, extent, extent)
|
|
|
|
|
|
def _washer_side(dims: dict):
|
|
d1, d2, h = dims["d1"], dims["d2"], dims["h"]
|
|
ents = [
|
|
_poly([(0.0, d1 / 2.0), (h, d1 / 2.0), (h, d2 / 2.0), (0.0, d2 / 2.0)]),
|
|
_poly([(0.0, -d1 / 2.0), (h, -d1 / 2.0), (h, -d2 / 2.0), (0.0, -d2 / 2.0)]),
|
|
_line(-AXIS_OVERRUN, 0.0, h + AXIS_OVERRUN, 0.0, LAYER_CENTER),
|
|
]
|
|
return ents, (-AXIS_OVERRUN, -d2 / 2.0, h + AXIS_OVERRUN, d2 / 2.0)
|
|
|
|
|
|
def _washer_top(dims: dict):
|
|
d1, d2 = dims["d1"], dims["d2"]
|
|
ents = [_circle(0.0, 0.0, d2 / 2.0), _circle(0.0, 0.0, d1 / 2.0)]
|
|
ents += _centre_mark(0.0, 0.0, d2 / 2.0 + AXIS_OVERRUN)
|
|
extent = d2 / 2.0 + AXIS_OVERRUN
|
|
return ents, (-extent, -extent, extent, extent)
|
|
|
|
|
|
def _socket_head_side(dims: dict):
|
|
d, dk, k = dims["d"], dims["dk"], dims["k"]
|
|
length, b = dims["l"], dims["b"]
|
|
minor = THREAD_MINOR_RATIO * d / 2.0
|
|
x0 = length - b
|
|
ents = [
|
|
_poly([(-k, -dk / 2.0), (0.0, -dk / 2.0), (0.0, dk / 2.0), (-k, dk / 2.0)]),
|
|
_line(0.0, d / 2.0, length, d / 2.0),
|
|
_line(0.0, -d / 2.0, length, -d / 2.0),
|
|
_line(length, -d / 2.0, length, d / 2.0),
|
|
_line(x0, minor, length, minor),
|
|
_line(x0, -minor, length, -minor),
|
|
]
|
|
if x0 > 1e-9:
|
|
ents.append(_line(x0, -d / 2.0, x0, d / 2.0))
|
|
ents.append(_line(-k - AXIS_OVERRUN, 0.0, length + AXIS_OVERRUN, 0.0, LAYER_CENTER))
|
|
return ents, (-k - AXIS_OVERRUN, -dk / 2.0, length + AXIS_OVERRUN, dk / 2.0)
|
|
|
|
|
|
def _socket_head_top(dims: dict):
|
|
dk, s = dims["dk"], dims["s"]
|
|
ents = [_circle(0.0, 0.0, dk / 2.0), _poly(_hex_points(across_corners(s) / 2.0))]
|
|
ents += _centre_mark(0.0, 0.0, dk / 2.0 + AXIS_OVERRUN)
|
|
extent = dk / 2.0 + AXIS_OVERRUN
|
|
return ents, (-extent, -extent, extent, extent)
|
|
|
|
|
|
def _bearing_simplified(dims: dict):
|
|
inner, outer, half = dims["d"] / 2.0, dims["D"] / 2.0, dims["B"] / 2.0
|
|
ents = [
|
|
_poly([(-half, inner), (half, inner), (half, outer), (-half, outer)]),
|
|
_poly([(-half, -inner), (half, -inner), (half, -outer), (-half, -outer)]),
|
|
]
|
|
arm_x = CROSS_FRACTION * half
|
|
arm_y = CROSS_FRACTION * (outer - inner) / 2.0
|
|
middle = (inner + outer) / 2.0
|
|
for centre in (middle, -middle):
|
|
ents.append(_line(-arm_x, centre, arm_x, centre))
|
|
ents.append(_line(0.0, centre - arm_y, 0.0, centre + arm_y))
|
|
ents.append(_line(-half - AXIS_OVERRUN, 0.0, half + AXIS_OVERRUN, 0.0, LAYER_CENTER))
|
|
return ents, (-half - AXIS_OVERRUN, -outer, half + AXIS_OVERRUN, outer)
|
|
|
|
|
|
def _bearing_detailed(dims: dict):
|
|
inner, outer, half = dims["d"] / 2.0, dims["D"] / 2.0, dims["B"] / 2.0
|
|
radius = BALL_SYMBOL_FRACTION * (outer - inner) / 2.0
|
|
middle = (inner + outer) / 2.0
|
|
ents = [
|
|
_poly([(-half, inner), (half, inner), (half, outer), (-half, outer)]),
|
|
_poly([(-half, -inner), (half, -inner), (half, -outer), (-half, -outer)]),
|
|
_circle(0.0, middle, radius),
|
|
_circle(0.0, -middle, radius),
|
|
_line(-half - AXIS_OVERRUN, 0.0, half + AXIS_OVERRUN, 0.0, LAYER_CENTER),
|
|
]
|
|
return ents, (-half - AXIS_OVERRUN, -outer, half + AXIS_OVERRUN, outer)
|
|
|
|
|
|
_BUILDERS = {
|
|
("hex_bolt", "side"): lambda dims: _hex_fastener_side(dims, threaded_to_head=False),
|
|
("hex_bolt", "top"): _hex_fastener_top,
|
|
("hex_screw", "side"): lambda dims: _hex_fastener_side(dims, threaded_to_head=True),
|
|
("hex_screw", "top"): _hex_fastener_top,
|
|
("hex_nut", "side"): _hex_nut_side,
|
|
("hex_nut", "top"): _hex_nut_top,
|
|
("washer", "side"): _washer_side,
|
|
("washer", "top"): _washer_top,
|
|
("socket_head_screw", "side"): _socket_head_side,
|
|
("socket_head_screw", "top"): _socket_head_top,
|
|
("deep_groove_bearing", "simplified"): _bearing_simplified,
|
|
("deep_groove_bearing", "detailed"): _bearing_detailed,
|
|
}
|
|
|
|
|
|
def block_spec(designation: str, view: str) -> dict:
|
|
"""Typed primitives and ATTDEFs for ``block_define``, in block-local mm.
|
|
|
|
The block's origin is the part's own datum: the head bearing face for a
|
|
bolt or cap screw, the bearing face for a nut or washer, the centre for a
|
|
bearing, with the axis along +X. Every refusal fires before any geometry
|
|
is built.
|
|
"""
|
|
parsed = parse_designation(designation)
|
|
kind = _resolve_view(parsed["family"], view)
|
|
dims = _resolve_dims(parsed)
|
|
entities, bbox = _BUILDERS[(parsed["family"], kind)](dims)
|
|
return {
|
|
"name": _block_name(parsed["standard"], parsed["size"], kind),
|
|
"entities": entities,
|
|
"attdefs": _attdefs(parsed, bbox),
|
|
"base_x": 0.0,
|
|
"base_y": 0.0,
|
|
"bbox": bbox,
|
|
"view": kind,
|
|
"designation": parsed["designation"],
|
|
"standard": parsed["standard"],
|
|
"size": parsed["size"],
|
|
"family": parsed["family"],
|
|
"dims": dims,
|
|
"source": STANDARD_SOURCE[parsed["standard"]],
|
|
}
|
|
|
|
|
|
async def insert_std_part(
|
|
backend: AutoCADBackend,
|
|
designation: str,
|
|
*,
|
|
at,
|
|
view: str = DEFAULT_VIEW,
|
|
rotation: float = 0.0,
|
|
layer: str = LAYER_VISIBLE,
|
|
attributes: dict | None = None,
|
|
material: str | None = None,
|
|
) -> dict:
|
|
"""Define the part's block once, insert it, write its ``std_part`` payload.
|
|
|
|
``at`` is the WCS point the block's own datum lands on. The default
|
|
``view="side"`` falls back to a family's first view (``simplified`` for a
|
|
bearing) and the result reports which view was drawn; any other mismatch is
|
|
refused. Refusals - an unknown designation with the nearest catalogue
|
|
entries, a bad view, a non-finite coordinate or rotation, an attribute tag
|
|
the block does not have - all fire before the first entity is written.
|
|
"""
|
|
spec = block_spec(designation, view)
|
|
try:
|
|
x, y = float(at[0]), float(at[1])
|
|
except (TypeError, IndexError, ValueError) as exc:
|
|
raise ValueError(f"insert_std_part: 'at' must be two numbers, got {at!r}") from exc
|
|
if not (math.isfinite(x) and math.isfinite(y)):
|
|
raise ValueError(f"insert_std_part: 'at' must be two finite numbers, got {at!r}")
|
|
angle = float(rotation)
|
|
if not math.isfinite(angle):
|
|
raise ValueError(f"insert_std_part: rotation must be finite, got {rotation!r}")
|
|
|
|
values = {att["tag"]: att["default"] for att in spec["attdefs"]}
|
|
supplied = dict(attributes or {})
|
|
if material is not None:
|
|
supplied["MAT"] = material
|
|
unknown = sorted(set(supplied) - set(values))
|
|
if unknown:
|
|
raise ValueError(
|
|
f"insert_std_part: {spec['name']} has no attribute {', '.join(unknown)}; "
|
|
f"valid tags: {', '.join(values)}."
|
|
)
|
|
values.update({str(key): str(value) for key, value in supplied.items()})
|
|
|
|
existing = {block.name for block in await backend.block_list()}
|
|
defined = spec["name"] not in existing
|
|
layers_created: list[str] = []
|
|
if defined:
|
|
created = await backend.block_define(
|
|
spec["name"],
|
|
spec["entities"],
|
|
spec["attdefs"],
|
|
spec["base_x"],
|
|
spec["base_y"],
|
|
False,
|
|
True,
|
|
)
|
|
layers_created = list(created.get("layers_created", []))
|
|
ref = await backend.block_insert(spec["name"], x, y, 1.0, 1.0, angle, values, layer)
|
|
payload = {
|
|
"v": PAYLOAD_VERSION,
|
|
"kind": "std_part",
|
|
"designation": spec["designation"],
|
|
"standard": spec["standard"],
|
|
"size": spec["size"],
|
|
"qty": 1,
|
|
"material": values["MAT"],
|
|
}
|
|
await backend.entity_set_xdata(ref.handle, APP_ID, to_values(payload))
|
|
return {
|
|
"ok": True,
|
|
"handle": ref.handle,
|
|
"block_name": spec["name"],
|
|
"defined": defined,
|
|
"layers_created": layers_created,
|
|
"designation": spec["designation"],
|
|
"standard": spec["standard"],
|
|
"size": spec["size"],
|
|
"family": spec["family"],
|
|
"view": spec["view"],
|
|
"layer": layer,
|
|
"at": [x, y],
|
|
"rotation": angle,
|
|
"dims": spec["dims"],
|
|
"source": spec["source"],
|
|
"payload": payload,
|
|
"backend": backend.name,
|
|
}
|
|
|
|
|
|
# ── standard features drawn onto geometry that already exists ───────────────
|
|
#
|
|
# Local frame for every feature: the origin is on the axis, +X runs along the
|
|
# axis in the direction the feature extends, +Y is radially outward. No feature
|
|
# draws a centre line - the geometry it is added to already has one.
|
|
|
|
STD_FEATURE_KINDS = ("thread", "undercut", "ring_groove", "centre_hole", "oring_groove")
|
|
|
|
FEATURE_SOURCE = {
|
|
"thread": SOURCE_ISO_6410,
|
|
"undercut": "DIN 509 relief grooves - profile from the registered DIN 509 table",
|
|
"ring_groove": "DIN 471 / DIN 472 retaining rings - dimensions from the registered table",
|
|
"centre_hole": "DIN 332-1 centre holes forms A and B - dimensions from the registered table",
|
|
"oring_groove": "ISO 3601-2 O-ring housings - dimensions from the registered table",
|
|
}
|
|
|
|
|
|
def _positive(params: dict, key: str, kind: str) -> float:
|
|
if key not in params:
|
|
raise ValueError(f"{kind}: params[{key!r}] is required")
|
|
value = params[key]
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise ValueError(f"{kind}: params[{key!r}] must be a number, got {value!r}")
|
|
number = float(value)
|
|
if not math.isfinite(number) or number <= 0.0:
|
|
raise ValueError(f"{kind}: params[{key!r}] must be finite and > 0, got {value!r}")
|
|
return number
|
|
|
|
|
|
def _side(params: dict, kind: str) -> str:
|
|
where = " ".join(str(params.get("kind", "shaft")).split()).lower()
|
|
if where not in ("shaft", "bore"):
|
|
raise ValueError(
|
|
f"{kind}: params['kind'] must be 'shaft' or 'bore', got {params.get('kind')!r}"
|
|
)
|
|
return where
|
|
|
|
|
|
def _table_row(standard: str, size, kind: str, required: tuple[str, ...]) -> dict:
|
|
try:
|
|
row = dict(standards_lookup(standard, size))
|
|
except (KeyError, ValueError) as exc:
|
|
raise ValueError(f"{kind}: {standard} - {exc}") from exc
|
|
missing = [key for key in required if key not in row]
|
|
if missing:
|
|
raise ValueError(
|
|
f"{kind}: the {standard} row for {size} carries no {', '.join(missing)}. "
|
|
"std_feature_draw draws the dimensions the standard's own table module "
|
|
"publishes and never reconstructs them."
|
|
)
|
|
return row
|
|
|
|
|
|
def _groove_prims(surface: float, floor: float, width: float) -> tuple[dict, ...]:
|
|
steps = (
|
|
(0.0, surface, 0.0, floor),
|
|
(0.0, floor, width, floor),
|
|
(width, floor, width, surface),
|
|
)
|
|
return tuple(
|
|
_line(x1, sign * y1, x2, sign * y2) for sign in (1.0, -1.0) for x1, y1, x2, y2 in steps
|
|
)
|
|
|
|
|
|
def _thread_feature(params: dict):
|
|
d = _positive(params, "d", "thread")
|
|
length = _positive(params, "length", "thread")
|
|
internal = bool(params.get("internal", False))
|
|
size = params.get("size")
|
|
if size:
|
|
pitch = thread_pitch(size)
|
|
elif "pitch" in params:
|
|
pitch = _positive(params, "pitch", "thread")
|
|
else:
|
|
raise ValueError(
|
|
"thread: give params['size'] (e.g. 'M20', whose pitch comes from ISO 261) or "
|
|
"an explicit params['pitch'] - the pitch is never guessed."
|
|
)
|
|
thin = d / 2.0 if internal else THREAD_MINOR_RATIO * d / 2.0
|
|
prims = (
|
|
_line(0.0, thin, length, thin),
|
|
_line(0.0, -thin, length, -thin),
|
|
_line(length, -d / 2.0, length, d / 2.0),
|
|
)
|
|
dims = {"d": d, "length": length, "pitch": pitch, "internal": internal}
|
|
return prims, dims, "ISO 6410"
|
|
|
|
|
|
def _ring_groove_feature(params: dict):
|
|
d = _positive(params, "d", "ring_groove")
|
|
where = _side(params, "ring_groove")
|
|
standard = " ".join(
|
|
str(params.get("standard") or ("DIN 471" if where == "shaft" else "DIN 472")).split()
|
|
)
|
|
row = _table_row(standard, d, "ring_groove", ("m", "d2"))
|
|
width, groove_d = float(row["m"]), float(row["d2"])
|
|
if not math.isfinite(width) or width <= 0.0:
|
|
raise ValueError(f"ring_groove: {standard} row for d={d} has groove width m={width}.")
|
|
if where == "shaft" and not 0.0 < groove_d < d:
|
|
raise ValueError(
|
|
f"ring_groove: {standard} row for d={d} has d2={groove_d}, which is not a "
|
|
"shaft groove (a shaft groove needs d2 < d)."
|
|
)
|
|
if where == "bore" and not groove_d > d:
|
|
raise ValueError(
|
|
f"ring_groove: {standard} row for d={d} has d2={groove_d}, which is not a "
|
|
"bore groove (a bore groove needs d2 > d)."
|
|
)
|
|
prims = _groove_prims(d / 2.0, groove_d / 2.0, width)
|
|
return prims, {"d": d, "m": width, "d2": groove_d, "kind": where}, standard
|
|
|
|
|
|
def _oring_groove_feature(params: dict):
|
|
d = _positive(params, "d", "oring_groove")
|
|
cord = _positive(params, "cord", "oring_groove")
|
|
where = _side(params, "oring_groove")
|
|
standard = " ".join(str(params.get("standard") or "ISO 3601-2").split())
|
|
row = _table_row(standard, cord, "oring_groove", ("b", "h"))
|
|
width, depth = float(row["b"]), float(row["h"])
|
|
if not math.isfinite(width) or width <= 0.0 or not math.isfinite(depth) or depth <= 0.0:
|
|
raise ValueError(
|
|
f"oring_groove: {standard} row for cord={cord} has b={width}, h={depth}; "
|
|
"both must be finite and > 0."
|
|
)
|
|
if where == "shaft" and depth >= d / 2.0:
|
|
raise ValueError(
|
|
f"oring_groove: {standard} groove depth h={depth} is not inside a shaft of "
|
|
f"d={d} (the floor would be at or through the axis)."
|
|
)
|
|
surface = d / 2.0
|
|
floor = surface - depth if where == "shaft" else surface + depth
|
|
prims = _groove_prims(surface, floor, width)
|
|
return prims, {"d": d, "cord": cord, "b": width, "h": depth, "kind": where}, standard
|
|
|
|
|
|
def _centre_hole_feature(params: dict):
|
|
form = " ".join(str(params.get("form", "A")).split()).upper()
|
|
if form not in ("A", "B"):
|
|
raise ValueError(
|
|
"centre_hole: this tool draws DIN 332-1 forms A and B. Form R's radius "
|
|
f"profile is not transcribed here and is refused rather than approximated; "
|
|
f"got {form!r}."
|
|
)
|
|
if params.get("size") is None:
|
|
raise ValueError("centre_hole: params['size'] is the DIN 332 pilot diameter d1, e.g. 2.5")
|
|
size = params["size"]
|
|
standard = " ".join(str(params.get("standard") or f"DIN 332-{form}").split())
|
|
required = ("d1", "d2", "t") if form == "A" else ("d1", "d2", "d3", "t")
|
|
row = _table_row(standard, size, "centre_hole", required)
|
|
d1, d2, depth = float(row["d1"]), float(row["d2"]), float(row["t"])
|
|
if not 0.0 < d1 < d2:
|
|
raise ValueError(
|
|
f"centre_hole: {standard} row for {size} has d1={d1}, d2={d2}; d1 < d2 is required."
|
|
)
|
|
# 60 degrees included -> the axial run of the countersink is (d2-d1)/2 / tan(30).
|
|
cone = math.sqrt(3.0) * (d2 - d1) / 2.0
|
|
start = 0.0
|
|
prims: list[dict] = []
|
|
dims = {"form": form, "d1": d1, "d2": d2, "t": depth, "cone": cone}
|
|
if form == "B":
|
|
d3 = float(row["d3"])
|
|
if not d3 > d2:
|
|
raise ValueError(
|
|
f"centre_hole: {standard} row for {size} has d3={d3}; form B needs d3 > d2={d2}."
|
|
)
|
|
# 120 degrees included -> (d3-d2)/2 / tan(60).
|
|
chamfer = (d3 - d2) / (2.0 * math.sqrt(3.0))
|
|
prims += [
|
|
_line(0.0, d3 / 2.0, chamfer, d2 / 2.0),
|
|
_line(0.0, -d3 / 2.0, chamfer, -d2 / 2.0),
|
|
]
|
|
start = chamfer
|
|
dims["d3"] = d3
|
|
dims["chamfer"] = chamfer
|
|
prims += [
|
|
_line(start, d2 / 2.0, start + cone, d1 / 2.0),
|
|
_line(start, -d2 / 2.0, start + cone, -d1 / 2.0),
|
|
]
|
|
if depth <= start + cone:
|
|
raise ValueError(
|
|
f"centre_hole: {standard} row for {size} has t={depth} mm, which is not deeper "
|
|
f"than the countersink ({start + cone:.3f} mm); t is measured from the end face."
|
|
)
|
|
prims += [
|
|
_line(start + cone, d1 / 2.0, depth, d1 / 2.0),
|
|
_line(start + cone, -d1 / 2.0, depth, -d1 / 2.0),
|
|
_line(depth, -d1 / 2.0, depth, d1 / 2.0),
|
|
]
|
|
return tuple(prims), dims, standard
|
|
|
|
|
|
def _undercut_feature(params: dict):
|
|
d = _positive(params, "d", "undercut")
|
|
form = " ".join(str(params.get("form", "E")).split()).upper()
|
|
if form not in ("E", "F"):
|
|
raise ValueError(f"undercut: DIN 509 defines forms E and F; got {form!r}.")
|
|
standard = " ".join(str(params.get("standard") or f"DIN 509-{form}").split())
|
|
row = _table_row(standard, d, "undercut", ("profile",))
|
|
profile = row["profile"]
|
|
if not isinstance(profile, (list, tuple)) or len(profile) < 2:
|
|
raise ValueError(
|
|
f"undercut: the {standard} row for d={d} has a profile with fewer than two points."
|
|
)
|
|
points: list[tuple[float, float]] = []
|
|
for index, point in enumerate(profile):
|
|
if not isinstance(point, (list, tuple)) or len(point) != 2:
|
|
raise ValueError(
|
|
f"undercut: {standard} profile[{index}] must be an [along, depth] pair, "
|
|
f"got {point!r}"
|
|
)
|
|
along, depth = float(point[0]), float(point[1])
|
|
if not (math.isfinite(along) and math.isfinite(depth)):
|
|
raise ValueError(f"undercut: {standard} profile[{index}] must be finite.")
|
|
if depth < 0.0 or depth >= d / 2.0:
|
|
raise ValueError(
|
|
f"undercut: {standard} profile[{index}] depth {depth} is not inside a shaft "
|
|
f"of d={d} (needs 0 <= depth < {d / 2.0})."
|
|
)
|
|
points.append((along, d / 2.0 - depth))
|
|
prims = (
|
|
_poly(points, closed=False),
|
|
_poly([(x, -y) for x, y in points], closed=False),
|
|
)
|
|
dims = {"d": d, "form": form, "profile": [[float(a), float(b)] for a, b in profile]}
|
|
return prims, dims, standard
|
|
|
|
|
|
_FEATURES = {
|
|
"thread": _thread_feature,
|
|
"undercut": _undercut_feature,
|
|
"ring_groove": _ring_groove_feature,
|
|
"centre_hole": _centre_hole_feature,
|
|
"oring_groove": _oring_groove_feature,
|
|
}
|
|
|
|
|
|
def feature_spec(kind: str, params: dict) -> dict:
|
|
"""Primitives, resolved dimensions and provenance for one standard feature."""
|
|
name = " ".join(str(kind).split()).lower()
|
|
if name not in STD_FEATURE_KINDS:
|
|
raise ValueError(
|
|
f"std_feature_draw: kind must be one of {', '.join(STD_FEATURE_KINDS)}, got {kind!r}."
|
|
)
|
|
prims, dims, standard = _FEATURES[name](dict(params or {}))
|
|
return {
|
|
"kind": name,
|
|
"prims": prims,
|
|
"dims": dims,
|
|
"standard": standard,
|
|
"source": FEATURE_SOURCE[name],
|
|
}
|
|
|
|
|
|
def feature_prims(kind: str, params: dict) -> tuple[dict, ...]:
|
|
"""Just the primitives of :func:`feature_spec`, in the feature's local frame."""
|
|
return feature_spec(kind, params)["prims"]
|
|
|
|
|
|
def place_prims(prims, at, rotation: float = 0.0) -> tuple[dict, ...]:
|
|
"""Map local primitives into WCS: rotate about the origin, then translate to ``at``."""
|
|
try:
|
|
x0, y0 = float(at[0]), float(at[1])
|
|
except (TypeError, IndexError, ValueError) as exc:
|
|
raise ValueError(f"place_prims: 'at' must be two numbers, got {at!r}") from exc
|
|
if not (math.isfinite(x0) and math.isfinite(y0)):
|
|
raise ValueError(f"place_prims: 'at' must be two finite numbers, got {at!r}")
|
|
degrees = float(rotation)
|
|
if not math.isfinite(degrees):
|
|
raise ValueError(f"place_prims: rotation must be finite, got {rotation!r}")
|
|
cosine, sine = math.cos(math.radians(degrees)), math.sin(math.radians(degrees))
|
|
|
|
def point(x: float, y: float) -> tuple[float, float]:
|
|
return (x0 + x * cosine - y * sine, y0 + x * sine + y * cosine)
|
|
|
|
placed: list[dict] = []
|
|
for prim in prims:
|
|
moved = dict(prim)
|
|
kind = prim["type"]
|
|
if kind == "line":
|
|
moved["x1"], moved["y1"] = point(prim["x1"], prim["y1"])
|
|
moved["x2"], moved["y2"] = point(prim["x2"], prim["y2"])
|
|
elif kind == "circle":
|
|
moved["cx"], moved["cy"] = point(prim["cx"], prim["cy"])
|
|
elif kind == "arc":
|
|
moved["cx"], moved["cy"] = point(prim["cx"], prim["cy"])
|
|
moved["start_deg"] = prim["start_deg"] + degrees
|
|
moved["end_deg"] = prim["end_deg"] + degrees
|
|
elif kind == "polyline":
|
|
moved["points"] = [list(point(px, py)) for px, py in prim["points"]]
|
|
else: # pragma: no cover - the builders emit nothing else
|
|
raise ValueError(f"place_prims: cannot place a {kind!r} primitive")
|
|
placed.append(moved)
|
|
return tuple(placed)
|
|
|
|
|
|
async def draw_std_feature(
|
|
backend: AutoCADBackend,
|
|
kind: str,
|
|
at,
|
|
params: dict,
|
|
*,
|
|
rotation: float = 0.0,
|
|
layer: str | None = None,
|
|
) -> dict:
|
|
"""Draw a standard feature onto geometry that already exists.
|
|
|
|
``at`` is a WCS point on the axis; ``rotation`` turns the local +X (along
|
|
the axis, in the direction the feature extends) counter-clockwise. ``layer``
|
|
overrides the visible primitives only - CENTER and HIDDEN primitives keep
|
|
their own. Every refusal fires before the first entity is written.
|
|
"""
|
|
spec = feature_spec(kind, params)
|
|
placed = place_prims(spec["prims"], at, rotation)
|
|
handles: list[str] = []
|
|
layers: list[str] = []
|
|
for prim in placed:
|
|
target = layer if (layer and prim["layer"] == LAYER_VISIBLE) else prim["layer"]
|
|
if prim["type"] == "line":
|
|
info = await backend.entity_create_line(
|
|
prim["x1"], prim["y1"], prim["x2"], prim["y2"], layer=target
|
|
)
|
|
elif prim["type"] == "circle":
|
|
info = await backend.entity_create_circle(
|
|
prim["cx"], prim["cy"], prim["r"], layer=target
|
|
)
|
|
elif prim["type"] == "arc":
|
|
info = await backend.entity_create_arc(
|
|
prim["cx"], prim["cy"], prim["r"], prim["start_deg"], prim["end_deg"], layer=target
|
|
)
|
|
else:
|
|
info = await backend.entity_create_polyline(prim["points"], prim["closed"], target)
|
|
handles.append(info.handle)
|
|
if target not in layers:
|
|
layers.append(target)
|
|
return {
|
|
"ok": True,
|
|
"kind": spec["kind"],
|
|
"handles": handles,
|
|
"primitive_count": len(handles),
|
|
"dims": spec["dims"],
|
|
"standard": spec["standard"],
|
|
"source": spec["source"],
|
|
"layers": layers,
|
|
"at": [float(at[0]), float(at[1])],
|
|
"rotation": float(rotation),
|
|
"backend": backend.name,
|
|
}
|