feat(mech): six mech_* critique focuses, the mech pack, lean 60, aliases and the counts
The four track-B/G branches are merged; this is the integration that makes the 25 new tools one surface instead of four. - engineering/mech/critique.py: the six focuses of spec 13, dispatched the way the P&ID ones are. One index per critique run, shared through run_critique's context, so six focuses cost one read of the drawing; a drawing with no ACADMCP_MECH payload yields nothing at all, which is what keeps focus=None cheap for a P&ID sheet or a plain DXF. Each focus has a positive and a negative fixture. Two narrowings are in the module docstring rather than hidden: mech_view_misaligned checks the projection axis and not the quadrant, because the pinned payload carries no projection angle to check it against, and mech_duplicate_dimension catches two dimensions in the same place rather than a semantic restatement, because no engine exposes what a dimension measured. - The index reads what the tools actually write, not only the plan's fixture shape. mech_part_draw anchors its part payload on a POINT (layer MECH) and lists every drawn view under "views"; the plan's index read INSERT/CIRCLE only and one "view" per payload, so it was silent on every part the server draws. It now reads POINT too, expands "views" into one entry per drawn view (the section check looks in the section's own WCS box, the alignment check compares frame_bbox centres), and pages entity_list instead of one call. Test: a part drawn by draw_part + add_view is read and passes all six; the same part with its hatch erased is reported. - Live type names, measured on AutoCAD 2026 (2026-09-23, scratch document, closed): ObjectName AcDbBlockReference -> BLOCKREFERENCE, AcDbRotatedDimension -> ROTATEDDIMENSION, AcDbAlignedDimension -> ALIGNEDDIMENSION, AcDbPoint -> POINT with GetBoundingBox = the point. The index reads BLOCKREFERENCE as an INSERT and any *DIMENSION as a dimension; a wrapper test reports the ezdxf entities under exactly those measured names. - mech_thread_unrepresented uses the view engine's own MINOR_RATIO, and checks the thin line ISO 6410 draws: the minor diameter of an external thread, the major of an internal one. - mech_bom_balloon_mismatch: identical parts share one balloon (ISO 6433 balloons a parts-list row), so a second M12x60 is covered by the first one's balloon instead of reported. - TOOL_PACK_NAMES has "mech" (the 14 tools of SECTIONS 21-23). SECTION 24 stays core: a frame, a title block, a parts list and an xref are universal drafting, not a vertical. Lean gains five and reaches 60 (54 with TOOL_PACKS=core). - _GROUP_TAG_PRIORITY gains sheet directly after mech; the mech group label becomes "mechanical". SECTION 24's last four tools (xref_attach, xref_manage, image_attach, drawing_export_dwg) now carry the sheet tag, so group, pack and SECTION are one list: 229 tools in 25 groups, and engineering/blocks/entity_creation/drawing return to their pre-SECTION-24 sizes. - Ten golden tuning cases, two of them Turkish (with the diacritic spellings added to the alias records next to the ASCII-folded ones). The shared AutoCAD command set needed no change: the merge had already resolved TABLE, REVCLOUD, SECTIONPLANE, INSERT, ARRAY and SAVEAS, and nothing is stale. - Inventory regenerated, the frozen group-size snapshot retaken from the live registry, and the README release-snapshot / pack / lean figures taken from scripts/check_doc_numbers.py rather than typed. Co-Authored-By: Claude Opus 5.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.5
parent
a6c9c0d927
commit
3b033033f9
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3976 collected tests.
|
||||||
> 229 is the **registered** count; a default install advertises 224 over `tools/list`,
|
> 229 is the **registered** count; a default install advertises 224 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>229 tools in 24 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>229 tools in 25 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> keeps the last) from a client that needs none of them. 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.
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ Nothing loads a `.env` file — export these, or set them in your MCP client's `
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
|
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
|
||||||
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to |
|
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to |
|
||||||
| `TOOL_PROFILE` | `full` | `lean` (55 curated tools, 52 with `TOOL_PACKS=core`) or `full` |
|
| `TOOL_PROFILE` | `full` | `lean` (60 curated tools, 54 with `TOOL_PACKS=core`) or `full` |
|
||||||
| `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid,settings` (`core` always on) |
|
| `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid,settings` (`core` always on) |
|
||||||
| `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` |
|
| `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` |
|
||||||
| `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM) |
|
| `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM) |
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"machining symbol",
|
"machining symbol",
|
||||||
"yuzey puruzlulugu",
|
"yuzey puruzlulugu",
|
||||||
"yuzey isleme sembolu",
|
"yuzey isleme sembolu",
|
||||||
|
"yüzey pürüzlülüğü",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"weld_symbol": ToolAliases(
|
"weld_symbol": ToolAliases(
|
||||||
@@ -382,6 +383,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"weld all around",
|
"weld all around",
|
||||||
"kaynak sembolu",
|
"kaynak sembolu",
|
||||||
"kose kaynagi",
|
"kose kaynagi",
|
||||||
|
"kaynak sembolü",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"centre_marks": ToolAliases(
|
"centre_marks": ToolAliases(
|
||||||
@@ -1077,6 +1079,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"materials table",
|
"materials table",
|
||||||
"iso 7573 table",
|
"iso 7573 table",
|
||||||
"parca listesi tablosu",
|
"parca listesi tablosu",
|
||||||
|
"draw the parts list table on the sheet",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"balloon_add": ToolAliases(
|
"balloon_add": ToolAliases(
|
||||||
@@ -1149,6 +1152,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"autocad 2018 format",
|
"autocad 2018 format",
|
||||||
"downgrade to r2000",
|
"downgrade to r2000",
|
||||||
"dwg olarak kaydet",
|
"dwg olarak kaydet",
|
||||||
|
"save this drawing as a dwg",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# ── Layers & linetypes ──────────────────────────────────────────────────
|
# ── Layers & linetypes ──────────────────────────────────────────────────
|
||||||
@@ -2202,6 +2206,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"somun",
|
"somun",
|
||||||
"rondela",
|
"rondela",
|
||||||
"rulman",
|
"rulman",
|
||||||
|
"put an m12 hex bolt on the drawing",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"std_feature_draw": ToolAliases(
|
"std_feature_draw": ToolAliases(
|
||||||
|
|||||||
+40
-35
@@ -6,26 +6,27 @@
|
|||||||
"tools": 229,
|
"tools": 229,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 24
|
"groups": 25
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 11,
|
"blocks": 9,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 12,
|
"drawing": 11,
|
||||||
"engineering": 17,
|
"engineering": 10,
|
||||||
"entity_creation": 19,
|
"entity_creation": 18,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"environment": 22,
|
"environment": 22,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 12,
|
||||||
"mech": 14,
|
"mechanical": 14,
|
||||||
"page_setup": 6,
|
"page_setup": 6,
|
||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
|
"sheet": 11,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
"styles": 10,
|
"styles": 10,
|
||||||
"system": 7,
|
"system": 7,
|
||||||
@@ -181,7 +182,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "balloon_add",
|
"name": "balloon_add",
|
||||||
"group": "engineering",
|
"group": "sheet",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Add an ISO 6433 balloon linked to its item row.",
|
"summary": "Add an ISO 6433 balloon linked to its item row.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -320,7 +321,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bom_extract",
|
"name": "bom_extract",
|
||||||
"group": "engineering",
|
"group": "sheet",
|
||||||
"cost": "read",
|
"cost": "read",
|
||||||
"summary": "Read the bill of materials out of the drawing's block references.",
|
"summary": "Read the bill of materials out of the drawing's block references.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -335,7 +336,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bom_table",
|
"name": "bom_table",
|
||||||
"group": "engineering",
|
"group": "sheet",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw the parts list as a real TABLE above the title block.",
|
"summary": "Draw the parts list as a real TABLE above the title block.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -389,7 +390,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "centre_marks",
|
"name": "centre_marks",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.",
|
"summary": "Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -431,7 +432,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "data_extract",
|
"name": "data_extract",
|
||||||
"group": "engineering",
|
"group": "sheet",
|
||||||
"cost": "safe",
|
"cost": "safe",
|
||||||
"summary": "Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.",
|
"summary": "Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -729,11 +730,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "drawing_export_dwg",
|
"name": "drawing_export_dwg",
|
||||||
"group": "drawing",
|
"group": "sheet",
|
||||||
"cost": "safe",
|
"cost": "safe",
|
||||||
"summary": "Write the drawing as a real DWG at a chosen AutoCAD version.",
|
"summary": "Write the drawing as a real DWG at a chosen AutoCAD version.",
|
||||||
"tags": [
|
"tags": [
|
||||||
"drawing"
|
"drawing",
|
||||||
|
"sheet"
|
||||||
],
|
],
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Drawing: Export DWG",
|
"title": "Drawing: Export DWG",
|
||||||
@@ -1680,7 +1682,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "hatch_material",
|
"name": "hatch_material",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Hatch a region with the ISO 128-50 section pattern for a named material.",
|
"summary": "Hatch a region with the ISO 128-50 section pattern for a named material.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1708,12 +1710,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "image_attach",
|
"name": "image_attach",
|
||||||
"group": "entity_creation",
|
"group": "sheet",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Attach a raster image (PNG/JPG) as an underlay.",
|
"summary": "Attach a raster image (PNG/JPG) as an underlay.",
|
||||||
"tags": [
|
"tags": [
|
||||||
"create",
|
"create",
|
||||||
"drawing"
|
"drawing",
|
||||||
|
"sheet"
|
||||||
],
|
],
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Sheet: Attach Image",
|
"title": "Sheet: Attach Image",
|
||||||
@@ -2084,7 +2087,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mech_dimension_part",
|
"name": "mech_dimension_part",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
|
"summary": "Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2098,7 +2101,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mech_hole_pattern",
|
"name": "mech_hole_pattern",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
|
"summary": "Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2112,7 +2115,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mech_part_draw",
|
"name": "mech_part_draw",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw a machine part from a segment list or an outline plus typed features.",
|
"summary": "Draw a machine part from a segment list or an outline plus typed features.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2126,7 +2129,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mech_part_from_spec",
|
"name": "mech_part_from_spec",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw a whole mechanical sheet - several parts and their views - in one transaction.",
|
"summary": "Draw a whole mechanical sheet - several parts and their views - in one transaction.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2140,7 +2143,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mech_part_inspect",
|
"name": "mech_part_inspect",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "read",
|
"cost": "read",
|
||||||
"summary": "Read the part model back off the drawing: segments, features, views, omissions.",
|
"summary": "Read the part model back off the drawing: segments, features, views, omissions.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2154,7 +2157,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mech_view_add",
|
"name": "mech_view_add",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
|
"summary": "Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2412,7 +2415,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "revision_add",
|
"name": "revision_add",
|
||||||
"group": "engineering",
|
"group": "sheet",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Append a revision row, with optional revision clouds and triangular tags.",
|
"summary": "Append a revision row, with optional revision clouds and triangular tags.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2426,7 +2429,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "section_line",
|
"name": "section_line",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.",
|
"summary": "Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2496,7 +2499,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sheet_frame",
|
"name": "sheet_frame",
|
||||||
"group": "engineering",
|
"group": "sheet",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw an ISO 5457 sheet frame with the zone grid and centring marks.",
|
"summary": "Draw an ISO 5457 sheet frame with the zone grid and centring marks.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2575,7 +2578,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "std_feature_draw",
|
"name": "std_feature_draw",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole - onto existing geometry.",
|
"summary": "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole - onto existing geometry.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2589,7 +2592,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "std_part_insert",
|
"name": "std_part_insert",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing - as a tagged block.",
|
"summary": "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing - as a tagged block.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2603,7 +2606,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "std_part_list",
|
"name": "std_part_list",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "read",
|
"cost": "read",
|
||||||
"summary": "Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.",
|
"summary": "Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2617,7 +2620,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "surface_texture",
|
"name": "surface_texture",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw an ISO 21920-1 surface-texture symbol: Ra/Rz, process, lay, allowance.",
|
"summary": "Draw an ISO 21920-1 surface-texture symbol: Ra/Rz, process, lay, allowance.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2895,7 +2898,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "titleblock_apply",
|
"name": "titleblock_apply",
|
||||||
"group": "engineering",
|
"group": "sheet",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.",
|
"summary": "Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3216,7 +3219,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "weld_symbol",
|
"name": "weld_symbol",
|
||||||
"group": "mech",
|
"group": "mechanical",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Draw an ISO 2553 weld symbol: reference line, identification line, size and pitch.",
|
"summary": "Draw an ISO 2553 weld symbol: reference line, identification line, size and pitch.",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3230,12 +3233,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "xref_attach",
|
"name": "xref_attach",
|
||||||
"group": "blocks",
|
"group": "sheet",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "Attach a DWG/DXF as an external reference (attachment or overlay).",
|
"summary": "Attach a DWG/DXF as an external reference (attachment or overlay).",
|
||||||
"tags": [
|
"tags": [
|
||||||
"block",
|
"block",
|
||||||
"drawing"
|
"drawing",
|
||||||
|
"sheet"
|
||||||
],
|
],
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Sheet: Attach Xref",
|
"title": "Sheet: Attach Xref",
|
||||||
@@ -3244,12 +3248,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "xref_manage",
|
"name": "xref_manage",
|
||||||
"group": "blocks",
|
"group": "sheet",
|
||||||
"cost": "mutate",
|
"cost": "mutate",
|
||||||
"summary": "List, reload, bind, detach or repath an external reference.",
|
"summary": "List, reload, bind, detach or repath an external reference.",
|
||||||
"tags": [
|
"tags": [
|
||||||
"block",
|
"block",
|
||||||
"drawing"
|
"drawing",
|
||||||
|
"sheet"
|
||||||
],
|
],
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Sheet: Manage Xrefs",
|
"title": "Sheet: Manage Xrefs",
|
||||||
|
|||||||
@@ -412,6 +412,10 @@ from .pid.critique import PID_DISPATCH # noqa: E402 (registered after the mech
|
|||||||
|
|
||||||
_FOCUS_DISPATCH.update(PID_DISPATCH)
|
_FOCUS_DISPATCH.update(PID_DISPATCH)
|
||||||
|
|
||||||
|
from .mech.critique import MECH_DISPATCH # noqa: E402 (same reason: after the base focuses)
|
||||||
|
|
||||||
|
_FOCUS_DISPATCH.update(MECH_DISPATCH)
|
||||||
|
|
||||||
|
|
||||||
async def run_critique(
|
async def run_critique(
|
||||||
backend: AutoCADBackend,
|
backend: AutoCADBackend,
|
||||||
|
|||||||
@@ -0,0 +1,666 @@
|
|||||||
|
"""The six mechanical critique focuses (spec §13).
|
||||||
|
|
||||||
|
Every check is pure over one index built once per critique run and shared
|
||||||
|
through ``run_critique``'s context, so six focuses cost one read of the
|
||||||
|
drawing. A drawing with no ``ACADMCP_MECH`` payload on it yields no issues at
|
||||||
|
all: a P&ID sheet or a plain DXF must not be scored against mechanical
|
||||||
|
conventions, and ``focus=None`` has to stay cheap for them.
|
||||||
|
|
||||||
|
What the index reads, and why only that: every mechanical payload this server
|
||||||
|
writes lands on a POINT (``mech_part_draw``'s part anchor, on layer ``MECH``),
|
||||||
|
an INSERT (a standard part; BLOCKREFERENCE on the live engine) or a CIRCLE (a
|
||||||
|
balloon is a circle with a leader), so XDATA is read for those types only. The
|
||||||
|
rest of the drawing comes from a paged ``entity_list`` -- every entity, not the
|
||||||
|
first 200 -- whose ``EntityInfo`` already carries each entity's WCS
|
||||||
|
``bounding_box`` on both engines.
|
||||||
|
|
||||||
|
A part payload written by ``mech_part_draw`` lists every view it drew under
|
||||||
|
``views``, each with its WCS ``bbox`` and its ``frame_bbox`` (the same box in
|
||||||
|
the unrotated sheet frame the views are laid out in). The index expands that
|
||||||
|
list into one view entry per drawn view, so the section check looks inside the
|
||||||
|
section's own box and the alignment check compares boxes in the frame the
|
||||||
|
projection rule is stated in. A payload with no ``views`` list stands for one
|
||||||
|
view (its ``view`` key) at its anchor.
|
||||||
|
|
||||||
|
Two narrowings are deliberate, and neither is hidden:
|
||||||
|
|
||||||
|
* ``mech_view_misaligned`` checks the projection *axis*, not the quadrant. The
|
||||||
|
pinned part payload records the view kind and the part id — not the
|
||||||
|
projection angle, and not which side a side view was taken from — so the
|
||||||
|
quadrant rule has nothing true to read. Guessing it would report a correct
|
||||||
|
drawing as wrong, or the reverse.
|
||||||
|
* ``mech_duplicate_dimension`` reports two dimensions of the same type that
|
||||||
|
occupy the same place: the failure a second run of ``dimension_auto``, or a
|
||||||
|
manual dimension over an automatic one, produces. A semantic restatement (a
|
||||||
|
chain that also carries the overall length) is not detected, because no
|
||||||
|
engine exposes a dimension's measured feature.
|
||||||
|
|
||||||
|
Where a box has to be guessed at all — the region of a view — it is guessed
|
||||||
|
*generously*. A check that misses a bad drawing costs one review; a check that
|
||||||
|
calls a correct drawing bad gets switched off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from engineering.plan_spec import Issue
|
||||||
|
|
||||||
|
from .primitives import ROLE_LAYER
|
||||||
|
from .standards.threads import MINOR_RATIO
|
||||||
|
from .xdata import APP_ID, decode
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from backends.base import AutoCADBackend
|
||||||
|
|
||||||
|
MECH_FOCUSES = (
|
||||||
|
"mech_missing_centreline",
|
||||||
|
"mech_unhatched_section",
|
||||||
|
"mech_view_misaligned",
|
||||||
|
"mech_duplicate_dimension",
|
||||||
|
"mech_thread_unrepresented",
|
||||||
|
"mech_bom_balloon_mismatch",
|
||||||
|
)
|
||||||
|
_KEY = "mech_index"
|
||||||
|
|
||||||
|
#: The entity types that can carry an ACADMCP_MECH payload. The live engine
|
||||||
|
#: names a type from its ActiveX ObjectName (``AcDb`` stripped, upper-cased), so
|
||||||
|
#: an INSERT reads back as BLOCKREFERENCE there -- measured on AutoCAD 2026.
|
||||||
|
_XDATA_TYPES = ("POINT", "INSERT", "BLOCKREFERENCE", "CIRCLE")
|
||||||
|
#: One ``entity_list`` page; the index pages until a short page.
|
||||||
|
PAGE_SIZE = 1000
|
||||||
|
#: Half a millimetre is visible on a printed sheet.
|
||||||
|
ALIGN_TOL_MM = 0.5
|
||||||
|
#: How close a CENTER line must pass to a circle's centre to be its mark.
|
||||||
|
CENTRE_TOL_MM = 0.5
|
||||||
|
#: ISO 6410 draws a thread's minor diameter at 0.8 x the nominal -- the same
|
||||||
|
#: constant the view engine draws it with, so the check and the drawing agree.
|
||||||
|
THREAD_MINOR_RATIO = MINOR_RATIO
|
||||||
|
#: Relative tolerance when matching drawn geometry against a computed diameter.
|
||||||
|
DIAMETER_TOL = 0.02
|
||||||
|
#: Padding added to a view's computed extents before looking inside it.
|
||||||
|
VIEW_PAD_MM = 10.0
|
||||||
|
|
||||||
|
_DIM_TYPES = {
|
||||||
|
"DIMENSION",
|
||||||
|
"DIMLINEAR",
|
||||||
|
"DIMALIGNED",
|
||||||
|
"DIMANGULAR",
|
||||||
|
"DIMRADIUS",
|
||||||
|
"DIMDIAMETER",
|
||||||
|
"DIMORDINATE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dimension(ent) -> bool:
|
||||||
|
"""A DIMENSION on either engine: ezdxf says ``DIMENSION``; the live engine
|
||||||
|
says ``ROTATEDDIMENSION`` / ``ALIGNEDDIMENSION`` / ``RADIALDIMENSION`` ...
|
||||||
|
(the ObjectName, measured on AutoCAD 2026)."""
|
||||||
|
kind = str(ent.type).upper()
|
||||||
|
return kind in _DIM_TYPES or kind.endswith("DIMENSION")
|
||||||
|
|
||||||
|
|
||||||
|
_METRIC_THREAD = re.compile(r"^M\s*([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
# ── the index ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _anchor_point(ent) -> tuple[float, float] | None:
|
||||||
|
props = getattr(ent, "properties", None) or {}
|
||||||
|
for key in ("insertion", "center"):
|
||||||
|
value = props.get(key)
|
||||||
|
if value and len(value) >= 2:
|
||||||
|
return (float(value[0]), float(value[1]))
|
||||||
|
box = props.get("bounding_box")
|
||||||
|
if box:
|
||||||
|
return (
|
||||||
|
(float(box["min"][0]) + float(box["max"][0])) / 2.0,
|
||||||
|
(float(box["min"][1]) + float(box["max"][1])) / 2.0,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox_center(ent) -> tuple[float, float] | None:
|
||||||
|
box = (getattr(ent, "properties", None) or {}).get("bounding_box")
|
||||||
|
if not box:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
(float(box["min"][0]) + float(box["max"][0])) / 2.0,
|
||||||
|
(float(box["min"][1]) + float(box["max"][1])) / 2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _all_entities(backend: AutoCADBackend) -> list:
|
||||||
|
"""Every entity of the current space, paged -- ``entity_list`` defaults to 200.
|
||||||
|
|
||||||
|
Stops on a short page, and on a page that brings nothing new (a backend
|
||||||
|
that ignored ``offset`` would otherwise return the same page for ever).
|
||||||
|
"""
|
||||||
|
found: list = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
page = await backend.entity_list(limit=PAGE_SIZE, offset=offset)
|
||||||
|
fresh = [ent for ent in page if ent.handle not in seen]
|
||||||
|
if not fresh:
|
||||||
|
return found
|
||||||
|
seen.update(ent.handle for ent in fresh)
|
||||||
|
found.extend(fresh)
|
||||||
|
if len(page) < PAGE_SIZE:
|
||||||
|
return found
|
||||||
|
offset += len(page)
|
||||||
|
|
||||||
|
|
||||||
|
def _box(value) -> tuple[float, float, float, float] | None:
|
||||||
|
try:
|
||||||
|
x0, y0, x1, y1 = (float(v) for v in value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
|
||||||
|
|
||||||
|
|
||||||
|
def _centre(box) -> tuple[float, float]:
|
||||||
|
return ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _views_of(record: dict) -> list[dict]:
|
||||||
|
"""The views one part payload stands for (see the module docstring)."""
|
||||||
|
payload = record["payload"]
|
||||||
|
part_id = str(payload.get("id") or record["handle"])
|
||||||
|
drawn = payload.get("views")
|
||||||
|
if not (isinstance(drawn, list) and drawn):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"handle": record["handle"],
|
||||||
|
"part_id": part_id,
|
||||||
|
"kind": _view_kind(record),
|
||||||
|
"index": 0,
|
||||||
|
"at": record["at"],
|
||||||
|
"box": _view_box(record),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
out: list[dict] = []
|
||||||
|
for position, view in enumerate(drawn):
|
||||||
|
if not isinstance(view, dict):
|
||||||
|
continue
|
||||||
|
wcs = _box(view.get("bbox"))
|
||||||
|
frame = _box(view.get("frame_bbox")) or wcs
|
||||||
|
if wcs is None or frame is None:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"handle": record["handle"],
|
||||||
|
"part_id": part_id,
|
||||||
|
"kind": str(view.get("kind") or "").split(":")[0].strip().lower(),
|
||||||
|
"index": position,
|
||||||
|
"at": _centre(frame),
|
||||||
|
"box": (
|
||||||
|
wcs[0] - VIEW_PAD_MM,
|
||||||
|
wcs[1] - VIEW_PAD_MM,
|
||||||
|
wcs[2] + VIEW_PAD_MM,
|
||||||
|
wcs[3] + VIEW_PAD_MM,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def build_index(backend: AutoCADBackend) -> dict:
|
||||||
|
"""One read of the drawing: every entity, plus every ACADMCP_MECH payload."""
|
||||||
|
try:
|
||||||
|
entities = await _all_entities(backend)
|
||||||
|
except Exception:
|
||||||
|
entities = []
|
||||||
|
records: list[dict] = []
|
||||||
|
for ent in entities:
|
||||||
|
if str(ent.type).upper() not in _XDATA_TYPES:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = await backend.entity_get_xdata(ent.handle, APP_ID)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
values = (raw.get("xdata") or {}).get(APP_ID) or []
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = decode(values)
|
||||||
|
except ValueError:
|
||||||
|
# A foreign or corrupt payload is not mechanical content; the codec
|
||||||
|
# already refused it, and guessing at it here would undo that.
|
||||||
|
continue
|
||||||
|
at = _anchor_point(ent)
|
||||||
|
if at is None:
|
||||||
|
continue
|
||||||
|
records.append({"handle": ent.handle, "payload": payload, "at": at})
|
||||||
|
views = [
|
||||||
|
view
|
||||||
|
for record in records
|
||||||
|
if record["payload"].get("kind") == "part"
|
||||||
|
for view in _views_of(record)
|
||||||
|
]
|
||||||
|
return {"entities": entities, "records": records, "views": views}
|
||||||
|
|
||||||
|
|
||||||
|
def has_mech_content(index: dict) -> bool:
|
||||||
|
"""True when at least one entity carries an ACADMCP_MECH payload."""
|
||||||
|
return bool(index.get("records"))
|
||||||
|
|
||||||
|
|
||||||
|
def _of_kind(index: dict, kind: str) -> list[dict]:
|
||||||
|
return [r for r in index["records"] if r["payload"].get("kind") == kind]
|
||||||
|
|
||||||
|
|
||||||
|
def _view_kind(record: dict) -> str:
|
||||||
|
return str(record["payload"].get("view") or "").split(":")[0].strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _extents(part: dict) -> tuple[float, float] | None:
|
||||||
|
"""``(length, height)`` of a part model, read defensively.
|
||||||
|
|
||||||
|
The pinned ``Segment`` is a NamedTuple ``(length, d_outer, d_inner,
|
||||||
|
taper_to)``, so a serialised segment is either a mapping or that sequence.
|
||||||
|
Both are read rather than assuming which one ``part_to_dict`` chose.
|
||||||
|
"""
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
return None
|
||||||
|
segments = part.get("segments")
|
||||||
|
if segments:
|
||||||
|
length = 0.0
|
||||||
|
dmax = 0.0
|
||||||
|
for seg in segments:
|
||||||
|
if isinstance(seg, dict):
|
||||||
|
run = float(seg.get("length", 0.0) or 0.0)
|
||||||
|
outer = float(seg.get("d_outer", 0.0) or 0.0)
|
||||||
|
taper = seg.get("taper_to")
|
||||||
|
else:
|
||||||
|
row = list(seg)
|
||||||
|
run = float(row[0]) if row else 0.0
|
||||||
|
outer = float(row[1]) if len(row) > 1 else 0.0
|
||||||
|
taper = row[3] if len(row) > 3 else None
|
||||||
|
length += run
|
||||||
|
dmax = max(dmax, outer, float(taper) if taper else 0.0)
|
||||||
|
return (length, dmax)
|
||||||
|
outline = part.get("outline")
|
||||||
|
if outline:
|
||||||
|
xs = [float(p[0]) for p in outline]
|
||||||
|
ys = [float(p[1]) for p in outline]
|
||||||
|
return (max(xs) - min(xs), max(ys) - min(ys))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _view_box(record: dict) -> tuple[float, float, float, float] | None:
|
||||||
|
"""A deliberately generous box around a view's anchor.
|
||||||
|
|
||||||
|
The anchor may be the view's origin or its centre depending on how the
|
||||||
|
view was placed, so the box spans the part's full extents on *both* sides
|
||||||
|
plus a pad. It answers only "is there any hatch near this section", which
|
||||||
|
is the question that matters.
|
||||||
|
"""
|
||||||
|
extents = _extents(record["payload"].get("part") or {})
|
||||||
|
if extents is None:
|
||||||
|
return None
|
||||||
|
x, y = record["at"]
|
||||||
|
half_w = extents[0] + VIEW_PAD_MM
|
||||||
|
half_h = max(extents[1], extents[0]) + VIEW_PAD_MM
|
||||||
|
return (x - half_w, y - half_h, x + half_w, y + half_h)
|
||||||
|
|
||||||
|
|
||||||
|
def _inside(point, box) -> bool:
|
||||||
|
if point is None or box is None:
|
||||||
|
return False
|
||||||
|
return box[0] <= point[0] <= box[2] and box[1] <= point[1] <= box[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _distance_to_segment(point, start, end) -> float:
|
||||||
|
if not start or not end:
|
||||||
|
return math.inf
|
||||||
|
px, py = point
|
||||||
|
ax, ay = float(start[0]), float(start[1])
|
||||||
|
bx, by = float(end[0]), float(end[1])
|
||||||
|
dx, dy = bx - ax, by - ay
|
||||||
|
length_sq = dx * dx + dy * dy
|
||||||
|
if length_sq <= 0.0:
|
||||||
|
return math.hypot(px - ax, py - ay)
|
||||||
|
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length_sq))
|
||||||
|
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||||
|
|
||||||
|
|
||||||
|
# ── the six checks ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_centreline(index: dict) -> list[Issue]:
|
||||||
|
"""ISO 128-23: a circular feature carries a centre mark."""
|
||||||
|
geometry = {ROLE_LAYER["visible"].upper(), ROLE_LAYER["hidden"].upper()}
|
||||||
|
centre_layer = ROLE_LAYER["center"].upper()
|
||||||
|
marks = [
|
||||||
|
e
|
||||||
|
for e in index["entities"]
|
||||||
|
if str(e.type).upper() == "LINE" and str(e.layer).upper() == centre_layer
|
||||||
|
]
|
||||||
|
out: list[Issue] = []
|
||||||
|
for ent in index["entities"]:
|
||||||
|
if str(ent.type).upper() != "CIRCLE" or str(ent.layer).upper() not in geometry:
|
||||||
|
continue
|
||||||
|
centre = (ent.properties or {}).get("center")
|
||||||
|
if not centre:
|
||||||
|
continue
|
||||||
|
point = (float(centre[0]), float(centre[1]))
|
||||||
|
crossing = [
|
||||||
|
m
|
||||||
|
for m in marks
|
||||||
|
if _distance_to_segment(
|
||||||
|
point, (m.properties or {}).get("start"), (m.properties or {}).get("end")
|
||||||
|
)
|
||||||
|
<= CENTRE_TOL_MM
|
||||||
|
]
|
||||||
|
if len(crossing) < 2:
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"warning",
|
||||||
|
"mech_missing_centreline",
|
||||||
|
f"Circle {ent.handle} at ({point[0]:.3f}, {point[1]:.3f}) has no ISO "
|
||||||
|
f"128-23 centre mark ({len(crossing)} of the two crossing "
|
||||||
|
f"{centre_layer} lines found).",
|
||||||
|
[ent.handle],
|
||||||
|
{"hint": "centre_marks(handles=[...]) draws them", "found": len(crossing)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _unhatched_section(index: dict) -> list[Issue]:
|
||||||
|
hatches = [e for e in index["entities"] if str(e.type).upper() == "HATCH"]
|
||||||
|
out: list[Issue] = []
|
||||||
|
for view in index.get("views", ()):
|
||||||
|
if view["kind"] != "section":
|
||||||
|
continue
|
||||||
|
if any(_inside(_bbox_center(h), view["box"]) for h in hatches):
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"error",
|
||||||
|
"mech_unhatched_section",
|
||||||
|
f"Section view {view['index']} of part {view['part_id']!r} "
|
||||||
|
f"({view['handle']}) has no hatched cut face; ISO 128-50 hatches every "
|
||||||
|
"cut surface.",
|
||||||
|
[view["handle"]],
|
||||||
|
{
|
||||||
|
"hint": "hatch_material(handles=[...], material=...)",
|
||||||
|
"hatches": len(hatches),
|
||||||
|
"view": view["index"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _view_misaligned(index: dict) -> list[Issue]:
|
||||||
|
out: list[Issue] = []
|
||||||
|
by_part: dict[str, list[dict]] = {}
|
||||||
|
for view in index.get("views", ()):
|
||||||
|
by_part.setdefault(view["part_id"], []).append(view)
|
||||||
|
for part_id, records in sorted(by_part.items()):
|
||||||
|
fronts = [r for r in records if r["kind"] == "front"]
|
||||||
|
if not fronts:
|
||||||
|
continue
|
||||||
|
front = fronts[0]
|
||||||
|
fx, fy = front["at"]
|
||||||
|
for record in records:
|
||||||
|
kind = record["kind"]
|
||||||
|
x, y = record["at"]
|
||||||
|
if kind in ("side", "section"):
|
||||||
|
delta = abs(y - fy)
|
||||||
|
axis = "y"
|
||||||
|
elif kind == "top":
|
||||||
|
delta = abs(x - fx)
|
||||||
|
axis = "x"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if delta <= ALIGN_TOL_MM:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"warning",
|
||||||
|
"mech_view_misaligned",
|
||||||
|
f"The {kind} view {record['handle']} of part {part_id!r} sits "
|
||||||
|
f"{delta:.3f} mm off the projection axis of its front view "
|
||||||
|
f"{front['handle']} (view {record['index']} of the part).",
|
||||||
|
list(dict.fromkeys((record["handle"], front["handle"]))),
|
||||||
|
{
|
||||||
|
"axis": axis,
|
||||||
|
"delta_mm": round(delta, 4),
|
||||||
|
"tolerance_mm": ALIGN_TOL_MM,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_dimension(index: dict) -> list[Issue]:
|
||||||
|
seen: dict[tuple, str] = {}
|
||||||
|
out: list[Issue] = []
|
||||||
|
for ent in index["entities"]:
|
||||||
|
if not _is_dimension(ent):
|
||||||
|
continue
|
||||||
|
props = ent.properties or {}
|
||||||
|
box = props.get("bounding_box")
|
||||||
|
if not box:
|
||||||
|
continue
|
||||||
|
defpoint = props.get("defpoint")
|
||||||
|
key = (
|
||||||
|
str(ent.type).upper(),
|
||||||
|
tuple(
|
||||||
|
round(float(v), 3)
|
||||||
|
for v in (box["min"][0], box["min"][1], box["max"][0], box["max"][1])
|
||||||
|
),
|
||||||
|
tuple(round(float(v), 3) for v in defpoint[:2]) if defpoint else None,
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"warning",
|
||||||
|
"mech_duplicate_dimension",
|
||||||
|
f"Dimensions {seen[key]} and {ent.handle} occupy the same place and "
|
||||||
|
"measure the same feature; ISO 129-1 gives each feature one dimension.",
|
||||||
|
[seen[key], ent.handle],
|
||||||
|
{"hint": "delete one, or dimension the feature in a single view"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
seen[key] = ent.handle
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_diameter(feature: dict) -> float | None:
|
||||||
|
for key in ("d", "diameter", "nominal", "nominal_mm"):
|
||||||
|
value = feature.get(key)
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
return float(value)
|
||||||
|
match = _METRIC_THREAD.match(str(feature.get("designation") or ""))
|
||||||
|
return float(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_diameter(index: dict, target: float) -> bool:
|
||||||
|
"""Is anything on the sheet drawn at this diameter?
|
||||||
|
|
||||||
|
A round feature answers as a CIRCLE or ARC of half the diameter; the axial
|
||||||
|
view answers as two horizontal lines that far apart.
|
||||||
|
"""
|
||||||
|
tol = max(DIAMETER_TOL * target, 1e-6)
|
||||||
|
horizontals: list[float] = []
|
||||||
|
for ent in index["entities"]:
|
||||||
|
kind = str(ent.type).upper()
|
||||||
|
props = ent.properties or {}
|
||||||
|
if kind in ("CIRCLE", "ARC"):
|
||||||
|
radius = props.get("radius")
|
||||||
|
if radius is not None and abs(2.0 * float(radius) - target) <= tol:
|
||||||
|
return True
|
||||||
|
elif kind == "LINE":
|
||||||
|
start, end = props.get("start"), props.get("end")
|
||||||
|
if start and end and abs(float(start[1]) - float(end[1])) <= 1e-6:
|
||||||
|
horizontals.append(float(start[1]))
|
||||||
|
for i, y1 in enumerate(horizontals[:500]):
|
||||||
|
for y2 in horizontals[i + 1 : 500]:
|
||||||
|
if abs(abs(y1 - y2) - target) <= tol:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_unrepresented(index: dict) -> list[Issue]:
|
||||||
|
out: list[Issue] = []
|
||||||
|
for record in _of_kind(index, "part"):
|
||||||
|
part = record["payload"].get("part") or {}
|
||||||
|
for feature in part.get("features") or ():
|
||||||
|
if not isinstance(feature, dict):
|
||||||
|
continue
|
||||||
|
if not str(feature.get("kind") or "").lower().startswith("thread"):
|
||||||
|
continue
|
||||||
|
nominal = _thread_diameter(feature)
|
||||||
|
if nominal is None:
|
||||||
|
continue
|
||||||
|
minor = THREAD_MINOR_RATIO * nominal
|
||||||
|
# ISO 6410: the thin line is the minor diameter of an external
|
||||||
|
# thread and the major diameter of an internal one (a tapped
|
||||||
|
# hole's drilled bore is the minor, drawn thick).
|
||||||
|
internal = bool(feature.get("internal"))
|
||||||
|
thin = nominal if internal else minor
|
||||||
|
if _has_diameter(index, thin):
|
||||||
|
continue
|
||||||
|
name = feature.get("id") or feature.get("designation") or "thread"
|
||||||
|
which = (
|
||||||
|
f"major diameter ({nominal:.3f} mm)"
|
||||||
|
if internal
|
||||||
|
else f"minor diameter ({minor:.3f} mm = {THREAD_MINOR_RATIO:g} x {nominal:.3f} mm)"
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"error",
|
||||||
|
"mech_thread_unrepresented",
|
||||||
|
f"Thread {name!r} on part {record['payload'].get('id')!r} is drawn "
|
||||||
|
f"without its ISO 6410 thin-line {which}.",
|
||||||
|
[record["handle"]],
|
||||||
|
{
|
||||||
|
"nominal_mm": round(nominal, 4),
|
||||||
|
"minor_mm": round(minor, 4),
|
||||||
|
"internal": internal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _bom_balloon_mismatch(index: dict) -> list[Issue]:
|
||||||
|
out: list[Issue] = []
|
||||||
|
balloons = _of_kind(index, "balloon")
|
||||||
|
parts = _of_kind(index, "std_part")
|
||||||
|
if not balloons and not parts:
|
||||||
|
return out
|
||||||
|
part_handles = {r["handle"] for r in parts} | {r["handle"] for r in _of_kind(index, "part")}
|
||||||
|
balloon_targets: set[str] = set()
|
||||||
|
by_item: dict[str, list[dict]] = {}
|
||||||
|
for record in balloons:
|
||||||
|
item = record["payload"].get("item")
|
||||||
|
targets = [str(t) for t in (record["payload"].get("targets") or [])]
|
||||||
|
by_item.setdefault(str(item), []).append(record)
|
||||||
|
resolved = [t for t in targets if t in part_handles]
|
||||||
|
balloon_targets.update(resolved)
|
||||||
|
if not resolved:
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"error",
|
||||||
|
"mech_bom_balloon_mismatch",
|
||||||
|
f"Balloon {item!r} ({record['handle']}) points at "
|
||||||
|
f"{targets or 'nothing'}, which is not a part on this sheet.",
|
||||||
|
[record["handle"]],
|
||||||
|
{"targets": targets},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item, records in sorted(by_item.items()):
|
||||||
|
if len(records) > 1:
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"warning",
|
||||||
|
"mech_bom_balloon_mismatch",
|
||||||
|
f"Item number {item} is carried by {len(records)} balloons; ISO 6433 "
|
||||||
|
"gives one item reference per parts-list row.",
|
||||||
|
[r["handle"] for r in records],
|
||||||
|
{"item": item, "count": len(records)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if balloons:
|
||||||
|
# ISO 6433 balloons a parts-list *row*, not every instance: four
|
||||||
|
# identical bolts share one item reference, so a part is covered when
|
||||||
|
# any balloon points at a part of the same designation.
|
||||||
|
covered = {
|
||||||
|
str(r["payload"].get("designation")) for r in parts if r["handle"] in balloon_targets
|
||||||
|
}
|
||||||
|
for record in parts:
|
||||||
|
if record["handle"] in balloon_targets:
|
||||||
|
continue
|
||||||
|
if str(record["payload"].get("designation")) in covered:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"warning",
|
||||||
|
"mech_bom_balloon_mismatch",
|
||||||
|
f"{record['payload'].get('designation')} ({record['handle']}) has no "
|
||||||
|
"balloon; ISO 7573 gives every parts-list row an item reference.",
|
||||||
|
[record["handle"]],
|
||||||
|
{"designation": record["payload"].get("designation")},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inserted: dict[str, int] = {}
|
||||||
|
declared: dict[str, int] = {}
|
||||||
|
for record in parts:
|
||||||
|
designation = str(record["payload"].get("designation"))
|
||||||
|
inserted[designation] = inserted.get(designation, 0) + 1
|
||||||
|
declared[designation] = declared.get(designation, 0) + int(
|
||||||
|
record["payload"].get("qty") or 1
|
||||||
|
)
|
||||||
|
for designation, count in sorted(inserted.items()):
|
||||||
|
if declared[designation] != count:
|
||||||
|
out.append(
|
||||||
|
Issue(
|
||||||
|
"warning",
|
||||||
|
"mech_bom_balloon_mismatch",
|
||||||
|
f"{designation} is inserted {count} time(s) but its XDATA declares "
|
||||||
|
f"{declared[designation]}.",
|
||||||
|
[r["handle"] for r in parts if r["payload"].get("designation") == designation],
|
||||||
|
{"inserted": count, "declared": declared[designation]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_CHECKS = {
|
||||||
|
"mech_missing_centreline": _missing_centreline,
|
||||||
|
"mech_unhatched_section": _unhatched_section,
|
||||||
|
"mech_view_misaligned": _view_misaligned,
|
||||||
|
"mech_duplicate_dimension": _duplicate_dimension,
|
||||||
|
"mech_thread_unrepresented": _thread_unrepresented,
|
||||||
|
"mech_bom_balloon_mismatch": _bom_balloon_mismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def issues_for(focus: str, index: dict) -> list[Issue]:
|
||||||
|
if not has_mech_content(index):
|
||||||
|
return []
|
||||||
|
return _CHECKS[focus](index)
|
||||||
|
|
||||||
|
|
||||||
|
def _make(focus: str):
|
||||||
|
async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]:
|
||||||
|
index = shared.get(_KEY)
|
||||||
|
if index is None:
|
||||||
|
index = shared[_KEY] = await build_index(backend)
|
||||||
|
return issues_for(focus, index)
|
||||||
|
|
||||||
|
check.needs_shared = True # type: ignore[attr-defined]
|
||||||
|
check.__name__ = f"check_{focus}"
|
||||||
|
return check
|
||||||
|
|
||||||
|
|
||||||
|
MECH_DISPATCH = {focus: _make(focus) for focus in MECH_FOCUSES}
|
||||||
@@ -27,6 +27,12 @@ CritiqueFocus = Literal[
|
|||||||
"pid_untagged_instrument",
|
"pid_untagged_instrument",
|
||||||
"pid_illegal_tag",
|
"pid_illegal_tag",
|
||||||
"pid_unconnected_equipment",
|
"pid_unconnected_equipment",
|
||||||
|
"mech_missing_centreline",
|
||||||
|
"mech_unhatched_section",
|
||||||
|
"mech_view_misaligned",
|
||||||
|
"mech_duplicate_dimension",
|
||||||
|
"mech_thread_unrepresented",
|
||||||
|
"mech_bom_balloon_mismatch",
|
||||||
]
|
]
|
||||||
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
|
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
|
||||||
|
|
||||||
@@ -44,6 +50,12 @@ ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
|
|||||||
"pid_untagged_instrument",
|
"pid_untagged_instrument",
|
||||||
"pid_illegal_tag",
|
"pid_illegal_tag",
|
||||||
"pid_unconnected_equipment",
|
"pid_unconnected_equipment",
|
||||||
|
"mech_missing_centreline",
|
||||||
|
"mech_unhatched_section",
|
||||||
|
"mech_view_misaligned",
|
||||||
|
"mech_duplicate_dimension",
|
||||||
|
"mech_thread_unrepresented",
|
||||||
|
"mech_bom_balloon_mismatch",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -691,6 +691,7 @@ async def _registered_tool_count() -> int | None:
|
|||||||
_GROUP_TAG_PRIORITY = (
|
_GROUP_TAG_PRIORITY = (
|
||||||
"pid",
|
"pid",
|
||||||
"mech",
|
"mech",
|
||||||
|
"sheet",
|
||||||
"style",
|
"style",
|
||||||
"pagesetup",
|
"pagesetup",
|
||||||
"environment",
|
"environment",
|
||||||
@@ -719,7 +720,8 @@ _GROUP_TAG_PRIORITY = (
|
|||||||
# Map the winning tag to a human-readable group label for the breakdown.
|
# Map the winning tag to a human-readable group label for the breakdown.
|
||||||
_GROUP_TAG_LABELS = {
|
_GROUP_TAG_LABELS = {
|
||||||
"pid": "pid",
|
"pid": "pid",
|
||||||
"mech": "mech",
|
"mech": "mechanical",
|
||||||
|
"sheet": "sheet",
|
||||||
"style": "styles",
|
"style": "styles",
|
||||||
"pagesetup": "page_setup",
|
"pagesetup": "page_setup",
|
||||||
"environment": "environment",
|
"environment": "environment",
|
||||||
@@ -949,6 +951,17 @@ LEAN_TOOL_NAMES = frozenset(
|
|||||||
# them on the lean surface; with S's three above, lean is 55.
|
# them on the lean surface; with S's three above, lean is 55.
|
||||||
"page_setup_apply",
|
"page_setup_apply",
|
||||||
"batch_plot",
|
"batch_plot",
|
||||||
|
# Mechanical + sheet (tracks B/G) — the five a lean client needs to
|
||||||
|
# draw a part and put it on a sheet. The catalogue, the annotation
|
||||||
|
# symbols and the delivery tools stay full-profile: a lean client
|
||||||
|
# draws the part and leaves the paperwork to a full one. The first
|
||||||
|
# three are `mech`-pack tools, so TOOL_PACKS=core hides them from a
|
||||||
|
# lean surface too; sheet_frame and titleblock_apply are core.
|
||||||
|
"mech_part_draw",
|
||||||
|
"mech_view_add",
|
||||||
|
"mech_dimension_part",
|
||||||
|
"sheet_frame",
|
||||||
|
"titleblock_apply",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1004,8 +1017,10 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
|
|||||||
"system_prompt_message",
|
"system_prompt_message",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
# SECTIONS 21-23 - the mechanical part model, standard parts and
|
# SECTIONS 21-23 — the mechanical part drawer, the standard-parts catalogue
|
||||||
# mechanical annotation (tracks B+G).
|
# and the ISO annotation symbols (spec §11). SECTION 24 (the sheet, the
|
||||||
|
# parts list, xrefs and DWG) stays in `core`: a frame, a title block, a
|
||||||
|
# parts list and an xref are universal drafting, not a vertical.
|
||||||
"mech": frozenset(
|
"mech": frozenset(
|
||||||
{
|
{
|
||||||
"mech_part_draw",
|
"mech_part_draw",
|
||||||
@@ -5692,7 +5707,10 @@ async def drawing_finalize(
|
|||||||
(iso128, layer_color, dim_overlap, untrimmed_corner, duplicate_entities, construction_left,
|
(iso128, layer_color, dim_overlap, untrimmed_corner, duplicate_entities, construction_left,
|
||||||
gdt, plus the P&ID focuses pid_dangling_line, pid_duplicate_tag,
|
gdt, plus the P&ID focuses pid_dangling_line, pid_duplicate_tag,
|
||||||
pid_incompatible_connection, pid_untagged_instrument, pid_illegal_tag,
|
pid_incompatible_connection, pid_untagged_instrument, pid_illegal_tag,
|
||||||
pid_unconnected_equipment — silent on a sheet with no P&ID symbols),
|
pid_unconnected_equipment — silent on a sheet with no P&ID symbols, plus the
|
||||||
|
mechanical focuses mech_missing_centreline, mech_unhatched_section,
|
||||||
|
mech_view_misaligned, mech_duplicate_dimension, mech_thread_unrepresented,
|
||||||
|
mech_bom_balloon_mismatch — silent on a sheet with no mechanical part),
|
||||||
then saves to disk, exports a screenshot, and returns the DWG path.
|
then saves to disk, exports a screenshot, and returns the DWG path.
|
||||||
|
|
||||||
Raises ToolError if any validator 'error' finding is present, or if critique reports an
|
Raises ToolError if any validator 'error' finding is present, or if critique reports an
|
||||||
@@ -5947,10 +5965,14 @@ async def drawing_critique(
|
|||||||
Field(
|
Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Subset of: iso128, layer_color, dim_overlap, untrimmed_corner, "
|
description="Subset of: iso128, layer_color, dim_overlap, untrimmed_corner, "
|
||||||
"duplicate_entities, construction_left, gdt, and the P&ID focuses "
|
"duplicate_entities, construction_left, gdt, the P&ID focuses "
|
||||||
"pid_dangling_line, pid_duplicate_tag, pid_incompatible_connection, "
|
"pid_dangling_line, pid_duplicate_tag, pid_incompatible_connection, "
|
||||||
"pid_untagged_instrument, pid_illegal_tag, pid_unconnected_equipment "
|
"pid_untagged_instrument, pid_illegal_tag, pid_unconnected_equipment "
|
||||||
"(silent on a drawing with no P&ID symbols). None = run all.",
|
"(silent on a drawing with no P&ID symbols), and the mechanical focuses "
|
||||||
|
"mech_missing_centreline, mech_unhatched_section, mech_view_misaligned, "
|
||||||
|
"mech_duplicate_dimension, mech_thread_unrepresented, "
|
||||||
|
"mech_bom_balloon_mismatch (silent on a drawing with no mechanical part). "
|
||||||
|
"None = run all.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
ctx: Context = None,
|
ctx: Context = None,
|
||||||
@@ -9576,7 +9598,7 @@ async def balloon_add(
|
|||||||
)
|
)
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations={"title": "Sheet: Attach Xref", "destructiveHint": False},
|
annotations={"title": "Sheet: Attach Xref", "destructiveHint": False},
|
||||||
tags={"drawing", "block"},
|
tags={"sheet", "drawing", "block"},
|
||||||
)
|
)
|
||||||
async def xref_attach(
|
async def xref_attach(
|
||||||
path: Annotated[str, Field(description="Full path of the drawing to reference.")],
|
path: Annotated[str, Field(description="Full path of the drawing to reference.")],
|
||||||
@@ -9612,7 +9634,7 @@ async def xref_attach(
|
|||||||
@cad_tool(summary="List, reload, bind, detach or repath an external reference.", cost="mutate")
|
@cad_tool(summary="List, reload, bind, detach or repath an external reference.", cost="mutate")
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations={"title": "Sheet: Manage Xrefs", "destructiveHint": True},
|
annotations={"title": "Sheet: Manage Xrefs", "destructiveHint": True},
|
||||||
tags={"drawing", "block"},
|
tags={"sheet", "drawing", "block"},
|
||||||
)
|
)
|
||||||
async def xref_manage(
|
async def xref_manage(
|
||||||
action: Annotated[
|
action: Annotated[
|
||||||
@@ -9646,7 +9668,7 @@ async def xref_manage(
|
|||||||
@cad_tool(summary="Attach a raster image (PNG/JPG) as an underlay.", cost="mutate")
|
@cad_tool(summary="Attach a raster image (PNG/JPG) as an underlay.", cost="mutate")
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations={"title": "Sheet: Attach Image", "destructiveHint": False},
|
annotations={"title": "Sheet: Attach Image", "destructiveHint": False},
|
||||||
tags={"drawing", "create"},
|
tags={"sheet", "drawing", "create"},
|
||||||
)
|
)
|
||||||
async def image_attach(
|
async def image_attach(
|
||||||
path: Annotated[str, Field(description="Full path of the raster image.")],
|
path: Annotated[str, Field(description="Full path of the raster image.")],
|
||||||
@@ -9720,7 +9742,7 @@ async def data_extract(
|
|||||||
@cad_tool(summary="Write the drawing as a real DWG at a chosen AutoCAD version.", cost="safe")
|
@cad_tool(summary="Write the drawing as a real DWG at a chosen AutoCAD version.", cost="safe")
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations={"title": "Drawing: Export DWG", "destructiveHint": False},
|
annotations={"title": "Drawing: Export DWG", "destructiveHint": False},
|
||||||
tags={"drawing"},
|
tags={"sheet", "drawing"},
|
||||||
)
|
)
|
||||||
async def drawing_export_dwg(
|
async def drawing_export_dwg(
|
||||||
path: Annotated[str, Field(description="Full path of the .dwg to write.")],
|
path: Annotated[str, Field(description="Full path of the .dwg to write.")],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Golden query set for tool discovery: English in, one right tool out.
|
"""Golden query set for tool discovery: English in, one right tool out.
|
||||||
|
|
||||||
Eighty-one cases (66 tuning + 15 holdout) split into a **tuning** set and a
|
Ninety-one cases (76 tuning + 15 holdout) split into a **tuning** set and a
|
||||||
**holdout** set. The split is
|
**holdout** set. The split is
|
||||||
the point of the file. Ranking work is measured against
|
the point of the file. Ranking work is measured against
|
||||||
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
|
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
|
||||||
@@ -174,6 +174,22 @@ TUNING_CASES: tuple[GoldenCase, ...] = (
|
|||||||
"counting",
|
"counting",
|
||||||
risk="read",
|
risk="read",
|
||||||
),
|
),
|
||||||
|
# v1.6 tracks B + G — the mechanical part drawer and the sheet standard.
|
||||||
|
# Written before the merged alias records were measured against them; a
|
||||||
|
# miss here is fixed by adding vocabulary to that tool's record in
|
||||||
|
# discovery/aliases.py, never by moving the expectation. Two Turkish
|
||||||
|
# queries are in deliberately: this shop searches in Turkish, and the
|
||||||
|
# alias corpus is the only place that vocabulary can live.
|
||||||
|
GoldenCase("XATTACH", "xref_attach", "command"),
|
||||||
|
GoldenCase("IMAGEATTACH", "image_attach", "command"),
|
||||||
|
GoldenCase("draw a stepped shaft from a segment list", "mech_part_draw", "paraphrase"),
|
||||||
|
GoldenCase("add a section view of the part", "mech_view_add", "paraphrase"),
|
||||||
|
GoldenCase("kaynak sembolü", "weld_symbol", "synonym"),
|
||||||
|
GoldenCase("yüzey pürüzlülüğü", "surface_texture", "synonym"),
|
||||||
|
GoldenCase("iso 5457 sheet frame", "sheet_frame", "synonym"),
|
||||||
|
GoldenCase("put an m12 hex bolt on the drawing", "std_part_insert", "paraphrase"),
|
||||||
|
GoldenCase("draw the parts list table on the sheet", "bom_table", "paraphrase"),
|
||||||
|
GoldenCase("save this drawing as a dwg", "drawing_export_dwg", "paraphrase"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""Six mechanical focuses, each with a positive and a negative fixture.
|
||||||
|
|
||||||
|
All six are silent on a drawing with no ACADMCP_MECH payload on it, so
|
||||||
|
`focus=None` stays cheap for a P&ID sheet or a plain DXF.
|
||||||
|
|
||||||
|
A fixture writes the payload onto a small CIRCLE on layer 0 rather than onto a
|
||||||
|
block reference. The index reads INSERT and CIRCLE alike (a balloon is a circle
|
||||||
|
with a leader), and a circle keeps the fixtures free of block definitions —
|
||||||
|
what is under test here is the reading, not the drawing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engineering.layers import ensure_engineering_layers
|
||||||
|
from engineering.mech.critique import (
|
||||||
|
MECH_FOCUSES,
|
||||||
|
build_index,
|
||||||
|
has_mech_content,
|
||||||
|
issues_for,
|
||||||
|
)
|
||||||
|
from engineering.mech.xdata import APP_ID, to_values
|
||||||
|
from engineering.plan_spec import ALL_CRITIQUE_FOCUSES
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
SHAFT = {
|
||||||
|
"name": "shaft",
|
||||||
|
"material": "steel",
|
||||||
|
"segments": [{"length": 60.0, "d_outer": 40.0, "d_inner": 20.0}],
|
||||||
|
"features": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _anchor(backend, payload, x=0.0, y=0.0):
|
||||||
|
ent = await backend.entity_create_circle(x, y, 0.5, layer="0")
|
||||||
|
await backend.entity_set_xdata(ent.handle, APP_ID, to_values(payload))
|
||||||
|
return ent.handle
|
||||||
|
|
||||||
|
|
||||||
|
def _focus(index, name):
|
||||||
|
return issues_for(name, index)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_six_focuses_are_the_spec_ones_and_are_in_the_closed_enum():
|
||||||
|
assert MECH_FOCUSES == (
|
||||||
|
"mech_missing_centreline",
|
||||||
|
"mech_unhatched_section",
|
||||||
|
"mech_view_misaligned",
|
||||||
|
"mech_duplicate_dimension",
|
||||||
|
"mech_thread_unrepresented",
|
||||||
|
"mech_bom_balloon_mismatch",
|
||||||
|
)
|
||||||
|
for focus in MECH_FOCUSES:
|
||||||
|
assert focus in ALL_CRITIQUE_FOCUSES
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_focus_is_silent_on_a_drawing_with_no_mechanical_content(backend):
|
||||||
|
"""A circle with no centre mark, two identical dimensions and no hatch —
|
||||||
|
every trigger present, and not one issue, because nothing on the sheet
|
||||||
|
claims to be a mechanical part."""
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
|
||||||
|
await backend.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
|
||||||
|
await backend.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
|
||||||
|
index = await build_index(backend)
|
||||||
|
assert has_mech_content(index) is False
|
||||||
|
for focus in MECH_FOCUSES:
|
||||||
|
assert _focus(index, focus) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_centreline_fires_on_a_bare_circle(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
|
||||||
|
await backend.entity_create_circle(100.0, 100.0, 10.0, layer="GEOMETRY")
|
||||||
|
issues = _focus(await build_index(backend), "mech_missing_centreline")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].focus == "mech_missing_centreline"
|
||||||
|
assert "centre mark" in issues[0].message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_centreline_is_quiet_once_the_cross_is_drawn(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
|
||||||
|
await backend.entity_create_circle(100.0, 100.0, 10.0, layer="GEOMETRY")
|
||||||
|
await backend.entity_create_line(87.0, 100.0, 113.0, 100.0, layer="CENTER")
|
||||||
|
await backend.entity_create_line(100.0, 87.0, 100.0, 113.0, layer="CENTER")
|
||||||
|
assert _focus(await build_index(backend), "mech_missing_centreline") == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unhatched_section_fires_on_a_section_view_with_no_hatch(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(
|
||||||
|
backend,
|
||||||
|
{"v": 1, "kind": "part", "id": "P1", "view": "section", "part": SHAFT},
|
||||||
|
x=0.0,
|
||||||
|
y=0.0,
|
||||||
|
)
|
||||||
|
issues = _focus(await build_index(backend), "mech_unhatched_section")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].severity == "error"
|
||||||
|
assert "hatched cut face" in issues[0].message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unhatched_section_is_quiet_when_the_cut_face_is_hatched(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(
|
||||||
|
backend,
|
||||||
|
{"v": 1, "kind": "part", "id": "P1", "view": "section", "part": SHAFT},
|
||||||
|
x=0.0,
|
||||||
|
y=0.0,
|
||||||
|
)
|
||||||
|
await backend.entity_create_hatch(
|
||||||
|
"ANSI31", [[10.0, 10.0], [20.0, 10.0], [20.0, 20.0], [10.0, 20.0]], layer="HATCH"
|
||||||
|
)
|
||||||
|
assert _focus(await build_index(backend), "mech_unhatched_section") == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_view_misaligned_fires_when_a_side_view_leaves_the_projection_axis(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(
|
||||||
|
backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}, 0.0, 0.0
|
||||||
|
)
|
||||||
|
await _anchor(
|
||||||
|
backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": SHAFT}, 150.0, 5.0
|
||||||
|
)
|
||||||
|
issues = _focus(await build_index(backend), "mech_view_misaligned")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].detail["axis"] == "y"
|
||||||
|
assert issues[0].detail["delta_mm"] == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_view_misaligned_is_quiet_on_an_aligned_pair(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(
|
||||||
|
backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}, 0.0, 0.0
|
||||||
|
)
|
||||||
|
await _anchor(
|
||||||
|
backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": SHAFT}, 150.0, 0.0
|
||||||
|
)
|
||||||
|
assert _focus(await build_index(backend), "mech_view_misaligned") == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_dimension_fires_on_the_same_dimension_twice(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
|
||||||
|
await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
|
||||||
|
await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
|
||||||
|
issues = _focus(await build_index(backend), "mech_duplicate_dimension")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert len(issues[0].handles) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_dimension_is_quiet_on_two_different_dimensions(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
|
||||||
|
await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
|
||||||
|
await backend.dimension_linear(0, 0, 40, 0, 20, -35, layer="DIM")
|
||||||
|
assert _focus(await build_index(backend), "mech_duplicate_dimension") == []
|
||||||
|
|
||||||
|
|
||||||
|
THREADED = {
|
||||||
|
"name": "stud",
|
||||||
|
"material": "steel",
|
||||||
|
"segments": [{"length": 60.0, "d_outer": 20.0}],
|
||||||
|
"features": [{"kind": "thread", "id": "t1", "designation": "M20x1.5", "segment": 0}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_thread_unrepresented_fires_when_only_the_major_diameter_is_drawn(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": THREADED})
|
||||||
|
await backend.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY")
|
||||||
|
issues = _focus(await build_index(backend), "mech_thread_unrepresented")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].detail["minor_mm"] == 16.0
|
||||||
|
assert "ISO 6410" in issues[0].message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_thread_unrepresented_is_quiet_once_the_minor_circle_is_drawn(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": THREADED})
|
||||||
|
await backend.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY")
|
||||||
|
await backend.entity_create_arc(0.0, 0.0, 8.0, 0.0, 270.0, layer="GEOMETRY")
|
||||||
|
assert _focus(await build_index(backend), "mech_thread_unrepresented") == []
|
||||||
|
|
||||||
|
|
||||||
|
BOLT = {
|
||||||
|
"v": 1,
|
||||||
|
"kind": "std_part",
|
||||||
|
"designation": "ISO 4014 - M12x60",
|
||||||
|
"standard": "ISO 4014",
|
||||||
|
"size": "M12x60",
|
||||||
|
"qty": 1,
|
||||||
|
"material": "8.8",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bom_balloon_mismatch_fires_on_a_balloon_pointing_at_nothing(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(backend, BOLT, 0.0, 0.0)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": ["DEAD"]}, 40.0, 40.0)
|
||||||
|
issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch")
|
||||||
|
assert any("not a part on this sheet" in issue.message for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bom_balloon_mismatch_fires_on_a_part_with_no_balloon(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
bolt = await _anchor(backend, BOLT, 0.0, 0.0)
|
||||||
|
await _anchor(
|
||||||
|
backend, {**BOLT, "size": "M16x60", "designation": "ISO 4014 - M16x60"}, 60.0, 0.0
|
||||||
|
)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [bolt]}, 40.0, 40.0)
|
||||||
|
issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch")
|
||||||
|
assert any("has no balloon" in issue.message for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bom_balloon_mismatch_fires_when_the_declared_quantity_disagrees(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
first = await _anchor(backend, {**BOLT, "qty": 4}, 0.0, 0.0)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [first]}, 40.0, 40.0)
|
||||||
|
issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch")
|
||||||
|
assert any("declares 4" in issue.message for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bom_balloon_mismatch_is_quiet_on_a_matched_sheet(backend):
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
bolt = await _anchor(backend, BOLT, 0.0, 0.0)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [bolt]}, 40.0, 40.0)
|
||||||
|
assert _focus(await build_index(backend), "mech_bom_balloon_mismatch") == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_dispatch_entries_share_one_index_per_run(backend):
|
||||||
|
"""Six focuses must cost one read of the drawing, like the P&ID ones."""
|
||||||
|
from engineering.mech.critique import MECH_DISPATCH
|
||||||
|
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
|
||||||
|
shared: dict = {}
|
||||||
|
for focus in MECH_FOCUSES:
|
||||||
|
check = MECH_DISPATCH[focus]
|
||||||
|
assert check.needs_shared is True
|
||||||
|
await check(backend, shared)
|
||||||
|
assert list(shared) == ["mech_index"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── the payloads the real writers put on the drawing ────────────────────────
|
||||||
|
#
|
||||||
|
# The fixtures above anchor a hand-written payload on a CIRCLE. The tools do
|
||||||
|
# not: mech_part_draw anchors its part payload on a POINT (layer MECH) and
|
||||||
|
# lists every view it drew under "views", std_part_insert writes onto the
|
||||||
|
# INSERT, balloon_add onto the balloon's circle. An index that read only the
|
||||||
|
# fixtures' shape would be silent on every drawing the server actually makes.
|
||||||
|
|
||||||
|
REAL_SHAFT = {
|
||||||
|
"kind": "revolved",
|
||||||
|
"name": "stud",
|
||||||
|
"material": "steel",
|
||||||
|
"segments": [
|
||||||
|
{"length": 30.0, "d_outer": 20.0},
|
||||||
|
{"length": 50.0, "d_outer": 30.0, "d_inner": 10.0},
|
||||||
|
],
|
||||||
|
"features": [
|
||||||
|
{"kind": "thread", "id": "t1", "designation": "M20x1.5", "segment": 0, "length": 20.0}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
CROSS_SECTION = {"p1": [50.0, -30.0], "p2": [50.0, 30.0]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _real_part(backend):
|
||||||
|
from engineering.mech.draw import add_view, draw_part
|
||||||
|
from engineering.mech.part import build_part
|
||||||
|
|
||||||
|
drawn = await draw_part(backend, build_part(REAL_SHAFT), views=("front", "side"))
|
||||||
|
await add_view(backend, drawn["part_id"], "section", plane=CROSS_SECTION)
|
||||||
|
return drawn["part_id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_part_drawn_by_the_tools_is_read_and_passes_every_focus(backend):
|
||||||
|
part_id = await _real_part(backend)
|
||||||
|
index = await build_index(backend)
|
||||||
|
assert has_mech_content(index) is True
|
||||||
|
assert [r["handle"] for r in index["records"]] == [part_id]
|
||||||
|
assert [v["kind"] for v in index["views"]] == ["front", "side", "section"]
|
||||||
|
for focus in MECH_FOCUSES:
|
||||||
|
assert _focus(index, focus) == [], focus
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_real_section_whose_hatch_was_erased_is_reported(backend):
|
||||||
|
part_id = await _real_part(backend)
|
||||||
|
for hatch in await backend.entity_list(type_filter="HATCH"):
|
||||||
|
await backend.entity_delete(hatch.handle)
|
||||||
|
issues = _focus(await build_index(backend), "mech_unhatched_section")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert issues[0].handles == [part_id]
|
||||||
|
assert issues[0].detail["view"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_identical_parts_share_one_balloon(backend):
|
||||||
|
"""ISO 6433 balloons a parts-list row: four identical bolts, one item."""
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
first = await _anchor(backend, BOLT, 0.0, 0.0)
|
||||||
|
await _anchor(backend, BOLT, 60.0, 0.0)
|
||||||
|
await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [first]}, 40.0, 40.0)
|
||||||
|
assert _focus(await build_index(backend), "mech_bom_balloon_mismatch") == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_index_reads_past_the_first_page_of_entities(backend):
|
||||||
|
"""entity_list defaults to 200; a payload on entity 251 must still count."""
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
for i in range(250):
|
||||||
|
await backend.entity_create_line(float(i), 0.0, float(i), 5.0, layer="GEOMETRY")
|
||||||
|
await _anchor(backend, BOLT, 0.0, 50.0)
|
||||||
|
assert has_mech_content(await build_index(backend)) is True
|
||||||
|
|
||||||
|
|
||||||
|
class _LiveTypeNames:
|
||||||
|
"""The ezdxf backend reporting entity types the way the live engine does.
|
||||||
|
|
||||||
|
ComBackend names a type from its ActiveX ObjectName with ``AcDb`` stripped
|
||||||
|
and upper-cased; measured on AutoCAD 2026 (2026-09-23), a block reference
|
||||||
|
reads back as BLOCKREFERENCE and a linear dimension as ROTATEDDIMENSION.
|
||||||
|
Only those two renames are applied -- POINT, CIRCLE, LINE and HATCH are
|
||||||
|
already the same on both engines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_RENAME = {"INSERT": "BLOCKREFERENCE", "DIMENSION": "ROTATEDDIMENSION"}
|
||||||
|
|
||||||
|
def __init__(self, inner):
|
||||||
|
self._inner = inner
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._inner, name)
|
||||||
|
|
||||||
|
async def entity_list(self, *args, **kwargs):
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
return [
|
||||||
|
dataclasses.replace(ent, type=self._RENAME.get(ent.type, ent.type))
|
||||||
|
for ent in await self._inner.entity_list(*args, **kwargs)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_live_engine_type_names_are_read(backend):
|
||||||
|
"""A standard part on an INSERT and two stacked dimensions, seen through
|
||||||
|
the live engine's type names: the payload is still read and the duplicate
|
||||||
|
still found."""
|
||||||
|
await ensure_engineering_layers(backend)
|
||||||
|
ref = await backend.entity_create_circle(0.0, 0.0, 1.0, layer="0")
|
||||||
|
await backend.block_create_from_entities("T15_BOLT", [ref.handle], 0.0, 0.0)
|
||||||
|
bolt = await backend.block_insert("T15_BOLT", 0.0, 0.0, 1.0, 1.0, 0.0, None, "GEOMETRY")
|
||||||
|
await backend.entity_set_xdata(bolt.handle, APP_ID, to_values(BOLT))
|
||||||
|
await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
|
||||||
|
await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
|
||||||
|
|
||||||
|
index = await build_index(_LiveTypeNames(backend))
|
||||||
|
assert [r["handle"] for r in index["records"]] == [bolt.handle]
|
||||||
|
assert {e.type for e in index["entities"]} >= {"BLOCKREFERENCE", "ROTATEDDIMENSION"}
|
||||||
|
duplicates = _focus(index, "mech_duplicate_dimension")
|
||||||
|
assert len(duplicates) == 1 and len(duplicates[0].handles) == 2
|
||||||
@@ -8,7 +8,9 @@ surface and nothing else.
|
|||||||
|
|
||||||
v1.6 tracks B+G add the `mech` pack: SECTION 21's six part-model tools and
|
v1.6 tracks B+G add the `mech` pack: SECTION 21's six part-model tools and
|
||||||
SECTION 22's three standard-parts tools and SECTION 23's five annotation
|
SECTION 22's three standard-parts tools and SECTION 23's five annotation
|
||||||
symbols - 14 in all.
|
symbols - 14 in all. SECTION 24 (the sheet: frames, title blocks, revisions,
|
||||||
|
the parts list, balloons, xrefs, images, DWG) stays in `core` - universal
|
||||||
|
drafting, not a vertical - and files under its own `sheet` group.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -99,6 +101,28 @@ MECH_TOOLS = {
|
|||||||
"hatch_material",
|
"hatch_material",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SHEET_TOOLS = {
|
||||||
|
"sheet_frame",
|
||||||
|
"titleblock_apply",
|
||||||
|
"revision_add",
|
||||||
|
"bom_extract",
|
||||||
|
"bom_table",
|
||||||
|
"balloon_add",
|
||||||
|
"data_extract",
|
||||||
|
"xref_attach",
|
||||||
|
"xref_manage",
|
||||||
|
"image_attach",
|
||||||
|
"drawing_export_dwg",
|
||||||
|
}
|
||||||
|
|
||||||
|
LEAN_MECH_ESSENTIALS = {
|
||||||
|
"mech_part_draw",
|
||||||
|
"mech_view_add",
|
||||||
|
"mech_dimension_part",
|
||||||
|
"sheet_frame",
|
||||||
|
"titleblock_apply",
|
||||||
|
}
|
||||||
|
|
||||||
LEAN_SETTINGS_ESSENTIALS = {
|
LEAN_SETTINGS_ESSENTIALS = {
|
||||||
"drawing_apply_standard",
|
"drawing_apply_standard",
|
||||||
"page_setup_apply",
|
"page_setup_apply",
|
||||||
@@ -130,6 +154,9 @@ def test_pack_registry_names_real_tools_and_only_them():
|
|||||||
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
|
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
|
||||||
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"])
|
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"])
|
||||||
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"])
|
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"])
|
||||||
|
assert not (SHEET_TOOLS & set().union(*server.PACK_TOOL_NAMES.values())), (
|
||||||
|
"SECTION 24 is core drafting, not a vertical pack"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_every_pack_member_is_a_registered_tool():
|
async def test_every_pack_member_is_a_registered_tool():
|
||||||
@@ -146,6 +173,7 @@ async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(
|
|||||||
assert PID_TOOLS <= disabled
|
assert PID_TOOLS <= disabled
|
||||||
assert SETTINGS_TOOLS <= disabled
|
assert SETTINGS_TOOLS <= disabled
|
||||||
assert MECH_TOOLS <= disabled
|
assert MECH_TOOLS <= disabled
|
||||||
|
assert not (SHEET_TOOLS & disabled)
|
||||||
assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials"
|
assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials"
|
||||||
assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core"
|
assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core"
|
||||||
assert "block_define" not in disabled and "entity_set_xdata" not in disabled
|
assert "block_define" not in disabled and "entity_set_xdata" not in disabled
|
||||||
@@ -194,16 +222,19 @@ async def test_lean_intersects_with_packs(monkeypatch):
|
|||||||
async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack(
|
async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
):
|
):
|
||||||
"""Spec §8.2: lean = 55. The five are core-pack tools, so `TOOL_PACKS=core`
|
"""Spec §8.2: lean = 60 (55 + the five mechanical/sheet essentials). The
|
||||||
leaves them on a lean surface; no environment tool is lean."""
|
five settings essentials are core-pack tools, so `TOOL_PACKS=core` leaves
|
||||||
|
them on a lean surface; no environment tool is lean."""
|
||||||
assert LEAN_SETTINGS_ESSENTIALS <= server.LEAN_TOOL_NAMES
|
assert LEAN_SETTINGS_ESSENTIALS <= server.LEAN_TOOL_NAMES
|
||||||
|
assert LEAN_MECH_ESSENTIALS <= server.LEAN_TOOL_NAMES
|
||||||
assert not (SETTINGS_TOOLS & server.LEAN_TOOL_NAMES)
|
assert not (SETTINGS_TOOLS & server.LEAN_TOOL_NAMES)
|
||||||
assert len(server.LEAN_TOOL_NAMES) == 55
|
assert len(server.LEAN_TOOL_NAMES) == 60
|
||||||
monkeypatch.setattr(config.settings, "tool_packs", "core")
|
monkeypatch.setattr(config.settings, "tool_packs", "core")
|
||||||
info = await server._apply_tool_profile("lean")
|
info = await server._apply_tool_profile("lean")
|
||||||
disabled = set(info["disabled_tools"])
|
disabled = set(info["disabled_tools"])
|
||||||
assert not (LEAN_SETTINGS_ESSENTIALS & disabled)
|
assert not (LEAN_SETTINGS_ESSENTIALS & disabled)
|
||||||
assert info["enabled_count"] == 55 - 3, "lean minus the three pid tools"
|
assert not ({"sheet_frame", "titleblock_apply"} & disabled), "sheet tools are core"
|
||||||
|
assert info["enabled_count"] == 60 - 6, "lean minus the three pid and three mech tools"
|
||||||
|
|
||||||
|
|
||||||
async def test_the_three_track_e_sections_file_under_their_own_groups():
|
async def test_the_three_track_e_sections_file_under_their_own_groups():
|
||||||
@@ -213,8 +244,9 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
|
|||||||
assert set(groups["styles"]) == STYLE_TOOLS
|
assert set(groups["styles"]) == STYLE_TOOLS
|
||||||
assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
|
assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
|
||||||
assert set(groups["environment"]) == SETTINGS_TOOLS
|
assert set(groups["environment"]) == SETTINGS_TOOLS
|
||||||
assert set(groups["mech"]) == MECH_TOOLS
|
assert set(groups["mechanical"]) == MECH_TOOLS
|
||||||
assert len(groups) == 24
|
assert set(groups["sheet"]) == SHEET_TOOLS
|
||||||
|
assert len(groups) == 25
|
||||||
|
|
||||||
|
|
||||||
async def test_system_about_reports_packs():
|
async def test_system_about_reports_packs():
|
||||||
|
|||||||
@@ -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 (229 tools, 24 groups).
|
"""Frozen snapshot of the surface (229 tools, 25 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
|
||||||
@@ -565,26 +565,43 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
are tagged `engineering` and `mech`; with `mech` ranked ahead of
|
are tagged `engineering` and `mech`; with `mech` ranked ahead of
|
||||||
`engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all,
|
`engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all,
|
||||||
exactly `PACK_TOOL_NAMES["mech"]`) and leave `engineering` untouched.
|
exactly `PACK_TOOL_NAMES["mech"]`) and leave `engineering` untouched.
|
||||||
|
|
||||||
|
Two groups appeared with v1.6's tracks B and G — `mechanical` (14,
|
||||||
|
SECTIONS 21-23: the part drawer, the standard-parts catalogue and the ISO
|
||||||
|
annotation symbols) and `sheet` (11, SECTION 24: frames, title blocks,
|
||||||
|
revisions, the parts list, balloons, xrefs, images and DWG). Both tags are
|
||||||
|
ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so a part tool that
|
||||||
|
also carries `create` and an annotation tool that also carries `dimension`
|
||||||
|
file under their own section. `mechanical` is exactly
|
||||||
|
`PACK_TOOL_NAMES["mech"]` (the `mech` label above was renamed when the
|
||||||
|
integration task gave the section its full name); `sheet` is core-pack,
|
||||||
|
because a frame, a title block, a parts list and an xref are universal
|
||||||
|
drafting. The integration also tagged SECTION 24's last four tools
|
||||||
|
(`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`) with
|
||||||
|
`sheet`, so pack, group and SECTION are one list again: `engineering`
|
||||||
|
returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
|
||||||
|
`drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
|
||||||
"""
|
"""
|
||||||
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 == {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 11,
|
"blocks": 9,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 12,
|
"drawing": 11,
|
||||||
"engineering": 17,
|
"engineering": 10,
|
||||||
"entity_creation": 19,
|
"entity_creation": 18,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"environment": 22,
|
"environment": 22,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 12,
|
||||||
"mech": 14,
|
"mechanical": 14,
|
||||||
"page_setup": 6,
|
"page_setup": 6,
|
||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
|
"sheet": 11,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
"styles": 10,
|
"styles": 10,
|
||||||
"system": 7,
|
"system": 7,
|
||||||
|
|||||||
Reference in New Issue
Block a user