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 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, field
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
- Annotations are stacked outward from the view edge by calling
170
- :meth:`allocate`. The cursor starts at ``anchor + direction * gap`` and
171
- advances after each successful allocation.
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`` — cursor moves away from anchor (right/above);
178
- ``-1`` — cursor retreats from anchor (left/below).
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
- _SCALES = [10.0, 5.0, 2.0, 1.0, 0.5, 0.2]
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]