3013 lines
133 KiB
Python
3013 lines
133 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__)
|
||
|
||
# World-unit tolerance used when matching a saved line/circle/arc position to
|
||
# an existing point entity during load. Old files can carry derived geometry
|
||
# that is stale relative to the point entities (saved after a drag that was
|
||
# never re-solved); the fallback must be generous enough to reach the real
|
||
# point while staying far below typical feature sizes.
|
||
_LOAD_POINT_TOL = 0.5
|
||
|
||
|
||
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
|
||
|
||
# Cached 2D normal for the workplane. SolveSpace's add_arc() needs
|
||
# a normal_2d handle, and creating one per arc pollutes the solver
|
||
# with redundant entities. We create it lazily on first arc and
|
||
# reset it whenever the workplane or solver is reset.
|
||
self._wp_normal_handle: Optional[Any] = None
|
||
# Set of arc ids whose diameter is locked by a ``diameter``
|
||
# constraint — for those we MUST NOT overwrite the stored radius
|
||
# from the geometry, because the user explicitly fixed it.
|
||
self._arc_diameter_fixed: set = set()
|
||
|
||
# ── 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.
|
||
"""
|
||
|
||
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 _make_arc_normal_3d(self) -> Any:
|
||
"""Build a SolveSpace 3D normal (quaternion) that matches this sketch's workplane orientation.
|
||
|
||
SolveSpace's ``add_arc`` requires a 3D normal (quaternion) for the
|
||
arc plane, NOT a 2D one — passing a 2D normal raises
|
||
``TypeError: ... is not a 3d normal``. The 3D normal is a
|
||
unit quaternion that rotates the canonical Z-axis onto the
|
||
workplane's stored normal.
|
||
|
||
The default XY workplane (normal = +Z) maps to the identity
|
||
quaternion ``(1, 0, 0, 0)``. For arbitrary workplanes we derive
|
||
the shortest-arc quaternion that takes +Z onto the workplane
|
||
normal; this is the standard axis-angle → quaternion conversion
|
||
via the cross product as rotation axis.
|
||
"""
|
||
import math as _math
|
||
|
||
nx, ny, nz = self._wp_normal
|
||
|
||
# Identity rotation when the workplane normal is already +Z.
|
||
if abs(nx) < 1e-12 and abs(ny) < 1e-12 and abs(nz - 1.0) < 1e-12:
|
||
return self._solver.add_normal_3d(1.0, 0.0, 0.0, 0.0)
|
||
|
||
# Antiparallel case (workplane normal = -Z) — 180° about X axis.
|
||
if abs(nx) < 1e-12 and abs(ny) < 1e-12 and abs(nz + 1.0) < 1e-12:
|
||
return self._solver.add_normal_3d(0.0, 1.0, 0.0, 0.0)
|
||
|
||
# Axis = +Z × n = (-ny, nx, 0); angle = arccos(nz).
|
||
axis_len = _math.sqrt(nx * nx + ny * ny)
|
||
ax = -ny / axis_len
|
||
ay = nx / axis_len
|
||
az = 0.0
|
||
angle = _math.acos(max(-1.0, min(1.0, nz)))
|
||
half = angle * 0.5
|
||
s = _math.sin(half)
|
||
qw = _math.cos(half)
|
||
qx = ax * s
|
||
qy = ay * s
|
||
qz = az * s
|
||
return self._solver.add_normal_3d(qw, qx, qy, qz)
|
||
|
||
def add_arc(
|
||
self,
|
||
center: SketchEntity,
|
||
radius: float,
|
||
start_point: SketchEntity,
|
||
end_point: SketchEntity,
|
||
sweep: Optional[float] = None,
|
||
) -> OCCSketchEntity:
|
||
"""Add an arc (added to solver + tracked).
|
||
|
||
The arc is registered with SolveSpace so its three reference points
|
||
are linked: start, end, and centre. SolveSpace's arc entity
|
||
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
|
||
so the radius is **derived** from the current geometry rather than
|
||
stored as a fixed scalar.
|
||
|
||
Consequences:
|
||
* If the user constrains the start or end (e.g. coincident to a
|
||
rectangle corner) and then resizes the rectangle, the centre
|
||
slides on the perpendicular bisector of start↔end to keep the
|
||
arc consistent — the arc shape follows the rectangle, which is
|
||
the behaviour users expect from a fillet.
|
||
* If the centre is dragged instead, the radius adjusts so the
|
||
endpoints stay on the new circle.
|
||
* If the user wants the **diameter pinned** to a specific value
|
||
(e.g. a quarter-circle of exactly 10 mm), they can call
|
||
:meth:`constrain_arc_diameter` afterwards.
|
||
|
||
*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")
|
||
if center_entity.handle is None or start_entity.handle is None or end_entity.handle is None:
|
||
raise ValueError("Arc endpoints must already be in the solver")
|
||
|
||
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
|
||
|
||
# ── Add the arc to the SolveSpace solver ───────────────────────
|
||
# We need a 3D normal (quaternion) for the work plane. Cache one
|
||
# per (sketch, workplane) so we don't accumulate unused normals
|
||
# across many arc creations, and so the cache is invalidated
|
||
# whenever the workplane orientation changes. The normal is
|
||
# invalidated by ``clear`` / ``_rebuild_solver`` /
|
||
# ``set_workplane`` (the workplane reference changes).
|
||
if self._wp_normal_handle is None:
|
||
self._wp_normal_handle = self._make_arc_normal_3d()
|
||
nm: Any = self._wp_normal_handle
|
||
assert nm is not None # _make_arc_normal_3d always returns a handle
|
||
arc_handle = self._solver.add_arc(
|
||
nm,
|
||
center_entity.handle,
|
||
start_entity.handle,
|
||
end_entity.handle,
|
||
self._wp,
|
||
)
|
||
|
||
entity = OCCSketchEntity(
|
||
entity_id=entity_id,
|
||
entity_type="arc",
|
||
geometry={
|
||
"center": (cx, cy),
|
||
"radius": radius,
|
||
"start": (sx, sy),
|
||
"end": (ex, ey),
|
||
"sweep": sweep,
|
||
},
|
||
handle=arc_handle,
|
||
)
|
||
|
||
self._entities[entity_id] = entity
|
||
self._arcs[entity_id] = {
|
||
"center": center.id,
|
||
"start": start_point.id,
|
||
"end": end_point.id,
|
||
"radius": radius,
|
||
"sweep": sweep,
|
||
# ``original_sweep`` captures the angular span the user drew
|
||
# the arc with. When the host geometry (e.g. a rectangle
|
||
# the arc is attached to) resizes, ``_sync_solved_positions``
|
||
# uses this to re-derive the centre position so the arc
|
||
# scales with the rectangle while keeping the same shape.
|
||
# The user can override it later with
|
||
# :meth:`constrain_arc_diameter` if they want a fixed-size
|
||
# arc regardless of the host geometry.
|
||
"original_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
|
||
|
||
#: UV distance below which two projected points are considered the same
|
||
#: corner when importing / re-projecting external underlay geometry.
|
||
_EXTERNAL_MERGE_TOL: float = 1e-6
|
||
|
||
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 ``_EXTERNAL_MERGE_TOL`` 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). Merging also applies against
|
||
*previously imported* external points, so consecutive polylines that
|
||
share a corner (separate face edges meeting at a vertex) reuse one
|
||
point entity — the corner becomes a single connection hub for
|
||
coincident constraints instead of two stacked duplicates.
|
||
"""
|
||
points, lines = self.add_external_polylines([uv_points])
|
||
return (points[0] if points else []), lines
|
||
|
||
def add_external_polylines(
|
||
self, polylines: List[List[Tuple[float, float]]]
|
||
) -> Tuple[List[List[OCCSketchEntity]], List[OCCSketchEntity]]:
|
||
"""Bulk-import several polylines with corner dedup *across* polylines.
|
||
|
||
A face projection yields one polyline per boundary edge; edges that
|
||
meet at a vertex must share a single external point entity, otherwise
|
||
every corner ends up as two independent fixed points and user geometry
|
||
coincident to one duplicate is *not* connected to geometry coincident
|
||
to the other. Returns ``(points_per_polyline, all_lines)``.
|
||
"""
|
||
tol = self._EXTERNAL_MERGE_TOL
|
||
|
||
def find_or_create(u: float, v: float) -> OCCSketchEntity:
|
||
# Tolerance-based nearest lookup against existing external points
|
||
# (entity counts are small — a face boundary has tens of points).
|
||
best: Optional[OCCSketchEntity] = None
|
||
best_d = tol
|
||
for eid in self._external_entity_ids:
|
||
ent = self._entities.get(eid)
|
||
if ent is None or ent.entity_type != "point" or ent.geometry is None:
|
||
continue
|
||
d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
|
||
if d <= best_d:
|
||
best_d = d
|
||
best = ent
|
||
if best is None:
|
||
best = self.add_external_point(float(u), float(v))
|
||
return best
|
||
|
||
all_points: List[List[OCCSketchEntity]] = []
|
||
all_lines: List[OCCSketchEntity] = []
|
||
for uv_points in polylines:
|
||
if len(uv_points) < 2:
|
||
continue
|
||
points = [find_or_create(float(u), float(v)) for (u, v) in uv_points]
|
||
all_points.append(points)
|
||
for i in range(len(points) - 1):
|
||
if points[i] is points[i + 1]:
|
||
continue # degenerate zero-length segment after merging
|
||
# Skip duplicate segments (two edges projecting onto the
|
||
# same pair of corner points).
|
||
dupe = False
|
||
for lid, (sid, eid2) in self._lines.items():
|
||
if lid not in self._external_entity_ids:
|
||
continue
|
||
if (sid == points[i].id and eid2 == points[i + 1].id) or (
|
||
sid == points[i + 1].id and eid2 == points[i].id
|
||
):
|
||
dupe = True
|
||
break
|
||
if dupe:
|
||
continue
|
||
try:
|
||
ln = self.add_external_line(points[i], points[i + 1])
|
||
all_lines.append(ln)
|
||
except ValueError:
|
||
pass
|
||
return all_points, all_lines
|
||
|
||
def _drop_external_entities(self) -> set:
|
||
"""Remove external entities from local tracking + prune their constraints.
|
||
|
||
Does NOT rebuild the solver — the caller decides when to rebuild
|
||
(removal-only flows rebuild immediately; re-projection flows first
|
||
import the new externals so the rebuild sees them and doesn't
|
||
auto-anchor a user point instead).
|
||
"""
|
||
removed = set(self._external_entity_ids)
|
||
if not removed:
|
||
return removed
|
||
# Wipe external entities from local tracking.
|
||
for eid in list(removed):
|
||
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 removed or eid2 in removed:
|
||
del self._lines[lid]
|
||
if lid in self._entities:
|
||
del self._entities[lid]
|
||
self._external_entity_ids.clear()
|
||
self._prune_log_for(removed)
|
||
return removed
|
||
|
||
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
|
||
self._drop_external_entities()
|
||
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)
|
||
|
||
def update_external_entities(self, polylines: List[List[Tuple[float, float]]]) -> bool:
|
||
"""Re-project external (underlay) entities from updated source geometry.
|
||
|
||
Called when the 3D body the underlay was projected from has been
|
||
rebuilt (e.g. its source sketch was edited and re-extruded) and the
|
||
face edges were re-projected to UV. The underlay must follow the
|
||
body so user geometry constrained to it propagates through the
|
||
solver.
|
||
|
||
Two paths:
|
||
|
||
* **In-place update** (same topology): when the new projection has
|
||
the same number of unique corner points and segments, each existing
|
||
external point is paired with the nearest new position (greedy
|
||
one-to-one) and moved via ``set_params``. Entity ids and every
|
||
constraint referencing them survive untouched, and the next
|
||
:meth:`solve` pulls the user geometry along.
|
||
* **Rebuild + rebind** (topology changed): external entities are
|
||
removed and re-imported; constraints that referenced external
|
||
entities are re-created against the nearest new external entity
|
||
(point-to-point for coincident on corners, point-on-line for
|
||
coincident on edges) so user geometry stays anchored.
|
||
|
||
Returns True when the underlay was updated and solved OK.
|
||
"""
|
||
# Flatten the new projection into unique corner positions + segments.
|
||
tol = self._EXTERNAL_MERGE_TOL
|
||
new_pts: List[Tuple[float, float]] = []
|
||
|
||
def new_index(u: float, v: float) -> int:
|
||
for i, (x, y) in enumerate(new_pts):
|
||
if math.hypot(x - u, y - v) <= tol:
|
||
return i
|
||
new_pts.append((float(u), float(v)))
|
||
return len(new_pts) - 1
|
||
|
||
new_segs: List[Tuple[int, int]] = []
|
||
for poly in polylines:
|
||
if len(poly) < 2:
|
||
continue
|
||
idx = [new_index(float(u), float(v)) for (u, v) in poly]
|
||
for i in range(len(idx) - 1):
|
||
if idx[i] == idx[i + 1]:
|
||
continue
|
||
seg = (min(idx[i], idx[i + 1]), max(idx[i], idx[i + 1]))
|
||
if seg not in new_segs:
|
||
new_segs.append(seg)
|
||
|
||
old_ext_points = [
|
||
self._entities[eid]
|
||
for eid in sorted(self._external_entity_ids)
|
||
if eid in self._entities and self._entities[eid].entity_type == "point"
|
||
]
|
||
old_ext_lines = [
|
||
lid for lid in sorted(self._lines.keys()) if lid in self._external_entity_ids
|
||
]
|
||
|
||
same_topology = len(new_pts) == len(old_ext_points) and len(new_segs) == len(old_ext_lines)
|
||
|
||
if same_topology and old_ext_points:
|
||
# Greedy one-to-one nearest matching old point -> new position.
|
||
pairs: List[Tuple[float, int, int]] = [] # (dist, old_idx, new_idx)
|
||
for oi, ent in enumerate(old_ext_points):
|
||
ox, oy = ent.geometry
|
||
for ni, (nx, ny) in enumerate(new_pts):
|
||
pairs.append((math.hypot(ox - nx, oy - ny), oi, ni))
|
||
pairs.sort()
|
||
match: Dict[int, int] = {}
|
||
used_new: set = set()
|
||
for d, oi, ni in pairs:
|
||
if oi in match or ni in used_new:
|
||
continue
|
||
match[oi] = ni
|
||
used_new.add(ni)
|
||
if len(match) == len(old_ext_points):
|
||
# Verify segment connectivity is preserved under the matching
|
||
# (same corners, but edges rewired -> rebuild instead).
|
||
mapped_segs = set()
|
||
for a, b in new_segs:
|
||
mapped_segs.add((a, b))
|
||
connectivity_ok = True
|
||
for lid in old_ext_lines:
|
||
sid, eid2 = self._lines[lid]
|
||
oi_s = next((i for i, e in enumerate(old_ext_points) if e.id == sid), None)
|
||
oi_e = next((i for i, e in enumerate(old_ext_points) if e.id == eid2), None)
|
||
if oi_s is None or oi_e is None:
|
||
connectivity_ok = False
|
||
break
|
||
seg = (min(match[oi_s], match[oi_e]), max(match[oi_s], match[oi_e]))
|
||
if seg not in mapped_segs:
|
||
connectivity_ok = False
|
||
break
|
||
if connectivity_ok:
|
||
for oi, ent in enumerate(old_ext_points):
|
||
nx, ny = new_pts[match[oi]]
|
||
self.set_entity_position(ent, nx, ny)
|
||
return self.solve()
|
||
|
||
# ── Rebuild + rebind path (topology changed, or no externals yet) ──
|
||
# Capture constraints that tie USER entities to external entities so
|
||
# they can be re-created against the nearest new external entity.
|
||
saved: List[Dict[str, Any]] = []
|
||
for entry in self._constraint_log:
|
||
ext_ids = [i for i in entry["ids"] if i in self._external_entity_ids]
|
||
user_ids = [i for i in entry["ids"] if i not in self._external_entity_ids]
|
||
if not ext_ids or not user_ids:
|
||
continue
|
||
for ext_id in ext_ids:
|
||
ent = self._entities.get(ext_id)
|
||
if ent is None:
|
||
continue
|
||
if ent.entity_type == "point" and ent.geometry is not None:
|
||
anchor: Any = ("point", ext_id, tuple(ent.geometry))
|
||
elif ent.entity_type == "line" and ext_id in self._lines:
|
||
sid, eid2 = self._lines[ext_id]
|
||
s_ent = self._entities.get(sid)
|
||
e_ent = self._entities.get(eid2)
|
||
if s_ent is None or e_ent is None:
|
||
continue
|
||
mx = (s_ent.geometry[0] + e_ent.geometry[0]) / 2.0
|
||
my = (s_ent.geometry[1] + e_ent.geometry[1]) / 2.0
|
||
anchor = ("line", ext_id, (mx, my))
|
||
else:
|
||
continue
|
||
saved.append({"type": entry["type"], "user_ids": list(user_ids), "anchor": anchor})
|
||
|
||
# Drop old externals, import the new projection, then rebuild the
|
||
# solver exactly once. The import MUST happen before the rebuild:
|
||
# with external ids present the rebuild re-fixes the new underlay
|
||
# points and (per the add_point guard) does not auto-anchor a user
|
||
# point — which would conflict with the re-bound coincidents below.
|
||
self._drop_external_entities()
|
||
self.add_external_polylines(polylines)
|
||
self._rebuild_solver()
|
||
self._rebuild_labels()
|
||
|
||
# Rebind saved constraints to the nearest new external entity.
|
||
rebound = 0
|
||
for item in saved:
|
||
kind, _old_id, pos = item["anchor"]
|
||
target: Optional[OCCSketchEntity] = None
|
||
best_d = float("inf")
|
||
if kind == "point":
|
||
for eid in self._external_entity_ids:
|
||
ent = self._entities.get(eid)
|
||
if ent is None or ent.entity_type != "point" or ent.geometry is None:
|
||
continue
|
||
d = math.hypot(ent.geometry[0] - pos[0], ent.geometry[1] - pos[1])
|
||
if d < best_d:
|
||
best_d = d
|
||
target = ent
|
||
else: # line: nearest segment midpoint
|
||
for lid, (sid, eid2) in self._lines.items():
|
||
if lid not in self._external_entity_ids:
|
||
continue
|
||
s_ent = self._entities.get(sid)
|
||
e_ent = self._entities.get(eid2)
|
||
if s_ent is None or e_ent is None:
|
||
continue
|
||
mx = (s_ent.geometry[0] + e_ent.geometry[0]) / 2.0
|
||
my = (s_ent.geometry[1] + e_ent.geometry[1]) / 2.0
|
||
d = math.hypot(mx - pos[0], my - pos[1])
|
||
if d < best_d:
|
||
best_d = d
|
||
target = self._entities.get(lid)
|
||
if target is None:
|
||
continue
|
||
for uid in item["user_ids"]:
|
||
user_ent = self._entities.get(uid)
|
||
if user_ent is None:
|
||
continue
|
||
if item["type"] == "coincident":
|
||
if self.constrain_coincident(user_ent, target):
|
||
rebound += 1
|
||
if rebound:
|
||
logger.info("Rebound %d constraint(s) to re-projected underlay", rebound)
|
||
return self.solve()
|
||
|
||
# ── 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
|
||
ent0 = self._entities.get(ids[0])
|
||
ent1 = self._entities.get(ids[1])
|
||
# Normalise (line, point) -> (point, line) like constrain_distance
|
||
# does, and drop legacy line-line entries the solver can't hold.
|
||
if ent0 is not None and ent1 is not None:
|
||
if ent0.entity_type == "line" and ent1.entity_type == "line":
|
||
return False
|
||
if ent0.entity_type == "line" and ent1.entity_type == "point":
|
||
self._solver.distance(h(ids[1]), h(ids[0]), params[0], self._wp)
|
||
else:
|
||
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
|
||
elif ctype == "diameter":
|
||
# Update circle radius in sketch data. Legacy files (pre-ghost-
|
||
# circle-fix) sometimes recorded the diameter against the CENTER
|
||
# point id instead of the circle entity id; resolve such entries
|
||
# to the real circle via the _circles center mapping. Never
|
||
# touch the geometry of a non-circle entity — doing so used to
|
||
# turn a point into circle-shaped geometry and crashed the UI's
|
||
# ``round()`` calls.
|
||
circle_id = ids[0]
|
||
radius = params[0] / 2.0
|
||
resolved: Optional[int] = circle_id
|
||
if resolved not in self._circles:
|
||
for cid, (center_id, _r) in self._circles.items():
|
||
if center_id == circle_id:
|
||
resolved = cid
|
||
break
|
||
else:
|
||
resolved = None
|
||
if resolved is not None:
|
||
if resolved in self._circles:
|
||
center_id, _ = self._circles[resolved]
|
||
self._circles[resolved] = (center_id, radius)
|
||
ent = self._entities.get(resolved)
|
||
if ent is not None and ent.entity_type == "circle" and ent.geometry is not None:
|
||
cx, cy = (
|
||
ent.geometry[0]
|
||
if isinstance(ent.geometry[0], (tuple, list))
|
||
else ent.geometry
|
||
)
|
||
ent.geometry = ((cx, cy), radius)
|
||
elif ctype == "arc_diameter":
|
||
# Re-apply the solver-side diameter constraint and refresh the
|
||
# stored radius so the renderer matches. Marks the arc as
|
||
# diameter-pinned so subsequent solves don't overwrite the
|
||
# radius from the implicit geometry.
|
||
arc_id = ids[0]
|
||
ent = self._entities.get(arc_id)
|
||
if ent is None or ent.handle is None or arc_id not in self._arcs:
|
||
return False
|
||
try:
|
||
diameter_value = float(params[0])
|
||
except (TypeError, ValueError) as e:
|
||
logger.debug("arc_diameter log had non-numeric param: %s", e)
|
||
return False
|
||
try:
|
||
self._solver.diameter(ent.handle, diameter_value)
|
||
except Exception as e:
|
||
logger.debug("Re-applying arc_diameter failed: %s", e)
|
||
return False
|
||
radius = diameter_value / 2.0
|
||
self._arcs[arc_id]["radius"] = radius
|
||
if isinstance(ent.geometry, dict):
|
||
ent.geometry["radius"] = radius
|
||
self._arc_diameter_fixed.add(arc_id)
|
||
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, re-add every surviving
|
||
arc, 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
|
||
# New solver = new work plane = new normal entity. Drop the cache
|
||
# so ``add_arc`` recreates it on demand.
|
||
self._wp_normal_handle = 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 pid in self._external_entity_ids:
|
||
# External (underlay) points are ALWAYS fixed — the dragged
|
||
# applied at creation isn't in the constraint log, so it must
|
||
# be re-applied here or the underlay becomes draggable after
|
||
# any solver rebuild (e.g. deleting an unrelated user point).
|
||
self._solver.dragged(new_handle, self._wp)
|
||
elif self._first_point_id is None and not self._external_entity_ids:
|
||
# Mirror add_point's guard: when the sketch carries external
|
||
# underlay points those are the natural anchors, and fixing a
|
||
# user point too would over-constrain the system.
|
||
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-add arc entities in id order. Without this, every arc loses
|
||
# its solver-side constraint that ties start/end/centre together,
|
||
# and the renderer would happily draw the old radius over the new
|
||
# geometry — the exact "arc doesn't follow the rectangle" bug
|
||
# that motivated the add-arc-to-solver change.
|
||
if self._arcs:
|
||
if self._wp_normal_handle is None:
|
||
# Use the 3D-normal helper, not add_normal_2d — the latter
|
||
# produces a 2D entity that add_arc rejects with
|
||
# ``TypeError: ... is not a 3d normal``.
|
||
self._wp_normal_handle = self._make_arc_normal_3d()
|
||
nm: Any = self._wp_normal_handle
|
||
assert nm is not None
|
||
for aid in sorted(self._arcs.keys()):
|
||
arc_data = self._arcs[aid]
|
||
c_id = arc_data.get("center")
|
||
s_id = arc_data.get("start")
|
||
e_id = arc_data.get("end")
|
||
c_ent = self._entities.get(c_id) if c_id is not None else None
|
||
s_ent = self._entities.get(s_id) if s_id is not None else None
|
||
e_ent = self._entities.get(e_id) if e_id is not None else None
|
||
if (
|
||
c_ent is None
|
||
or s_ent is None
|
||
or e_ent is None
|
||
or c_ent.handle is None
|
||
or s_ent.handle is None
|
||
or e_ent.handle is None
|
||
):
|
||
continue
|
||
new_handle = self._solver.add_arc(
|
||
nm, c_ent.handle, s_ent.handle, e_ent.handle, self._wp
|
||
)
|
||
arc_ent = self._entities.get(aid)
|
||
if arc_ent is not None:
|
||
arc_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 _point_line_signed_offset(
|
||
self, point_ent: OCCSketchEntity, line_ent: OCCSketchEntity
|
||
) -> float:
|
||
"""Signed perpendicular offset of *point_ent* from *line_ent*, in the
|
||
solver's sign convention — i.e. the ``valA`` that pins the point on
|
||
its current side of the line.
|
||
|
||
Mirrors SolveSpace's ``PT_LINE_DISTANCE`` equation exactly
|
||
(a = line start, b = line end, d = a - b):
|
||
|
||
proj = dv·(ua − u) − du·(va − v), m = |d|
|
||
offset = proj / m
|
||
|
||
Returns 0.0 when the line is degenerate or the point lies on it
|
||
(the side is then undefined — callers fall back to the unsigned
|
||
value).
|
||
"""
|
||
if line_ent.id not in self._lines:
|
||
return 0.0
|
||
sid, eid2 = self._lines[line_ent.id]
|
||
s_ent = self._entities.get(sid)
|
||
e_ent = self._entities.get(eid2)
|
||
if s_ent is None or e_ent is None or not s_ent.geometry or not e_ent.geometry:
|
||
return 0.0
|
||
ua, va = s_ent.geometry
|
||
ub, vb = e_ent.geometry
|
||
u, v = point_ent.geometry
|
||
du = ua - ub
|
||
dv = va - vb
|
||
m = math.hypot(du, dv)
|
||
if m < 1e-12:
|
||
return 0.0
|
||
return (dv * (ua - u) - du * (va - v)) / m
|
||
|
||
def constrain_distance(
|
||
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
|
||
) -> bool:
|
||
"""Constrain distance between two entities.
|
||
|
||
python-solvespace's ``distance`` accepts point-point and point-line
|
||
(in that order) only, so line-point pairs are normalised and line-line
|
||
pairs are rejected with a warning instead of raising TypeError.
|
||
|
||
A point constrained to itself with a non-zero value is always
|
||
inconsistent — reject it up front so the UI never creates a constraint
|
||
the solver cannot satisfy.
|
||
"""
|
||
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
|
||
if e1 is e2 and e1.entity_type == "point" and distance != 0.0:
|
||
logger.warning("distance: refusing point-to-itself constraint")
|
||
return False
|
||
|
||
t1, t2 = e1.entity_type, e2.entity_type
|
||
if t1 == "line" and t2 == "line":
|
||
logger.warning(
|
||
"distance: line-to-line distance is not supported by the solver "
|
||
"(select a point and a line instead)"
|
||
)
|
||
return False
|
||
|
||
# python-solvespace only accepts (point, line) ordering.
|
||
if t1 == "line" and t2 == "point":
|
||
e1, e2 = e2, e1
|
||
entity1, entity2 = entity2, entity1
|
||
|
||
# Point-to-line distance is SIGNED in SolveSpace: the constraint
|
||
# equation is (signed perpendicular offset) = valA, so a positive
|
||
# valA always drives the point onto one fixed side of the line
|
||
# (for a vertical line, always +x). Pass a value whose sign
|
||
# matches the side the point is currently on so the constraint
|
||
# pins it there instead of flipping it across the line. The sign
|
||
# is recorded with the value so a solver rebuild reproduces the
|
||
# same side.
|
||
solver_value = distance
|
||
if e2.entity_type == "line" and e1.entity_type == "point" and distance != 0.0:
|
||
signed = self._point_line_signed_offset(e1, e2)
|
||
if abs(signed) > 1e-9:
|
||
solver_value = math.copysign(distance, signed)
|
||
|
||
self._solver.distance(e1.handle, e2.handle, solver_value, self._wp)
|
||
# Record in the normalised (point-first) order so replayed / legacy
|
||
# constraint logs are consistent.
|
||
self._record_constraint("distance", (entity1.id, entity2.id), (solver_value,))
|
||
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_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||
"""Set the diameter of a circle."""
|
||
radius = diameter / 2.0
|
||
# Update the circle's radius in the sketch
|
||
if circle.id in self._circles:
|
||
center_id, _ = self._circles[circle.id]
|
||
self._circles[circle.id] = (center_id, radius)
|
||
# Update the entity geometry. Circle geometry is
|
||
# ``((cx, cy), old_radius)`` — keep the center, swap the radius.
|
||
ent = self._entities.get(circle.id)
|
||
if ent is not None and ent.geometry is not None:
|
||
if isinstance(ent.geometry[0], (tuple, list)):
|
||
(cx, cy), _old_radius = ent.geometry
|
||
else:
|
||
cx, cy = ent.geometry
|
||
ent.geometry = ((cx, cy), radius)
|
||
self._record_constraint("diameter", (circle.id,), (diameter,))
|
||
return True
|
||
|
||
def constrain_arc_diameter(self, arc: SketchEntity, diameter: float) -> bool:
|
||
"""Pin the diameter of an arc to a specific value.
|
||
|
||
Without this constraint an arc is implicit-radius: its diameter
|
||
is whatever the geometry needs it to be to keep
|
||
``distance(start, centre) = distance(end, centre)`` — perfect for
|
||
fillets that grow with the rectangle they're attached to. When
|
||
the user wants a quarter-circle of *exactly* N mm they pin it
|
||
with this method; afterwards the solver enforces the diameter
|
||
and our ``_arc_diameter_fixed`` set tells ``_sync_solved_positions``
|
||
to stop overwriting the stored radius.
|
||
"""
|
||
ent = self._entities.get(arc.id)
|
||
if ent is None or ent.handle is None or arc.id not in self._arcs:
|
||
return False
|
||
try:
|
||
d = float(diameter)
|
||
except (TypeError, ValueError) as e:
|
||
logger.error("Arc diameter must be numeric: %s", e)
|
||
return False
|
||
try:
|
||
self._solver.diameter(ent.handle, d)
|
||
except Exception as e:
|
||
logger.error("Arc diameter constraint failed: %s", e)
|
||
return False
|
||
radius = d / 2.0
|
||
self._arcs[arc.id]["radius"] = radius
|
||
if isinstance(ent.geometry, dict):
|
||
ent.geometry["radius"] = radius
|
||
self._arc_diameter_fixed.add(arc.id)
|
||
try:
|
||
self._record_constraint("arc_diameter", (arc.id,), (d,))
|
||
except Exception as e:
|
||
logger.error("Recording arc diameter constraint failed: %s", e)
|
||
return False
|
||
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 is_entity_dragged(self, entity_id: int) -> bool:
|
||
"""True if the entity already has a ``dragged`` (fixed) constraint.
|
||
|
||
Used by the UI to avoid stacking duplicate ``dragged`` constraints
|
||
on the same point every time the user moves it — SolveSpace can
|
||
take several dragged constraints on the same point, but each one
|
||
bloats the constraint log without changing the locked position.
|
||
The user can still move the point later: a fresh
|
||
``set_entity_position`` updates the params and the existing
|
||
``dragged`` keeps the point at the new location on the next solve.
|
||
"""
|
||
for entry in self._constraint_log:
|
||
if entry["type"] == "fixed" and entity_id in entry["ids"]:
|
||
return True
|
||
return False
|
||
|
||
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.
|
||
|
||
Returns True on success, False if the solver returns a non-OKAY
|
||
result (INCONSISTENT, DIDNT_CONVERGE, TOO_MANY_UNKNOWNS). When
|
||
False, :attr:`last_solve_status` is set to a human-readable string
|
||
describing the failure so the UI can surface it to the user.
|
||
|
||
Callers that need to know *which* failure happened should use
|
||
:meth:`last_solve_result` (returns the raw :class:`ResultFlag`).
|
||
"""
|
||
try:
|
||
result = self._solver.solve()
|
||
self._last_solve_result = int(result)
|
||
if result == ResultFlag.OKAY:
|
||
# Sync solved positions back to entity geometries
|
||
self._sync_solved_positions()
|
||
self._last_solve_status = "ok"
|
||
return True
|
||
# Map SolveSpace's result enum to a one-line user-facing
|
||
# hint. INCONSISTENT is the most common and the most useful
|
||
# to call out: a new constraint conflicts with existing
|
||
# ones, so the geometry can't satisfy all of them.
|
||
status_map = {
|
||
int(ResultFlag.INCONSISTENT): (
|
||
"inconsistent: the new constraint conflicts with existing constraints"
|
||
),
|
||
int(ResultFlag.DIDNT_CONVERGE): (
|
||
"didn't converge: try simplifying the constraints or removing one"
|
||
),
|
||
int(ResultFlag.TOO_MANY_UNKNOWNS): (
|
||
"too many unknowns: the sketch is under-constrained"
|
||
),
|
||
}
|
||
self._last_solve_status = status_map.get(
|
||
int(result), f"failed (result code {int(result)})"
|
||
)
|
||
logger.warning(f"Solver returned: {result} — {self._last_solve_status}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"Solver error: {e}")
|
||
self._last_solve_status = f"error: {e}"
|
||
self._last_solve_result = -1
|
||
return False
|
||
|
||
def last_solve_result(self) -> int:
|
||
"""Raw SolveSpace result code from the most recent :meth:`solve` call.
|
||
|
||
``0`` = OKAY, ``1`` = INCONSISTENT, ``2`` = DIDNT_CONVERGE,
|
||
``3`` = TOO_MANY_UNKNOWNS, ``-1`` if solve raised an exception.
|
||
Use :attr:`last_solve_status` for a human-readable version.
|
||
"""
|
||
return getattr(self, "_last_solve_result", 0)
|
||
|
||
@property
|
||
def last_solve_status(self) -> str:
|
||
"""One-line human-readable description of the most recent solve outcome.
|
||
|
||
``"ok"`` on success, or a short explanation of the failure
|
||
(e.g. ``"inconsistent: the new constraint conflicts with existing
|
||
constraints"``). Useful for status-bar messages when the solver
|
||
can't satisfy the current set of constraints.
|
||
"""
|
||
return getattr(self, "_last_solve_status", "ok")
|
||
|
||
def _sync_solved_positions(self) -> None:
|
||
"""Read solved point positions from solver and update entity geometries.
|
||
|
||
After syncing points and lines, also refreshes every arc's stored
|
||
radius from the current centre→start distance. This is what
|
||
makes a coincident-constrained arc follow the rectangle it's
|
||
attached to: as the start/end points move with the rectangle's
|
||
corners, the solver shifts the centre onto the perpendicular
|
||
bisector and the radius becomes the new centre-to-endpoint
|
||
distance. Without this refresh the renderer would still draw
|
||
the arc with the original radius and the visual would desync
|
||
from the constraints.
|
||
|
||
Arcs whose diameter has been explicitly pinned via
|
||
:meth:`constrain_arc_diameter` are skipped — the user wants
|
||
the diameter fixed and we must not overwrite it.
|
||
"""
|
||
import math as _math
|
||
|
||
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)
|
||
|
||
elif entity.entity_type == "arc" and entity_id in self._arcs:
|
||
if entity_id in self._arc_diameter_fixed:
|
||
# User has pinned the diameter; don't touch it.
|
||
continue
|
||
arc_data = self._arcs[entity_id]
|
||
center_id = arc_data.get("center")
|
||
start_id = arc_data.get("start")
|
||
end_id = arc_data.get("end")
|
||
center_ent = self._entities.get(center_id) if center_id is not None else None
|
||
start_ent = self._entities.get(start_id) if start_id is not None else None
|
||
end_ent = self._entities.get(end_id) if end_id is not None else None
|
||
if (
|
||
center_ent is not None
|
||
and start_ent is not None
|
||
and end_ent is not None
|
||
and center_ent.geometry is not None
|
||
and start_ent.geometry is not None
|
||
and end_ent.geometry is not None
|
||
):
|
||
cx, cy = center_ent.geometry
|
||
sx, sy = start_ent.geometry
|
||
ex, ey = end_ent.geometry
|
||
# ── Side lock: keep the arc on the side the user drew it on.
|
||
# The L2-norm minimisation SolveSpace uses to pick
|
||
# the new centre position can land on the *opposite*
|
||
# side of the chord from where the user originally
|
||
# drew the arc — for example, when a corner is
|
||
# dragged upward past the original centre, the chord
|
||
# ends up above the centre and the arc now bulges
|
||
# INTO the rectangle. The sign of the stored
|
||
# ``sweep`` encodes which side the centre is on
|
||
# (positive = CCW from start→end, negative = CW),
|
||
# so we use that to detect a side flip and mirror
|
||
# the centre across the chord midpoint to put it
|
||
# back on the correct side.
|
||
sa = _math.atan2(sy - cy, sx - cx)
|
||
ea = _math.atan2(ey - cy, ex - cx)
|
||
new_sweep = ea - sa
|
||
while new_sweep > _math.pi:
|
||
new_sweep -= 2 * _math.pi
|
||
while new_sweep < -_math.pi:
|
||
new_sweep += 2 * _math.pi
|
||
prev_sweep = arc_data.get("sweep")
|
||
if (
|
||
prev_sweep is not None
|
||
and prev_sweep != 0.0
|
||
and new_sweep != 0.0
|
||
and (prev_sweep * new_sweep) < 0.0
|
||
):
|
||
# Sign flipped — mirror the centre across the
|
||
# chord so the arc stays on the original side.
|
||
mid_x = (sx + ex) * 0.5
|
||
mid_y = (sy + ey) * 0.5
|
||
cx = 2.0 * mid_x - cx
|
||
cy = 2.0 * mid_y - cy
|
||
center_ent.geometry = (cx, cy)
|
||
# Recompute sweep with the mirrored centre.
|
||
sa = _math.atan2(sy - cy, sx - cx)
|
||
ea = _math.atan2(ey - cy, ex - cx)
|
||
new_sweep = ea - sa
|
||
while new_sweep > _math.pi:
|
||
new_sweep -= 2 * _math.pi
|
||
while new_sweep < -_math.pi:
|
||
new_sweep += 2 * _math.pi
|
||
new_radius = _math.dist((cx, cy), (sx, sy))
|
||
arc_data["radius"] = new_radius
|
||
arc_data["sweep"] = new_sweep
|
||
if isinstance(entity.geometry, dict):
|
||
entity.geometry["radius"] = new_radius
|
||
entity.geometry["center"] = (cx, cy)
|
||
entity.geometry["start"] = (sx, sy)
|
||
entity.geometry["end"] = (ex, ey)
|
||
entity.geometry["sweep"] = new_sweep
|
||
|
||
# ── Auto-scale: preserve the original sweep ──
|
||
# The user-drawn sweep (captured in
|
||
# ``original_sweep`` at add_arc time) describes the
|
||
# arc's *shape* — its angular span and which side
|
||
# of the chord the centre is on. When the host
|
||
# geometry (e.g. a rectangle the arc is attached
|
||
# to) resizes, the L2-minimising solver leaves the
|
||
# centre close to its previous position, which
|
||
# gives the *wrong* shape (the sweep drifts). If
|
||
# the centre is free, we override it with the
|
||
# position that exactly preserves the original
|
||
# sweep on the new chord — geometrically, this is
|
||
# the only well-defined choice for an arc whose
|
||
# start and end are constrained but whose radius
|
||
# should scale with the host.
|
||
original_sweep = arc_data.get("original_sweep")
|
||
if (
|
||
original_sweep is not None
|
||
and abs(original_sweep) > 1e-9
|
||
and not self._is_centre_constrained(center_id)
|
||
):
|
||
new_cx, new_cy = self._centre_for_sweep((sx, sy), (ex, ey), original_sweep)
|
||
if new_cx is not None:
|
||
# Push the new centre into the solver
|
||
# AND the entity geometry. The solver
|
||
# accepts set_params even after solve()
|
||
# because the centre is a free point and
|
||
# the arc constraint
|
||
# (|s-c| = |e-c|) is satisfied
|
||
# automatically when the centre sits on
|
||
# the perpendicular bisector.
|
||
assert new_cy is not None # both-or-neither from _centre_for_sweep
|
||
try:
|
||
self._solver.set_params(
|
||
center_ent.handle.params,
|
||
(new_cx, new_cy),
|
||
)
|
||
except Exception as e:
|
||
logger.debug("set_params for arc centre failed: %s", e)
|
||
center_ent.geometry = (new_cx, new_cy)
|
||
cx, cy = new_cx, new_cy
|
||
new_radius = _math.dist((cx, cy), (sx, sy))
|
||
arc_data["radius"] = new_radius
|
||
# Recompute the sweep from the new
|
||
# geometry — should equal original_sweep
|
||
# up to floating point.
|
||
sa = _math.atan2(sy - cy, sx - cx)
|
||
ea = _math.atan2(ey - cy, ex - cx)
|
||
new_sweep = ea - sa
|
||
while new_sweep > _math.pi:
|
||
new_sweep -= 2 * _math.pi
|
||
while new_sweep < -_math.pi:
|
||
new_sweep += 2 * _math.pi
|
||
arc_data["sweep"] = new_sweep
|
||
if isinstance(entity.geometry, dict):
|
||
entity.geometry["radius"] = new_radius
|
||
entity.geometry["center"] = (cx, cy)
|
||
entity.geometry["sweep"] = new_sweep
|
||
|
||
def _is_centre_constrained(self, centre_id: Optional[int]) -> bool:
|
||
"""True if *centre_id* is referenced by any constraint in the log.
|
||
|
||
Used by the arc auto-scale path in :meth:`_sync_solved_positions`
|
||
to avoid moving a centre that's locked by a coincident, fixed,
|
||
distance, or symmetric constraint — in those cases the user
|
||
has expressed an intent about where the centre should be, and
|
||
overriding it would silently break the constraint.
|
||
"""
|
||
if centre_id is None:
|
||
return True # Conservative: don't move a centre we can't identify.
|
||
for entry in self._constraint_log:
|
||
if centre_id in entry.get("ids", ()):
|
||
return True
|
||
return False
|
||
|
||
def _centre_for_sweep(
|
||
self,
|
||
start: Tuple[float, float],
|
||
end: Tuple[float, float],
|
||
sweep: float,
|
||
) -> Tuple[Optional[float], Optional[float]]:
|
||
"""Return the centre position that gives an arc a specific sweep on a chord.
|
||
|
||
Given two endpoints *start* and *end* and a signed sweep
|
||
*sweep* (positive = CCW from start→end, negative = CW), the
|
||
unique centre on the perpendicular bisector at the right
|
||
distance is::
|
||
|
||
d = |start − end| / (2 * tan(|sweep| / 2))
|
||
C = midpoint ± d * normal
|
||
|
||
where *normal* is the unit vector 90° CCW from the chord
|
||
direction and the sign of *sweep* picks which side the centre
|
||
is on. Returns ``(None, None)`` for degenerate inputs (zero
|
||
chord, sweep ≥ π so d ≤ 0).
|
||
"""
|
||
import math as _math
|
||
|
||
sx, sy = start
|
||
ex, ey = end
|
||
dx = ex - sx
|
||
dy = ey - sy
|
||
chord_len = _math.hypot(dx, dy)
|
||
if chord_len < 1e-12:
|
||
return None, None
|
||
|
||
half_sweep = abs(sweep) * 0.5
|
||
# tan(π/2) is infinite — the arc is a semicircle and the centre
|
||
# sits on the chord. Skip rather than divide by zero.
|
||
if half_sweep >= _math.pi * 0.5 - 1e-9:
|
||
return None, None
|
||
|
||
d = chord_len / (2.0 * _math.tan(half_sweep))
|
||
# Left normal: rotate the chord direction 90° CCW. For a chord
|
||
# direction (dx, dy) the CCW perpendicular is (-dy, dx). With
|
||
# this convention, a POSITIVE sweep (CCW from start→end) places
|
||
# the centre on the +n side — i.e. the bulge is on the "left"
|
||
# of the chord, matching what the user sees when they draw the
|
||
# arc. Get the sign wrong and the centre lands on the wrong side
|
||
# and the stored ``original_sweep`` flips sign on the next solve.
|
||
nx = -dy / chord_len
|
||
ny = dx / chord_len
|
||
side = 1.0 if sweep > 0 else -1.0
|
||
mid_x = (sx + ex) * 0.5
|
||
mid_y = (sy + ey) * 0.5
|
||
return mid_x + d * side * nx, mid_y + d * side * ny
|
||
|
||
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_line_axis(self, line_id: int) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
|
||
"""Return ``((origin_x, origin_y, origin_z), (dir_x, dir_y, dir_z))`` for a line.
|
||
|
||
The axis is computed from the line's solved endpoints mapped into world
|
||
coordinates on the sketch workplane: origin = line start, direction =
|
||
normalized start→end. Returns ``None`` if the id is missing, not a
|
||
line, or degenerate. Used by the revolve tool and feature replay so
|
||
the revolve axis tracks the sketch line even after it is dragged.
|
||
"""
|
||
import math
|
||
|
||
ent = self._entities.get(line_id)
|
||
if ent is None or ent.entity_type != "line" or ent.is_external:
|
||
return None
|
||
geom = ent.geometry
|
||
if not geom or len(geom) != 2 or not geom[0] or not geom[1]:
|
||
return None
|
||
try:
|
||
start = self._uv_to_world(*geom[0])
|
||
end = self._uv_to_world(*geom[1])
|
||
except Exception:
|
||
return None
|
||
dx = end.X() - start.X()
|
||
dy = end.Y() - start.Y()
|
||
dz = end.Z() - start.Z()
|
||
length = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||
if length < 1e-9:
|
||
return None
|
||
return (
|
||
(float(start.X()), float(start.Y()), float(start.Z())),
|
||
(dx / length, dy / length, dz / length),
|
||
)
|
||
|
||
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-2 # world-unit tolerance for snapping line endpoints in loop detection
|
||
|
||
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_Circ
|
||
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
|
||
# New solver = new work plane; cached normal_2d is now stale.
|
||
self._wp_normal_handle = None
|
||
self._arc_diameter_fixed.clear()
|
||
|
||
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]
|
||
# If a diameter-pinned arc is being torn down, clear its flag
|
||
# so the set doesn't grow stale.
|
||
self._arc_diameter_fixed.discard(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]
|
||
# Serialize line / circle / arc geometry DERIVED from the point
|
||
# entities they reference, not from ``ent.geometry``. A drag
|
||
# (set_entity_position) updates the point but not the line/circle
|
||
# stored geometry, so the raw attribute can be stale — writing it
|
||
# produces files whose lines/circles no longer match their
|
||
# endpoints, and the next load drops those entities.
|
||
geometry_payload = ent.geometry
|
||
if ent.entity_type == "line":
|
||
line_ref = self._lines.get(eid)
|
||
if line_ref is not None:
|
||
s_ent = self._entities.get(line_ref[0])
|
||
e_ent = self._entities.get(line_ref[1])
|
||
if (
|
||
s_ent is not None
|
||
and e_ent is not None
|
||
and s_ent.geometry is not None
|
||
and e_ent.geometry is not None
|
||
):
|
||
geometry_payload = (tuple(s_ent.geometry), tuple(e_ent.geometry))
|
||
elif ent.entity_type == "circle":
|
||
circle_ref = self._circles.get(eid)
|
||
if circle_ref is not None:
|
||
c_ent = self._entities.get(circle_ref[0])
|
||
if c_ent is not None and c_ent.geometry is not None:
|
||
try:
|
||
geometry_payload = (tuple(c_ent.geometry), float(circle_ref[1]))
|
||
except (TypeError, ValueError):
|
||
# Corrupt in-memory radius must not abort the save.
|
||
geometry_payload = ent.geometry
|
||
elif ent.entity_type == "arc" and isinstance(ent.geometry, dict):
|
||
arc_data = self._arcs.get(eid)
|
||
if arc_data is not None:
|
||
c_ent = self._entities.get(arc_data.get("center"))
|
||
s_ent = self._entities.get(arc_data.get("start"))
|
||
e_ent = self._entities.get(arc_data.get("end"))
|
||
if (
|
||
c_ent is not None
|
||
and s_ent is not None
|
||
and e_ent is not None
|
||
and c_ent.geometry is not None
|
||
and s_ent.geometry is not None
|
||
and e_ent.geometry is not None
|
||
):
|
||
try:
|
||
radius_val = float(
|
||
arc_data.get("radius", ent.geometry.get("radius", 0.0))
|
||
)
|
||
sweep_val = float(arc_data.get("sweep", ent.geometry.get("sweep", 0.0)))
|
||
except (TypeError, ValueError):
|
||
radius_val = ent.geometry.get("radius", 0.0)
|
||
sweep_val = ent.geometry.get("sweep", 0.0)
|
||
geometry_payload = {
|
||
"center": tuple(c_ent.geometry),
|
||
"start": tuple(s_ent.geometry),
|
||
"end": tuple(e_ent.geometry),
|
||
"radius": radius_val,
|
||
"sweep": sweep_val,
|
||
}
|
||
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": geometry_payload,
|
||
"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.
|
||
|
||
Numeric coercions (``int``/``float``) and entity-replay calls are
|
||
wrapped in try/except: a single corrupt entry in a project file
|
||
(hand-edited, partially-written, from a different app version) must
|
||
not abort the whole load. The bad entry is logged and skipped so
|
||
the surviving geometry can still be loaded and used.
|
||
"""
|
||
# 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.
|
||
try:
|
||
self._entity_counter = int(data.get("entity_counter", 0))
|
||
except (TypeError, ValueError) as e:
|
||
logger.warning("entity_counter invalid (%s); starting at 0", e)
|
||
self._entity_counter = 0
|
||
|
||
# 3. Replay entities. Points are loaded in a first pass (in file
|
||
# order) and lines / circles / arcs in a second pass, so an
|
||
# endpoint reference can resolve by position even when the
|
||
# referenced point has a HIGHER id than the line (re-saved
|
||
# recovery points, hand-edited files). 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] = {}
|
||
# Pre-compute the centerline id set (step 5 below restores it after
|
||
# the loops) and the highest saved id. A legacy file can save a
|
||
# centerline whose axis point has stale coordinates, leaving the
|
||
# line's other endpoint unmatched; in that case we reconstruct the
|
||
# missing point with an id ABOVE every saved id so it can't collide
|
||
# with the entities still to load.
|
||
try:
|
||
centerline_ids_set = {int(x) for x in data.get("centerline_ids", [])}
|
||
except (TypeError, ValueError):
|
||
centerline_ids_set = set()
|
||
max_saved_id = 0
|
||
try:
|
||
max_saved_id = max(int(e["id"]) for e in data.get("entities", []))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
def _replay_entry(entry: Dict[str, Any]) -> None:
|
||
"""Recreate one saved entity, preserving its id and flags."""
|
||
nonlocal entities_by_id
|
||
try:
|
||
eid = int(entry["id"])
|
||
except (TypeError, ValueError) as e:
|
||
logger.warning("Skipping entity with invalid id (%s): %r", e, entry)
|
||
return
|
||
# 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 geom is None:
|
||
logger.warning("Skipping entity %s during load: missing geometry", eid)
|
||
return
|
||
|
||
try:
|
||
if etype == "point":
|
||
try:
|
||
x, y = float(geom[0]), float(geom[1])
|
||
except (TypeError, ValueError, IndexError) as e:
|
||
logger.warning("Skipping point %s during load: bad geometry (%s)", eid, e)
|
||
return
|
||
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)); the endpoints are
|
||
# point entities already loaded in pass 1. Look them up
|
||
# by saved position via _points. Older files can carry
|
||
# line geometry that is stale relative to the endpoint
|
||
# points (a drag without a subsequent solve), so fall
|
||
# back to the nearest point within a small world
|
||
# tolerance before giving up.
|
||
try:
|
||
(x1, y1), (x2, y2) = geom
|
||
except (TypeError, ValueError) as e:
|
||
logger.warning("Skipping line %s during load: bad geometry (%s)", eid, e)
|
||
return
|
||
s_id = self._find_point_at(x1, y1) or self._find_point_near(x1, y1)
|
||
e_id = self._find_point_at(x2, y2) or self._find_point_near(x2, y2)
|
||
if (s_id is None or e_id is None) and eid in centerline_ids_set:
|
||
# Centerline endpoint missing — older files saved the
|
||
# axis point with stale coordinates. Reconstruct it
|
||
# from the line's own geometry so the reference axis
|
||
# survives; allocate above every saved id to avoid
|
||
# colliding with entities that load afterwards.
|
||
self._entity_counter = max(max_saved_id, self._entity_counter)
|
||
if s_id is None:
|
||
s_ent = self.add_point(x1, y1)
|
||
s_ent.is_construction = True
|
||
s_id = s_ent.id
|
||
entities_by_id[s_id] = s_ent
|
||
self._centerline_ids.add(s_id)
|
||
if e_id is None:
|
||
e_ent = self.add_point(x2, y2)
|
||
e_ent.is_construction = True
|
||
e_id = e_ent.id
|
||
entities_by_id[e_id] = e_ent
|
||
self._centerline_ids.add(e_id)
|
||
# Recovery points were allocated above every saved
|
||
# id; re-point the counter at the line's own id so
|
||
# the line below keeps its saved id (and the next
|
||
# file entity re-points the counter anyway).
|
||
self._entity_counter = eid - 1
|
||
if s_id is None or e_id is None:
|
||
logger.warning("Skipping line %s during load: endpoints not found", eid)
|
||
return
|
||
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":
|
||
try:
|
||
(cx, cy), radius = geom
|
||
radius = float(radius)
|
||
except (TypeError, ValueError) as e:
|
||
logger.warning("Skipping circle %s during load: bad geometry (%s)", eid, e)
|
||
return
|
||
c_id = self._find_point_at(cx, cy) or self._find_point_near(cx, cy)
|
||
if c_id is None:
|
||
logger.warning("Skipping circle %s during load: center not found", eid)
|
||
return
|
||
ent = self.add_circle(entities_by_id[c_id], radius)
|
||
elif etype == "arc":
|
||
try:
|
||
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))
|
||
except (TypeError, ValueError, KeyError) as e:
|
||
logger.warning("Skipping arc %s during load: bad geometry (%s)", eid, e)
|
||
return
|
||
c_id = self._find_point_at(*center_pos) or self._find_point_near(*center_pos)
|
||
s_id = self._find_point_at(*start_pos) or self._find_point_near(*start_pos)
|
||
e_id = self._find_point_at(*end_pos) or self._find_point_near(*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)
|
||
return
|
||
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)
|
||
return
|
||
except Exception as e:
|
||
# Last-ditch guard: a single bad entity must not abort the
|
||
# whole load. Log and move on so the rest of the sketch
|
||
# can still be reconstructed.
|
||
logger.warning("Skipping entity %s during load: %s", eid, e)
|
||
return
|
||
|
||
# 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
|
||
|
||
all_entries = data.get("entities", [])
|
||
# Pass 1: every point, so pass 2 can resolve endpoint references by
|
||
# position regardless of the id order in the file.
|
||
for entry in all_entries:
|
||
if entry.get("type") == "point":
|
||
_replay_entry(entry)
|
||
# Pass 2: lines, circles, arcs (id order preserved per entry).
|
||
for entry in all_entries:
|
||
if entry.get("type") != "point":
|
||
_replay_entry(entry)
|
||
|
||
# 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", []):
|
||
try:
|
||
self._external_entity_ids.add(int(eid))
|
||
except (TypeError, ValueError) as exc:
|
||
logger.warning("Skipping invalid external_entity_ids entry: %s", exc)
|
||
for eid in data.get("centerline_ids", []):
|
||
try:
|
||
self._centerline_ids.add(int(eid))
|
||
except (TypeError, ValueError) as exc:
|
||
logger.warning("Skipping invalid centerline_ids entry: %s", exc)
|
||
|
||
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
|
||
|
||
def _find_point_near(self, x: float, y: float, tol: float = _LOAD_POINT_TOL) -> Optional[int]:
|
||
"""Return the id of the point *closest* to ``(x, y)`` within ``tol``.
|
||
|
||
Fallback for ``_find_point_at`` when loading older project files
|
||
whose line/circle/arc geometry is stale relative to the point
|
||
entities (saved after a drag that was never re-solved). Points are
|
||
authoritative — the derived geometry just has to reach them.
|
||
"""
|
||
best_id: Optional[int] = None
|
||
best_d = tol
|
||
for pid, pos in self._points.items():
|
||
d = math.hypot(pos[0] - x, pos[1] - y)
|
||
if d < best_d:
|
||
best_d = d
|
||
best_id = pid
|
||
return best_id
|