Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/draftwright/_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,11 @@ def _boxes_overlap(a, b) -> bool:
return bool(a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1])


def detail_caption_text_gap(font_size: float) -> float:
"""Minimum page-space gap between a detail caption and another label."""
return font_size / 3.0


def _segment_crosses_box(p1, p2, box) -> bool:
"""True when line segment *p1*-*p2* intersects axis-aligned *box*
``(x0, y0, x1, y1)`` — the precise counterpart of ``_box_hits`` for a
Expand Down Expand Up @@ -1039,6 +1044,62 @@ def material_span(p, q, field: MaterialField) -> float:
)


def label_on_narrow_material(box, field: MaterialField) -> bool:
"""Whether text covers material too close to its projected boundary.

A projected edge can miss a text box by a fraction of a millimetre while
the glyphs still fill a thin shaft. A broad face keeps room around the box.
Probe both centre lines and half the shorter label extent beyond each edge; the
material field is the same one used for leader routing. An edge of a broad
face alone is not a narrow shaft: the local material band across the label's
short axis must also be less than three short-axis extents.
"""
if not field or field.box is None:
return False
x0, y0, x1, y1 = box
width, height = x1 - x0, y1 - y0
if width <= 0.0 or height <= 0.0:
return False
x = (x0 + x1) / 2.0
y = (y0 + y1) / 2.0
if (
material_span((x0, y), (x1, y), field) < width - MATERIAL_VISIBLE_FLOOR
or material_span((x, y0), (x, y1), field) < height - MATERIAL_VISIBLE_FLOOR
):
return False
short = min(width, height)
if width <= height:
p, q, centre = (field.box[0], y), (field.box[2], y), x
axis = 0
else:
p, q, centre = (x, field.box[1]), (x, field.box[3]), y
axis = 1
span = q[axis] - p[axis]
if span <= 0.0:
return False
centre_tick = round((centre - p[axis]) / span * _MATERIAL_SPAN_TICKS)
local_band = next(
(
span * (hi - lo) / _MATERIAL_SPAN_TICKS
for lo, hi in material_intervals(p, q, field)
if lo <= centre_tick <= hi
),
None,
)
if local_band is None or local_band >= 3.0 * short:
return False
margin = short / 2.0
return any(
material_span(p, q, field) < margin - MATERIAL_VISIBLE_FLOOR
for p, q in (
((x0 - margin, y), (x0, y)),
((x1, y), (x1 + margin, y)),
((x, y0 - margin), (x, y0)),
((x, y1), (x, y1 + margin)),
)
)


def material_intervals(
p, q, field: MaterialField, *, bridge: float = 0.0
) -> tuple[tuple[int, int], ...]:
Expand Down
5 changes: 4 additions & 1 deletion src/draftwright/annotations/_diameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,9 @@ def _within_page(box):
# axis. The far axial margin is deliberately NOT a candidate — a leader run
# the whole length of the part to the opposite end reads worse than the
# near-miss it replaces; restore-and-flag is the honest fallback instead.
near_a = lo_b - gap if tip[ax] - lo_b <= hi_b - tip[ax] else hi_b + gap
toward_lo = tip[ax] - lo_b <= hi_b - tip[ax]
near_a = lo_b - gap if toward_lo else hi_b + gap
edge_a = lo_b - draft.pad_around_text if toward_lo else hi_b + draft.pad_around_text

def _pt(a_val, r_val, _ax=ax):
p = [0.0, 0.0]
Expand All @@ -907,6 +909,7 @@ def _pt(a_val, r_val, _ax=ax):

candidates = (
_pt(near_a, tip[rad]),
_pt(edge_a, tip[rad]),
_pt(tip[ax], fb[rad] - gap),
_pt(tip[ax], fb[rad + 2] + gap),
)
Expand Down
132 changes: 99 additions & 33 deletions src/draftwright/annotations/_gdt.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
_text_line_spacing_em,
_text_size,
)
from draftwright._geometry import _turned_profile_site
from draftwright._geometry import _turned_profile_site, material_span
from draftwright.annotations._common import (
PRIORITY,
CorridorCandidate,
Expand Down Expand Up @@ -349,6 +349,25 @@ def _gdt_retry_sides(side: str, *, normal_side_only: bool) -> tuple[str, ...]:
}[side]


def _datum_label_has_whitespace(label, strip, horizontal, field) -> bool:
"""A local datum glyph must clear projected material or sit beyond the view."""
x0, y0, x1, y1 = label
if horizontal:
outside = y1 <= strip.anchor if strip.direction < 0 else y0 >= strip.anchor
else:
outside = x1 <= strip.anchor if strip.direction < 0 else x0 >= strip.anchor
if outside:
return True
if not field:
return False
# The shared view-edge guard rejects partial boundary crossings. These
# interior probes reject a glyph sitting wholly on a blank projected face.
return all(
material_span((x0, y), (x1, y), field) <= 1e-6
for y in (y0 + (y1 - y0) * 0.1, (y0 + y1) / 2.0, y1 - (y1 - y0) * 0.1)
)


def _gdt_retry_geometry(state, tier, alt):
"""Give the deferred carve its strip and glyph extent."""
alt_strip = getattr(state.zones, alt, None)
Expand Down Expand Up @@ -518,8 +537,57 @@ def _retry():
return _drop


def _gdt_compact_candidates(
original, build, strip, size, horizontal, item, site, tier, material_field
):
"""Nearest-first same-strip landings checked later against exact ink.

Dimension extension lines make their conservative boxes intentionally
broad. A GD&T leader may pass through the empty part of such a box, so
the corridor result is an upper bound rather than necessarily the best
landing. Keep this search finite and inside the requested strip.
"""
if original is None:
return
original_pos = original.elbow[1 if horizontal else 0]
extent = size[1 if horizontal else 0]
if item.kind == "datum_ref" and item.reference_surface_kind:
# A whole-view strip can be far from a small datum face. First try
# bounded positions between the face and that strip, while keeping
# the shaft on the proven surface normal. The shared postsolve
# checks the complete glyph, view edges and settled annotation ink.
distance = (original_pos - site) * strip.direction
first = extent / 2.0 + 1.0
step = max(tier / 2.0, 1.0)
for index in range(min(12, max(0, int((distance - first) / step) + 1))):
travel = first + index * step
if travel >= distance - 1e-6:
break
pos = site + strip.direction * travel
candidate = build(pos)
# A symbol inside the whole-view box must sit in projected
# whitespace, not on a blank face with no visible edge. The
# shared postsolve also checks visible edges and exact ink.
if _datum_label_has_whitespace(
candidate.label_bbox, strip, horizontal, material_field
):
yield candidate
near = strip.anchor + strip.direction * (strip.gap + extent / 2.0)
distance = (original_pos - near) * strip.direction
if distance <= 1e-6:
return
step = max(tier + strip.spacing, 1.0)
count = min(64, int(math.ceil(distance / step)) + 1)
for index in range(count):
travel = min(distance, index * step)
pos = near + strip.direction * travel
if abs(pos - original_pos) <= 1e-6:
return
yield build(pos)


def _gdt_candidate_builders(
item, draft, leader_ctor, fallback_glyph, px, py, horizontal, strip, size, tier
item, draft, leader_ctor, fallback_glyph, px, py, horizontal, strip, size, tier, material_field
):
"""Build one glyph's primary and fallback leaders plus bounded strip retries."""

Expand Down Expand Up @@ -608,36 +676,18 @@ def _build_routed(bends, elbow, _px=px, _py=py, _it=item, _g=fallback_glyph):
_attach_gdt_text_evidence(leader, _g, _it, draft)
return leader

def _compact_candidates(
original,
_build=_build,
_strip=strip,
_size=size,
_horizontal=horizontal,
):
"""Nearest-first same-strip landings checked later against exact ink.

Dimension extension lines make their conservative boxes intentionally
broad. A GD&T leader may pass through the empty part of such a box, so
the corridor result is an upper bound rather than necessarily the best
landing. Keep this search finite and inside the requested strip.
"""
if original is None:
return
original_pos = original.elbow[1 if _horizontal else 0]
extent = _size[1 if _horizontal else 0]
near = _strip.anchor + _strip.direction * (_strip.gap + extent / 2.0)
distance = (original_pos - near) * _strip.direction
if distance <= 1e-6:
return
step = max(tier + _strip.spacing, 1.0)
count = min(64, int(math.ceil(distance / step)) + 1)
for index in range(count):
travel = min(distance, index * step)
pos = near + _strip.direction * travel
if abs(pos - original_pos) <= 1e-6:
return
yield _build(pos)
def _compact_candidates(original):
return _gdt_compact_candidates(
original,
_build,
strip,
size,
horizontal,
item,
py if horizontal else px,
tier,
material_field,
)

def _ink_repair_candidates(
original,
Expand Down Expand Up @@ -772,6 +822,12 @@ def render_gdt(
)
continue
size = (gb.X, gb.Y)
placed_view = dwg.views.get(item.view)
field = (
dwg.material_fields().get(id(placed_view[0]))
if item.kind == "datum_ref" and item.reference_surface_kind and placed_view
else None
)

(
_build,
Expand All @@ -780,7 +836,17 @@ def render_gdt(
_compact_candidates,
_ink_repair_candidates,
) = _gdt_candidate_builders(
item, draft, leader_ctor, fallback_glyph, px, py, horizontal, strip, size, tier
item,
draft,
leader_ctor,
fallback_glyph,
px,
py,
horizontal,
strip,
size,
tier,
field,
)

_drop = _gdt_drop_callback(
Expand Down
13 changes: 12 additions & 1 deletion src/draftwright/annotations/_height_ladder.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,19 @@ def register_height_ladder_candidates(
_tolerances = {c[0]: c[8] for c in chain}

names = [c[0] for c in chain]
side_right_space = (
frame.zones("side").right.outer_limit - frame.zones("side").right.anchor
if "side" in dwg.views
else float("inf")
)
overall_side = (
"left"
if side_right_space < frame.zones(view).right.outer_limit - frame.zones(view).right.anchor
and frame.zones(view).left.anchor - frame.zones(view).left.outer_limit >= tier
else "right"
)
sides = {
name: (overall.rungs[0].side or "right")
name: (overall.rungs[0].side or overall_side)
if name == "dim_height" and overall is not None
else "right"
for name in names
Expand Down
26 changes: 23 additions & 3 deletions src/draftwright/annotations/_leader_fixed_ink.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,8 @@ def _fixed_ink_hull(polygon):
return hull if len(hull) >= 3 else None


def _annotation_fixed_ink(dwg, name, annotation, *, max_components=None):
"""Exact-width fixed ink components for one already-rendered annotation."""

def _reserved_fixed_ink(dwg, name, annotation, max_components):
"""Return a complete block reservation when the annotation owns one."""
if isinstance(annotation, DerivedViewReservation):
# A future required view owns its entire planned rectangle, not merely
# the strokes it will eventually draw. Keep it hard even in the bounded
Expand All @@ -371,6 +370,27 @@ def _annotation_fixed_ink(dwg, name, annotation, *, max_components=None):
),
)

if getattr(annotation, "table_rows", None) is not None:
# A table reserves its cells as a block. Individual glyph/line faces leave
# apparently free pockets where another annotation can cross a cell.
if max_components is not None and max_components < 1:
return _FIXED_INVENTORY_EXHAUSTED
box = _coerce_box(_geom_box(annotation, getattr(dwg, "box_cache", None)))
if box is None:
if max_components is not None:
return _FIXED_INVENTORY_EXHAUSTED
box = (0.0, 0.0, float(dwg.page_w), float(dwg.page_h))
return (_FixedInkComponent(f"{name}:table", box=box, kind="Table"),)
return None


def _annotation_fixed_ink(dwg, name, annotation, *, max_components=None):
"""Exact-width fixed ink components for one already-rendered annotation."""

reserved = _reserved_fixed_ink(dwg, name, annotation, max_components)
if reserved is not None:
return reserved

components: list[_FixedInkComponent] = []
owner = dwg.registry.feature_of(name)
kind = type(annotation).__name__
Expand Down
12 changes: 3 additions & 9 deletions src/draftwright/annotations/_machined_leaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from build123d_drafting.helpers import DEFAULT_FONT_PATH

from draftwright.annotations._placement_occupancy import label_clears_foreign_annotations
from draftwright.annotations.leaders import (
FeatureLeaderCandidate,
FeatureLeaderJob,
Expand All @@ -39,7 +40,6 @@ class MachinedLeaderBindings:
collect_feature_leader: Callable
place_feature_leader_jobs: Callable[..., int]
attribute_annotations: Callable
boxes_overlap: Callable


@dataclass
Expand Down Expand Up @@ -75,14 +75,8 @@ def foreign_label_clear(
for _name, owner, box, has_label in self.bindings.attribute_annotations(self.dwg)
if owner != view
)
pad = self.dwg.draft.pad_around_text
padded = (label[0] - pad, label[1] - pad, label[2] + pad, label[3] + pad)
return not any(
self.bindings.boxes_overlap(
padded,
(box[0] - pad, box[1] - pad, box[2] + pad, box[3] + pad) if has_label else box,
)
for box, has_label in self.foreign_boxes_by_view[view]
return label_clears_foreign_annotations(
label, self.foreign_boxes_by_view[view], self.dwg.draft.pad_around_text
)

def lower(self, row) -> FeatureLeaderJob:
Expand Down
19 changes: 19 additions & 0 deletions src/draftwright/annotations/_placement_occupancy.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import math
from collections.abc import Iterable
from itertools import chain

from build123d_drafting.helpers import Dimension, Leader, Note, SafeDimension
Expand Down Expand Up @@ -713,3 +714,21 @@ def view_label_clearance(dwg, view):
return None
entries = _view_edge_entries(placed[0], {})
return lambda box: entries is not None and not _edges_intersect_rect(entries, box)


def label_clears_foreign_annotations(
box: tuple[float, float, float, float],
foreign_boxes: Iterable[tuple[tuple[float, float, float, float], bool]],
pad: float,
) -> bool:
"""Keep a candidate label clear of another view's settled text and ink."""
protected = (box[0] - pad, box[1] - pad, box[2] + pad, box[3] + pad)
return not any(
_boxes_overlap(
protected,
(other[0] - pad, other[1] - pad, other[2] + pad, other[3] + pad)
if has_label
else other,
)
for other, has_label in foreign_boxes
)
Loading
Loading