diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 5ac5f2a..398539f 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,11 +4,13 @@
-
+
-
+
+
+
-
+
@@ -503,7 +505,15 @@
1786125317688
-
+
+
+ 1786180549405
+
+
+
+ 1786180549405
+
+
@@ -524,7 +534,6 @@
-
@@ -549,6 +558,7 @@
-
+
+
\ No newline at end of file
diff --git a/icons/pt_pt.aseprite b/icons/pt_pt.aseprite
new file mode 100644
index 0000000..b8dddc9
Binary files /dev/null and b/icons/pt_pt.aseprite differ
diff --git a/icons/pt_pt.png b/icons/pt_pt.png
new file mode 100644
index 0000000..f4290f6
Binary files /dev/null and b/icons/pt_pt.png differ
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index 9300bf1..6316da1 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -1949,7 +1949,7 @@ class OCCSketch(SketchInterface):
_SNAP_TOL: float = 1e-2 # world-unit tolerance for snapping line endpoints in loop detection
- def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
+ def _line_segments(self) -> List[Tuple[float, float, float, float, int]]:
"""Current line segments as world-coordinate tuples (uses solved positions).
Returns both straight line segments AND tessellated arc segments so
@@ -1957,10 +1957,13 @@ class OCCSketch(SketchInterface):
and external entities are excluded — they're reference geometry and
must not affect the sketch profile.
+ Each segment is ``(x1, y1, x2, y2, entity_id)`` — the entity_id lets
+ callers trace which sketch entity produced each segment.
+
Tessellation density: roughly 12 segments per π radians of arc sweep,
which gives smooth-looking closed loops for face detection.
"""
- segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
+ segs: List[Tuple[float, float, float, float, int]] = []
# ── Straight line segments ──
for line_id, (sid, eid2) in self._lines.items():
@@ -1974,8 +1977,9 @@ class OCCSketch(SketchInterface):
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
segs.append(
(
- (float(s_ent.geometry[0]), float(s_ent.geometry[1])),
- (float(e_ent.geometry[0]), float(e_ent.geometry[1])),
+ float(s_ent.geometry[0]), float(s_ent.geometry[1]),
+ float(e_ent.geometry[0]), float(e_ent.geometry[1]),
+ line_id,
)
)
@@ -2010,7 +2014,7 @@ class OCCSketch(SketchInterface):
a2 = start_angle + t2 * sweep
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2))
- segs.append((p1, p2))
+ segs.append((p1[0], p1[1], p2[0], p2[1], arc_id))
return segs
@@ -2018,8 +2022,8 @@ class OCCSketch(SketchInterface):
"""Detect closed loops: polygon cycles from connected lines + each circle.
Each loop is one of:
- {"type": "polygon", "points": [(x,y), ...]} (closed, last == first)
- {"type": "circle", "center": (x,y), "radius": r}
+ {"type": "polygon", "points": [(x,y), ...], "entity_ids": [int, ...]} (closed, last == first)
+ {"type": "circle", "center": (x,y), "radius": r, "entity_ids": [int]}
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
rectangle's four corners join into one cycle even after solver floating
point jitter. Only connected components where every node has degree 2
@@ -2035,11 +2039,13 @@ class OCCSketch(SketchInterface):
reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
edges: List[Tuple[Any, Any]] = []
- for p1, p2 in segs:
- k1, k2 = key(p1), key(p2)
- reprs.setdefault(k1, p1)
- reprs.setdefault(k2, p2)
+ edge_eids: Dict[Tuple[Any, Any], int] = {} # (k1,k2) -> entity_id
+ for x1, y1, x2, y2, eid in segs:
+ k1, k2 = key((x1, y1)), key((x2, y2))
+ reprs.setdefault(k1, (x1, y1))
+ reprs.setdefault(k2, (x2, y2))
edges.append((k1, k2))
+ edge_eids[(k1, k2) if k1 < k2 else (k2, k1)] = eid
# Undirected adjacency.
adj: Dict[Any, List[Any]] = {}
@@ -2066,8 +2072,9 @@ class OCCSketch(SketchInterface):
if nb not in comp_seen:
stack.append(nb)
if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3:
- # Order the cycle by following each node's neighbor not yet visited.
+ # Order the cycle by following each node's neighbour not yet visited.
ordered: List[Any] = []
+ eids: List[int] = []
cur = comp[0]
prev = None
for _ in range(len(comp)):
@@ -2075,16 +2082,26 @@ class OCCSketch(SketchInterface):
nbrs = [nb for nb in adj[cur] if nb != prev]
if not nbrs:
break
+ ekey = (cur, nbrs[0]) if cur < nbrs[0] else (nbrs[0], cur)
+ if ekey in edge_eids:
+ eids.append(edge_eids[ekey])
prev = cur
cur = nbrs[0]
if len(ordered) == len(comp):
pts = [reprs[k] for k in ordered]
pts.append(pts[0])
- loops.append({"type": "polygon", "points": pts})
+ loops.append(
+ {"type": "polygon", "points": pts, "entity_ids": sorted(set(eids))}
+ )
seen |= comp_seen
# Circles are closed loops of their own.
for cid, (center_id, r) in self._circles.items():
+ circle_ent = self._entities.get(cid)
+ if circle_ent is not None and circle_ent.is_construction:
+ continue
+ if cid in self._external_entity_ids:
+ continue
c_ent = self._entities.get(center_id)
if c_ent and c_ent.geometry and r > 0:
loops.append(
@@ -2092,6 +2109,7 @@ class OCCSketch(SketchInterface):
"type": "circle",
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
"radius": float(r),
+ "entity_ids": [cid],
}
)
return loops
@@ -2242,8 +2260,7 @@ class OCCSketch(SketchInterface):
that is the rectangle minus the circle — exactly the
"shape within a shape = closed without inner" behavior. A shape nested
inside a hole (depth 2) becomes its own solid face again.
-
- Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``.
+ Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int, "entity_ids": [int, ...]}``.
"""
loops = self.get_closed_loops()
if not loops:
@@ -2267,7 +2284,11 @@ class OCCSketch(SketchInterface):
# directly nested: depth one greater, and outer contains inner.
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
holes.append(inner)
- faces.append({"outer": outer, "holes": holes, "depth": depths[i]})
+ # Face entity_ids = union of outer + hole loop entity_ids.
+ eids = set(outer.get("entity_ids", []))
+ for h in holes:
+ eids.update(h.get("entity_ids", []))
+ faces.append({"outer": outer, "holes": holes, "depth": depths[i], "entity_ids": sorted(eids)})
return faces
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py
index 9d1f864..1aa712f 100644
--- a/src/fluency/io/project_io.py
+++ b/src/fluency/io/project_io.py
@@ -226,6 +226,8 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
"tangent_propagation": bool(feat.tangent_propagation),
"scope": feat.scope,
"edge_refs": list(feat.edge_refs),
+ "face_keys": json.dumps(feat.face_keys) if feat.face_keys is not None else None,
+ "face_keys_sketch_id": feat.face_keys_sketch_id,
"pattern_type": feat.pattern_type,
"count": feat.count,
"spacing": feat.spacing,
@@ -256,6 +258,8 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
tangent_propagation=bool(data.get("tangent_propagation", False)),
scope=data.get("scope", "selected"),
edge_refs=list(data.get("edge_refs") or []),
+ face_keys=None,
+ face_keys_sketch_id=data.get("face_keys_sketch_id"),
pattern_type=data.get("pattern_type", "linear"),
count=int(data.get("count") or 2),
spacing=_to_float(data.get("spacing"), 10.0),
@@ -264,6 +268,16 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
mirror_plane_normal=tuple(float(v) for v in (data.get("mirror_plane_normal") or (1, 0, 0))),
keep_original=bool(data.get("keep_original", True)),
)
+
+ # Deserialize face_keys from JSON if present.
+ fk_raw = data.get("face_keys")
+ if fk_raw and isinstance(fk_raw, str):
+ try:
+ feat.face_keys = json.loads(fk_raw)
+ except (json.JSONDecodeError, TypeError):
+ pass
+ elif isinstance(fk_raw, list):
+ feat.face_keys = fk_raw
sid = data.get("sketch_id")
if sid and sid in sketches:
feat.sketch = sketches[sid]
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 924f743..89bc32d 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -275,6 +275,14 @@ class Feature:
scope: str = "selected"
edge_refs: List[str] = field(default_factory=list)
+ # FaceKey references for fillet/chamfer: stable face classification
+ # that survives sketch dimension changes. ``face_keys`` is a list of
+ # (FaceKey, FaceKey) pairs — one pair per user-picked face;
+ # ``face_keys_sketch_id`` tracks which sketch produced the body these
+ # faces belong to.
+ face_keys: Optional[List[Tuple[Dict[str, Any], Dict[str, Any]]]] = None
+ face_keys_sketch_id: Optional[str] = None
+
# "array" / "pattern" features only: repeat the running solid.
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
# number of items including the original. Linear arrays use
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index 2643d19..92ab4bc 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -274,7 +274,7 @@ def _make_component_thumbnail(
py_min, py_max = all_py.min(), all_py.max()
span_x = px_max - px_min
span_y = py_max - py_min
- if span_x < 1e-10 or span_y < 1e-10:
+ if span_x < 1e-6 or span_y < 1e-6:
return None
# Scale to fill ~90% of the image
@@ -820,6 +820,229 @@ def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) ->
return face_geom
+
+def _classify_extruded_faces(
+ body_shape: Any,
+ sketch: OCCSketch,
+ workplane_origin: Tuple[float, float, float],
+ workplane_normal: Tuple[float, float, float],
+) -> Dict[int, Dict[str, Any]]:
+ """Classify every face of *body_shape* relative to the extrusion sketch.
+
+ Returns ``{face_index: FaceKey}`` where each FaceKey is:
+
+ - ``{"type": "cap", "top": bool, "entity_ids": [int, ...]}``
+ - ``{"type": "lateral", "entity_ids": [int, ...]}``
+
+ *entity_ids* are the sketch entity IDs whose extrusion produced the face.
+ """
+ import numpy as np
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE
+ from OCP.TopoDS import TopoDS
+ from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
+ from OCP.GeomAbs import GeomAbs_Plane
+ from OCP.BRepGProp import BRepGProp
+ from OCP.GProp import GProp_GProps
+ from OCP.BRep import BRep_Tool
+ from OCP.gp import gp_Pnt
+
+ # Workplane frame.
+ origin = np.asarray(workplane_origin, dtype=float)
+ normal = np.asarray(workplane_normal, dtype=float)
+ normal = normal / (np.linalg.norm(normal) + 1e-30)
+
+ # Infer x_dir from sketch workplane (needed for UV projection).
+ wp = sketch.get_workplane()
+ x_dir = np.asarray(wp[2], dtype=float) if len(wp) > 2 else np.array([1.0, 0.0, 0.0], dtype=float)
+ x_dir = x_dir / (np.linalg.norm(x_dir) + 1e-30)
+ y_dir = np.cross(normal, x_dir)
+ y_dir = y_dir / (np.linalg.norm(y_dir) + 1e-30)
+
+ def _world_to_uv(p3d):
+ v = np.array([p3d[0] - origin[0], p3d[1] - origin[1], p3d[2] - origin[2]], dtype=float)
+ return (float(np.dot(v, x_dir)), float(np.dot(v, y_dir)))
+
+ def _face_center(face):
+ props = GProp_GProps()
+ BRepGProp.SurfaceProperties_s(face, props)
+ c = props.CentreOfMass()
+ return (float(c.X()), float(c.Y()), float(c.Z()))
+
+ def _point_to_segment_dist_sq(px, py, ax, ay, bx, by):
+ """Squared distance from point P to segment AB."""
+ dx, dy = bx - ax, by - ay
+ if abs(dx) < 1e-12 and abs(dy) < 1e-12:
+ return (px - ax) ** 2 + (py - ay) ** 2
+ t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)))
+ return (px - (ax + t * dx)) ** 2 + (py - (ay + t * dy)) ** 2
+
+ # ── 1. Collect all faces ──
+ all_faces: list = []
+ ex = TopExp_Explorer(body_shape, TopAbs_FACE)
+ while ex.More():
+ all_faces.append(TopoDS.Face_s(ex.Current()))
+ ex.Next()
+
+ # ── 2. Classify each face as cap or lateral ──
+ cap_faces: list = [] # (face, center_3d, top)
+ lateral_faces: list = [] # (face, index)
+ face_idx: Dict[Any, int] = {}
+
+ for idx, face in enumerate(all_faces):
+ face_idx[face] = idx
+ try:
+ surf = BRepAdaptor_Surface(face)
+ if surf.GetType() != GeomAbs_Plane:
+ continue
+ plane = surf.Plane()
+ fn = np.array(
+ [plane.Axis().Direction().X(), plane.Axis().Direction().Y(), plane.Axis().Direction().Z()],
+ dtype=float,
+ )
+ cos_angle = abs(float(np.dot(normal, fn)))
+ center = _face_center(face)
+ if cos_angle > 0.999:
+ cap_faces.append((face, center))
+ else:
+ lateral_faces.append((face, idx))
+ except Exception:
+ continue
+
+ # Determine top/bottom: sort cap faces by signed distance along normal,
+ # top = larger projection.
+ if cap_faces:
+ cap_projections = [
+ float(np.dot(np.asarray(c) - origin, normal)) for _, c in cap_faces
+ ]
+ mid = (min(cap_projections) + max(cap_projections)) / 2.0
+ cap_faces = [(f, c, float(np.dot(np.asarray(c) - origin, normal)) > mid) for (f, c) in cap_faces]
+
+ # Build set of cap face shapes for lateral edge matching.
+ cap_face_shapes = {f for f, _, _ in cap_faces}
+
+ def _edges_of_face(face):
+ edges = []
+ ex2 = TopExp_Explorer(face, TopAbs_EDGE)
+ while ex2.More():
+ edges.append(TopoDS.Edge_s(ex2.Current()))
+ ex2.Next()
+ return edges
+
+ # ── 3. Helper: nearest sketch entity to a UV point ──
+ def _nearest_entity_id(uv_pt):
+ """Find the ID of the sketch entity nearest to *uv_pt* in UV space."""
+ best_id = None
+ best_dist = float("inf")
+ ux, uy = uv_pt
+
+ # Check lines.
+ for line_id, (sid, eid2) in sketch._lines.items():
+ if line_id in sketch._external_entity_ids:
+ continue
+ ent = sketch._entities.get(line_id)
+ if ent is not None and ent.is_construction:
+ continue
+ s_ent = sketch._entities.get(sid)
+ e_ent = sketch._entities.get(eid2)
+ if not (s_ent and e_ent and s_ent.geometry and e_ent.geometry):
+ continue
+ d2 = _point_to_segment_dist_sq(
+ ux, uy,
+ float(s_ent.geometry[0]), float(s_ent.geometry[1]),
+ float(e_ent.geometry[0]), float(e_ent.geometry[1]),
+ )
+ if d2 < best_dist:
+ best_dist = d2
+ best_id = line_id
+
+ # Check circles.
+ for cid, (center_id, r) in sketch._circles.items():
+ if cid in sketch._external_entity_ids:
+ continue
+ ent = sketch._entities.get(cid)
+ if ent is not None and ent.is_construction:
+ continue
+ c_ent = sketch._entities.get(center_id)
+ if not (c_ent and c_ent.geometry):
+ continue
+ cx, cy = float(c_ent.geometry[0]), float(c_ent.geometry[1])
+ d2 = (math.sqrt((ux - cx) ** 2 + (uy - cy) ** 2) - float(r)) ** 2
+ if d2 < best_dist:
+ best_dist = d2
+ best_id = cid
+
+ # Check arcs.
+ for arc_id, arc_data in sketch._arcs.items():
+ ent = sketch._entities.get(arc_id)
+ if ent is not None and ent.is_construction:
+ continue
+ center_id = arc_data.get("center")
+ radius = arc_data.get("radius", 0.0)
+ c_ent = sketch._entities.get(center_id)
+ if not (c_ent and c_ent.geometry and radius > 0):
+ continue
+ cx, cy = float(c_ent.geometry[0]), float(c_ent.geometry[1])
+ d2 = (math.sqrt((ux - cx) ** 2 + (uy - cy) ** 2) - float(radius)) ** 2
+ if d2 < best_dist:
+ best_dist = d2
+ best_id = arc_id
+
+ return best_id
+
+ # ── 4. Build result ──
+ result: Dict[int, Dict[str, Any]] = {}
+
+ for face, center, top in cap_faces:
+ uv = _world_to_uv(center)
+ sketch_face = sketch.find_face_at(uv[0], uv[1])
+ eids = sketch_face.get("entity_ids", []) if sketch_face else []
+ result[face_idx[face]] = {"type": "cap", "top": top, "entity_ids": sorted(eids)}
+
+ for face, idx in lateral_faces:
+ # Find edges this lateral face shares with any cap face.
+ lat_edges = _edges_of_face(face)
+ cap_adj_edges = []
+ for le in lat_edges:
+ for cf in cap_face_shapes:
+ for ce in _edges_of_face(cf):
+ if le.IsSame(ce):
+ cap_adj_edges.append(le)
+ break
+
+ # Project midpoints of cap-adjacent edges to UV and match sketch entities.
+ eids: set = set()
+ for edge in cap_adj_edges:
+ try:
+ ac = BRepAdaptor_Curve(edge)
+ mid_param = (ac.FirstParameter() + ac.LastParameter()) / 2.0
+ mp = ac.Value(mid_param)
+ uv = _world_to_uv((mp.X(), mp.Y(), mp.Z()))
+ eid = _nearest_entity_id(uv)
+ if eid is not None:
+ eids.add(eid)
+ except Exception:
+ continue
+
+ # Fallback: if no cap-adjacent edges found (e.g., non-prismatic body),
+ # project the face's own boundary edges.
+ if not eids:
+ for edge in lat_edges:
+ try:
+ ac = BRepAdaptor_Curve(edge)
+ mid_param = (ac.FirstParameter() + ac.LastParameter()) / 2.0
+ mp = ac.Value(mid_param)
+ uv = _world_to_uv((mp.X(), mp.Y(), mp.Z()))
+ eid = _nearest_entity_id(uv)
+ if eid is not None:
+ eids.add(eid)
+ except Exception:
+ continue
+
+ result[idx] = {"type": "lateral", "entity_ids": sorted(eids)}
+
+ return result
+
# ── Fillet edge helpers ─────────────────────────────────────────────────────
@@ -1018,11 +1241,69 @@ def _resolve_edges_by_fingerprint(shape: Any, refs: List[str]) -> List[Any]:
return out
+def _resolve_edges_by_face_keys(
+ shape: Any, feat: Feature, component: Optional[Any]
+) -> Optional[List[Any]]:
+ """Try to resolve fillet/chamfer edges using FaceKey classification.
+
+ Returns a list of seed edges if resolution succeeds, *None* if it
+ doesn't (so the caller can fall back to fingerprint matching).
+ """
+ if feat.face_keys is None or len(feat.face_keys) != 1:
+ return None
+ if component is None or feat.face_keys_sketch_id is None:
+ return None
+ sk = component.sketches.get(feat.face_keys_sketch_id)
+ if sk is None or sk.occ_sketch is None:
+ return None
+
+ try:
+ face_map = _classify_extruded_faces(
+ shape,
+ sk.occ_sketch,
+ tuple(sk.workplane_origin.tolist()),
+ tuple(sk.workplane_normal.tolist()),
+ )
+ except Exception:
+ return None
+
+ key_a, key_b = feat.face_keys[0]
+
+ # Find matching faces by FaceKey content.
+ from OCP.TopoDS import TopoDS as _TopoDS
+ from OCP.TopExp import TopExp_Explorer as _TopExp_Explorer
+ from OCP.TopAbs import TopAbs_FACE as _TopAbs_FACE
+
+ face_a = face_b = None
+ ex2 = _TopExp_Explorer(shape, _TopAbs_FACE)
+ idx = 0
+ while ex2.More():
+ face_obj = _TopoDS.Face_s(ex2.Current())
+ fk = face_map.get(idx)
+ if fk is not None:
+ if fk == key_a:
+ face_a = face_obj
+ if fk == key_b:
+ face_b = face_obj
+ idx += 1
+ ex2.Next()
+
+ if face_a is None or face_b is None:
+ return None
+
+ seed_edges = _shared_edges_between_faces(shape, face_a, face_b)
+ if not seed_edges:
+ return None
+
+ return _resolve_fillet_edges(shape, seed_edges, feat.tangent_propagation, feat.scope)
+
+
def _replay_body_features(
kernel: OCGeometryKernel,
body: Body,
features: List[Feature],
through_all_length_fn: Callable[[Any, Sketch], float],
+ component: Optional[Any] = None, # Component for FaceKey sketch lookup
) -> Optional[Any]:
"""Replay *features* in order and return the resulting geometry.
@@ -1047,19 +1328,28 @@ def _replay_body_features(
if feat.radius is None:
logger.warning(f"Body '{body.name}': fillet feature has no radius, replay aborted")
return None
- if feat.scope == "all" or not feat.edge_refs:
+ if feat.scope == "all":
edges: Optional[List[Any]] = None # round every edge
+ elif feat.face_keys is not None:
+ # Try FaceKey resolution first (survives dimensional changes),
+ # fall back to edge fingerprints.
+ edges = _resolve_edges_by_face_keys(geom.shape, feat, component)
+ if edges is None:
+ edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
+ if not edges:
+ logger.warning(
+ f"Body '{body.name}': fillet edge refs unresolved after rebuild, "
+ "skipping fillet"
+ )
+ continue
else:
edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
if not edges:
- # The referenced edges no longer exist after a topology
- # change — abort so the body keeps its previous state
- # (marked ⚠) instead of silently rounding nothing.
logger.warning(
f"Body '{body.name}': fillet edge refs unresolved after rebuild, "
- "replay aborted"
+ "skipping fillet"
)
- return None
+ continue
geom = kernel.fillet(geom, feat.radius, edges=edges)
if geom is None:
return None
@@ -1073,20 +1363,26 @@ def _replay_body_features(
if feat.radius is None:
logger.warning(f"Body '{body.name}': chamfer feature has no size, replay aborted")
return None
- if feat.scope == "all" or not feat.edge_refs:
- edges = None # bevel every edge
+ if feat.scope == "all":
+ edges: Optional[List[Any]] = None # bevel every edge
+ elif feat.face_keys is not None:
+ edges = _resolve_edges_by_face_keys(geom.shape, feat, component)
+ if edges is None:
+ edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
+ if not edges:
+ logger.warning(
+ f"Body '{body.name}': chamfer edge refs unresolved after rebuild, "
+ "skipping chamfer"
+ )
+ continue
else:
edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
if not edges:
logger.warning(
f"Body '{body.name}': chamfer edge refs unresolved after rebuild, "
- "replay aborted"
+ "skipping chamfer"
)
- return None
- geom = kernel.chamfer(geom, feat.radius, edges=edges)
- if geom is None:
- return None
- continue
+ continue
if feat.operation in ("array", "pattern"):
# Pattern needs no sketch — it repeats the running solid.
@@ -2374,6 +2670,7 @@ class MainWindow(QMainWindow):
geom = _replay_body_features(
self._kernel, body, features[: index + 1],
self._through_all_length_for_geometry,
+ component=self._current_component,
)
if geom is None:
return None
@@ -2400,11 +2697,11 @@ class MainWindow(QMainWindow):
if face_geom is None:
return None
- # For through-all cuts we need the pre-op body to size the tool.
if feat.through_all:
pre_geom = _replay_body_features(
self._kernel, body, features[:index],
self._through_all_length_for_geometry,
+ component=self._current_component,
)
if pre_geom is not None:
length = self._through_all_length_for_geometry(pre_geom, sketch)
@@ -2544,9 +2841,9 @@ class MainWindow(QMainWindow):
self._viewer_3d.clear_preview()
return
try:
- # Replay features up to and including the selected one.
geom = _replay_body_features(
- self._kernel, body, features[: index + 1], self._through_all_length_for_geometry
+ self._kernel, body, features[: index + 1], self._through_all_length_for_geometry,
+ component=self._current_component,
)
if geom is None:
self._viewer_3d.clear_preview()
@@ -2689,7 +2986,8 @@ class MainWindow(QMainWindow):
try:
new_geom = _replay_body_features(
- self._kernel, body, features, self._through_all_length_for_geometry
+ self._kernel, body, features, self._through_all_length_for_geometry,
+ component=self._current_component,
)
except Exception as exc:
logger.exception(f"Body '{body.name}': feature replay failed: {exc}")
@@ -6573,6 +6871,51 @@ class MainWindow(QMainWindow):
if not features and body.geometry is not None:
# Imported / baked body: freeze current geometry as the base.
features.append(Feature(operation="base", geometry=body.geometry))
+
+ # ── Compute FaceKeys for stable replay across dimension changes ──
+ face_keys = None
+ face_keys_sketch_id = None
+ if scope != "all" and self._fillet_face1 is not None and self._fillet_face2 is not None:
+ # Find the last sketch-producing feature before this fillet.
+ sketch_feat: Optional[Feature] = None
+ for f in reversed(features):
+ if f.operation in ("extrude", "revolve", "cut", "union") and f.sketch is not None:
+ sketch_feat = f
+ break
+ if sketch_feat is not None and sketch_feat.sketch is not None:
+ sk = sketch_feat.sketch
+ if sk.occ_sketch is not None:
+ try:
+ face_map = _classify_extruded_faces(
+ shape,
+ sk.occ_sketch,
+ tuple(sk.workplane_origin.tolist()),
+ tuple(sk.workplane_normal.tolist()),
+ )
+ # Re-iterate faces in the same TopExp order to get
+ # actual face objects for IsSame comparison.
+ from OCP.TopoDS import TopoDS as _TopoDS
+ from OCP.TopExp import TopExp_Explorer as _TopExp_Explorer
+ from OCP.TopAbs import TopAbs_FACE as _TopAbs_FACE
+ key_a = key_b = None
+ ex2 = _TopExp_Explorer(shape, _TopAbs_FACE)
+ idx = 0
+ while ex2.More():
+ face_obj = _TopoDS.Face_s(ex2.Current())
+ fk = face_map.get(idx)
+ if fk is not None:
+ if face_obj.IsSame(self._fillet_face1):
+ key_a = fk
+ if face_obj.IsSame(self._fillet_face2):
+ key_b = fk
+ idx += 1
+ ex2.Next()
+ if key_a is not None and key_b is not None:
+ face_keys = [(key_a, key_b)]
+ face_keys_sketch_id = sk.id
+ except Exception:
+ logger.debug("FaceKey classification failed for fillet", exc_info=True)
+
features.append(
Feature(
operation="fillet",
@@ -6580,6 +6923,8 @@ class MainWindow(QMainWindow):
tangent_propagation=tangent_propagation,
scope=scope,
edge_refs=[_edge_fingerprint(e) for e in (edges or [])],
+ face_keys=face_keys,
+ face_keys_sketch_id=face_keys_sketch_id,
)
)
@@ -6752,6 +7097,48 @@ class MainWindow(QMainWindow):
features = _ensure_feature_history(body)
if not features and body.geometry is not None:
features.append(Feature(operation="base", geometry=body.geometry))
+
+ # ── Compute FaceKeys for stable replay across dimension changes ──
+ face_keys = None
+ face_keys_sketch_id = None
+ if scope != "all" and self._chamfer_face1 is not None and self._chamfer_face2 is not None:
+ sketch_feat: Optional[Feature] = None
+ for f in reversed(features):
+ if f.operation in ("extrude", "revolve", "cut", "union") and f.sketch is not None:
+ sketch_feat = f
+ break
+ if sketch_feat is not None and sketch_feat.sketch is not None:
+ sk = sketch_feat.sketch
+ if sk.occ_sketch is not None:
+ try:
+ face_map = _classify_extruded_faces(
+ shape,
+ sk.occ_sketch,
+ tuple(sk.workplane_origin.tolist()),
+ tuple(sk.workplane_normal.tolist()),
+ )
+ from OCP.TopoDS import TopoDS as _TopoDS
+ from OCP.TopExp import TopExp_Explorer as _TopExp_Explorer
+ from OCP.TopAbs import TopAbs_FACE as _TopAbs_FACE
+ key_a = key_b = None
+ ex2 = _TopExp_Explorer(shape, _TopAbs_FACE)
+ idx = 0
+ while ex2.More():
+ face_obj = _TopoDS.Face_s(ex2.Current())
+ fk = face_map.get(idx)
+ if fk is not None:
+ if face_obj.IsSame(self._chamfer_face1):
+ key_a = fk
+ if face_obj.IsSame(self._chamfer_face2):
+ key_b = fk
+ idx += 1
+ ex2.Next()
+ if key_a is not None and key_b is not None:
+ face_keys = [(key_a, key_b)]
+ face_keys_sketch_id = sk.id
+ except Exception:
+ logger.debug("FaceKey classification failed for chamfer", exc_info=True)
+
features.append(
Feature(
operation="chamfer",
@@ -6759,6 +7146,8 @@ class MainWindow(QMainWindow):
tangent_propagation=tangent_propagation,
scope=scope,
edge_refs=[_edge_fingerprint(e) for e in (edges or [])],
+ face_keys=face_keys,
+ face_keys_sketch_id=face_keys_sketch_id,
)
)
diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py
index d5adaf1..efe463a 100644
--- a/src/fluency/ui/sketch_widget.py
+++ b/src/fluency/ui/sketch_widget.py
@@ -260,6 +260,7 @@ class Sketch2DWidget(QWidget):
self._move_anchor_orig: Optional[QPoint] = None
self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
self._move_active: bool = False
+ self._move_did_move: bool = False
# Auto-constraint tracking on snap
self._snap_point_target: Optional[OCCSketchEntity] = None
@@ -811,6 +812,7 @@ class Sketch2DWidget(QWidget):
self._move_anchor_orig = None
self._move_orig_positions = {}
self._move_active = False
+ self._move_did_move = False
self._hovered_face = None
self._snap_point_target = None
self._snap_line_target = None
@@ -2317,6 +2319,8 @@ class Sketch2DWidget(QWidget):
target_world = self._screen_to_world(snapped_screen)
dx = target_world.x() - self._move_anchor_orig.x()
dy = target_world.y() - self._move_anchor_orig.y()
+ if dx != 0 or dy != 0:
+ self._move_did_move = True
for ent in self._moving_points:
if ent.id in self._move_orig_positions and ent.geometry is not None:
ox, oy = self._move_orig_positions[ent.id]
@@ -2514,8 +2518,7 @@ class Sketch2DWidget(QWidget):
# the user expects from dragging a single corner.
if self._move_anchor is not None and self._move_anchor.geometry is not None:
ax, ay = self._move_anchor.geometry
- if not self._sketch.is_entity_dragged(self._move_anchor.id):
- self._sketch.constrain_fixed(self._move_anchor)
+ if self._move_did_move and not self._sketch.is_entity_dragged(self._move_anchor.id):
# constrain_fixed reads the current params via
# the dragged() call, so re-sync to be safe.
self._solve_and_sync()