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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,20 @@

### Added

- **Tapered bosses (convex cones) are now a feature.** A full-revolution
CONVEX cone — a conical stud or draft-angled boss, truncated or
pointed, standing on a face — is detected (`cone_boss`), planned as an
additive `ConeOp`, and rebuilt as a `PartDesign::AdditiveCone`
primitive (base on the face, tip exact). This is the convex mirror
image of the existing conical pocket. The core already *fit* convex
cones (coverage 1.0); previously the planner had no additive path, so
a tapered boss was left unassigned and the terrace machinery
substituted a straight-walled pad of the base radius — the right
footprint at the wrong profile, reported as a clean reconstruction.
Headless suite: 23 new `test_cone_boss.py` tests (detection, planning,
round-trip); smoke test gains a tapered-boss case.
- **FreeCAD executor CI.** A second CI job (`freecad-smoke`) runs the
full headless smoke test (`scripts/freecad_smoke_test.py`: 17 parts
full headless smoke test (`scripts/freecad_smoke_test.py`: 18 parts
through reconstruct -> snap -> plan -> feature detection -> PartDesign
build, including the manifold3d deviation-correction case) inside a
sha256-verified FreeCAD 1.1.x AppImage on every push and PR. The
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ Recognized and rebuilt on the current release:
`HoleCutType = Counterdrill`.
- **Conical pockets**: tapered recesses, conical seats, and tapered
through holes, rebuilt as `PartDesign::SubtractiveCone` primitives.
- **Tapered bosses** (convex cones): conical studs and draft-angled
bosses, truncated or pointed, rebuilt as `PartDesign::AdditiveCone`
primitives standing on their base face.
- **Cross-axis holes** (horizontal holes), both through and blind (a
blind side hole is rebuilt as a depth-limited pocket).
- **Vertical bosses** and **lateral pads** — flanges, rails, and gusseted
Expand All @@ -61,11 +64,11 @@ Recognized and rebuilt on the current release:
## Limitations (please read before filing bugs)

- **Prismatic parts only.** The reconstruction targets parts made of
planes and cylinders (plates, brackets, housings, fixtures), plus
conical countersinks and conical pockets. Organic / sculpted / scanned
shapes will not reconstruct meaningfully. Spheres and tori are fitted
by the core but not yet rebuilt as features; CONVEX cones (tapered
bosses, draft-angled studs) are not yet a feature either.
planes, cylinders, and cones (plates, brackets, housings, fixtures),
plus conical countersinks, conical pockets, and tapered bosses.
Organic / sculpted / scanned shapes will not reconstruct meaningfully.
Spheres and tori are fitted by the core but not yet rebuilt as
features.
- **Freeform transition geometry is carried by the correction pass.**
Some parts blend a wall into a base through a swept, freeform
transition band (the Thingiverse corner bracket is the canonical
Expand Down
12 changes: 9 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ Exit criteria:
the job — verified once during development.
- Headless suite still green; dependency docs accurate.

Status: **in progress**.
Status: **done** — merged as PR #11 (2026-10-28); ships in the next
release.

## Phase 2 — Feature depth: convex cones, spheres, tori
(current slice: `feature/additive-cones`)

The README's core limitation is that the core *fits* spheres, tori, and
convex cones but the planner/executor do not *rebuild* them. Each item
Expand All @@ -50,7 +52,10 @@ Order (smallest documented gap first):
1. **Convex cones** (tapered bosses, draft-angled studs) as
`PartDesign::AdditiveCone` — the `SubtractiveCone` path (ConeOp)
already exists end to end; the convex rule, plan op, and executor
branch are the delta.
branch are the delta. **Done** on `feature/additive-cones`: the
`cone_boss` feature, the additive `ConeOp`, the `AdditiveCone`
executor branch (base sunk into the face, tip exact), 23 headless
tests, and a smoke case.
2. **Spheres** (spherical bosses and concave seats) — PartDesign has no
sphere primitive; the executor builds a placed `Part::Sphere` revolve
(circle sketch) fused/cut through the body, or declines loudly.
Expand All @@ -62,7 +67,8 @@ Exit criteria: corpus parts carrying each new feature class with analytic
ground truth; smoke case per executor path; no unplanned/silent drops of
newly recognized features.

Status: not started.
Status: in progress — item 1 (convex cones) done on
`feature/additive-cones`; items 2-3 (spheres, tori) not started.

## Phase 3 — Per-feature deviation report

Expand Down
42 changes: 31 additions & 11 deletions freecad/meshtofeatures_wb/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,7 @@ def build_body(doc, plan: BuildPlan, name: str = "Rebuilt"):

doc.recompute()

# ---- conical pockets: placed Subtractive/AdditiveCone primitives -------
# ---- conical features: placed Subtractive/AdditiveCone primitives ------
for k, c in enumerate(getattr(plan, "cones", [])):
try:
diag = float(body.Shape.BoundBox.DiagonalLength) or float(L)
Expand All @@ -780,19 +780,39 @@ def build_body(doc, plan: BuildPlan, name: str = "Rebuilt"):
slope = (c.r_mouth - c.r_far) / max(c.depth, 1e-12)
face_z = c.surface_z if c.surface_z is not None \
else (L if c.from_top else 0.0)
far_pad = pad if c.through else 0.0
r_end = max(c.r_far - slope * far_pad, 0.0)
height = c.depth + far_pad + pad
if c.from_top:
z_lo = float(face_z) - c.depth - far_pad
r_lo, r_hi = r_end, c.r_mouth + slope * pad
if c.additive:
# tapered boss: the base (r_mouth, the wide end) sits on the
# face and the feature grows OUT of the material, along the
# face's outward normal. SINK the base `pad` INTO the face so
# the fuse does not rest on a coincident-face boolean (the cut
# buffer doctrine, applied to an add); the sunk leg keeps the
# cone's exact slope, so the base lands exactly. The TIP is the
# outermost point (in the air, coincident with nothing), so it
# is placed EXACTLY -- extending it would overshoot the source.
if c.from_top:
z_lo = float(face_z) - pad
r_lo, r_hi = c.r_mouth + slope * pad, c.r_far
height = c.depth + pad
else:
z_lo = float(face_z) - c.depth
r_lo, r_hi = c.r_far, c.r_mouth + slope * pad
height = c.depth + pad
name = f"TaperedBoss{k}"
else:
z_lo = float(face_z) - pad
r_lo, r_hi = c.r_mouth + slope * pad, r_end
far_pad = pad if c.through else 0.0
r_end = max(c.r_far - slope * far_pad, 0.0)
height = c.depth + far_pad + pad
if c.from_top:
z_lo = float(face_z) - c.depth - far_pad
r_lo, r_hi = r_end, c.r_mouth + slope * pad
else:
z_lo = float(face_z) - pad
r_lo, r_hi = c.r_mouth + slope * pad, r_end
name = f"ConePocket{k}"
for pos in c.positions:
_add_cone(doc, body, plan, f"ConePocket{k}", pos[0], pos[1],
_add_cone(doc, body, plan, name, pos[0], pos[1],
z_lo, r_lo, r_hi, height, additive=c.additive,
label=c.label or f"ConePocket{k}", deferred=deferred)
label=c.label or name, deferred=deferred)
doc.recompute()

# ---- cross-axis holes: through = midplane pocket, blind = Length -------
Expand Down
77 changes: 77 additions & 0 deletions freecad/meshtofeatures_wb/core/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,83 @@ def _ends(i: int) -> dict:
f"{'through' if through else f'x {depth:g} deep'}"),
))

# ---- standalone convex cones -> tapered bosses -------------------------
# The convex mirror image of the conical pocket above: a full-revolution
# cone whose WIDE end (the base) opens at a face and whose NARROW end
# (the tip) is either capped by a disk or runs to a point. This is a
# tapered boss / draft-angled stud. Claimed only when both ends are
# explained (base face + capped-or-pointed tip); anything less is left
# unassigned rather than guessed.
for ci in cones:
if ci in consumed:
continue
scone = report.surfaces[ci]
cone = scone.fit.primitive
if not patches[ci].full_u or _is_concave_cone(scone):
continue
ha = cone.half_angle
h_lo, h_hi = patches[ci].v_range # heights above the apex
r_wide = float(h_hi * np.tan(ha))
r_narrow = float(h_lo * np.tan(ha))
if r_wide <= tol:
continue
# the base: the WIDE end opening at a face (an interior hole loop
# rides the cone near the wide end)
base_p = None
for p in planes:
lps = _plane_hole_loops_on_cone(report.surfaces[p], cone, tol)
if not lps:
continue
h = max(float(np.mean((lp - cone.apex) @ cone.axis))
for lp in lps)
if abs(h - h_hi) <= max(tol, 0.1 * (h_hi - h_lo)):
base_p = p
break
if base_p is None:
continue
# the tip: the NARROW end, capped by a disk or running to a point
cap_p = None
if r_narrow > tol:
for p in planes:
if p == base_p or p in consumed:
continue
if _outer_loop_is_disk_on_cone(report.surfaces[p], cone, tol):
h = float(np.mean((report.surfaces[p].segment.points
- cone.apex) @ cone.axis))
if abs(h - h_lo) <= max(tol, 0.1 * (h_hi - h_lo)):
cap_p = p
break
if cap_p is None:
continue
tip_r = 0.0 if r_narrow <= tol else r_narrow
height = float(h_hi - h_lo)
if height <= tol:
continue
axis = cone.axis.copy() # apex -> wide end (base)
base_pt = cone.apex + h_hi * axis
anchor = base_pt - float(base_pt @ axis) * axis
used = [ci, base_p]
if cap_p is not None:
used.append(cap_p)
consumed.add(cap_p)
consumed.add(ci)
out.features.append(Feature(
kind="cone_boss",
surface_indices=used,
params={
"base_radius": r_wide,
"tip_radius": tip_r,
"height": height,
"position": anchor.tolist(),
"axis": axis.tolist(),
"base": base_pt.tolist(),
"half_angle": float(np.rad2deg(ha)),
},
description=(
f"Tapered boss d{2 * r_wide:g} -> d{2 * tip_r:g} "
f"x {height:g}"),
))

# ---- coaxial stacks of concave cylinders -> holes / counterbores ------
stacks: list[list[int]] = []
for i in full_concave:
Expand Down
24 changes: 24 additions & 0 deletions freecad/meshtofeatures_wb/core/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,10 @@ def plan_history(report: ReconstructionReport, feats: FeatureReport,
# would inflate the base extrusion to the top of the boss
boss_caps = {f.surface_indices[1] for f in feats.features
if f.kind == "boss" and len(f.surface_indices) >= 2}
# a tapered boss's tip cap (provenance [cone, base, cap]) is the same
# story: it is the feature's own end face, not a base extent
boss_caps |= {f.surface_indices[2] for f in feats.features
if f.kind == "cone_boss" and len(f.surface_indices) >= 3}
extent_members = [(i, s) for i, s in members if i not in boss_caps] or members
offsets = [float(s.fit.primitive.point @ z) for _, s in extent_members]
z0, z1 = min(offsets), max(offsets)
Expand Down Expand Up @@ -960,6 +964,26 @@ def _cross_hole_op(f):
# the floor disk is this cone's own end face: keep the terrace
# pass off it, or it also emits a straight-walled substitute
_cone_faces.update(f.surface_indices[2:])
elif f.kind == "cone_boss" and _aligned(f):
# tapered boss: r_mouth is the base radius AT the face it
# stands on, r_far the tip. The base face -- not the cone
# span -- fixes the side, since the boss grows OUT of the
# material (the mirror of a cone_pocket, which is cut INTO it)
base3d = np.asarray(f.params["base"], dtype=float)
bz = float((base3d - origin) @ z)
plan.cones.append(ConeOp(
r_mouth=float(f.params["base_radius"]),
r_far=float(f.params["tip_radius"]),
depth=float(f.params["height"]),
positions=[_pos2d(f)],
from_top=bz > plan.base.length / 2,
additive=True,
through=False,
surface_z=bz,
label=f.description))
# the tip cap is this cone's own end face: keep the terrace
# pass off it (as for cone_pocket's floor disk)
_cone_faces.update(f.surface_indices[2:])
elif f.kind == "slot" and _aligned(f) and f.params.get("open"):
# synthesize the closed notch profile: two lines + far arc +
# a mouth-closing segment pushed OUTWARD so the cut clears the
Expand Down
19 changes: 17 additions & 2 deletions freecad/meshtofeatures_wb/core/solidify.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,14 +360,29 @@ def _plan_to_mesh(plan, sections: int) -> trimesh.Trimesh:
sections=sections)
solid = solid.difference(fr)

# conical pockets / studs
# conical pockets / tapered bosses
for c in getattr(plan, "cones", []):
face_z = (float(c.surface_z) if c.surface_z is not None
else (L if c.from_top else 0.0))
depth = (face_z + 1.0 if c.from_top else L + 1.0 - face_z) \
if getattr(c, "through", False) else float(c.depth)
slope = (c.r_mouth - c.r_far) / max(float(c.depth), 1e-12)
for (x, y) in c.positions:
if c.from_top:
if c.additive:
# tapered boss: base (r_mouth, the wide end) on the face,
# growing OUT of the material. The base leg is sunk 0.5
# INTO the face (inside already-solid ground) along the
# exact slope; the tip is placed EXACTLY (it is the
# outermost point, so extending it would overshoot).
if c.from_top:
fr = _frustum(c.r_mouth + slope * 0.5, c.r_far,
face_z - 0.5, face_z + c.depth,
center=(x, y), sections=sections)
else:
fr = _frustum(c.r_far, c.r_mouth + slope * 0.5,
face_z - c.depth, face_z + 0.5,
center=(x, y), sections=sections)
elif c.from_top:
fr = _frustum(c.r_far, c.r_mouth,
face_z - depth, face_z + 0.5,
center=(x, y), sections=sections)
Expand Down
34 changes: 34 additions & 0 deletions freecad/meshtofeatures_wb/scripts/freecad_smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,40 @@ def check(name, cond, info=""):
f"{cbbody.Shape.Volume:.2f} vs {cbp.Volume:.2f}")
check("cbore body shape is valid", cbbody.Shape.isValid())

# --- tapered boss (AdditiveCone primitive) --------------------------------
# an r9 -> r5 truncated tapered boss (a conical stud) 6 mm tall on a 10 mm
# plate: additive material standing on the top face. Rebuilt as a
# PartDesign::AdditiveCone -- base (r9) on the face, tip (r5) exact. The
# volume check is what verifies it: a boss built the wrong way round (a
# straight r5 cylinder, or a subtractive cut into the plate) is visibly off.
tbo_solid = Part.makeBox(50, 40, 10, App.Vector(-25, -20, 0)) \
.fuse(Part.makeCone(9.0, 5.0, 6.0, App.Vector(0, 0, 10.0)))
tbv, tbf = tbo_solid.tessellate(0.05)
tbotm = trimesh.Trimesh(vertices=np.array([[v.x, v.y, v.z] for v in tbv]),
faces=np.array(tbf), process=True)
tbrep = snap_report(reconstruct(tbotm)).report
tbp = plan_patches(tbrep)
tbfeats = detect_features(tbrep, tbp)
tbplan = plan_history(tbrep, tbfeats, detect_patterns(tbfeats), tbp)
tbocones = [c for c in tbplan.cones if c.additive]
check("tapered boss planned as an additive ConeOp", len(tbocones) == 1,
f"{len(tbplan.cones)} cones, {len(tbplan.pockets)} pockets")
check("tapered boss did not also emit a terrace pocket",
tbplan.pockets == [], f"{len(tbplan.pockets)} pockets")
if tbocones:
_c = tbocones[0]
check("tapered boss radii recovered",
abs(_c.r_mouth - 9.0) < 0.2 and abs(_c.r_far - 5.0) < 0.2,
f"r_mouth {_c.r_mouth:.3f}, r_far {_c.r_far:.3f}")
tbo_body = build.build_body(doc, tbplan, name="RebuiltTaperedBoss")
check("tapered boss body uses an AdditiveCone primitive",
any(o.TypeId == "PartDesign::AdditiveCone" for o in tbo_body.Group),
", ".join(sorted({o.TypeId.split("::")[-1] for o in tbo_body.Group})))
check("tapered boss body volume within 2%",
abs(tbo_body.Shape.Volume - tbo_solid.Volume) / tbo_solid.Volume < 0.02,
f"{tbo_body.Shape.Volume:.2f} vs {tbo_solid.Volume:.2f}")
check("tapered boss body shape is valid", tbo_body.Shape.isValid())

# --- deviation correction: freeform blends the feature tree cannot capture --
# issue #5's corner bracket rebuilds +25% over-volume parametrically (its
# diagonal band and base scoops fit no primitive); the correction pass
Expand Down
Loading
Loading