- Operation highlighting, body highlighting
This commit is contained in:
Generated
+16
-6
@@ -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 - Cut working - Edit sketch working" />
|
<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" />
|
<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="- Render improvements, camera plane, update" />
|
||||||
<MESSAGE value="- added "measurement lines"" />
|
<MESSAGE value="- added "measurement lines"" />
|
||||||
<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.
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
|
_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]]:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+407
-18
@@ -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,19 +1363,25 @@ 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
|
|
||||||
geom = kernel.chamfer(geom, feat.radius, edges=edges)
|
|
||||||
if geom is None:
|
|
||||||
return None
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if feat.operation in ("array", "pattern"):
|
if feat.operation in ("array", "pattern"):
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user