The evidence for tracks B and G, in the shape tracks A and E used.
- benchmarks/tasks_v5.py: 18 tasks, DEFAULT_MATRIX = "v5"; v4, v3 and v2 stay
addressable so an earlier report reproduces exactly rather than being only
described. mech_assembly is roadmap criterion 3 - a flange-coupling sheet
built from the part model and the sheet standard, through the code the
tools call (apply_titleblock, draw_part/add_view/dimension_part,
insert_std_part, extract_records -> draw_bom_table, add_balloon), gated on
two numbers that cannot be negotiated: drawing_critique(focus=None) returns
zero issues and the finalize score is at least 90 (it scores 100, 18/18 on
the v5 matrix). The adapter returns the focuses that fired, so a
regression names itself, and a test drops one balloon to prove the gate
fails and names mech_bom_balloon_mismatch.
- Seven correctness checks (32 -> 39). mech_section_hatch_area is the one
that cannot be faked: a sleeve of length 60, outside diameter 40 and bore
20 cuts two faces totalling 1200 mm^2 exactly, read back out of the drawing
through entity_measure. sheet_frame_iso5457_a3 pins the A3 frame (20,10 to
410,287 inside the 420 x 297 trimmed sheet); mech_iso286_on_dimension pins
40 H7 at +0.025/0 through the layout; std_part_iso4014_m12 pins s = 18 and
k = 7.5 by value; mech_thread_unrepresented_is_caught is the seventh: a
quality gate that never fires is not a gate.
- scripts/smoke_mech_com.py executed every new COM path once on AutoCAD 2026
(25.1s): frame, title block, a sectioned shaft with 12 dimensions, an ISO
4014 block with 1 ACADMCP_MECH chunk, a weld symbol, the parts list read off
the drawing, a linked balloon, an xref attached / listed / detached, a
31687-byte .dwg (AC1032) through ActiveX SaveAs, critique 0 issues, exit 0,
every scratch document closed. It retries RPC_E_CALL_REJECTED (measured:
the first call after Documents.Add is refused) and tracks its documents by
the document list, since SaveAs renames them.
- Defects the evidence found, each fixed with a test that fails on the old
code:
* ezdxf dimension_diameter put the text beyond the SECOND chord point;
ActiveX AddDimDiametric puts it beyond the first (measured live: a d50
from (x,170) to (x,220), leader 10, text at (x,160)). Headlessly every
front-view diameter stood on its own chain dimension. ezdxf now follows
ActiveX.
* draw_part's anchor goes on MECH; ActiveX refuses entity.Layer on a
missing layer (measured -0x7ffdfff7 'Key not found'). draw.py ensures
every target layer before the first entity.
* bom.extract_records filtered on INSERT; ActiveX names it BLOCKREFERENCE
(ObjectName AcDbBlockReference) - the live parts list was empty.
* COM _entity_info reported no dimension text_position: ModelSpace.Item
narrows to IAcadEntity, which has no TextPosition (measured
AttributeError); read through _com_unnarrow (TextPosition measured on
AcDbDiametricDimension, WCS). dim_overlap compared bbox centres and a
diameter collided with its own bore.
* pid graph raised KeyError 'start' mid-critique on a LINE whose point read
AutoCAD refused while busy; such a line is left out.
* the validator read "ISO 4014 - M12x60" as fake dimension text.
- The published A/B report is re-run over the grown suite rather than
re-typed (26/39 -> 39/39, 13 fixed, 0 regressed), check_doc_numbers derives
the task count from the v5 matrix, the token table is remeasured with a
TOOL_PACKS=core,mech row (193 tools / 61,673 tokens), and the README's
collected-tests figure is retaken (3990).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
658 lines
26 KiB
Python
658 lines
26 KiB
Python
"""Dimension intents for a view, laid out so no two texts overlap.
|
|
|
|
Pure: no backend, no async, no DXF. The features declare what they want
|
|
dimensioned; this module adds the part's own sizes, removes the duplicates
|
|
(ISO 129-1: each dimension appears once), resolves ISO 286 fits and ISO 129
|
|
tolerances through the modules that already own them, and then *places* the
|
|
texts.
|
|
|
|
The placement is not a matter of taste. Every intent's text is measured into a
|
|
rectangle by :func:`text_rects`, and an intent is put on the lowest free level
|
|
whose rectangle does not overlap one already placed on that level. Two
|
|
rectangles are disjoint when they are separated along *either* axis, so the
|
|
layout separates them along both in turn: within a level, along the axis the
|
|
level runs in; between levels, along the axis the levels stack in. The second
|
|
one is why the level pitch is clamped to the extent of the text *in the
|
|
stacking direction* - which is the text height for the row of dimensions under
|
|
the view, and the text **width** for the column of diameters beside it. A pitch
|
|
clamped only to the height would let two 9.45 mm wide diameter labels sit
|
|
8 mm apart on the same centreline, overlapping by 1.45 mm.
|
|
|
|
The test asserts pairwise disjointness with the same helper, which makes "no
|
|
overlapping dimension text" measured rather than claimed - and it is the same
|
|
property the repository's `dim_overlap` critique focus checks on the finished
|
|
drawing.
|
|
|
|
Three rules the module enforces rather than assumes:
|
|
|
|
*ISO 286 fits reach the pipeline.* :func:`dimension_intents` takes
|
|
``fits={key: code}`` and stamps the code onto the intent it names, so a bearing
|
|
seat gets its k6 without anyone hand-building a ``DimIntent``. The part's own
|
|
diameters are addressable by segment (``"segment[1]"``, ``"segment[1].bore"``);
|
|
a feature's are addressable by its id. An unknown key is refused by name - a fit
|
|
that lands nowhere is a manufacturing requirement lost in silence.
|
|
|
|
*An explicit tolerance is validated as a whole.* ``DimIntent.tol`` is
|
|
``{"mode", "upper", "lower"}`` and nothing else. ``mode`` is required, because
|
|
``build_dim_override`` reads a missing mode as ``"none"`` and returns an empty
|
|
override: a +-0.05 with no ``mode`` used to resolve, report its own numbers back
|
|
and then draw a dimension carrying no tolerance at all.
|
|
|
|
*ISO 129-1: each measurement appears once.* A closed chain plus the overall
|
|
states the overall twice. :func:`redundant_linear` finds the span that the
|
|
others tile end to end, and :func:`dimension_intents` marks it as an auxiliary
|
|
(reference) dimension - ``(<>)``, drawn in parentheses - rather than emitting a
|
|
fourth plain DIMENSION that contradicts nothing and adds nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from engineering.fits import fit_lookup, parse_fit_code
|
|
from engineering.mech.features import Hole
|
|
from engineering.mech.part import (
|
|
PrismaticPart,
|
|
RevolvedPart,
|
|
outline_bbox,
|
|
part_length,
|
|
segment_bounds,
|
|
)
|
|
from engineering.mech.primitives import DimIntent
|
|
from engineering.tolerances import TOL_MODES, build_dim_override
|
|
|
|
_EPS = 1e-9
|
|
|
|
DIM_STYLES: tuple[str, ...] = ("chain", "baseline", "ordinate")
|
|
|
|
#: Mean glyph width as a fraction of the text height, for measuring a label
|
|
#: into a rectangle. Deliberately generous: over-estimating the width can only
|
|
#: push two dimensions further apart.
|
|
TEXT_WIDTH_FACTOR = 0.7
|
|
|
|
#: Padding added around a measured label, in text heights.
|
|
TEXT_PADDING = 0.6
|
|
|
|
#: How an auxiliary (reference) dimension is written - ISO 129-1 puts a
|
|
#: measurement that is already given elsewhere in parentheses. ``<>`` is the
|
|
#: DIMENSION text placeholder for the measured value, on both engines.
|
|
REFERENCE_FORMAT = "(<>)"
|
|
|
|
#: The only keys ``DimIntent.tol`` accepts.
|
|
TOL_KEYS: frozenset[str] = frozenset({"mode", "upper", "lower"})
|
|
|
|
#: Tolerance modes that write no deviation, so carrying upper/lower with them
|
|
#: would silently discard the numbers.
|
|
_NO_DEVIATION_MODES: frozenset[str] = frozenset({"none", "basic"})
|
|
|
|
|
|
def measured_value(intent: DimIntent) -> float:
|
|
"""What the dimension measures, in drawing units."""
|
|
p1, p2 = intent.p1, intent.p2
|
|
if intent.kind == "linear":
|
|
dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1])
|
|
return dx if dx >= dy else dy
|
|
return math.dist(p1, p2)
|
|
|
|
|
|
def _format(value: float) -> str:
|
|
return f"{round(value, 3):g}"
|
|
|
|
|
|
def _with_fit(text: str, fit: str | None) -> str:
|
|
"""Append an ISO 286 code to a dimension's text, once.
|
|
|
|
A feature that already names itself (a hole's ``⌀6``) keeps its own text and
|
|
gains the code; without this the deviations would be written and the code
|
|
the inspector reads would not be on the sheet.
|
|
"""
|
|
code = (fit or "").strip()
|
|
if not code or code in text:
|
|
return text
|
|
return f"{text} {code}"
|
|
|
|
|
|
def _measured_text(intent: DimIntent) -> str:
|
|
if intent.kind == "diameter":
|
|
return f"⌀{_format(measured_value(intent))}"
|
|
if intent.kind == "radius":
|
|
return f"R{_format(measured_value(intent))}"
|
|
return _format(measured_value(intent))
|
|
|
|
|
|
def intent_text(intent: DimIntent) -> str:
|
|
"""The text a dimension will carry, as the engine will render it.
|
|
|
|
``<>`` is the DIMENSION placeholder for the measured value, so an override
|
|
of ``"(<>)"`` reads ``"(75)"`` and a resolved fit's ``"<> H7"`` reads
|
|
``"⌀40 H7"``. Expanding it here is what makes :func:`text_rects` measure the
|
|
string that is actually drawn - a label measured as ``"<> H7"`` and drawn as
|
|
``"⌀40 H7"`` is a layout computed against the wrong width.
|
|
"""
|
|
measured = _measured_text(intent)
|
|
base = intent.text_override.replace("<>", measured) if intent.text_override else measured
|
|
# The fit code is appended to the text by `resolve_tolerance`; the rectangle
|
|
# has to reserve room for it before the layout runs.
|
|
return _with_fit(base, intent.fit)
|
|
|
|
|
|
def is_reference(intent: DimIntent) -> bool:
|
|
"""True for an auxiliary (reference) dimension - ISO 129-1 parentheses."""
|
|
override = intent.text_override or ""
|
|
return override.startswith("(") and override.endswith(")")
|
|
|
|
|
|
def _text_size(intent: DimIntent, height: float) -> tuple[float, float]:
|
|
"""``(width, height)`` of the rectangle this intent's label occupies."""
|
|
label = intent_text(intent)
|
|
return (
|
|
(len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height,
|
|
height * (1.0 + TEXT_PADDING),
|
|
)
|
|
|
|
|
|
def text_rects(intents, *, height: float = 3.5):
|
|
"""``(x0, y0, x1, y1)`` per placed intent, from its text and its ``text_at``."""
|
|
rects = []
|
|
for intent in intents:
|
|
if intent.text_at is None:
|
|
raise ValueError(
|
|
f"text_rects: intent {intent.feature or intent.kind!r} has no text_at; "
|
|
"run layout_dimensions first."
|
|
)
|
|
width, tall = _text_size(intent, height)
|
|
cx, cy = float(intent.text_at[0]), float(intent.text_at[1])
|
|
rects.append((cx - width / 2.0, cy - tall / 2.0, cx + width / 2.0, cy + tall / 2.0))
|
|
return tuple(rects)
|
|
|
|
|
|
# -- tolerances --------------------------------------------------------------
|
|
|
|
|
|
def _explicit_tol(intent: DimIntent) -> tuple[str, float | None, float | None]:
|
|
"""Validate ``intent.tol`` as a whole and return ``(mode, upper, lower)``.
|
|
|
|
``build_dim_override`` alone is not enough: it defaults a missing mode to
|
|
``"none"`` and answers with an empty override, so ``{"upper": 0.05,
|
|
"lower": 0.05}`` used to resolve, echo its own numbers back and draw a
|
|
dimension with no DIMTOL/DIMTP/DIMTM at all. A tolerance that disappears
|
|
between the spec and the sheet is the silent wrong value this repository
|
|
refuses, so the schema is checked here, by name.
|
|
"""
|
|
who = intent.feature or intent.kind
|
|
tol = intent.tol
|
|
if not isinstance(tol, dict):
|
|
raise ValueError(
|
|
f"{who}.tol: expected a dict with keys {sorted(TOL_KEYS)}, got {type(tol).__name__}."
|
|
)
|
|
unknown = set(tol) - TOL_KEYS
|
|
if unknown:
|
|
raise ValueError(
|
|
f"{who}.tol: unknown key(s) {sorted(unknown)}; a tolerance takes {sorted(TOL_KEYS)}."
|
|
)
|
|
if "mode" not in tol:
|
|
raise ValueError(
|
|
f"{who}.tol: needs an explicit 'mode' (one of {list(TOL_MODES)}). Without it the "
|
|
"deviations are carried in the payload and no tolerance reaches the drawing."
|
|
)
|
|
mode = str(tol["mode"]).strip().lower()
|
|
upper = tol.get("upper")
|
|
lower = tol.get("lower")
|
|
if mode in _NO_DEVIATION_MODES and (upper is not None or lower is not None):
|
|
raise ValueError(
|
|
f"{who}.tol: tol_mode={mode!r} writes no deviation, but upper={upper!r} / "
|
|
f"lower={lower!r} were given. Use 'symmetric', 'deviation' or 'limit', or drop "
|
|
"the values."
|
|
)
|
|
return mode, upper, lower
|
|
|
|
|
|
def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict:
|
|
"""Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments.
|
|
|
|
Mirrors `server.py::_fit_to_tolerances`, including its sign convention:
|
|
``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus
|
|
deviation, so a resolved fit passes ``-deviation.lower_mm``.
|
|
"""
|
|
has_tol = intent.tol is not None
|
|
if intent.fit and has_tol:
|
|
raise ValueError(
|
|
f"{intent.feature or intent.kind}: pass either fit=<ISO 286 code> or an explicit "
|
|
"tol, not both."
|
|
)
|
|
if intent.fit:
|
|
size = float(nominal) if nominal is not None else measured_value(intent)
|
|
if size <= _EPS:
|
|
raise ValueError(
|
|
f"{intent.feature or intent.kind}: fit {intent.fit!r} needs a nominal size, and "
|
|
f"the intent measures {size}. Pass nominal= explicitly."
|
|
)
|
|
deviation = fit_lookup(intent.fit, size)
|
|
return {
|
|
"tol_upper": deviation.upper_mm,
|
|
"tol_lower": -deviation.lower_mm,
|
|
"tol_mode": "deviation",
|
|
"text_override": _with_fit(intent.text_override or "<>", deviation.code),
|
|
}
|
|
if has_tol:
|
|
mode, upper, lower = _explicit_tol(intent)
|
|
build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises
|
|
return {
|
|
"tol_upper": upper,
|
|
"tol_lower": lower,
|
|
"tol_mode": mode,
|
|
"text_override": intent.text_override,
|
|
}
|
|
return {
|
|
"tol_upper": None,
|
|
"tol_lower": None,
|
|
"tol_mode": "none",
|
|
"text_override": intent.text_override,
|
|
}
|
|
|
|
|
|
# -- collecting the intents --------------------------------------------------
|
|
|
|
|
|
def _dedupe(intents) -> tuple[DimIntent, ...]:
|
|
seen: set[tuple] = set()
|
|
out: list[DimIntent] = []
|
|
for intent in intents:
|
|
key = (
|
|
intent.kind,
|
|
round(float(intent.p1[0]), 6),
|
|
round(float(intent.p1[1]), 6),
|
|
round(float(intent.p2[0]), 6),
|
|
round(float(intent.p2[1]), 6),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(intent)
|
|
return tuple(out)
|
|
|
|
|
|
def _linear_span(intent: DimIntent) -> tuple[str, float, float, float] | None:
|
|
"""``(axis, level, lo, hi)`` of an axis-parallel linear dimension, else None.
|
|
|
|
``level`` is the coordinate the dimension does *not* run along, so two
|
|
horizontal dimensions at different heights are never compared with each
|
|
other. A skewed linear intent has no span: it is not part of a chain.
|
|
"""
|
|
if intent.kind != "linear":
|
|
return None
|
|
(ax, ay), (bx, by) = intent.p1, intent.p2
|
|
if abs(bx - ax) >= abs(by - ay):
|
|
if abs(by - ay) > _EPS:
|
|
return None
|
|
return ("x", round(float(ay), 6), round(min(ax, bx), 6), round(max(ax, bx), 6))
|
|
if abs(bx - ax) > _EPS:
|
|
return None
|
|
return ("y", round(float(ax), 6), round(min(ay, by), 6), round(max(ay, by), 6))
|
|
|
|
|
|
def redundant_linear(intents) -> tuple[int, ...]:
|
|
"""Indices of the linear intents that shorter ones already tile end to end.
|
|
|
|
ISO 129-1: a measurement appears once. A closed chain (20 + 40 + 15) that
|
|
also carries the overall (75) states the overall twice, and the second
|
|
statement has to be an auxiliary (reference) dimension or be left off.
|
|
|
|
The error is one-sided on purpose. A tiling that is found is a real one -
|
|
the pieces butt, in order, from ``lo`` to ``hi`` on the same axis and level
|
|
- so a dimension is never called redundant when it is not. A tiling that
|
|
the greedy walk misses simply leaves the dimension plain, which is the way
|
|
round the repository's critique rule wants: a check that misses a bad
|
|
drawing costs a review, a check that calls a good drawing bad gets
|
|
switched off.
|
|
"""
|
|
groups: dict[tuple[str, float], list[tuple[float, float, int]]] = {}
|
|
for index, intent in enumerate(intents):
|
|
span = _linear_span(intent)
|
|
if span is None:
|
|
continue
|
|
axis, level, lo, hi = span
|
|
groups.setdefault((axis, level), []).append((lo, hi, index))
|
|
|
|
out: list[int] = []
|
|
for group in groups.values():
|
|
for lo, hi, index in group:
|
|
pieces = sorted(
|
|
(a, b)
|
|
for a, b, other in group
|
|
if other != index
|
|
and a >= lo - 1e-6
|
|
and b <= hi + 1e-6
|
|
and (b - a) < (hi - lo) - 1e-6
|
|
)
|
|
cursor = lo
|
|
for a, b in pieces:
|
|
if abs(a - cursor) <= 1e-6:
|
|
cursor = b
|
|
if cursor > lo + 1e-6 and abs(cursor - hi) <= 1e-6:
|
|
out.append(index)
|
|
return tuple(sorted(out))
|
|
|
|
|
|
def _mark_references(intents: list[DimIntent]) -> list[DimIntent]:
|
|
"""Put the redundant measurements in parentheses, ISO 129-1."""
|
|
for index in redundant_linear(intents):
|
|
intent = intents[index]
|
|
if intent.text_override is None:
|
|
intents[index] = intent._replace(text_override=REFERENCE_FORMAT)
|
|
return intents
|
|
|
|
|
|
def _apply_fits(intents: list[DimIntent], fits, *, table_ids: frozenset[str]) -> list[DimIntent]:
|
|
"""Stamp each ``{key: ISO 286 code}`` onto the intent its key names.
|
|
|
|
Without this the ``fit`` branch of :func:`resolve_tolerance` is reachable
|
|
only from a hand-built ``DimIntent``: no feature and no segment carries a
|
|
fit, so the ISO 286 path would be real in the table and absent from every
|
|
drawing the module actually produces.
|
|
"""
|
|
if not fits:
|
|
return intents
|
|
if not isinstance(fits, dict):
|
|
raise ValueError(
|
|
f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}."
|
|
)
|
|
|
|
available = {intent.feature for intent in intents if intent.feature}
|
|
by_key: dict[str, list[int]] = {}
|
|
for index, intent in enumerate(intents):
|
|
if intent.feature:
|
|
by_key.setdefault(intent.feature, []).append(index)
|
|
|
|
for key, code in fits.items():
|
|
name = str(key)
|
|
if name not in available:
|
|
if name in table_ids:
|
|
raise ValueError(
|
|
f"fits[{name!r}]: that hole's diameter moved into the hole table, which has "
|
|
"no tolerance column. Raise hole_table_threshold, or dimension the hole "
|
|
"directly."
|
|
)
|
|
raise ValueError(
|
|
f"fits[{name!r}]: nothing in this view is dimensioned under that name. "
|
|
f"Available: {sorted(available)}."
|
|
)
|
|
parse_fit_code(str(code)) # refuses a typo here, not at draw time
|
|
for index in by_key[name]:
|
|
intent = intents[index]
|
|
if is_reference(intent):
|
|
raise ValueError(
|
|
f"fits[{name!r}]: {intent_text(intent)} is an auxiliary (reference) "
|
|
"dimension - ISO 129-1 gives it no tolerance. Put the fit on the "
|
|
"dimension that states the size."
|
|
)
|
|
if intent.tol is not None:
|
|
raise ValueError(
|
|
f"fits[{name!r}]: that dimension already carries an explicit tol. Pass "
|
|
"either fit=<ISO 286 code> or an explicit tol, not both."
|
|
)
|
|
intents[index] = intent._replace(fit=str(code))
|
|
return intents
|
|
|
|
|
|
def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]:
|
|
stations = [0.0] + [x1 for _, x1 in segment_bounds(part)]
|
|
intents: list[DimIntent] = []
|
|
if style == "chain":
|
|
for a, b in zip(stations, stations[1:], strict=False):
|
|
intents.append(DimIntent(kind="linear", p1=(a, 0.0), p2=(b, 0.0), feature="part"))
|
|
intents.append(
|
|
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(part_length(part), 0.0), feature="part")
|
|
)
|
|
else: # baseline and ordinate both measure from the left face
|
|
for station in stations[1:]:
|
|
intents.append(
|
|
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(station, 0.0), feature="part")
|
|
)
|
|
return intents
|
|
|
|
|
|
def segment_key(index: int, *, bore: bool = False) -> str:
|
|
"""The ``fits`` key that names one segment's outer diameter, or its bore.
|
|
|
|
The part's own diameters have to be addressable one at a time: "ISO 286
|
|
fits on the bearing seats" means a code on *segment 1*, not on every
|
|
dimension the part emits.
|
|
"""
|
|
return f"segment[{int(index)}].bore" if bore else f"segment[{int(index)}]"
|
|
|
|
|
|
def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]:
|
|
"""Each segment's outside diameter and bore, measured across the axis.
|
|
|
|
``p1`` is the upper chord end: a diameter's text stands beyond ``p1`` (the
|
|
ActiveX ``AddDimDiametric`` ChordPoint rule, which both engines follow), so
|
|
every front-view diameter is annotated above the view, clear of the axial
|
|
chain rows below it.
|
|
"""
|
|
intents: list[DimIntent] = []
|
|
bounds = zip(segment_bounds(part), part.segments, strict=True)
|
|
for index, ((x0, x1), segment) in enumerate(bounds):
|
|
mid = (x0 + x1) / 2.0
|
|
radius = segment.d_outer / 2.0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="diameter",
|
|
p1=(mid, radius),
|
|
p2=(mid, -radius),
|
|
feature=segment_key(index),
|
|
)
|
|
)
|
|
if segment.d_inner > _EPS:
|
|
bore = segment.d_inner / 2.0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="diameter",
|
|
p1=(mid, bore),
|
|
p2=(mid, -bore),
|
|
feature=segment_key(index, bore=True),
|
|
)
|
|
)
|
|
return intents
|
|
|
|
|
|
def _hole_groups(part) -> dict[tuple, list[Hole]]:
|
|
groups: dict[tuple, list[Hole]] = {}
|
|
for feature in getattr(part, "features", ()):
|
|
if not isinstance(feature, Hole):
|
|
continue
|
|
key = (
|
|
round(float(feature.diameter), 6),
|
|
feature.thread or "",
|
|
None if feature.cbore_d is None else round(float(feature.cbore_d), 6),
|
|
None if feature.depth is None else round(float(feature.depth), 6),
|
|
)
|
|
groups.setdefault(key, []).append(feature)
|
|
return groups
|
|
|
|
|
|
def dimension_intents(
|
|
part,
|
|
view,
|
|
*,
|
|
style: str = "chain",
|
|
hole_table_threshold: int = 8,
|
|
fits: dict | None = None,
|
|
) -> tuple[DimIntent, ...]:
|
|
"""Every dimension this view wants: the features' own, plus the part's sizes.
|
|
|
|
``fits`` maps a dimension's name to an ISO 286 code - a feature id, or one
|
|
of the part's own diameters by :func:`segment_key` (``"segment[1]"``,
|
|
``"segment[1].bore"``). The code is stamped onto the matching intent, so
|
|
:func:`resolve_tolerance` resolves it and :func:`layout_dimensions` reserves
|
|
room for the widened text. A key that names nothing is refused with the
|
|
names that exist.
|
|
|
|
ISO 129-1 is applied last: a measurement the other dimensions already tile
|
|
end to end is marked as an auxiliary (reference) dimension, in parentheses,
|
|
instead of being drawn a second time as a plain DIMENSION.
|
|
"""
|
|
name = str(style or "").strip().lower()
|
|
if name not in DIM_STYLES:
|
|
raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.")
|
|
|
|
intents: list[DimIntent] = list(view.dims)
|
|
kind = "front" if view.kind == "section" else view.kind
|
|
|
|
if isinstance(part, RevolvedPart):
|
|
if kind == "front":
|
|
intents.extend(_revolved_axial(part, name))
|
|
intents.extend(_revolved_diameters(part))
|
|
elif kind == "side":
|
|
last = len(part.segments) - 1
|
|
near = part.segments[last]
|
|
radius = near.d_outer / 2.0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="diameter",
|
|
p1=(-radius, 0.0),
|
|
p2=(radius, 0.0),
|
|
feature=segment_key(last),
|
|
)
|
|
)
|
|
if near.d_inner > _EPS:
|
|
bore = near.d_inner / 2.0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="diameter",
|
|
p1=(-bore, 0.0),
|
|
p2=(bore, 0.0),
|
|
feature=segment_key(last, bore=True),
|
|
)
|
|
)
|
|
elif isinstance(part, PrismaticPart):
|
|
x0, y0, x1, y1 = outline_bbox(part)
|
|
if kind == "front":
|
|
intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x1, y0), feature="part"))
|
|
intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x0, y1), feature="part"))
|
|
else:
|
|
# An edge view sees the thickness, measured at the near edge of
|
|
# whichever outline axis this view runs along.
|
|
u0 = y0 if kind == "side" else x0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="linear",
|
|
p1=(u0, 0.0),
|
|
p2=(u0, float(part.thickness)),
|
|
feature="part",
|
|
)
|
|
)
|
|
|
|
groups = _hole_groups(part)
|
|
total_holes = sum(len(holes) for holes in groups.values())
|
|
table_ids: frozenset[str] = frozenset()
|
|
if total_holes > int(hole_table_threshold):
|
|
table_ids = frozenset(hole.id for holes in groups.values() for hole in holes)
|
|
intents = [i for i in intents if i.feature not in table_ids]
|
|
|
|
collected = _mark_references(list(_dedupe(intents)))
|
|
return tuple(_apply_fits(collected, fits, table_ids=table_ids))
|
|
|
|
|
|
def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]:
|
|
"""One row per hole, tagged and counted by group - empty under the threshold.
|
|
|
|
``view`` is part of the pinned signature and is accepted unused, so a later
|
|
view-specific filter (a hole that does not appear in this view) has one
|
|
place to go.
|
|
"""
|
|
del view
|
|
groups = _hole_groups(part)
|
|
total = sum(len(holes) for holes in groups.values())
|
|
if total <= int(hole_table_threshold):
|
|
return ()
|
|
rows: list[dict] = []
|
|
for index, key in enumerate(sorted(groups, key=lambda k: (k[0], k[1]))):
|
|
tag = chr(ord("A") + index)
|
|
holes = groups[key]
|
|
diameter, thread, cbore, depth = key
|
|
note_parts = []
|
|
if thread:
|
|
note_parts.append(str(thread))
|
|
if cbore is not None:
|
|
note_parts.append(f"c'bore ⌀{cbore:g}")
|
|
if depth is not None:
|
|
note_parts.append(f"deep {depth:g}")
|
|
note = " ".join(note_parts)
|
|
for hole in holes:
|
|
rows.append(
|
|
{
|
|
"tag": tag,
|
|
"x": float(hole.x),
|
|
"y": float(hole.y),
|
|
"d": float(diameter),
|
|
"qty": len(holes),
|
|
"note": note,
|
|
}
|
|
)
|
|
return tuple(rows)
|
|
|
|
|
|
# -- the layout --------------------------------------------------------------
|
|
|
|
|
|
def _axis_of(intent: DimIntent) -> str:
|
|
""" "bottom" (u = x, level moves -y), "left" (u = y, level moves -x) or "right"."""
|
|
if intent.kind in ("diameter", "radius", "angular"):
|
|
return "right"
|
|
dx = abs(intent.p2[0] - intent.p1[0])
|
|
dy = abs(intent.p2[1] - intent.p1[1])
|
|
return "bottom" if dx >= dy else "left"
|
|
|
|
|
|
def layout_dimensions(
|
|
view, intents, *, offset: float = 10.0, step: float = 8.0, height: float = 3.5
|
|
) -> tuple[DimIntent, ...]:
|
|
"""Give every intent a ``text_at`` such that no two text rectangles overlap."""
|
|
x0, y0, x1, y1 = view.bbox
|
|
del y1
|
|
|
|
# Measure first: a level's pitch has to clear the widest text that stacks
|
|
# across it, and that is the text WIDTH on the left/right axes, where the
|
|
# levels march in x, not the text height.
|
|
measured: list[tuple[DimIntent, str, float, float]] = []
|
|
across: dict[str, float] = {}
|
|
for intent in intents:
|
|
axis = _axis_of(intent)
|
|
width, tall = _text_size(intent, height)
|
|
# ``along`` is the extent parallel to the level, ``across`` the extent
|
|
# perpendicular to it - the one successive levels have to clear.
|
|
along, over = (width, tall) if axis == "bottom" else (tall, width)
|
|
measured.append((intent, axis, along, over))
|
|
across[axis] = max(across.get(axis, 0.0), over)
|
|
pitch = {axis: max(float(step), value + 1.0) for axis, value in across.items()}
|
|
|
|
occupied: dict[tuple[str, int], list[tuple[float, float]]] = {}
|
|
placed: list[DimIntent] = []
|
|
|
|
for intent, axis, along, _over in measured:
|
|
if axis == "bottom":
|
|
center = (float(intent.p1[0]) + float(intent.p2[0])) / 2.0
|
|
else:
|
|
center = (float(intent.p1[1]) + float(intent.p2[1])) / 2.0
|
|
span = (center - along / 2.0, center + along / 2.0)
|
|
|
|
level = 0
|
|
while True:
|
|
taken = occupied.setdefault((axis, level), [])
|
|
if all(span[1] <= a + 1e-9 or span[0] >= b - 1e-9 for a, b in taken):
|
|
taken.append(span)
|
|
break
|
|
level += 1
|
|
|
|
distance = float(offset) + level * pitch[axis]
|
|
if axis == "bottom":
|
|
text_at = (center, y0 - distance)
|
|
elif axis == "left":
|
|
text_at = (x0 - distance, center)
|
|
else:
|
|
text_at = (x1 + distance, center)
|
|
placed.append(intent._replace(text_at=text_at))
|
|
|
|
return tuple(placed)
|