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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## v0.3.62

### Added

- **`find_countersinks()` — recognise conical countersinks**, the feature `find_holes` reports only as a plain opening. A countersink is detected as an internal `CONE` face that flares from a drilled bore (minor circular edge) out to a larger opening (major circle) and is **coaxial with a `CYLINDER` of the drill radius** — which excludes drill-point cones (a single circle + apex) and external edge chamfers (no coaxial bore). Returns `{count, countersinks: [{location, axis (into the part), major_diameter, drill_diameter, included_angle (e.g. 82/90/100/120°; near-flat cones above 160° are rejected as drafts), depth}]}`. First **in-house, Apache-licensed** recognizer (build123d/OCP only, in `tools/recognizers/`) — kept self-contained so it can be repatriated into a shared permissive recognition package later; build123d ships `CounterSinkHole` to build them but no recognizer for them. (#349-adjacent)
- **`verify_spec` gains a `{kind:"countersink", count, major_diameter_mm, drill_diameter_mm, included_angle_deg, depth_mm}` feature** (any subset), wiring the new recognizer into the conformance gate so countersinks are checkable requested-vs-built, not just discoverable.

## v0.3.61

### Changed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ When using an AI to write build123d scripts, the AI writes blind — it cannot s
- `cross_sections` — cross-sectional areas at evenly spaced planes along X/Y/Z; useful for detecting voids and wall-thickness variation
- `resolve` — evaluate a selector expression (e.g. `.faces().filter_by(Axis.Z).last()`) against a named object and return a geometry descriptor
- `find_holes` / `find_bosses` / `find_hole_patterns` — feature recognition: coaxial drill + counterbore + spotface stacks as one hole record (axis, location, diameter, depth, bottom: through/flat/drill_point/unknown), external bosses with height, bolt-circle and linear-array patterns
- `find_countersinks` — recognise conical countersinks (major/drill diameter, included angle, depth) that `find_holes` reports only as plain openings
- `analyze_printability` — BREP-exact FDM printability analysis: overhangs, thin walls, minimum features, bed fit, tip-over risk
- `design_audit` — audit the session program as a *design*, not just a shape: surface its named numeric parameters and perturb each ±ε in isolation, re-running the validity gate to flag *brittle* parameters where a small edit collapses the solid (Arko-T design-state robustness)
- `verify_spec` — check the built solid against a declared design-intent spec (envelope, solid count/validity, hole/boss features, parameter ranges): answers *"did I build what was requested?"* with an evidence-tiered PASS/FAIL/UNVERIFIED conformance report; reusable as a regression/acceptance gate after edits
Expand Down
1 change: 1 addition & 0 deletions llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ Check the built solid against a **declared design-intent spec** — the "did I b
- `{kind:"hole", count, diameter_mm, depth_mm, through:bool, counterbore:{diameter_mm, depth_mm}|true|false, spotface:{...}}` — any subset of attributes; all frame-independent (absolute position is not matched). `counterbore`/`spotface`: `true` requires one present, `false` requires it absent, an object matches its dims. Note a `depth_mm` on `counterbore`/`spotface` is matched against the **recognizer-measured** depth, which can differ from a drawing callout — match on `diameter_mm` when unsure.
- `{kind:"hole_pattern", pattern:"bolt_circle"|"linear_array", holes, bcd_mm (bolt_circle) | pitch_mm (linear_array), diameter_mm}`
- `{kind:"boss", diameter_mm, height_mm}`
- `{kind:"countersink", count, major_diameter_mm, drill_diameter_mm, included_angle_deg, depth_mm}` — any subset; conical screw-head recesses (see `find_countersinks`). A shallow lead-in/deburr chamfer also registers as a small countersink.

`min_wall_mm` and `targets: [{name, verifiable:false}]` are reported UNVERIFIED (deferred / out of scope), not silently dropped. Feature kinds beyond hole/hole_pattern/boss (pocket, fillet, chamfer, rib, …) need new recognizers and currently read UNVERIFIED.

Expand Down
8 changes: 7 additions & 1 deletion src/build123d_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def design_audit(epsilon: float = 0.1, max_params: int = 8) -> str:

@mcp.tool()
def verify_spec(spec: str = "", spec_path: str = "", object_name: str = "") -> str:
"""Verify the built solid against a declared design-intent spec — did you build what was requested? Checks requested features/constraints against the actual geometry and returns an evidence-tiered conformance report; unlike validate() (is the solid valid?) this answers requested-vs-built. Provide the spec as inline JSON (spec=) or a .json file path (spec_path=). Supported spec keys: envelope_mm {x/y/z:[lo,hi]} (bbox size in range), solid {count, valid}, volume_mm3 {min,max}, features:[{kind:"hole_pattern",pattern:"bolt_circle"|"linear_array",holes,bcd_mm|pitch_mm,diameter_mm} | {kind:"hole",count,diameter_mm,depth_mm,through:bool,counterbore:{diameter_mm,depth_mm}|true|false,spotface:{...}} | {kind:"boss",diameter_mm,height_mm}] (counterbore/spotface: true=present, false=absent; a depth_mm matches the recognizer-measured depth which may differ from a drawing callout), parameters:[{name,min,max}] (top-level numeric assignment in range), min_wall_mm (deferred→UNVERIFIED), targets:[{name,verifiable:false}] (→UNVERIFIED). Returns JSON: {conformance:[{requirement, status:PASS|FAIL|UNVERIFIED, tier:measured|structural|recognised|unverified, actual/found/hint}], summary:{pass,fail,unverified,conforms}, note}. conforms = no FAILs; UNVERIFIED requirements are NOT met (out of scope), never counted as passing. Dimensions match within max(0.1mm, 1%); counts exact; an unrecognised feature kind is UNVERIFIED, not a false FAIL. Not a certification. Re-run after edits as a regression/acceptance gate. object_name: named object from show() (default: current shape)."""
"""Verify the built solid against a declared design-intent spec — did you build what was requested? Checks requested features/constraints against the actual geometry and returns an evidence-tiered conformance report; unlike validate() (is the solid valid?) this answers requested-vs-built. Provide the spec as inline JSON (spec=) or a .json file path (spec_path=). Supported spec keys: envelope_mm {x/y/z:[lo,hi]} (bbox size in range), solid {count, valid}, volume_mm3 {min,max}, features:[{kind:"hole_pattern",pattern:"bolt_circle"|"linear_array",holes,bcd_mm|pitch_mm,diameter_mm} | {kind:"hole",count,diameter_mm,depth_mm,through:bool,counterbore:{diameter_mm,depth_mm}|true|false,spotface:{...}} | {kind:"boss",diameter_mm,height_mm} | {kind:"countersink",count,major_diameter_mm,drill_diameter_mm,included_angle_deg,depth_mm}] (counterbore/spotface: true=present, false=absent; a depth_mm matches the recognizer-measured depth which may differ from a drawing callout), parameters:[{name,min,max}] (top-level numeric assignment in range), min_wall_mm (deferred→UNVERIFIED), targets:[{name,verifiable:false}] (→UNVERIFIED). Returns JSON: {conformance:[{requirement, status:PASS|FAIL|UNVERIFIED, tier:measured|structural|recognised|unverified, actual/found/hint}], summary:{pass,fail,unverified,conforms}, note}. conforms = no FAILs; UNVERIFIED requirements are NOT met (out of scope), never counted as passing. Dimensions match within max(0.1mm, 1%); counts exact; an unrecognised feature kind is UNVERIFIED, not a false FAIL. Not a certification. Re-run after edits as a regression/acceptance gate. object_name: named object from show() (default: current shape)."""
return _resolve_session().verify_spec(spec, spec_path, object_name)


Expand Down Expand Up @@ -500,6 +500,12 @@ def find_bosses(object_name: str = "") -> str:
return _resolve_session().find_bosses(object_name)


@mcp.tool()
def find_countersinks(object_name: str = "") -> str:
"""Recognise countersinks (conical screw-head recesses) on a session object (defaults to current shape) — the feature find_holes reports only as a plain opening. A countersink is an internal cone flaring from a drilled bore out to a larger opening, coaxial with the drill; drill-point cones and external edge chamfers are excluded. Returns JSON: {count, countersinks: [{location (opening centre), axis (into the part), major_diameter (countersink Ø at the surface), drill_diameter, included_angle (deg, e.g. 82/90/100/120), depth}]}. object_name: named object from show() (default: current shape)."""
return _resolve_session().find_countersinks(object_name)


@mcp.tool()
def align_check(object_a: str, object_b: str, axis: str = "Z", mode: str = "flush") -> str:
"""Check alignment between two named objects along an axis. axis: X, Y, or Z. mode: flush (signed distance between bbox extremes — positive=A extends further), center (offset between bbox centroids), clearance (gap between nearest faces — positive=apart, negative=overlap). Returns JSON: {delta, axis, mode, object_a, object_b, interpretation}."""
Expand Down
6 changes: 6 additions & 0 deletions src/build123d_mcp/tools/recognizers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""In-house Apache-licensed feature recognizers for build123d-mcp.

Kept self-contained (build123d/OCP only, no session coupling in the pure
recognizer functions) so they can be repatriated into a shared permissive
recognition package later without a rewrite. See the licensing note.
"""
109 changes: 109 additions & 0 deletions src/build123d_mcp/tools/recognizers/countersink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Countersink recognition (in-house, Apache).

build123d builds countersinks (``CounterSinkHole``) but the external hole
recognizer reports them as plain openings, not as countersinks. A countersink is
an internal CONE face that flares from a drilled bore (minor circle) out to a
larger opening (major circle) — coaxial with a CYLINDER of the drill radius. We
key on exactly that: a cone with two distinct-radius circular edges whose smaller
radius matches a coaxial drilled cylinder. This excludes drill-point cones (a
single circle + apex, not flared) and external edge chamfers (no coaxial bore).

``recognise_countersinks(part)`` is pure (build123d/OCP only) so it can move to a
shared recognition package unchanged; ``find_countersinks(session, ...)`` is the
thin MCP tool wrapper.

Heuristic limits (``recognised`` tier): a small lead-in / deburr chamfer at a hole
mouth is geometrically a shallow countersink and also registers (its small
``major_diameter``/``depth`` make that visible); a near-flat cone above
``_MAX_INCLUDED_ANGLE`` (a draft/relief) is excluded; and a countersink cone
clipped by another feature (its edges no longer full circles) is missed.
"""

import json
import math

from build123d import GeomType

_TOL = 0.05 # mm — dimension match tolerance (matches other feature matchers)
_COAXIAL_TOL = 0.1 # mm — how far the opening may sit off the drill's axis line
# Real countersinks are ≤120° included (60/82/90/100/120 standards); a near-flat
# cone is a draft/relief/washer face, not a countersink. 160° keeps every real
# countersink with margin while excluding drafts (~176–178° included).
_MAX_INCLUDED_ANGLE = 160.0


def _parallel(a, b) -> bool:
return abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) > 1 - 1e-3


def _dist_to_line(pt, line_pt, line_dir) -> float:
v = (pt[0] - line_pt[0], pt[1] - line_pt[1], pt[2] - line_pt[2])
t = v[0] * line_dir[0] + v[1] * line_dir[1] + v[2] * line_dir[2]
perp = (v[0] - t * line_dir[0], v[1] - t * line_dir[1], v[2] - t * line_dir[2])
return math.sqrt(perp[0] ** 2 + perp[1] ** 2 + perp[2] ** 2)


def recognise_countersinks(part) -> list:
"""Return countersink records: {location (opening), axis, major_diameter,
drill_diameter, included_angle, depth}. Pure — no session coupling."""
from OCP.BRepAdaptor import BRepAdaptor_Surface

cyls = []
for cy in part.faces().filter_by(GeomType.CYLINDER):
ax = BRepAdaptor_Surface(cy.wrapped).Cylinder().Axis()
p, d = ax.Location(), ax.Direction()
cyls.append((cy.radius, (p.X(), p.Y(), p.Z()), (d.X(), d.Y(), d.Z())))

out = []
for f in part.faces().filter_by(GeomType.CONE):
circles = sorted(f.edges().filter_by(GeomType.CIRCLE), key=lambda e: e.radius)
if len(circles) < 2:
continue # drill-point cone (one circle + apex) or degenerate
minor_e, major_e = circles[0], circles[-1]
minor_r, major_r = minor_e.radius, major_e.radius
if major_r - minor_r < _TOL:
continue # not flared — not a countersink
cone = BRepAdaptor_Surface(f.wrapped).Cone()
included_angle = round(2 * abs(math.degrees(cone.SemiAngle())), 2)
if included_angle > _MAX_INCLUDED_ANGLE:
continue # a near-flat cone is a draft/relief/washer face, not a countersink
opening = major_e.arc_center
opening_pt = (opening.X, opening.Y, opening.Z)
mc = minor_e.arc_center
minor_pt = (mc.X, mc.Y, mc.Z)
# Axis points INTO the part: from the wide opening toward the drilled bore.
# (Deterministic — don't trust OCP's cone-axis sign across constructions.)
av = (minor_pt[0] - opening_pt[0], minor_pt[1] - opening_pt[1], minor_pt[2] - opening_pt[2])
alen = math.sqrt(av[0] ** 2 + av[1] ** 2 + av[2] ** 2) or 1.0
axis = (av[0] / alen, av[1] / alen, av[2] / alen)
# A countersink sits on a drilled bore: a coaxial cylinder of the minor radius.
if not any(
abs(r - minor_r) <= _TOL
and _parallel(axis, ld)
and _dist_to_line(opening_pt, lp, ld) <= _COAXIAL_TOL
for r, lp, ld in cyls
):
continue
out.append(
{
"location": [round(v, 4) for v in opening_pt],
"axis": [round(v, 4) for v in axis],
"major_diameter": round(2 * major_r, 4),
"drill_diameter": round(2 * minor_r, 4),
"included_angle": included_angle,
"depth": round(alen, 4),
}
)
return out


def find_countersinks(session, object_name: str = "") -> str:
"""Recognise countersinks on a named session object (MCP tool wrapper)."""
from build123d_mcp.tools.measure import _resolve_shape

try:
shape = _resolve_shape(session, object_name)
except ValueError as exc:
return json.dumps({"error": str(exc)})
cs = recognise_countersinks(shape)
return json.dumps({"count": len(cs), "countersinks": cs})
46 changes: 44 additions & 2 deletions src/build123d_mcp/tools/verify_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ def _spec_shape_error(data: dict) -> str | None:
"height_mm",
"holes",
"count",
"major_diameter_mm",
"drill_diameter_mm",
"included_angle_deg",
):
if k in f and not _is_num(f[k]):
return f"features[{i}].{k} must be a number"
Expand Down Expand Up @@ -320,11 +323,44 @@ def _check_boss(f: dict, bosses: list, err, out: list) -> None:
out.append({"requirement": req, "status": "FAIL", "tier": "recognised"})


def _check_countersink(f: dict, csinks: list, err, out: list) -> None:
want = f.get("count", 1)
req = f"{want}× countersink" + (
f" Ø{f['major_diameter_mm']}" if "major_diameter_mm" in f else ""
)
if err:
out.append({"requirement": req, "status": "UNVERIFIED", "tier": "unverified", "note": err})
return

def _matches(c: dict) -> bool:
for spec_key, rec_key in (
("major_diameter_mm", "major_diameter"),
("drill_diameter_mm", "drill_diameter"),
("included_angle_deg", "included_angle"),
("depth_mm", "depth"),
):
if spec_key in f and not _close(c.get(rec_key), f[spec_key]):
return False
return True

matching = [c for c in csinks if _matches(c)]
ok = len(matching) == want if "count" in f else len(matching) >= 1
out.append(
{
"requirement": req,
"status": "PASS" if ok else "FAIL",
"tier": "recognised",
"found": len(matching),
}
)


def _check_features(session, object_name: str, features: list, out: list) -> None:
from build123d_mcp.tools.find_features import find_bosses, find_hole_patterns, find_holes
from build123d_mcp.tools.recognizers.countersink import find_countersinks

holes = pats = bosses = None
holes_err = pats_err = bosses_err = None
holes = pats = bosses = csinks = None
holes_err = pats_err = bosses_err = csinks_err = None
for f in features:
kind = f.get("kind")
if kind == "hole_pattern":
Expand All @@ -339,6 +375,12 @@ def _check_features(session, object_name: str, features: list, out: list) -> Non
if bosses is None:
bosses, bosses_err = _recognise(find_bosses, session, object_name, "bosses")
_check_boss(f, bosses, bosses_err, out)
elif kind == "countersink":
if csinks is None:
csinks, csinks_err = _recognise(
find_countersinks, session, object_name, "countersinks"
)
_check_countersink(f, csinks, csinks_err, out)
else:
out.append(
{
Expand Down
4 changes: 4 additions & 0 deletions src/build123d_mcp/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,10 @@ def drafting_api(self) -> str:
def find_holes(self, object_name: str = "") -> str:
raise NotImplementedError

@_op(_tool(f"{_T}.recognizers.countersink:find_countersinks"), _GEOMETRY_TIMEOUT)
def find_countersinks(self, object_name: str = "") -> str:
raise NotImplementedError

@_op(_tool(f"{_T}.find_features:find_bosses"), _GEOMETRY_TIMEOUT)
def find_bosses(self, object_name: str = "") -> str:
raise NotImplementedError
Expand Down
Loading
Loading