- Operation highlighting, body highlighting

This commit is contained in:
bklronin
2026-08-08 13:46:12 +02:00
parent b29bc11b42
commit 3d63f033f2
8 changed files with 488 additions and 43 deletions
+16 -6
View File
@@ -4,11 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Operation highlighting, body highlighting">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -503,7 +505,15 @@
<option name="project" value="LOCAL" />
<updated>1786125317688</updated>
</task>
<option name="localTasksCounter" value="49" />
<task id="LOCAL-00049" summary="- Operation highlighting, body highlighting">
<option name="closed" value="true" />
<created>1786180549405</created>
<option name="number" value="00049" />
<option name="presentableId" value="LOCAL-00049" />
<option name="project" value="LOCAL" />
<updated>1786180549405</updated>
</task>
<option name="localTasksCounter" value="50" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -524,7 +534,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
<MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" />
<MESSAGE value="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance" />
@@ -549,6 +558,7 @@
<MESSAGE value="- Render improvements, camera plane, update" />
<MESSAGE value="- added &quot;measurement lines&quot;" />
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
<option name="LAST_COMMIT_MESSAGE" value="- arc improvements, fillets, operations, bodys" />
<MESSAGE value="- Operation highlighting, body highlighting" />
<option name="LAST_COMMIT_MESSAGE" value="- Operation highlighting, body highlighting" />
</component>
</project>
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

+37 -16
View File
@@ -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]]:
+14
View File
@@ -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]
+8
View File
@@ -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
+408 -19
View File
@@ -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,
)
)
+5 -2
View File
@@ -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()