优化engine

This commit is contained in:
2026-08-24 10:01:21 +08:00
parent 91a443d990
commit fd8c0c37ad
16 changed files with 4486 additions and 74 deletions
+305 -2
View File
@@ -38,7 +38,7 @@ from __future__ import annotations
import math
from copy import deepcopy
from typing import Any
from typing import Any, Iterable
# ═══════════════════════════════════════════════════════════════
@@ -84,8 +84,9 @@ def _contour_arc(
end_mm: list[float],
center_mm: list[float],
radius_mm: float | None,
clockwise: bool | None = None,
) -> dict[str, Any]:
return {
result = {
"type": "arc",
"start_mm": [
float(start_mm[0]),
@@ -104,6 +105,9 @@ def _contour_arc(
],
"radius_mm": float(radius_mm) if radius_mm is not None else None,
}
if clockwise is not None:
result["clockwise"] = bool(clockwise)
return result
# ═══════════════════════════════════════════════════════════════
@@ -1558,6 +1562,235 @@ def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ct
return [], []
# ═══════════════════════════════════════════════════════════════
# Evidence v2 analytic contours
# ═══════════════════════════════════════════════════════════════
_ANALYTIC_TOLERANCE_MM = 1e-5
def _distance_2d(left: list[float], right: list[float]) -> float:
return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1]))
def _reverse_analytic_edge(edge: _Ctx) -> _Ctx:
result = deepcopy(edge)
result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"]
if result.get("type") == "arc" and "clockwise" in result:
result["clockwise"] = not bool(result["clockwise"])
return result
def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]:
"""Order/reorient a contour without depending on SolidWorks segment order."""
if not edges:
return []
pending = [deepcopy(edge) for edge in edges]
ordered = [pending.pop(0)]
while pending:
tail = ordered[-1]["end_mm"]
match_index = None
reverse = False
for index, edge in enumerate(pending):
if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM:
match_index = index
break
if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM:
match_index = index
reverse = True
break
if match_index is None:
raise ValueError("analytic_contours: segments do not form a connected contour")
edge = pending.pop(match_index)
ordered.append(_reverse_analytic_edge(edge) if reverse else edge)
if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM:
raise ValueError("analytic_contours: closed contour endpoints do not meet")
return ordered
def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]:
center = segment.get("center") or [0.0, 0.0]
radius = float(segment.get("radius_mm") or 0.0)
if radius <= 0:
raise ValueError("analytic_contours: circle radius_mm must be > 0")
cx, cy = float(center[0]), float(center[1])
clockwise = bool(segment.get("clockwise", False))
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
return [
_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise)
for index in range(4)
]
def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]:
segment_type = segment.get("type")
if segment_type == "line":
return [_contour_line(segment["start"], segment["end"])]
if segment_type == "arc":
return [
_contour_arc(
segment["start"], segment["end"], segment["center"],
segment.get("radius_mm"), segment.get("clockwise"),
)
]
if segment_type == "circle":
return _analytic_circle_edges(segment)
if segment_type == "bspline":
raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}")
def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
"""Create a deterministic planar sample only for containment classification."""
points: list[tuple[float, float]] = []
for edge in edges:
start = edge["start_mm"]
points.append((float(start[0]), float(start[1])))
if edge.get("type") != "arc":
continue
center = edge["center_mm"]
end = edge["end_mm"]
sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1])
ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1])
start_angle = math.atan2(sy, sx)
end_angle = math.atan2(ey, ex)
delta = end_angle - start_angle
if edge.get("clockwise"):
if delta >= 0:
delta -= math.tau
elif delta <= 0:
delta += math.tau
for fraction in (0.25, 0.5, 0.75):
angle = start_angle + delta * fraction
radius = float(edge.get("radius_mm") or math.hypot(sx, sy))
points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle)))
return points
def _loop_area(points: list[tuple[float, float]]) -> float:
if len(points) < 3:
return 0.0
return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0)
def _endpoint_signed_area(edges: list[_Ctx]) -> float:
points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges]
return sum(
points[index][0] * points[(index + 1) % len(points)][1]
- points[(index + 1) % len(points)][0] * points[index][1]
for index in range(len(points))
) / 2.0
def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None:
"""Repair inconsistent sweep flags on a conventional rounded rectangle.
Evidence exports occasionally label one or more 90-degree corner arcs
with the opposite direction. Honouring those isolated flags creates
270-degree loops. This normalizer applies only to the unambiguous shape:
exactly four equal-radius quarter arcs in one closed loop. Other arcs,
including annular sectors and long sweeps, retain their captured flags.
"""
arcs = [edge for edge in edges if edge.get("type") == "arc"]
if len(arcs) != 4:
return
radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs]
if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM:
return
for edge in arcs:
center = edge.get("center_mm")
if not isinstance(center, list):
return
start, end = edge["start_mm"], edge["end_mm"]
first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1]))
second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1]))
angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1]))
if abs(angle - math.pi / 2) > 1e-4:
return
# A clockwise endpoint loop needs clockwise short corner arcs; a
# counter-clockwise loop needs their reverse. This preserves the actual
# rounded-rectangle boundary, independent of per-segment export noise.
clockwise = _endpoint_signed_area(edges) < 0.0
for edge in arcs:
edge["clockwise"] = clockwise
def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
if len(loop) < 3:
return False
inside = False
x, y = point
previous = loop[-1]
for current in loop:
x1, y1 = current
x2, y2 = previous
if (y1 > y) != (y2 > y):
intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1
if x < intersect_x:
inside = not inside
previous = current
return inside
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Resolve Evidence v2 line/arc/circle loops into engine-neutral regions.
The returned regions preserve holes and islands. The build adapter owns
B-rep creation; this profile generator only reasons about sketch geometry.
"""
loops: list[_Ctx] = []
entities: list[_Ctx] = []
for contour_index, contour in enumerate(profile.get("contours") or []):
if not contour.get("closed"):
raise ValueError(f"analytic_contours: contour {contour_index} is open")
segment_edges: list[_Ctx] = []
for segment in contour.get("segments") or []:
segment_type = segment.get("type")
if segment_type == "line":
entities.append(_line(segment["start"], segment["end"]))
elif segment_type == "circle":
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
segment_edges.extend(_analytic_segment_edges(segment))
if not segment_edges:
continue
edges = _join_analytic_edges(segment_edges, closed=True)
_normalize_quarter_rounding_direction(edges)
points = _sample_analytic_loop(edges)
area = _loop_area(points)
if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM:
raise ValueError(f"analytic_contours: contour {contour_index} is degenerate")
loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area})
for segment in profile.get("construction") or []:
if segment.get("type") == "line":
entities.append(_line(segment["start"], segment["end"], construction=True))
elif segment.get("type") == "circle":
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True))
if not loops:
return entities, []
for loop in loops:
# Role tags captured from the source sketch are useful provenance but
# not authoritative geometry. A number of exports label separate
# closed contours as ``inner`` although no outer contour contains
# them. The even-odd containment rule is deterministic for the
# supported analytic curves and preserves those independent regions.
contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop)
loop["role"] = "inner" if contained_by % 2 else "outer"
outers = [loop for loop in loops if loop["role"] == "outer"]
inners = [loop for loop in loops if loop["role"] == "inner"]
regions = [{"outer": outer["edges"], "holes": []} for outer in outers]
for inner in inners:
containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])]
if not containing:
raise ValueError("analytic_contours: inner contour has no containing outer contour")
selected = min(containing, key=lambda outer: outer["area"])
regions[outers.index(selected)]["holes"].append(inner["edges"])
meta["_regions"] = regions
return entities, []
# ═══════════════════════════════════════════════════════════════
# 生成器注册表 —— 唯一索引点
# ═══════════════════════════════════════════════════════════════
@@ -1588,6 +1821,7 @@ SHAPE_GENERATORS: dict[str, Any] = {
"radial_slot": _gen_radial_slot,
"patterned_cutouts": _gen_patterned_cutouts,
"compound_patterned_cutouts": _gen_compound_patterned_cutouts,
"analytic_contours": _gen_analytic_contours,
"arc_chain": _gen_arc_chain,
"complex_arc_shape": _gen_polygon, # 从 compiler_context entities 重建
"unknown_shape": _gen_polygon, # 未分类形状也走 compiler_context 回退
@@ -1748,3 +1982,72 @@ def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]:
out = deepcopy(cdsl)
out.setdefault("geometry", {})["sketches"] = result
return out
def resolve_required_sketches(
cdsl: dict[str, Any],
sketch_ids: Iterable[str],
*,
errors: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Resolve only profiles that an executable feature actually consumes.
``profile_from`` dependencies are resolved recursively. Callers that
pass ``errors`` get feature-addressable failures without losing unrelated
resolved sketches; callers that omit it retain the strict exception
behavior useful to profile tooling.
"""
sketches = list((cdsl.get("geometry") or {}).get("sketches") or [])
by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None}
resolved: dict[str, dict[str, Any]] = {}
resolving: set[str] = set()
def resolve_one(sketch_id: str) -> dict[str, Any]:
if sketch_id in resolved:
return resolved[sketch_id]
sketch = by_id.get(sketch_id)
if sketch is None:
raise ValueError(f"sketch {sketch_id!r} was not found")
if sketch_id in resolving:
raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle")
resolving.add(sketch_id)
try:
if "profile" in sketch:
output = resolve_profile(sketch)
elif sketch.get("profile_from"):
source_id = str(sketch["profile_from"])
source = resolve_one(source_id)
if not source.get("profile"):
raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile")
output = deepcopy(sketch)
output["profile"] = deepcopy(source["profile"])
output.pop("profile_from", None)
shift = sketch.get("profile_shift")
if shift and len(shift) == 2 and output["profile"].get("type") == "polygon":
du, dv = float(shift[0]), float(shift[1])
for vertex in output["profile"]["vertices"]:
vertex[0] = round(vertex[0] + du, 6)
vertex[1] = round(vertex[1] + dv, 6)
output.pop("profile_shift", None)
output = resolve_profile(output)
else:
output = deepcopy(sketch)
resolved[sketch_id] = output
return output
finally:
resolving.discard(sketch_id)
for sketch_id in {str(item) for item in sketch_ids}:
try:
resolve_one(sketch_id)
except ValueError as error:
if errors is None:
raise
errors[sketch_id] = str(error)
output = deepcopy(cdsl)
output.setdefault("geometry", {})["sketches"] = [
resolved.get(str(sketch.get("id")), deepcopy(sketch))
for sketch in sketches
]
return output