engineering/mech/views.py grows the two remaining view kinds. A section's cut
faces come out of the same radial profile the silhouette does, so the hatched
area and the drawn outline are one calculation and cannot disagree: a solid
shaft's half-section loop measures exactly the sum of its segment rectangles,
and a bore closes the loop on itself rather than being hatched over.
ISO 6410's section convention falls out of the model rather than being special-
cased: an internal thread's radial_edit already raises the bore to the major
diameter, so the hatch stops there. ISO 128-3's rib rule is a flag on the
feature - a pocket marked no_section_hatch is drawn and left unhatched, while
the rest of the cut face is hatched with the part's ISO 128-50 material.
Four styles, each either implemented or refused by name with the alternative
rather than approximated: full and half on a longitudinal plane through a
revolved axis; full and revolved on a transverse plane (the annulus, flattened
at 2 degrees - 0.015% of the radius, stated in CIRCLE_SEGMENTS rather than
hidden); full and offset on a prismatic part, where through features split the
cut face and pockets reduce it to the remaining thickness. A plane that misses
the part, or that grazes an outline vertex so its crossings cannot be paired, is
refused instead of producing a guessed cut face.
Details scale a circular region of a parent view, emit the ISO 128-34 circle and
letter for the parent and the "A (5:1)" label for the detail itself, and report
every feature that falls outside the circle in omitted - the honesty rule again.
Three test assertions were corrected to the measured truth rather than the
engine bent to them, each verified by running the code:
* the ISO 6410 test asked for the whole cut-face loop's minimum to be the M20
major (r=10). It is not, and must not be: over the 5 mm that is drilled but
not threaded the loop closes on the real 16 mm drill (r=8). The loop is
((0,10),(0,25),(25,25),(30,25),(30,8),(30,8),(25,8),(25,10),(0,10)). The test
now pins both spans, which is the convention it meant to assert.
* the transverse-annulus areas were pinned at rel=2e-4. An inscribed regular
180-gon is exactly 1 - (n/2pi) sin(2pi/n) = 2.0307e-4 under the circle, so
2e-4 rejects the very flattening CIRCLE_SEGMENTS declares. The tolerance is
now 2.1e-4 and the test computes the deficit and pins it, so the number is
derived rather than guessed. CIRCLE_SEGMENTS' docstring now states both
figures: 0.015% radial (the 2 degree chord's sagitta) and 0.0203% of area.
* tests/test_mech_views.py pinned VIEW_KINDS == ("front","side","top"), which
this task deliberately grows. It now pins the three orthographic kinds as the
first three and the full five-kind tuple.
_is_longitudinal takes only the plane; the plan's signature also took the part
and never read it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
1090 lines
42 KiB
Python
1090 lines
42 KiB
Python
"""The view engine: a part model projected onto a plane, as primitive descriptions.
|
|
|
|
Pure: no backend, no async, no DXF. A ``View`` carries the primitives, the
|
|
dimension intents its features asked for, the features it could **not** project
|
|
(the honesty rule of spec section 6 - nothing is silently dropped), and its own
|
|
bounding box, so the next view can be laid out against it.
|
|
|
|
The one calculation everything comes out of for a revolved part is the **radial
|
|
profile**: the part cut into bands of constant (or linearly tapering) outer
|
|
radius and constant bore radius, with every feature's ``radial_edit`` applied.
|
|
The silhouette is that profile walked as a path; the section cut face (Task 5)
|
|
is the same profile walked as a closed loop. One calculation, so the outline
|
|
and the hatched area can never disagree.
|
|
|
|
Line roles follow ISO 128 and are mapped to layers by
|
|
``engineering/mech/primitives.py``: the silhouette is ``visible``, a bore or a
|
|
feature behind material is ``hidden``, the axis and centre marks are
|
|
``center``, a bend line is ``phantom``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import Any, NamedTuple
|
|
|
|
from engineering.mech.features import Pocket as _PocketType
|
|
from engineering.mech.features import mirror_about_x
|
|
from engineering.mech.part import (
|
|
PrismaticPart,
|
|
RevolvedPart,
|
|
inner_radius_at,
|
|
outer_radius_at,
|
|
outline_bbox,
|
|
part_length,
|
|
part_max_diameter,
|
|
segment_bounds,
|
|
)
|
|
from engineering.mech.primitives import (
|
|
Arc,
|
|
Circle,
|
|
DimIntent,
|
|
HatchArea,
|
|
Line,
|
|
Poly,
|
|
Prim,
|
|
Pt,
|
|
Text,
|
|
bbox,
|
|
translate,
|
|
)
|
|
from engineering.mech.primitives import scale as scale_prims
|
|
|
|
_EPS = 1e-9
|
|
_SNAP = 6 # decimals used to match a corner vertex to a corner edit
|
|
#: Radii closer together than this are the same circle, in millimetres. Looser
|
|
#: than ``_EPS`` on purpose: it compares *measured* radii, not exact vertices.
|
|
_TOL = 1e-6
|
|
|
|
VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail")
|
|
PROJECTIONS: tuple[str, ...] = ("first", "third")
|
|
SIDES: tuple[str, ...] = ("right", "left")
|
|
|
|
#: How far the axis and the centre lines run past the material, as a fraction
|
|
#: of the part's largest dimension (ISO 128-23 asks for a short, even overrun).
|
|
AXIS_OVERRUN = 0.10
|
|
|
|
#: The cutting-plane record `section_line` (Task 11, group A) produces and this
|
|
#: engine consumes. `via` is optional and only meaningful for an offset plane.
|
|
SECTION_PLANE = {
|
|
"p1": (0.0, 0.0),
|
|
"p2": (0.0, 0.0),
|
|
"label": "A",
|
|
"direction": (0.0, -1.0),
|
|
"style": "full",
|
|
}
|
|
|
|
SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved")
|
|
|
|
#: A circular cut-face boundary has to be handed to the hatch as points. 180
|
|
#: segments is a 2 degree chord: the boundary is never further than
|
|
#: r * (1 - cos(1 deg)) = 0.015% of the radius from the true circle, and the
|
|
#: area of the inscribed 180-gon is 0.0203% under the circle's. Both figures are
|
|
#: stated rather than hidden, and the same loop is what the hatch and any area
|
|
#: check read, so they cannot disagree with each other.
|
|
CIRCLE_SEGMENTS = 180
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class View:
|
|
kind: str
|
|
prims: tuple[Prim, ...]
|
|
dims: tuple[DimIntent, ...]
|
|
omitted: tuple[dict, ...]
|
|
bbox: tuple[float, float, float, float]
|
|
label: str | None = None
|
|
scale: float = 1.0
|
|
|
|
|
|
class Band(NamedTuple):
|
|
"""One axial slice of a revolved part with a constant bore and a linear outside."""
|
|
|
|
x0: float
|
|
x1: float
|
|
r_in: float
|
|
r_out0: float
|
|
r_out1: float
|
|
|
|
|
|
# -- the radial profile ------------------------------------------------------
|
|
|
|
|
|
def _band_radii(part: RevolvedPart, a: float, b: float) -> tuple[float, float, float]:
|
|
"""``(r_out0, r_out1, r_in)`` of one band, read off the segment it lies in.
|
|
|
|
The segment is resolved from the band's **midpoint** - a band never spans a
|
|
segment boundary, because every boundary is one of the cuts - and a taper is
|
|
then evaluated at the band's own two ends. Sampling a hair inside the band
|
|
instead (the obvious way to dodge the boundary ambiguity) writes the taper
|
|
slope times that inset into every coordinate the silhouette is built from:
|
|
a 30 -> 10 mm cone over 20 mm reports 14.99999 for a radius that is exactly
|
|
15, and nothing downstream can tell that it is wrong.
|
|
"""
|
|
mid = (a + b) / 2.0
|
|
found: tuple[float, float, Any] | None = None
|
|
for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True):
|
|
if x0 - _EPS <= mid <= x1 + _EPS:
|
|
found = (x0, x1, segment)
|
|
break
|
|
if found is None: # pragma: no cover - the cuts always include every segment bound
|
|
raise ValueError(f"radial_profile: x={mid} is outside the part.")
|
|
x0, x1, segment = found
|
|
r_in = segment.d_inner / 2.0
|
|
if segment.taper_to is None:
|
|
radius = segment.d_outer / 2.0
|
|
return radius, radius, r_in
|
|
span = x1 - x0
|
|
|
|
def taper_at(x: float) -> float:
|
|
t = 0.0 if span <= _EPS else (x - x0) / span
|
|
return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0
|
|
|
|
return taper_at(a), taper_at(b), r_in
|
|
|
|
|
|
def radial_profile(part: RevolvedPart) -> tuple[Band, ...]:
|
|
"""The part as bands, with every feature's ``radial_edit`` applied."""
|
|
length = part_length(part)
|
|
cuts: set[float] = {0.0, length}
|
|
for x0, x1 in segment_bounds(part):
|
|
cuts.add(x0)
|
|
cuts.add(x1)
|
|
|
|
edits: list[dict] = []
|
|
for feature in part.features:
|
|
for edit in feature.radial_edit(part):
|
|
x0 = max(0.0, min(length, float(edit["x0"])))
|
|
x1 = max(0.0, min(length, float(edit["x1"])))
|
|
if x1 - x0 <= _EPS:
|
|
continue
|
|
edits.append(
|
|
{"kind": edit["kind"], "x0": x0, "x1": x1, "radius": float(edit["radius"])}
|
|
)
|
|
cuts.add(x0)
|
|
cuts.add(x1)
|
|
|
|
xs = sorted(cuts)
|
|
bands: list[Band] = []
|
|
for a, b in zip(xs, xs[1:], strict=False):
|
|
if b - a <= _EPS:
|
|
continue
|
|
r_out0, r_out1, r_in = _band_radii(part, a, b)
|
|
for edit in edits:
|
|
if edit["x0"] - _EPS <= a and b <= edit["x1"] + _EPS:
|
|
if edit["kind"] == "outer":
|
|
r_out0 = min(r_out0, edit["radius"])
|
|
r_out1 = min(r_out1, edit["radius"])
|
|
else:
|
|
r_in = max(r_in, edit["radius"])
|
|
bands.append(Band(a, b, r_in, r_out0, r_out1))
|
|
return tuple(bands)
|
|
|
|
|
|
def _outer_path(bands: tuple[Band, ...]) -> list[Pt]:
|
|
"""The +Y half-outline, from the left face at the bore, over the outside, down
|
|
to the right face at the bore. The end faces are *part of the path*, so a
|
|
chamfer or fillet at an end corner substitutes into them instead of being
|
|
drawn across them - which is what stops the overshoot the `untrimmed_corner`
|
|
critique focus looks for."""
|
|
points: list[Pt] = [(bands[0].x0, bands[0].r_in), (bands[0].x0, bands[0].r_out0)]
|
|
for band in bands:
|
|
if math.dist(points[-1], (band.x0, band.r_out0)) > _EPS:
|
|
points.append((band.x0, band.r_out0))
|
|
points.append((band.x1, band.r_out1))
|
|
points.append((bands[-1].x1, bands[-1].r_in))
|
|
return points
|
|
|
|
|
|
def _bore_path(bands: tuple[Band, ...]) -> list[list[Pt]]:
|
|
"""One path per contiguous run of bored bands; a blind end is capped."""
|
|
runs: list[list[Pt]] = []
|
|
current: list[Pt] = []
|
|
for band in bands:
|
|
if band.r_in <= _EPS:
|
|
if current:
|
|
runs.append(current)
|
|
current = []
|
|
continue
|
|
if not current:
|
|
current = [(band.x0, band.r_in)]
|
|
elif math.dist(current[-1], (band.x0, band.r_in)) > _EPS:
|
|
current.append((band.x0, band.r_in))
|
|
current.append((band.x1, band.r_in))
|
|
if current:
|
|
runs.append(current)
|
|
return runs
|
|
|
|
|
|
def _key(point: Pt) -> tuple[float, float]:
|
|
return (round(float(point[0]), _SNAP), round(float(point[1]), _SNAP))
|
|
|
|
|
|
def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool = False):
|
|
"""Walk a vertex path, substituting corner edits, and emit Lines and Arcs.
|
|
|
|
Returns ``(prims, used)`` - ``used`` is the set of edit ids that actually
|
|
found their corner, so the caller can report the ones that did not.
|
|
"""
|
|
by_corner: dict[tuple[float, float], dict] = {}
|
|
for edit in edits:
|
|
by_corner[_key(edit["corner"])] = edit
|
|
|
|
sequence: list[tuple[str, Any]] = []
|
|
used: set[str] = set()
|
|
for point in points:
|
|
edit = by_corner.get(_key(point))
|
|
if edit is None:
|
|
sequence.append(("pt", (float(point[0]), float(point[1]))))
|
|
continue
|
|
used.add(edit["owner"])
|
|
sequence.append(("pt", edit["before"]))
|
|
if edit["arc"] is not None:
|
|
sequence.append(("arc", edit["arc"]))
|
|
sequence.append(("pt", edit["after"]))
|
|
|
|
if closed and sequence:
|
|
sequence.append(sequence[0])
|
|
|
|
prims: list[Prim] = []
|
|
previous: Pt | None = None
|
|
for tag, item in sequence:
|
|
if tag == "pt":
|
|
if previous is not None and math.dist(previous, item) > _EPS:
|
|
prims.append(Line(previous, item, role))
|
|
previous = item
|
|
else:
|
|
prims.append(item)
|
|
previous = None
|
|
return tuple(prims), used
|
|
|
|
|
|
def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]:
|
|
"""The mirrored half-outline (end faces included), the bore, and any unplaced edit."""
|
|
bands = radial_profile(part)
|
|
edits: list[dict] = []
|
|
for feature in part.features:
|
|
edit = feature.corner_edit(part)
|
|
if edit is not None:
|
|
edits.append({**edit, "owner": feature.id})
|
|
|
|
upper, used = _emit_path(_outer_path(bands), edits, "visible")
|
|
prims: list[Prim] = list(upper) + list(mirror_about_x(upper))
|
|
|
|
for run in _bore_path(bands):
|
|
bore, _ = _emit_path(run, [], "hidden")
|
|
prims.extend(bore)
|
|
prims.extend(mirror_about_x(bore))
|
|
|
|
unplaced = tuple(
|
|
{
|
|
"feature": edit["owner"],
|
|
"reason": (
|
|
"its silhouette corner was changed by another feature, so the "
|
|
"chamfer/fillet could not be placed"
|
|
),
|
|
}
|
|
for edit in edits
|
|
if edit["owner"] not in used
|
|
)
|
|
return tuple(prims), unplaced
|
|
|
|
|
|
def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]:
|
|
"""The closed outline with every corner fillet/chamfer substituted in."""
|
|
edits: list[dict] = []
|
|
for feature in part.features:
|
|
edit = feature.outline_edit(part)
|
|
if edit is None:
|
|
continue
|
|
corner = part.outline[int(edit["vertex"]) % len(part.outline)]
|
|
edits.append({**edit, "corner": corner, "owner": feature.id})
|
|
|
|
prims, used = _emit_path(list(part.outline), edits, "visible", closed=True)
|
|
unplaced = tuple(
|
|
{"feature": edit["owner"], "reason": "its outline vertex was already replaced"}
|
|
for edit in edits
|
|
if edit["owner"] not in used
|
|
)
|
|
return prims, unplaced
|
|
|
|
|
|
# -- view construction -------------------------------------------------------
|
|
|
|
|
|
def _axis_prims(part: RevolvedPart) -> tuple[Prim, ...]:
|
|
length = part_length(part)
|
|
over = max(length, part_max_diameter(part)) * AXIS_OVERRUN
|
|
return (Line((-over, 0.0), (length + over, 0.0), "center"),)
|
|
|
|
|
|
def _band_order(bands: tuple[Band, ...], side: str) -> list[Band]:
|
|
"""The bands from the end the observer stands at, nearest first."""
|
|
return list(reversed(bands)) if side == "right" else list(bands)
|
|
|
|
|
|
def _band_radii_near_first(band: Band, side: str) -> tuple[float, float]:
|
|
"""A band's two outer radii, the one nearer the observer first.
|
|
|
|
They differ only on a taper: ``r_out0`` sits at ``x0`` and ``r_out1`` at
|
|
``x1``, so which of them faces the observer depends on the end they are
|
|
looking from.
|
|
"""
|
|
return (band.r_out1, band.r_out0) if side == "right" else (band.r_out0, band.r_out1)
|
|
|
|
|
|
def _front_max(bands: tuple[Band, ...], side: str, x: float) -> float:
|
|
"""The largest outer radius of the material lying **between** ``x`` and the observer.
|
|
|
|
This is the whole hidden-line rule for a view along the axis of a solid of
|
|
revolution: a circular edge of radius ``r`` at station ``x`` is drawn
|
|
continuous when ``r`` is larger than everything in front of it, because then
|
|
nothing can cover it, and dashed when it is not, because the material in
|
|
front does cover it. A step face whose circle is *larger* than the material
|
|
ahead of it is the one edge that cannot be occluded.
|
|
"""
|
|
best = 0.0
|
|
for band in bands:
|
|
nearer = band.x0 >= x - _TOL if side == "right" else band.x1 <= x + _TOL
|
|
if nearer:
|
|
best = max(best, band.r_out0, band.r_out1)
|
|
return best
|
|
|
|
|
|
def _end_circles(part: RevolvedPart, side: str) -> tuple[Prim, ...]:
|
|
"""The concentric circles the profile crosses at one end, roled by occlusion.
|
|
|
|
Outer edges: walking from the observer, a radius larger than everything
|
|
already passed is ``visible``; a smaller one is behind that material and is
|
|
``hidden`` - it is still drawn, dashed, because it is a real edge of the
|
|
part. Bore edges follow the mirror-image rule: you can see down a bore only
|
|
while it stays at least as wide, so a bore radius no larger than the
|
|
narrowest bore in front of it is ``visible`` and anything wider (or anything
|
|
behind solid material) is ``hidden``.
|
|
|
|
Each distinct radius is emitted once: two coincident circles would be a
|
|
duplicate entity, which is what ``drawing_critique`` reports.
|
|
"""
|
|
bands = radial_profile(part)
|
|
order = _band_order(bands, side)
|
|
|
|
prims: list[Prim] = []
|
|
emitted: set[float] = set()
|
|
|
|
def emit(radius: float, role: str) -> None:
|
|
key = round(float(radius), _SNAP)
|
|
if key <= _TOL or key in emitted:
|
|
return
|
|
emitted.add(key)
|
|
prims.append(Circle((0.0, 0.0), float(radius), role))
|
|
|
|
front_max = 0.0
|
|
for band in order:
|
|
for radius in _band_radii_near_first(band, side):
|
|
emit(radius, "visible" if radius > front_max + _TOL else "hidden")
|
|
front_max = max(front_max, radius)
|
|
|
|
open_r = math.inf
|
|
for band in order:
|
|
if band.r_in <= _TOL:
|
|
open_r = 0.0 # solid across the whole section: nothing behind is seen
|
|
continue
|
|
emit(band.r_in, "visible" if band.r_in <= open_r + _TOL else "hidden")
|
|
open_r = min(open_r, band.r_in)
|
|
|
|
over = front_max * (1.0 + AXIS_OVERRUN)
|
|
prims.append(Line((-over, 0.0), (over, 0.0), "center"))
|
|
prims.append(Line((0.0, -over), (0.0, over), "center"))
|
|
return tuple(prims)
|
|
|
|
|
|
def _rectangle(
|
|
u0: float, u1: float, v0: float, v1: float, role: str = "visible"
|
|
) -> tuple[Prim, ...]:
|
|
return (
|
|
Line((u0, v0), (u1, v0), role),
|
|
Line((u1, v0), (u1, v1), role),
|
|
Line((u1, v1), (u0, v1), role),
|
|
Line((u0, v1), (u0, v0), role),
|
|
)
|
|
|
|
|
|
def _mirror_u(prims, axis: float) -> tuple[Prim, ...]:
|
|
"""Reflect primitives about the vertical line ``u = axis`` (screen left <-> right).
|
|
|
|
``Text`` moves with the drawing but keeps its rotation: a mirrored label
|
|
would read backwards, and no drawing has ever wanted that.
|
|
"""
|
|
|
|
def flip(point: Pt) -> Pt:
|
|
return (2.0 * axis - float(point[0]), float(point[1]))
|
|
|
|
out: list[Prim] = []
|
|
for prim in prims:
|
|
if isinstance(prim, Line):
|
|
out.append(Line(flip(prim.p1), flip(prim.p2), prim.role))
|
|
elif isinstance(prim, Arc):
|
|
# reflection reverses the sweep, so the CCW arc runs from the
|
|
# reflected end angle to the reflected start angle
|
|
out.append(
|
|
Arc(
|
|
flip(prim.center),
|
|
prim.radius,
|
|
(180.0 - prim.end_deg) % 360.0,
|
|
(180.0 - prim.start_deg) % 360.0,
|
|
prim.role,
|
|
)
|
|
)
|
|
elif isinstance(prim, Circle):
|
|
out.append(Circle(flip(prim.center), prim.radius, prim.role))
|
|
elif isinstance(prim, Poly):
|
|
out.append(Poly(tuple(flip(pt) for pt in prim.points), prim.closed, prim.role))
|
|
elif isinstance(prim, HatchArea):
|
|
out.append(
|
|
HatchArea(
|
|
tuple(tuple(flip(pt) for pt in loop) for loop in prim.loops), prim.material
|
|
)
|
|
)
|
|
elif isinstance(prim, Text):
|
|
out.append(prim._replace(at=flip(prim.at)))
|
|
else: # pragma: no cover - Prim is a closed union
|
|
raise TypeError(f"_mirror_u: not a primitive: {prim!r}")
|
|
return tuple(out)
|
|
|
|
|
|
def _same_geometry(a: Prim, b: Prim) -> bool:
|
|
"""True when two primitives would be drawn on top of each other, role aside.
|
|
|
|
Exactly what ``engineering/critique.py::_check_duplicate_entities`` matches,
|
|
so anything this returns True for is a warning on the finished drawing.
|
|
"""
|
|
if isinstance(a, Line) and isinstance(b, Line):
|
|
forward = math.dist(a.p1, b.p1) <= _TOL and math.dist(a.p2, b.p2) <= _TOL
|
|
reverse = math.dist(a.p1, b.p2) <= _TOL and math.dist(a.p2, b.p1) <= _TOL
|
|
return forward or reverse
|
|
if isinstance(a, Circle) and isinstance(b, Circle):
|
|
return math.dist(a.center, b.center) <= _TOL and abs(a.radius - b.radius) <= _TOL
|
|
if isinstance(a, Arc) and isinstance(b, Arc):
|
|
return (
|
|
math.dist(a.center, b.center) <= _TOL
|
|
and abs(a.radius - b.radius) <= _TOL
|
|
and abs((a.start_deg - b.start_deg) % 360.0) <= _TOL
|
|
and abs((a.end_deg - b.end_deg) % 360.0) <= _TOL
|
|
)
|
|
return False
|
|
|
|
|
|
def _profile_owned(feature, part, prim: Prim, kind: str, body: list[Prim]) -> bool:
|
|
"""True when the radial profile has already drawn this primitive.
|
|
|
|
Two separate reasons, and both are the same principle - the profile is the
|
|
one calculation the silhouette comes out of, so a feature never draws it a
|
|
second time:
|
|
|
|
* the primitive is coincident with something the body already emitted; or
|
|
* the feature edits the radial profile and this is a **silhouette** line of
|
|
a revolved front view. ``radial_profile`` already cut the notch, and the
|
|
feature's own idea of where the surface is can even contradict it - a
|
|
groove straddling a step reads one surface radius for a span that has two.
|
|
"""
|
|
if any(_same_geometry(prim, other) for other in body):
|
|
return True
|
|
if kind == "front" and getattr(prim, "role", None) == "visible":
|
|
return bool(feature.radial_edit(part))
|
|
return False
|
|
|
|
|
|
def _end_view_role(feature, part, prim: Prim, bands: tuple[Band, ...], side: str) -> Prim:
|
|
"""Re-role a feature's axis-centred end-view circle by the same occlusion rule.
|
|
|
|
The feature knows its diameter, not what is standing in front of it. Its
|
|
axial station comes from ``removal_box``; a feature that does not report one
|
|
keeps its own verdict rather than having one guessed for it.
|
|
"""
|
|
if not isinstance(prim, Circle) or math.dist(prim.center, (0.0, 0.0)) > _TOL:
|
|
return prim
|
|
box = feature.removal_box(part)
|
|
if box is None:
|
|
return prim
|
|
x_near = float(box[2]) if side == "right" else float(box[0])
|
|
covered = _front_max(bands, side, x_near)
|
|
return prim._replace(role="visible" if prim.radius > covered + _TOL else "hidden")
|
|
|
|
|
|
def _feature_layer(
|
|
part,
|
|
kind: str,
|
|
ctx: dict,
|
|
*,
|
|
body: list[Prim] | None = None,
|
|
bands: tuple[Band, ...] | None = None,
|
|
) -> tuple[list[Prim], list[DimIntent], list[dict]]:
|
|
drawn: list[Prim] = list(body or ())
|
|
prims: list[Prim] = []
|
|
dims: list[DimIntent] = []
|
|
omitted: list[dict] = []
|
|
end_view = bands is not None and kind == "side"
|
|
side = str(ctx.get("side", "right")).strip().lower()
|
|
for feature in part.features:
|
|
own = tuple(feature.prims(part, kind, ctx))
|
|
if end_view:
|
|
own = tuple(_end_view_role(feature, part, prim, bands, side) for prim in own)
|
|
own = tuple(prim for prim in own if not _profile_owned(feature, part, prim, kind, drawn))
|
|
if own and kind == "front" and getattr(feature, "axisymmetric", False):
|
|
own = tuple(own) + mirror_about_x(own)
|
|
prims.extend(own)
|
|
drawn.extend(own)
|
|
dims.extend(feature.dims(part, kind, ctx))
|
|
if own:
|
|
continue
|
|
if feature.radial_edit(part) or feature.corner_edit(part) or feature.outline_edit(part):
|
|
continue
|
|
reason = feature.omission(part, kind)
|
|
if reason:
|
|
omitted.append({"feature": feature.id, "reason": reason})
|
|
return prims, dims, omitted
|
|
|
|
|
|
def build_view(
|
|
part,
|
|
kind: str,
|
|
*,
|
|
side: str = "right",
|
|
plane: dict | None = None,
|
|
style: str = "full",
|
|
scale: float = 1.0,
|
|
projection: str = "first",
|
|
detail: dict | None = None,
|
|
) -> View:
|
|
"""Project a part onto one view plane and return its primitive description.
|
|
|
|
``side`` is the end the observer stands at for an end view, and it decides
|
|
both what is in front of what and which way round the view reads. The
|
|
``top`` view is always seen from above, so ``side`` does not apply to it.
|
|
"""
|
|
name = str(kind or "").strip().lower()
|
|
if name not in VIEW_KINDS:
|
|
raise ValueError(f"build_view: kind {kind!r} is not one of {VIEW_KINDS}.")
|
|
where = str(side or "right").strip().lower()
|
|
if where not in SIDES:
|
|
raise ValueError(f"build_view: side {side!r} is not one of {SIDES}.")
|
|
if name == "section":
|
|
return section_view(part, plane, style=style, scale=scale, side=where)
|
|
if name == "detail":
|
|
return detail_view(part, detail or {}, projection=projection, side=where)
|
|
|
|
ctx = {"side": where, "scale": float(scale), "projection": str(projection)}
|
|
prims: list[Prim] = []
|
|
omitted: list[dict] = []
|
|
bands: tuple[Band, ...] | None = None
|
|
mirror_u: float | None = None
|
|
|
|
if isinstance(part, RevolvedPart):
|
|
if name == "top":
|
|
raise ValueError(
|
|
"build_view: a revolved part's top view is identical to its front view; "
|
|
"use kind='front', or kind='section' for a cut."
|
|
)
|
|
if name == "front":
|
|
body, unplaced = silhouette_prims(part)
|
|
prims.extend(body)
|
|
prims.extend(_axis_prims(part))
|
|
omitted.extend(unplaced)
|
|
else:
|
|
bands = radial_profile(part)
|
|
prims.extend(_end_circles(part, where))
|
|
elif isinstance(part, PrismaticPart):
|
|
x0, y0, x1, y1 = outline_bbox(part)
|
|
if name == "front":
|
|
body, unplaced = outline_prims(part)
|
|
prims.extend(body)
|
|
omitted.extend(unplaced)
|
|
elif name == "side":
|
|
prims.extend(_rectangle(y0, y1, 0.0, part.thickness))
|
|
# An observer standing at -X sees screen-right = -Y, so the left
|
|
# view is the right view mirrored. The thickness rectangle spans
|
|
# y0..y1 either way and is its own mirror; the features are not.
|
|
if where == "left":
|
|
mirror_u = (y0 + y1) / 2.0
|
|
else:
|
|
prims.extend(_rectangle(x0, x1, 0.0, part.thickness))
|
|
else: # pragma: no cover - build_part only makes these two
|
|
raise ValueError(f"build_view: {type(part).__name__} is not a part model.")
|
|
|
|
feature_prims, dims, feature_omitted = _feature_layer(part, name, ctx, body=prims, bands=bands)
|
|
if mirror_u is not None:
|
|
feature_prims = list(_mirror_u(feature_prims, mirror_u))
|
|
prims.extend(feature_prims)
|
|
omitted.extend(feature_omitted)
|
|
|
|
return View(
|
|
kind=name,
|
|
prims=tuple(prims),
|
|
dims=tuple(dims),
|
|
omitted=tuple(omitted),
|
|
bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0),
|
|
label=None,
|
|
scale=float(scale),
|
|
)
|
|
|
|
|
|
def layout_origin(
|
|
parent: View,
|
|
kind: str,
|
|
*,
|
|
side: str = "right",
|
|
projection: str = "first",
|
|
gap: float = 20.0,
|
|
child: View | None = None,
|
|
) -> Pt:
|
|
"""Where a child view's bounding box lower-left corner goes, relative to its parent.
|
|
|
|
First angle (ISO 128-30, the default here): the view obtained by looking
|
|
from the right is placed on the **left**, and the view from above is placed
|
|
**below**. Third angle flips both. With no ``child`` the child is assumed
|
|
the same size as the parent, which keeps the views aligned even before the
|
|
child has been built.
|
|
"""
|
|
projection_name = str(projection or "").strip().lower()
|
|
if projection_name not in PROJECTIONS:
|
|
raise ValueError(f"layout_origin: projection {projection!r} is not one of {PROJECTIONS}.")
|
|
px0, py0, px1, py1 = parent.bbox
|
|
if child is None:
|
|
width, height = px1 - px0, py1 - py0
|
|
else:
|
|
cx0, cy0, cx1, cy1 = child.bbox
|
|
width, height = cx1 - cx0, cy1 - cy0
|
|
mid_x = (px0 + px1) / 2.0
|
|
mid_y = (py0 + py1) / 2.0
|
|
first = projection_name == "first"
|
|
name = str(kind or "").strip().lower()
|
|
gap = float(gap)
|
|
|
|
if name in ("side", "section"):
|
|
looking_from_right = str(side or "right").strip().lower() == "right"
|
|
to_the_left = looking_from_right == first
|
|
if to_the_left:
|
|
return (px0 - gap - width, mid_y - height / 2.0)
|
|
return (px1 + gap, mid_y - height / 2.0)
|
|
if name == "top":
|
|
if first:
|
|
return (mid_x - width / 2.0, py0 - gap - height)
|
|
return (mid_x - width / 2.0, py1 + gap)
|
|
if name == "detail":
|
|
return (px1 + gap, mid_y - height / 2.0)
|
|
raise ValueError(
|
|
f"layout_origin: kind {kind!r} has no projection position; expected "
|
|
"'side', 'top', 'section' or 'detail'."
|
|
)
|
|
|
|
|
|
# -- sections ----------------------------------------------------------------
|
|
|
|
|
|
def normalise_plane(plane: dict | None) -> dict:
|
|
"""Fill the `SECTION_PLANE` shape and refuse a plane that has no direction."""
|
|
if plane is None:
|
|
raise ValueError(
|
|
"section: a cutting plane is required - pass plane={'p1': ..., 'p2': ...}, "
|
|
"or take one from section_line()."
|
|
)
|
|
if not isinstance(plane, dict):
|
|
raise ValueError(f"plane: expected a dict, got {type(plane).__name__}.")
|
|
unknown = sorted(set(plane) - set(SECTION_PLANE) - {"via"})
|
|
if unknown:
|
|
raise ValueError(f"plane: unknown key(s) {unknown}; it takes {sorted(SECTION_PLANE)}.")
|
|
p1 = tuple(float(v) for v in plane.get("p1", SECTION_PLANE["p1"]))
|
|
p2 = tuple(float(v) for v in plane.get("p2", SECTION_PLANE["p2"]))
|
|
if math.dist(p1, p2) < 1e-6:
|
|
raise ValueError(f"plane: p1 and p2 are the same point, the plane has zero length ({p1}).")
|
|
style = str(plane.get("style", SECTION_PLANE["style"])).strip().lower()
|
|
if style not in SECTION_STYLES:
|
|
raise ValueError(f"plane.style: expected one of {SECTION_STYLES}, got {style!r}.")
|
|
via = tuple(tuple(float(v) for v in point) for point in plane.get("via", ()))
|
|
return {
|
|
"p1": p1,
|
|
"p2": p2,
|
|
"label": str(plane.get("label", SECTION_PLANE["label"])),
|
|
"direction": tuple(float(v) for v in plane.get("direction", SECTION_PLANE["direction"])),
|
|
"style": style,
|
|
"via": via,
|
|
}
|
|
|
|
|
|
def _inner_path(bands: tuple[Band, ...]) -> list[Pt]:
|
|
points: list[Pt] = [(bands[0].x0, bands[0].r_in)]
|
|
for band in bands:
|
|
if math.dist(points[-1], (band.x0, band.r_in)) > _EPS:
|
|
points.append((band.x0, band.r_in))
|
|
points.append((band.x1, band.r_in))
|
|
return points
|
|
|
|
|
|
def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]:
|
|
steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS)
|
|
return tuple(
|
|
(
|
|
radius * math.cos(2.0 * math.pi * k / CIRCLE_SEGMENTS),
|
|
radius * math.sin(2.0 * math.pi * k / CIRCLE_SEGMENTS),
|
|
)
|
|
for k in steps
|
|
)
|
|
|
|
|
|
def _is_longitudinal(plane: dict) -> bool:
|
|
"""True when the cutting plane runs along the revolved axis (y = 0 at both ends)."""
|
|
return abs(plane["p1"][1]) < 1e-6 and abs(plane["p2"][1]) < 1e-6
|
|
|
|
|
|
def _subtract(intervals: list[tuple[float, float]], cut: tuple[float, float]):
|
|
out: list[tuple[float, float]] = []
|
|
low, high = cut
|
|
for a, b in intervals:
|
|
if high <= a + _EPS or low >= b - _EPS:
|
|
out.append((a, b))
|
|
continue
|
|
if low > a + _EPS:
|
|
out.append((a, low))
|
|
if high < b - _EPS:
|
|
out.append((high, b))
|
|
return [(a, b) for a, b in out if b - a > 1e-9]
|
|
|
|
|
|
def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]:
|
|
"""Parameters along `unit` from `p1` where the line is inside the outline.
|
|
|
|
Crossings are paired in order, which is exact for a simple polygon whose
|
|
edges the line crosses transversally. A line that grazes a vertex produces
|
|
an odd crossing count and is refused by the caller rather than paired wrong.
|
|
"""
|
|
ts: list[float] = []
|
|
count = len(outline)
|
|
for index in range(count):
|
|
ax, ay = outline[index]
|
|
bx, by = outline[(index + 1) % count]
|
|
ex, ey = bx - ax, by - ay
|
|
denominator = ex * unit[1] - ey * unit[0]
|
|
if abs(denominator) < 1e-12:
|
|
continue
|
|
s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator
|
|
if -1e-9 <= s <= 1.0 + 1e-9:
|
|
px, py = ax + s * ex, ay + s * ey
|
|
ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1])
|
|
ts = sorted({round(t, 9) for t in ts})
|
|
if len(ts) % 2 != 0:
|
|
raise ValueError(
|
|
"section: the cutting plane grazes an outline vertex, so its crossings cannot be "
|
|
"paired. Move the plane off the vertex rather than accept a guessed cut face."
|
|
)
|
|
return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)]
|
|
|
|
|
|
def _box_span(box, p1: Pt, unit: Pt, normal: Pt):
|
|
xs = (box[0], box[2])
|
|
ys = (box[1], box[3])
|
|
corners = [(x, y) for x in xs for y in ys]
|
|
distances = [(x - p1[0]) * normal[0] + (y - p1[1]) * normal[1] for x, y in corners]
|
|
if min(distances) > 1e-9 or max(distances) < -1e-9:
|
|
return None
|
|
spans = [(x - p1[0]) * unit[0] + (y - p1[1]) * unit[1] for x, y in corners]
|
|
return (min(spans), max(spans))
|
|
|
|
|
|
def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]:
|
|
"""The cutting legs of an offset plane.
|
|
|
|
ISO 128-40: the offset itself is not drawn, so with ``via`` present the
|
|
odd-numbered legs are the transfer jogs and contribute nothing to the cut
|
|
face.
|
|
"""
|
|
points = [plane["p1"], *plane["via"], plane["p2"]]
|
|
legs = [(points[i], points[i + 1]) for i in range(len(points) - 1)]
|
|
if plane["via"]:
|
|
legs = [leg for index, leg in enumerate(legs) if index % 2 == 0]
|
|
return legs
|
|
|
|
|
|
def _prismatic_cut(part: PrismaticPart, plane: dict, style: str):
|
|
"""``[(u0, u1, v0, v1, hatched), ...]`` - the cut rectangles along the plane."""
|
|
legs = _plane_legs(plane) if style == "offset" else [(plane["p1"], plane["p2"])]
|
|
thickness = float(part.thickness)
|
|
rectangles: list[tuple[float, float, float, float, bool]] = []
|
|
origin = 0.0
|
|
for start, end in legs:
|
|
length = math.dist(start, end)
|
|
if length < 1e-9:
|
|
continue
|
|
unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length)
|
|
normal = (-unit[1], unit[0])
|
|
intervals = _chords(part.outline, start, unit)
|
|
intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9]
|
|
intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals]
|
|
intervals = [(a, b) for a, b in intervals if b - a > 1e-9]
|
|
|
|
pockets: list[tuple[tuple[float, float], float, bool]] = []
|
|
for feature in part.features:
|
|
box = feature.removal_box(part)
|
|
if box is None:
|
|
continue
|
|
span = _box_span(box, start, unit, normal)
|
|
if span is None:
|
|
continue
|
|
span = (max(span[0], 0.0), min(span[1], length))
|
|
if span[1] - span[0] <= 1e-9:
|
|
continue
|
|
if isinstance(feature, _PocketType):
|
|
pockets.append((span, thickness - float(feature.depth), feature.no_section_hatch))
|
|
else:
|
|
intervals = _subtract(intervals, span)
|
|
|
|
for pocket_span, floor, unhatched in pockets:
|
|
intervals = _subtract(intervals, pocket_span)
|
|
rectangles.append(
|
|
(origin + pocket_span[0], origin + pocket_span[1], 0.0, floor, not unhatched)
|
|
)
|
|
for a, b in intervals:
|
|
rectangles.append((origin + a, origin + b, 0.0, thickness, True))
|
|
origin += length
|
|
if rectangles:
|
|
# the section view has its own frame: u = 0 at the first cut.
|
|
shift = min(u0 for u0, _u1, _v0, _v1, _hatched in rectangles)
|
|
rectangles = [
|
|
(u0 - shift, u1 - shift, v0, v1, hatched) for u0, u1, v0, v1, hatched in rectangles
|
|
]
|
|
return rectangles
|
|
|
|
|
|
def cut_loops(part, plane: dict, *, style: str = "full"):
|
|
"""The closed cut-face loops, each flagged with whether it is hatched."""
|
|
plane = normalise_plane(plane)
|
|
name = str(style or "full").strip().lower()
|
|
if name not in SECTION_STYLES:
|
|
raise ValueError(f"style: expected one of {SECTION_STYLES}, got {style!r}.")
|
|
|
|
if isinstance(part, RevolvedPart):
|
|
bands = radial_profile(part)
|
|
if _is_longitudinal(plane):
|
|
if name == "offset":
|
|
raise ValueError(
|
|
"section: an offset plane has no meaning along a revolved part's axis; "
|
|
"use style='full' (or a transverse plane for a cross section)."
|
|
)
|
|
upper = tuple(_outer_path(bands)) + tuple(reversed(_inner_path(bands)))
|
|
loops = [(upper, True)]
|
|
if name != "half":
|
|
loops.append((tuple((x, -y) for x, y in upper), True))
|
|
return tuple(loops)
|
|
if name == "half":
|
|
raise ValueError(
|
|
"section: a half section is defined on a longitudinal plane through the axis; "
|
|
"a transverse plane takes style='full' or 'revolved'."
|
|
)
|
|
station = plane["p1"][0]
|
|
r_out = outer_radius_at(part, station)
|
|
r_in = inner_radius_at(part, station)
|
|
loops = [(_circle_loop(r_out), True)]
|
|
if r_in > _EPS:
|
|
loops.append((_circle_loop(r_in, reverse=True), True))
|
|
return tuple(loops)
|
|
|
|
if name in ("half", "revolved"):
|
|
raise ValueError(
|
|
f"section: a {name} section is defined on a revolved part; a prismatic part takes "
|
|
"style='full' or 'offset'."
|
|
)
|
|
rectangles = _prismatic_cut(part, plane, name)
|
|
if not rectangles:
|
|
raise ValueError(
|
|
f"section: the plane {plane['p1']} -> {plane['p2']} does not cut {part.name!r}."
|
|
)
|
|
return tuple(
|
|
(((u0, v0), (u1, v0), (u1, v1), (u0, v1)), hatched)
|
|
for u0, u1, v0, v1, hatched in rectangles
|
|
)
|
|
|
|
|
|
def section_view(
|
|
part, plane: dict, *, style: str = "full", scale: float = 1.0, side: str = "right"
|
|
) -> View:
|
|
"""A cut view: the cut faces as hatch areas, plus the geometry behind them."""
|
|
from engineering.mech.standards.materials import hatch_for
|
|
|
|
plane = normalise_plane(plane)
|
|
name = str(style or plane["style"]).strip().lower()
|
|
hatch_for(part.material) # refuse an unknown material before anything is built
|
|
ctx = {"side": side, "scale": float(scale), "projection": "first", "plane": plane}
|
|
|
|
loops = cut_loops(part, plane, style=name)
|
|
prims: list[Prim] = []
|
|
omitted: list[dict] = []
|
|
dims: list[DimIntent] = []
|
|
|
|
if isinstance(part, RevolvedPart) and _is_longitudinal(plane):
|
|
body, unplaced = silhouette_prims(part)
|
|
prims.extend(body)
|
|
prims.extend(_axis_prims(part))
|
|
omitted.extend(unplaced)
|
|
for feature in part.features:
|
|
own = feature.cut_prims(part, plane, ctx)
|
|
if own and getattr(feature, "axisymmetric", False):
|
|
own = tuple(own) + mirror_about_x(own)
|
|
prims.extend(own)
|
|
dims.extend(feature.dims(part, "front", ctx))
|
|
elif isinstance(part, RevolvedPart):
|
|
role = "phantom" if name == "revolved" else "visible"
|
|
station = plane["p1"][0]
|
|
r_out = outer_radius_at(part, station)
|
|
r_in = inner_radius_at(part, station)
|
|
prims.append(Circle((0.0, 0.0), r_out, role))
|
|
if r_in > _EPS:
|
|
prims.append(Circle((0.0, 0.0), r_in, role))
|
|
over = r_out * (1.0 + AXIS_OVERRUN)
|
|
prims.append(Line((-over, 0.0), (over, 0.0), "center"))
|
|
prims.append(Line((0.0, -over), (0.0, over), "center"))
|
|
else:
|
|
for points, _hatched in loops:
|
|
xs = [p[0] for p in points]
|
|
ys = [p[1] for p in points]
|
|
prims.extend(_rectangle(min(xs), max(xs), min(ys), max(ys)))
|
|
|
|
hatched = [points for points, flag in loops if flag]
|
|
if isinstance(part, RevolvedPart) and not _is_longitudinal(plane):
|
|
# a transverse cut face is one region with the bore as its island
|
|
if hatched:
|
|
prims.append(HatchArea(tuple(hatched), part.material))
|
|
else:
|
|
for points in hatched:
|
|
prims.append(HatchArea((points,), part.material))
|
|
|
|
label = f"{plane['label']}-{plane['label']}"
|
|
return View(
|
|
kind="section",
|
|
prims=tuple(prims),
|
|
dims=tuple(dims),
|
|
omitted=tuple(omitted),
|
|
bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0),
|
|
label=label,
|
|
scale=float(scale),
|
|
)
|
|
|
|
|
|
# -- detail views ------------------------------------------------------------
|
|
|
|
|
|
def _prim_bbox(prim) -> tuple[float, float, float, float]:
|
|
return bbox([prim])
|
|
|
|
|
|
def _touches(prim, center: Pt, radius: float) -> bool:
|
|
x0, y0, x1, y1 = _prim_bbox(prim)
|
|
nearest_x = min(max(center[0], x0), x1)
|
|
nearest_y = min(max(center[1], y0), y1)
|
|
return math.dist((nearest_x, nearest_y), center) <= radius + 1e-9
|
|
|
|
|
|
def _clip_segment(p1: Pt, p2: Pt, center: Pt, radius: float):
|
|
"""The part of the segment inside the circle, or None."""
|
|
dx, dy = p2[0] - p1[0], p2[1] - p1[1]
|
|
fx, fy = p1[0] - center[0], p1[1] - center[1]
|
|
a = dx * dx + dy * dy
|
|
if a < 1e-18:
|
|
return (p1, p2) if math.dist(p1, center) <= radius + 1e-9 else None
|
|
b = 2.0 * (fx * dx + fy * dy)
|
|
c = fx * fx + fy * fy - radius * radius
|
|
discriminant = b * b - 4.0 * a * c
|
|
if discriminant <= 0.0:
|
|
return None
|
|
root = math.sqrt(discriminant)
|
|
t0 = max(0.0, (-b - root) / (2.0 * a))
|
|
t1 = min(1.0, (-b + root) / (2.0 * a))
|
|
if t1 - t0 <= 1e-9:
|
|
return None
|
|
return ((p1[0] + t0 * dx, p1[1] + t0 * dy), (p1[0] + t1 * dx, p1[1] + t1 * dy))
|
|
|
|
|
|
def _clip_to_circle(prim, center: Pt, radius: float) -> tuple[Prim, ...]:
|
|
"""Straight geometry is trimmed to the detail circle; curved geometry is kept
|
|
whole when it touches it.
|
|
|
|
Trimming an arc would mean solving its angular range against the circle, and
|
|
a detail that silently re-cuts an arc is worse than one that carries a
|
|
slightly long arc into the enlargement - so the rule is stated rather than
|
|
approximated, and a detail is meant to be drawn inside its circle.
|
|
"""
|
|
if isinstance(prim, Line):
|
|
clipped = _clip_segment(prim.p1, prim.p2, center, radius)
|
|
return (Line(clipped[0], clipped[1], prim.role),) if clipped else ()
|
|
if isinstance(prim, Poly):
|
|
points = list(prim.points)
|
|
if prim.closed and points:
|
|
points.append(points[0])
|
|
out: list[Prim] = []
|
|
for index in range(len(points) - 1):
|
|
clipped = _clip_segment(points[index], points[index + 1], center, radius)
|
|
if clipped:
|
|
out.append(Line(clipped[0], clipped[1], prim.role))
|
|
return tuple(out)
|
|
return (prim,) if _touches(prim, center, radius) else ()
|
|
|
|
|
|
def detail_marker_prims(detail: dict) -> tuple[Prim, ...]:
|
|
"""ISO 128-34 detail circle and its letter, for the **parent** view."""
|
|
center = tuple(float(v) for v in detail["center"])
|
|
radius = float(detail["radius"])
|
|
label = str(detail.get("label", "A"))
|
|
return (
|
|
Circle(center, radius, "visible"),
|
|
Text((center[0] + radius + 2.0, center[1] + radius + 2.0), label, 5.0, 0.0, "text"),
|
|
)
|
|
|
|
|
|
def detail_view(part, detail: dict, *, projection: str = "first", side: str = "right") -> View:
|
|
"""A circular region of a parent view, enlarged, with the ISO 128-34 labels.
|
|
|
|
Straight geometry is trimmed to the circle and curved geometry that touches
|
|
it is kept whole (see :func:`_clip_to_circle`); every feature whose own
|
|
primitives all fall outside is reported in ``omitted``.
|
|
"""
|
|
if not isinstance(detail, dict):
|
|
raise ValueError(f"detail: expected a dict, got {type(detail).__name__}.")
|
|
if "radius" not in detail:
|
|
raise ValueError("detail.radius: a detail needs the radius of its circle.")
|
|
center = tuple(float(v) for v in detail.get("center", (0.0, 0.0)))
|
|
radius = float(detail["radius"])
|
|
if radius <= _EPS:
|
|
raise ValueError(f"detail.radius: must be greater than zero, got {radius}.")
|
|
factor = float(detail.get("scale", 5.0))
|
|
if factor <= _EPS:
|
|
raise ValueError(f"detail.scale: must be greater than zero, got {factor}.")
|
|
label = str(detail.get("label", "A"))
|
|
of = str(detail.get("of", "front")).strip().lower()
|
|
|
|
parent = build_view(part, of, side=side, projection=projection)
|
|
kept: list[Prim] = []
|
|
for prim in parent.prims:
|
|
kept.extend(_clip_to_circle(prim, center, radius))
|
|
kept = tuple(kept)
|
|
if not kept:
|
|
raise ValueError(
|
|
f"detail: the circle at {center} r={radius} contains no geometry of the {of} view."
|
|
)
|
|
|
|
moved = translate(kept, -center[0], -center[1])
|
|
enlarged = scale_prims(moved, factor, (0.0, 0.0))
|
|
|
|
omitted = list(parent.omitted)
|
|
for feature in part.features:
|
|
own = feature.prims(part, of, {"side": side, "scale": 1.0, "projection": projection})
|
|
if own and not any(_touches(prim, center, radius) for prim in own):
|
|
omitted.append({"feature": feature.id, "reason": f"outside the detail circle {label}"})
|
|
|
|
return View(
|
|
kind="detail",
|
|
prims=tuple(enlarged),
|
|
dims=(),
|
|
omitted=tuple(omitted),
|
|
bbox=bbox(enlarged),
|
|
label=f"{label} ({factor:g}:1)",
|
|
scale=factor,
|
|
)
|