From 9da79b4c6546f272341b41937f22d575828062e5 Mon Sep 17 00:00:00 2001 From: Paul Fremantle Date: Sun, 4 Oct 2026 00:01:19 +0100 Subject: [PATCH 1/5] Preserve source knurl diameter in manufacturing rows Render the full source-owned knurl requirement in schedule rows while keeping the compact suffix for direct diameter callouts. Compare placed rows against that complete text in lint. Preserve source pitch, chamfer, and diameter precision. --- src/draftwright/linting/pmi_coverage.py | 2 +- src/draftwright/model/ir_foundation.py | 17 +++++++++++-- .../model/manufacturing_schedule.py | 2 +- ...t_issue_1298_manufacturing_requirements.py | 18 ++++++++++++-- tests/test_manufacturing_schedule.py | 24 +++++++++++++++++-- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/draftwright/linting/pmi_coverage.py b/src/draftwright/linting/pmi_coverage.py index cb7e466bb..b2248aaed 100644 --- a/src/draftwright/linting/pmi_coverage.py +++ b/src/draftwright/linting/pmi_coverage.py @@ -101,7 +101,7 @@ def lint_manufacturing_references(registry) -> list[LintIssue]: claimed_sources = set(source_ids_by_tag.get(tag, ())) printed = " ".join(rows_by_tag.get(tag, ())) complete = bool(printed and claimed_sources) and any( - claimed_sources == set(aspect.source_ids) and printed == aspect.callout_suffix + claimed_sources == set(aspect.source_ids) and printed == aspect.callout_text for aspect in aspects ) if complete: diff --git a/src/draftwright/model/ir_foundation.py b/src/draftwright/model/ir_foundation.py index ab4524f41..820c4a5b0 100644 --- a/src/draftwright/model/ir_foundation.py +++ b/src/draftwright/model/ir_foundation.py @@ -13,6 +13,7 @@ from draftwright import contract_values from draftwright._geometry import ( _fmt, + _fmt_pmi_magnitude, plane_axes, ) from draftwright.feature_identity import ( @@ -538,6 +539,11 @@ def callout_suffix(self) -> str: terms.append(f"{_fmt(self.drill_point_angle)}° CONVENTIONAL DRILL POINT") return "; ".join(terms) + @property + def callout_text(self) -> str: + """Complete thread text when no feature diameter precedes it.""" + return self.callout_suffix + @dataclass(frozen=True) class ThreadOperation: @@ -629,16 +635,23 @@ def callout_suffix(self) -> str: terms = [] if self.maximum_diameter is not None: terms.append("MAX AFTER KNURL") - knurl = f"{self.pattern.upper()} KNURL P{_fmt(self.pitch)}" + knurl = f"{self.pattern.upper()} KNURL P{_fmt_pmi_magnitude(self.pitch)}" if self.full_width: knurl += " FULL WIDTH" if self.edge_chamfer is not None: - knurl += f" TO C{_fmt(self.edge_chamfer)} CHAMFERS" + knurl += f" TO C{_fmt_pmi_magnitude(self.edge_chamfer)} CHAMFERS" terms.append(knurl) if self.processes: terms.append("/".join(process.upper() for process in self.processes) + " PERMITTED") return "; ".join(terms) + @property + def callout_text(self) -> str: + """Complete knurl text when no feature diameter precedes it.""" + if self.maximum_diameter is None: + return self.callout_suffix + return f"ø{_fmt_pmi_magnitude(self.maximum_diameter)} {self.callout_suffix}" + def display(p: DimParameter) -> str: """A font-safe text form of a parameter (uses only glyphs the pinned font has; diff --git a/src/draftwright/model/manufacturing_schedule.py b/src/draftwright/model/manufacturing_schedule.py index 006a135c3..429623a4b 100644 --- a/src/draftwright/model/manufacturing_schedule.py +++ b/src/draftwright/model/manufacturing_schedule.py @@ -56,7 +56,7 @@ def owners(self) -> tuple[object, ...]: def rows(self) -> tuple[tuple[str, str], ...]: rows = [("REF", "MANUFACTURING REQUIREMENT")] for entry in self.entries: - for index, line in enumerate(_wrap_requirement(entry.requirement.callout_suffix)): + for index, line in enumerate(_wrap_requirement(entry.requirement.callout_text)): rows.append((entry.tag if index == 0 else "", line)) return tuple(rows) diff --git a/tests/test_issue_1298_manufacturing_requirements.py b/tests/test_issue_1298_manufacturing_requirements.py index 275f79181..58b692b98 100644 --- a/tests/test_issue_1298_manufacturing_requirements.py +++ b/tests/test_issue_1298_manufacturing_requirements.py @@ -900,7 +900,7 @@ def test_typed_manufacturing_row_keeps_plain_sibling_diameters_in_the_shared_sol assert table.table_rows[0] == ("REF", "MANUFACTURING REQUIREMENT") assert " ".join(cell for row in table.table_rows for cell in row if cell) == ( "REF MANUFACTURING REQUIREMENT MFG 1 M3 x 0.5-6g RH, FULL AVAILABLE LENGTH " - "MFG 2 MAX AFTER KNURL; STRAIGHT KNURL P1 FULL WIDTH TO C0.3 CHAMFERS; " + "MFG 2 ø10 MAX AFTER KNURL; STRAIGHT KNURL P1 FULL WIDTH TO C0.3 CHAMFERS; " "CUT/FORMED PERMITTED" ) knurl_owner = next( @@ -952,6 +952,16 @@ def test_typed_manufacturing_row_keeps_plain_sibling_diameters_in_the_shared_sol } == {("manufacturing_requirement:#2000",)} table.table_rows = intact_rows + table.table_rows = tuple( + (tag, requirement.replace("ø10 ", "")) for tag, requirement in intact_rows + ) + assert { + issue.source_ids + for issue in drawing.lint(physical=False) + if issue.code == "manufacturing_reference_unresolved" + } == {("manufacturing_requirement:#2008",)} + table.table_rows = intact_rows + # A later curation edit must not leave apparently complete short references # after removing their full manufacturing carrier. drawing.remove("manufacturing_requirements") @@ -1712,7 +1722,7 @@ def test_exact_grm03_renders_complete_source_owned_manufacturing_drawing_once(): "REF MANUFACTURING REQUIREMENT " "MFG 1 M3 x 0.5-6g RH, FULL AVAILABLE LENGTH " "MFG 2 M2 x 0.4-6H RH; 6 MIN FULL THREAD; 118° CONVENTIONAL DRILL POINT " - "MFG 3 MAX AFTER KNURL; STRAIGHT KNURL P1 FULL WIDTH TO C0.3 CHAMFERS; " + "MFG 3 ø10 MAX AFTER KNURL; STRAIGHT KNURL P1 FULL WIDTH TO C0.3 CHAMFERS; " "CUT/FORMED PERMITTED" ) typed_occurrences = [] @@ -1725,6 +1735,10 @@ def test_exact_grm03_renders_complete_source_owned_manufacturing_drawing_once(): expected_manufacturing ) typed_owners = dict(typed_occurrences) + assert ( + "DIA 10 mm maximum after knurling" + in typed_owners["manufacturing_requirement:#2008"].knurl.text + ) for source_id, (expected_name, expected_label) in expected_manufacturing.items(): owner = typed_owners[source_id] matches = [ diff --git a/tests/test_manufacturing_schedule.py b/tests/test_manufacturing_schedule.py index 2cfd291bc..552dae0f9 100644 --- a/tests/test_manufacturing_schedule.py +++ b/tests/test_manufacturing_schedule.py @@ -44,7 +44,7 @@ def _knurl(): pattern="straight", pitch=1.0, full_width=True, - text="Straight knurl, full width between C0.3 chamfers", + text="Straight knurl, full width between C0.3 chamfers, DIA 10 mm maximum after knurling", source_ids=("manufacturing_requirement:#2",), part21_id="#2", shape_aspect_ids=("#2:aspect",), @@ -78,7 +78,12 @@ def test_long_imported_requirements_get_stable_complete_keyed_rows(): by_tag[current] = [] by_tag[current].append(text) for entry in schedule.entries: - assert " ".join(by_tag[entry.tag]) == entry.requirement.callout_suffix + printed = " ".join(by_tag[entry.tag]) + if isinstance(entry.requirement, KnurlRequirement): + assert "DIA 10 mm maximum after knurling" in entry.requirement.text + assert "ø10 MAX AFTER KNURL" in printed + else: + assert printed == entry.requirement.callout_suffix def test_one_requirement_or_pmi_off_keeps_direct_callouts(): @@ -96,6 +101,21 @@ def test_one_requirement_or_pmi_off_keeps_direct_callouts(): ) +def test_knurl_schedule_text_keeps_source_diameter_precision(): + requirement = replace( + _knurl(), + maximum_diameter=10.05, + pitch=0.25, + edge_chamfer=0.05, + text="Straight knurl, 0.25 mm pitch, full width between C0.05 chamfers, DIA 10.05 mm maximum after knurling", + ) + assert "0.25 mm pitch, full width between C0.05 chamfers, DIA 10.05 mm" in requirement.text + assert ( + requirement.callout_text + == "ø10.05 MAX AFTER KNURL; STRAIGHT KNURL P0.25 FULL WIDTH TO C0.05 CHAMFERS; CUT/FORMED PERMITTED" + ) + + def test_long_requirement_wrap_keeps_the_final_phrase_together(): assert _wrap_requirement( "M2 x 0.4-6H RH; 6 MIN FULL THREAD; 118° CONVENTIONAL DRILL POINT" From 4837196d23c8459a2c7a217f65dc48ffc1e7dbba Mon Sep 17 00:00:00 2001 From: Paul Fremantle Date: Sun, 4 Oct 2026 02:34:53 +0100 Subject: [PATCH 2/5] Preserve source knurl precision in approved diameter labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 10.05 mm source knurl previously produced a schedule row with ø10.05 and a referring diameter leader with ø10.1. Compile source knurl diameters with the source magnitude precision as a floor, including when a caller requests one decimal place. Keep the ordinary formatting path for other dimensions. A public drawing guard covers automatic and one-decimal requests; restoring the old formatting rule makes both named cases fail. The compiled source tier selects that guard. Focused tests and scripts/pr-check --static pass. --- src/draftwright/model/compiled.py | 52 ++++++++++++++---- tests/_tier_manifest.py | 1 + ...t_issue_1298_manufacturing_requirements.py | 54 +++++++++++++++++++ tests/test_tier_manifest.py | 5 ++ 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/src/draftwright/model/compiled.py b/src/draftwright/model/compiled.py index 72ee863ef..2aded7f0d 100644 --- a/src/draftwright/model/compiled.py +++ b/src/draftwright/model/compiled.py @@ -51,9 +51,15 @@ from collections.abc import Iterable from dataclasses import dataclass, field, replace -from typing import Any, Literal - -from draftwright._geometry import _fmt, _fmt_angle, _fmt_chamfer, _fmt_tolerance +from typing import Any, Literal, cast + +from draftwright._geometry import ( + _fmt, + _fmt_angle, + _fmt_chamfer, + _fmt_pmi_magnitude, + _fmt_tolerance, +) from draftwright.fits import FitClass from draftwright.measurement_support import coincident_location_axes from draftwright.model.callout import resolved_through_indicator @@ -64,6 +70,7 @@ EnvelopeFeature, Feature, HoleFeature, + KnurlRequirement, Note, PadFeature, PartModel, @@ -1814,6 +1821,36 @@ def _dimension_witness_span(feature, parameter): ) +def _group_display_decimals(feature, planned) -> int | None: + """Use source precision as a floor for a knurled external diameter.""" + knurl = getattr(feature, "knurl", None) + if ( + planned.param.kind == "diameter" + and isinstance(knurl, KnurlRequirement) + and knurl.source == "ap242_pmi" + and knurl.maximum_diameter is not None + ): + # The schedule prints the source maximum. A coarser diameter leader would + # contradict it even when an authored decimal policy asks for fewer places. + source_places = len(_fmt_pmi_magnitude(knurl.maximum_diameter).partition(".")[2]) + requested_places = cast(int | None, planned.display_decimals) + return max(source_places, requested_places or 0) + return cast(int | None, planned.display_decimals) + + +def _group_value_text(feature, planned, flat_auto_decimals: int | None) -> str: + """Keep a source knurl's maximum readable in its approved diameter callout.""" + decimals = _group_display_decimals(feature, planned) + if decimals != planned.display_decimals: + return _fmt_pmi_magnitude(planned.param.value, decimals) + decimals = ( + planned.display_decimals + if planned.display_decimals is not None or feature.kind != "flat" + else flat_auto_decimals + ) + return _fmt(planned.param.value, decimals) + + def _compile_groups( planned, *, restore_width: bool = False ) -> tuple[list[ApprovedGroup], list[Omission]]: @@ -1884,12 +1921,7 @@ def _compile_groups( id=DimensionId(g.feature, pd.param.parameter_id), # DimParameter.value is a required float. Keep that invariant explicit at # the boundary instead of implying a nullable state renderers cannot handle. - value_text=_fmt( - pd.param.value, - pd.display_decimals - if pd.display_decimals is not None or g.feature_kind != "flat" - else flat_auto_decimals, - ), + value_text=_group_value_text(g.feature, pd, flat_auto_decimals), value=float(pd.param.value), span=_dimension_witness_span(g.feature, pd.param), ref=FeatureRef(g.feature), @@ -1904,7 +1936,7 @@ def _compile_groups( if pd.param.angular_reference is not None else None ), - display_decimals=pd.display_decimals, + display_decimals=_group_display_decimals(g.feature, pd), view=pd.view, side=pd.side, lane=pd.lane, diff --git a/tests/_tier_manifest.py b/tests/_tier_manifest.py index 937e4587e..2c0400c81 100644 --- a/tests/_tier_manifest.py +++ b/tests/_tier_manifest.py @@ -737,6 +737,7 @@ class ContractGroup: "src/draftwright/linting/suggest.py": ("test_lint_suggestions.py",), "src/draftwright/model/compiled.py": ( "test_blind_slot_semantics.py", + "test_issue_1298_manufacturing_requirements.py", "test_issue_1511_turned_boss_heights.py", "test_issue_1517_through_indicator.py", "test_issue_1560_envelope_axes.py", diff --git a/tests/test_issue_1298_manufacturing_requirements.py b/tests/test_issue_1298_manufacturing_requirements.py index 58b692b98..450dee199 100644 --- a/tests/test_issue_1298_manufacturing_requirements.py +++ b/tests/test_issue_1298_manufacturing_requirements.py @@ -29,6 +29,7 @@ PartModel, PatternFeature, PmiFeature, + RequestedDimension, StepFeature, ThreadRequirement, ) @@ -976,6 +977,59 @@ def test_typed_manufacturing_row_keeps_plain_sibling_diameters_in_the_shared_sol } +@pytest.mark.parametrize("requested_decimals", [None, 1]) +def test_source_knurl_maximum_keeps_diameter_precision_in_placed_reference(requested_decimals): + part = Cylinder(10.05 / 2, 2, align=(Align.CENTER, Align.CENTER, Align.MIN)).rotate( + Axis.Y, 90 + ) + ( + Pos(2, 0, 0) + * Cylinder(1.5, 20, align=(Align.CENTER, Align.CENTER, Align.MIN)).rotate(Axis.Y, 90) + ) + source_text = KNURL_TEXT.replace("DIA 10 mm", "DIA 10.05 mm") + assert "DIA 10.05 mm maximum after knurling" in source_text + model = lower_ap242_manufacturing_requirements( + PartModel( + part.bounding_box(), + "x", + [ + _step(10.05, 0, 2), + _step(3, 2, 22), + _raw( + "knurl", + source_text, + _reference(diameter=10.05, interval=(0.3, 1.7), sense="external"), + "#2008", + ), + _raw( + "external_thread", + EXTERNAL_TEXT, + _reference(diameter=3, interval=(2.5, 21.5), sense="external"), + "#2000", + ), + ], + ) + ) + owner = next(feature for feature in model.features if getattr(feature, "knurl", None)) + assert owner.knurl.maximum_diameter == 10.05 + if requested_decimals is not None: + model = replace( + model, + requested_dimensions=( + RequestedDimension(owner, "step.diameter", display_decimals=requested_decimals), + ), + ) + + drawing = build_drawing(part, model=model, pmi="annotate", page="A2") + assert drawing.get_annotation("m_dia_x0").label == "ø10.05 SEE MFG 2" + table = drawing.get_annotation("manufacturing_requirements") + assert "ø10.05 MAX AFTER KNURL" in " ".join(cell for _, cell in table.table_rows) + assert not [ + issue + for issue in drawing.lint(physical=False) + if issue.code == "manufacturing_reference_unresolved" + ] + + def test_unplaced_manufacturing_table_keeps_complete_direct_labels(monkeypatch): align = (Align.CENTER, Align.CENTER, Align.MIN) part = Cylinder(5.0, 2.0, align=align).rotate(Axis.Y, 90) + ( diff --git a/tests/test_tier_manifest.py b/tests/test_tier_manifest.py index 8e3e68e3e..a0cf3552b 100644 --- a/tests/test_tier_manifest.py +++ b/tests/test_tier_manifest.py @@ -462,6 +462,11 @@ def test_ir_foundation_change_runs_manufacturing_requirement_contract(): assert "test_part_model.py" in selected +def test_compiled_plan_change_runs_source_knurl_precision_contract(): + selected = pr_modules(_TESTS, ["src/draftwright/model/compiled.py"]) + assert "test_issue_1298_manufacturing_requirements.py" in selected + + def test_pmi_lowering_change_runs_source_diameter_ownership_contract(): selected = pr_modules(_TESTS, ["src/draftwright/model/pmi_lowering.py"]) assert "test_issue_1296_cylindrical_diameter_pmi.py" in selected From 6f6b4c7e1e0700f9e5a700c6731e60a1b41f97fd Mon Sep 17 00:00:00 2001 From: Paul Fremantle Date: Sun, 4 Oct 2026 02:46:41 +0100 Subject: [PATCH 3/5] docs: explain numeric fidelity for imported knurl diameter --- docs/reference/sheet.md | 4 ++++ src/draftwright/model/compiled.py | 6 +++--- src/draftwright/model/ir.py | 4 +++- src/draftwright/sheet.py | 4 +++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/reference/sheet.md b/docs/reference/sheet.md index f67e27518..5ad04b459 100644 --- a/docs/reference/sheet.md +++ b/docs/reference/sheet.md @@ -867,6 +867,10 @@ A location intent's precision applies to all its selected directional values, in side-drilled holes and slots. The policy survives generated-script replay. When several location intents share one coincident mark, give them matching display precision; incompatible printed values raise an actionable error instead of silently choosing one intent's label. +For a diameter that references an imported knurl maximum, the numeric value sets a +minimum number of decimal places: `format(decimals=1)` cannot print `ø10.05` as +`ø10.1`. The numeric PMI fields are floats; this policy does not preserve trailing +zeroes from the retained source prose. ::: draftwright.sheet.DimensionIntent diff --git a/src/draftwright/model/compiled.py b/src/draftwright/model/compiled.py index 2aded7f0d..3537cd508 100644 --- a/src/draftwright/model/compiled.py +++ b/src/draftwright/model/compiled.py @@ -1822,7 +1822,7 @@ def _dimension_witness_span(feature, parameter): def _group_display_decimals(feature, planned) -> int | None: - """Use source precision as a floor for a knurled external diameter.""" + """Avoid rounding a knurl maximum beyond the table's numeric precision.""" knurl = getattr(feature, "knurl", None) if ( planned.param.kind == "diameter" @@ -1830,8 +1830,8 @@ def _group_display_decimals(feature, planned) -> int | None: and knurl.source == "ap242_pmi" and knurl.maximum_diameter is not None ): - # The schedule prints the source maximum. A coarser diameter leader would - # contradict it even when an authored decimal policy asks for fewer places. + # The schedule prints the imported numeric maximum. A coarser diameter + # leader would contradict it even when an authored policy asks for less. source_places = len(_fmt_pmi_magnitude(knurl.maximum_diameter).partition(".")[2]) requested_places = cast(int | None, planned.display_decimals) return max(source_places, requested_places or 0) diff --git a/src/draftwright/model/ir.py b/src/draftwright/model/ir.py index 2e2656124..52c3aac00 100644 --- a/src/draftwright/model/ir.py +++ b/src/draftwright/model/ir.py @@ -2597,7 +2597,9 @@ class RequestedDimension: discriminator: str | None = None #: Explicit display precision for this referential dimension. The numeric value and #: identity still come from ``feature``; this controls only the compiler-owned text at - #: the rendering boundary (#1349). ``None`` preserves the existing automatic formatting. + #: the rendering boundary (#1349). ``None`` preserves automatic formatting. + #: An imported knurl's numeric maximum sets a minimum number of decimal places, + #: so a coarser request cannot round its referenced diameter away from that value. display_decimals: int | None = None #: Optional semantic projection/strip preference. The planner validates renderer #: compatibility; the placement engine still owns coordinates. diff --git a/src/draftwright/sheet.py b/src/draftwright/sheet.py index 704975ca4..cb1af2a38 100644 --- a/src/draftwright/sheet.py +++ b/src/draftwright/sheet.py @@ -645,7 +645,9 @@ def format(self, *, decimals: int) -> DimensionIntent: tolerance, suppression and provenance continue to use the feature parameter's numeric value and semantic identity. Automatic dimensions keep their existing formatting unless their explicit ``add_dimension`` intent opts in. For a location intent, - the policy applies to every selected directional value. + the policy applies to every selected directional value. An imported knurl's + numeric maximum keeps enough decimal places to avoid rounding that value, + even when ``decimals`` requests fewer places. """ if isinstance(decimals, bool) or not isinstance(decimals, int) or not 0 <= decimals <= 15: raise ValueError("format(decimals=...) requires an integer from 0 to 15") From 0ba5f82966d5931518eefaef4a4c9f448e4820eb Mon Sep 17 00:00:00 2001 From: Paul Fremantle Date: Sun, 4 Oct 2026 05:36:25 +0100 Subject: [PATCH 4/5] Keep source knurl maximum consistent across leader, row, and lint --- src/draftwright/linting/pmi_coverage.py | 22 +++++++++++- src/draftwright/model/compiled.py | 14 +++++--- ...t_issue_1298_manufacturing_requirements.py | 34 +++++++++++++++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/draftwright/linting/pmi_coverage.py b/src/draftwright/linting/pmi_coverage.py index b2248aaed..c185da095 100644 --- a/src/draftwright/linting/pmi_coverage.py +++ b/src/draftwright/linting/pmi_coverage.py @@ -9,11 +9,29 @@ from build123d import Align, Location, Mode, Sketch, Text +from draftwright._geometry import _fmt_pmi_magnitude from draftwright.linting.issues import LintIssue from draftwright.pmi import PmiExtractionReport _SUPPORTED_MANUFACTURING_REQUIREMENTS = frozenset(("external_thread", "internal_thread", "knurl")) _MANUFACTURING_REF = re.compile(r"\bSEE (MFG [1-9][0-9]*)\b") +_DIAMETER_TOKEN = re.compile(r"ø(\d+(?:\.\d+)?)") +_KNURL_MAX_TOKEN = re.compile(r"ø(\d+(?:\.\d+)?)\s+MAX AFTER KNURL\b") + + +def _source_knurl_max_is_visible(label: str, printed: str, aspect) -> bool: + """Judge source maximum ink independently of the row's callout formatter.""" + maximum = getattr(aspect, "maximum_diameter", None) + if maximum is None: + return True + expected = Decimal(_fmt_pmi_magnitude(maximum)) + row_match = _KNURL_MAX_TOKEN.search(printed) + if row_match is None or Decimal(row_match.group(1)) != expected: + return False + if "ø" not in label: + return True + leader_match = _DIAMETER_TOKEN.search(label) + return leader_match is not None and Decimal(leader_match.group(1)) == expected def _title_value_has_finished_ink(title, field: str, value: str) -> bool: @@ -101,7 +119,9 @@ def lint_manufacturing_references(registry) -> list[LintIssue]: claimed_sources = set(source_ids_by_tag.get(tag, ())) printed = " ".join(rows_by_tag.get(tag, ())) complete = bool(printed and claimed_sources) and any( - claimed_sources == set(aspect.source_ids) and printed == aspect.callout_text + claimed_sources == set(aspect.source_ids) + and printed == aspect.callout_text + and _source_knurl_max_is_visible(label, printed, aspect) for aspect in aspects ) if complete: diff --git a/src/draftwright/model/compiled.py b/src/draftwright/model/compiled.py index 3537cd508..4cd188898 100644 --- a/src/draftwright/model/compiled.py +++ b/src/draftwright/model/compiled.py @@ -1830,8 +1830,8 @@ def _group_display_decimals(feature, planned) -> int | None: and knurl.source == "ap242_pmi" and knurl.maximum_diameter is not None ): - # The schedule prints the imported numeric maximum. A coarser diameter - # leader would contradict it even when an authored policy asks for less. + # The schedule prints the imported maximum, which can differ slightly + # from the recognized solid and must survive a coarser authored policy. source_places = len(_fmt_pmi_magnitude(knurl.maximum_diameter).partition(".")[2]) requested_places = cast(int | None, planned.display_decimals) return max(source_places, requested_places or 0) @@ -1841,8 +1841,14 @@ def _group_display_decimals(feature, planned) -> int | None: def _group_value_text(feature, planned, flat_auto_decimals: int | None) -> str: """Keep a source knurl's maximum readable in its approved diameter callout.""" decimals = _group_display_decimals(feature, planned) - if decimals != planned.display_decimals: - return _fmt_pmi_magnitude(planned.param.value, decimals) + knurl = getattr(feature, "knurl", None) + if ( + planned.param.kind == "diameter" + and isinstance(knurl, KnurlRequirement) + and knurl.source == "ap242_pmi" + and knurl.maximum_diameter is not None + ): + return _fmt_pmi_magnitude(knurl.maximum_diameter, decimals) decimals = ( planned.display_decimals if planned.display_decimals is not None or feature.kind != "flat" diff --git a/tests/test_issue_1298_manufacturing_requirements.py b/tests/test_issue_1298_manufacturing_requirements.py index 450dee199..08a868158 100644 --- a/tests/test_issue_1298_manufacturing_requirements.py +++ b/tests/test_issue_1298_manufacturing_requirements.py @@ -978,8 +978,11 @@ def test_typed_manufacturing_row_keeps_plain_sibling_diameters_in_the_shared_sol @pytest.mark.parametrize("requested_decimals", [None, 1]) -def test_source_knurl_maximum_keeps_diameter_precision_in_placed_reference(requested_decimals): - part = Cylinder(10.05 / 2, 2, align=(Align.CENTER, Align.CENTER, Align.MIN)).rotate( +@pytest.mark.parametrize("owner_diameter", [10.05, 10.043]) +def test_source_knurl_maximum_keeps_diameter_precision_in_placed_reference( + requested_decimals, owner_diameter, monkeypatch +): + part = Cylinder(owner_diameter / 2, 2, align=(Align.CENTER, Align.CENTER, Align.MIN)).rotate( Axis.Y, 90 ) + ( Pos(2, 0, 0) @@ -992,7 +995,7 @@ def test_source_knurl_maximum_keeps_diameter_precision_in_placed_reference(reque part.bounding_box(), "x", [ - _step(10.05, 0, 2), + _step(owner_diameter, 0, 2), _step(3, 2, 22), _raw( "knurl", @@ -1028,6 +1031,31 @@ def test_source_knurl_maximum_keeps_diameter_precision_in_placed_reference(reque for issue in drawing.lint(physical=False) if issue.code == "manufacturing_reference_unresolved" ] + if owner_diameter == 10.043 and requested_decimals is None: + leader = drawing.get_annotation("m_dia_x0") + leader.label = "ø10.04 SEE MFG 2" + assert { + issue.source_ids + for issue in drawing.lint(physical=False) + if issue.code == "manufacturing_reference_unresolved" + } == {("manufacturing_requirement:#2008",)} + leader.label = "ø10.05 SEE MFG 2" + + # Simulate a formatter regression before a fresh build: comparing the + # row with the same formatter would accept a missing source maximum. + monkeypatch.setattr( + KnurlRequirement, "callout_text", property(lambda self: self.callout_suffix) + ) + table.table_rows = tuple( + (tag, text.replace("ø10.05 ", "")) for tag, text in table.table_rows + ) + assert "MAX AFTER KNURL" in " ".join(cell for _, cell in table.table_rows) + assert "ø10.05" not in " ".join(cell for _, cell in table.table_rows) + assert { + issue.source_ids + for issue in drawing.lint(physical=False) + if issue.code == "manufacturing_reference_unresolved" + } == {("manufacturing_requirement:#2008",)} def test_unplaced_manufacturing_table_keeps_complete_direct_labels(monkeypatch): From bfb21a3c313c806f5c65abbccb47826106fb9a1c Mon Sep 17 00:00:00 2001 From: Paul Fremantle Date: Sun, 4 Oct 2026 05:37:55 +0100 Subject: [PATCH 5/5] Require source knurl maximum on linked leader --- src/draftwright/linting/pmi_coverage.py | 2 -- tests/test_issue_1298_manufacturing_requirements.py | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/draftwright/linting/pmi_coverage.py b/src/draftwright/linting/pmi_coverage.py index c185da095..dee2439ae 100644 --- a/src/draftwright/linting/pmi_coverage.py +++ b/src/draftwright/linting/pmi_coverage.py @@ -28,8 +28,6 @@ def _source_knurl_max_is_visible(label: str, printed: str, aspect) -> bool: row_match = _KNURL_MAX_TOKEN.search(printed) if row_match is None or Decimal(row_match.group(1)) != expected: return False - if "ø" not in label: - return True leader_match = _DIAMETER_TOKEN.search(label) return leader_match is not None and Decimal(leader_match.group(1)) == expected diff --git a/tests/test_issue_1298_manufacturing_requirements.py b/tests/test_issue_1298_manufacturing_requirements.py index 08a868158..78d73c669 100644 --- a/tests/test_issue_1298_manufacturing_requirements.py +++ b/tests/test_issue_1298_manufacturing_requirements.py @@ -1039,6 +1039,12 @@ def test_source_knurl_maximum_keeps_diameter_precision_in_placed_reference( for issue in drawing.lint(physical=False) if issue.code == "manufacturing_reference_unresolved" } == {("manufacturing_requirement:#2008",)} + leader.label = "SEE MFG 2" + assert { + issue.source_ids + for issue in drawing.lint(physical=False) + if issue.code == "manufacturing_reference_unresolved" + } == {("manufacturing_requirement:#2008",)} leader.label = "ø10.05 SEE MFG 2" # Simulate a formatter regression before a fresh build: comparing the