draftwright 0.3.1__tar.gz → 0.3.2__tar.gz
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-0.3.1 → draftwright-0.3.2}/CHANGELOG.md +45 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/PKG-INFO +2 -2
- {draftwright-0.3.1 → draftwright-0.3.2}/pyproject.toml +2 -2
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/_core.py +29 -16
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/analysis.py +5 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/_common.py +228 -17
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/from_model.py +170 -9
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/holes.py +23 -5
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/builder.py +8 -3
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/drawing.py +19 -1
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/export.py +27 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/structural.py +102 -35
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/fillets.py +23 -11
- draftwright-0.3.2/tests/refactor_golden/grid_plate.json +474 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/holed_slot.json +2 -2
- draftwright-0.3.2/tests/refactor_golden/scattered_plate.json +649 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_carve_free_position_callers.py +7 -1
- draftwright-0.3.2/tests/test_detect_once.py +81 -0
- draftwright-0.3.2/tests/test_export_dxf_zoom.py +65 -0
- draftwright-0.3.2/tests/test_fillets_adjacency.py +53 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_layout_cleanliness.py +8 -1
- draftwright-0.3.2/tests/test_lint_box_cache.py +117 -0
- draftwright-0.3.2/tests/test_pitch_dim_footprint.py +157 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_refactor_golden.py +40 -1
- {draftwright-0.3.1 → draftwright-0.3.2}/.gitignore +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/LICENSE +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/README.md +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/skills/SKILL.md +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/__init__.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/_geometry.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotate.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/__init__.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/orchestrator.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/sections.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/cli.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/compose.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fits.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/__init__.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/intents.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/layout.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/__init__.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/coverage.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/issues.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/suggest.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/make_drawing.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/__init__.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/declare.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/detect.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/ir.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/planner.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/pmi.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/projection.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/__init__.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/_features.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/_record.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/chamfers.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/countersinks.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/flats.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/grooves.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/levels.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/plates.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/slots.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/turned.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/registry.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/repair.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/score.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/sheet.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/sheet_dsl.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/sheet_emit.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/_kernel.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/_layout_sig.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/bracket_section.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/centered_rebate.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/chamfered.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/filleted.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/flange_dense.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/grooved_shaft.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/hex_bar.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/pocketed.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/prismatic_ladder.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/side_drilled.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/turned_stepped.json +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_declare.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_drawing_encapsulation.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_e2e_slice.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_e2e_standards.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_fits.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_gdt_placement.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_import_boundaries.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_layout.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_layout_hypothesis.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_layout_property.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_lint_reconciliation.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_lint_structural.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_linting.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_make_drawing.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_object_aspects.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_part_model.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_pmi.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_private_test_imports.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_recogniser_contract.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_recognition.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_registry.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_render_seam.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_score.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_emit.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_gdt.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_notes.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_of.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_tables.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_strip_layout.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_tolerances.py +0 -0
- {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_turned_steps.py +0 -0
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.3.2 — 2026-07-18
|
|
4
|
+
|
|
5
|
+
The **performance release**: the #602 perf loop (six PRs) plus the
|
|
6
|
+
build123d-drafting-helpers 0.14 upgrade. A dimension-dense drawing now builds
|
|
7
|
+
roughly **14× faster** than v0.3.1 (grid-plate benchmark 40 s → 2.8 s; NIST
|
|
8
|
+
CTC-02 ~107 s → well under half); the full test tier dropped ~14 min → ~3.5 min.
|
|
9
|
+
|
|
10
|
+
### Performance
|
|
11
|
+
|
|
12
|
+
- **Placement measures, then builds** (#678, #679): corridor/strip candidates are
|
|
13
|
+
evaluated on analytical footprints (`dim_footprint`) or a single probe box; an
|
|
14
|
+
accepted candidate builds its OCC geometry exactly once and re-validates the
|
|
15
|
+
real box. Dimension constructions on the scattered-plate benchmark: 59 → 21
|
|
16
|
+
(= the placed count).
|
|
17
|
+
- **Detect once** (#682): the sizing model `_analyse` builds pre-scale is stored
|
|
18
|
+
on `Analysis.model` and reused as the render model — restoring ADR 0008
|
|
19
|
+
Amendment 5 ("one inventory, detected once"). ~13 s off CTC-02.
|
|
20
|
+
- **Fillet recognition de-quadratised** (#683): edge→faces adjacency map replaces
|
|
21
|
+
the O(faces²×edges²) `IsSame` sweep (3.7 M calls on CTC-02); 10.8 s → 4.6 s,
|
|
22
|
+
byte-identical records.
|
|
23
|
+
- **Lint bounding boxes memoised** (#681): `Drawing.lint()` 0.88 s → 0.07 s per
|
|
24
|
+
call (identity+location-token-checked cache beside the #143 view-edge cache).
|
|
25
|
+
- **DXF viewport from the known page window** (#680): skips `ezdxf.zoom.extents`'
|
|
26
|
+
per-entity walk; ~2.4 s per DXF export.
|
|
27
|
+
- **helpers 0.14** (#684): boolean-free dimension ink (upstream #177, filed and
|
|
28
|
+
fixed same-day) — ~25 ms per `Dimension`, was ~234 ms.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Tight-span dimensions now render honestly** (helpers 0.14): ISO outside
|
|
33
|
+
arrows where 0.13 silently degraded to a bare line. Ink extents grow on such
|
|
34
|
+
dims; placement models the flip analytically. Two placement-golden fixtures
|
|
35
|
+
re-blessed (ink extent only).
|
|
36
|
+
- Diameter-row leader labels space by **measured text width** and repair
|
|
37
|
+
helpers' shelf-flip collisions (crowded rows only; ordinary rows unchanged).
|
|
38
|
+
- A plate-thickness dim whose home strip corner is structurally contested falls
|
|
39
|
+
through to the opposite strip / side view instead of dropping (#684; the
|
|
40
|
+
underlying one-box-occupancy artifact is tracked as #685).
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `recognise_fillets` neighbour tie-break is order-independent (#683 review).
|
|
45
|
+
- Lint's annotation-box cache prunes departed objects and detects in-place
|
|
46
|
+
relocation (#681 review).
|
|
47
|
+
|
|
3
48
|
## v0.3.1 — 2026-07-17
|
|
4
49
|
|
|
5
50
|
Also lands the **#635 consolidation epic** (P1–P6, all six children closed): the render
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: draftwright
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Automated technical-drawing generation for build123d
|
|
5
5
|
Project-URL: Homepage, https://github.com/pzfreo/draftwright
|
|
6
6
|
Project-URL: Repository, https://github.com/pzfreo/draftwright
|
|
@@ -682,7 +682,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
682
682
|
Classifier: Programming Language :: Python :: 3.14
|
|
683
683
|
Classifier: Topic :: Scientific/Engineering
|
|
684
684
|
Requires-Python: <3.15,>=3.10
|
|
685
|
-
Requires-Dist: build123d-drafting-helpers>=0.
|
|
685
|
+
Requires-Dist: build123d-drafting-helpers>=0.14.0
|
|
686
686
|
Requires-Dist: build123d<0.11,>=0.9.0; python_full_version < '3.13'
|
|
687
687
|
Requires-Dist: build123d<0.12,>=0.11; python_full_version >= '3.13'
|
|
688
688
|
Requires-Dist: numpy>=1.24
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "draftwright"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Automated technical-drawing generation for build123d"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -17,7 +17,7 @@ dependencies = [
|
|
|
17
17
|
# 3.13+. Mirrors pzfreo/build123d-mcp's dependency handling.
|
|
18
18
|
"build123d>=0.9.0,<0.11 ; python_full_version < '3.13'",
|
|
19
19
|
"build123d>=0.11,<0.12 ; python_full_version >= '3.13'",
|
|
20
|
-
"build123d-drafting-helpers>=0.
|
|
20
|
+
"build123d-drafting-helpers>=0.14.0",
|
|
21
21
|
"numpy>=1.24",
|
|
22
22
|
# PNG raster export (SVG→PDF→PNG). Both permissively licensed (Pillow HPND; pypdfium2
|
|
23
23
|
# Apache-2.0 wrapping Google PDFium BSD-3) and ship pre-built wheels with NO native system
|
|
@@ -107,8 +107,10 @@ _QUOTED_RE = re.compile(r"'([^']*)'")
|
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
@functools.lru_cache(maxsize=512)
|
|
110
|
-
def
|
|
111
|
-
|
|
110
|
+
def _text_size(
|
|
111
|
+
text: str, font_size: float, font_path: str | None = PLEX_MONO, font: str = "Arial"
|
|
112
|
+
) -> tuple[float, float]:
|
|
113
|
+
"""Measured rendered (width, height) (page-mm) of *text* at *font_size*.
|
|
112
114
|
|
|
113
115
|
Uses build123d's ``Text`` — the same primitive ``Dimension``/``HoleCallout``
|
|
114
116
|
stroke their labels with — so callout-width estimates use real glyph metrics
|
|
@@ -116,22 +118,27 @@ def _text_width(text: str, font_size: float, font_path: str = PLEX_MONO) -> floa
|
|
|
116
118
|
(``font_path``), not a system font *name*: name resolution substitutes a
|
|
117
119
|
different font on Linux, which makes this estimate — and the layout it feeds —
|
|
118
120
|
platform-variant (#149). The default is the same face the annotations render
|
|
119
|
-
with, so estimate and render agree.
|
|
120
|
-
|
|
121
|
+
with, so estimate and render agree. *font* only matters when a caller opts out
|
|
122
|
+
of path-pinning (``font_path=None``) — then the *name* resolves through the OS
|
|
123
|
+
font stack, exactly as the renderer would. Cached because the same numeric
|
|
124
|
+
labels recur across holes and the rasterisation is the costly part.
|
|
121
125
|
"""
|
|
122
126
|
if not text:
|
|
123
|
-
return 0.0
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
return (0.0, 0.0)
|
|
128
|
+
bb = Text(
|
|
129
|
+
txt=text,
|
|
130
|
+
font_size=font_size,
|
|
131
|
+
font=font,
|
|
132
|
+
font_path=font_path,
|
|
133
|
+
align=(Align.CENTER, Align.CENTER),
|
|
134
|
+
mode=Mode.PRIVATE,
|
|
135
|
+
).bounding_box()
|
|
136
|
+
return (bb.size.X, bb.size.Y)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _text_width(text: str, font_size: float, font_path: str = PLEX_MONO) -> float:
|
|
140
|
+
"""Measured rendered width (page-mm) of *text* — see :func:`_text_size`."""
|
|
141
|
+
return _text_size(text, font_size, font_path)[0]
|
|
135
142
|
|
|
136
143
|
|
|
137
144
|
# Cheap mean glyph advance as a fraction of the em (font size), for label-width ESTIMATES in
|
|
@@ -556,6 +563,12 @@ class Analysis:
|
|
|
556
563
|
out: str
|
|
557
564
|
pmi: list
|
|
558
565
|
pmi_mode: str
|
|
566
|
+
# The PartModel built by _analyse's pre-scale sizing pass (#584 WP1 A) — stored so
|
|
567
|
+
# the render path reuses it instead of re-running the detectors (ADR 0008 Amdt 5:
|
|
568
|
+
# one inventory, detected once; #602). Typed `object` because model/ sits above
|
|
569
|
+
# _core in the DAG. None when the caller declared a model (ADR 0011) or on a
|
|
570
|
+
# manually-built Analysis — consumers fall back to build_model(a).
|
|
571
|
+
model: object | None = None
|
|
559
572
|
|
|
560
573
|
|
|
561
574
|
_greedy_strip_ys = _greedy_strip_1d
|
|
@@ -772,4 +772,9 @@ def _analyse(
|
|
|
772
772
|
out=out,
|
|
773
773
|
pmi=pmi_records,
|
|
774
774
|
pmi_mode=pmi,
|
|
775
|
+
# The sizing model IS the render model when detection ran (identical inputs by
|
|
776
|
+
# construction — #584 WP1 A); store it so the pipeline never detects twice
|
|
777
|
+
# (ADR 0008 Amdt 5, #602). A declared model (layout_model) is NOT stored: the
|
|
778
|
+
# builder coerces + decorates the caller's model itself.
|
|
779
|
+
model=sizing_model if layout_model is None else None,
|
|
775
780
|
)
|
|
@@ -8,11 +8,13 @@ and an AABB overlap test (`_box_hits`). Bottom of the annotations DAG.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
|
+
import math
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
14
|
-
from build123d_drafting.helpers import Dimension, SafeDimension
|
|
15
|
+
from build123d_drafting.helpers import DEFAULT_FONT_PATH, Dimension, SafeDimension
|
|
15
16
|
|
|
17
|
+
from draftwright._core import _text_size
|
|
16
18
|
from draftwright.layout import StripCandidate, plan_strip
|
|
17
19
|
from draftwright.linting.issues import LintIssue
|
|
18
20
|
from draftwright.linting.structural import _centerline_extent
|
|
@@ -100,6 +102,84 @@ def _geom_box(o):
|
|
|
100
102
|
return None
|
|
101
103
|
|
|
102
104
|
|
|
105
|
+
def dim_footprint(p1, p2, side, distance, draft, label):
|
|
106
|
+
"""Analytical page-mm AABB ``(x0, y0, x1, y1)`` of the :class:`Dimension` that
|
|
107
|
+
``_dim(p1, p2, side, distance, draft, label=label)`` would build — WITHOUT
|
|
108
|
+
constructing any OCC geometry (#602: a rejected candidate must not pay the
|
|
109
|
+
rendering cost).
|
|
110
|
+
|
|
111
|
+
Mirrors ``helpers.Dimension`` / build123d ``ExtensionLine``: each extension line
|
|
112
|
+
is the object→dimension-line segment *translated* ``extension_gap`` along itself,
|
|
113
|
+
so it spans ``p + side·gap`` → ``p + side·(distance + gap)`` — starting ``gap``
|
|
114
|
+
clear of the object and overshooting the dimension line by the same ``gap``. The
|
|
115
|
+
measured label is centred on the line's midpoint with its extents swapped for a
|
|
116
|
+
vertical measured segment (the label is rotated); everything strokes at
|
|
117
|
+
``line_width``. With inside arrows the heads lie within the extension-line
|
|
118
|
+
overshoot (at preset sizes), so they add nothing to the hull.
|
|
119
|
+
|
|
120
|
+
Tight spans mirror helpers ≥0.14's outside-arrows flip (``_dim_line_ink``):
|
|
121
|
+
when the label and both heads don't fit — ``w + 2·al ≥ length`` or the shaft
|
|
122
|
+
piece beside a head would vanish — the ink extends ``2·arrow_length`` past
|
|
123
|
+
each end along the line. The label itself stays centred regardless of width
|
|
124
|
+
(``Dimension`` always passes an explicit ``label_t``, so the hang branch
|
|
125
|
+
never runs), and when its keep-clear reaches a witness end the witness's
|
|
126
|
+
overshoot past the dimension line is cut away with it. Without this model
|
|
127
|
+
the estimate under-covers exactly the dims v0.14 widens, and the
|
|
128
|
+
accept-time rebuild fails validation until the candidate is dropped (the
|
|
129
|
+
declared-plate 8 mm thickness dim was the first casualty). Assumes the
|
|
130
|
+
centred label (``label_offset_x=0``), like the rest of the estimate.
|
|
131
|
+
Callers accepting a candidate off this footprint must still build the real
|
|
132
|
+
geometry once and re-validate its box (the #602 validation fallback) so any
|
|
133
|
+
residual mismatch degrades to a wasted probe, never a collision.
|
|
134
|
+
"""
|
|
135
|
+
if isinstance(side, str): # mirror helpers._SIDE_VECTORS ("above"/"below"/"left"/"right")
|
|
136
|
+
side = {
|
|
137
|
+
"above": (0.0, 1.0, 0.0),
|
|
138
|
+
"below": (0.0, -1.0, 0.0),
|
|
139
|
+
"left": (-1.0, 0.0, 0.0),
|
|
140
|
+
"right": (1.0, 0.0, 0.0),
|
|
141
|
+
}[side]
|
|
142
|
+
sx, sy = side[0], side[1]
|
|
143
|
+
off = abs(distance)
|
|
144
|
+
gap = draft.extension_gap
|
|
145
|
+
dxp, dyp = p2[0] - p1[0], p2[1] - p1[1]
|
|
146
|
+
# Mirror the renderer's font resolution exactly (helpers._font_path): the pinned
|
|
147
|
+
# font *file* when set, else the helpers default; an explicit font_path=None means
|
|
148
|
+
# name-based resolution of draft.font — so pass the name through too.
|
|
149
|
+
w, h = _text_size(
|
|
150
|
+
label,
|
|
151
|
+
draft.font_size,
|
|
152
|
+
getattr(draft, "font_path", DEFAULT_FONT_PATH),
|
|
153
|
+
getattr(draft, "font", "Arial"),
|
|
154
|
+
)
|
|
155
|
+
hx, hy = (h / 2.0, w / 2.0) if abs(dyp) > abs(dxp) else (w / 2.0, h / 2.0)
|
|
156
|
+
lcx = (p1[0] + p2[0]) / 2.0 + sx * off
|
|
157
|
+
lcy = (p1[1] + p2[1]) / 2.0 + sy * off
|
|
158
|
+
far = off + gap
|
|
159
|
+
xs = [p1[0] + sx * gap, p2[0] + sx * gap, lcx - hx, lcx + hx]
|
|
160
|
+
ys = [p1[1] + sy * gap, p2[1] + sy * gap, lcy - hy, lcy + hy]
|
|
161
|
+
# Helpers ≥0.14 tight-span behaviour. Along the line: the label's half-extent
|
|
162
|
+
# is w/2 regardless of orientation (the text rotates with the line); outside
|
|
163
|
+
# arrows extend the ink 2·al past each end when label+heads don't fit. Along
|
|
164
|
+
# the witness: the label keep-clear (h/2 + pad either side of the line) is
|
|
165
|
+
# cut out of a witness the centred label reaches, removing its overshoot
|
|
166
|
+
# unless a stub past the keep-clear survives (gap > h/2 + pad).
|
|
167
|
+
length = math.hypot(dxp, dyp)
|
|
168
|
+
al = getattr(draft, "arrow_length", 0.9 * draft.font_size)
|
|
169
|
+
tpad = getattr(draft, "pad_around_text", 0.0)
|
|
170
|
+
fits = length > 0 and (w + 2.0 * al < length) and (length / 2.0 - w / 2.0 - tpad > al / 2.0)
|
|
171
|
+
if not fits and length > 0:
|
|
172
|
+
ux, uy = dxp / length, dyp / length
|
|
173
|
+
xs += [p1[0] + sx * off - ux * 2.0 * al, p2[0] + sx * off + ux * 2.0 * al]
|
|
174
|
+
ys += [p1[1] + sy * off - uy * 2.0 * al, p2[1] + sy * off + uy * 2.0 * al]
|
|
175
|
+
label_covers_witness = length > 0 and length / 2.0 < w / 2.0 + tpad
|
|
176
|
+
if not label_covers_witness or gap > h / 2.0 + tpad:
|
|
177
|
+
xs += [p1[0] + sx * far, p2[0] + sx * far]
|
|
178
|
+
ys += [p1[1] + sy * far, p2[1] + sy * far]
|
|
179
|
+
pad = draft.line_width / 2.0
|
|
180
|
+
return (min(xs) - pad, min(ys) - pad, max(xs) + pad, max(ys) + pad)
|
|
181
|
+
|
|
182
|
+
|
|
103
183
|
CROSSABLE_TYPES = frozenset({"Centerline", "CenterlineCircle", "CenterMark"})
|
|
104
184
|
"""Annotation types a *dimension* may legitimately cross (ISO 128): centre lines
|
|
105
185
|
and centre marks. A **leader**, by contrast, must avoid them (#305) — so this is a
|
|
@@ -415,9 +495,20 @@ class CorridorCandidate:
|
|
|
415
495
|
# the title block, which is placed after the corridor drain so the strip carve can't see
|
|
416
496
|
# it (#481). ``None`` (every dim) skips the check → byte-identical.
|
|
417
497
|
forbid: object | None = None
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
498
|
+
# Analytical ``pos -> (x0, y0, x1, y1)`` footprint of the geometry ``build(pos)``
|
|
499
|
+
# would produce (#602): lets the strip solve measure and evaluate this candidate
|
|
500
|
+
# without constructing OCC geometry at all (see :func:`dim_footprint`). ``None``
|
|
501
|
+
# falls back to one probe build at the strip edge + the box-shift model. CONTRACT:
|
|
502
|
+
# the footprint must be accurate — its PERPENDICULAR extent feeds the obstacle
|
|
503
|
+
# band filter, so an underestimate hides obstacles from the carve. The solve
|
|
504
|
+
# re-validates each built survivor against the blockers, the forbid box and the
|
|
505
|
+
# band-filtered-out obstacles, so a miss costs a wasted build and a retry, but
|
|
506
|
+
# keeping footprints truthful (``dim_footprint``, ±0.05 mm) is what keeps that
|
|
507
|
+
# fallback rare and the placement identical to the probe path.
|
|
508
|
+
footprint: object | None = None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def solve_corridor(dwg, strip, view, axis, cands, tier, corner_reserves=()):
|
|
421
512
|
"""One collect-then-solve over every :class:`CorridorCandidate` a shared strip
|
|
422
513
|
accumulated across passes (ADR 0009 end state). Dedup → order → one non-force
|
|
423
514
|
:func:`place_strip_candidates` pass → a force pass for the force-eligible leftovers →
|
|
@@ -472,6 +563,7 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
|
|
|
472
563
|
prio = {c.name: c.priority for c in kept if c.priority} # over-capacity survival rank (#357)
|
|
473
564
|
anchored = {c.name: c.anchored for c in kept if c.anchored}
|
|
474
565
|
naturals = {c.name: c.natural for c in kept if c.natural is not None}
|
|
566
|
+
foots = {c.name: c.footprint for c in kept if c.footprint is not None} # analytical (#602)
|
|
475
567
|
left = {
|
|
476
568
|
n
|
|
477
569
|
for n, _ in place_strip_candidates(
|
|
@@ -487,6 +579,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
|
|
|
487
579
|
priorities=prio,
|
|
488
580
|
anchored=anchored,
|
|
489
581
|
naturals=naturals,
|
|
582
|
+
footprints=foots,
|
|
583
|
+
corner_reserves=corner_reserves,
|
|
490
584
|
)
|
|
491
585
|
}
|
|
492
586
|
force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
|
|
@@ -501,6 +595,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
|
|
|
501
595
|
force_pairs,
|
|
502
596
|
tier,
|
|
503
597
|
force=True,
|
|
598
|
+
footprints=foots,
|
|
599
|
+
corner_reserves=corner_reserves,
|
|
504
600
|
features=feats,
|
|
505
601
|
sizes=sizes,
|
|
506
602
|
forbid=forbid,
|
|
@@ -540,6 +636,10 @@ class PlacementContext:
|
|
|
540
636
|
corridor_batch: dict = field(default_factory=dict)
|
|
541
637
|
escalations: list = field(default_factory=list)
|
|
542
638
|
detail_requests: list = field(default_factory=list)
|
|
639
|
+
# Fallthrough callbacks a pass's on_drop queues to run AFTER every corridor has
|
|
640
|
+
# drained (#684 review): a mid-drain carve could occupy space a later sibling
|
|
641
|
+
# corridor's force candidate needs; deferral makes "post-drain" literally true.
|
|
642
|
+
post_drain: list = field(default_factory=list)
|
|
543
643
|
# The drawing's build-state stores, referenced (not owned) by the run's passes (#639).
|
|
544
644
|
# Duck-typed as ``Any`` — matching the untyped ``Drawing._record_build_issue`` they replace —
|
|
545
645
|
# so mypy does not reject the delegating calls below.
|
|
@@ -601,10 +701,43 @@ def register_corridor(ctx, key, strip, view, axis, tier, cand):
|
|
|
601
701
|
def drain_corridors(ctx, dwg):
|
|
602
702
|
"""Solve every registered corridor (one :func:`solve_corridor` per strip), then clear
|
|
603
703
|
the batch. Called once, after all corridor-feeding passes have registered. Takes both the
|
|
604
|
-
scratch *ctx* (the batch) and *dwg* (the drawing :func:`solve_corridor` places onto).
|
|
605
|
-
|
|
606
|
-
|
|
704
|
+
scratch *ctx* (the batch) and *dwg* (the drawing :func:`solve_corridor` places onto).
|
|
705
|
+
|
|
706
|
+
Corner coordination (helpers ≥0.14): perpendicular strips of one view contest the
|
|
707
|
+
view corners — a tight-span dim's outside-arrow tails overhang past the view edge
|
|
708
|
+
into the sibling strip's band (an 8 mm plate-thickness dim on the left strip dips
|
|
709
|
+
below the view bottom; the below strip's own tight dim pokes left of the view edge).
|
|
710
|
+
Solved sequentially and blind, the first drain fills the corner and the second's
|
|
711
|
+
force-kept candidate hard-drops. So each solve receives the *innermost-tier
|
|
712
|
+
footprint boxes* of every not-yet-drained same-view sibling's **force** candidates
|
|
713
|
+
as extra obstacles: the earlier drain places clear of the corner the later one
|
|
714
|
+
provably needs. Reservation is exact (the candidate's own analytical footprint, at
|
|
715
|
+
the innermost position it would take), restricted to force candidates — principal
|
|
716
|
+
dims that would otherwise drop rather than relocate — so best-effort occupants
|
|
717
|
+
never lose capacity to it. Already-drained siblings need nothing: their dims are
|
|
718
|
+
real obstacles via :func:`strip_obstacles`."""
|
|
719
|
+
batches = list(ctx.corridor_batch.values())
|
|
720
|
+
for i, b in enumerate(batches):
|
|
721
|
+
reserves = []
|
|
722
|
+
for sib in batches[i + 1 :]:
|
|
723
|
+
if sib["view"] != b["view"] or sib["strip"] is None:
|
|
724
|
+
continue
|
|
725
|
+
s = sib["strip"]
|
|
726
|
+
inner_pos = s.anchor + s.direction * s.gap
|
|
727
|
+
for c in sib["cands"]:
|
|
728
|
+
if c.force and c.footprint is not None:
|
|
729
|
+
box = c.footprint(inner_pos)
|
|
730
|
+
if box is not None:
|
|
731
|
+
reserves.append(box)
|
|
732
|
+
solve_corridor(
|
|
733
|
+
dwg, b["strip"], b["view"], b["axis"], b["cands"], b["tier"], corner_reserves=reserves
|
|
734
|
+
)
|
|
607
735
|
ctx.corridor_batch = {}
|
|
736
|
+
# Deferred fallthroughs (opposite-strip retries) run once every strip has drained,
|
|
737
|
+
# so a retry can never preempt a corner a later sibling's force candidate needs.
|
|
738
|
+
pending, ctx.post_drain = ctx.post_drain, []
|
|
739
|
+
for cb in pending:
|
|
740
|
+
cb()
|
|
608
741
|
|
|
609
742
|
|
|
610
743
|
def place_strip_candidates(
|
|
@@ -622,6 +755,8 @@ def place_strip_candidates(
|
|
|
622
755
|
priorities=None,
|
|
623
756
|
anchored=None,
|
|
624
757
|
naturals=None,
|
|
758
|
+
footprints=None,
|
|
759
|
+
corner_reserves=(),
|
|
625
760
|
):
|
|
626
761
|
"""Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
|
|
627
762
|
The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
|
|
@@ -699,13 +834,59 @@ def place_strip_candidates(
|
|
|
699
834
|
# obstacles out first. The perpendicular extent is independent of the tier position,
|
|
700
835
|
# so a single probe build per candidate suffices; the corridor check below already
|
|
701
836
|
# uses the full 2-D box, so it needs no such filter.
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
837
|
+
#
|
|
838
|
+
# That one probe build is also this call's entire MEASUREMENT step (#602): every
|
|
839
|
+
# candidate is a fixed feature-side anchor (witness origin / leader shaft end)
|
|
840
|
+
# plus a dim line that translates with the tier position, so its box at position
|
|
841
|
+
# ``pos`` is the probe box with the OUTWARD stacking-axis edge shifted by
|
|
842
|
+
# ``pos - lo`` — no further geometry is built to evaluate a position. The
|
|
843
|
+
# segment loop below re-solves and re-checks on these predicted boxes only;
|
|
844
|
+
# each finally-accepted candidate is built once and its real box re-validated
|
|
845
|
+
# (a prediction miss degrades to a later-segment retry, never a collision).
|
|
846
|
+
# A candidate with an analytical *footprints* entry (#602) needs no probe build at
|
|
847
|
+
# all — its box at any position is computed, not measured.
|
|
848
|
+
probe_boxes = {
|
|
849
|
+
name: (footprints[name](lo) if name in (footprints or {}) else _geom_box(build(lo)))
|
|
850
|
+
for name, build in cands
|
|
851
|
+
}
|
|
852
|
+
pbands = [(b[perp], b[perp + 2]) for b in probe_boxes.values() if b is not None]
|
|
853
|
+
|
|
854
|
+
def _predicted_box(name, pos):
|
|
855
|
+
fp = (footprints or {}).get(name)
|
|
856
|
+
if fp is not None:
|
|
857
|
+
return fp(pos)
|
|
858
|
+
pb = probe_boxes.get(name)
|
|
859
|
+
if pb is None:
|
|
860
|
+
return None
|
|
861
|
+
box = list(pb)
|
|
862
|
+
# The moving edge is the one AWAY from the view (`inner`); the feature-side
|
|
863
|
+
# edge is anchored geometry and stays put.
|
|
864
|
+
box[idx + 2 if inner == lo else idx] += pos - lo
|
|
865
|
+
return tuple(box)
|
|
866
|
+
|
|
705
867
|
occupied = strip_obstacles(dwg, view=view, crossable=CROSSABLE_TYPES)
|
|
868
|
+
# Obstacles OUTSIDE the batch's predicted perpendicular band are invisible to the
|
|
869
|
+
# carve below by design — but that makes the band prediction itself load-bearing: a
|
|
870
|
+
# candidate whose real geometry exceeds its predicted band could land on one with no
|
|
871
|
+
# check ever seeing it (review #679). Keep the filtered-out set: the post-build
|
|
872
|
+
# validation re-checks each survivor's REAL box against it. In-band overlaps are NOT
|
|
873
|
+
# validated — witness lines legitimately cross the boxes of dims stacked further in
|
|
874
|
+
# (ISO 129-1), which is exactly why the carve projects onto the stacking axis only.
|
|
875
|
+
out_of_band: list = []
|
|
706
876
|
if pbands:
|
|
707
877
|
band_lo, band_hi = min(p[0] for p in pbands), max(p[1] for p in pbands)
|
|
708
|
-
|
|
878
|
+
in_band = [b for b in occupied if b[perp] < band_hi and b[perp + 2] > band_lo]
|
|
879
|
+
out_of_band = [b for b in occupied if not (b[perp] < band_hi and b[perp + 2] > band_lo)]
|
|
880
|
+
occupied = in_band
|
|
881
|
+
# Corner reserves (drain_corridors): projected boxes of not-yet-drained sibling
|
|
882
|
+
# corridors' force candidates. Same band relevance filter as real obstacles, but
|
|
883
|
+
# NEVER in out_of_band — a reserve is a projection, not geometry, so a survivor's
|
|
884
|
+
# real box must not be failed against it.
|
|
885
|
+
occupied += [
|
|
886
|
+
r
|
|
887
|
+
for r in corner_reserves
|
|
888
|
+
if r is not None and r[perp] < band_hi and r[perp + 2] > band_lo
|
|
889
|
+
]
|
|
709
890
|
blockers = () if force else corridor_blockers(dwg, view)
|
|
710
891
|
segs = carve_free_segments(lo, hi, [(b[idx], b[idx + 2]) for b in occupied], pad)
|
|
711
892
|
# Fill innermost-first (nearest the view), matching the old cursor's stack order.
|
|
@@ -764,8 +945,15 @@ def place_strip_candidates(
|
|
|
764
945
|
if pos is None: # segment over its estimated capacity (shouldn't occur)
|
|
765
946
|
rejected.append((name, build))
|
|
766
947
|
continue
|
|
767
|
-
|
|
768
|
-
|
|
948
|
+
# Predicted box, not built geometry (#602): the refill loop re-evaluates
|
|
949
|
+
# every already-accepted candidate each iteration, so building here made
|
|
950
|
+
# the drain quadratic in OCC builds.
|
|
951
|
+
box = _predicted_box(name, pos)
|
|
952
|
+
if box is None: # probe didn't bbox — measure the old way, once per check
|
|
953
|
+
box = _geom_box(build(pos))
|
|
954
|
+
if (
|
|
955
|
+
not force and box is not None and _box_hits(box, blockers)
|
|
956
|
+
): # corridor crosses a leader
|
|
769
957
|
rejected.append((name, build))
|
|
770
958
|
continue
|
|
771
959
|
# A forbidden box (the title block, #481) is rejected even under force — it is
|
|
@@ -773,10 +961,10 @@ def place_strip_candidates(
|
|
|
773
961
|
# must still not stack onto it. `forbid` maps names to their box (only GD&T sets it,
|
|
774
962
|
# so dims are byte-identical). Returned unplaced → the caller's on_drop fallthrough.
|
|
775
963
|
fb = (forbid or {}).get(name)
|
|
776
|
-
if fb is not None and _box_hits(
|
|
964
|
+
if fb is not None and box is not None and _box_hits(box, (fb,)):
|
|
777
965
|
rejected.append((name, build))
|
|
778
966
|
continue
|
|
779
|
-
accepted.append(((name, build),
|
|
967
|
+
accepted.append(((name, build), pos))
|
|
780
968
|
return accepted, rejected
|
|
781
969
|
|
|
782
970
|
for seg_lo, seg_hi in segs:
|
|
@@ -792,9 +980,32 @@ def place_strip_candidates(
|
|
|
792
980
|
if vacancies <= 0 or not todo:
|
|
793
981
|
break
|
|
794
982
|
fill, todo = _take_for_segment(todo, vacancies)
|
|
795
|
-
take = [nb for nb,
|
|
983
|
+
take = [nb for nb, _pos in accepted] + fill
|
|
984
|
+
# Build each survivor ONCE at its solved position and re-validate the real box
|
|
985
|
+
# (the #602 validation fallback): a prediction miss is returned to the pool for
|
|
986
|
+
# the next segment — exactly where a same-segment rejection would have sent it.
|
|
987
|
+
placed = []
|
|
988
|
+
for (name, build), pos in accepted:
|
|
989
|
+
dim = build(pos)
|
|
990
|
+
real = _geom_box(dim)
|
|
991
|
+
if real is not None:
|
|
992
|
+
if not force and _box_hits(real, blockers):
|
|
993
|
+
rejected_total.append((name, build))
|
|
994
|
+
continue
|
|
995
|
+
fb = (forbid or {}).get(name)
|
|
996
|
+
if fb is not None and _box_hits(real, (fb,)):
|
|
997
|
+
rejected_total.append((name, build))
|
|
998
|
+
continue
|
|
999
|
+
# A survivor whose real geometry escaped the batch's predicted
|
|
1000
|
+
# perpendicular band could overlap an obstacle the carve was never
|
|
1001
|
+
# shown (review #679) — the one collision class the stacking-axis
|
|
1002
|
+
# model cannot tolerate. Never fires while predictions are accurate.
|
|
1003
|
+
if _box_hits(real, out_of_band):
|
|
1004
|
+
rejected_total.append((name, build))
|
|
1005
|
+
continue
|
|
1006
|
+
placed.append((name, dim))
|
|
796
1007
|
todo = todo + rejected_total
|
|
797
|
-
for
|
|
1008
|
+
for name, dim in placed:
|
|
798
1009
|
# Record feature provenance (ADR 0010): the drain-time seam for corridor-placed
|
|
799
1010
|
# dims — `features` maps this batch's names to their source IR feature.
|
|
800
1011
|
dwg.add(dim, name, view=view, feature=(features or {}).get(name))
|