from __future__ import annotations import hashlib import json import math from typing import Any from . import lookup AXIS_NAMES = ("x", "y", "z") AXIS_INDEX = {name: index for index, name in enumerate(AXIS_NAMES)} AXIS_ALIGNMENT_THRESHOLD = 0.985 def _float_triplet(value: object) -> tuple[float, float, float] | None: if not isinstance(value, (list, tuple)) or len(value) != 3: return None try: return (float(value[0]), float(value[1]), float(value[2])) except (TypeError, ValueError): return None def _normalize(vector: tuple[float, float, float] | None) -> tuple[float, float, float] | None: if vector is None: return None length = math.sqrt(sum(component * component for component in vector)) if length <= 1e-12: return None return tuple(component / length for component in vector) def dominant_axis( vector: object, *, aligned_threshold: float = AXIS_ALIGNMENT_THRESHOLD, ) -> dict[str, object] | None: normalized = _normalize(_float_triplet(vector)) if normalized is None: return None magnitudes = [abs(component) for component in normalized] axis_index = max(range(3), key=lambda index: magnitudes[index]) component = normalized[axis_index] return { "axis": AXIS_NAMES[axis_index], "index": axis_index, "sign": 1 if component >= 0.0 else -1, "component": component, "magnitude": magnitudes[axis_index], "aligned": magnitudes[axis_index] >= aligned_threshold, } def bbox_size(bbox: object) -> list[float] | None: if not isinstance(bbox, dict): return None min_point = _float_triplet(bbox.get("min")) max_point = _float_triplet(bbox.get("max")) if min_point is None or max_point is None: return None return [ float(max_point[0] - min_point[0]), float(max_point[1] - min_point[1]), float(max_point[2] - min_point[2]), ] def bbox_center(bbox: object) -> list[float] | None: if not isinstance(bbox, dict): return None min_point = _float_triplet(bbox.get("min")) max_point = _float_triplet(bbox.get("max")) if min_point is None or max_point is None: return None return [ float((min_point[0] + max_point[0]) * 0.5), float((min_point[1] + max_point[1]) * 0.5), float((min_point[2] + max_point[2]) * 0.5), ] def bbox_diag(bbox: object) -> float | None: size = bbox_size(bbox) if size is None: return None return math.sqrt(sum(component * component for component in size)) def bbox_facts(bbox: object) -> dict[str, object]: facts: dict[str, object] = {} if not isinstance(bbox, dict): return facts size = bbox_size(bbox) center = bbox_center(bbox) diag = bbox_diag(bbox) if size is not None: facts["size"] = size extent_axis = dominant_axis(size, aligned_threshold=0.0) if extent_axis is not None: facts["extentAxis"] = extent_axis["axis"] if center is not None: facts["center"] = center if diag is not None: facts["diag"] = diag return facts def geometry_facts_for_row( selector_type: str, row: dict[str, object], index: lookup.SelectorIndex | None = None, ) -> dict[str, object]: facts = bbox_facts(row.get("bbox")) if selector_type in {"occurrence", "shape"}: return facts params = row.get("params") if isinstance(row.get("params"), dict) else {} center = _float_triplet(row.get("center")) if center is not None: facts.setdefault("center", list(center)) if selector_type == "vertex": if row.get("edgeCount") not in {None, ""}: facts["edgeCount"] = int(row["edgeCount"]) if index is not None and row.get("id"): facts["selector"] = lookup.display_selector(str(row["id"]), index) return facts axis_vector = _float_triplet(params.get("axis")) direction_vector = _float_triplet(params.get("direction")) normal_vector = _float_triplet(row.get("normal")) or _float_triplet(params.get("normal")) or axis_vector if selector_type == "face": surface_type = str(row.get("surfaceType") or "") if surface_type: facts["surfaceType"] = surface_type if row.get("area") not in {None, ""}: facts["area"] = float(row["area"]) normal_axis = dominant_axis(normal_vector) if normal_axis is not None: facts["normalAxis"] = { "axis": normal_axis["axis"], "sign": normal_axis["sign"], "aligned": normal_axis["aligned"], } if center is not None and bool(normal_axis["aligned"]): facts["planeCoordinate"] = center[int(normal_axis["index"])] radius = params.get("radius") if radius not in {None, ""}: facts["radius"] = float(radius) if surface_type == "plane" and axis_vector is not None: facts["axis"] = list(axis_vector) if index is not None and row.get("id"): facts["selector"] = lookup.display_selector(str(row["id"]), index) return facts curve_type = str(row.get("curveType") or "") if curve_type: facts["curveType"] = curve_type if row.get("length") not in {None, ""}: facts["length"] = float(row["length"]) direction_axis = dominant_axis(direction_vector or axis_vector or bbox_size(row.get("bbox"))) if direction_axis is not None: facts["directionAxis"] = { "axis": direction_axis["axis"], "sign": direction_axis["sign"], "aligned": direction_axis["aligned"], } radius = params.get("radius") if radius not in {None, ""}: facts["radius"] = float(radius) if index is not None and row.get("id"): facts["selector"] = lookup.display_selector(str(row["id"]), index) return facts def _merge_bboxes(boxes: list[dict[str, object]]) -> dict[str, object]: min_x = min(float(box["min"][0]) for box in boxes) min_y = min(float(box["min"][1]) for box in boxes) min_z = min(float(box["min"][2]) for box in boxes) max_x = max(float(box["max"][0]) for box in boxes) max_y = max(float(box["max"][1]) for box in boxes) max_z = max(float(box["max"][2]) for box in boxes) return { "min": [min_x, min_y, min_z], "max": [max_x, max_y, max_z], } def major_planar_face_groups( index: lookup.SelectorIndex, *, coordinate_tolerance: float = 1e-3, min_area_ratio: float = 0.05, limit: int = 12, ) -> list[dict[str, object]]: planar_rows = [ row for row in index.faces if str(row.get("surfaceType") or "").lower() == "plane" ] total_planar_area = sum(float(row.get("area") or 0.0) for row in planar_rows) grouped: dict[tuple[str, int], dict[str, object]] = {} for row in planar_rows: facts = geometry_facts_for_row("face", row, index) normal_axis = facts.get("normalAxis") plane_coordinate = facts.get("planeCoordinate") if not isinstance(normal_axis, dict) or plane_coordinate in {None, ""}: continue axis = str(normal_axis.get("axis") or "") if axis not in AXIS_INDEX: continue coordinate = float(plane_coordinate) bucket = int(round(coordinate / coordinate_tolerance)) if coordinate_tolerance > 0 else 0 key = (axis, bucket) bbox = row.get("bbox") if not isinstance(bbox, dict): continue group = grouped.get(key) if group is None: group = { "axis": axis, "coordinate": 0.0, "normalSign": int(normal_axis.get("sign") or 1), "faceCount": 0, "totalArea": 0.0, "bboxParts": [], "selectors": [], } grouped[key] = group area = float(row.get("area") or 0.0) group["coordinate"] = float(group["coordinate"]) + (coordinate * max(area, 1e-9)) group["faceCount"] = int(group["faceCount"]) + 1 group["totalArea"] = float(group["totalArea"]) + area group["bboxParts"].append(bbox) selector = lookup.display_selector(str(row.get("id") or ""), index) if selector: group["selectors"].append(selector) result: list[dict[str, object]] = [] for group in grouped.values(): total_area = float(group["totalArea"]) if total_planar_area > 0.0 and total_area / total_planar_area < min_area_ratio: continue weighted_coordinate = float(group["coordinate"]) / max(total_area, 1e-9) merged_bbox = _merge_bboxes(list(group["bboxParts"])) result.append( { "axis": group["axis"], "coordinate": weighted_coordinate, "normalSign": group["normalSign"], "faceCount": group["faceCount"], "totalArea": total_area, "bbox": merged_bbox, "selectors": sorted(set(str(selector) for selector in group["selectors"] if selector)), } ) result.sort(key=lambda item: (-float(item["totalArea"]), str(item["axis"]), float(item["coordinate"]))) return result[: max(int(limit), 0)] def _table_rows(manifest: dict[str, Any], table_name: str, columns_name: str) -> list[dict[str, Any]]: columns = manifest.get("tables", {}).get(columns_name) rows = manifest.get(table_name) if not isinstance(columns, list) or not isinstance(rows, list): return [] materialized: list[dict[str, Any]] = [] for row in rows: if not isinstance(row, list): continue materialized.append({str(columns[index]): row[index] for index in range(min(len(columns), len(row)))}) return materialized def _stable_hash(payload: object) -> str: encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True) return hashlib.sha256(encoded.encode("utf-8")).hexdigest() def selector_manifest_diff( old_manifest: dict[str, Any] | None, new_manifest: dict[str, Any], ) -> dict[str, object]: if old_manifest is None: return { "hasPrevious": False, "topologyChanged": False, "geometryChanged": False, "bboxChanged": False, "countDelta": {}, } old_faces = _table_rows(old_manifest, "faces", "faceColumns") new_faces = _table_rows(new_manifest, "faces", "faceColumns") old_edges = _table_rows(old_manifest, "edges", "edgeColumns") new_edges = _table_rows(new_manifest, "edges", "edgeColumns") old_vertices = _table_rows(old_manifest, "vertices", "vertexColumns") new_vertices = _table_rows(new_manifest, "vertices", "vertexColumns") old_topology = { "occurrences": [row.get("id") for row in _table_rows(old_manifest, "occurrences", "occurrenceColumns")], "shapes": [row.get("id") for row in _table_rows(old_manifest, "shapes", "shapeColumns")], "faces": [row.get("id") for row in old_faces], "edges": [row.get("id") for row in old_edges], "vertices": [row.get("id") for row in old_vertices], "faceCount": old_manifest.get("stats", {}).get("faceCount"), "edgeCount": old_manifest.get("stats", {}).get("edgeCount"), "vertexCount": old_manifest.get("stats", {}).get("vertexCount"), } new_topology = { "occurrences": [row.get("id") for row in _table_rows(new_manifest, "occurrences", "occurrenceColumns")], "shapes": [row.get("id") for row in _table_rows(new_manifest, "shapes", "shapeColumns")], "faces": [row.get("id") for row in new_faces], "edges": [row.get("id") for row in new_edges], "vertices": [row.get("id") for row in new_vertices], "faceCount": new_manifest.get("stats", {}).get("faceCount"), "edgeCount": new_manifest.get("stats", {}).get("edgeCount"), "vertexCount": new_manifest.get("stats", {}).get("vertexCount"), } old_geometry = { "bbox": old_manifest.get("bbox"), "faces": [ { "id": row.get("id"), "surfaceType": row.get("surfaceType"), "center": row.get("center"), "normal": row.get("normal"), "bbox": row.get("bbox"), "area": row.get("area"), } for row in old_faces ], "edges": [ { "id": row.get("id"), "curveType": row.get("curveType"), "center": row.get("center"), "bbox": row.get("bbox"), "length": row.get("length"), } for row in old_edges ], "vertices": [ { "id": row.get("id"), "center": row.get("center"), "bbox": row.get("bbox"), } for row in old_vertices ], } new_geometry = { "bbox": new_manifest.get("bbox"), "faces": [ { "id": row.get("id"), "surfaceType": row.get("surfaceType"), "center": row.get("center"), "normal": row.get("normal"), "bbox": row.get("bbox"), "area": row.get("area"), } for row in new_faces ], "edges": [ { "id": row.get("id"), "curveType": row.get("curveType"), "center": row.get("center"), "bbox": row.get("bbox"), "length": row.get("length"), } for row in new_edges ], "vertices": [ { "id": row.get("id"), "center": row.get("center"), "bbox": row.get("bbox"), } for row in new_vertices ], } count_delta = { "faceCount": int(new_manifest.get("stats", {}).get("faceCount") or 0) - int(old_manifest.get("stats", {}).get("faceCount") or 0), "edgeCount": int(new_manifest.get("stats", {}).get("edgeCount") or 0) - int(old_manifest.get("stats", {}).get("edgeCount") or 0), "vertexCount": int(new_manifest.get("stats", {}).get("vertexCount") or 0) - int(old_manifest.get("stats", {}).get("vertexCount") or 0), "shapeCount": int(new_manifest.get("stats", {}).get("shapeCount") or 0) - int(old_manifest.get("stats", {}).get("shapeCount") or 0), } return { "hasPrevious": True, "topologyChanged": _stable_hash(old_topology) != _stable_hash(new_topology), "geometryChanged": _stable_hash(old_geometry) != _stable_hash(new_geometry), "bboxChanged": old_manifest.get("bbox") != new_manifest.get("bbox"), "countDelta": count_delta, } def view_name_for_axis(axis: str, sign: int) -> str: normalized_sign = 1 if sign >= 0 else -1 if axis == "x": return "right" if normalized_sign > 0 else "left" if axis == "y": return "top" if normalized_sign > 0 else "bottom" return "front" if normalized_sign > 0 else "back" def aligned_view_name_for_facts( selector_type: str, facts: dict[str, object], ) -> str | None: if selector_type == "face": normal_axis = facts.get("normalAxis") if isinstance(normal_axis, dict): axis = str(normal_axis.get("axis") or "") sign = int(normal_axis.get("sign") or 1) if axis in AXIS_INDEX: return view_name_for_axis(axis, sign) return None if selector_type != "edge": return None direction_axis = facts.get("directionAxis") if not isinstance(direction_axis, dict): return None axis = str(direction_axis.get("axis") or "") if axis == "x": return "front" if axis == "y": return "front" if axis == "z": return "top" return None