Three defects in the orthographic view engine, all one missing idea: the engine
had no visibility rule and no single owner for the silhouette.
1. `_end_circles` inverted the hidden-line rule. Walking the bands from the
viewing end it drew a circle only when its radius EXCEEDED everything nearer,
and roled every such circle `hidden` unless it was the first band - so the one
edge that CANNOT be occluded (a step face larger than the material in front of
it) shipped dashed on HIDDEN, and the circle that really is covered was not
drawn at all. `_front_max` is now the rule: an edge of radius r at station x is
continuous when r is larger than every outer radius between x and the observer,
dashed when it is not, and it is always drawn. Bores follow the mirror image -
you see down a bore only while it stays at least as wide. Each distinct radius
is emitted once, because two coincident circles are a duplicate entity.
The same rule now roles a feature's axis-centred end-view circle, whose axial
station comes from `removal_box`; a feature that reports none keeps its own
verdict rather than having one guessed.
Ground truth by ray cast along the axis of the same solid of revolution
(segments 20x25, 40x30, 15x20; observer at +X, probe just inside each edge):
r=10 at x=75 -> first surface x=75 -> VISIBLE (was visible, kept)
r=15 at x=60 -> first surface x=60 -> VISIBLE (was HIDDEN, fixed)
r=12.5 at x=20 -> first surface x=60 -> HIDDEN (was not drawn, fixed)
2. Every axisymmetric groove was drawn twice - once as the notch
`radial_profile` cuts into the silhouette, once by the feature's own
`prims()`, which `_feature_layer` appended unconditionally. Six exactly
coincident LINEs per grooved shaft, which is precisely what
`critique.py::_check_duplicate_entities` matches, so premium rule 7 could
never be met on a part carrying a retaining or O-ring groove. `_profile_owned`
now drops a feature primitive that the body already emitted, and drops a
revolved front view's `visible` silhouette line from any feature that edits
the radial profile: the spec's rule is that grooves cut a notch in the
silhouette, and the notch belongs to the one calculation. Suppression is
"the profile already drew this", not "this feature edits the profile", so a
blind bore keeps the bottom line the profile cannot express, and an internal
thread keeps its ISO 6410 limit line.
A groove straddling a step was worse than duplicated: the feature reads one
surface radius for a span that has two, so it drew a groove floor through
solid material and left the +Y path with four odd-degree nodes. It now has
two - its two ends on the axis.
3. `build_view` validated `side` and put it in ctx, but the prismatic branch
ignored it: `side="left"` returned the right-hand view, with every feature on
the wrong side of the sheet and a symmetric outline rectangle to hide it. An
observer at -X sees screen-right = -Y, so the feature layer is now mirrored
about the outline's mid-Y. The top view is always seen from above and does
not move with `side`; that is now said in the docstring and pinned by a test.
Tests: the two that pinned the inverted rule are rewritten around the ray cast,
and one of them contradicted its own name. Seven tests added, and the prismatic
side-view test's hole moved off the y=25 mirror axis, where it could not tell a
left view from a right one. Nine of them fail against the previous `views.py`
and pass against this one.
Live AutoCAD was unavailable for a SOLPROF cross-check: the running AutoCAD 2026
(pid 40068) reports Responding=False and `GetActiveObject("AutoCAD.Application")`
never returned inside 100 s, so no scratch document was created and none is left
open. No new ActiveX member is called by this change - views.py is pure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
651 lines
24 KiB
Python
651 lines
24 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 mirror_about_x
|
|
from engineering.mech.part import (
|
|
PrismaticPart,
|
|
RevolvedPart,
|
|
outline_bbox,
|
|
part_length,
|
|
part_max_diameter,
|
|
segment_bounds,
|
|
)
|
|
from engineering.mech.primitives import (
|
|
Arc,
|
|
Circle,
|
|
DimIntent,
|
|
HatchArea,
|
|
Line,
|
|
Poly,
|
|
Prim,
|
|
Pt,
|
|
Text,
|
|
bbox,
|
|
)
|
|
|
|
_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")
|
|
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
|
|
|
|
|
|
@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}.")
|
|
del plane, style, detail # the orthographic kinds take no plane or detail
|
|
|
|
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'."
|
|
)
|