Two wrong-drawing bugs in SECTION 22.
1. _hex_points defaulted to start_deg=0, putting vertices at 3 and 9 o'clock,
so the plan of an M12 hex head measured 18 mm (across flats) across the
vertical while the elevation drew that same head 20.78 mm tall (across
corners e, silhouette edges at +/- e/4 - the projection of a hexagon with
vertices top and bottom). Place the two blocks in projection and the two
views of one part disagree. The default is now 30 deg, named HEX_START_DEG
and documented on both the constant and _hex_points as the convention the
elevation is the projection of, so a future fourth caller cannot pick the
other one by accident. The three top builders (_hex_fastener_top,
_hex_nut_top, _socket_head_top) inherit it.
2. _thread_feature read the pitch from params['size'] via the ISO 261 table
while drawing the geometry from params['d'], never cross-checking the two.
feature_spec('thread', {'d': 8.0, 'length': 20.0, 'size': 'M20'}) drew an M8
minor diameter and returned dims['pitch'] = 2.5 with ISO 261 cited as the
source - one thread's pitch on another thread's geometry, a silent wrong
number carrying a standards citation. A mismatch is now refused by name,
like every other check in the module. std_feature_draw's params description
says so.
Guard tests: the old top-view test asserted only len(points) == 6 and never a
vertex coordinate, so the orientation was untested. Added the vertex
coordinates, the plan-vs-elevation silhouette-height invariant across ISO 4014
/ 4017 / 4032, the socket screw's dk invariant, and the refused thread pair.
No ActiveX member is added or changed by either fix; both are pure geometry and
validation, so no live measurement is cited. The live AutoCAD could not be
probed for a confirmation run - GetActiveObject blocked past 150 s under
sibling contention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
383 lines
14 KiB
Python
383 lines
14 KiB
Python
"""The standard-parts catalogue: search, designations, block specs, insertion.
|
|
|
|
Geometry is asserted as emitted primitive dicts - counts, layers, coordinates -
|
|
never as a screenshot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from backends.block_specs import validate_attdef_specs, validate_entity_specs
|
|
from engineering.mech.stdparts import (
|
|
FAMILY_STANDARDS,
|
|
FAMILY_VIEWS,
|
|
THREAD_MINOR_RATIO,
|
|
block_spec,
|
|
catalogue,
|
|
parse_designation,
|
|
)
|
|
|
|
R12 = 2.0 * 18.0 / math.sqrt(3.0) / 2.0 # across-corners radius of an M12 hex head
|
|
MINOR12 = THREAD_MINOR_RATIO * 12.0 / 2.0 # ISO 6410 minor radius of an M12 thread
|
|
|
|
|
|
def test_the_iso6410_minor_radius_is_four_point_eight():
|
|
"""Pinned as the builder computes it: 0.8 * 12 / 2 is 4.800000000000001 in binary."""
|
|
assert MINOR12 == pytest.approx(4.8)
|
|
|
|
|
|
def test_the_catalogue_holds_every_transcribed_size():
|
|
rows = catalogue()
|
|
assert len(rows) == 83 # 5 fastener standards x 10 sizes + 33 bearings
|
|
assert len(catalogue(standard="ISO 15")) == 33
|
|
assert len(catalogue("M12")) == 5
|
|
assert len(catalogue("6205")) == 1
|
|
assert {row["standard"] for row in rows} == set(FAMILY_STANDARDS)
|
|
|
|
|
|
def test_a_catalogue_row_carries_what_a_drawing_needs():
|
|
(row,) = catalogue("6205")
|
|
assert row["designation"] == "ISO 15 - 6205"
|
|
assert row["standard"] == "ISO 15"
|
|
assert row["family"] == "deep_groove_bearing"
|
|
assert row["views"] == ["simplified", "detailed"]
|
|
assert row["length_required"] is False
|
|
assert row["dims"] == {"d": 25.0, "D": 52.0, "B": 15.0}
|
|
assert "ISO 15:2017" in row["source"]
|
|
|
|
bolt = next(r for r in catalogue(standard="ISO 4014") if r["size"] == "M12")
|
|
assert bolt["length_required"] is True
|
|
assert bolt["dims"]["s"] == 18.0 and bolt["dims"]["k"] == 7.5
|
|
|
|
|
|
def test_catalogue_refuses_an_unknown_standard():
|
|
with pytest.raises(ValueError, match="ISO 4014"):
|
|
catalogue(standard="DIN 931")
|
|
|
|
|
|
def test_parse_designation_normalises_and_splits():
|
|
assert parse_designation("iso4014 - m12x60") == {
|
|
"designation": "ISO 4014 - M12x60",
|
|
"standard": "ISO 4014",
|
|
"family": "hex_bolt",
|
|
"size": "M12x60",
|
|
"thread": "M12",
|
|
"length": 60.0,
|
|
}
|
|
assert parse_designation("6205")["designation"] == "ISO 15 - 6205"
|
|
assert parse_designation("ISO 4032 - M12")["length"] is None
|
|
|
|
|
|
def test_parse_designation_refuses_a_missing_or_stray_length():
|
|
with pytest.raises(ValueError, match="nominal length"):
|
|
parse_designation("ISO 4014 - M12")
|
|
with pytest.raises(ValueError, match="takes no length"):
|
|
parse_designation("ISO 4032 - M12x60")
|
|
with pytest.raises(ValueError, match="catalogue standard"):
|
|
parse_designation("ISO 9999 - M12")
|
|
|
|
|
|
def test_an_unknown_size_is_refused_with_the_nearest_entries():
|
|
with pytest.raises(ValueError, match=r"ISO 4014 - M12"):
|
|
block_spec("ISO 4014 - M13x60", "side")
|
|
with pytest.raises(ValueError, match=r"ISO 15 - 6210"):
|
|
block_spec("ISO 15 - 6211", "simplified")
|
|
with pytest.raises(ValueError, match="approximately"):
|
|
block_spec("ISO 4014 - M13x60", "side")
|
|
|
|
|
|
def test_hex_bolt_side_view_is_the_iso6410_representation():
|
|
spec = block_spec("ISO 4014 - M12x60", "side")
|
|
assert spec["name"] == "STD_ISO_4014_M12X60_SIDE"
|
|
assert spec["view"] == "side"
|
|
assert spec["dims"]["b"] == pytest.approx(30.0) # 2d + 6 for l <= 125
|
|
ents = spec["entities"]
|
|
assert len(ents) == 10
|
|
assert ents[0]["type"] == "polyline" and ents[0]["closed"] is True
|
|
assert ents[0]["points"] == [
|
|
[-7.5, -R12],
|
|
[0.0, -R12],
|
|
[0.0, R12],
|
|
[-7.5, R12],
|
|
]
|
|
assert ents[3] == {
|
|
"type": "line",
|
|
"x1": 0.0,
|
|
"y1": 6.0,
|
|
"x2": 60.0,
|
|
"y2": 6.0,
|
|
"layer": "GEOMETRY",
|
|
}
|
|
# ISO 6410: minor diameter at 0.8 x major, over the useful thread length.
|
|
assert ents[6] == {
|
|
"type": "line",
|
|
"x1": 30.0,
|
|
"y1": MINOR12,
|
|
"x2": 60.0,
|
|
"y2": MINOR12,
|
|
"layer": "GEOMETRY",
|
|
}
|
|
# ISO 6410: the thread-length limit is a line to the major diameter.
|
|
assert ents[8] == {
|
|
"type": "line",
|
|
"x1": 30.0,
|
|
"y1": -6.0,
|
|
"x2": 30.0,
|
|
"y2": 6.0,
|
|
"layer": "GEOMETRY",
|
|
}
|
|
assert ents[9]["layer"] == "CENTER"
|
|
assert spec["bbox"] == pytest.approx((-10.5, -R12, 63.0, R12))
|
|
|
|
|
|
def test_a_fully_threaded_screw_has_no_thread_limit_line():
|
|
spec = block_spec("ISO 4017 - M12x60", "side")
|
|
assert len(spec["entities"]) == 9
|
|
assert spec["dims"]["b"] == pytest.approx(60.0)
|
|
minor = [e for e in spec["entities"] if e.get("y1") == MINOR12]
|
|
assert minor[0]["x1"] == 0.0 and minor[0]["x2"] == 60.0
|
|
|
|
|
|
def test_hex_top_view_uses_the_three_quarter_minor_arc():
|
|
spec = block_spec("ISO 4014 - M12x60", "top")
|
|
ents = spec["entities"]
|
|
assert len(ents) == 5
|
|
assert ents[0]["type"] == "polyline" and len(ents[0]["points"]) == 6
|
|
assert ents[1] == {"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0, "layer": "GEOMETRY"}
|
|
assert ents[2] == {
|
|
"type": "arc",
|
|
"cx": 0.0,
|
|
"cy": 0.0,
|
|
"r": MINOR12,
|
|
"start_deg": 90.0,
|
|
"end_deg": 360.0,
|
|
"layer": "GEOMETRY",
|
|
}
|
|
assert ents[3]["layer"] == "CENTER" and ents[4]["layer"] == "CENTER"
|
|
|
|
|
|
def _hexagon(spec: dict) -> list[list[float]]:
|
|
return next(e for e in spec["entities"] if e["type"] == "polyline")["points"]
|
|
|
|
|
|
def _height(points) -> float:
|
|
return max(y for _, y in points) - min(y for _, y in points)
|
|
|
|
|
|
def _width(points) -> float:
|
|
return max(x for x, _ in points) - min(x for x, _ in points)
|
|
|
|
|
|
def test_the_hexagon_has_vertices_at_twelve_and_six_oclock():
|
|
"""The orientation the elevation is drawn as the projection of."""
|
|
points = _hexagon(block_spec("ISO 4014 - M12x60", "top"))
|
|
assert [pytest.approx(v, abs=1e-9) for v in points[0]] == [
|
|
R12 * math.sqrt(3.0) / 2.0,
|
|
R12 / 2.0,
|
|
]
|
|
assert [pytest.approx(v, abs=1e-9) for v in points[1]] == [0.0, R12]
|
|
assert _height(points) == pytest.approx(2.0 * R12) # across corners e
|
|
assert _width(points) == pytest.approx(18.0) # across flats s
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"designation", ["ISO 4014 - M12x60", "ISO 4017 - M12x60", "ISO 4032 - M12"]
|
|
)
|
|
def test_the_two_views_of_one_fastener_agree_on_the_silhouette_height(designation):
|
|
"""Plan and elevation of the same part must measure the same across the vertical.
|
|
|
|
Place the two blocks in projection and a disagreement here is a wrong
|
|
workshop drawing: the head would read 20.78 mm in the elevation and 18 mm in
|
|
the plan. The elevation's outline polyline spans the full head height, and
|
|
the plan's hexagon must span the same.
|
|
"""
|
|
elevation = _height(_hexagon(block_spec(designation, "side")))
|
|
assert elevation == pytest.approx(2.0 * R12)
|
|
assert _height(_hexagon(block_spec(designation, "top"))) == pytest.approx(elevation)
|
|
|
|
|
|
def test_the_socket_head_views_agree_on_the_head_diameter():
|
|
"""The socket screw's silhouette is dk in both views; its hexagon is the key socket."""
|
|
elevation = _height(_hexagon(block_spec("ISO 4762 - M12x60", "side")))
|
|
plan = block_spec("ISO 4762 - M12x60", "top")["entities"]
|
|
assert plan[0]["type"] == "circle" and 2.0 * plan[0]["r"] == pytest.approx(elevation)
|
|
socket = plan[1]["points"]
|
|
assert _height(socket) > _width(socket) # same orientation as every other hexagon here
|
|
|
|
|
|
def test_hex_nut_side_view_shows_the_thread_as_hidden_lines():
|
|
spec = block_spec("ISO 4032 - M12", "side")
|
|
ents = spec["entities"]
|
|
assert len(ents) == 8
|
|
hidden = [e for e in ents if e["layer"] == "HIDDEN"]
|
|
assert len(hidden) == 4
|
|
assert {abs(e["y1"]) for e in hidden} == {6.0, MINOR12}
|
|
assert ents[0]["points"][1] == [10.8, -R12] # nut height m
|
|
|
|
|
|
def test_washer_views():
|
|
side = block_spec("ISO 7089 - M12", "side")
|
|
assert len(side["entities"]) == 3
|
|
assert side["entities"][0]["points"] == [[0.0, 6.5], [2.5, 6.5], [2.5, 12.0], [0.0, 12.0]]
|
|
top = block_spec("ISO 7089 - M12", "top")
|
|
assert top["entities"][0] == {
|
|
"type": "circle",
|
|
"cx": 0.0,
|
|
"cy": 0.0,
|
|
"r": 12.0,
|
|
"layer": "GEOMETRY",
|
|
}
|
|
assert top["entities"][1]["r"] == 6.5
|
|
|
|
|
|
def test_socket_head_screw_views():
|
|
side = block_spec("ISO 4762 - M8x30", "side")
|
|
assert len(side["entities"]) == 8
|
|
assert side["dims"]["b"] == pytest.approx(28.0)
|
|
assert side["entities"][0]["points"][0] == [-8.0, -6.5] # k = 8, dk/2 = 6.5
|
|
top = block_spec("ISO 4762 - M8x30", "top")
|
|
assert top["entities"][0]["r"] == 6.5
|
|
assert len(top["entities"][1]["points"]) == 6 # the hexagon socket, s = 6
|
|
|
|
|
|
def test_bearing_simplified_is_the_iso8826_1_outline_and_cross():
|
|
spec = block_spec("ISO 15 - 6205", "simplified")
|
|
ents = spec["entities"]
|
|
assert len(ents) == 7
|
|
assert ents[0]["points"] == [[-7.5, 12.5], [7.5, 12.5], [7.5, 26.0], [-7.5, 26.0]]
|
|
assert ents[1]["points"] == [[-7.5, -12.5], [7.5, -12.5], [7.5, -26.0], [-7.5, -26.0]]
|
|
assert ents[2] == {
|
|
"type": "line",
|
|
"x1": -4.5,
|
|
"y1": 19.25,
|
|
"x2": 4.5,
|
|
"y2": 19.25,
|
|
"layer": "GEOMETRY",
|
|
}
|
|
assert ents[3] == {
|
|
"type": "line",
|
|
"x1": 0.0,
|
|
"y1": 15.2,
|
|
"x2": 0.0,
|
|
"y2": 23.3,
|
|
"layer": "GEOMETRY",
|
|
}
|
|
assert ents[6]["layer"] == "CENTER"
|
|
|
|
|
|
def test_bearing_detailed_draws_the_element_symbol():
|
|
spec = block_spec("ISO 15 - 6205", "detailed")
|
|
ents = spec["entities"]
|
|
assert len(ents) == 5
|
|
assert ents[2] == {
|
|
"type": "circle",
|
|
"cx": 0.0,
|
|
"cy": 19.25,
|
|
"r": 4.05,
|
|
"layer": "GEOMETRY",
|
|
}
|
|
assert ents[3]["cy"] == -19.25
|
|
|
|
|
|
def test_a_view_the_family_does_not_have_is_refused_and_side_falls_back():
|
|
assert block_spec("ISO 15 - 6205", "side")["view"] == "simplified"
|
|
with pytest.raises(ValueError, match="simplified"):
|
|
block_spec("ISO 15 - 6205", "top")
|
|
with pytest.raises(ValueError, match="side"):
|
|
block_spec("ISO 4014 - M12x60", "simplified")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("designation", "view"),
|
|
[
|
|
("ISO 4014 - M12x60", "side"),
|
|
("ISO 4014 - M12x60", "top"),
|
|
("ISO 4017 - M12x60", "side"),
|
|
("ISO 4032 - M12", "side"),
|
|
("ISO 4032 - M12", "top"),
|
|
("ISO 7089 - M12", "side"),
|
|
("ISO 7089 - M12", "top"),
|
|
("ISO 4762 - M8x30", "side"),
|
|
("ISO 4762 - M8x30", "top"),
|
|
("ISO 15 - 6205", "simplified"),
|
|
("ISO 15 - 6205", "detailed"),
|
|
],
|
|
)
|
|
def test_every_block_spec_validates_against_block_define(designation, view):
|
|
spec = block_spec(designation, view)
|
|
validate_entity_specs(spec["entities"])
|
|
validate_attdef_specs(spec["attdefs"])
|
|
assert [att["tag"] for att in spec["attdefs"]] == ["DESIG", "STD", "SIZE", "MAT"]
|
|
assert spec["attdefs"][0]["default"] == spec["designation"]
|
|
assert spec["attdefs"][0]["invisible"] is False
|
|
assert all(att["invisible"] for att in spec["attdefs"][1:])
|
|
assert spec["bbox"][0] < spec["bbox"][2] and spec["bbox"][1] < spec["bbox"][3]
|
|
assert set(FAMILY_VIEWS[spec["family"]]) >= {spec["view"]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insert_defines_the_block_once_and_writes_the_payload(backend):
|
|
from engineering.mech.stdparts import insert_std_part
|
|
from engineering.mech.xdata import APP_ID, decode
|
|
|
|
result = await insert_std_part(backend, "ISO 4014 - M12x60", at=(100.0, 50.0), material="8.8")
|
|
assert result["ok"] is True
|
|
assert result["block_name"] == "STD_ISO_4014_M12X60_SIDE"
|
|
assert result["defined"] is True
|
|
assert sorted(result["layers_created"]) == ["CENTER", "GEOMETRY"]
|
|
assert result["view"] == "side"
|
|
assert result["layer"] == "GEOMETRY"
|
|
assert result["dims"]["s"] == 18.0
|
|
|
|
stored = await backend.entity_get_xdata(result["handle"], APP_ID)
|
|
payload = decode([(1001, APP_ID)] + [(1000, value) for value in stored["xdata"][APP_ID]])
|
|
assert payload == {
|
|
"v": 1,
|
|
"kind": "std_part",
|
|
"designation": "ISO 4014 - M12x60",
|
|
"standard": "ISO 4014",
|
|
"size": "M12x60",
|
|
"qty": 1,
|
|
"material": "8.8",
|
|
}
|
|
assert await backend.block_get_attributes(result["handle"]) == {
|
|
"DESIG": "ISO 4014 - M12x60",
|
|
"STD": "ISO 4014",
|
|
"SIZE": "M12x60",
|
|
"MAT": "8.8",
|
|
}
|
|
|
|
again = await insert_std_part(backend, "ISO 4014 - M12x60", at=(200.0, 50.0))
|
|
assert again["defined"] is False and again["layers_created"] == []
|
|
assert again["payload"]["material"] == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bearing_inserts_with_its_own_view_name(backend):
|
|
from engineering.mech.stdparts import insert_std_part
|
|
|
|
result = await insert_std_part(backend, "6205", at=(0.0, 0.0), rotation=90.0)
|
|
assert result["block_name"] == "STD_ISO_15_6205_SIMPLIFIED"
|
|
assert result["view"] == "simplified"
|
|
assert result["rotation"] == 90.0
|
|
assert result["dims"]["B"] == 15.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insert_refuses_before_writing_anything(backend):
|
|
from engineering.mech.stdparts import insert_std_part
|
|
|
|
before = len(await backend.entity_list())
|
|
with pytest.raises(ValueError, match="Nearest entries"):
|
|
await insert_std_part(backend, "ISO 4014 - M13x60", at=(0.0, 0.0))
|
|
with pytest.raises(ValueError, match="finite"):
|
|
await insert_std_part(backend, "ISO 4014 - M12x60", at=(float("nan"), 0.0))
|
|
with pytest.raises(ValueError, match="no attribute"):
|
|
await insert_std_part(
|
|
backend, "ISO 4014 - M12x60", at=(0.0, 0.0), attributes={"TORQUE": "80"}
|
|
)
|
|
assert len(await backend.entity_list()) == before
|