engineering/mech/dimension.py collects what the features asked to have dimensioned, adds the part's own sizes (chain, baseline or ordinate for the axial lengths, a diameter per segment and per bore, the two overall sizes of a plate), and removes the duplicates - ISO 129-1 says each dimension appears once, and a test asserts no measurement is placed twice. Fits and tolerances are resolved through the modules that already own them: engineering.fits for an ISO 286 code and engineering.tolerances for an explicit ISO 129 tolerance, including build_dim_override's sign convention (the lower deviation is passed as the magnitude of the minus side, exactly as server.py::_fit_to_tolerances does). A H7 bore at 40 mm resolves to +0.025/0 - the correctness check the spec names. A fit together with an explicit tolerance, a fit outside the authored ISO 286 range, and a fit on an intent with no measurable nominal are each refused by name rather than guessed. The layout is the part that had to be measured rather than asserted: every intent's text is turned into a rectangle by the same helper the test uses, and an intent is placed on the lowest level whose rectangle is free, with the level pitch clamped to clear the text whatever the caller passed. The test then asserts the placed rectangles are pairwise disjoint - including on a crowded plate and with a deliberately too-small step. That is the same property the repository's dim_overlap critique focus checks on the finished drawing. The plan's layout clamped that pitch to the text HEIGHT on every axis, which is only the stacking direction for the row of dimensions under the view. Beside the view the levels march in x, so the extent they have to clear is the text WIDTH: measured here, three diameter labels are 9.45 mm wide (9.45, 5.6) at a 8.0 mm pitch, so a shaft's three segment diameters - all on the y = 0 centreline, hence all on different levels - overlapped by 1.45 mm. The pitch and the within-level span are now both taken per axis from the same _text_size helper text_rects uses, which makes the disjointness a property of the algorithm rather than of the label lengths that happened to be in the test. Over hole_table_threshold holes the per-hole diameters leave the view and become a table instead, grouped by diameter, thread, counterbore and depth, one tag per group and the quantity on every row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
381 lines
14 KiB
Python
381 lines
14 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from engineering.fits import fit_lookup
|
|
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 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
|
|
|
|
|
|
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 intent_text(intent: DimIntent) -> str:
|
|
"""The text a dimension will carry - the override, or the measurement."""
|
|
if intent.text_override:
|
|
return intent.text_override
|
|
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 _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 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 = bool(intent.tol)
|
|
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": intent.text_override or f"<> {deviation.code}",
|
|
}
|
|
if has_tol:
|
|
tol = dict(intent.tol)
|
|
mode = str(tol.get("mode", "none"))
|
|
upper = tol.get("upper")
|
|
lower = tol.get("lower")
|
|
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 _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 _revolved_diameters(part: RevolvedPart) -> list[DimIntent]:
|
|
intents: list[DimIntent] = []
|
|
for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True):
|
|
mid = (x0 + x1) / 2.0
|
|
radius = segment.d_outer / 2.0
|
|
intents.append(
|
|
DimIntent(kind="diameter", p1=(mid, radius), p2=(mid, -radius), feature="part")
|
|
)
|
|
if segment.d_inner > _EPS:
|
|
bore = segment.d_inner / 2.0
|
|
intents.append(
|
|
DimIntent(kind="diameter", p1=(mid, bore), p2=(mid, -bore), feature="part")
|
|
)
|
|
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,
|
|
) -> tuple[DimIntent, ...]:
|
|
"""Every dimension this view wants: the features' own, plus the part's sizes."""
|
|
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":
|
|
near = part.segments[-1]
|
|
radius = near.d_outer / 2.0
|
|
intents.append(
|
|
DimIntent(kind="diameter", p1=(-radius, 0.0), p2=(radius, 0.0), feature="part")
|
|
)
|
|
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="part")
|
|
)
|
|
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())
|
|
if total_holes > int(hole_table_threshold):
|
|
hole_ids = {hole.id for holes in groups.values() for hole in holes}
|
|
intents = [i for i in intents if i.feature not in hole_ids]
|
|
|
|
return _dedupe(intents)
|
|
|
|
|
|
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)
|