diff --git a/docs/reference/sheet.md b/docs/reference/sheet.md index f67e2751..5ad04b45 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/linting/pmi_coverage.py b/src/draftwright/linting/pmi_coverage.py index cb7e466b..dee2439a 100644 --- a/src/draftwright/linting/pmi_coverage.py +++ b/src/draftwright/linting/pmi_coverage.py @@ -9,11 +9,27 @@ 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 + 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 +117,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_suffix + 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 72ee863e..4cd18889 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,42 @@ def _dimension_witness_span(feature, parameter): ) +def _group_display_decimals(feature, planned) -> int | None: + """Avoid rounding a knurl maximum beyond the table's numeric precision.""" + 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 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) + 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) + 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" + 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 +1927,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 +1942,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/src/draftwright/model/ir.py b/src/draftwright/model/ir.py index 2e265612..52c3aac0 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/model/ir_foundation.py b/src/draftwright/model/ir_foundation.py index ab4524f4..820c4a5b 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 006a135c..429623a4 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/src/draftwright/sheet.py b/src/draftwright/sheet.py index 704975ca..cb1af2a3 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") diff --git a/tests/_tier_manifest.py b/tests/_tier_manifest.py index 937e4587..2c0400c8 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 275f7918..78d73c66 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, ) @@ -900,7 +901,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 +953,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") @@ -966,6 +977,93 @@ def test_typed_manufacturing_row_keeps_plain_sibling_diameters_in_the_shared_sol } +@pytest.mark.parametrize("requested_decimals", [None, 1]) +@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) + * 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(owner_diameter, 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" + ] + 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 = "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): align = (Align.CENTER, Align.CENTER, Align.MIN) part = Cylinder(5.0, 2.0, align=align).rotate(Axis.Y, 90) + ( @@ -1712,7 +1810,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 +1823,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 2cfd291b..552dae0f 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" diff --git a/tests/test_tier_manifest.py b/tests/test_tier_manifest.py index 8e3e68e3..a0cf3552 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