From 139a355ef3b5eab36f49ce1139311ea23d7a40e5 Mon Sep 17 00:00:00 2001 From: Antoine Date: Tue, 17 Feb 2026 14:37:13 +0000 Subject: [PATCH] Add v2 geometry normalization and boundary-layer seed points --- tools/adaptive-isogrid/src/brain/__main__.py | 4 +- .../src/brain/geometry_schema.py | 132 ++++++++++++++++++ .../src/brain/triangulation.py | 69 +++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 tools/adaptive-isogrid/src/brain/geometry_schema.py diff --git a/tools/adaptive-isogrid/src/brain/__main__.py b/tools/adaptive-isogrid/src/brain/__main__.py index 267bd877..8e852e8d 100644 --- a/tools/adaptive-isogrid/src/brain/__main__.py +++ b/tools/adaptive-isogrid/src/brain/__main__.py @@ -16,6 +16,7 @@ from shapely.geometry import Polygon from src.atomizer_study import DEFAULT_PARAMS from src.shared.arc_utils import typed_segments_to_polyline from .density_field import evaluate_density_grid +from .geometry_schema import normalize_geometry_schema from .triangulation import generate_triangulation from .pocket_profiles import generate_pockets from .profile_assembly import assemble_profile, profile_to_json @@ -212,7 +213,8 @@ def _plot_final_profile(geometry, pockets, ribbed_plate, out_path: Path, params: def run_pipeline(geometry_path: Path, params_path: Path | None, output_dir: Path, output_json_name: str = "rib_profile.json") -> Dict[str, Any]: - geometry = _load_json(geometry_path) + raw_geometry = _load_json(geometry_path) + geometry = normalize_geometry_schema(raw_geometry) params = _merge_params(geometry, params_path) triangulation = generate_triangulation(geometry, params) diff --git a/tools/adaptive-isogrid/src/brain/geometry_schema.py b/tools/adaptive-isogrid/src/brain/geometry_schema.py new file mode 100644 index 00000000..4461e962 --- /dev/null +++ b/tools/adaptive-isogrid/src/brain/geometry_schema.py @@ -0,0 +1,132 @@ +"""Geometry schema normalization (v1.0 and v2.0 typed-segment support).""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import Any, Dict, List + +import numpy as np +from shapely.geometry import Polygon + +from src.shared.arc_utils import arc_to_polyline + + +def _as_xy(pt: Any) -> List[float]: + return [float(pt[0]), float(pt[1])] + + +def _arc_span(seg: Dict[str, Any]) -> float: + cx, cy = _as_xy(seg["center"]) + sx, sy = _as_xy(seg["start"]) + ex, ey = _as_xy(seg["end"]) + + a0 = math.atan2(sy - cy, sx - cx) + a1 = math.atan2(ey - cy, ex - cx) + cw = bool(seg.get("clockwise", False)) + + if abs(sx - ex) < 1e-9 and abs(sy - ey) < 1e-9: + return 2.0 * math.pi + + if cw: + if a1 > a0: + a1 -= 2.0 * math.pi + else: + if a1 < a0: + a1 += 2.0 * math.pi + return abs(a1 - a0) + + +def _typed_segments_to_polyline_v2(segments: List[Dict[str, Any]], full_circle_segments: int = 32) -> List[List[float]]: + out: List[List[float]] = [] + + for seg in segments or []: + stype = seg.get("type", "line") + if stype == "arc": + span = _arc_span(seg) + n_seg = max(2, int(round(full_circle_segments * span / (2.0 * math.pi)))) + pts = arc_to_polyline(seg, n_pts=n_seg + 1) + else: + pts = [_as_xy(seg["start"]), _as_xy(seg["end"])] + + if out and pts: + if abs(out[-1][0] - pts[0][0]) < 1e-9 and abs(out[-1][1] - pts[0][1]) < 1e-9: + out.extend(pts[1:]) + else: + out.extend(pts) + else: + out.extend(pts) + + if len(out) >= 2 and abs(out[0][0] - out[-1][0]) < 1e-9 and abs(out[0][1] - out[-1][1]) < 1e-9: + out = out[:-1] + + return out + + +def _inner_boundary_to_hole(inner: Dict[str, Any], default_weight: float = 0.5) -> Dict[str, Any]: + segments = inner.get("segments", []) + boundary = _typed_segments_to_polyline_v2(segments) + + # Circular hole detection: single full-circle arc + is_circular = False + center = None + diameter = None + if len(segments) == 1 and segments[0].get("type") == "arc": + seg = segments[0] + s = _as_xy(seg["start"]) + e = _as_xy(seg["end"]) + if abs(s[0] - e[0]) < 1e-8 and abs(s[1] - e[1]) < 1e-8: + is_circular = True + center = _as_xy(seg["center"]) + diameter = 2.0 * float(seg["radius"]) + + if center is None or diameter is None: + if len(boundary) >= 3: + poly = Polygon(boundary) + if poly.is_valid and not poly.is_empty: + c = poly.centroid + center = [float(c.x), float(c.y)] + minx, miny, maxx, maxy = poly.bounds + diameter = float(max(maxx - minx, maxy - miny)) + else: + arr = np.asarray(boundary, dtype=float) + center = [float(np.mean(arr[:, 0])), float(np.mean(arr[:, 1]))] + diameter = float(max(np.ptp(arr[:, 0]), np.ptp(arr[:, 1]))) + else: + center = [0.0, 0.0] + diameter = 1.0 + + return { + "index": int(inner.get("index", 0)), + "center": center, + "diameter": float(diameter), + "boundary": boundary, + "is_circular": bool(is_circular), + "weight": float(inner.get("weight", default_weight)), + } + + +def normalize_geometry_schema(geometry: Dict[str, Any]) -> Dict[str, Any]: + """Return geometry in legacy Brain format (outer_boundary + holes), preserving typed data.""" + schema_version = str(geometry.get("schema_version", "1.0")) + + if schema_version.startswith("1"): + out = deepcopy(geometry) + out.setdefault("holes", []) + return out + + if not schema_version.startswith("2"): + # Unknown schema: best effort fallback (assume legacy fields are present) + out = deepcopy(geometry) + out.setdefault("holes", []) + return out + + out = deepcopy(geometry) + typed_outer = out.get("outer_boundary_typed", []) + if typed_outer: + out["outer_boundary"] = _typed_segments_to_polyline_v2(typed_outer) + + inner_boundaries = out.get("inner_boundaries", []) + out["holes"] = [_inner_boundary_to_hole(inner) for inner in inner_boundaries] + + return out diff --git a/tools/adaptive-isogrid/src/brain/triangulation.py b/tools/adaptive-isogrid/src/brain/triangulation.py index 0a607a7c..7e77c837 100644 --- a/tools/adaptive-isogrid/src/brain/triangulation.py +++ b/tools/adaptive-isogrid/src/brain/triangulation.py @@ -21,6 +21,71 @@ from src.shared.arc_utils import inset_arc, typed_segments_to_polyline, typed_se from .density_field import evaluate_density, density_to_spacing +def _boundary_layer_offset_for_segment(mid_pt, geometry, params): + """Choose inward offset for boundary seed row.""" + explicit = params.get('boundary_layer_offset', None) + if explicit is not None: + return max(float(explicit), 0.0) + eta = evaluate_density(mid_pt[0], mid_pt[1], geometry, params) + return max(float(density_to_spacing(eta, params)), 1e-3) + + +def _add_boundary_layer_seed_points(points, geometry, params, plate_poly, keepout_union): + """Add a structured point row offset inward from each straight outer edge.""" + boundary_pts = [] + ring = LinearRing(geometry['outer_boundary']) + is_ccw = bool(ring.is_ccw) + + # Prefer typed segments to avoid treating discretized arcs as straight edges + typed = geometry.get('outer_boundary_typed') + if typed: + segments = [seg for seg in typed if seg.get('type', 'line') == 'line'] + edge_pairs = [(_np(seg['start']), _np(seg['end'])) for seg in segments] + else: + coords = np.asarray(geometry['outer_boundary'], dtype=float) + if len(coords) >= 2 and np.allclose(coords[0], coords[-1]): + coords = coords[:-1] + edge_pairs = [] + for i in range(len(coords)): + edge_pairs.append((coords[i], coords[(i + 1) % len(coords)])) + + for a, b in edge_pairs: + dx, dy = b[0] - a[0], b[1] - a[1] + edge_len = float(np.hypot(dx, dy)) + if edge_len < 1e-9: + continue + + mid = np.array([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5], dtype=float) + spacing = float(density_to_spacing(evaluate_density(mid[0], mid[1], geometry, params), params)) + spacing = max(spacing, 1e-3) + offset = _boundary_layer_offset_for_segment(mid, geometry, params) + + nx_l, ny_l = (-dy / edge_len), (dx / edge_len) + nx, ny = (nx_l, ny_l) if is_ccw else (-nx_l, -ny_l) + + n_pts = max(int(np.floor(edge_len / spacing)), 1) + for k in range(1, n_pts + 1): + t = k / (n_pts + 1) + bx = a[0] + t * dx + by = a[1] + t * dy + px = bx + offset * nx + py = by + offset * ny + p = Point(px, py) + if not plate_poly.buffer(1e-6).contains(p): + continue + if not keepout_union.is_empty and keepout_union.contains(p): + continue + boundary_pts.append([px, py]) + + if boundary_pts: + return np.vstack([points, np.asarray(boundary_pts, dtype=np.float64)]) + return points + + +def _np(pt): + return np.asarray([float(pt[0]), float(pt[1])], dtype=float) + + def _generate_hex_grid(bbox, base_spacing): """ Generate a regular hexagonal-packed point grid. @@ -267,6 +332,10 @@ def generate_triangulation(geometry, params, max_refinement_passes=3): # Add boundary-conforming vertices and get inset plate polygon for clipping all_pts, inner_plate = _add_boundary_vertices(grid_pts, geometry, params, keepout_union) + # Add structured boundary-layer seed row along straight edges + plate_poly = Polygon(geometry['outer_boundary']) + all_pts = _add_boundary_layer_seed_points(all_pts, geometry, params, plate_poly, keepout_union) + # Deduplicate close points all_pts = np.unique(np.round(all_pts, 4), axis=0)