5269c0897c
- Split main.py refactor
1878 lines
78 KiB
Python
1878 lines
78 KiB
Python
"""
|
||
OpenCASCADE-based sketch for Fluency CAD with SolveSpace constraint solver integration.
|
||
|
||
This module provides 2D sketching capabilities using the SolveSpace constraint
|
||
solver (via python_solvespace) for constraint management, and CadQuery for
|
||
geometry generation from solved positions.
|
||
"""
|
||
|
||
from typing import List, Tuple, Optional, Dict, Any
|
||
import math
|
||
import numpy as np
|
||
import logging
|
||
import re
|
||
|
||
from python_solvespace import SolverSystem, ResultFlag
|
||
|
||
from fluency.geometry.base import (
|
||
SketchInterface,
|
||
SketchEntity,
|
||
GeometryObject,
|
||
Point2D,
|
||
)
|
||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OCCSketchEntity(SketchEntity):
|
||
"""Sketch entity for OpenCASCADE-based sketch with solver integration."""
|
||
|
||
def __init__(self, entity_id: int, entity_type: str, geometry: Any = None, handle: Any = None):
|
||
super().__init__(entity_id, entity_type)
|
||
self.geometry = geometry
|
||
self.handle = handle # SolveSpace solver entity handle
|
||
self.is_construction: bool = False
|
||
self.constraints: List[str] = [] # Track applied constraint names for UI
|
||
# External / underlay entities are reference geometry projected from
|
||
# a 3D face (or otherwise supplied from outside the sketch). They live
|
||
# in the solver so user constraints can reference them, but they are
|
||
# *not* user-drawn, *not* deletable, *not* moveable, and never
|
||
# contribute to the sketch profile (detect_faces / get_geometry).
|
||
self.is_external: bool = False
|
||
|
||
|
||
class OCCSketch(SketchInterface):
|
||
"""
|
||
Sketch with SolveSpace constraint solver integration.
|
||
|
||
Uses python_solvespace as the constraint engine, allowing points and lines
|
||
to be parametrically constrained. After solving, positions are read from
|
||
the solver and used to build CadQuery geometry for extrusion.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._solver: SolverSystem = SolverSystem()
|
||
self._wp: Any = self._solver.create_2d_base()
|
||
|
||
self._entities: Dict[int, OCCSketchEntity] = {}
|
||
self._entity_counter: int = 0
|
||
self._points: Dict[int, Tuple[float, float]] = {}
|
||
self._lines: Dict[int, Tuple[int, int]] = {}
|
||
self._circles: Dict[int, Tuple[int, float]] = {}
|
||
self._arcs: Dict[int, Any] = {}
|
||
self._constraint_count: int = 0
|
||
# Re-appliable log of every constraint, so we can rebuild the solver
|
||
# after deleting an entity (python_solvespace has no per-entity delete).
|
||
# Each entry: {"type": str, "ids": (int, ...), "params": tuple, "labels": set[str]}
|
||
self._constraint_log: List[Dict[str, Any]] = []
|
||
# External / underlay entity ids (face-projected reference geometry).
|
||
# Kept in their own set so we can:
|
||
# • render them with a distinct style
|
||
# • filter them out of get_closed_loops / detect_faces
|
||
# • refuse to delete / move them
|
||
# • clear them as a group when the source face is removed
|
||
self._external_entity_ids: set = set()
|
||
|
||
# Centerline entity ids (X and Y reference axes through origin).
|
||
# These are construction lines that span the sketch and are used
|
||
# as reference axes for constraining geometry. They are:
|
||
# • fixed in the solver (never move)
|
||
# • marked is_construction (excluded from profile detection)
|
||
# • non-deletable
|
||
self._centerline_ids: set = set()
|
||
|
||
# Track first point as dragged/fixed for solver stability
|
||
self._first_point_id: Optional[int] = None
|
||
|
||
# ── Workplane ───────────────────────────────────────────────────
|
||
# The sketch lives in a 2D UV frame on this plane. UV coordinates
|
||
# map to world via: P = origin + u*x_dir + v*y_dir
|
||
# where y_dir = normal × x_dir. Defaults to the world XY plane so
|
||
# existing XY-only behaviour is unchanged.
|
||
self._wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||
self._wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||
self._wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||
self._wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
|
||
|
||
# ─── Workplane management ──────────────────────────────────────────────
|
||
|
||
def set_workplane(
|
||
self,
|
||
origin: Tuple[float, float, float],
|
||
normal: Tuple[float, float, float],
|
||
x_dir: Tuple[float, float, float],
|
||
) -> None:
|
||
"""Place this sketch on an arbitrary plane in 3D.
|
||
|
||
*normal* and *x_dir* need not be unit/perpendicular — they are
|
||
orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``.
|
||
Existing UV coordinates are unchanged; only their world mapping moves.
|
||
"""
|
||
import numpy as np
|
||
|
||
n = np.asarray(normal, dtype=float)
|
||
x = np.asarray(x_dir, dtype=float)
|
||
n = n / np.linalg.norm(n)
|
||
# Remove any component of x along n, then renormalise.
|
||
x = x - np.dot(x, n) * n
|
||
x_norm = np.linalg.norm(x)
|
||
if x_norm < 1e-9:
|
||
# x_dir is parallel to normal — pick any orthogonal basis vector.
|
||
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
||
x = fallback - np.dot(fallback, n) * n
|
||
x_norm = np.linalg.norm(x)
|
||
x = x / x_norm
|
||
y = np.cross(n, x)
|
||
y = y / np.linalg.norm(y)
|
||
|
||
self._wp_origin = tuple(float(v) for v in origin)
|
||
self._wp_normal = tuple(float(v) for v in n)
|
||
self._wp_x_dir = tuple(float(v) for v in x)
|
||
self._wp_y_dir = tuple(float(v) for v in y)
|
||
|
||
def get_workplane(self) -> Tuple[Tuple[float, float, float], ...]:
|
||
"""Return the (origin, normal, x_dir, y_dir) of this sketch's plane."""
|
||
return (self._wp_origin, self._wp_normal, self._wp_x_dir, self._wp_y_dir)
|
||
|
||
def _uv_to_world(self, u: float, v: float):
|
||
"""Map a UV point to a world ``gp_Pnt`` on the workplane."""
|
||
from OCP.gp import gp_Pnt
|
||
ox, oy, oz = self._wp_origin
|
||
xx, xy, xz = self._wp_x_dir
|
||
yx, yy, yz = self._wp_y_dir
|
||
return gp_Pnt(
|
||
ox + u * xx + v * yx,
|
||
oy + u * xy + v * yy,
|
||
oz + u * xz + v * yz,
|
||
)
|
||
|
||
def _circle_axis(self, u: float, v: float):
|
||
"""Return a ``gp_Ax2`` for a circle centred at UV on the workplane."""
|
||
from OCP.gp import gp_Ax2, gp_Dir
|
||
center = self._uv_to_world(u, v)
|
||
return gp_Ax2(
|
||
center,
|
||
gp_Dir(*self._wp_normal),
|
||
gp_Dir(*self._wp_x_dir),
|
||
)
|
||
|
||
@property
|
||
def solver(self) -> SolverSystem:
|
||
"""Access the underlying SolveSpace solver."""
|
||
return self._solver
|
||
|
||
@property
|
||
def workplane(self) -> Any:
|
||
"""Get the SolveSpace 2D solver workplane entity.
|
||
|
||
Note: this is the solver's internal 2D base, not the 3D placement
|
||
plane — see :meth:`set_workplane` / :meth:`workplane` (no underscore)
|
||
for the 3D plane. The solver always runs in UV regardless of the
|
||
3D placement.
|
||
"""
|
||
return self._wp
|
||
|
||
def _next_id(self) -> int:
|
||
self._entity_counter += 1
|
||
return self._entity_counter
|
||
|
||
def _get_handle_nr(self, handle_str: str) -> int:
|
||
match = re.search(r"handle=(\d+)", str(handle_str))
|
||
return int(match.group(1)) if match else 0
|
||
|
||
def add_point(self, x: float, y: float) -> OCCSketchEntity:
|
||
"""Add a point to the sketch (added to solver + tracked).
|
||
|
||
The very first point added to an empty solver is auto-anchored via
|
||
``dragged`` to give the solver a stable reference frame. If the
|
||
sketch already carries external / underlay points (those are
|
||
always dragged at creation), we skip this auto-anchor — the
|
||
external point is the natural reference, and a second dragged
|
||
point would over-constrain the system and make any
|
||
user-to-external distance constraint unsolvable.
|
||
"""
|
||
entity_id = self._next_id()
|
||
|
||
# Add to solver
|
||
solver_handle = self._solver.add_point_2d(x, y, self._wp)
|
||
if self._first_point_id is None and not self._external_entity_ids:
|
||
self._first_point_id = entity_id
|
||
# Fix first point so solver has a reference
|
||
self._solver.dragged(solver_handle, self._wp)
|
||
|
||
entity = OCCSketchEntity(
|
||
entity_id=entity_id, entity_type="point", geometry=(x, y), handle=solver_handle
|
||
)
|
||
|
||
self._entities[entity_id] = entity
|
||
self._points[entity_id] = (x, y)
|
||
|
||
return entity
|
||
|
||
def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
|
||
"""Add a line between two points (added to solver + tracked)."""
|
||
entity_id = self._next_id()
|
||
|
||
start_entity = self._entities.get(start.id)
|
||
end_entity = self._entities.get(end.id)
|
||
|
||
if start_entity is None or end_entity is None:
|
||
raise ValueError("Start or end point not found in sketch")
|
||
|
||
# Get solver handles
|
||
s_handle = start_entity.handle
|
||
e_handle = end_entity.handle
|
||
|
||
# Add line to solver
|
||
solver_handle = self._solver.add_line_2d(s_handle, e_handle, self._wp)
|
||
|
||
x1, y1 = start_entity.geometry
|
||
x2, y2 = end_entity.geometry
|
||
|
||
entity = OCCSketchEntity(
|
||
entity_id=entity_id,
|
||
entity_type="line",
|
||
geometry=((x1, y1), (x2, y2)),
|
||
handle=solver_handle,
|
||
)
|
||
|
||
self._entities[entity_id] = entity
|
||
self._lines[entity_id] = (start.id, end.id)
|
||
|
||
return entity
|
||
|
||
def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity:
|
||
"""Add a circle (tracked only — solver has no native circle in this API)."""
|
||
entity_id = self._next_id()
|
||
|
||
center_entity = self._entities.get(center.id)
|
||
if center_entity is None:
|
||
raise ValueError("Center point not found in sketch")
|
||
|
||
cx, cy = center_entity.geometry
|
||
|
||
entity = OCCSketchEntity(
|
||
entity_id=entity_id, entity_type="circle", geometry=((cx, cy), radius)
|
||
)
|
||
|
||
self._entities[entity_id] = entity
|
||
self._circles[entity_id] = (center.id, radius)
|
||
|
||
return entity
|
||
|
||
def add_arc(
|
||
self,
|
||
center: SketchEntity,
|
||
radius: float,
|
||
start_point: SketchEntity,
|
||
end_point: SketchEntity,
|
||
sweep: Optional[float] = None,
|
||
) -> OCCSketchEntity:
|
||
"""Add an arc (tracked only).
|
||
|
||
*sweep* is the signed angular span in radians (positive = CCW, negative = CW).
|
||
When *None* the rendering will infer the shortest path between start and end.
|
||
"""
|
||
import math
|
||
entity_id = self._next_id()
|
||
|
||
center_entity = self._entities.get(center.id)
|
||
start_entity = self._entities.get(start_point.id)
|
||
end_entity = self._entities.get(end_point.id)
|
||
|
||
if center_entity is None or start_entity is None or end_entity is None:
|
||
raise ValueError("Arc points not found in sketch")
|
||
|
||
cx, cy = center_entity.geometry
|
||
sx, sy = start_entity.geometry
|
||
ex, ey = end_entity.geometry
|
||
|
||
# Infer sweep from geometry when not provided.
|
||
if sweep is None:
|
||
sa = math.atan2(sy - cy, sx - cx)
|
||
ea = math.atan2(ey - cy, ex - cx)
|
||
sweep = ea - sa
|
||
while sweep > math.pi:
|
||
sweep -= 2 * math.pi
|
||
while sweep < -math.pi:
|
||
sweep += 2 * math.pi
|
||
|
||
entity = OCCSketchEntity(
|
||
entity_id=entity_id,
|
||
entity_type="arc",
|
||
geometry={
|
||
"center": (cx, cy),
|
||
"radius": radius,
|
||
"start": (sx, sy),
|
||
"end": (ex, ey),
|
||
"sweep": sweep,
|
||
},
|
||
)
|
||
|
||
self._entities[entity_id] = entity
|
||
self._arcs[entity_id] = {
|
||
"center": center.id,
|
||
"start": start_point.id,
|
||
"end": end_point.id,
|
||
"radius": radius,
|
||
"sweep": sweep,
|
||
}
|
||
|
||
return entity
|
||
|
||
# ─── External / underlay entities (face-projected reference geometry) ───
|
||
|
||
def add_external_point(self, x: float, y: float) -> OCCSketchEntity:
|
||
"""Add a point that participates in the solver but is *not* user-drawn.
|
||
|
||
External points are used to anchor projected face edges (sketch-on-
|
||
surface underlay) so the user can snap to them and add constraints
|
||
like "hole center 50mm from the body's top edge". The point is
|
||
immediately marked *fixed* in the solver (via ``dragged``) so it never
|
||
moves when other entities are dragged or re-solved.
|
||
|
||
External entities are skipped by ``get_closed_loops`` /
|
||
``detect_faces`` / ``get_geometry`` so they never contribute to the
|
||
extruded profile — they're reference geometry only.
|
||
"""
|
||
entity_id = self._next_id()
|
||
solver_handle = self._solver.add_point_2d(x, y, self._wp)
|
||
# Always fix external points — they MUST NOT move when the solver
|
||
# adjusts other entities. We bypass the first-point auto-fix in
|
||
# ``add_point`` (which would also fix the very first one and leave
|
||
# the rest free), and we apply dragged() unconditionally here.
|
||
self._solver.dragged(solver_handle, self._wp)
|
||
|
||
entity = OCCSketchEntity(
|
||
entity_id=entity_id, entity_type="point",
|
||
geometry=(x, y), handle=solver_handle,
|
||
)
|
||
entity.is_external = True
|
||
entity.is_construction = True # external points are reference / dashed
|
||
self._entities[entity_id] = entity
|
||
self._points[entity_id] = (x, y)
|
||
self._external_entity_ids.add(entity_id)
|
||
return entity
|
||
|
||
def add_external_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
|
||
"""Add a line between two existing external points.
|
||
|
||
Both endpoints must already be external points (created via
|
||
:meth:`add_external_point`). External lines are tagged ``is_external``
|
||
and are excluded from the sketch's profile-detect path so they don't
|
||
pollute the extruded face. Constraints applied to external lines
|
||
(horizontal, vertical, parallel, perpendicular, midpoint) work
|
||
normally — the line handle is real — but the line itself never moves.
|
||
"""
|
||
entity_id = self._next_id()
|
||
s_ent = self._entities.get(start.id)
|
||
e_ent = self._entities.get(end.id)
|
||
if s_ent is None or e_ent is None:
|
||
raise ValueError("Start or end point not found in sketch")
|
||
if s_ent.handle is None or e_ent.handle is None:
|
||
raise ValueError("External endpoints must have solver handles")
|
||
|
||
solver_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp)
|
||
x1, y1 = s_ent.geometry
|
||
x2, y2 = e_ent.geometry
|
||
entity = OCCSketchEntity(
|
||
entity_id=entity_id, entity_type="line",
|
||
geometry=((x1, y1), (x2, y2)),
|
||
handle=solver_handle,
|
||
)
|
||
entity.is_external = True
|
||
entity.is_construction = True
|
||
self._entities[entity_id] = entity
|
||
self._lines[entity_id] = (start.id, end.id)
|
||
self._external_entity_ids.add(entity_id)
|
||
return entity
|
||
|
||
def add_external_polyline(
|
||
self, uv_points: List[Tuple[float, float]]
|
||
) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
|
||
"""Bulk-import a polyline of UV points as external (underlay) entities.
|
||
|
||
Creates one external point per unique UV position and one external
|
||
line segment between consecutive points. Returns
|
||
``(points, lines)`` in the order they were created so the caller can
|
||
keep references (e.g. for rendering or for toggling).
|
||
|
||
Points very close to each other (within 1e-6 UV units) are merged
|
||
into a single shared point, so a closed rectangle becomes 4 unique
|
||
points and 4 line segments (not 4 points and 4 lines + 4 duplicates
|
||
at the corners).
|
||
"""
|
||
if len(uv_points) < 2:
|
||
return [], []
|
||
# Deduplicate nearby points so shared corners (e.g. a rectangle's
|
||
# four vertices) are *one* point entity reused by two line segments.
|
||
eps = 1e-6
|
||
points: List[OCCSketchEntity] = []
|
||
coord_to_entity: Dict[Tuple[int, int], OCCSketchEntity] = {}
|
||
for (u, v) in uv_points:
|
||
key = (int(round(u / eps)), int(round(v / eps)))
|
||
ent = coord_to_entity.get(key)
|
||
if ent is None:
|
||
ent = self.add_external_point(float(u), float(v))
|
||
coord_to_entity[key] = ent
|
||
points.append(ent)
|
||
lines: List[OCCSketchEntity] = []
|
||
for i in range(len(points) - 1):
|
||
try:
|
||
ln = self.add_external_line(points[i], points[i + 1])
|
||
lines.append(ln)
|
||
except ValueError:
|
||
pass
|
||
return points, lines
|
||
|
||
def remove_external_entities(self) -> None:
|
||
"""Remove every external / underlay entity and prune related constraints.
|
||
|
||
Used when the source face is removed (or rebinded). External
|
||
entities are *never* user-deletable; this is the only way to clear
|
||
them. Any constraint that references a removed external id is
|
||
pruned from the constraint log and the solver is rebuilt from the
|
||
surviving user geometry so the next solve is consistent.
|
||
"""
|
||
if not self._external_entity_ids:
|
||
return
|
||
# Wipe external entities from local tracking.
|
||
for eid in list(self._external_entity_ids):
|
||
if eid in self._entities:
|
||
del self._entities[eid]
|
||
self._points.pop(eid, None)
|
||
self._lines.pop(eid, None)
|
||
self._circles.pop(eid, None)
|
||
self._arcs.pop(eid, None)
|
||
# Also clean lines that USE an external point as an endpoint but
|
||
# somehow aren't themselves external (defensive — shouldn't happen
|
||
# via the public API, but rebuild_solver needs a clean graph).
|
||
for lid, (sid, eid2) in list(self._lines.items()):
|
||
if sid in self._external_entity_ids or eid2 in self._external_entity_ids:
|
||
del self._lines[lid]
|
||
if lid in self._entities:
|
||
del self._entities[lid]
|
||
removed = set(self._external_entity_ids)
|
||
self._external_entity_ids.clear()
|
||
self._prune_log_for(removed)
|
||
self._rebuild_solver()
|
||
self._rebuild_labels()
|
||
|
||
def get_external_entity_ids(self) -> set:
|
||
"""Return the set of external (underlay) entity ids currently in the sketch."""
|
||
return set(self._external_entity_ids)
|
||
|
||
# ── Centerlines (X and Y reference axes) ────────────────────────────
|
||
|
||
_CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch
|
||
|
||
def add_centerlines(self) -> None:
|
||
"""Add X (horizontal) and Y (vertical) centerlines through the origin.
|
||
|
||
These construction lines serve as reference axes for constraining
|
||
sketch geometry. They are:
|
||
• Fixed in the solver (never move)
|
||
• Marked is_construction (excluded from profile / face detection)
|
||
• Non-deletable
|
||
• Available as constraint targets (snap, coincident, distance,
|
||
symmetric, horizontal, vertical, parallel, perpendicular)
|
||
|
||
The X centerline runs horizontal (left→right) through origin and
|
||
the Y centerline runs vertical (bottom→top) through origin. Both
|
||
extend to ``_CENTERLINE_EXTENT`` in both directions so they span
|
||
any realistic sketch. If centerlines have already been added this
|
||
is a no-op.
|
||
"""
|
||
if self._centerline_ids:
|
||
return
|
||
|
||
HALF = self._CENTERLINE_EXTENT
|
||
|
||
# Origin point — auto-fixed as the first point in the solver
|
||
origin = self.add_point(0.0, 0.0)
|
||
origin.is_construction = True
|
||
|
||
# X centerline (horizontal)
|
||
xl = self.add_point(-HALF, 0.0)
|
||
xl.is_construction = True
|
||
xr = self.add_point(HALF, 0.0)
|
||
xr.is_construction = True
|
||
xline = self.add_line(xl, xr)
|
||
xline.is_construction = True
|
||
self.constrain_horizontal(xline)
|
||
self.constrain_fixed(xl)
|
||
|
||
# Y centerline (vertical)
|
||
yb = self.add_point(0.0, -HALF)
|
||
yb.is_construction = True
|
||
yt = self.add_point(0.0, HALF)
|
||
yt.is_construction = True
|
||
yline = self.add_line(yb, yt)
|
||
yline.is_construction = True
|
||
self.constrain_vertical(yline)
|
||
self.constrain_fixed(yb)
|
||
|
||
self._centerline_ids = {
|
||
origin.id, xl.id, xr.id, xline.id,
|
||
yb.id, yt.id, yline.id,
|
||
}
|
||
|
||
self.solve()
|
||
|
||
def get_centerline_ids(self) -> set:
|
||
"""Return the set of centerline entity ids currently in the sketch."""
|
||
return set(self._centerline_ids)
|
||
|
||
def add_rectangle(
|
||
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
|
||
) -> List[OCCSketchEntity]:
|
||
"""Add a rectangle, returning the created entities."""
|
||
x1, y1 = corner1
|
||
x2, y2 = corner2
|
||
|
||
entities: List[OCCSketchEntity] = []
|
||
|
||
p1 = self.add_point(x1, y1)
|
||
p2 = self.add_point(x2, y1)
|
||
p3 = self.add_point(x2, y2)
|
||
p4 = self.add_point(x1, y2)
|
||
|
||
entities.extend([p1, p2, p3, p4])
|
||
|
||
l1 = self.add_line(p1, p2)
|
||
l2 = self.add_line(p2, p3)
|
||
l3 = self.add_line(p3, p4)
|
||
l4 = self.add_line(p4, p1)
|
||
|
||
entities.extend([l1, l2, l3, l4])
|
||
|
||
return entities
|
||
|
||
# ─── Constraint methods (actual solver calls) ──────────────────────────
|
||
|
||
def _record_constraint(
|
||
self, ctype: str, ids: Tuple[int, ...], params: Tuple = (), labels: Tuple[str, ...] = ()
|
||
) -> None:
|
||
"""Count and log a constraint so the solver can be rebuilt after deletions."""
|
||
self._constraint_count += 1
|
||
self._constraint_log.append(
|
||
{"type": ctype, "ids": tuple(int(i) for i in ids), "params": tuple(params), "labels": set(labels)}
|
||
)
|
||
|
||
def _apply_constraint_log(self, entry: Dict[str, Any]) -> bool:
|
||
"""Re-apply a single logged constraint to the current (rebuilt) solver.
|
||
|
||
Uses live solver handles looked up by entity id. Returns False silently if
|
||
any referenced entity is now gone (pruning should have removed it, but
|
||
this is defensive).
|
||
"""
|
||
ctype = entry["type"]
|
||
ids = entry["ids"]
|
||
params = entry["params"]
|
||
|
||
def h(i: int) -> Any:
|
||
ent = self._entities.get(i)
|
||
return ent.handle if ent is not None else None
|
||
|
||
if ctype == "coincident":
|
||
if h(ids[0]) is None or h(ids[1]) is None:
|
||
return False
|
||
self._solver.coincident(h(ids[0]), h(ids[1]), self._wp)
|
||
elif ctype == "horizontal":
|
||
if h(ids[0]) is None:
|
||
return False
|
||
self._solver.horizontal(h(ids[0]), self._wp)
|
||
elif ctype == "vertical":
|
||
if h(ids[0]) is None:
|
||
return False
|
||
self._solver.vertical(h(ids[0]), self._wp)
|
||
elif ctype == "distance":
|
||
if h(ids[0]) is None or h(ids[1]) is None:
|
||
return False
|
||
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
|
||
elif ctype == "angle":
|
||
if h(ids[0]) is None or h(ids[1]) is None:
|
||
return False
|
||
self._solver.angle(h(ids[0]), h(ids[1]), params[0], self._wp)
|
||
elif ctype == "parallel":
|
||
if h(ids[0]) is None or h(ids[1]) is None:
|
||
return False
|
||
self._solver.parallel(h(ids[0]), h(ids[1]), self._wp)
|
||
elif ctype == "perpendicular":
|
||
if h(ids[0]) is None or h(ids[1]) is None:
|
||
return False
|
||
self._solver.perpendicular(h(ids[0]), h(ids[1]), self._wp)
|
||
elif ctype == "midpoint":
|
||
if h(ids[0]) is None or h(ids[1]) is None:
|
||
return False
|
||
self._solver.midpoint(h(ids[0]), h(ids[1]), self._wp)
|
||
elif ctype == "tangent":
|
||
if h(ids[0]) is None or h(ids[1]) is None:
|
||
return False
|
||
self._solver.tangent(h(ids[0]), h(ids[1]), self._wp)
|
||
elif ctype == "equal":
|
||
if h(ids[0]) is None or h(ids[1]) is None:
|
||
return False
|
||
self._solver.equal(h(ids[0]), h(ids[1]), self._wp)
|
||
elif ctype == "fixed":
|
||
if h(ids[0]) is None:
|
||
return False
|
||
self._solver.dragged(h(ids[0]), self._wp)
|
||
elif ctype == "symmetric":
|
||
if h(ids[0]) is None or h(ids[1]) is None or h(ids[2]) is None:
|
||
return False
|
||
self._solver.symmetric(h(ids[0]), h(ids[1]), h(ids[2]), self._wp)
|
||
elif ctype == "equal_radius":
|
||
# tracked only (no solver entity)
|
||
pass
|
||
else:
|
||
return False
|
||
return True
|
||
|
||
def _rebuild_solver(self) -> None:
|
||
"""Recreate the SolveSpace system from current points/lines + log.
|
||
|
||
python_solvespace cannot remove individual entities/constraints, so
|
||
after deleting an entity we rebuild the whole system: re-add every
|
||
surviving point at its current position (first point re-fixed for
|
||
stability), re-add every surviving line, then re-apply the pruned
|
||
constraint log. Entity ids are preserved; only solver handles change.
|
||
"""
|
||
# Snapshot current point positions before resetting the solver.
|
||
saved_pos: Dict[int, Tuple[float, float]] = {}
|
||
for eid, ent in self._entities.items():
|
||
if ent.entity_type == "point" and ent.geometry is not None:
|
||
saved_pos[eid] = (float(ent.geometry[0]), float(ent.geometry[1]))
|
||
|
||
self._solver = SolverSystem()
|
||
self._wp = self._solver.create_2d_base()
|
||
self._first_point_id = None
|
||
|
||
# Re-add point entities in id order (preserves first-point-fixed).
|
||
for pid in sorted(eid for eid, e in self._entities.items() if e.entity_type == "point"):
|
||
ent = self._entities[pid]
|
||
x, y = saved_pos.get(pid, (0.0, 0.0))
|
||
new_handle = self._solver.add_point_2d(x, y, self._wp)
|
||
ent.handle = new_handle
|
||
if self._first_point_id is None:
|
||
self._first_point_id = pid
|
||
self._solver.dragged(new_handle, self._wp)
|
||
|
||
# Re-add line entities in id order, updating their solver handles.
|
||
for lid in sorted(self._lines.keys()):
|
||
sid, eid2 = self._lines[lid]
|
||
s_ent = self._entities.get(sid)
|
||
e_ent = self._entities.get(eid2)
|
||
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
|
||
continue
|
||
new_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp)
|
||
line_ent = self._entities.get(lid)
|
||
if line_ent is not None:
|
||
line_ent.handle = new_handle
|
||
|
||
# Re-apply every surviving logged constraint.
|
||
for entry in self._constraint_log:
|
||
self._apply_constraint_log(entry)
|
||
|
||
def constrain_coincident(self, *entities: SketchEntity) -> bool:
|
||
"""Make entities coincident via solver."""
|
||
if len(entities) < 2:
|
||
return False
|
||
e1 = self._entities.get(entities[0].id)
|
||
e2 = self._entities.get(entities[1].id)
|
||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||
return False
|
||
self._solver.coincident(e1.handle, e2.handle, self._wp)
|
||
self._record_constraint("coincident", (entities[0].id, entities[1].id))
|
||
return True
|
||
|
||
def constrain_horizontal(self, line: SketchEntity) -> bool:
|
||
"""Constrain a line to be horizontal."""
|
||
entity = self._entities.get(line.id)
|
||
if entity is None or entity.handle is None:
|
||
return False
|
||
self._solver.horizontal(entity.handle, self._wp)
|
||
self._record_constraint("horizontal", (line.id,), labels=("hrz",))
|
||
if "hrz" not in entity.constraints:
|
||
entity.constraints.append("hrz")
|
||
return True
|
||
|
||
def constrain_vertical(self, line: SketchEntity) -> bool:
|
||
"""Constrain a line to be vertical."""
|
||
entity = self._entities.get(line.id)
|
||
if entity is None or entity.handle is None:
|
||
return False
|
||
self._solver.vertical(entity.handle, self._wp)
|
||
self._record_constraint("vertical", (line.id,), labels=("vrt",))
|
||
if "vrt" not in entity.constraints:
|
||
entity.constraints.append("vrt")
|
||
return True
|
||
|
||
def constrain_distance(
|
||
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
|
||
) -> bool:
|
||
"""Constrain distance between two entities."""
|
||
e1 = self._entities.get(entity1.id)
|
||
e2 = self._entities.get(entity2.id)
|
||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||
return False
|
||
self._solver.distance(e1.handle, e2.handle, distance, self._wp)
|
||
self._record_constraint("distance", (entity1.id, entity2.id), (distance,))
|
||
return True
|
||
|
||
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
|
||
"""Constrain angle between two lines."""
|
||
e1 = self._entities.get(line1.id)
|
||
e2 = self._entities.get(line2.id)
|
||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||
return False
|
||
self._solver.angle(e1.handle, e2.handle, angle, self._wp)
|
||
self._record_constraint("angle", (line1.id, line2.id), (angle,))
|
||
return True
|
||
|
||
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||
"""Constrain two lines to be parallel."""
|
||
e1 = self._entities.get(line1.id)
|
||
e2 = self._entities.get(line2.id)
|
||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||
return False
|
||
self._solver.parallel(e1.handle, e2.handle, self._wp)
|
||
self._record_constraint("parallel", (line1.id, line2.id))
|
||
return True
|
||
|
||
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||
"""Constrain two lines to be perpendicular."""
|
||
e1 = self._entities.get(line1.id)
|
||
e2 = self._entities.get(line2.id)
|
||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||
return False
|
||
self._solver.perpendicular(e1.handle, e2.handle, self._wp)
|
||
self._record_constraint("perpendicular", (line1.id, line2.id))
|
||
return True
|
||
|
||
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
|
||
"""Constrain a point to be at the midpoint of a line."""
|
||
pt = self._entities.get(point.id)
|
||
ln = self._entities.get(line.id)
|
||
if pt is None or ln is None or pt.handle is None or ln.handle is None:
|
||
return False
|
||
self._solver.midpoint(pt.handle, ln.handle, self._wp)
|
||
self._record_constraint("midpoint", (point.id, line.id), labels=("mid",))
|
||
if "mid" not in ln.constraints:
|
||
ln.constraints.append("mid")
|
||
return True
|
||
|
||
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
|
||
"""Constrain two entities to be tangent."""
|
||
e1 = self._entities.get(entity1.id)
|
||
e2 = self._entities.get(entity2.id)
|
||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||
return False
|
||
self._solver.tangent(e1.handle, e2.handle, self._wp)
|
||
self._record_constraint("tangent", (entity1.id, entity2.id))
|
||
return True
|
||
|
||
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||
"""Constrain two lines to have equal length."""
|
||
e1 = self._entities.get(line1.id)
|
||
e2 = self._entities.get(line2.id)
|
||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||
return False
|
||
self._solver.equal(e1.handle, e2.handle, self._wp)
|
||
self._record_constraint("equal", (line1.id, line2.id), labels=("eql",))
|
||
return True
|
||
|
||
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
|
||
"""Circle equal-radius (tracked only — solver limit)."""
|
||
self._record_constraint("equal_radius", (circle1.id, circle2.id))
|
||
return True
|
||
|
||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||
"""Fix an entity in place via dragged constraint."""
|
||
ent = self._entities.get(entity.id)
|
||
if ent is None or ent.handle is None:
|
||
return False
|
||
self._solver.dragged(ent.handle, self._wp)
|
||
self._record_constraint("fixed", (entity.id,))
|
||
return True
|
||
|
||
def constrain_symmetric(
|
||
self, entity1: SketchEntity, entity2: SketchEntity, line: SketchEntity
|
||
) -> bool:
|
||
"""Constrain symmetry about a line."""
|
||
e1 = self._entities.get(entity1.id)
|
||
e2 = self._entities.get(entity2.id)
|
||
ln = self._entities.get(line.id)
|
||
if e1 is None or e2 is None or ln is None:
|
||
return False
|
||
if e1.handle is None or e2.handle is None or ln.handle is None:
|
||
return False
|
||
self._solver.symmetric(e1.handle, e2.handle, ln.handle, self._wp)
|
||
self._record_constraint("symmetric", (entity1.id, entity2.id, line.id))
|
||
return True
|
||
|
||
# ─── Position updates (for moving entities) ──────────────────────────
|
||
|
||
def set_entity_position(self, entity: SketchEntity, x: float, y: float) -> bool:
|
||
"""Move a point entity's position in BOTH the solver (params) and local tracking.
|
||
|
||
Updating only ``entity.geometry`` is not enough — ``solve()`` reads from
|
||
the solver's internal parameter values and would revert the move. We push
|
||
the new coordinates into the solver via ``set_params`` so unconstrained
|
||
points keep their dragged location and constrained ones are recomputed.
|
||
"""
|
||
ent = self._entities.get(entity.id)
|
||
if ent is None or ent.handle is None:
|
||
return False
|
||
try:
|
||
self._solver.set_params(ent.handle.params, (x, y))
|
||
except Exception as e:
|
||
logger.debug(f"set_params failed for entity {entity.id}: {e}")
|
||
return False
|
||
ent.geometry = (x, y)
|
||
if entity.id in self._points:
|
||
self._points[entity.id] = (x, y)
|
||
return True
|
||
|
||
def set_positions(self, positions: Dict[int, Tuple[float, float]]) -> bool:
|
||
"""Bulk-apply new positions for a set of point entities (entity_id -> (x, y))."""
|
||
ok = True
|
||
for eid, (x, y) in positions.items():
|
||
ent = self._entities.get(eid)
|
||
if ent is None or ent.handle is None:
|
||
continue
|
||
try:
|
||
self._solver.set_params(ent.handle.params, (x, y))
|
||
ent.geometry = (x, y)
|
||
if eid in self._points:
|
||
self._points[eid] = (x, y)
|
||
except Exception as e:
|
||
logger.debug(f"set_positions failed for entity {eid}: {e}")
|
||
ok = False
|
||
return ok
|
||
|
||
# ─── Solving ───────────────────────────────────────────────────────────
|
||
|
||
def solve(self) -> bool:
|
||
"""Solve all constraints via SolveSpace solver."""
|
||
try:
|
||
result = self._solver.solve()
|
||
if result == ResultFlag.OKAY:
|
||
# Sync solved positions back to entity geometries
|
||
self._sync_solved_positions()
|
||
return True
|
||
else:
|
||
logger.warning(f"Solver returned: {result}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"Solver error: {e}")
|
||
return False
|
||
|
||
def _sync_solved_positions(self) -> None:
|
||
"""Read solved point positions from solver and update entity geometries."""
|
||
for entity_id, entity in list(self._entities.items()):
|
||
if entity.entity_type == "point" and entity.handle is not None:
|
||
try:
|
||
x, y = self._solver.params(entity.handle.params)
|
||
entity.geometry = (x, y)
|
||
if entity_id in self._points:
|
||
self._points[entity_id] = (x, y)
|
||
except Exception as e:
|
||
logger.debug(f"Could not sync point {entity_id}: {e}")
|
||
|
||
elif entity.entity_type == "line" and entity_id in self._lines:
|
||
start_id, end_id = self._lines[entity_id]
|
||
start_entity = self._entities.get(start_id)
|
||
end_entity = self._entities.get(end_id)
|
||
if start_entity and end_entity and start_entity.geometry and end_entity.geometry:
|
||
entity.geometry = (start_entity.geometry, end_entity.geometry)
|
||
|
||
def get_solved_point(self, entity_id: int) -> Optional[Tuple[float, float]]:
|
||
"""Get the solved position of a point entity."""
|
||
entity = self._entities.get(entity_id)
|
||
if entity and entity.entity_type == "point" and entity.handle is not None:
|
||
try:
|
||
x, y = self._solver.params(entity.handle.params)
|
||
return (float(x), float(y))
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def get_solved_param(self, handle: Any) -> Optional[Tuple[float, float]]:
|
||
"""Get solved params for a solver entity handle."""
|
||
try:
|
||
x, y = self._solver.params(handle.params)
|
||
return (float(x), float(y))
|
||
except Exception:
|
||
return None
|
||
|
||
# ─── Geometry extraction for operations ────────────────────────────────
|
||
|
||
def get_geometry(self) -> GeometryObject:
|
||
"""Get the solved geometry as an OCC ``TopoDS_Face`` on the workplane.
|
||
|
||
If the sketch has exactly one detected face (outer boundary + optional
|
||
holes) that face is returned. Otherwise falls back to returning a
|
||
single circle or polygon as a face. The returned object carries the
|
||
workplane normal in ``metadata["normal"]`` so the kernel can extrude
|
||
along the plane normal (not a hardcoded +Z).
|
||
"""
|
||
faces = self.detect_faces()
|
||
if len(faces) == 1:
|
||
return self.build_face_geometry(faces[0])
|
||
|
||
# Fallback: wrap the first non-external circle, or the polygon, as a
|
||
# single-loop face. External (underlay) circles are reference geometry
|
||
# and must not be returned as the extruded profile.
|
||
if self._circles:
|
||
for entity_id, (center_id, radius) in self._circles.items():
|
||
if entity_id in self._external_entity_ids:
|
||
continue
|
||
center_entity = self._entities.get(center_id)
|
||
circle_ent = self._entities.get(entity_id)
|
||
if center_entity and center_entity.geometry and not center_entity.is_external:
|
||
# Skip construction circles — they're reference geometry.
|
||
if circle_ent is not None and circle_ent.is_construction:
|
||
continue
|
||
cx, cy = center_entity.geometry
|
||
face_dict = {
|
||
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
|
||
"holes": [],
|
||
}
|
||
return self.build_face_geometry(face_dict)
|
||
|
||
points = self.get_polygon_points()
|
||
if not points:
|
||
return OCCGeometryObject(None)
|
||
|
||
face_dict = {
|
||
"outer": {"type": "polygon", "points": [(p.x, p.y) for p in points]},
|
||
"holes": [],
|
||
}
|
||
return self.build_face_geometry(face_dict)
|
||
|
||
def get_points(self) -> List[Point2D]:
|
||
"""Get all point positions from solved solver data."""
|
||
points: List[Point2D] = []
|
||
|
||
for entity_id, entity in self._entities.items():
|
||
if entity.entity_type == "point":
|
||
# Try to get solved position first
|
||
if entity.handle is not None:
|
||
try:
|
||
x, y = self._solver.params(entity.handle.params)
|
||
points.append(Point2D(x, y))
|
||
continue
|
||
except Exception:
|
||
pass
|
||
# Fall back to stored geometry
|
||
if entity.geometry:
|
||
x, y = entity.geometry
|
||
points.append(Point2D(x, y))
|
||
|
||
return points
|
||
|
||
def get_polygon_points(self) -> List[Point2D]:
|
||
"""Get ordered polygon points from connected lines (uses solved positions).
|
||
|
||
External (underlay) and construction lines are skipped — they are
|
||
reference geometry only, not part of the sketch profile.
|
||
"""
|
||
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
||
|
||
for entity in self._entities.values():
|
||
if entity.entity_type == "line" and entity.geometry and not entity.is_external and not entity.is_construction:
|
||
p1, p2 = entity.geometry
|
||
if p1 not in adjacency:
|
||
adjacency[p1] = []
|
||
if p2 not in adjacency:
|
||
adjacency[p2] = []
|
||
adjacency[p1].append(p2)
|
||
adjacency[p2].append(p1)
|
||
|
||
if not adjacency:
|
||
return []
|
||
|
||
ordered: List[Point2D] = []
|
||
visited: set = set()
|
||
current = next(iter(adjacency.keys()))
|
||
|
||
while current and tuple(current) not in visited:
|
||
ordered.append(Point2D(current[0], current[1]))
|
||
visited.add(tuple(current))
|
||
|
||
neighbors = adjacency.get(current, [])
|
||
next_point = None
|
||
for n in neighbors:
|
||
if tuple(n) not in visited:
|
||
next_point = n
|
||
break
|
||
current = next_point
|
||
|
||
if len(ordered) > 2:
|
||
ordered.append(ordered[0])
|
||
|
||
return ordered
|
||
|
||
# ─── Closed-loop / face detection (for region selection + holes) ──────
|
||
|
||
_SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints
|
||
|
||
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||
"""Current line segments as world-coordinate tuples (uses solved positions).
|
||
|
||
Returns both straight line segments AND tessellated arc segments so
|
||
that arcs participate in closed-loop / face detection. Construction
|
||
and external entities are excluded — they're reference geometry and
|
||
must not affect the sketch profile.
|
||
|
||
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]]] = []
|
||
|
||
# ── Straight line segments ──
|
||
for line_id, (sid, eid2) in self._lines.items():
|
||
if line_id in self._external_entity_ids:
|
||
continue
|
||
line_ent = self._entities.get(line_id)
|
||
if line_ent is not None and line_ent.is_construction:
|
||
continue
|
||
s_ent = self._entities.get(sid)
|
||
e_ent = self._entities.get(eid2)
|
||
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]))))
|
||
|
||
# ── Arc segments (tessellated) ──
|
||
for arc_id, arc_data in self._arcs.items():
|
||
arc_ent = self._entities.get(arc_id)
|
||
if arc_ent is not None and arc_ent.is_construction:
|
||
continue
|
||
center_id = arc_data.get("center")
|
||
start_id = arc_data.get("start")
|
||
end_id = arc_data.get("end")
|
||
radius = arc_data.get("radius", 0.0)
|
||
sweep = arc_data.get("sweep")
|
||
if sweep is None or radius <= 0:
|
||
continue
|
||
c_ent = self._entities.get(center_id)
|
||
s_ent = self._entities.get(start_id)
|
||
e_ent = self._entities.get(end_id)
|
||
if not (c_ent and s_ent and e_ent
|
||
and c_ent.geometry and s_ent.geometry and e_ent.geometry):
|
||
continue
|
||
cx, cy = c_ent.geometry
|
||
sx, sy = s_ent.geometry
|
||
start_angle = math.atan2(sy - cy, sx - cx)
|
||
# ~12 segments per π radians
|
||
n = max(4, int(abs(sweep) / (math.pi / 12)))
|
||
for i in range(n):
|
||
t1 = i / n
|
||
t2 = (i + 1) / n
|
||
a1 = start_angle + t1 * sweep
|
||
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))
|
||
|
||
return segs
|
||
|
||
def get_closed_loops(self) -> List[Dict[str, Any]]:
|
||
"""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}
|
||
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
|
||
(a simple closed polyline) are accepted as polygon loops.
|
||
"""
|
||
loops: List[Dict[str, Any]] = []
|
||
segs = self._line_segments()
|
||
|
||
if segs:
|
||
# Snap endpoints to integer-ish keys to group coincident points.
|
||
def key(pt):
|
||
return (round(pt[0] / self._SNAP_TOL), round(pt[1] / self._SNAP_TOL))
|
||
|
||
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)
|
||
edges.append((k1, k2))
|
||
|
||
# Undirected adjacency.
|
||
adj: Dict[Any, List[Any]] = {}
|
||
for a, b in edges:
|
||
adj.setdefault(a, []).append(b)
|
||
adj.setdefault(b, []).append(a)
|
||
|
||
# Connected components (each node with degree 2 → closed loop).
|
||
seen: set = set()
|
||
for start in adj:
|
||
if start in seen or len(adj[start]) != 2:
|
||
continue
|
||
# Walk the component.
|
||
comp: List[Any] = []
|
||
stack = [start]
|
||
comp_seen: set = set()
|
||
while stack:
|
||
n = stack.pop()
|
||
if n in comp_seen:
|
||
continue
|
||
comp_seen.add(n)
|
||
comp.append(n)
|
||
for nb in adj.get(n, []):
|
||
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.
|
||
ordered: List[Any] = []
|
||
cur = comp[0]
|
||
prev = None
|
||
for _ in range(len(comp)):
|
||
ordered.append(cur)
|
||
nbrs = [nb for nb in adj[cur] if nb != prev]
|
||
if not nbrs:
|
||
break
|
||
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})
|
||
seen |= comp_seen
|
||
|
||
# Circles are closed loops of their own.
|
||
for cid, (center_id, r) in self._circles.items():
|
||
c_ent = self._entities.get(center_id)
|
||
if c_ent and c_ent.geometry and r > 0:
|
||
loops.append({"type": "circle", "center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
|
||
"radius": float(r)})
|
||
return loops
|
||
|
||
@staticmethod
|
||
def _point_in_polygon(
|
||
pt: Tuple[float, float],
|
||
poly: List[Tuple[float, float]],
|
||
margin: float = 0.0,
|
||
) -> bool:
|
||
"""Ray-casting point-in-polygon test.
|
||
|
||
Returns *True* for points strictly inside the polygon. Points on
|
||
the boundary (within eps=1e-9) are *outside* by default so the
|
||
outer boundary of a nested shape doesn't falsely contain a hole's
|
||
rep point. When *margin* > 0, points that are within that many
|
||
world-unit of the boundary are also treated as inside — used by
|
||
``_loop_contains`` to prevent float rounding from breaking
|
||
thin-wall nesting detection.
|
||
"""
|
||
x, y = pt
|
||
eps = 1e-9 # strict boundary rejection
|
||
margin = float(margin)
|
||
n = len(poly)
|
||
inside = False
|
||
j = n - 1
|
||
for i in range(n):
|
||
xi, yi = poly[i]
|
||
xj, yj = poly[j]
|
||
# Point-on-segment test — exclude strict boundary hits.
|
||
# First check bounding box of the segment.
|
||
bbox_tol = max(eps, margin)
|
||
if min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol:
|
||
# Check collinearity
|
||
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
|
||
abs_cross = abs(cross)
|
||
if abs_cross < eps:
|
||
# Strictly on boundary — return False unless margin says otherwise.
|
||
if margin > 0 and abs_cross < margin:
|
||
pass # fall through to ray-cast below
|
||
else:
|
||
return False
|
||
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
|
||
inside = not inside
|
||
j = i
|
||
return inside
|
||
|
||
@staticmethod
|
||
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
|
||
"""Does ``outer`` fully enclose ``inner``?
|
||
|
||
For polygon-polygon: checks that ALL vertices of ``inner`` are strictly
|
||
inside ``outer`` using ray-casting. This is robust for convex polygons
|
||
and avoids the representative-point issue where a large nested loop's
|
||
centroid lands inside an inner loop.
|
||
|
||
For circle-in-polygon: checks the circle centre is inside the polygon
|
||
(vertex check would be too strict for tessellated arc segments).
|
||
|
||
For circle-in-circle: checks distance between centres + inner radius
|
||
< outer radius + margin.
|
||
|
||
For polygon-in-circle: checks all polygon vertices are inside the
|
||
circle.
|
||
"""
|
||
eps = 1e-3
|
||
|
||
if outer["type"] == "circle":
|
||
ox, oy = outer["center"]
|
||
orad = outer["radius"]
|
||
if inner["type"] == "circle":
|
||
# Two circles: centre distance + inner radius < outer radius
|
||
dx = inner["center"][0] - ox
|
||
dy = inner["center"][1] - oy
|
||
return math.hypot(dx, dy) + inner["radius"] < orad + eps
|
||
else:
|
||
# Polygon in circle: all vertices inside
|
||
pts = inner["points"]
|
||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||
pts = pts[:-1]
|
||
for pt in pts:
|
||
if math.hypot(pt[0] - ox, pt[1] - oy) > orad - eps:
|
||
return False
|
||
return True
|
||
else:
|
||
# outer is polygon
|
||
if inner["type"] == "circle":
|
||
# Circle in polygon: centre must be inside with margin
|
||
cx, cy = inner["center"]
|
||
return OCCSketch._point_in_polygon(
|
||
(cx, cy), outer["points"], margin=1e-3
|
||
)
|
||
else:
|
||
# Polygon in polygon: ALL inner vertices inside outer
|
||
pts = inner["points"]
|
||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||
pts = pts[:-1]
|
||
for pt in pts:
|
||
if not OCCSketch._point_in_polygon(
|
||
pt, outer["points"], margin=eps
|
||
):
|
||
return False
|
||
return True
|
||
|
||
@staticmethod
|
||
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
|
||
"""An interior representative point inside a loop.
|
||
|
||
Only used for circle-in-polygon containment checks (polygon-in-polygon
|
||
uses all-vertex containment). Returns the centroid for polygons and
|
||
the centre for circles.
|
||
"""
|
||
if loop["type"] == "polygon":
|
||
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
|
||
n = len(pts)
|
||
if n < 3:
|
||
return loop.get("center", (0.0, 0.0))
|
||
sx = sum(p[0] for p in pts) / n
|
||
sy = sum(p[1] for p in pts) / n
|
||
return (sx, sy)
|
||
return loop.get("center", (0.0, 0.0))
|
||
|
||
@staticmethod
|
||
def _loop_area(loop: Dict[str, Any]) -> float:
|
||
if loop["type"] == "circle":
|
||
return math.pi * loop["radius"] ** 2
|
||
pts = loop["points"]
|
||
if len(pts) < 4:
|
||
return 0.0
|
||
area = 0.0
|
||
n = len(pts) - 1 # last == first
|
||
for i in range(n):
|
||
x1, y1 = pts[i]
|
||
x2, y2 = pts[i + 1]
|
||
area += x1 * y2 - x2 * y1
|
||
return abs(area) / 2.0
|
||
|
||
def detect_faces(self) -> List[Dict[str, Any]]:
|
||
"""Build faces from closed loops using nesting depth.
|
||
|
||
Nesting rule (standard CAD even-odd): a loop's depth = number of other
|
||
loops that strictly contain it. Even-depth loops (0, 2, ...) are outer
|
||
boundaries (solid material); odd-depth loops directly inside them are
|
||
holes. So a rectangle (depth 0) wrapping a circle (depth 1) yields a face
|
||
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}``.
|
||
"""
|
||
loops = self.get_closed_loops()
|
||
if not loops:
|
||
return []
|
||
depths: List[int] = []
|
||
for i, li in enumerate(loops):
|
||
d = 0
|
||
for j, lj in enumerate(loops):
|
||
if i != j and OCCSketch._loop_contains(li, lj):
|
||
d += 1
|
||
depths.append(d)
|
||
|
||
faces: List[Dict[str, Any]] = []
|
||
for i, outer in enumerate(loops):
|
||
if depths[i] % 2 != 0:
|
||
continue # only even-depth loops are outer boundaries
|
||
holes: List[Dict[str, Any]] = []
|
||
for j, inner in enumerate(loops):
|
||
if i == j:
|
||
continue
|
||
# 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]})
|
||
return faces
|
||
|
||
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
|
||
"""Return the face whose solid region (outer minus holes) contains (x, y)."""
|
||
pt = (x, y)
|
||
best: Optional[Dict[str, Any]] = None
|
||
best_area = float("inf")
|
||
for face in self.detect_faces():
|
||
outer = face["outer"]
|
||
if outer["type"] == "polygon":
|
||
if not OCCSketch._point_in_polygon(pt, outer["points"]):
|
||
continue
|
||
else:
|
||
cx, cy = outer["center"]
|
||
if not (math.hypot(pt[0] - cx, pt[1] - cy) < outer["radius"]):
|
||
continue
|
||
# Must not be inside any hole of this face.
|
||
in_hole = False
|
||
for h in face["holes"]:
|
||
if h["type"] == "polygon":
|
||
if OCCSketch._point_in_polygon(pt, h["points"]):
|
||
in_hole = True; break
|
||
else:
|
||
hcx, hcy = h["center"]
|
||
if math.hypot(pt[0] - hcx, pt[1] - hcy) < h["radius"]:
|
||
in_hole = True; break
|
||
if in_hole:
|
||
continue
|
||
area = OCCSketch._loop_area(outer)
|
||
if area < best_area:
|
||
best_area = area
|
||
best = face
|
||
return best
|
||
|
||
@staticmethod
|
||
def _loop_signed_area(loop: Dict[str, Any]) -> float:
|
||
"""Signed area of a loop. Positive = CCW, negative = CW.
|
||
|
||
Circles are treated as CCW (positive area) because
|
||
``gp_Circ`` / ``gp_Ax2`` creates edges with CCW parametric
|
||
direction when looking against the normal.
|
||
"""
|
||
if loop["type"] == "circle":
|
||
r = loop.get("radius", 0.0)
|
||
return math.pi * r * r # always positive (CCW)
|
||
pts = loop["points"]
|
||
if len(pts) < 3:
|
||
return 0.0
|
||
area = 0.0
|
||
n = len(pts) - 1 # last point == first for closed loops
|
||
for i in range(n):
|
||
x1, y1 = pts[i]
|
||
x2, y2 = pts[i + 1]
|
||
area += x1 * y2 - x2 * y1
|
||
return area / 2.0
|
||
|
||
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
|
||
"""Build an OCC face (outer boundary + inner holes) on the workplane.
|
||
|
||
Wires are constructed from UV coordinates mapped through
|
||
:meth:`_uv_to_world`, so the resulting ``TopoDS_Face`` lies on this
|
||
sketch's 3D plane (not necessarily XY). The returned object stores
|
||
the raw OCC face in ``.shape`` and the plane normal in
|
||
``metadata["normal"]`` for the extrude kernel.
|
||
|
||
Hole wires are oriented to have OPPOSITE geometric winding relative
|
||
to the outer wire, which is what OCC's face builder expects for
|
||
proper hole treatment. Previous code unconditionally reversed ALL
|
||
hole wires, which produced solid islands (not holes) whenever the
|
||
outer loop had clockwise winding — e.g. after dragging a rectangle
|
||
from top-left to bottom-right.
|
||
"""
|
||
from OCP.BRepBuilderAPI import (
|
||
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
|
||
BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge,
|
||
)
|
||
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
|
||
from OCP.TopoDS import TopoDS as _TopoDS
|
||
|
||
def _wire_from_loop(loop: Dict[str, Any]):
|
||
"""Build a wire from a loop dict. No orientation adjustment."""
|
||
if loop["type"] == "polygon":
|
||
mp = BRepBuilderAPI_MakePolygon()
|
||
for (pu, pv) in loop["points"]:
|
||
mp.Add(self._uv_to_world(pu, pv))
|
||
mp.Close()
|
||
mp.Build()
|
||
return mp.Wire()
|
||
cu, cv = loop["center"]
|
||
r = loop["radius"]
|
||
circ = gp_Circ(self._circle_axis(cu, cv), r)
|
||
me = BRepBuilderAPI_MakeEdge(circ)
|
||
me.Build()
|
||
mw = BRepBuilderAPI_MakeWire()
|
||
mw.Add(me.Edge())
|
||
mw.Build()
|
||
return mw.Wire()
|
||
|
||
outer_loop = face["outer"]
|
||
outer_wire = _wire_from_loop(outer_loop)
|
||
outer_winding = self._loop_signed_area(outer_loop)
|
||
|
||
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
|
||
for h in face["holes"]:
|
||
hole_wire = _wire_from_loop(h)
|
||
hole_winding = self._loop_signed_area(h)
|
||
# OCC expects hole wires to have OPPOSITE winding to the outer
|
||
# wire (material on the other side). We reverse the hole wire
|
||
# only when its natural winding matches the outer's; if they
|
||
# already differ the wire is left as-is.
|
||
if (hole_winding >= 0 and outer_winding >= 0) or (hole_winding < 0 and outer_winding < 0):
|
||
hole_wire = _TopoDS.Wire_s(hole_wire.Reversed())
|
||
face_maker.Add(hole_wire)
|
||
face_maker.Build()
|
||
occ_face = face_maker.Face()
|
||
|
||
obj = OCCGeometryObject(occ_face, {
|
||
"type": "sketch_face",
|
||
"normal": self._wp_normal,
|
||
"origin": self._wp_origin,
|
||
})
|
||
return obj
|
||
|
||
def get_solver_dof(self) -> int:
|
||
"""Get remaining degrees of freedom from solver."""
|
||
return self._solver.dof()
|
||
|
||
def get_solver_failures(self) -> List[Any]:
|
||
"""Get list of failed constraints."""
|
||
return self._solver.failures()
|
||
|
||
# ─── Management ────────────────────────────────────────────────────────
|
||
|
||
def clear(self) -> None:
|
||
"""Clear all geometry and constraints from both solver and tracker."""
|
||
self._solver = SolverSystem()
|
||
self._wp = self._solver.create_2d_base()
|
||
self._entities.clear()
|
||
self._points.clear()
|
||
self._lines.clear()
|
||
self._circles.clear()
|
||
self._arcs.clear()
|
||
self._entity_counter = 0
|
||
self._constraint_count = 0
|
||
self._constraint_log.clear()
|
||
self._external_entity_ids.clear()
|
||
self._centerline_ids.clear()
|
||
self._first_point_id = None
|
||
|
||
def _prune_log_for(self, removed_ids: set) -> None:
|
||
"""Drop constraint-log entries that reference any id in ``removed_ids``."""
|
||
kept_log: List[Dict[str, Any]] = []
|
||
for entry in self._constraint_log:
|
||
if not (set(entry["ids"]) & removed_ids):
|
||
kept_log.append(entry)
|
||
self._constraint_log = kept_log
|
||
self._constraint_count = len(kept_log)
|
||
|
||
def delete_line(self, line: SketchEntity) -> bool:
|
||
"""Delete a single line and recompute the surviving constraints.
|
||
|
||
python_solvespace has no API to remove an individual entity/constraint,
|
||
so this removes the line from local tracking, prunes any logged
|
||
constraint that referenced it, rebuilds the whole solver system from
|
||
the surviving points/lines + pruned log, and re-solves. The line's
|
||
endpoint points are NOT removed — only the line segment.
|
||
|
||
Note: centerlines (reference axes through origin) cannot be deleted.
|
||
"""
|
||
if line.id not in self._lines or line.id not in self._entities:
|
||
return False
|
||
|
||
# Centerlines are permanent reference axes — refuse deletion.
|
||
if line.id in self._centerline_ids:
|
||
logger.debug("Refusing to delete centerline")
|
||
return False
|
||
|
||
del self._lines[line.id]
|
||
if line.id in self._entities:
|
||
del self._entities[line.id]
|
||
|
||
# Prune log entries referencing the deleted line (labels are re-derived
|
||
# from the surviving log below, so no manual label stripping here).
|
||
self._prune_log_for({line.id})
|
||
|
||
self._rebuild_solver()
|
||
self._rebuild_labels()
|
||
return self.solve()
|
||
|
||
def remove_constraint_at(self, index: int) -> bool:
|
||
"""Remove a single constraint (by log index) and recompute the rest.
|
||
|
||
Used by the sketch widget when the user hovers a constraint tag and
|
||
presses Delete. Drops that one log entry, rebuilds the solver from the
|
||
surviving log, re-derives UI labels, and re-solves.
|
||
"""
|
||
if index < 0 or index >= len(self._constraint_log):
|
||
return False
|
||
del self._constraint_log[index]
|
||
self._constraint_count = len(self._constraint_log)
|
||
self._rebuild_solver()
|
||
self._rebuild_labels()
|
||
return self.solve()
|
||
|
||
def delete_point(self, point: SketchEntity) -> bool:
|
||
"""Delete a point, any lines that use it as an endpoint, and recompute.
|
||
|
||
Removing a point invalidates every line that references it (a line with
|
||
a missing endpoint is meaningless), so those lines are removed too.
|
||
All constraints that reference the point OR the removed lines are
|
||
pruned from the log, the solver is rebuilt from survivors, labels are
|
||
re-derived, and the system is re-solved.
|
||
|
||
Note: centerlines (reference axes through origin) cannot be deleted.
|
||
"""
|
||
if point.id not in self._entities or point.id not in self._points:
|
||
return False
|
||
|
||
# Centerline points are permanent reference anchors — refuse deletion.
|
||
if point.id in self._centerline_ids:
|
||
logger.debug("Refusing to delete centerline point")
|
||
return False
|
||
|
||
removed_ids: set = {point.id}
|
||
# Remove lines that use this point as an endpoint.
|
||
removed_line_keys: List[int] = [
|
||
lid for lid, (sid, eid2) in list(self._lines.items())
|
||
if sid == point.id or eid2 == point.id
|
||
]
|
||
for lid in removed_line_keys:
|
||
removed_ids.add(lid)
|
||
del self._lines[lid]
|
||
if lid in self._entities:
|
||
del self._entities[lid]
|
||
# Remove the point itself.
|
||
del self._points[point.id]
|
||
if point.id in self._entities:
|
||
del self._entities[point.id]
|
||
# Circles anchored on the point are also invalid.
|
||
removed_circle_keys: List[int] = [
|
||
cid for cid, (center_id, _r) in list(self._circles.items())
|
||
if center_id == point.id
|
||
]
|
||
for cid in removed_circle_keys:
|
||
removed_ids.add(cid)
|
||
del self._circles[cid]
|
||
if cid in self._entities:
|
||
del self._entities[cid]
|
||
# Arcs referencing this point (as centre, start, or end) are invalid.
|
||
removed_arc_keys: List[int] = [
|
||
aid for aid, adata in list(self._arcs.items())
|
||
if adata.get("center") == point.id
|
||
or adata.get("start") == point.id
|
||
or adata.get("end") == point.id
|
||
]
|
||
for aid in removed_arc_keys:
|
||
removed_ids.add(aid)
|
||
del self._arcs[aid]
|
||
if aid in self._entities:
|
||
del self._entities[aid]
|
||
|
||
self._prune_log_for(removed_ids)
|
||
self._rebuild_solver()
|
||
self._rebuild_labels()
|
||
return self.solve()
|
||
|
||
def _rebuild_labels(self) -> None:
|
||
"""Re-derive each entity's UI constraint labels from the surviving log.
|
||
|
||
paintEvent displays labels read off the endpoint POINT entities ("hrz",
|
||
"vrt", "mid", ...). After a delete, recompute them from scratch so a
|
||
removed line's labels don't linger on points that still belong to other
|
||
(unaffected) lines.
|
||
"""
|
||
for ent in self._entities.values():
|
||
ent.constraints = []
|
||
for entry in self._constraint_log:
|
||
labels = entry.get("labels") or set()
|
||
if not labels:
|
||
continue
|
||
ctype = entry["type"]
|
||
ids = entry["ids"]
|
||
targets: List[OCCSketchEntity] = []
|
||
if ctype in ("horizontal", "vertical"):
|
||
sid, eid2 = self._lines.get(ids[0], (None, None))
|
||
for pid in (sid, eid2):
|
||
if pid is not None and pid in self._entities:
|
||
targets.append(self._entities[pid])
|
||
elif ctype == "midpoint":
|
||
sid, eid2 = self._lines.get(ids[1], (None, None))
|
||
for pid in (sid, eid2):
|
||
if pid is not None and pid in self._entities:
|
||
targets.append(self._entities[pid])
|
||
if ids[0] in self._entities:
|
||
targets.append(self._entities[ids[0]])
|
||
else:
|
||
# distance / equal / parallel / etc.: tag referenced entities'
|
||
# endpoints (lines) or the points themselves.
|
||
for eid in ids:
|
||
if eid in self._lines:
|
||
sid, eid2 = self._lines[eid]
|
||
for pid in (sid, eid2):
|
||
if pid in self._entities:
|
||
targets.append(self._entities[pid])
|
||
elif eid in self._entities:
|
||
targets.append(self._entities[eid])
|
||
for t in targets:
|
||
for lbl in labels:
|
||
if lbl not in t.constraints:
|
||
t.constraints.append(lbl)
|
||
|
||
def delete_entity(self, entity: SketchEntity) -> bool:
|
||
"""Delete an entity and its constraints (no solver rebuild)."""
|
||
if entity.id not in self._entities:
|
||
return False
|
||
|
||
# Remove from solver (clear + rebuild is simplest)
|
||
# For simplicity, we skip solver removal — on next solve, stale handles
|
||
# will be ignored. A full rebuild would need entity-by-entity solver removal.
|
||
del self._entities[entity.id]
|
||
|
||
if entity.id in self._points:
|
||
del self._points[entity.id]
|
||
if entity.id in self._lines:
|
||
del self._lines[entity.id]
|
||
if entity.id in self._circles:
|
||
del self._circles[entity.id]
|
||
if entity.id in self._arcs:
|
||
del self._arcs[entity.id]
|
||
|
||
return True
|
||
|
||
def get_entity_count(self) -> int:
|
||
"""Get the number of entities in the sketch."""
|
||
return len(self._entities)
|
||
|
||
def get_constraint_count(self) -> int:
|
||
"""Get the number of constraints applied via solver."""
|
||
return self._constraint_count
|
||
|
||
def is_fully_constrained(self) -> bool:
|
||
"""Check if the sketch is fully constrained (0 DOF)."""
|
||
try:
|
||
return self._solver.dof() == 0
|
||
except Exception:
|
||
return False
|
||
|
||
# ─── Serialization (used by fluency.io.project_io) ─────────────────────
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
"""Serialize the sketch to a plain-dict for JSON storage.
|
||
|
||
Captures: the workplane, every entity (with its current geometry and
|
||
flags), the constraint log, and the entity counter. Live solver
|
||
handles are intentionally NOT saved — the consumer must call
|
||
:meth:`from_dict` (or :meth:`rebuild_from_dict`) to rebuild the
|
||
SolveSpace system before solving again.
|
||
"""
|
||
# Sort entities by id so replay order is deterministic and matches
|
||
# creation order (ids are assigned monotonically by ``_next_id``).
|
||
entities_payload: List[Dict[str, Any]] = []
|
||
for eid in sorted(self._entities.keys()):
|
||
ent = self._entities[eid]
|
||
entities_payload.append(
|
||
{
|
||
"id": eid,
|
||
"type": ent.entity_type,
|
||
# geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)),
|
||
# circle→((cx,cy),r), arc→dict. All JSON-friendly.
|
||
"geometry": ent.geometry,
|
||
"is_construction": bool(ent.is_construction),
|
||
"is_external": bool(ent.is_external),
|
||
"constraints": list(ent.constraints),
|
||
}
|
||
)
|
||
|
||
# Sets become sorted lists for JSON. ``labels`` inside constraint_log
|
||
# is a set on the wire; convert to sorted list for JSON round-trip.
|
||
constraint_log_payload: List[Dict[str, Any]] = []
|
||
for entry in self._constraint_log:
|
||
constraint_log_payload.append(
|
||
{
|
||
"type": entry["type"],
|
||
"ids": list(entry["ids"]),
|
||
"params": list(entry["params"]),
|
||
"labels": sorted(entry["labels"]),
|
||
}
|
||
)
|
||
|
||
return {
|
||
"wp_origin": list(self._wp_origin),
|
||
"wp_normal": list(self._wp_normal),
|
||
"wp_x_dir": list(self._wp_x_dir),
|
||
"wp_y_dir": list(self._wp_y_dir),
|
||
"entity_counter": self._entity_counter,
|
||
"first_point_id": self._first_point_id,
|
||
"external_entity_ids": sorted(self._external_entity_ids),
|
||
"centerline_ids": sorted(self._centerline_ids),
|
||
"constraint_count": self._constraint_count,
|
||
"entities": entities_payload,
|
||
"constraint_log": constraint_log_payload,
|
||
}
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: Dict[str, Any]) -> "OCCSketch":
|
||
"""Build a fresh OCCSketch that reproduces the saved state.
|
||
|
||
Replays the construction sequence (points → lines → circles → arcs,
|
||
respecting external/centerline flags) and re-applies every constraint
|
||
in the saved log. The SolveSpace solver is deterministic for a given
|
||
input, so the post-solve state matches the saved one.
|
||
"""
|
||
sk = cls()
|
||
sk.rebuild_from_dict(data)
|
||
return sk
|
||
|
||
def rebuild_from_dict(self, data: Dict[str, Any]) -> None:
|
||
"""In-place restore from a dict produced by :meth:`to_dict`.
|
||
|
||
Wipes the current solver/state and re-creates every entity in id
|
||
order so :attr:`_first_point_id` is anchored correctly. Existing
|
||
callers (notably the solver-rebuild path on entity delete) don't use
|
||
this; only the project load path does.
|
||
"""
|
||
# Wipe solver + trackers (don't lose the workplane yet — we set it
|
||
# explicitly below).
|
||
self.clear()
|
||
self._external_entity_ids.clear()
|
||
self._centerline_ids.clear()
|
||
|
||
# 1. Workplane.
|
||
self.set_workplane(
|
||
tuple(data["wp_origin"]),
|
||
tuple(data["wp_normal"]),
|
||
tuple(data["wp_x_dir"]),
|
||
)
|
||
|
||
# 2. Force the entity counter so the replay assigns the same ids as
|
||
# the saved sketch — the constraint log references those ids.
|
||
self._entity_counter = int(data.get("entity_counter", 0))
|
||
|
||
# 3. Replay entities in id order. We need the OCCSketchEntity
|
||
# objects back (for arc center/start/end lookups), so we
|
||
# reconstruct by id and let ``_next_id`` advance the counter.
|
||
entities_by_id: Dict[int, OCCSketchEntity] = {}
|
||
for entry in data.get("entities", []):
|
||
eid = int(entry["id"])
|
||
# Ensure the next _next_id() call returns eid.
|
||
self._entity_counter = eid - 1
|
||
etype = entry["type"]
|
||
geom = entry.get("geometry")
|
||
is_external = bool(entry.get("is_external", False))
|
||
|
||
if etype == "point":
|
||
x, y = float(geom[0]), float(geom[1])
|
||
if is_external:
|
||
ent = self.add_external_point(x, y)
|
||
else:
|
||
ent = self.add_point(x, y)
|
||
elif etype == "line":
|
||
# line geometry is ((x1,y1),(x2,y2)); we already know the
|
||
# endpoints exist as point entities. Look them up by saved
|
||
# position via _points (which was just populated above).
|
||
(x1, y1), (x2, y2) = geom
|
||
s_id = self._find_point_at(x1, y1)
|
||
e_id = self._find_point_at(x2, y2)
|
||
if s_id is None or e_id is None:
|
||
logger.warning(
|
||
"Skipping line %s during load: endpoints not found", eid
|
||
)
|
||
continue
|
||
if is_external:
|
||
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
|
||
else:
|
||
ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id])
|
||
elif etype == "circle":
|
||
(cx, cy), radius = geom
|
||
c_id = self._find_point_at(cx, cy)
|
||
if c_id is None:
|
||
logger.warning(
|
||
"Skipping circle %s during load: center not found", eid
|
||
)
|
||
continue
|
||
ent = self.add_circle(entities_by_id[c_id], float(radius))
|
||
elif etype == "arc":
|
||
center_pos = tuple(geom["center"])
|
||
start_pos = tuple(geom["start"])
|
||
end_pos = tuple(geom["end"])
|
||
radius = float(geom["radius"])
|
||
sweep = float(geom.get("sweep", 0.0))
|
||
c_id = self._find_point_at(*center_pos)
|
||
s_id = self._find_point_at(*start_pos)
|
||
e_id = self._find_point_at(*end_pos)
|
||
if c_id is None or s_id is None or e_id is None:
|
||
logger.warning(
|
||
"Skipping arc %s during load: endpoints not found", eid
|
||
)
|
||
continue
|
||
ent = self.add_arc(
|
||
entities_by_id[c_id],
|
||
radius,
|
||
entities_by_id[s_id],
|
||
entities_by_id[e_id],
|
||
sweep=sweep,
|
||
)
|
||
else:
|
||
logger.warning("Unknown sketch entity type %r; skipping", etype)
|
||
continue
|
||
|
||
# Restore the per-entity UI flags / labels that aren't carried
|
||
# by the add_* methods themselves.
|
||
ent.is_construction = bool(entry.get("is_construction", False))
|
||
ent.constraints = list(entry.get("constraints", []))
|
||
entities_by_id[eid] = ent
|
||
|
||
# 4. Replay constraint log. ``_apply_constraint_log`` re-issues the
|
||
# solver call and pushes back into the entity tracker via
|
||
# ``entity.constraints``. We don't double-record into the log
|
||
# itself (the log was already cleared by ``clear()`` and we
|
||
# re-populate it here, so the live ``_constraint_count`` will
|
||
# reflect the saved state at the end).
|
||
for entry in data.get("constraint_log", []):
|
||
self._record_constraint(
|
||
entry["type"],
|
||
tuple(entry["ids"]),
|
||
tuple(entry.get("params", ())),
|
||
tuple(entry.get("labels", ())),
|
||
)
|
||
# Re-apply the live solver calls AFTER the log is restored, so that
|
||
# the constraint tracker matches the solver state on a fresh solve.
|
||
for entry in self._constraint_log:
|
||
self._apply_constraint_log(entry)
|
||
|
||
# 5. Restore external / centerline id sets. ``add_external_*`` adds
|
||
# to the set internally; if the entity's id was a regular entity
|
||
# for some reason (legacy / hand-edited file), fold it in too so
|
||
# the saved flag is authoritative.
|
||
for eid in data.get("external_entity_ids", []):
|
||
self._external_entity_ids.add(int(eid))
|
||
for eid in data.get("centerline_ids", []):
|
||
self._centerline_ids.add(int(eid))
|
||
|
||
def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]:
|
||
"""Return the entity id of a point sitting at UV ``(x, y)`` (within tol)."""
|
||
for pid, pos in self._points.items():
|
||
if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol:
|
||
return pid
|
||
return None
|