- Operation highlighting, body highlighting
This commit is contained in:
Generated
+16
-6
@@ -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 - Cut working - Edit sketch working" />
|
||||
<MESSAGE value="- delete sketch working - added mid point snap - added hovering line with distance" />
|
||||
@@ -549,6 +558,7 @@
|
||||
<MESSAGE value="- Render improvements, camera plane, update" />
|
||||
<MESSAGE value="- added "measurement lines"" />
|
||||
<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.
Binary file not shown.
|
After Width: | Height: | Size: 267 B |
@@ -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]]:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+407
-18
@@ -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,19 +1363,25 @@ 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
|
||||
|
||||
if feat.operation in ("array", "pattern"):
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user