- 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" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <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$/.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/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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -503,7 +505,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1786125317688</updated> <updated>1786125317688</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -524,7 +534,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" /> <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="- 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" /> <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="- Render improvements, camera plane, update" />
<MESSAGE value="- added &quot;measurement lines&quot;" /> <MESSAGE value="- added &quot;measurement lines&quot;" />
<MESSAGE value="- arc improvements, fillets, operations, bodys" /> <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> </component>
</project> </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 _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). """Current line segments as world-coordinate tuples (uses solved positions).
Returns both straight line segments AND tessellated arc segments so 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 and external entities are excluded — they're reference geometry and
must not affect the sketch profile. 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, Tessellation density: roughly 12 segments per π radians of arc sweep,
which gives smooth-looking closed loops for face detection. 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 ── # ── Straight line segments ──
for line_id, (sid, eid2) in self._lines.items(): 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: if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
segs.append( segs.append(
( (
(float(s_ent.geometry[0]), float(s_ent.geometry[1])), float(s_ent.geometry[0]), float(s_ent.geometry[1]),
(float(e_ent.geometry[0]), float(e_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 a2 = start_angle + t2 * sweep
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1)) p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2)) 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 return segs
@@ -2018,8 +2022,8 @@ class OCCSketch(SketchInterface):
"""Detect closed loops: polygon cycles from connected lines + each circle. """Detect closed loops: polygon cycles from connected lines + each circle.
Each loop is one of: Each loop is one of:
{"type": "polygon", "points": [(x,y), ...]} (closed, last == first) {"type": "polygon", "points": [(x,y), ...], "entity_ids": [int, ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r} {"type": "circle", "center": (x,y), "radius": r, "entity_ids": [int]}
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
rectangle's four corners join into one cycle even after solver floating rectangle's four corners join into one cycle even after solver floating
point jitter. Only connected components where every node has degree 2 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 reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
edges: List[Tuple[Any, Any]] = [] edges: List[Tuple[Any, Any]] = []
for p1, p2 in segs: edge_eids: Dict[Tuple[Any, Any], int] = {} # (k1,k2) -> entity_id
k1, k2 = key(p1), key(p2) for x1, y1, x2, y2, eid in segs:
reprs.setdefault(k1, p1) k1, k2 = key((x1, y1)), key((x2, y2))
reprs.setdefault(k2, p2) reprs.setdefault(k1, (x1, y1))
reprs.setdefault(k2, (x2, y2))
edges.append((k1, k2)) edges.append((k1, k2))
edge_eids[(k1, k2) if k1 < k2 else (k2, k1)] = eid
# Undirected adjacency. # Undirected adjacency.
adj: Dict[Any, List[Any]] = {} adj: Dict[Any, List[Any]] = {}
@@ -2066,8 +2072,9 @@ class OCCSketch(SketchInterface):
if nb not in comp_seen: if nb not in comp_seen:
stack.append(nb) stack.append(nb)
if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3: 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] = [] ordered: List[Any] = []
eids: List[int] = []
cur = comp[0] cur = comp[0]
prev = None prev = None
for _ in range(len(comp)): for _ in range(len(comp)):
@@ -2075,16 +2082,26 @@ class OCCSketch(SketchInterface):
nbrs = [nb for nb in adj[cur] if nb != prev] nbrs = [nb for nb in adj[cur] if nb != prev]
if not nbrs: if not nbrs:
break 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 prev = cur
cur = nbrs[0] cur = nbrs[0]
if len(ordered) == len(comp): if len(ordered) == len(comp):
pts = [reprs[k] for k in ordered] pts = [reprs[k] for k in ordered]
pts.append(pts[0]) pts.append(pts[0])
loops.append({"type": "polygon", "points": pts}) loops.append(
{"type": "polygon", "points": pts, "entity_ids": sorted(set(eids))}
)
seen |= comp_seen seen |= comp_seen
# Circles are closed loops of their own. # Circles are closed loops of their own.
for cid, (center_id, r) in self._circles.items(): 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) c_ent = self._entities.get(center_id)
if c_ent and c_ent.geometry and r > 0: if c_ent and c_ent.geometry and r > 0:
loops.append( loops.append(
@@ -2092,6 +2109,7 @@ class OCCSketch(SketchInterface):
"type": "circle", "type": "circle",
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])), "center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
"radius": float(r), "radius": float(r),
"entity_ids": [cid],
} }
) )
return loops return loops
@@ -2242,8 +2260,7 @@ class OCCSketch(SketchInterface):
that is the rectangle minus the circle — exactly the that is the rectangle minus the circle — exactly the
"shape within a shape = closed without inner" behavior. A shape nested "shape within a shape = closed without inner" behavior. A shape nested
inside a hole (depth 2) becomes its own solid face again. inside a hole (depth 2) becomes its own solid face again.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int, "entity_ids": [int, ...]}``.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``.
""" """
loops = self.get_closed_loops() loops = self.get_closed_loops()
if not loops: if not loops:
@@ -2267,7 +2284,11 @@ class OCCSketch(SketchInterface):
# directly nested: depth one greater, and outer contains inner. # directly nested: depth one greater, and outer contains inner.
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer): if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
holes.append(inner) 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 return faces
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]: 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), "tangent_propagation": bool(feat.tangent_propagation),
"scope": feat.scope, "scope": feat.scope,
"edge_refs": list(feat.edge_refs), "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, "pattern_type": feat.pattern_type,
"count": feat.count, "count": feat.count,
"spacing": feat.spacing, "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)), tangent_propagation=bool(data.get("tangent_propagation", False)),
scope=data.get("scope", "selected"), scope=data.get("scope", "selected"),
edge_refs=list(data.get("edge_refs") or []), 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"), pattern_type=data.get("pattern_type", "linear"),
count=int(data.get("count") or 2), count=int(data.get("count") or 2),
spacing=_to_float(data.get("spacing"), 10.0), 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))), 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)), 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") sid = data.get("sketch_id")
if sid and sid in sketches: if sid and sid in sketches:
feat.sketch = sketches[sid] feat.sketch = sketches[sid]
+8
View File
@@ -275,6 +275,14 @@ class Feature:
scope: str = "selected" scope: str = "selected"
edge_refs: List[str] = field(default_factory=list) 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. # "array" / "pattern" features only: repeat the running solid.
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total # ``pattern_type`` is "linear" or "circular"; ``count`` is the total
# number of items including the original. Linear arrays use # 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() py_min, py_max = all_py.min(), all_py.max()
span_x = px_max - px_min span_x = px_max - px_min
span_y = py_max - py_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 return None
# Scale to fill ~90% of the image # 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 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 ───────────────────────────────────────────────────── # ── Fillet edge helpers ─────────────────────────────────────────────────────
@@ -1018,11 +1241,69 @@ def _resolve_edges_by_fingerprint(shape: Any, refs: List[str]) -> List[Any]:
return out 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( def _replay_body_features(
kernel: OCGeometryKernel, kernel: OCGeometryKernel,
body: Body, body: Body,
features: List[Feature], features: List[Feature],
through_all_length_fn: Callable[[Any, Sketch], float], through_all_length_fn: Callable[[Any, Sketch], float],
component: Optional[Any] = None, # Component for FaceKey sketch lookup
) -> Optional[Any]: ) -> Optional[Any]:
"""Replay *features* in order and return the resulting geometry. """Replay *features* in order and return the resulting geometry.
@@ -1047,19 +1328,28 @@ def _replay_body_features(
if feat.radius is None: if feat.radius is None:
logger.warning(f"Body '{body.name}': fillet feature has no radius, replay aborted") logger.warning(f"Body '{body.name}': fillet feature has no radius, replay aborted")
return None return None
if feat.scope == "all" or not feat.edge_refs: if feat.scope == "all":
edges: Optional[List[Any]] = None # round every edge 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: else:
edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs) edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
if not edges: 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( logger.warning(
f"Body '{body.name}': fillet edge refs unresolved after rebuild, " 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) geom = kernel.fillet(geom, feat.radius, edges=edges)
if geom is None: if geom is None:
return None return None
@@ -1073,20 +1363,26 @@ def _replay_body_features(
if feat.radius is None: if feat.radius is None:
logger.warning(f"Body '{body.name}': chamfer feature has no size, replay aborted") logger.warning(f"Body '{body.name}': chamfer feature has no size, replay aborted")
return None return None
if feat.scope == "all" or not feat.edge_refs: if feat.scope == "all":
edges = None # bevel every edge 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: else:
edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs) edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
if not edges: if not edges:
logger.warning( logger.warning(
f"Body '{body.name}': chamfer edge refs unresolved after rebuild, " f"Body '{body.name}': chamfer edge refs unresolved after rebuild, "
"replay aborted" "skipping chamfer"
) )
return None continue
geom = kernel.chamfer(geom, feat.radius, edges=edges)
if geom is None:
return None
continue
if feat.operation in ("array", "pattern"): if feat.operation in ("array", "pattern"):
# Pattern needs no sketch — it repeats the running solid. # Pattern needs no sketch — it repeats the running solid.
@@ -2374,6 +2670,7 @@ class MainWindow(QMainWindow):
geom = _replay_body_features( geom = _replay_body_features(
self._kernel, body, features[: index + 1], self._kernel, body, features[: index + 1],
self._through_all_length_for_geometry, self._through_all_length_for_geometry,
component=self._current_component,
) )
if geom is None: if geom is None:
return None return None
@@ -2400,11 +2697,11 @@ class MainWindow(QMainWindow):
if face_geom is None: if face_geom is None:
return None return None
# For through-all cuts we need the pre-op body to size the tool.
if feat.through_all: if feat.through_all:
pre_geom = _replay_body_features( pre_geom = _replay_body_features(
self._kernel, body, features[:index], self._kernel, body, features[:index],
self._through_all_length_for_geometry, self._through_all_length_for_geometry,
component=self._current_component,
) )
if pre_geom is not None: if pre_geom is not None:
length = self._through_all_length_for_geometry(pre_geom, sketch) length = self._through_all_length_for_geometry(pre_geom, sketch)
@@ -2544,9 +2841,9 @@ class MainWindow(QMainWindow):
self._viewer_3d.clear_preview() self._viewer_3d.clear_preview()
return return
try: try:
# Replay features up to and including the selected one.
geom = _replay_body_features( 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: if geom is None:
self._viewer_3d.clear_preview() self._viewer_3d.clear_preview()
@@ -2689,7 +2986,8 @@ class MainWindow(QMainWindow):
try: try:
new_geom = _replay_body_features( 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: except Exception as exc:
logger.exception(f"Body '{body.name}': feature replay failed: {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: if not features and body.geometry is not None:
# Imported / baked body: freeze current geometry as the base. # Imported / baked body: freeze current geometry as the base.
features.append(Feature(operation="base", geometry=body.geometry)) 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( features.append(
Feature( Feature(
operation="fillet", operation="fillet",
@@ -6580,6 +6923,8 @@ class MainWindow(QMainWindow):
tangent_propagation=tangent_propagation, tangent_propagation=tangent_propagation,
scope=scope, scope=scope,
edge_refs=[_edge_fingerprint(e) for e in (edges or [])], 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) features = _ensure_feature_history(body)
if not features and body.geometry is not None: if not features and body.geometry is not None:
features.append(Feature(operation="base", geometry=body.geometry)) 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( features.append(
Feature( Feature(
operation="chamfer", operation="chamfer",
@@ -6759,6 +7146,8 @@ class MainWindow(QMainWindow):
tangent_propagation=tangent_propagation, tangent_propagation=tangent_propagation,
scope=scope, scope=scope,
edge_refs=[_edge_fingerprint(e) for e in (edges or [])], 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_anchor_orig: Optional[QPoint] = None
self._move_orig_positions: Dict[int, Tuple[float, float]] = {} self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
self._move_active: bool = False self._move_active: bool = False
self._move_did_move: bool = False
# Auto-constraint tracking on snap # Auto-constraint tracking on snap
self._snap_point_target: Optional[OCCSketchEntity] = None self._snap_point_target: Optional[OCCSketchEntity] = None
@@ -811,6 +812,7 @@ class Sketch2DWidget(QWidget):
self._move_anchor_orig = None self._move_anchor_orig = None
self._move_orig_positions = {} self._move_orig_positions = {}
self._move_active = False self._move_active = False
self._move_did_move = False
self._hovered_face = None self._hovered_face = None
self._snap_point_target = None self._snap_point_target = None
self._snap_line_target = None self._snap_line_target = None
@@ -2317,6 +2319,8 @@ class Sketch2DWidget(QWidget):
target_world = self._screen_to_world(snapped_screen) target_world = self._screen_to_world(snapped_screen)
dx = target_world.x() - self._move_anchor_orig.x() dx = target_world.x() - self._move_anchor_orig.x()
dy = target_world.y() - self._move_anchor_orig.y() 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: for ent in self._moving_points:
if ent.id in self._move_orig_positions and ent.geometry is not None: if ent.id in self._move_orig_positions and ent.geometry is not None:
ox, oy = self._move_orig_positions[ent.id] ox, oy = self._move_orig_positions[ent.id]
@@ -2514,8 +2518,7 @@ class Sketch2DWidget(QWidget):
# the user expects from dragging a single corner. # the user expects from dragging a single corner.
if self._move_anchor is not None and self._move_anchor.geometry is not None: if self._move_anchor is not None and self._move_anchor.geometry is not None:
ax, ay = self._move_anchor.geometry ax, ay = self._move_anchor.geometry
if not self._sketch.is_entity_dragged(self._move_anchor.id): if self._move_did_move and not self._sketch.is_entity_dragged(self._move_anchor.id):
self._sketch.constrain_fixed(self._move_anchor)
# constrain_fixed reads the current params via # constrain_fixed reads the current params via
# the dragged() call, so re-sync to be safe. # the dragged() call, so re-sync to be safe.
self._solve_and_sync() self._solve_and_sync()