Files
cdsl-cad/backend/app/services/quality.py
T
2026-08-25 17:41:24 +08:00

263 lines
13 KiB
Python

"""Generic, model-family-independent verification for direct CDSL revisions."""
from __future__ import annotations
import math
from typing import Any
QUALITY_RULE_TYPES = frozenset({
"bbox", "solid_count", "feature_count", "hole_count", "hole_diameter",
"hole_center", "overall_length", "overall_diameter", "through_condition",
})
_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"}
_SEVERITIES = {"blocking", "warning", "informational"}
def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None:
"""Return a whole-model or feature-owned runtime bounding box."""
candidates: list[Any] = []
if feature_id:
for record in engine_result.get("topology_records") or []:
if not isinstance(record, dict):
continue
owners = record.get("owner_feature_ids") or []
if record.get("feature_id") == feature_id or feature_id in owners:
geometry = record.get("geometry")
if isinstance(geometry, dict):
candidates.append(geometry.get("bbox_mm"))
else:
bbox = engine_result.get("bbox_mm")
if isinstance(bbox, dict):
candidates.append([
*(bbox.get("min") or []),
*(bbox.get("max") or []),
])
boxes = [
value for value in candidates
if isinstance(value, list) and len(value) == 6
and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value)
]
if not boxes:
return None
minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)]
maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)]
return [float(item) for item in minimum], [float(item) for item in maximum]
def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None:
bounds = _bbox_bounds(engine_result, feature_id)
if bounds is None:
return None
minimum, maximum = bounds
return {
"min": minimum,
"max": maximum,
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
}
def _dimensions(engine_result: dict[str, Any]) -> list[float] | None:
value = _bbox_value(engine_result)
return value["dimensions"] if value else None
def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None:
return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None)
def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None:
return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None)
def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]:
if profile.get("type") == "circle":
center, radius = profile.get("center"), profile.get("radius_mm")
if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)):
return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}]
if profile.get("type") != "analytic_contours":
return []
return [
{"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])}
for contour in profile.get("contours") or [] if isinstance(contour, dict)
for segment in contour.get("segments") or [] if isinstance(segment, dict)
and segment.get("type") == "circle"
and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2
and isinstance(segment.get("radius_mm"), (int, float))
]
def _finite_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
def _validate_expected(kind: str, expected: Any, index: int) -> None:
scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"}
if kind in scalar_types and not _finite_number(expected):
raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}")
if kind == "bbox":
valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected)
valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"})
if valid_ranges and set(expected) == {"min", "max"}:
valid_ranges = all(
isinstance(expected[key], list)
and len(expected[key]) == 3
and all(_finite_number(value) for value in expected[key])
for key in ("min", "max")
)
elif valid_ranges:
valid_ranges = all(_finite_number(expected[key]) for key in expected)
if not valid_dimensions and not valid_ranges:
raise ValueError(
f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], "
"{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}"
)
if kind == "hole_center" and (
not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected)
):
raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center")
if kind == "through_condition" and not isinstance(expected, bool):
raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition")
def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]:
if verification is None:
return []
if not isinstance(verification, dict) or set(verification) - {"rules"}:
raise ValueError("verification must be an object containing only rules")
rules = verification.get("rules", [])
if not isinstance(rules, list) or len(rules) > 32:
raise ValueError("verification.rules must be an array with at most 32 rules")
feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")}
normalized: list[dict[str, Any]] = []
ids: set[str] = set()
for index, raw in enumerate(rules):
if not isinstance(raw, dict):
raise ValueError(f"verification.rules[{index}] must be an object")
allowed = {"id", "type", "feature", "expected", "tolerance", "severity"}
if set(raw) - allowed:
raise ValueError(f"verification.rules[{index}] has unsupported fields")
rule_id = str(raw.get("id") or "").strip()
kind = str(raw.get("type") or "").strip()
if not rule_id or rule_id in ids:
raise ValueError(f"verification.rules[{index}].id must be unique and non-empty")
if kind not in QUALITY_RULE_TYPES:
raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}")
if "expected" not in raw:
raise ValueError(f"verification.rules[{index}].expected is required")
_validate_expected(kind, raw["expected"], index)
severity = str(raw.get("severity") or "blocking")
if severity not in _SEVERITIES:
raise ValueError(f"verification.rules[{index}].severity is unsupported")
try:
tolerance = float(raw.get("tolerance") or 0.0)
except (TypeError, ValueError) as error:
raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error
if not math.isfinite(tolerance) or tolerance < 0:
raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative")
feature = str(raw.get("feature") or "").strip()
if kind in _FEATURE_RULE_TYPES and not feature:
raise ValueError(f"verification.rules[{index}].feature is required for {kind}")
if feature and feature not in feature_ids:
raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID")
ids.add(rule_id)
normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity})
return normalized
def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]:
kind, target = rule["type"], rule.get("feature") or None
feature = _feature(cdsl, target)
params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {}
sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or ""))
profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {}
circles = _circles(profile) if isinstance(profile, dict) else []
dimensions = _dimensions(engine_result)
if kind == "bbox":
value = _bbox_value(engine_result, target)
source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm"
return value, source
if kind == "solid_count":
return float(engine_result.get("solid_count", 1)), "runtime.solid_count"
if kind == "feature_count":
return float(len(cdsl.get("features") or [])), "cdsl.features"
if kind == "hole_count":
return float(len(circles)), f"cdsl.features.{target}.sketch"
if kind == "hole_diameter":
value = params.get("diameter_mm", params.get("hole_diameter_mm"))
if isinstance(value, (int, float)):
return float(value), f"cdsl.features.{target}.params"
return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch"
if kind == "hole_center":
centers = [circle["center"] for circle in circles]
return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch"
if kind == "overall_length":
return (max(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "overall_diameter":
return (min(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "through_condition":
end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {}
if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}:
return True, f"cdsl.features.{target}.params.end_condition"
distance = params.get("distance_mm")
return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm"
return None, "unsupported verification type"
def _matches(expected: Any, actual: Any, tolerance: float) -> bool:
if actual is None:
return False
if isinstance(expected, list):
return isinstance(actual, list) and len(expected) == len(actual) and all(
_matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual)
)
if isinstance(expected, bool):
return bool(actual) is expected
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0)
return expected == actual
def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool:
if actual is None:
return False
if isinstance(expected, list):
return _matches(expected, actual["dimensions"], tolerance)
if not isinstance(expected, dict):
return False
if set(expected) == {"min", "max"}:
return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance)
if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}:
actual_ranges = {
"x_min": actual["min"][0], "x_max": actual["max"][0],
"y_min": actual["min"][1], "y_max": actual["max"][1],
"z_min": actual["min"][2], "z_max": actual["max"][2],
}
return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges)
return False
def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]:
results = []
for rule in rules:
actual, source = _actual(rule, cdsl, engine_result)
passed = (
_matches_bbox(rule["expected"], actual, rule["tolerance"])
if rule["type"] == "bbox"
else _matches(rule["expected"], actual, rule["tolerance"])
)
results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source})
blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"]
warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"]
return {
"schema": "cad.quality-report.v1",
"schema_version": "1.0",
"status": "passed" if not blocking else "failed",
"verification_requested": bool(rules),
"results": results,
"blocking_failures": blocking,
"warnings": warnings,
"measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)},
}