Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/sheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/draftwright/linting/pmi_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
58 changes: 48 additions & 10 deletions src/draftwright/model/compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -64,6 +70,7 @@
EnvelopeFeature,
Feature,
HoleFeature,
KnurlRequirement,
Note,
PadFeature,
PartModel,
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/draftwright/model/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions src/draftwright/model/ir_foundation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from draftwright import contract_values
from draftwright._geometry import (
_fmt,
_fmt_pmi_magnitude,
plane_axes,
)
from draftwright.feature_identity import (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/draftwright/model/manufacturing_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 3 additions & 1 deletion src/draftwright/sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions tests/_tier_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 104 additions & 2 deletions tests/test_issue_1298_manufacturing_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
PartModel,
PatternFeature,
PmiFeature,
RequestedDimension,
StepFeature,
ThreadRequirement,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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) + (
Expand Down Expand Up @@ -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 = []
Expand All @@ -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 = [
Expand Down
Loading
Loading