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
68 changes: 45 additions & 23 deletions src/draftwright/annotations/holes.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
_diameter_column_left,
_diameter_row_below,
callout_from_spec,
hole_callout_spec,
)
from draftwright.annotations.hole_leader_candidates import (
FrontHoleLeaderCandidateAdapter,
Expand Down Expand Up @@ -116,6 +115,37 @@
_AXIS_ALIGN_COS = 0.9996


def _editable_hole_callout_batches(
feature, model, views, *, include_source_pmi, manufacturing_tags=None
):
"""Read one edit verb through the same member-aware batches as automatic ink."""
from draftwright.model.callout import hole_callout_batches

group = next(
(
candidate
for candidate in plan_dimensions(model, planned_views=views)
if candidate.feature is feature
),
None,
)
batches = (
hole_callout_batches(
(group,),
include_source_pmi=include_source_pmi,
manufacturing_tags=manufacturing_tags,
)
if group is not None
else ()
)
if len(batches) > 1:
raise ValueError(
"callout(): this pattern needs separate member callouts; "
"one editable callout would lose member requirements"
)
return group, batches


def add_feature_callout(
dwg,
feature,
Expand All @@ -129,9 +159,10 @@ def add_feature_callout(
"""Add a hole/pattern ø-depth **leader callout** for *feature* — the #414 add verb,
the callout-mechanism half of the editable surface (symmetric with :meth:`Drawing.drop`).

Funnels into the same :func:`hole_callout_spec` / :func:`callout_from_spec` the
auto-pass uses, so the callout text (ø, ``n×``, through/depth, cbore, pattern suffix)
is identical. Placement is a single reasonable leader beside the feature's end-on view
Funnels into the same batching and :func:`callout_from_spec` the auto-pass uses,
so the callout text (ø, ``n×``, through/depth, cbore, pattern suffix) is identical.
A pattern needing separate member callouts raises instead of losing requirements.
Placement is a single reasonable leader beside the feature's end-on view
— not the auto-pass's whole-set priority solve (byte-identity is not a goal, #400 Ph2):
a lone added callout goes into free strip space and leans on :meth:`Drawing.repair` /
the coverage lint for the rest. The leader is tagged with *feature* so :meth:`drop` /
Expand All @@ -147,22 +178,14 @@ def add_feature_callout(
"callout(): feature is not from this drawing's model — "
"pass one from dwg.model().features"
)
group = next(
(
g
for g in plan_dimensions(model, planned_views=tuple(dwg.views))
if g.feature is feature
),
None,
)
spec = (
hole_callout_spec(
group,
include_source_pmi=not ctx.document_member or cast(Analysis, a).pmi_mode == "annotate",
)
if group is not None
else None
group, batches = _editable_hole_callout_batches(
feature,
model,
tuple(dwg.views),
include_source_pmi=not ctx.document_member or cast(Analysis, a).pmi_mode == "annotate",
manufacturing_tags=getattr(ctx, "manufacturing_tags", None),
)
spec = batches[0].spec if batches else None
if spec is None:
if any(o.feature is feature and o.authored for o in compile_dimensions(model).diagnostics):
# "Exposes none" would be a false claim: the feature exposes a bore ⌀ and was
Expand All @@ -181,11 +204,10 @@ def add_feature_callout(
f"{type(feature).__name__} exposes none — use dimension() for a linear param"
)
draft = dwg.draft
members = feature.members or (feature.frame.origin,)
# count comes from the spec (== feat.count) — the same source the auto-pass's
# bare path uses — not re-derived from len(members).
members = batches[0].locations
# The batch, not the feature, owns the count represented by this callout.
callout = callout_from_spec(spec, draft, spec["count"])
assert callout is not None # spec is non-None here, so callout_from_spec returns one
assert callout is not None
view = view or (group.view if group is not None else _END_ON[feature.frame.axis])
if view not in (*_END_ON.values(), "rear") or (view == "rear" and feature.frame.axis != "y"):
raise ValueError(f"callout(): view {view!r} is not a hole-callout view for this axis")
Expand Down
31 changes: 25 additions & 6 deletions src/draftwright/build_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,15 +432,34 @@ def _record(winner, attempts):
# the default preserved A. That is the opposite of "preserve every supported requirement
# or reject the candidate" (#1130).
#
# The rule is therefore one-sided, and deliberately so: the alternative may not introduce
# any blocker the preferred result did not already have. It is free to preserve MORE, and
# it does not have to beat the historical arrangement on volume — that arrangement
# was the comparison floor before this local choice existed, so an alternative
# earns its place by costing nothing, not by costing less.
# The comparison is one-sided for ordinary losses: the alternative may not introduce
# a different blocker merely to reduce the number lost. The exception below applies
# only when both drawings are incomplete and the alternative preserves a strict
# superset of source-authored requirements without losing another source requirement
# or introducing a warning/error-level inferred loss.
introduced = collections.Counter(map(_blocker_identity, blockers)) - collections.Counter(
map(_blocker_identity, preferred_blockers)
)
if introduced:
# Neither arrangement can be called complete when both have blockers. In that
# case, a candidate that preserves strictly more source-authored requirements
# may still win despite losing informational inferred measurements. Keep the losses visible
# on the chosen drawing; a different source loss never trades for this gain.
candidate_source = collections.Counter(
_blocker_identity(blocker) for blocker in blockers if blocker.get("source_ids")
)
preferred_source = collections.Counter(
_blocker_identity(blocker) for blocker in preferred_blockers if blocker.get("source_ids")
)
preserves_more_source = (
not candidate_source - preferred_source
and bool(preferred_source - candidate_source)
and all(
blocker.get("severity") == "info"
for blocker in blockers
if _blocker_identity(blocker) in introduced
)
)
if introduced and not preserves_more_source:
return _record(
preferred,
[
Expand Down
17 changes: 17 additions & 0 deletions src/draftwright/drawing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1621,6 +1621,8 @@ def callout(self, feature, *, view=None, name=None) -> str:
whole-set solve (byte-identity is not a goal, #400 Ph2) — :meth:`repair` tidies the
rest. A step/boss diameter that finds no room returns ``""`` (a warning-level drop,
like the auto-pass), rather than raising, so a reconstruction script never aborts.
A hole pattern needing separate member callouts raises; automatic annotation
emits them with their own requirement provenance.
"""
kind = getattr(feature, "kind", None)
if (kind in _MACHINED_CALLOUT_KINDS or kind in ("pocket_pattern", "slot_pattern")) and (
Expand All @@ -1641,6 +1643,21 @@ def callout(self, feature, *, view=None, name=None) -> str:
# live call returns "" with an `authored_omission` build issue, and the deferred
# intent drains through the same migrated renderers to the same nothing.
if self._defer_intents: # #426: record, don't place — finalize() drains it
if (
kind in ("hole", "pattern")
and self._part_model is not None
and (not self._document_member or self._analysis is not None)
and any(owner is feature for owner in self._part_model.features)
):
from draftwright.annotations.holes import _editable_hole_callout_batches

_editable_hole_callout_batches(
feature,
self._part_model,
tuple(self.views),
include_source_pmi=not self._document_member
or getattr(self._analysis, "pmi_mode", None) == "annotate",
)
self._intents.append(Intent("callout", feature, {"view": view, "name": name}))
return ""
from draftwright.annotations.holes import add_feature_callout, add_feature_diameter
Expand Down
6 changes: 6 additions & 0 deletions src/draftwright/linting/pmi_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ def _registry_names_for_decoration(registry, key: tuple) -> list:
kind = str(key[1]) if len(key) > 1 else ""
role = str(key[2]) if len(key) > 2 else ""
parameter = f"{role}.{kind}" if role else ""
if parameter and len(key) > 3:
parameter += f".{key[3]}"
return [
name
for name in registry.names()
Expand Down Expand Up @@ -203,6 +205,10 @@ def _decorated_source_features(decorations, *, features=()) -> list[tuple[tuple,
out.append((key, source_ids))
for feature in features:
owner = feature
for index, requirement in enumerate(getattr(feature, "member_size_requirements", ())):
source_ids = _source_ids(requirement)
if source_ids:
out.append(((owner, "diameter", "bore", f"member_{index}"), source_ids))
target = getattr(feature, "member", feature)
thread = getattr(target, "thread", None)
source_ids = _source_ids(thread)
Expand Down
96 changes: 95 additions & 1 deletion src/draftwright/model/callout.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,60 @@ class HoleCalloutBatch:
spec: dict


def _merge_member_pattern_batches(batches: list[HoleCalloutBatch]) -> list[HoleCalloutBatch]:
"""Use one callout for members with identical complete printed requirements."""
excluded = {
"count",
"measurements",
"source_measurements",
"geometry_measurements",
"geometry_qualifiers",
"source_ids",
"source_features",
"owner_counts",
}
grouped: dict[tuple, list[HoleCalloutBatch]] = {}
for batch in batches:
key = (
id(batch.groups[0].feature),
any(
measurement.parameter.startswith("bore.diameter.member_")
for _source_id, measurement in batch.spec["source_measurements"]
),
tuple((name, value) for name, value in batch.spec.items() if name not in excluded),
)
grouped.setdefault(key, []).append(batch)
result = []
for siblings in grouped.values():
first = siblings[0]
feature = first.groups[0].feature
locations = tuple(location for sibling in siblings for location in sibling.locations)
spec = dict(first.spec)
spec["count"] = len(locations) if len(locations) > 1 else None
for field in (
"measurements",
"source_measurements",
"geometry_measurements",
"source_ids",
):
spec[field] = tuple(
dict.fromkeys(item for sibling in siblings for item in sibling.spec[field])
)
qualifiers = tuple(
dict.fromkeys(
item for sibling in siblings for item in sibling.spec["geometry_qualifiers"]
)
)
spec["geometry_qualifiers"] = (
(*qualifiers, "grouping.count") if len(locations) > 1 else qualifiers
)
spec["owner_counts"] = ((feature, len(locations)),)
result.append(
HoleCalloutBatch(tuple(sibling.groups[0] for sibling in siblings), locations, spec)
)
return result


def bore_callout_value(spec: dict, tolerance_suffix=lambda _value: "") -> str:
"""Format the bore value after the callout's leading diameter symbol."""
if limits := spec.get("diameter_limits"):
Expand Down Expand Up @@ -77,10 +131,48 @@ def hole_callout_batches(
member sites. Pattern furniture and profiled supports remain independent. A
batch never combines overlapping member sites or incomplete count inventories.
"""
member_batches: list[HoleCalloutBatch] = []
buckets: dict[tuple, list[list]] = {}
ordered: list[list] = []
for group in groups:
feature = group.feature
if isinstance(feature, PatternFeature) and feature.member_size_requirements:
complete = tuple(feature.members or (group.anchor,))
locations = (
complete if member_locations is None else member_locations.get(id(feature), ())
)
for index, point in enumerate(complete):
if point not in locations:
continue
member_id = f"bore.diameter.member_{index}"
member_group = replace(
group,
units=tuple(
unit
for unit in group.units
if unit.id == member_id
or not str(unit.id).startswith("bore.diameter.member_")
),
)
spec = hole_callout_spec(
member_group,
include_source_pmi=include_source_pmi,
manufacturing_tags=manufacturing_tags,
)
if spec is None:
continue
spec["count"] = None
spec["pattern_suffix"] = None
spec["geometry_qualifiers"] = tuple(
qualifier
for qualifier in spec["geometry_qualifiers"]
if qualifier != "grouping.count"
)
spec["suffix"] = hole_callout_suffix(spec)
spec["source_features"] = (feature,)
spec["owner_counts"] = ((feature, 1),)
member_batches.append(HoleCalloutBatch((member_group,), (point,), spec))
continue
spec = hole_callout_spec(
group,
include_source_pmi=include_source_pmi,
Expand Down Expand Up @@ -186,7 +278,9 @@ def hole_callout_batches(
spec,
)
)
return _qualify_coincident_axial_patterns(result)
return _qualify_coincident_axial_patterns(
[*_merge_member_pattern_batches(member_batches), *result]
)


def _qualify_coincident_axial_patterns(
Expand Down
2 changes: 2 additions & 0 deletions src/draftwright/model/declare.py
Original file line number Diff line number Diff line change
Expand Up @@ -1882,6 +1882,7 @@ def pattern(
rows=None,
cols=None,
angle=None,
member_size_requirements=(),
) -> PatternFeature:
"""A hole pattern = ``count`` × a *member* hole (build one with :func:`hole`). The
arrangement (``bolt_circle`` / ``linear`` / ``grid``) and its defining dims (``bcd`` /
Expand Down Expand Up @@ -1967,6 +1968,7 @@ def pattern(
rows=rows,
cols=cols,
angle=angle,
member_size_requirements=tuple(member_size_requirements),
)


Expand Down
Loading
Loading