draftwright 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- draftwright/_core.py +25 -51
- draftwright/annotations/_common.py +489 -0
- draftwright/annotations/from_model.py +367 -116
- draftwright/annotations/holes.py +400 -204
- draftwright/annotations/orchestrator.py +160 -97
- draftwright/annotations/sections.py +121 -24
- draftwright/builder.py +34 -9
- draftwright/drawing.py +79 -37
- draftwright/layout.py +363 -4
- draftwright/linting/coverage.py +15 -0
- draftwright/registry.py +14 -3
- draftwright/repair.py +2 -2
- draftwright/sheet.py +47 -0
- {draftwright-0.2.2.dist-info → draftwright-0.2.4.dist-info}/METADATA +63 -49
- {draftwright-0.2.2.dist-info → draftwright-0.2.4.dist-info}/RECORD +18 -18
- {draftwright-0.2.2.dist-info → draftwright-0.2.4.dist-info}/WHEEL +0 -0
- {draftwright-0.2.2.dist-info → draftwright-0.2.4.dist-info}/entry_points.txt +0 -0
- {draftwright-0.2.2.dist-info → draftwright-0.2.4.dist-info}/licenses/LICENSE +0 -0
draftwright/_core.py
CHANGED
|
@@ -14,7 +14,7 @@ import functools
|
|
|
14
14
|
import logging
|
|
15
15
|
import re
|
|
16
16
|
from collections.abc import Callable
|
|
17
|
-
from dataclasses import dataclass
|
|
17
|
+
from dataclasses import dataclass
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from types import SimpleNamespace
|
|
20
20
|
from typing import TYPE_CHECKING
|
|
@@ -166,16 +166,17 @@ def _dim(p1, p2, side, distance, draft, **kwargs):
|
|
|
166
166
|
class Strip:
|
|
167
167
|
"""A one-dimensional annotation band adjacent to an orthographic view.
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
:
|
|
171
|
-
|
|
169
|
+
A plain geometry record: the collect-then-solve placers (ADR 0009) read its
|
|
170
|
+
bounds (:func:`~draftwright.annotations._common.strip_free_span`) and carve
|
|
171
|
+
around the placed annotations. The mutable ``allocate``/``peek`` cursor was
|
|
172
|
+
retired once every placer moved to the carve (#150).
|
|
172
173
|
|
|
173
174
|
Attributes:
|
|
174
175
|
anchor: Page coordinate of the view edge this strip starts from.
|
|
175
176
|
outer_limit: Page coordinate at which the strip ends (page margin,
|
|
176
177
|
neighbouring view, or title-block boundary).
|
|
177
|
-
direction: ``+1`` —
|
|
178
|
-
``-1`` —
|
|
178
|
+
direction: ``+1`` — stacks away from anchor (right/above);
|
|
179
|
+
``-1`` — stacks back toward smaller coords (left/below).
|
|
179
180
|
gap: Clearance between the view edge and the first annotation.
|
|
180
181
|
spacing: Clearance between successive annotations.
|
|
181
182
|
"""
|
|
@@ -185,56 +186,12 @@ class Strip:
|
|
|
185
186
|
direction: float = 1.0
|
|
186
187
|
gap: float = 8.0
|
|
187
188
|
spacing: float = 4.0
|
|
188
|
-
_cursor: float = field(init=False, compare=False, repr=False)
|
|
189
|
-
|
|
190
|
-
def __post_init__(self):
|
|
191
|
-
self._cursor = self.anchor + self.direction * self.gap
|
|
192
|
-
|
|
193
|
-
# ------------------------------------------------------------------
|
|
194
|
-
# Public API
|
|
195
189
|
|
|
196
190
|
@property
|
|
197
191
|
def available(self) -> float:
|
|
198
192
|
"""Total space available in this strip (mm)."""
|
|
199
193
|
return abs(self.outer_limit - self.anchor)
|
|
200
194
|
|
|
201
|
-
@property
|
|
202
|
-
def depth_used(self) -> float:
|
|
203
|
-
"""How far the cursor has advanced from the anchor (mm)."""
|
|
204
|
-
return abs(self._cursor - self.anchor)
|
|
205
|
-
|
|
206
|
-
def peek(self, size: float) -> float | None:
|
|
207
|
-
"""Return what ``allocate(size)`` would return without advancing the cursor."""
|
|
208
|
-
if self.direction == 1:
|
|
209
|
-
start = self._cursor
|
|
210
|
-
return start if (start + size) <= self.outer_limit else None
|
|
211
|
-
else:
|
|
212
|
-
end = self._cursor
|
|
213
|
-
return end if (end - size) >= self.outer_limit else None
|
|
214
|
-
|
|
215
|
-
def allocate(self, size: float) -> float | None:
|
|
216
|
-
"""Reserve *size* mm; return the near-edge page coordinate, or ``None`` if full.
|
|
217
|
-
|
|
218
|
-
The returned value is the page coordinate of the annotation's
|
|
219
|
-
dimension line (or leader elbow). Convert to a relative offset with::
|
|
220
|
-
|
|
221
|
-
distance = abs(page_coord - strip.anchor)
|
|
222
|
-
"""
|
|
223
|
-
if self.direction == 1:
|
|
224
|
-
start = self._cursor
|
|
225
|
-
end = start + size
|
|
226
|
-
if end > self.outer_limit:
|
|
227
|
-
return None
|
|
228
|
-
self._cursor = end + self.spacing
|
|
229
|
-
return start
|
|
230
|
-
else:
|
|
231
|
-
end = self._cursor
|
|
232
|
-
start = end - size
|
|
233
|
-
if start < self.outer_limit:
|
|
234
|
-
return None
|
|
235
|
-
self._cursor = start - self.spacing
|
|
236
|
-
return end
|
|
237
|
-
|
|
238
195
|
|
|
239
196
|
@dataclass
|
|
240
197
|
class ViewZones:
|
|
@@ -626,7 +583,11 @@ _PAGE_SIZES = {
|
|
|
626
583
|
"A0": (1189.0, 841.0),
|
|
627
584
|
}
|
|
628
585
|
|
|
629
|
-
|
|
586
|
+
# ISO 5455 scale series (1-2-5 decades). Enlargements + 1:1 first, then reductions
|
|
587
|
+
# down to 1:10000 so a very large part still gets a scale that FITS rather than an
|
|
588
|
+
# overflowing layout (#350). Ordered largest-scale-first for "least reduction first".
|
|
589
|
+
_SCALES = [10.0, 5.0, 2.0, 1.0]
|
|
590
|
+
_SCALES += [0.5, 0.2, 0.1, 0.05, 0.02, 0.01, 0.005, 0.002, 0.001, 0.0005, 0.0002, 0.0001]
|
|
630
591
|
|
|
631
592
|
# Horizontal page budget to reserve for the isometric view during scale
|
|
632
593
|
# selection and view placement, as a fraction of bbox_max * scale. This is a
|
|
@@ -677,6 +638,19 @@ _LADDER = [
|
|
|
677
638
|
(0.2, 841.0, 594.0, 150.0), # A1 1:5
|
|
678
639
|
(0.5, 1189.0, 841.0, 150.0), # A0 1:2
|
|
679
640
|
(0.2, 1189.0, 841.0, 150.0), # A0 1:5
|
|
641
|
+
# Past 1:5 keep reducing on A0 (the largest sheet) through the rest of the ISO 5455
|
|
642
|
+
# series, so a part too big for A0 1:5 still gets a scale that FITS rather than an
|
|
643
|
+
# overflowing layout (#350). A0 1:10000 holds anything up to ~8.4 m of drawn height.
|
|
644
|
+
(0.1, 1189.0, 841.0, 150.0), # A0 1:10
|
|
645
|
+
(0.05, 1189.0, 841.0, 150.0), # A0 1:20
|
|
646
|
+
(0.02, 1189.0, 841.0, 150.0), # A0 1:50
|
|
647
|
+
(0.01, 1189.0, 841.0, 150.0), # A0 1:100
|
|
648
|
+
(0.005, 1189.0, 841.0, 150.0), # A0 1:200
|
|
649
|
+
(0.002, 1189.0, 841.0, 150.0), # A0 1:500
|
|
650
|
+
(0.001, 1189.0, 841.0, 150.0), # A0 1:1000
|
|
651
|
+
(0.0005, 1189.0, 841.0, 150.0), # A0 1:2000
|
|
652
|
+
(0.0002, 1189.0, 841.0, 150.0), # A0 1:5000
|
|
653
|
+
(0.0001, 1189.0, 841.0, 150.0), # A0 1:10000
|
|
680
654
|
]
|
|
681
655
|
|
|
682
656
|
|
|
@@ -7,6 +7,50 @@ and an AABB overlap test (`_box_hits`). Bottom of the annotations DAG.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from build123d_drafting.helpers import Dimension, SafeDimension
|
|
14
|
+
|
|
15
|
+
from draftwright.layout import StripCandidate, plan_strip
|
|
16
|
+
|
|
17
|
+
_log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Escalation:
|
|
22
|
+
"""A first-class "could not place this here" signal (ADR 0009 Amendment 1, P5-strand-2).
|
|
23
|
+
|
|
24
|
+
Placers *collect* one of these into ``dwg._escalations`` at the point of failure —
|
|
25
|
+
instead of recording a stringly-typed ``*_dropped`` lint code and letting the escalators
|
|
26
|
+
grep for it — and one later resolver pass groups them by ``(view, feature-or-pattern)``,
|
|
27
|
+
picks a remedy per group (ISO pattern-grouped balloon / table / detail / drop), and emits
|
|
28
|
+
the ``*_dropped`` lint codes only for what stays unresolved (so coverage lint + the
|
|
29
|
+
cleanliness ratchet keep working). See the ADR / epic #351.
|
|
30
|
+
|
|
31
|
+
The hole callout/location placers emit these (#351 PR-2); the resolver in
|
|
32
|
+
``annotations/orchestrator.py`` (``_maybe_tabulate_holes``) consumes them, including
|
|
33
|
+
the ISO pattern-grouped balloon fallback for a dropped pattern callout (#351 PR-3).
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
kind: what could not be placed — ``"callout" | "location" | "slot" | "step" | "pmi"``.
|
|
37
|
+
view: the owning orthographic view (``None`` for drawing-level).
|
|
38
|
+
feature: reference to the IR feature / ``HoleRef`` / key it belongs to — carries the
|
|
39
|
+
pattern membership the resolver groups on (a ``"callout"`` escalation's
|
|
40
|
+
feature is the dropped group's ``PatternFeature`` when it is a
|
|
41
|
+
fully-surviving recognised pattern, else ``None``). Left untyped to keep
|
|
42
|
+
this module a leaf (no dependency on ``model.ir``).
|
|
43
|
+
reason: why placement failed — ``"strip_full" | "illegible" | "corridor_blocked" | "no_room"``.
|
|
44
|
+
remedies: ranked candidate remedies the resolver may pick, e.g.
|
|
45
|
+
``("group_balloon", "table", "detail", "drop")``. Empty = resolver's default ladder.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
kind: str
|
|
49
|
+
view: str | None
|
|
50
|
+
feature: object
|
|
51
|
+
reason: str
|
|
52
|
+
remedies: tuple[str, ...] = field(default_factory=tuple)
|
|
53
|
+
|
|
10
54
|
|
|
11
55
|
def _anno_box(o):
|
|
12
56
|
"""Page-space bbox ``(x0, y0, x1, y1)`` of an annotation — its text
|
|
@@ -38,6 +82,139 @@ def _occupied_boxes(dwg):
|
|
|
38
82
|
return boxes
|
|
39
83
|
|
|
40
84
|
|
|
85
|
+
def _geom_box(o):
|
|
86
|
+
"""Full rendered-geometry bbox ``(x0, y0, x1, y1)`` of an annotation — leader
|
|
87
|
+
shafts and arrow tips, dimension witness/extension lines, centrelines, hatch —
|
|
88
|
+
*not* just its label box. ``None`` if it does not bbox cleanly (logged at
|
|
89
|
+
debug: a silently dropped occupant is the wrong failure mode for an occupancy
|
|
90
|
+
model, so the omission is at least observable)."""
|
|
91
|
+
try:
|
|
92
|
+
b = o.bounding_box()
|
|
93
|
+
return (b.min.X, b.min.Y, b.max.X, b.max.Y)
|
|
94
|
+
except Exception as exc: # noqa: BLE001 — not every annotation bbox-es cleanly
|
|
95
|
+
_log.debug("strip occupancy: %s did not bbox (%s); omitted", type(o).__name__, exc)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
CROSSABLE_TYPES = frozenset({"Centerline", "CenterlineCircle", "CenterMark"})
|
|
100
|
+
"""Annotation types a *dimension* may legitimately cross (ISO 128): centre lines
|
|
101
|
+
and centre marks. A **leader**, by contrast, must avoid them (#305) — so this is a
|
|
102
|
+
per-consumer choice, passed as ``crossable`` to :func:`strip_obstacles`."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def strip_obstacles(dwg, view=None, *, crossable=()):
|
|
106
|
+
"""The COMPLETE occupancy for strip placement (ADR 0009): every placed
|
|
107
|
+
annotation's full rendered footprint, optionally restricted to *view*, minus
|
|
108
|
+
any annotation whose type name is in *crossable* (things this particular
|
|
109
|
+
consumer may legitimately overlap — e.g. a location dim crosses a centre line
|
|
110
|
+
but a leader does not; see :data:`CROSSABLE_TYPES`).
|
|
111
|
+
|
|
112
|
+
Unlike :func:`_occupied_boxes` (label boxes only, with bare centrelines
|
|
113
|
+
excluded), this captures the geometry a label box hides — leader shafts and
|
|
114
|
+
arrow tips, dimension witness/extension lines, centrelines, and the section
|
|
115
|
+
hatch. That hidden geometry is the 'invisible occupant' class behind the
|
|
116
|
+
recurring strip overlaps (#133/#225/#305): a placer that consults only label
|
|
117
|
+
boxes commits a callout into space a leader or extension line already crosses.
|
|
118
|
+
|
|
119
|
+
*view* scoping keeps this view's own annotations **and** drawing-level obstacles
|
|
120
|
+
that no orthographic view owns (the section hatch, title block, …) — those a
|
|
121
|
+
strip placer must still avoid — and drops only the *other* ortho views' blocks
|
|
122
|
+
(which compose-then-pack keeps disjoint, ADR 0004). The section hatch
|
|
123
|
+
(``view_of`` ``None``) is therefore present in every per-view query, the way
|
|
124
|
+
:func:`_occupied_boxes` special-cased it; restricting it to ``view=None`` would
|
|
125
|
+
re-open the very blind spot this closes.
|
|
126
|
+
|
|
127
|
+
Boxes are AABBs ``(x0, y0, x1, y1)`` (use with :func:`_box_hits`) — intentionally
|
|
128
|
+
conservative: a diagonal leader's box over-claims its empty triangle (ADR 0009
|
|
129
|
+
notes angled leaders weaken the bound), which only ever over-avoids, never
|
|
130
|
+
under-avoids.
|
|
131
|
+
|
|
132
|
+
The occupancy source for the collect-then-solve carve — every migrated renderer's
|
|
133
|
+
``place_strip_candidates`` call wires this in (#321/#150/P3)."""
|
|
134
|
+
boxes = []
|
|
135
|
+
for name, o in dwg.iter_annotations():
|
|
136
|
+
if view is not None:
|
|
137
|
+
owner = dwg.view_of(name)
|
|
138
|
+
if owner is not None and owner != view:
|
|
139
|
+
continue # owned by a different ortho view → its own (disjoint) block
|
|
140
|
+
if type(o).__name__ in crossable:
|
|
141
|
+
continue # this consumer may cross it (centre lines/marks for a dim)
|
|
142
|
+
bb = _geom_box(o)
|
|
143
|
+
if bb is not None:
|
|
144
|
+
boxes.append(bb)
|
|
145
|
+
return boxes
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def strip_free_span(strip):
|
|
149
|
+
"""``(lo, hi, inner)`` page coords of *strip* along its stacking axis, where
|
|
150
|
+
*inner* is the end nearest the view edge (the first tier a dim fills). Reads the
|
|
151
|
+
live ``outer_limit`` so an orchestrator reservation (#133) stays honoured. The
|
|
152
|
+
cursor-free counterpart of :meth:`Strip.allocate` — a collect-then-solve pass
|
|
153
|
+
(ADR 0009) reads these bounds and carves, rather than advancing a mutable cursor."""
|
|
154
|
+
near = strip.anchor + strip.direction * strip.gap
|
|
155
|
+
if strip.direction == 1:
|
|
156
|
+
return near, strip.outer_limit, near # lo, hi, inner (=lo)
|
|
157
|
+
return strip.outer_limit, near, near # lo, hi, inner (=hi)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def carve_free_segments(lo, hi, intervals, pad):
|
|
161
|
+
"""``[lo, hi]`` minus every obstacle interval inflated by *pad*, merged and
|
|
162
|
+
complemented — the option-(c) occupancy carve (ADR 0009 / #321). A dim is then
|
|
163
|
+
spaced only WITHIN a clear segment, so it can never overprint a placed occupant
|
|
164
|
+
(a leader shaft, the section hatch, a location-dim tier): the old per-tier
|
|
165
|
+
``allocate`` + post-hoc ``_box_hits`` retry becomes structural. *intervals* are
|
|
166
|
+
``(a, b)`` pairs along the strip's stacking axis (e.g. ``(box_y0, box_y1)`` for a
|
|
167
|
+
below strip). Returns a list of ``(seg_lo, seg_hi)`` free segments, lo→hi."""
|
|
168
|
+
blocked = []
|
|
169
|
+
for a0, b0 in intervals:
|
|
170
|
+
a1, b1 = max(lo, a0 - pad), min(hi, b0 + pad)
|
|
171
|
+
if b1 > a1:
|
|
172
|
+
blocked.append((a1, b1))
|
|
173
|
+
blocked.sort()
|
|
174
|
+
merged: list[list[float]] = []
|
|
175
|
+
for a0, b0 in blocked:
|
|
176
|
+
if merged and a0 <= merged[-1][1]:
|
|
177
|
+
merged[-1][1] = max(merged[-1][1], b0)
|
|
178
|
+
else:
|
|
179
|
+
merged.append([a0, b0])
|
|
180
|
+
free, cur = [], lo
|
|
181
|
+
for a0, b0 in merged:
|
|
182
|
+
if a0 > cur:
|
|
183
|
+
free.append((cur, a0))
|
|
184
|
+
cur = b0
|
|
185
|
+
if cur < hi:
|
|
186
|
+
free.append((cur, hi))
|
|
187
|
+
return free
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def corridor_blockers(dwg, view):
|
|
191
|
+
"""Boxes of annotations a dimension's *witness corridor* (the span from the view
|
|
192
|
+
edge out to its dim line) must not cross — leaders/callouts, the section hatch, the
|
|
193
|
+
title block: everything that is neither a datum-chained ``Dimension`` nor a
|
|
194
|
+
crossable centre line/mark (:data:`CROSSABLE_TYPES`).
|
|
195
|
+
|
|
196
|
+
:func:`strip_obstacles` carves the 1-D strip so a dim *line* clears every occupant,
|
|
197
|
+
but a right/below dim also occupies the 2-D corridor back to the view — and a bore
|
|
198
|
+
callout's leader sitting in that corridor is crossed however far out the line is
|
|
199
|
+
placed (the #133/#225/#305 leader class, in its witness-corridor form). A dim whose
|
|
200
|
+
full footprint hits one of these must route to another view, not overprint it (ISO
|
|
201
|
+
128). Sibling location/envelope dims are excluded: they chain off the shared datum
|
|
202
|
+
and legitimately share the corridor. View scoping mirrors :func:`strip_obstacles`
|
|
203
|
+
(this view's own annotations + drawing-level occupants that no ortho view owns)."""
|
|
204
|
+
boxes = []
|
|
205
|
+
for name, o in dwg.iter_annotations():
|
|
206
|
+
if view is not None:
|
|
207
|
+
owner = dwg.view_of(name)
|
|
208
|
+
if owner is not None and owner != view:
|
|
209
|
+
continue
|
|
210
|
+
if isinstance(o, (Dimension, SafeDimension)) or type(o).__name__ in CROSSABLE_TYPES:
|
|
211
|
+
continue # datum-chained dims share the corridor; centre lines are crossable
|
|
212
|
+
bb = _geom_box(o)
|
|
213
|
+
if bb is not None:
|
|
214
|
+
boxes.append(bb)
|
|
215
|
+
return boxes
|
|
216
|
+
|
|
217
|
+
|
|
41
218
|
def _box_hits(bb, boxes):
|
|
42
219
|
"""True when ``bb`` overlaps any box in ``boxes`` (strict AABB test). Slightly
|
|
43
220
|
more conservative than the within-view label lint (which tolerates a 0.5 mm
|
|
@@ -49,3 +226,315 @@ def _box_hits(bb, boxes):
|
|
|
49
226
|
if min(bb[2], c[2]) > max(bb[0], c[0]) and min(bb[3], c[3]) > max(bb[1], c[1]):
|
|
50
227
|
return True
|
|
51
228
|
return False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _segment_hits_box(p1, p2, box) -> bool:
|
|
232
|
+
"""True when line segment *p1*-*p2* intersects axis-aligned *box*
|
|
233
|
+
``(x0, y0, x1, y1)`` — the precise counterpart of :func:`_box_hits` for a
|
|
234
|
+
genuinely diagonal shaft (ADR 0009 P4/#318, #305: "a diagonal leader's box
|
|
235
|
+
over-claims its empty triangle"). Boxing an angled segment for a coarse
|
|
236
|
+
reject is correct and cheap; boxing it for the final accept/reject decision
|
|
237
|
+
over-avoids free space a real diagonal never crosses. Endpoint-in-box and
|
|
238
|
+
the 4 edge-crossing cases (a standard segment/AABB test).
|
|
239
|
+
|
|
240
|
+
The crossing test uses strict inequality deliberately, not an inclusive
|
|
241
|
+
``<= 0`` — an inclusive test also treats a segment merely COLLINEAR with
|
|
242
|
+
one of the box's (infinite) edge lines as a hit, regardless of whether it
|
|
243
|
+
is anywhere near the box along that line (verified: a vertical segment at
|
|
244
|
+
``x == box.x0`` but far outside ``[y0, y1]`` false-hits under `<=`). That
|
|
245
|
+
false-positive class is common (any axis-aligned shaft sharing an X or Y
|
|
246
|
+
coordinate with an edge), unlike the strict form's own known gap — a
|
|
247
|
+
segment passing exactly through two opposite corners is a measure-zero
|
|
248
|
+
event for the continuous, non-integer leader positions this computes over
|
|
249
|
+
(review finding, #351 P5 strand 3: tried the inclusive form, reverted)."""
|
|
250
|
+
x0, y0, x1, y1 = box
|
|
251
|
+
|
|
252
|
+
def _inside(p):
|
|
253
|
+
return x0 <= p[0] <= x1 and y0 <= p[1] <= y1
|
|
254
|
+
|
|
255
|
+
if _inside(p1) or _inside(p2):
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
def _cross(o, a, b):
|
|
259
|
+
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
|
260
|
+
|
|
261
|
+
def _seg_seg(a1, a2, b1, b2):
|
|
262
|
+
d1, d2 = _cross(b1, b2, a1), _cross(b1, b2, a2)
|
|
263
|
+
d3, d4 = _cross(a1, a2, b1), _cross(a1, a2, b2)
|
|
264
|
+
return ((d1 > 0 and d2 < 0) or (d1 < 0 and d2 > 0)) and (
|
|
265
|
+
(d3 > 0 and d4 < 0) or (d3 < 0 and d4 > 0)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
corners = ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
|
|
269
|
+
return any(_seg_seg(p1, p2, corners[i], corners[(i + 1) % 4]) for i in range(4))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class CorridorCandidate:
|
|
274
|
+
"""One datum-referenced linear dim collected for a shared corridor's single solve
|
|
275
|
+
(ADR 0009 end state, #345/#346). Multiple render passes (`render_locations`,
|
|
276
|
+
`render_slots`) feed the SAME above-view strip; committing per-pass interleaves the
|
|
277
|
+
dims and cannot dedup coincident spans. Each pass instead registers a candidate here;
|
|
278
|
+
one :func:`solve_corridor` per strip dedups, orders, and places the whole set.
|
|
279
|
+
|
|
280
|
+
Attributes:
|
|
281
|
+
name/build: the ``(name, pos->Dimension)`` pair :func:`place_strip_candidates`
|
|
282
|
+
consumes — unchanged.
|
|
283
|
+
order: sort key placing the candidate in the corridor ladder. Location dims
|
|
284
|
+
key on datum distance (the monotonic ISO ladder); size dims form a separate
|
|
285
|
+
contiguous run so a slot length never lands mid-ladder (#346).
|
|
286
|
+
dedup: coincidence key ``(view, meas-origin, meas-endpoint)`` on the MEASURED
|
|
287
|
+
axis, or ``None`` to never dedup (size dims). Two candidates with equal keys are
|
|
288
|
+
the same physical dimension; the higher-``precedence`` one survives (#345).
|
|
289
|
+
precedence: dedup survivor rank — a hole *location* dim (feeds coverage/table
|
|
290
|
+
escalation) outranks a coincident slot *position* line.
|
|
291
|
+
on_place/on_drop: the pass's own post-placement bookkeeping — coverage
|
|
292
|
+
registration / drop lint + `Escalation`, or a slot's below-side fallthrough.
|
|
293
|
+
force: policy-B force-keep after the corridor-respecting pass (locations have
|
|
294
|
+
no alternate view); size/position slot dims fall through instead (``on_drop``).
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
name: str
|
|
298
|
+
build: object
|
|
299
|
+
order: tuple
|
|
300
|
+
on_place: object
|
|
301
|
+
on_drop: object
|
|
302
|
+
dedup: tuple | None = None
|
|
303
|
+
precedence: int = 0
|
|
304
|
+
force: bool = False
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def solve_corridor(dwg, strip, view, axis, cands, tier):
|
|
308
|
+
"""One collect-then-solve over every :class:`CorridorCandidate` a shared strip
|
|
309
|
+
accumulated across passes (ADR 0009 end state). Dedup → order → one non-force
|
|
310
|
+
:func:`place_strip_candidates` pass → a force pass for the force-eligible leftovers →
|
|
311
|
+
dispatch each candidate's ``on_place``/``on_drop``. This is what removes the duplicate
|
|
312
|
+
span (#345) and the interleaved ladder (#346) by construction: a single solve sees the
|
|
313
|
+
full set, so coincident spans collapse and the order is one monotonic chain."""
|
|
314
|
+
if not cands:
|
|
315
|
+
return
|
|
316
|
+
# Dedup: keep the highest-precedence candidate per coincidence key (tie-break on name,
|
|
317
|
+
# deterministic — ADR 0001). A displaced duplicate is a *loser*: while its winner is
|
|
318
|
+
# drawn it is silently dropped (never starved, so firing its pass's drop lint would be a
|
|
319
|
+
# false report) — but if the winner itself fails to place, the top loser is promoted so
|
|
320
|
+
# the measurement still gets its pass's fallthrough/drop handling (no silent vanish).
|
|
321
|
+
winners: dict = {}
|
|
322
|
+
for c in cands:
|
|
323
|
+
if c.dedup is None:
|
|
324
|
+
continue
|
|
325
|
+
prev = winners.get(c.dedup)
|
|
326
|
+
# Winner: highest precedence, ties broken by the lexicographically smaller name.
|
|
327
|
+
if (
|
|
328
|
+
prev is None
|
|
329
|
+
or c.precedence > prev.precedence
|
|
330
|
+
or (c.precedence == prev.precedence and c.name < prev.name)
|
|
331
|
+
):
|
|
332
|
+
winners[c.dedup] = c
|
|
333
|
+
kept = [c for c in cands if c.dedup is None or winners.get(c.dedup) is c]
|
|
334
|
+
losers: dict = {} # dedup key → its displaced candidates (highest precedence first)
|
|
335
|
+
for c in cands:
|
|
336
|
+
if c.dedup is not None and winners.get(c.dedup) is not c:
|
|
337
|
+
losers.setdefault(c.dedup, []).append(c)
|
|
338
|
+
for group in losers.values():
|
|
339
|
+
group.sort(key=lambda c: (-c.precedence, c.name))
|
|
340
|
+
kept.sort(key=lambda c: c.order)
|
|
341
|
+
|
|
342
|
+
def _promote_losers(dropped_winner):
|
|
343
|
+
# The winner did not place → hand its measurement to the best surviving loser
|
|
344
|
+
# (e.g. the slot position's below-strip fallthrough), then stop.
|
|
345
|
+
for loser in losers.get(dropped_winner.dedup, ()):
|
|
346
|
+
loser.on_drop(loser.name)
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
if strip is None: # no such strip on this drawing — every candidate drops
|
|
350
|
+
for c in kept:
|
|
351
|
+
c.on_drop(c.name)
|
|
352
|
+
if c.dedup is not None:
|
|
353
|
+
_promote_losers(c)
|
|
354
|
+
return
|
|
355
|
+
pairs = [(c.name, c.build) for c in kept]
|
|
356
|
+
left = {n for n, _ in place_strip_candidates(dwg, strip, view, axis, pairs, tier)}
|
|
357
|
+
force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
|
|
358
|
+
still = (
|
|
359
|
+
{
|
|
360
|
+
n
|
|
361
|
+
for n, _ in place_strip_candidates(
|
|
362
|
+
dwg, strip, view, axis, force_pairs, tier, force=True
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
if force_pairs
|
|
366
|
+
else set()
|
|
367
|
+
)
|
|
368
|
+
for c in kept:
|
|
369
|
+
placed = c.name not in left or (c.force and c.name not in still)
|
|
370
|
+
if placed:
|
|
371
|
+
c.on_place(c.name) # placed in the corridor-respecting pass or the force pass
|
|
372
|
+
else:
|
|
373
|
+
c.on_drop(c.name) # dropped / not force-kept — the pass's drop handler runs
|
|
374
|
+
if c.dedup is not None: # a deduped winner failed → promote its top loser
|
|
375
|
+
_promote_losers(c)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def register_corridor(dwg, key, strip, view, axis, tier, cand):
|
|
379
|
+
"""Queue a :class:`CorridorCandidate` under a shared corridor *key* so one
|
|
380
|
+
:func:`drain_corridors` places the whole cross-pass set together (ADR 0009 end state).
|
|
381
|
+
The first registration for a key fixes its ``(strip, view, axis, tier)``."""
|
|
382
|
+
b = dwg._corridor_batch.setdefault(
|
|
383
|
+
key, {"strip": strip, "view": view, "axis": axis, "tier": tier, "cands": []}
|
|
384
|
+
)
|
|
385
|
+
b["cands"].append(cand)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def drain_corridors(dwg):
|
|
389
|
+
"""Solve every registered corridor (one :func:`solve_corridor` per strip), then clear
|
|
390
|
+
the batch. Called once, after all corridor-feeding passes have registered."""
|
|
391
|
+
for b in dwg._corridor_batch.values():
|
|
392
|
+
solve_corridor(dwg, b["strip"], b["view"], b["axis"], b["cands"], b["tier"])
|
|
393
|
+
dwg._corridor_batch = {}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False):
|
|
397
|
+
"""Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
|
|
398
|
+
The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
|
|
399
|
+
P3): each candidate in *cands* — an ``(name, build(pos)->dim)`` pair — is spaced by
|
|
400
|
+
one :func:`plan_strip` solve per free segment of the CARVED strip (`strip` carved
|
|
401
|
+
around :func:`strip_obstacles`), replacing the per-dim ``allocate`` + ``_box_hits``
|
|
402
|
+
tier-retry. *tier* is the label height (sets the inter-dim gap ``tier + spacing``).
|
|
403
|
+
|
|
404
|
+
Occupancy is THIS view's own placed annotations plus the drawing-level obstacles no
|
|
405
|
+
ortho view owns (the section hatch), recomputed per call so a dim placed earlier in
|
|
406
|
+
the pass is avoided; other ortho views are disjoint (ADR 0004) and excluded so their
|
|
407
|
+
rows never over-carve this strip. This makes the old post-hoc collision retry
|
|
408
|
+
structural: a dim can never land on a bore-callout leader shaft the label-only
|
|
409
|
+
occupancy missed (#133/#225/#305).
|
|
410
|
+
|
|
411
|
+
A right/below dim also occupies the 2-D corridor back to the view edge, which the
|
|
412
|
+
1-D strip carve cannot represent: a leader in that corridor is crossed no matter how
|
|
413
|
+
far out the dim line lands. By default such a placement is rejected so the caller can
|
|
414
|
+
route the dim to the other view (its disjoint block cannot cross this leader).
|
|
415
|
+
``force=True`` skips that corridor check — the caller's last resort when no view took
|
|
416
|
+
the dim cleanly: keep it on its natural view and accept the (same-feature) leader
|
|
417
|
+
crossing rather than drop a real dimension (policy B). Candidates that find no strip
|
|
418
|
+
tier AT ALL are still returned (a physically full strip — the caller records the
|
|
419
|
+
genuine drop)."""
|
|
420
|
+
if strip is None or not cands:
|
|
421
|
+
return list(cands)
|
|
422
|
+
lo, hi, inner = strip_free_span(strip)
|
|
423
|
+
# Reserve the outermost label's height at the strip boundary. plan_strip bounds the
|
|
424
|
+
# dim-LINE position, but the label extends `tier` OUTWARD from it — so without this
|
|
425
|
+
# the last tier's label overshoots outer_limit (into the iso view / page margin),
|
|
426
|
+
# unlike the old Strip.allocate which checked `start + tier <= outer_limit` (#338
|
|
427
|
+
# review). The strip edge is not an obstacle (obstacles carry their own footprint +
|
|
428
|
+
# pad), so only the boundary needs it; obstacle-bounded segments are unaffected.
|
|
429
|
+
if inner == lo:
|
|
430
|
+
hi -= tier
|
|
431
|
+
else:
|
|
432
|
+
lo += tier
|
|
433
|
+
idx = 1 if axis == "y" else 0
|
|
434
|
+
perp = 0 if axis == "y" else 1 # the axis the dims do NOT stack along
|
|
435
|
+
pad = tier + strip.spacing # min separation between stacked dim lines
|
|
436
|
+
# Perpendicular band of these candidates. The 1-D carve projects obstacles onto the
|
|
437
|
+
# stacking axis only, so an obstacle on ANOTHER strip of this view — disjoint in the
|
|
438
|
+
# perpendicular axis, never actually touching — would falsely block (e.g. the overall
|
|
439
|
+
# width dim below the view blocking a slot-width dim on the right strip). Filter such
|
|
440
|
+
# obstacles out first. The perpendicular extent is independent of the tier position,
|
|
441
|
+
# so a single probe build per candidate suffices; the corridor check below already
|
|
442
|
+
# uses the full 2-D box, so it needs no such filter.
|
|
443
|
+
pbands = [
|
|
444
|
+
(b[perp], b[perp + 2]) for _n, build in cands if (b := _geom_box(build(lo))) is not None
|
|
445
|
+
]
|
|
446
|
+
occupied = strip_obstacles(dwg, view=view, crossable=CROSSABLE_TYPES)
|
|
447
|
+
if pbands:
|
|
448
|
+
band_lo, band_hi = min(p[0] for p in pbands), max(p[1] for p in pbands)
|
|
449
|
+
occupied = [b for b in occupied if b[perp] < band_hi and b[perp + 2] > band_lo]
|
|
450
|
+
blockers = () if force else corridor_blockers(dwg, view)
|
|
451
|
+
segs = carve_free_segments(lo, hi, [(b[idx], b[idx + 2]) for b in occupied], pad)
|
|
452
|
+
# Fill innermost-first (nearest the view), matching the old cursor's stack order.
|
|
453
|
+
segs.sort(key=lambda s: abs((s[0] if inner == lo else s[1]) - inner))
|
|
454
|
+
todo = list(cands)
|
|
455
|
+
for seg_lo, seg_hi in segs:
|
|
456
|
+
if not todo:
|
|
457
|
+
break
|
|
458
|
+
cap = int((seg_hi - seg_lo) / pad) + 1
|
|
459
|
+
take, todo = todo[:cap], todo[cap:]
|
|
460
|
+
nat = seg_lo if inner == lo else seg_hi
|
|
461
|
+
anch = (0.0, nat) if axis == "y" else (nat, 0.0)
|
|
462
|
+
# Keys order the tiers so the FIRST candidate lands on the inner tier: for an
|
|
463
|
+
# inner=lo strip that is the lowest position (ascending keys); for a below strip
|
|
464
|
+
# (inner=hi) it is the highest, so the keys reverse.
|
|
465
|
+
triples = [
|
|
466
|
+
(
|
|
467
|
+
StripCandidate(
|
|
468
|
+
f"{(k if inner == lo else len(take) - 1 - k):04d}", anch, (tier, tier)
|
|
469
|
+
),
|
|
470
|
+
nb,
|
|
471
|
+
)
|
|
472
|
+
for k, nb in enumerate(take)
|
|
473
|
+
]
|
|
474
|
+
res = plan_strip([sc for sc, _ in triples], seg_lo, seg_hi, pad, axis=axis)
|
|
475
|
+
for sc, (name, build) in triples:
|
|
476
|
+
pos = res.placed.get(sc.key)
|
|
477
|
+
if pos is None: # segment over its estimated capacity (shouldn't occur)
|
|
478
|
+
todo.append((name, build))
|
|
479
|
+
continue
|
|
480
|
+
dim = build(pos)
|
|
481
|
+
if not force and _box_hits(_geom_box(dim), blockers): # corridor crosses a leader
|
|
482
|
+
todo.append((name, build))
|
|
483
|
+
continue
|
|
484
|
+
dwg.add(dim, name, view=view)
|
|
485
|
+
return todo
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def carve_free_position(dwg, strip, view, axis, tier, perp_span, *, outermost=False):
|
|
489
|
+
"""The single free tier POSITION on *strip* at which a dim of height *tier* spanning
|
|
490
|
+
*perp_span* ``(lo, hi)`` on the perpendicular axis clears every placed obstacle in
|
|
491
|
+
*view* — the innermost (nearest the view) tier by default, or the outermost fitting
|
|
492
|
+
one when *outermost*. Returns the dim-line page coord, or None if the strip is full.
|
|
493
|
+
|
|
494
|
+
The position-returning counterpart of :func:`place_strip_candidates` (which batches,
|
|
495
|
+
builds and adds): a caller that needs a dim's assigned position BEFORE building the
|
|
496
|
+
next — the height-ladder leapfrog chain, where each step dim's witness base is the
|
|
497
|
+
previous dim's line — uses this. Same carve: outer-label tier reservation, the
|
|
498
|
+
perpendicular-band filter (*perp_span* drops obstacles disjoint from this dim's own
|
|
499
|
+
perpendicular extent), and innermost-first fill.
|
|
500
|
+
|
|
501
|
+
**No corridor check, by construction — not just omission.** This avoids obstacle
|
|
502
|
+
*tiers* on the strip but does not reject a position whose witness *corridor* (feature
|
|
503
|
+
→ dim line, across *perp_span*) crosses a leader/callout. Crucially, a single-position
|
|
504
|
+
return *cannot* fix a corridor crossing by choosing a different tier: every tier on
|
|
505
|
+
one side shares that corridor, and a farther tier's corridor is a **superset** of a
|
|
506
|
+
nearer one's, so the innermost free tier this already returns has the shortest
|
|
507
|
+
corridor and the fewest crossings — moving outward only adds crossings. Corridor
|
|
508
|
+
avoidance is therefore inherently a **relocation** problem (reject this position →
|
|
509
|
+
place on another view/side), which is :func:`place_strip_candidates`' job and out of
|
|
510
|
+
scope for a position return. Per caller: the height-ladder chain has no alternate
|
|
511
|
+
view (correct to omit); public ``Drawing.place_dim`` takes the view AND side from the
|
|
512
|
+
caller, so it cannot relocate; the PMI dim helpers already fall through sides
|
|
513
|
+
(``_try_above(...) or _try_below(...)``) and are where a corridor-reject would go if
|
|
514
|
+
ever wanted. Left as a documented known-limitation — the crossing is unobserved on
|
|
515
|
+
the corpus (the cleanliness ratchet would catch it)."""
|
|
516
|
+
if strip is None:
|
|
517
|
+
return None
|
|
518
|
+
lo, hi, inner = strip_free_span(strip)
|
|
519
|
+
idx = 1 if axis == "y" else 0
|
|
520
|
+
perp = 0 if axis == "y" else 1
|
|
521
|
+
pad = tier + strip.spacing
|
|
522
|
+
band_lo, band_hi = perp_span
|
|
523
|
+
occ = [
|
|
524
|
+
b
|
|
525
|
+
for b in strip_obstacles(dwg, view=view, crossable=CROSSABLE_TYPES)
|
|
526
|
+
if b[perp] < band_hi and b[perp + 2] > band_lo
|
|
527
|
+
]
|
|
528
|
+
segs = carve_free_segments(lo, hi, [(b[idx], b[idx + 2]) for b in occ], pad)
|
|
529
|
+
# A segment holds the dim iff it is at least `tier` wide (the label height). This IS
|
|
530
|
+
# the outer-label reservation — inclusive at the boundary (a strip exactly `gap+tier`
|
|
531
|
+
# wide fits one dim, as the old `allocate` did) — so it must NOT be combined with a
|
|
532
|
+
# separate `hi -= tier` pull-in, which would double-reserve and drop that dim.
|
|
533
|
+
fitting = [s for s in segs if s[1] - s[0] >= tier - 1e-9]
|
|
534
|
+
if not fitting:
|
|
535
|
+
return None
|
|
536
|
+
if inner == lo: # inner edge = seg lo; outermost = the segment reaching furthest out
|
|
537
|
+
seg = max(fitting, key=lambda s: s[1]) if outermost else min(fitting, key=lambda s: s[0])
|
|
538
|
+
return seg[0]
|
|
539
|
+
seg = min(fitting, key=lambda s: s[0]) if outermost else max(fitting, key=lambda s: s[1])
|
|
540
|
+
return seg[1]
|