1209 lines
48 KiB
Python
1209 lines
48 KiB
Python
"""Declarative constrained sketch objects for SimpleCADAPI.
|
|
|
|
Sketches are intent-level 2D documents. Use sketch APIs to build sketch
|
|
profiles; use concrete edge/wire APIs only for paths or pure geometry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
|
|
|
|
import numpy as np
|
|
|
|
from .core import Edge, Face, TaggedMixin, TopoMixein, Wire
|
|
from .expr import ScalarLike, evaluate_scalar
|
|
|
|
|
|
_POINT_EPS = 1e-9
|
|
|
|
|
|
def _fresh_id(prefix: str, existing: Mapping[str, Any]) -> str:
|
|
index = 0
|
|
while True:
|
|
candidate = f"{prefix}_{index}"
|
|
if candidate not in existing:
|
|
return candidate
|
|
index += 1
|
|
|
|
|
|
def _as_float(value: ScalarLike) -> float:
|
|
return float(evaluate_scalar(value))
|
|
|
|
|
|
def _angle_delta(value: float) -> float:
|
|
while value <= -math.pi:
|
|
value += 2.0 * math.pi
|
|
while value > math.pi:
|
|
value -= 2.0 * math.pi
|
|
return value
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SketchEntity:
|
|
"""Serializable entity inside a declarative sketch."""
|
|
|
|
entity_id: str
|
|
kind: str
|
|
data: Dict[str, Any] = field(default_factory=dict)
|
|
construction: bool = False
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
payload = {
|
|
"id": self.entity_id,
|
|
"kind": self.kind,
|
|
"construction": self.construction,
|
|
}
|
|
payload.update(self.data)
|
|
return payload
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SketchConstraint:
|
|
"""Serializable constraint inside a declarative sketch."""
|
|
|
|
constraint_id: str
|
|
kind: str
|
|
targets: Tuple[Dict[str, Any], ...]
|
|
value: Any = None
|
|
driving: bool = True
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
payload: Dict[str, Any] = {
|
|
"id": self.constraint_id,
|
|
"kind": self.kind,
|
|
"targets": [dict(target) for target in self.targets],
|
|
"driving": bool(self.driving),
|
|
}
|
|
if self.value is not None:
|
|
payload["value"] = self.value
|
|
if self.metadata:
|
|
payload["metadata"] = dict(self.metadata)
|
|
return payload
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SketchConstraintDiagnostic:
|
|
"""Diagnostic emitted by the sketch solver."""
|
|
|
|
constraint_id: Optional[str]
|
|
severity: str
|
|
code: str
|
|
message: str
|
|
residual: Optional[float] = None
|
|
|
|
|
|
class SketchRef(TaggedMixin):
|
|
"""Stable reference to a sketch entity or subentity."""
|
|
|
|
def __init__(
|
|
self,
|
|
sketch_id: str,
|
|
entity_id: str,
|
|
*,
|
|
kind: str,
|
|
subentity: str = "geometry",
|
|
) -> None:
|
|
super().__init__()
|
|
self.sketch_id = str(sketch_id)
|
|
self.entity_id = str(entity_id)
|
|
self.kind = str(kind)
|
|
self.subentity = str(subentity)
|
|
|
|
def to_dict(self) -> Dict[str, str]:
|
|
return {
|
|
"sketch_id": self.sketch_id,
|
|
"entity_id": self.entity_id,
|
|
"kind": self.kind,
|
|
"subentity": self.subentity,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Mapping[str, Any]) -> "SketchRef":
|
|
return cls(
|
|
str(data["sketch_id"]),
|
|
str(data["entity_id"]),
|
|
kind=str(data["kind"]),
|
|
subentity=str(data.get("subentity", "geometry")),
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
"SketchRef("
|
|
f"sketch_id={self.sketch_id!r}, entity_id={self.entity_id!r}, "
|
|
f"kind={self.kind!r}, subentity={self.subentity!r})"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SketchSolveResult(TaggedMixin):
|
|
"""Result of solving a declarative sketch."""
|
|
|
|
sketch_id: str
|
|
status: str
|
|
dof: int
|
|
residual_norm: float
|
|
iterations: int
|
|
solved_points: Dict[str, Tuple[float, float]]
|
|
solved_scalars: Dict[str, float]
|
|
diagnostics: Tuple[SketchConstraintDiagnostic, ...] = ()
|
|
|
|
def __post_init__(self) -> None:
|
|
TaggedMixin.__init__(self)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"sketch_id": self.sketch_id,
|
|
"status": self.status,
|
|
"dof": int(self.dof),
|
|
"residual_norm": float(self.residual_norm),
|
|
"iterations": int(self.iterations),
|
|
"solved_points": {
|
|
key: [float(value[0]), float(value[1])]
|
|
for key, value in self.solved_points.items()
|
|
},
|
|
"solved_scalars": dict(self.solved_scalars),
|
|
"diagnostics": [diag.__dict__.copy() for diag in self.diagnostics],
|
|
}
|
|
|
|
|
|
class Sketch(TaggedMixin, TopoMixein):
|
|
"""Declarative constrained sketch container.
|
|
|
|
Use `make_sketch_rsketch(...)`, `add_point_rsketch(...)`,
|
|
`add_line_rsketch(...)`, `add_circle_rsketch(...)`, and
|
|
`constrain_*_rsketch(...)` as the canonical API for building sketch
|
|
profiles. Public sketch construction APIs are functional and return an
|
|
updated `Sketch` document. The legacy `curves` constructor remains only for
|
|
reading already-built wire/edge containers.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
curves: Iterable[Edge | Wire] | None = None,
|
|
*,
|
|
name: Optional[str] = None,
|
|
plane: Any = "XY",
|
|
sketch_id: Optional[str] = None,
|
|
) -> None:
|
|
TaggedMixin.__init__(self)
|
|
TopoMixein.__init__(self, level=2, self_shape_ref=self)
|
|
self.sketch_id = str(sketch_id or f"sketch_{uuid.uuid4().hex[:8]}")
|
|
self.name = name
|
|
self.plane = plane
|
|
self.entities: Dict[str, SketchEntity] = {}
|
|
self.entity_order: List[str] = []
|
|
self.constraints: List[SketchConstraint] = []
|
|
self._last_solve_result: Optional[SketchSolveResult] = None
|
|
if curves is not None:
|
|
for curve in curves:
|
|
self.add_curve(curve)
|
|
|
|
def add_curve(self, curve: Edge | Wire) -> "Sketch":
|
|
if not isinstance(curve, (Edge, Wire)):
|
|
raise ValueError("Sketch only supports Edge or Wire curve inputs")
|
|
self.add_child(curve)
|
|
return self
|
|
|
|
def curves(self) -> List[Edge | Wire]:
|
|
return list(self.get_children())
|
|
|
|
def closed_wires(self) -> List[Wire]:
|
|
result: List[Wire] = []
|
|
for curve in self.curves():
|
|
if isinstance(curve, Wire) and curve.is_closed():
|
|
result.append(curve)
|
|
return result
|
|
|
|
def to_faces(self) -> List[Face]:
|
|
if self.entities:
|
|
return [self.to_face()]
|
|
from .operations import make_face_from_wire_rface
|
|
|
|
return [make_face_from_wire_rface(wire) for wire in self.closed_wires()]
|
|
|
|
def to_face(self, profile: int | str = 0) -> Face:
|
|
from .operations import make_face_from_sketch_rface
|
|
|
|
return make_face_from_sketch_rface(self, profile=profile)
|
|
|
|
def clone(self, *, include_solve: bool = True) -> "Sketch":
|
|
cloned = Sketch(name=self.name, plane=self.plane, sketch_id=self.sketch_id)
|
|
cloned._tags = self._tags.copy()
|
|
cloned._metadata = self._metadata.copy()
|
|
cloned.entities = dict(self.entities)
|
|
cloned.entity_order = list(self.entity_order)
|
|
cloned.constraints = list(self.constraints)
|
|
cloned._last_solve_result = self._last_solve_result if include_solve else None
|
|
for curve in self.curves():
|
|
cloned.add_curve(curve)
|
|
return cloned
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"sketch_id": self.sketch_id,
|
|
"name": self.name,
|
|
"plane": self.plane,
|
|
"entities": [self.entities[key].to_dict() for key in self.entity_order],
|
|
"constraints": [constraint.to_dict() for constraint in self.constraints],
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Mapping[str, Any]) -> "Sketch":
|
|
sketch = cls(
|
|
name=data.get("name"),
|
|
plane=data.get("plane", "XY"),
|
|
sketch_id=str(data.get("sketch_id") or data.get("name") or "sketch"),
|
|
)
|
|
for entity_data in data.get("entities", []):
|
|
entity_id = str(entity_data["id"])
|
|
kind = str(entity_data["kind"])
|
|
construction = bool(entity_data.get("construction", False))
|
|
payload = dict(entity_data)
|
|
payload.pop("id", None)
|
|
payload.pop("kind", None)
|
|
payload.pop("construction", None)
|
|
sketch._add_entity(
|
|
SketchEntity(entity_id, kind, payload, construction=construction)
|
|
)
|
|
for constraint_data in data.get("constraints", []):
|
|
sketch.constraints.append(
|
|
SketchConstraint(
|
|
constraint_id=str(constraint_data["id"]),
|
|
kind=str(constraint_data["kind"]),
|
|
targets=tuple(dict(target) for target in constraint_data.get("targets", [])),
|
|
value=constraint_data.get("value"),
|
|
driving=bool(constraint_data.get("driving", True)),
|
|
metadata=dict(constraint_data.get("metadata", {})),
|
|
)
|
|
)
|
|
return sketch
|
|
|
|
def ref(self, entity_id: str, *, kind: Optional[str] = None, subentity: str = "geometry") -> SketchRef:
|
|
if entity_id not in self.entities:
|
|
raise ValueError(f"Unknown sketch entity '{entity_id}'")
|
|
entity = self.entities[entity_id]
|
|
return SketchRef(self.sketch_id, entity_id, kind=kind or entity.kind, subentity=subentity)
|
|
|
|
def point_ref(self, path: str) -> SketchRef:
|
|
if "." in path:
|
|
entity_id, subentity = path.split(".", 1)
|
|
if entity_id not in self.entities:
|
|
raise ValueError(f"Unknown sketch entity '{entity_id}'")
|
|
entity = self.entities[entity_id]
|
|
valid_subentities = {
|
|
"line": {"start", "end"},
|
|
"circle": {"center"},
|
|
"arc": {"start", "end"},
|
|
"bspline": {"start", "end"},
|
|
}.get(entity.kind, set())
|
|
if subentity not in valid_subentities:
|
|
raise ValueError(
|
|
f"Sketch entity '{entity_id}' has no point subentity '{subentity}'"
|
|
)
|
|
return SketchRef(self.sketch_id, entity_id, kind="point", subentity=subentity)
|
|
if path not in self.entities:
|
|
raise ValueError(f"Unknown sketch point '{path}'")
|
|
entity = self.entities[path]
|
|
if entity.kind != "point":
|
|
raise ValueError(f"Sketch entity '{path}' is kind '{entity.kind}', not 'point'")
|
|
return SketchRef(self.sketch_id, path, kind="point")
|
|
|
|
def resolve_target(
|
|
self,
|
|
target: Union[SketchRef, str],
|
|
*,
|
|
expected: Optional[Union[str, Sequence[str]]] = None,
|
|
) -> SketchRef:
|
|
if isinstance(expected, str):
|
|
expected_kinds = {expected}
|
|
elif expected is None:
|
|
expected_kinds = set()
|
|
else:
|
|
expected_kinds = {str(item) for item in expected}
|
|
|
|
if isinstance(target, SketchRef):
|
|
ref = target
|
|
elif isinstance(target, str):
|
|
if "." in target or expected_kinds == {"point"}:
|
|
ref = self.point_ref(target)
|
|
else:
|
|
if target not in self.entities:
|
|
raise ValueError(f"Unknown sketch entity '{target}'")
|
|
entity = self.entities[target]
|
|
ref = self.point_ref(target) if entity.kind == "point" else self.ref(target)
|
|
else:
|
|
raise TypeError("Sketch targets must be SketchRef or string ids")
|
|
|
|
self.validate_ref(ref)
|
|
if expected_kinds and ref.kind not in expected_kinds:
|
|
expected_label = ", ".join(sorted(expected_kinds))
|
|
raise ValueError(
|
|
f"Sketch target '{ref.entity_id}' is kind '{ref.kind}', expected {expected_label}"
|
|
)
|
|
return ref
|
|
|
|
def add_point(self, point_id: str, x: ScalarLike, y: ScalarLike) -> SketchRef:
|
|
self._add_entity(SketchEntity(point_id, "point", {"x": x, "y": y}))
|
|
return self.point_ref(point_id)
|
|
|
|
def add_line(
|
|
self,
|
|
entity_id: str,
|
|
start: SketchRef,
|
|
end: SketchRef,
|
|
*,
|
|
construction: bool = False,
|
|
) -> "Sketch":
|
|
start_id = self.resolve_point_id(start)
|
|
end_id = self.resolve_point_id(end)
|
|
if start_id == end_id:
|
|
raise ValueError("A sketch line requires two distinct point refs")
|
|
self._add_entity(
|
|
SketchEntity(
|
|
entity_id,
|
|
"line",
|
|
{"start": start_id, "end": end_id},
|
|
construction=construction,
|
|
)
|
|
)
|
|
return self
|
|
|
|
def add_circle(
|
|
self,
|
|
entity_id: str,
|
|
center: SketchRef,
|
|
radius: ScalarLike,
|
|
*,
|
|
construction: bool = False,
|
|
) -> "Sketch":
|
|
center_id = self.resolve_point_id(center)
|
|
if _as_float(radius) <= 0.0:
|
|
raise ValueError("A sketch circle radius must be positive")
|
|
self._add_entity(
|
|
SketchEntity(
|
|
entity_id,
|
|
"circle",
|
|
{"center": center_id, "radius": radius},
|
|
construction=construction,
|
|
)
|
|
)
|
|
return self
|
|
|
|
def add_arc(
|
|
self,
|
|
entity_id: str,
|
|
start: SketchRef,
|
|
end: SketchRef,
|
|
center: SketchRef,
|
|
*,
|
|
construction: bool = False,
|
|
) -> "Sketch":
|
|
"""Add an arc edge defined by start point, end point, and center point."""
|
|
start_id = self.resolve_point_id(start)
|
|
end_id = self.resolve_point_id(end)
|
|
center_id = self.resolve_point_id(center)
|
|
if start_id == end_id:
|
|
raise ValueError("A sketch arc requires two distinct endpoint refs")
|
|
self._add_entity(
|
|
SketchEntity(
|
|
entity_id,
|
|
"arc",
|
|
{"start": start_id, "end": end_id, "center": center_id},
|
|
construction=construction,
|
|
)
|
|
)
|
|
return self
|
|
|
|
def add_bspline(
|
|
self,
|
|
entity_id: str,
|
|
start: SketchRef,
|
|
end: SketchRef,
|
|
control_points: Sequence[Sequence[float]],
|
|
degree: int = 3,
|
|
knots: Optional[Sequence[float]] = None,
|
|
multiplicities: Optional[Sequence[int]] = None,
|
|
weights: Optional[Sequence[float]] = None,
|
|
periodic: bool = False,
|
|
*,
|
|
construction: bool = False,
|
|
) -> "Sketch":
|
|
"""Add a B-spline curve edge defined by control points.
|
|
|
|
The start/end point refs link the B-spline into the profile loop.
|
|
Control points are stored as literal 2-D coordinates (not point
|
|
entity ids) so the solver does not modify them.
|
|
"""
|
|
start_id = self.resolve_point_id(start)
|
|
end_id = self.resolve_point_id(end)
|
|
if start_id == end_id:
|
|
raise ValueError("A sketch bspline requires two distinct endpoint refs")
|
|
if len(control_points) < degree + 1:
|
|
raise ValueError(f"bspline requires at least degree+1 control points, got {len(control_points)}")
|
|
literal_cps = [[float(p[0]), float(p[1])] for p in control_points]
|
|
self._add_entity(
|
|
SketchEntity(
|
|
entity_id,
|
|
"bspline",
|
|
{
|
|
"start": start_id,
|
|
"end": end_id,
|
|
"control_points": literal_cps,
|
|
"degree": int(degree),
|
|
"knots": list(knots) if knots is not None else None,
|
|
"multiplicities": list(multiplicities) if multiplicities is not None else None,
|
|
"weights": list(weights) if weights is not None else None,
|
|
"periodic": bool(periodic),
|
|
},
|
|
construction=construction,
|
|
)
|
|
)
|
|
return self
|
|
|
|
def add_constraint(
|
|
self,
|
|
kind: str,
|
|
targets: Sequence[SketchRef],
|
|
*,
|
|
value: Any = None,
|
|
constraint_id: Optional[str] = None,
|
|
driving: bool = True,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> "Sketch":
|
|
for target in targets:
|
|
self.validate_ref(target)
|
|
existing = {constraint.constraint_id: constraint for constraint in self.constraints}
|
|
cid = constraint_id or _fresh_id(f"c_{kind}", existing)
|
|
if cid in existing:
|
|
raise ValueError(f"Duplicate sketch constraint id '{cid}'")
|
|
self.constraints.append(
|
|
SketchConstraint(
|
|
constraint_id=cid,
|
|
kind=str(kind),
|
|
targets=tuple(target.to_dict() for target in targets),
|
|
value=value,
|
|
driving=driving,
|
|
metadata=dict(metadata or {}),
|
|
)
|
|
)
|
|
return self
|
|
|
|
def solve(
|
|
self,
|
|
*,
|
|
require_fully_constrained: bool = False,
|
|
strict: bool = True,
|
|
tolerance: float = 1e-7,
|
|
max_iterations: int = 80,
|
|
) -> SketchSolveResult:
|
|
result = _SketchSolver(self, tolerance=tolerance, max_iterations=max_iterations).solve()
|
|
self._last_solve_result = result
|
|
if strict and result.status in {"conflicting", "failed"}:
|
|
raise ValueError(
|
|
f"Sketch solve failed with status={result.status}, residual={result.residual_norm:.6g}"
|
|
)
|
|
if require_fully_constrained and result.dof > 0:
|
|
raise ValueError(f"Sketch is underconstrained with {result.dof} remaining DOF")
|
|
return result
|
|
|
|
def solved_result(self) -> SketchSolveResult:
|
|
if self._last_solve_result is None:
|
|
return self.solve(strict=True)
|
|
return self._last_solve_result
|
|
|
|
def make_wire(
|
|
self,
|
|
profile: int | str = 0,
|
|
*,
|
|
solve_result: Optional[SketchSolveResult] = None,
|
|
) -> Wire:
|
|
profile_payload = self._profile_payload(profile, solve_result=solve_result)
|
|
return self._wire_from_profile_payload(profile_payload)
|
|
|
|
def _wire_from_profile_payload(self, profile_payload: Mapping[str, Any]) -> Wire:
|
|
from .operations import make_circle_redge, make_line_redge, make_wire_from_edges_rwire
|
|
|
|
if profile_payload["kind"] == "circle":
|
|
center = profile_payload["center"]
|
|
edge = make_circle_redge(center, profile_payload["radius"], profile_payload["normal"])
|
|
return make_wire_from_edges_rwire([edge])
|
|
if profile_payload["kind"] == "line_loop":
|
|
points = profile_payload["points"]
|
|
edges = [
|
|
make_line_redge(points[index], points[(index + 1) % len(points)])
|
|
for index in range(len(points))
|
|
]
|
|
return make_wire_from_edges_rwire(edges)
|
|
if profile_payload["kind"] == "edge_loop":
|
|
return self._wire_from_edge_loop(profile_payload)
|
|
raise ValueError(f"Unsupported sketch profile kind '{profile_payload['kind']}'")
|
|
|
|
def _wire_from_edge_loop(self, profile_payload: Mapping[str, Any]) -> Wire:
|
|
"""Build a wire from a mixed-edge profile (line + arc + bspline)."""
|
|
from .operations import (
|
|
make_angle_arc_redge,
|
|
make_line_redge,
|
|
make_spline_redge,
|
|
make_wire_from_edges_rwire,
|
|
)
|
|
|
|
entity_ids = profile_payload["entity_ids"]
|
|
result: SketchSolveResult = profile_payload["solve_result"]
|
|
# Build a point_id → 3-D coordinate map from the solve result
|
|
# (includes ALL points, not just loop vertices — needed for arc centers)
|
|
point_map: Dict[str, Tuple[float, float, float]] = {}
|
|
for pid, pt in result.solved_points.items():
|
|
point_map[pid] = self._point3(pt)
|
|
|
|
edges = []
|
|
for eid in entity_ids:
|
|
entity = self.entities[eid]
|
|
if entity.kind == "line":
|
|
start_id, end_id = str(entity.data["start"]), str(entity.data["end"])
|
|
edges.append(make_line_redge(point_map[start_id], point_map[end_id]))
|
|
elif entity.kind == "arc":
|
|
start_id = str(entity.data["start"])
|
|
end_id = str(entity.data["end"])
|
|
center_id = str(entity.data["center"])
|
|
sp = point_map[start_id]
|
|
ep = point_map[end_id]
|
|
cp = point_map[center_id]
|
|
import math as _math
|
|
start_angle = _math.atan2(sp[1] - cp[1], sp[0] - cp[0])
|
|
end_angle = _math.atan2(ep[1] - cp[1], ep[0] - cp[0])
|
|
radius = _math.hypot(sp[0] - cp[0], sp[1] - cp[1])
|
|
if abs(end_angle - start_angle) < 1e-12:
|
|
end_angle += 2.0 * _math.pi
|
|
edges.append(
|
|
make_angle_arc_redge(
|
|
center=cp, radius=radius,
|
|
start_angle=start_angle, end_angle=end_angle,
|
|
normal=(0.0, 0.0, 1.0),
|
|
)
|
|
)
|
|
elif entity.kind == "bspline":
|
|
cps_2d = entity.data["control_points"]
|
|
degree = int(entity.data.get("degree", 3))
|
|
knots = entity.data.get("knots")
|
|
multiplicities = entity.data.get("multiplicities")
|
|
weights = entity.data.get("weights")
|
|
periodic = bool(entity.data.get("periodic", False))
|
|
cps_3d = [(p[0], p[1], 0.0) for p in cps_2d]
|
|
edges.append(
|
|
make_spline_redge(
|
|
control_points=cps_3d,
|
|
degree=degree,
|
|
knots=knots,
|
|
multiplicities=multiplicities,
|
|
weights=weights,
|
|
periodic=periodic,
|
|
)
|
|
)
|
|
else:
|
|
raise ValueError(f"Unsupported edge kind '{entity.kind}' in edge_loop profile")
|
|
return make_wire_from_edges_rwire(edges)
|
|
|
|
def make_face(
|
|
self,
|
|
profile: int | str = 0,
|
|
*,
|
|
solve_result: Optional[SketchSolveResult] = None,
|
|
) -> Face:
|
|
from .operations import make_face_from_wire_rface
|
|
|
|
wire = self.make_wire(profile=profile, solve_result=solve_result)
|
|
return make_face_from_wire_rface(wire, normal=self._plane_normal_tuple())
|
|
|
|
def _add_entity(self, entity: SketchEntity) -> None:
|
|
if entity.entity_id in self.entities:
|
|
raise ValueError(f"Duplicate sketch entity id '{entity.entity_id}'")
|
|
self.entities[entity.entity_id] = entity
|
|
self.entity_order.append(entity.entity_id)
|
|
|
|
def validate_ref(self, ref: SketchRef) -> None:
|
|
if not isinstance(ref, SketchRef):
|
|
raise TypeError("Sketch constraints require SketchRef targets")
|
|
if ref.sketch_id != self.sketch_id:
|
|
raise ValueError("SketchRef belongs to a different sketch")
|
|
if ref.entity_id not in self.entities:
|
|
raise ValueError(f"Unknown sketch entity '{ref.entity_id}'")
|
|
if ref.kind == "point":
|
|
self.resolve_point_id(ref)
|
|
elif ref.kind in {"line", "circle", "arc", "bspline"}:
|
|
entity = self.entities[ref.entity_id]
|
|
if entity.kind != ref.kind:
|
|
raise ValueError(
|
|
f"SketchRef '{ref.entity_id}' is kind '{entity.kind}', not '{ref.kind}'"
|
|
)
|
|
|
|
def resolve_point_id(self, ref: SketchRef) -> str:
|
|
if ref.sketch_id != self.sketch_id:
|
|
raise ValueError("SketchRef belongs to a different sketch")
|
|
if ref.entity_id not in self.entities:
|
|
raise ValueError(f"Unknown sketch entity '{ref.entity_id}'")
|
|
entity = self.entities[ref.entity_id]
|
|
if ref.kind == "point" and entity.kind == "point":
|
|
return ref.entity_id
|
|
if entity.kind == "line" and ref.subentity in {"start", "end"}:
|
|
return str(entity.data[ref.subentity])
|
|
if entity.kind == "circle" and ref.subentity == "center":
|
|
return str(entity.data["center"])
|
|
if entity.kind in {"arc", "bspline"} and ref.subentity in {"start", "end"}:
|
|
return str(entity.data[ref.subentity])
|
|
raise ValueError(f"Cannot resolve {ref!r} to a sketch point")
|
|
|
|
def _constraint_refs(self, constraint: SketchConstraint) -> List[SketchRef]:
|
|
return [SketchRef.from_dict(target) for target in constraint.targets]
|
|
|
|
def _plane_frame(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
plane = self.plane
|
|
if isinstance(plane, str):
|
|
token = plane.upper()
|
|
if token == "XY":
|
|
return (
|
|
np.array([0.0, 0.0, 0.0]),
|
|
np.array([1.0, 0.0, 0.0]),
|
|
np.array([0.0, 1.0, 0.0]),
|
|
np.array([0.0, 0.0, 1.0]),
|
|
)
|
|
if token == "XZ":
|
|
return (
|
|
np.array([0.0, 0.0, 0.0]),
|
|
np.array([1.0, 0.0, 0.0]),
|
|
np.array([0.0, 0.0, 1.0]),
|
|
np.array([0.0, -1.0, 0.0]),
|
|
)
|
|
if token == "YZ":
|
|
return (
|
|
np.array([0.0, 0.0, 0.0]),
|
|
np.array([0.0, 1.0, 0.0]),
|
|
np.array([0.0, 0.0, 1.0]),
|
|
np.array([1.0, 0.0, 0.0]),
|
|
)
|
|
if isinstance(plane, Mapping):
|
|
origin = np.array(plane.get("origin", (0.0, 0.0, 0.0)), dtype=float)
|
|
x_axis = np.array(plane.get("x_axis", (1.0, 0.0, 0.0)), dtype=float)
|
|
y_axis = np.array(plane.get("y_axis", (0.0, 1.0, 0.0)), dtype=float)
|
|
x_axis = x_axis / np.linalg.norm(x_axis)
|
|
y_axis = y_axis / np.linalg.norm(y_axis)
|
|
normal = np.cross(x_axis, y_axis)
|
|
normal = normal / np.linalg.norm(normal)
|
|
return origin, x_axis, y_axis, normal
|
|
raise ValueError("Sketch plane must be 'XY', 'XZ', 'YZ', or a plane mapping")
|
|
|
|
def _point3(self, point: Tuple[float, float]) -> Tuple[float, float, float]:
|
|
origin, x_axis, y_axis, _normal = self._plane_frame()
|
|
vec = origin + float(point[0]) * x_axis + float(point[1]) * y_axis
|
|
return (float(vec[0]), float(vec[1]), float(vec[2]))
|
|
|
|
def _plane_normal_tuple(self) -> Tuple[float, float, float]:
|
|
_origin, _x_axis, _y_axis, normal = self._plane_frame()
|
|
return (float(normal[0]), float(normal[1]), float(normal[2]))
|
|
|
|
def _profile_payload(
|
|
self,
|
|
profile: int | str = 0,
|
|
*,
|
|
solve_result: Optional[SketchSolveResult] = None,
|
|
) -> Dict[str, Any]:
|
|
result = solve_result or self.solved_result()
|
|
profiles = self._profiles_from_solution(result)
|
|
if not profiles:
|
|
raise ValueError("Sketch does not contain a closed non-construction profile")
|
|
if isinstance(profile, str):
|
|
for item in profiles:
|
|
if item.get("id") == profile:
|
|
return item
|
|
raise ValueError(f"Unknown sketch profile '{profile}'")
|
|
index = int(profile)
|
|
if index < 0 or index >= len(profiles):
|
|
raise ValueError(f"Sketch profile index {index} is out of range")
|
|
return profiles[index]
|
|
|
|
def _profiles_from_solution(self, result: SketchSolveResult) -> List[Dict[str, Any]]:
|
|
profiles: List[Dict[str, Any]] = []
|
|
for entity_id in self.entity_order:
|
|
entity = self.entities[entity_id]
|
|
if entity.construction:
|
|
continue
|
|
if entity.kind == "circle":
|
|
center_id = str(entity.data["center"])
|
|
scalar_key = f"circle:{entity_id}:radius"
|
|
center = result.solved_points[center_id]
|
|
profiles.append(
|
|
{
|
|
"id": entity_id,
|
|
"kind": "circle",
|
|
"entity_ids": [entity_id],
|
|
"center": self._point3(center),
|
|
"radius": float(result.solved_scalars[scalar_key]),
|
|
"normal": self._plane_normal_tuple(),
|
|
}
|
|
)
|
|
profiles.extend(self._edge_loop_profiles(result))
|
|
return profiles
|
|
|
|
# --- Edge kinds that participate in closed-loop profiles ---
|
|
_EDGE_KINDS = frozenset({"line", "arc", "bspline"})
|
|
|
|
@staticmethod
|
|
def _edge_endpoints(entity: SketchEntity) -> Tuple[str, str]:
|
|
"""Extract (start_point_id, end_point_id) from any edge entity."""
|
|
return str(entity.data["start"]), str(entity.data["end"])
|
|
|
|
def _edge_loop_profiles(self, result: SketchSolveResult) -> List[Dict[str, Any]]:
|
|
edge_ids = [
|
|
entity_id
|
|
for entity_id in self.entity_order
|
|
if self.entities[entity_id].kind in self._EDGE_KINDS
|
|
and not self.entities[entity_id].construction
|
|
]
|
|
unused = set(edge_ids)
|
|
profiles: List[Dict[str, Any]] = []
|
|
while unused:
|
|
first_edge = min(unused, key=self.entity_order.index)
|
|
component = self._edge_component(first_edge, unused)
|
|
unused.difference_update(component)
|
|
ordered = self._ordered_edge_loop(component)
|
|
if ordered is None:
|
|
continue
|
|
point_ids, ordered_edge_ids = ordered
|
|
profiles.append(
|
|
{
|
|
"id": component[0],
|
|
"kind": "edge_loop",
|
|
"entity_ids": list(ordered_edge_ids),
|
|
"point_ids": list(point_ids),
|
|
"points": [self._point3(result.solved_points[pid]) for pid in point_ids],
|
|
"solve_result": result,
|
|
}
|
|
)
|
|
return profiles
|
|
|
|
def _edge_component(self, first_edge: str, candidates: set[str]) -> List[str]:
|
|
queue = [first_edge]
|
|
seen_edges: set[str] = set()
|
|
seen_points: set[str] = set()
|
|
while queue:
|
|
edge_id = queue.pop(0)
|
|
if edge_id in seen_edges:
|
|
continue
|
|
seen_edges.add(edge_id)
|
|
entity = self.entities[edge_id]
|
|
for point_id in self._edge_endpoints(entity):
|
|
if point_id in seen_points:
|
|
continue
|
|
seen_points.add(point_id)
|
|
for other_id in candidates:
|
|
other = self.entities[other_id]
|
|
if point_id in set(self._edge_endpoints(other)):
|
|
queue.append(other_id)
|
|
return sorted(seen_edges, key=self.entity_order.index)
|
|
|
|
def _ordered_edge_loop(self, edge_ids: Sequence[str]) -> Optional[Tuple[List[str], List[str]]]:
|
|
adjacency: Dict[str, List[str]] = {}
|
|
for edge_id in edge_ids:
|
|
entity = self.entities[edge_id]
|
|
start, end = self._edge_endpoints(entity)
|
|
adjacency.setdefault(start, []).append(edge_id)
|
|
adjacency.setdefault(end, []).append(edge_id)
|
|
if not adjacency or any(len(edges) != 2 for edges in adjacency.values()):
|
|
return None
|
|
|
|
start_edge = edge_ids[0]
|
|
entity = self.entities[start_edge]
|
|
start_point, current_point = self._edge_endpoints(entity)
|
|
used_edges = {start_edge}
|
|
ordered_points = [start_point, current_point]
|
|
ordered_edge_ids = [start_edge]
|
|
|
|
while current_point != start_point:
|
|
options = [eid for eid in adjacency[current_point] if eid not in used_edges]
|
|
if not options:
|
|
return None
|
|
next_edge = options[0]
|
|
used_edges.add(next_edge)
|
|
ordered_edge_ids.append(next_edge)
|
|
next_entity = self.entities[next_edge]
|
|
next_start, next_end = self._edge_endpoints(next_entity)
|
|
current_point = next_end if next_start == current_point else next_start
|
|
if current_point != start_point:
|
|
ordered_points.append(current_point)
|
|
if len(used_edges) > len(edge_ids):
|
|
return None
|
|
if len(used_edges) != len(edge_ids):
|
|
return None
|
|
return ordered_points, ordered_edge_ids
|
|
|
|
|
|
class _SketchSolver:
|
|
def __init__(self, sketch: Sketch, *, tolerance: float, max_iterations: int) -> None:
|
|
self.sketch = sketch
|
|
self.tolerance = float(tolerance)
|
|
self.max_iterations = int(max_iterations)
|
|
self.point_ids = [
|
|
entity_id
|
|
for entity_id in sketch.entity_order
|
|
if sketch.entities[entity_id].kind == "point"
|
|
]
|
|
self.scalar_ids = [
|
|
f"circle:{entity_id}:radius"
|
|
for entity_id in sketch.entity_order
|
|
if sketch.entities[entity_id].kind == "circle"
|
|
]
|
|
self.var_names = [f"point:{pid}:x" for pid in self.point_ids]
|
|
self.var_names.extend(f"point:{pid}:y" for pid in self.point_ids)
|
|
self.var_names.extend(self.scalar_ids)
|
|
|
|
def solve(self) -> SketchSolveResult:
|
|
if not self.var_names:
|
|
return SketchSolveResult(
|
|
sketch_id=self.sketch.sketch_id,
|
|
status="solved",
|
|
dof=0,
|
|
residual_norm=0.0,
|
|
iterations=0,
|
|
solved_points={},
|
|
solved_scalars={},
|
|
)
|
|
x = self._initial_vector()
|
|
diagnostics: List[SketchConstraintDiagnostic] = []
|
|
iterations = 0
|
|
residual = self._residual_vector(x)
|
|
best_norm = float(np.linalg.norm(residual))
|
|
damping = 1e-6
|
|
for iterations in range(self.max_iterations):
|
|
if best_norm <= self.tolerance:
|
|
break
|
|
jacobian = self._finite_difference_jacobian(x, residual)
|
|
lhs = jacobian.T @ jacobian + damping * np.eye(len(x))
|
|
rhs = -(jacobian.T @ residual)
|
|
try:
|
|
step = np.linalg.solve(lhs, rhs)
|
|
except np.linalg.LinAlgError:
|
|
step = np.linalg.lstsq(lhs, rhs, rcond=None)[0]
|
|
if not np.all(np.isfinite(step)):
|
|
diagnostics.append(
|
|
SketchConstraintDiagnostic(None, "error", "nonfinite_step", "Sketch solver produced a non-finite step.")
|
|
)
|
|
break
|
|
accepted = False
|
|
scale = 1.0
|
|
while scale >= 1e-4:
|
|
candidate = x + scale * step
|
|
candidate_residual = self._residual_vector(candidate)
|
|
candidate_norm = float(np.linalg.norm(candidate_residual))
|
|
if candidate_norm <= best_norm:
|
|
x = candidate
|
|
residual = candidate_residual
|
|
best_norm = candidate_norm
|
|
accepted = True
|
|
damping = max(damping * 0.5, 1e-12)
|
|
break
|
|
scale *= 0.5
|
|
if not accepted:
|
|
damping = min(damping * 10.0, 1e6)
|
|
final_jacobian = self._finite_difference_jacobian(x, residual)
|
|
rank = int(np.linalg.matrix_rank(final_jacobian, tol=1e-7)) if final_jacobian.size else 0
|
|
dof = max(0, len(x) - rank)
|
|
if len(residual) > rank and best_norm <= self.tolerance:
|
|
diagnostics.append(
|
|
SketchConstraintDiagnostic(None, "warning", "redundant_constraints", "Sketch has redundant but consistent constraints.")
|
|
)
|
|
if best_norm > self.tolerance:
|
|
status = "conflicting"
|
|
diagnostics.append(
|
|
SketchConstraintDiagnostic(None, "error", "residual_too_large", "Sketch constraints could not be satisfied.", best_norm)
|
|
)
|
|
elif dof > 0:
|
|
status = "underconstrained"
|
|
diagnostics.append(
|
|
SketchConstraintDiagnostic(None, "warning", "underconstrained", f"Sketch has {dof} remaining DOF.")
|
|
)
|
|
else:
|
|
status = "solved"
|
|
points, scalars = self._state_from_vector(x)
|
|
return SketchSolveResult(
|
|
sketch_id=self.sketch.sketch_id,
|
|
status=status,
|
|
dof=dof,
|
|
residual_norm=best_norm,
|
|
iterations=iterations,
|
|
solved_points=points,
|
|
solved_scalars=scalars,
|
|
diagnostics=tuple(diagnostics),
|
|
)
|
|
|
|
def _initial_vector(self) -> np.ndarray:
|
|
values: List[float] = []
|
|
for point_id in self.point_ids:
|
|
entity = self.sketch.entities[point_id]
|
|
values.append(_as_float(entity.data["x"]))
|
|
for point_id in self.point_ids:
|
|
entity = self.sketch.entities[point_id]
|
|
values.append(_as_float(entity.data["y"]))
|
|
for scalar_id in self.scalar_ids:
|
|
_prefix, entity_id, _name = scalar_id.split(":", 2)
|
|
entity = self.sketch.entities[entity_id]
|
|
values.append(_as_float(entity.data["radius"]))
|
|
return np.array(values, dtype=float)
|
|
|
|
def _state_from_vector(self, x: np.ndarray) -> Tuple[Dict[str, Tuple[float, float]], Dict[str, float]]:
|
|
points: Dict[str, Tuple[float, float]] = {}
|
|
offset_y = len(self.point_ids)
|
|
for idx, point_id in enumerate(self.point_ids):
|
|
points[point_id] = (float(x[idx]), float(x[offset_y + idx]))
|
|
scalars: Dict[str, float] = {}
|
|
scalar_offset = 2 * len(self.point_ids)
|
|
for idx, scalar_id in enumerate(self.scalar_ids):
|
|
scalars[scalar_id] = float(x[scalar_offset + idx])
|
|
return points, scalars
|
|
|
|
def _finite_difference_jacobian(self, x: np.ndarray, residual: np.ndarray) -> np.ndarray:
|
|
if len(residual) == 0:
|
|
return np.zeros((0, len(x)))
|
|
jacobian = np.zeros((len(residual), len(x)), dtype=float)
|
|
for idx in range(len(x)):
|
|
step = 1e-6 * max(1.0, abs(float(x[idx])))
|
|
shifted = x.copy()
|
|
shifted[idx] += step
|
|
jacobian[:, idx] = (self._residual_vector(shifted) - residual) / step
|
|
return jacobian
|
|
|
|
def _residual_vector(self, x: np.ndarray) -> np.ndarray:
|
|
points, scalars = self._state_from_vector(x)
|
|
residuals: List[float] = []
|
|
for constraint in self.sketch.constraints:
|
|
residuals.extend(self._constraint_residuals(constraint, points, scalars))
|
|
return np.array(residuals, dtype=float)
|
|
|
|
def _constraint_residuals(
|
|
self,
|
|
constraint: SketchConstraint,
|
|
points: Mapping[str, Tuple[float, float]],
|
|
scalars: Mapping[str, float],
|
|
) -> List[float]:
|
|
refs = self.sketch._constraint_refs(constraint)
|
|
kind = constraint.kind
|
|
if kind == "fix":
|
|
return self._fix_residuals(refs[0], points, scalars)
|
|
if kind == "coincident":
|
|
a = self._point(refs[0], points)
|
|
b = self._point(refs[1], points)
|
|
return [a[0] - b[0], a[1] - b[1]]
|
|
if kind == "horizontal":
|
|
a, b = self._line_points(refs[0], points)
|
|
return [b[1] - a[1]]
|
|
if kind == "vertical":
|
|
a, b = self._line_points(refs[0], points)
|
|
return [b[0] - a[0]]
|
|
if kind == "parallel":
|
|
return [self._cross_normalized(refs[0], refs[1], points)]
|
|
if kind == "perpendicular":
|
|
return [self._dot_normalized(refs[0], refs[1], points)]
|
|
if kind == "collinear":
|
|
a, _b = self._line_points(refs[0], points)
|
|
return [self._cross_normalized(refs[0], refs[1], points), self._point_line_distance(a, refs[1], points)]
|
|
if kind == "equal_length":
|
|
return [self._line_length(refs[0], points) - self._line_length(refs[1], points)]
|
|
if kind == "equal_radius":
|
|
return [self._circle_radius(refs[0], scalars) - self._circle_radius(refs[1], scalars)]
|
|
if kind == "distance":
|
|
return [self._point_distance(refs[0], refs[1], points) - _as_float(constraint.value)]
|
|
if kind == "distance_x":
|
|
a = self._point(refs[0], points)
|
|
b = self._point(refs[1], points)
|
|
return [(b[0] - a[0]) - _as_float(constraint.value)]
|
|
if kind == "distance_y":
|
|
a = self._point(refs[0], points)
|
|
b = self._point(refs[1], points)
|
|
return [(b[1] - a[1]) - _as_float(constraint.value)]
|
|
if kind == "length":
|
|
return [self._line_length(refs[0], points) - _as_float(constraint.value)]
|
|
if kind == "angle":
|
|
return [self._line_angle_delta(refs[0], refs[1], points, _as_float(constraint.value))]
|
|
if kind == "radius":
|
|
return [self._circle_radius(refs[0], scalars) - _as_float(constraint.value)]
|
|
if kind == "diameter":
|
|
return [2.0 * self._circle_radius(refs[0], scalars) - _as_float(constraint.value)]
|
|
if kind == "point_on":
|
|
return self._point_on_residuals(refs[0], refs[1], points, scalars)
|
|
if kind == "concentric":
|
|
a = self._circle_center(refs[0], points)
|
|
b = self._circle_center(refs[1], points)
|
|
return [a[0] - b[0], a[1] - b[1]]
|
|
if kind == "midpoint":
|
|
point = self._point(refs[0], points)
|
|
a, b = self._line_points(refs[1], points)
|
|
return [point[0] - 0.5 * (a[0] + b[0]), point[1] - 0.5 * (a[1] + b[1])]
|
|
if kind == "tangent":
|
|
return [self._tangent_residual(refs[0], refs[1], points, scalars)]
|
|
if kind == "symmetric":
|
|
a = self._point(refs[0], points)
|
|
b = self._point(refs[1], points)
|
|
axis_a, axis_b = self._line_points(refs[2], points)
|
|
axis = self._sub(axis_b, axis_a)
|
|
mid = ((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5)
|
|
return [self._point_line_distance(mid, refs[2], points), self._dot(self._sub(a, b), axis) / max(self._norm(axis), _POINT_EPS)]
|
|
raise ValueError(f"Unsupported sketch constraint kind '{kind}'")
|
|
|
|
def _fix_residuals(
|
|
self,
|
|
ref: SketchRef,
|
|
points: Mapping[str, Tuple[float, float]],
|
|
scalars: Mapping[str, float],
|
|
) -> List[float]:
|
|
entity = self.sketch.entities[ref.entity_id]
|
|
if ref.kind == "point" or entity.kind == "point":
|
|
pid = self.sketch.resolve_point_id(ref)
|
|
target = self.sketch.entities[pid]
|
|
point = points[pid]
|
|
return [point[0] - _as_float(target.data["x"]), point[1] - _as_float(target.data["y"])]
|
|
if entity.kind == "line":
|
|
start = self._fix_residuals(self.sketch.point_ref(f"{ref.entity_id}.start"), points, scalars)
|
|
end = self._fix_residuals(self.sketch.point_ref(f"{ref.entity_id}.end"), points, scalars)
|
|
return start + end
|
|
if entity.kind == "circle":
|
|
center = self._fix_residuals(self.sketch.point_ref(f"{ref.entity_id}.center"), points, scalars)
|
|
radius_key = f"circle:{ref.entity_id}:radius"
|
|
return center + [scalars[radius_key] - _as_float(entity.data["radius"])]
|
|
raise ValueError(f"Cannot fix sketch entity kind '{entity.kind}'")
|
|
|
|
def _point(self, ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> Tuple[float, float]:
|
|
return points[self.sketch.resolve_point_id(ref)]
|
|
|
|
def _line_points(
|
|
self, ref: SketchRef, points: Mapping[str, Tuple[float, float]]
|
|
) -> Tuple[Tuple[float, float], Tuple[float, float]]:
|
|
entity = self.sketch.entities[ref.entity_id]
|
|
if entity.kind != "line":
|
|
raise ValueError(f"Expected line ref, got '{entity.kind}'")
|
|
return points[str(entity.data["start"])], points[str(entity.data["end"])]
|
|
|
|
def _circle_center(self, ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> Tuple[float, float]:
|
|
entity = self.sketch.entities[ref.entity_id]
|
|
if entity.kind != "circle":
|
|
raise ValueError(f"Expected circle ref, got '{entity.kind}'")
|
|
return points[str(entity.data["center"])]
|
|
|
|
def _circle_radius(self, ref: SketchRef, scalars: Mapping[str, float]) -> float:
|
|
entity = self.sketch.entities[ref.entity_id]
|
|
if entity.kind != "circle":
|
|
raise ValueError(f"Expected circle ref, got '{entity.kind}'")
|
|
return scalars[f"circle:{ref.entity_id}:radius"]
|
|
|
|
def _point_distance(
|
|
self, a: SketchRef, b: SketchRef, points: Mapping[str, Tuple[float, float]]
|
|
) -> float:
|
|
return self._norm(self._sub(self._point(b, points), self._point(a, points)))
|
|
|
|
def _line_length(self, ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> float:
|
|
a, b = self._line_points(ref, points)
|
|
return self._norm(self._sub(b, a))
|
|
|
|
def _cross_normalized(self, a_ref: SketchRef, b_ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> float:
|
|
a0, a1 = self._line_points(a_ref, points)
|
|
b0, b1 = self._line_points(b_ref, points)
|
|
a = self._sub(a1, a0)
|
|
b = self._sub(b1, b0)
|
|
return self._cross(a, b) / max(self._norm(a) * self._norm(b), _POINT_EPS)
|
|
|
|
def _dot_normalized(self, a_ref: SketchRef, b_ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> float:
|
|
a0, a1 = self._line_points(a_ref, points)
|
|
b0, b1 = self._line_points(b_ref, points)
|
|
a = self._sub(a1, a0)
|
|
b = self._sub(b1, b0)
|
|
return self._dot(a, b) / max(self._norm(a) * self._norm(b), _POINT_EPS)
|
|
|
|
def _line_angle_delta(
|
|
self,
|
|
a_ref: SketchRef,
|
|
b_ref: SketchRef,
|
|
points: Mapping[str, Tuple[float, float]],
|
|
target: float,
|
|
) -> float:
|
|
a0, a1 = self._line_points(a_ref, points)
|
|
b0, b1 = self._line_points(b_ref, points)
|
|
a = self._sub(a1, a0)
|
|
b = self._sub(b1, b0)
|
|
angle = math.atan2(self._cross(a, b), self._dot(a, b))
|
|
return _angle_delta(angle - target)
|
|
|
|
def _point_on_residuals(
|
|
self,
|
|
point_ref: SketchRef,
|
|
entity_ref: SketchRef,
|
|
points: Mapping[str, Tuple[float, float]],
|
|
scalars: Mapping[str, float],
|
|
) -> List[float]:
|
|
point = self._point(point_ref, points)
|
|
entity = self.sketch.entities[entity_ref.entity_id]
|
|
if entity.kind == "line":
|
|
return [self._point_line_distance(point, entity_ref, points)]
|
|
if entity.kind == "circle":
|
|
center = self._circle_center(entity_ref, points)
|
|
radius = self._circle_radius(entity_ref, scalars)
|
|
return [self._norm(self._sub(point, center)) - radius]
|
|
raise ValueError(f"Unsupported point_on target kind '{entity.kind}'")
|
|
|
|
def _point_line_distance(
|
|
self,
|
|
point: Tuple[float, float],
|
|
line_ref: SketchRef,
|
|
points: Mapping[str, Tuple[float, float]],
|
|
) -> float:
|
|
a, b = self._line_points(line_ref, points)
|
|
ab = self._sub(b, a)
|
|
return self._cross(self._sub(point, a), ab) / max(self._norm(ab), _POINT_EPS)
|
|
|
|
def _tangent_residual(
|
|
self,
|
|
a_ref: SketchRef,
|
|
b_ref: SketchRef,
|
|
points: Mapping[str, Tuple[float, float]],
|
|
scalars: Mapping[str, float],
|
|
) -> float:
|
|
a_kind = self.sketch.entities[a_ref.entity_id].kind
|
|
b_kind = self.sketch.entities[b_ref.entity_id].kind
|
|
if {a_kind, b_kind} == {"line", "circle"}:
|
|
line_ref = a_ref if a_kind == "line" else b_ref
|
|
circle_ref = b_ref if a_kind == "line" else a_ref
|
|
center = self._circle_center(circle_ref, points)
|
|
return abs(self._point_line_distance(center, line_ref, points)) - self._circle_radius(circle_ref, scalars)
|
|
if a_kind == "circle" and b_kind == "circle":
|
|
a_center = self._circle_center(a_ref, points)
|
|
b_center = self._circle_center(b_ref, points)
|
|
return self._norm(self._sub(a_center, b_center)) - (
|
|
self._circle_radius(a_ref, scalars) + self._circle_radius(b_ref, scalars)
|
|
)
|
|
raise ValueError(f"Unsupported tangent target kinds '{a_kind}' and '{b_kind}'")
|
|
|
|
@staticmethod
|
|
def _sub(a: Tuple[float, float], b: Tuple[float, float]) -> Tuple[float, float]:
|
|
return (float(a[0] - b[0]), float(a[1] - b[1]))
|
|
|
|
@staticmethod
|
|
def _dot(a: Tuple[float, float], b: Tuple[float, float]) -> float:
|
|
return float(a[0] * b[0] + a[1] * b[1])
|
|
|
|
@staticmethod
|
|
def _cross(a: Tuple[float, float], b: Tuple[float, float]) -> float:
|
|
return float(a[0] * b[1] - a[1] * b[0])
|
|
|
|
@staticmethod
|
|
def _norm(a: Tuple[float, float]) -> float:
|
|
return float(math.hypot(a[0], a[1]))
|
|
|
|
|
|
__all__ = [
|
|
"Sketch",
|
|
"SketchRef",
|
|
"SketchSolveResult",
|
|
"SketchConstraint",
|
|
"SketchConstraintDiagnostic",
|
|
]
|