Cuando se quiere transferir un dibujo a una superficie con spray, lo habitual es recortar una plantilla: se vacían las zonas que deben quedar pintadas y el material restante hace de máscara. El método funciona… hasta que el dibujo tiene islas.

Llamo islas a cualquier trozo de la imagen que no toca el borde: la rueda de un camión, el interior de una letra, un ojo. Esas piezas no están sujetas a nada y se caen. La solución clásica es añadir puentes: finas tiras de material que unen cada isla con el resto de la plantilla y la mantienen en su sitio. Es la misma idea que los puentes de los troquelados o de las maquetas de plástico.

El problema es que un puente es una zona que no se pinta. Y no una zona cualquiera: es una costura que no estaba en el dibujo original. Cuantos más puentes, más se aleja la pintura transferida de la imagen de partida. Al final la plantilla decide por ti dónde poner los cortes.

Se ve bien con un ejemplo: a la izquierda, el dibujo original; a la derecha, el mismo dibujo con los puentes que hacen falta para sujetar cada isla.

Bici: a la izquierda el dibujo sin puentes, a la derecha con los puentes que dejan costuras sin pintar

La idea de este experimento era quitar esos puentes del plano de pintura.

En vez de coser las islas entre sí, cada isla se ancla con unos nervios verticales que suben un tramo y luego convergen —como una nervadura gótica— hacia una bóveda común. Toda la sujeción vive por encima de la plantilla, fuera del plano por el que pasa el spray. De ahí lo de puentes aéreos.

La cara inferior queda plana y es solo el dibujo. La pieza se apoya sobre la superficie con esa cara hacia abajo, se le pone un peso encima y se pinta desde arriba. La pintura atraviesa los huecos exactos del diseño: ningún puente interrumpe el trazo, se reproduce la imagen íntegra.

La imagen de partida era este camión:

Imagen de partida: un camión en blanco y negro

Así se ve la pirámide de nervios que construye el script: cada anclaje sube, los nervios convergen y todo se funde en la bóveda superior donde apoyar el peso.

Pirámide de nervios que construye el script: los anclajes suben desde la plantilla y convergen hacia la bóveda superior

Todo sale como una única pieza imprimible en PLA sin soportes: los nervios nacen enterrados en cada isla, se funden en el árbol de convergencia y terminan en una bóveda plana y horizontal donde apoyar el peso. La plantilla y su estructura de sujeción son el mismo objeto, con la cara de la imagen en (z=0).

Y al pintar, esto es lo que queda: el trazo íntegro, sin las costuras que dejarían los puentes.

Simulación del pintado: el camión reproducido con spray a través de la plantilla, sin costuras de puentes

(Desarrollado con DeepSeek v4.1 Flash)

prompt.md

# Tarea

Tienes en esta carpeta todo lo necesario. Puedes crear un entorno Python local
(`python -m venv`) e instalar dependencias con pip (esto sí requiere red: está
autorizado). Para el resto NO necesitas mirar *NADA* a nivel de sistemas, ips,
puertos, servicios, etc. Así que *CUALQUIER COSA* que se salga de esta carpeta o
de instalar paquetes Python, piensa primero si realmente es necesario, si lo
puedes hacer aquí dentro y, en caso contrario, te paras y me explicas qué pasa.

## Objetivo

Escribir un script:

    $ python vineteado.py <config.yaml> <image.png>

que genere, a partir de una imagen B/N, un modelo 3D imprimible en PLA.

## Contexto de uso (léelo antes de diseñar nada)

La pieza resultante es una **plantilla de spray (stencil)**. El flujo real es:

1. La pieza se apoya sobre la superficie a pintar, con la cara de la imagen
   hacia abajo, plana y en contacto total.
2. Se coloca un peso encima de la estructura.
3. Se pinta con spray desde arriba: la pintura pasa por los huecos y reproduce
   el viñeteado en la superficie.
4. Se levanta la pieza.

De ahí se derivan casi todas las restricciones: por qué la cara inferior debe
ser perfectamente plana, por qué toda la estructura de sujeción va hacia arriba
y no puede invadir el plano de la imagen, y por qué arriba debe haber una
superficie plana donde apoyar el peso.

`truck.png` es un ejemplo. Asume imagen de 1 bit: **blanco = sólido, negro =
hueco**. Los blancos pueden formar islas desconectadas entre sí, así que extruir
el plano no basta: hay que sujetarlas.

## Invariantes (no negociables)

Estos son requisitos de resultado, no sugerencias. El diseño que propongas debe
satisfacerlos y debes poder demostrar que los satisface:

- **Formato**: STL binario o 3MF, en **milímetros**, cargable en Blender.
- **Malla**: cerrada (watertight), manifold, normales coherentes hacia fuera, y
  **una sola componente conexa**. Si el resultado tiene 2+ componentes, es un
  fallo, no un aviso.
- **Imprimibilidad sin soportes**: toda la geometría generada debe respetar el
  límite de voladizo de una FDM típica. Esto condiciona la forma en que los
  nervios convergen: no vale una curva "bonita" si el tramo final es demasiado
  horizontal. Si alguna zona no se puede resolver sin soporte, dímelo
  explícitamente en el informe en lugar de generarla igual.
- **Grosor mínimo realizable**: nada por debajo del mínimo imprimible con
  boquilla estándar, ni en los nervios ni en los detalles finos del stencil.
  Los detalles de la imagen más finos que ese umbral: decide qué hacer
  (engordar, eliminar, avisar) y justifícalo; lo que no vale es generarlos y
  que no se impriman.
- **Cara inferior**: plana, z=0, sin nada que sobresalga por debajo.
- **Cara superior de la bóveda**: plana y horizontal, con área suficiente para
  apoyar un peso de forma estable.
- **Determinismo**: misma entrada → misma salida, byte a byte.

## Estrategia sugerida (aquí sí eres libre)

Son pautas mías, no dogma. Puedes cambiarlas, reducirlas o ampliarlas siempre
que se cumplan los invariantes y el objetivo.

1. Cada isla necesita quedar fija en posición **y en orientación**. Para
   orientación hacen falta al menos tres puntos de anclaje no alineados; en
   islas alargadas (tipo plátano) tres puntos a lo largo del eje son casi
   colineales y no fijan el giro, así que ahí probablemente tenga más sentido
   una costilla continua a lo largo del eje que tres puntos.
2. El número, posición y grosor de los anclajes debe depender de la geometría de
   la isla: área, momentos, grosor local, alargamiento. Una isla grande
   probablemente prefiera muchos anclajes finos repartidos antes que tres
   gordos; valóralo tú.
3. Una isla muy pequeña puede acabar teniendo sentido con uno o dos puntos, o
   directamente extruida hacia arriba como pilar. Define el umbral y justifícalo.
4. Los nervios suben en vertical hasta una altura mínima (libre de spray) y
   luego convergen poco a poco, tipo nervadura de catedral gótica, hasta fundirse
   en una **bóveda común**. La rigidez del conjunto viene de esa bóveda: es un
   árbol convergente, no una malla de puentes horizontales entre islas vecinas.
   Si crees que añadir arcos horizontales entre islas adyacentes mejora la
   rigidez o reduce el voladizo, propónlo, pero decídelo tú y dímelo.
5. Capas mínimas: (a) la pieza/stencil con su espesor, (b) tramo vertical de los
   nervios hasta la altura mínima, (c) tramo de convergencia, (d) bóveda hasta
   la superficie plana superior.
6. Una hipótesis razonable es que la convergencia tenga como referencia una
   figura (círculo, óvalo, rectángulo) centrada a cierta altura, hacia la que
   apuntan todos los nervios. Evalúala; si no te convence, propón otra.
7. **Marco perimetral**: decide si conviene añadir un marco exterior opcional
   que rodee la imagen y sirva de anclaje estructural. Si el borde de la imagen
   es negro, sin marco toda la carga pasa por la bóveda. Es una decisión de
   diseño importante: razónala y déjala configurable si procede.

## Dimensionado de los nervios

Quiero que el grosor de cada nervio esté justificado, no puesto a ojo. Pero
**no quiero un cálculo con apariencia de rigor y supuestos inventados**.

El caso de carga que me importa es: la pieza se manipula agarrándola por la
bóveda y todo el conjunto cuelga (peso propio), más una componente lateral
razonable al despegarla de la superficie. Define tú el valor concreto de esa
componente y el factor de seguridad, pero **decláralos como supuestos tuyos de
forma explícita** en el informe y en el código.

Si concluyes que un modelo de fuerzas serio no aporta sobre una heurística
geométrica bien razonada (grosor en función de área/momento de la isla y
longitud del nervio), dímelo y usa la heurística. Prefiero una heurística honesta
a una viga de Euler-Bernoulli con constantes sacadas de la nada.

## Configuración

El esquema YAML lo decides tú. Requisitos:

- Como mínimo: anchura del sólido en cm (la altura sale de la relación de
  aspecto) y espesor del stencil. El resto, lo que necesites.
- Validación del fichero con mensajes de error claros (qué falta, qué rango).
- Defaults sensatos para todo excepto la anchura.
- Opción para volcar un config de ejemplo comentado.

## Salida e informe

El script debe emitir, además del modelo, un informe legible con:

- nº de islas, área y dimensiones de cada una
- puntos de anclaje elegidos por isla y por qué
- grosores calculados y supuestos usados
- comprobación de watertight / manifold / componentes conexas (usa trimesh o
  equivalente, no lo afirmes sin verificarlo)
- ángulo de voladizo máximo presente en la malla
- detalles del stencil descartados o engordados por estar bajo el mínimo
- volumen y gramos estimados de PLA
- nº de triángulos (vigila que sea manejable: simplifica contornos con
  tolerancia configurable en vez de sacar millones de triángulos)

Y además **PNGs de inspección**: proyecciones y cortes horizontales a varias
alturas. No tengo Blender a mano para cada iteración y necesito poder revisar
el resultado rápido.

## Procedimiento

1. Primero, **lee la imagen y analízala** (resolución, nº de islas, tamaños)
   antes de diseñar, para que el plan esté basado en datos reales.
2. Después, **enumera todas las ambigüedades y contradicciones que detectes en
   este documento y pregúntame**. No las resuelvas por tu cuenta.
3. Luego preséntame el plan.
4. Solo entonces, escribe código.

Código fuente

El script completo, vineteado.formateado.py:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""vineteado.py — genera un stencil de spray imprimible en PLA a partir de una
imagen B/N (blanco = solido, negro = hueco).

Uso:
    python vineteado.py <config.yaml> <image.png> [--out-dir DIR] [--only-analysis] [--mode tree|fan]
    python vineteado.py --example-config

Supuestos estructurales DECLARADOS (no FEA). El caso de carga es compresion de
la boveda repartida por el arbol; el fallo que gobierna nervios esbeltos es el
pandeo. Se calcula por nodo y se REPORTA como COTA INFERIOR: el modelo de dos
fuerzas ignora los momentos de las uniones rigidas y el pandeo global, y E en
FDM es anisotropo. El dimensionado primario es la heuristica geometrica por
area de isla.
"""

from __future__ import annotations

import argparse
import math
import os
import sys
from dataclasses import dataclass, field
from itertools import count
from typing import Any, Optional

import numpy as np
import yaml
from PIL import Image, ImageDraw

import scipy.ndimage as ndi
from skimage import measure, morphology, filters
from shapely.geometry import Polygon, MultiPolygon, box

import trimesh

PI = math.pi
PLA_DENSITY_G_CM3 = 1.24
Z_TOL = 1e-3
ANGLE_TOL = 0.05
STRUCT4 = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], bool)


class UserError(Exception):
    """Error de entrada o de geometria con mensaje claro para el usuario."""


# --- Configuracion ---------------------------------------------------------

@dataclass
class ImageCfg:
    threshold: Any = "otsu"
    invert: bool = False
    min_island_area_mm2: float = 1.0
    simplify_tol_mm: float = 0.15
    hole_fill_max_px: int = 2


@dataclass
class PartCfg:
    width_cm: Optional[float] = None
    stencil_thickness_mm: float = 1.2
    min_wall_mm: float = 0.8
    min_slot_mm: float = 0.6
    nozzle_mm: float = 0.4


@dataclass
class AnchorsCfg:
    max_spacing_mm: float = 15.0
    spacing_width_factor: float = 5.0
    max_anchors_per_island: int = 400
    anchor_margin_mm: float = 0.05


@dataclass
class RibsCfg:
    min_height_mm: float = 0.0
    width_auto: bool = True
    width_min_mm: float = 0.8
    width_max_mm: float = 3.0
    sections: int = 12
    slenderness_denom: float = 40.0


@dataclass
class StructuralCfg:
    press_force_n: float = 25.0
    safety_factor: float = 3.0
    e_pla_mpa: float = 3500.0
    k_effective: float = 1.0
    sigma_bearing_mpa: float = 50.0
    peel_check: bool = False


@dataclass
class ConvergenceCfg:
    mode: str = "tree"
    max_overhang_deg: float = 45.0
    optimize: bool = True
    merge_eps_mm: float = 0.5


@dataclass
class VaultCfg:
    shape: str = "rounded_rect"
    footprint_mm: float = 30.0
    top_area_mm2: float = 300.0
    n_sec: int = 32
    n_rings: int = 8


@dataclass
class FrameCfg:
    mode: str = "low"
    width_mm: float = 2.0
    extra_mm: float = 1.5


@dataclass
class OutputCfg:
    format: str = "stl"
    stl_binary: bool = True
    report: bool = True
    inspection_pngs: bool = True
    report_lang: str = "es"
    png_size_px: int = 1024
    strict: bool = False


@dataclass
class Config:
    image: ImageCfg = field(default_factory=ImageCfg)
    part: PartCfg = field(default_factory=PartCfg)
    anchors: AnchorsCfg = field(default_factory=AnchorsCfg)
    ribs: RibsCfg = field(default_factory=RibsCfg)
    structural: StructuralCfg = field(default_factory=StructuralCfg)
    convergence: ConvergenceCfg = field(default_factory=ConvergenceCfg)
    vault: VaultCfg = field(default_factory=VaultCfg)
    frame: FrameCfg = field(default_factory=FrameCfg)
    output: OutputCfg = field(default_factory=OutputCfg)
    raw: dict = field(default_factory=dict)


EXAMPLE_CONFIG = """\
# Configuracion de vineteado.py
# Solo `part.width_cm` es obligatorio; el resto tiene defaults sensatos.

image:
  threshold: otsu        # "otsu" o un entero 0-255
  invert: false          # true si el solido es el negro
  min_island_area_mm2: 1.0   # islas menores: se eliminan (y se avisa)
  simplify_tol_mm: 0.15      # tolerancia de simplificacion de contornos
  hole_fill_max_px: 2        # rellena poros encerrados de <= N px

part:
  width_cm:              # OBLIGATORIO: anchura fisica final de la pieza (cm)
  stencil_thickness_mm: 1.2
  min_wall_mm: 0.8       # grosor minimo imprimible (boquilla/PETG-PLA)
  min_slot_mm: 0.6       # por debajo: solo aviso
  nozzle_mm: 0.4

anchors:
  max_spacing_mm: 15.0   # paso maximo entre anclajes de una isla
  spacing_width_factor: 5.0  # paso efectivo = min(max_spacing, factor * ancho del nervio de la isla)
  max_anchors_per_island: 400   # tope de seguridad
  anchor_margin_mm: 0.05  # margen del cilindro respecto al borde de la isla

ribs:
  min_height_mm: 0.0     # h0; 0 => igual al espesor del stencil (cilindro <= base)
  width_auto: true
  width_min_mm: 0.8
  width_max_mm: 3.0
  sections: 12
  slenderness_denom: 40.0   # w >= L/denom (0 desactiva) para nervios no flojos

structural:                # SUPUESTOS DECLARADOS (ver informe)
  press_force_n: 25.0      # compresion al presionar la boveda
  safety_factor: 3.0
  e_pla_mpa: 3500.0
  k_effective: 1.0
  sigma_bearing_mpa: 50.0
  peel_check: false        # caso secundario de despegue (off)

convergence:
  mode: tree             # tree | fan (ver informe: se mide cual conviene)
  max_overhang_deg: 45.0
  optimize: true         # acerca cada nudo al centro y fusiona esferas proximas
  merge_eps_mm: 0.5      # fusiona un nudo interno con su padre si el nervio mide menos que esto

vault:
  shape: rounded_rect    # rounded_rect | ellipse
  footprint_mm: 30.0
  top_area_mm2: 300.0
  n_sec: 32
  n_rings: 8

frame:
  mode: low              # none | low | perimeter
  width_mm: 2.0
  extra_mm: 1.5

output:
  format: stl            # stl | 3mf
  stl_binary: true
  report: true
  inspection_pngs: true
  report_lang: es
  png_size_px: 1024
  strict: false
"""


def _num(sec: dict, key: str, default, lo=None, hi=None, integer=False, path=""):
    val = sec.get(key, default)
    if val is None:
        raise UserError(f"Falta '{path}{key}' (obligatorio).")
    try:
        fval = float(val)
    except (TypeError, ValueError):
        raise UserError(f"'{path}{key}' debe ser un numero (valor: {val!r}).")
    if not integer:
        if lo is not None and fval < lo:
            raise UserError(f"'{path}{key}' debe ser >= {lo} (valor: {fval}).")
        if hi is not None and fval > hi:
            raise UserError(f"'{path}{key}' debe ser <= {hi} (valor: {fval}).")
        return fval
    if abs(fval - round(fval)) > 1e-9:
        raise UserError(f"'{path}{key}' debe ser entero (valor: {val!r}).")
    ival = int(round(fval))
    if lo is not None and ival < lo:
        raise UserError(f"'{path}{key}' debe ser >= {lo} (valor: {ival}).")
    if hi is not None and ival > hi:
        raise UserError(f"'{path}{key}' debe ser <= {hi} (valor: {ival}).")
    return ival


def _sec(raw: dict, name: str, warnings: list) -> dict:
    val = raw.get(name, {})
    if val is None:
        return {}
    if not isinstance(val, dict):
        raise UserError(f"La seccion '{name}' debe ser un mapa YAML.")
    return val


def _check_unknown(sec: dict, known: set, path: str, warnings: list):
    for k in sec:
        if k not in known:
            warnings.append(f"Clave desconocida ignorada: {path}{k}")


def load_config(path: str) -> Config:
    if not os.path.isfile(path):
        raise UserError(f"No existe el fichero de configuracion: {path}")
    try:
        with open(path, "r", encoding="utf-8") as fh:
            raw = yaml.safe_load(fh)
    except yaml.YAMLError as exc:
        raise UserError(f"YAML mal formado: {exc}")
    if raw is None:
        raise UserError("El fichero de configuracion esta vacio.")
    if not isinstance(raw, dict):
        raise UserError("La raiz del YAML debe ser un mapa de secciones.")

    warnings: list = []
    cfg = Config(raw=raw)

    # image
    si = _sec(raw, "image", warnings)
    _check_unknown(si, {"threshold", "invert", "min_island_area_mm2", "simplify_tol_mm",
                        "hole_fill_max_px"}, "image.", warnings)
    thr = si.get("threshold", "otsu")
    if isinstance(thr, str):
        if thr.lower() != "otsu":
            raise UserError("image.threshold debe ser 'otsu' o un entero 0-255.")
        cfg.image.threshold = "otsu"
    else:
        cfg.image.threshold = int(_num(si, "threshold", "otsu", 0, 255, integer=True, path="image."))
    cfg.image.invert = bool(si.get("invert", False))
    cfg.image.min_island_area_mm2 = _num(si, "min_island_area_mm2", 1.0, 0.0, path="image.")
    cfg.image.simplify_tol_mm = _num(si, "simplify_tol_mm", 0.15, 0.0, path="image.")
    cfg.image.hole_fill_max_px = _num(si, "hole_fill_max_px", 2, 0, integer=True, path="image.")

    # part
    sp = _sec(raw, "part", warnings)
    _check_unknown(sp, {"width_cm", "stencil_thickness_mm", "min_wall_mm", "min_slot_mm",
                        "nozzle_mm"}, "part.", warnings)
    if sp.get("width_cm") is None:
        raise UserError("Falta 'part.width_cm' (anchura fisica final en cm). Es el unico campo obligatorio.")
    cfg.part.width_cm = _num(sp, "width_cm", None, 1.0, 200.0, path="part.")
    cfg.part.stencil_thickness_mm = _num(sp, "stencil_thickness_mm", 1.2, 0.2, 50.0, path="part.")
    cfg.part.min_wall_mm = _num(sp, "min_wall_mm", 0.8, 0.2, 20.0, path="part.")
    cfg.part.min_slot_mm = _num(sp, "min_slot_mm", 0.6, 0.0, 20.0, path="part.")
    cfg.part.nozzle_mm = _num(sp, "nozzle_mm", 0.4, 0.1, 5.0, path="part.")
    if cfg.part.min_wall_mm < cfg.part.nozzle_mm - 1e-9:
        warnings.append("part.min_wall_mm < nozzle_mm: puede no imprimirse.")
    if cfg.part.stencil_thickness_mm < cfg.part.min_wall_mm:
        warnings.append("part.stencil_thickness_mm < min_wall_mm: revisar.")

    # anchors
    sa = _sec(raw, "anchors", warnings)
    _check_unknown(sa, {"max_spacing_mm", "spacing_width_factor", "max_anchors_per_island",
                        "anchor_margin_mm"}, "anchors.", warnings)
    cfg.anchors.max_spacing_mm = _num(sa, "max_spacing_mm", 15.0, 0.5, 200.0, path="anchors.")
    cfg.anchors.spacing_width_factor = _num(sa, "spacing_width_factor", 5.0, 0.5, 50.0, path="anchors.")
    cfg.anchors.max_anchors_per_island = _num(sa, "max_anchors_per_island", 400, 1, 5000,
                                              integer=True, path="anchors.")
    cfg.anchors.anchor_margin_mm = _num(sa, "anchor_margin_mm", 0.05, 0.0, 5.0, path="anchors.")

    # ribs
    sr = _sec(raw, "ribs", warnings)
    _check_unknown(sr, {"min_height_mm", "width_auto", "width_min_mm", "width_max_mm", "sections",
                        "slenderness_denom"}, "ribs.", warnings)
    cfg.ribs.min_height_mm = _num(sr, "min_height_mm", 0.0, 0.0, 100.0, path="ribs.")
    cfg.ribs.width_auto = bool(sr.get("width_auto", True))
    cfg.ribs.width_min_mm = _num(sr, "width_min_mm", 0.8, 0.2, 20.0, path="ribs.")
    cfg.ribs.width_max_mm = _num(sr, "width_max_mm", 3.0, 0.2, 50.0, path="ribs.")
    cfg.ribs.sections = _num(sr, "sections", 12, 6, 64, integer=True, path="ribs.")
    cfg.ribs.slenderness_denom = _num(sr, "slenderness_denom", 40.0, 0.0, 1000.0, path="ribs.")
    if cfg.ribs.width_min_mm > cfg.ribs.width_max_mm:
        raise UserError("ribs.width_min_mm > ribs.width_max_mm.")

    # structural
    ss = _sec(raw, "structural", warnings)
    _check_unknown(ss, {"press_force_n", "safety_factor", "e_pla_mpa", "k_effective",
                        "sigma_bearing_mpa", "peel_check"}, "structural.", warnings)
    cfg.structural.press_force_n = _num(ss, "press_force_n", 25.0, 0.001, path="structural.")
    cfg.structural.safety_factor = _num(ss, "safety_factor", 3.0, 1.0, path="structural.")
    cfg.structural.e_pla_mpa = _num(ss, "e_pla_mpa", 3500.0, 1.0, path="structural.")
    cfg.structural.k_effective = _num(ss, "k_effective", 1.0, 0.1, path="structural.")
    cfg.structural.sigma_bearing_mpa = _num(ss, "sigma_bearing_mpa", 50.0, 0.1, path="structural.")
    cfg.structural.peel_check = bool(ss.get("peel_check", False))

    # convergence
    sc = _sec(raw, "convergence", warnings)
    _check_unknown(sc, {"mode", "max_overhang_deg", "optimize", "merge_eps_mm"}, "convergence.", warnings)
    mode = str(sc.get("mode", "tree")).lower()
    if mode not in ("tree", "fan"):
        raise UserError("convergence.mode debe ser 'tree' o 'fan'.")
    cfg.convergence.mode = mode
    cfg.convergence.max_overhang_deg = _num(sc, "max_overhang_deg", 45.0, 1.0, 80.0, path="convergence.")
    cfg.convergence.optimize = bool(sc.get("optimize", True))
    cfg.convergence.merge_eps_mm = _num(sc, "merge_eps_mm", 0.5, 0.0, 20.0, path="convergence.")

    # vault
    sv = _sec(raw, "vault", warnings)
    _check_unknown(sv, {"shape", "footprint_mm", "top_area_mm2", "n_sec", "n_rings"}, "vault.", warnings)
    shape = str(sv.get("shape", "rounded_rect")).lower()
    if shape not in ("rounded_rect", "ellipse"):
        raise UserError("vault.shape debe ser 'rounded_rect' o 'ellipse'.")
    cfg.vault.shape = shape
    cfg.vault.footprint_mm = _num(sv, "footprint_mm", 30.0, 1.0, path="vault.")
    cfg.vault.top_area_mm2 = _num(sv, "top_area_mm2", 300.0, 1.0, path="vault.")
    cfg.vault.n_sec = _num(sv, "n_sec", 32, 8, 256, integer=True, path="vault.")
    cfg.vault.n_rings = _num(sv, "n_rings", 8, 2, 64, integer=True, path="vault.")

    # frame
    sf = _sec(raw, "frame", warnings)
    _check_unknown(sf, {"mode", "width_mm", "extra_mm"}, "frame.", warnings)
    fmode = str(sf.get("mode", "low")).lower()
    if fmode not in ("none", "low", "perimeter"):
        raise UserError("frame.mode debe ser 'none', 'low' o 'perimeter'.")
    cfg.frame.mode = fmode
    cfg.frame.width_mm = _num(sf, "width_mm", 2.0, 0.2, 50.0, path="frame.")
    cfg.frame.extra_mm = _num(sf, "extra_mm", 1.5, 0.0, 50.0, path="frame.")

    # output
    so = _sec(raw, "output", warnings)
    _check_unknown(so, {"format", "stl_binary", "report", "inspection_pngs", "report_lang",
                        "png_size_px", "strict"}, "output.", warnings)
    fmt = str(so.get("format", "stl")).lower()
    if fmt not in ("stl", "3mf"):
        raise UserError("output.format debe ser 'stl' o '3mf'.")
    cfg.output.format = fmt
    cfg.output.stl_binary = bool(so.get("stl_binary", True))
    cfg.output.report = bool(so.get("report", True))
    cfg.output.inspection_pngs = bool(so.get("inspection_pngs", True))
    cfg.output.report_lang = str(so.get("report_lang", "es"))
    cfg.output.png_size_px = _num(so, "png_size_px", 1024, 128, 8192, integer=True, path="output.")
    cfg.output.strict = bool(so.get("strict", False))

    _check_unknown(raw, {"image", "part", "anchors", "ribs", "structural", "convergence", "vault",
                         "frame", "output"}, "", warnings)
    cfg.raw["_warnings"] = warnings
    return cfg


# --- Modelos de datos ------------------------------------------------------

@dataclass
class Island:
    label: int
    area_mm2: float
    area_px: int
    centroid_xy: tuple
    bbox_px: tuple
    dims_mm: tuple
    elong: float
    major_dir_xy: tuple
    min_thick_mm: float
    med_thick_mm: float
    has_holes: bool
    perimeter_mm: float
    outline_xy: np.ndarray


@dataclass
class Anchor:
    x: float
    y: float
    row: int
    col: int
    island_label: int
    kind: str
    width_mm: float
    max_width_mm: float = 0.0


@dataclass
class Node:
    xy: tuple
    z: float
    width_mm: float
    cluster: int
    subtree_area_mm2: float
    load_n: float = 0.0
    p_cr_n: float = 0.0
    p_tope_n: float = 0.0
    margin: float = float("inf")
    label: str = ""
    max_width_mm: float = float("inf")

    @property
    def xyz(self):
        return (self.xy[0], self.xy[1], self.z)


@dataclass
class Strut:
    child: Node
    parent: Node
    width_mm: float

    @property
    def length(self):
        return _dist3(self.child.xyz, self.parent.xyz)


@dataclass
class Tree:
    nodes: list
    struts: list
    root: Node
    mode: str
    total_length_mm: float
    total_material_mm3: float
    max_margin: float
    min_margin: float


def _dist(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])


def _dist3(a, b):
    return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)


# --- Estructural (supuestos declarados) ------------------------------------

def p_crit(w: float, L: float, E: float, K: float) -> float:
    if L <= 0 or w <= 0:
        return float("inf")
    I = PI * w ** 4 / 64.0
    return PI ** 2 * E * I / (K * L) ** 2


def p_bearing(w: float, sigma: float) -> float:
    return sigma * PI * w ** 2 / 4.0


def w_buckling(P: float, L: float, E: float, K: float, FS: float) -> float:
    if P <= 0:
        return 0.0
    val = 64.0 * FS * P * (K * L) ** 2 / (PI ** 3 * E)
    return val ** 0.25 if val > 0 else 0.0


def w_crush(P: float, sigma: float, FS: float) -> float:
    if P <= 0:
        return 0.0
    val = 4.0 * FS * P / (PI * sigma)
    return math.sqrt(val) if val > 0 else 0.0


def w_required(P: float, L: float, cfg: Config) -> float:
    s = cfg.structural
    return max(w_buckling(P, L, s.e_pla_mpa, s.k_effective, s.safety_factor),
               w_crush(P, s.sigma_bearing_mpa, s.safety_factor))


def island_nominal_width(area_mm2: float, cfg: Config) -> float:
    A_ref = 50.0
    w = 1.2 * (1.0 + 0.5 * math.log10(max(area_mm2, 1e-6) / A_ref))
    w = max(w, cfg.part.min_wall_mm)
    if cfg.ribs.width_auto:
        w = min(max(w, cfg.ribs.width_min_mm), cfg.ribs.width_max_mm)
        w = max(w, cfg.part.min_wall_mm)
    return w


def _effective_h0(cfg: Config) -> float:
    """Altura del tramo vertical de los anclajes.

    0 (por defecto) significa 'igual al espesor del stencil', de modo que el cilindro del anclaje
    nunca sobresale por encima de la base.
    """
    return max(cfg.part.stencil_thickness_mm, cfg.ribs.min_height_mm)


# --- Etapa A — imagen a mascara --------------------------------------------

def load_gray(path: str) -> np.ndarray:
    if not os.path.isfile(path):
        raise UserError(f"No existe la imagen: {path}")
    try:
        img = Image.open(path)
    except Exception as exc:
        raise UserError(f"No se puede abrir la imagen {path}: {exc}")
    return np.asarray(img.convert("L"), dtype=np.uint8)


def _ridge_pixels(skel: np.ndarray, edt: np.ndarray) -> np.ndarray:
    ridge = skel & (edt >= ndi.maximum_filter(edt, size=3) - 1e-9)
    return ridge if ridge.any() else skel


def _remove_small_islands(solid, min_px):
    lbl, n = ndi.label(solid, structure=STRUCT4)
    removed = 0
    removed_area = 0
    if n > 0:
        counts = ndi.sum(np.ones_like(lbl), lbl, index=np.arange(1, n + 1))
        for i, c in enumerate(counts, start=1):
            if c < min_px:
                solid[lbl == i] = False
                removed += 1
                removed_area += int(c)
    return solid, removed, removed_area


def binarize_and_clean(gray: np.ndarray, cfg: Config, mm_px: float, log) -> tuple:
    W = gray.shape[1]
    rep: dict = {"img_shape": gray.shape}
    thr = cfg.image.threshold
    if thr == "otsu":
        thr_val = float(filters.threshold_otsu(gray))
        rep["threshold_mode"] = "otsu"
    else:
        thr_val = float(thr)
        rep["threshold_mode"] = f"fijo={thr}"
    rep["threshold"] = thr_val
    # Convencion Otsu: el umbral t separa [0..t] (fondo) de [t+1..255] (solido); por eso se
    # compara con > y no con >=. Importante en imagenes ya binarias (0/255), donde Otsu
    # devuelve 0 y >= marcaria el fondo entero como solido.
    if gray.min() == gray.max():
        # Imagen constante: Otsu no tiene sentido (devolveria el propio valor). Se decide por
        # claridad: valor alto -> todo solido, valor bajo -> todo hueco.
        solid = np.full(gray.shape, bool(gray.flat[0] > 127))
        rep["threshold_mode"] += " (imagen constante)"
    else:
        solid = gray > thr_val
    if cfg.image.invert:
        solid = ~solid
    rep["solid_pct"] = 100.0 * solid.mean()

    # relleno de poros encerrados pequenos
    if cfg.image.hole_fill_max_px > 0:
        black = ~solid
        lbl_b, nb = ndi.label(black, structure=np.ones((3, 3)))
        border_ids = set(np.unique(np.concatenate([lbl_b[0, :], lbl_b[-1, :],
                                                   lbl_b[:, 0], lbl_b[:, -1]])))
        filled_px = 0
        for i in range(1, nb + 1):
            if i in border_ids:
                continue
            m = lbl_b == i
            a = int(m.sum())
            if a <= cfg.image.hole_fill_max_px:
                solid[m] = True
                filled_px += a
        rep["poros_rellenados_px"] = filled_px

    # eliminacion de islas diminutas
    min_px = cfg.image.min_island_area_mm2 / (mm_px ** 2)
    solid, removed, removed_area = _remove_small_islands(solid, min_px)
    n = ndi.label(solid, structure=STRUCT4)[1]
    rep["islas_eliminadas_peq"] = removed
    rep["area_eliminada_peq_px"] = removed_area

    # minimo imprimible: apertura morfologica (fuera = solido, no erosiona borde). El disco se
    # toma con radio ENTERO por exceso (ceil) para que la cota garantizada 2*r_int >= min_wall.
    r_px = cfg.part.min_wall_mm / 2.0 / mm_px
    before_area = int(solid.sum())
    r_int = 0
    if r_px >= 0.5:
        r_int = int(math.ceil(r_px))
        footprint = morphology.disk(r_int)
        pad = r_int + 1
        padded = np.pad(solid, pad, constant_values=True)
        padded = morphology.opening(padded, footprint)
        solid = padded[pad:-pad, pad:-pad]
        solid, rem2, area2 = _remove_small_islands(solid, min_px)
        rep["islas_eliminadas_post"] = rem2
        rep["area_eliminada_post_px"] = area2
    rep["apertura_r_px"] = r_px
    rep["apertura_r_px_entero"] = r_int
    rep["ancho_garantizado_mm"] = 2.0 * r_int * mm_px
    rep["area_antes_apertura_px"] = before_area
    rep["area_despues_apertura_px"] = int(solid.sum())
    n_after = ndi.label(solid, structure=STRUCT4)[1]
    rep["islas_antes_apertura"] = n
    rep["islas_despues_apertura"] = n_after

    # grosores resultantes (cresta real: maximo local sobre el esqueleto, descarta extremos)
    edt = ndi.distance_transform_edt(solid)
    skel = morphology.skeletonize(solid)
    if skel.any():
        ridge = _ridge_pixels(skel, edt)
        vals = 2.0 * edt[ridge] * mm_px
        rep["grosor_min_mm_post"] = float(vals.min())
        rep["grosor_p5_mm_post"] = float(np.percentile(vals, 5))
        rep["grosor_med_mm_post"] = float(np.median(vals))

    # huecos (negro) finos: solo aviso
    void = ~solid
    if void.any():
        edt_v = ndi.distance_transform_edt(void)
        skel_v = morphology.skeletonize(void)
        if skel_v.any():
            ridge_v = _ridge_pixels(skel_v, edt_v)
            sv = 2.0 * edt_v[ridge_v] * mm_px
            rep["hueco_min_mm"] = float(sv.min())
            rep["hueco_p5_mm"] = float(np.percentile(sv, 5))
            rep["huecos_bajo_min_pct"] = float(100.0 * (sv < cfg.part.min_slot_mm).mean())
    return solid, rep


# --- Escala ----------------------------------------------------------------

def compute_scale(gray: np.ndarray, cfg: Config) -> dict:
    H, W = gray.shape
    mm_px = cfg.part.width_cm * 10.0 / W
    return {"mm_px": mm_px, "width_mm": cfg.part.width_cm * 10.0, "height_mm": H * mm_px,
            "W_px": W, "H_px": H}


def px_to_xy(row, col, H, mm_px):
    return (np.asarray(col) * mm_px, (H - 1 - np.asarray(row)) * mm_px)


# --- Etapa B — poligonos (con jerarquia exterior/agujero) ------------------

def _explode(geom):
    if geom is None or geom.is_empty:
        return
    if isinstance(geom, Polygon):
        yield geom
    elif isinstance(geom, MultiPolygon):
        for g in geom.geoms:
            yield g
    elif hasattr(geom, "geoms"):
        for g in geom.geoms:
            yield from _explode(g)


def extract_polygons(mask: np.ndarray, scale: dict, cfg: Config, log) -> tuple:
    H, W = mask.shape
    mm_px = scale["mm_px"]
    tol_px = max(cfg.image.simplify_tol_mm / mm_px, 0.05)
    padded = np.zeros((H + 2, W + 2), dtype=np.uint8)
    padded[1:-1, 1:-1] = mask.astype(np.uint8)
    contours = measure.find_contours(padded, 0.5)
    polys = []
    raw_pts = 0
    for c in contours:
        raw_pts += len(c)
        if len(c) < 4:
            continue
        simp = measure.approximate_polygon(c, tolerance=tol_px)
        if len(simp) < 4:
            continue
        rr = simp[:, 0] - 1.0
        cc = simp[:, 1] - 1.0
        x, y = px_to_xy(rr, cc, H, mm_px)
        p = Polygon(np.column_stack([x, y]))
        if not p.is_valid:
            p = p.buffer(0)
        for g in _explode(p):
            if g.area > 0:
                polys.append(g)

    n = len(polys)
    order = sorted(range(n), key=lambda i: polys[i].area, reverse=True)
    depth = [0] * n
    parent = [-1] * n
    for ii, idx in enumerate(order):
        rep = polys[idx].representative_point()
        d = 0
        par = -1
        for jj in range(ii):
            jdx = order[jj]
            if polys[jdx].contains(rep):
                d += 1
                par = jdx
        depth[idx] = d
        parent[idx] = par

    islands = []
    for idx in range(n):
        if depth[idx] % 2 != 0:
            continue
        holes = [polys[j].exterior.coords[:] for j in range(n)
                 if parent[j] == idx and depth[j] % 2 == 1]
        try:
            poly = Polygon(polys[idx].exterior.coords[:], holes)
            if not poly.is_valid:
                poly = poly.buffer(0)
        except Exception:
            poly = polys[idx]
        for g in _explode(poly):
            if g.area > 0:
                islands.append(g)

    stats = {"n_contornos": len(contours), "puntos_crudos": raw_pts, "n_poligonos": len(islands),
             "vertices": sum(len(g.exterior.coords) for g in islands) +
                         sum(len(r.coords) for g in islands for r in g.interiors)}
    log(f"  poligonos: {len(islands)}  vertices: {stats['vertices']} "
        f"(tol {cfg.image.simplify_tol_mm} mm)")
    return islands, stats


# --- Etapa C — analisis de islas --------------------------------------------

def analyze_islands(mask: np.ndarray, scale: dict, cfg: Config, log) -> list:
    H, W = mask.shape
    mm_px = scale["mm_px"]
    labels, n = ndi.label(mask, structure=STRUCT4)
    edt = ndi.distance_transform_edt(mask)
    skel = morphology.skeletonize(mask)
    out = []
    for lab in range(1, n + 1):
        m = labels == lab
        area_px = int(m.sum())
        if area_px == 0:
            continue
        coords = np.argwhere(m)  # (row, col)
        cr, cc = coords.mean(axis=0)
        rmin, cmin = coords.min(axis=0)
        rmax, cmax = coords.max(axis=0)
        cen = coords - coords.mean(axis=0)
        cov = (cen.T @ cen) / max(len(cen), 1)
        vals, vecs = np.linalg.eigh(cov)
        lam2 = max(vals[0], 1e-9)
        lam1 = max(vals[1], 1e-9)
        elong = math.sqrt(lam1 / lam2)
        major = vecs[:, 1]  # (row, col)
        sk = skel & m
        if sk.any():
            tv = 2.0 * edt[sk]
            min_t = float(tv.min())
            med_t = float(np.median(tv))
        else:
            min_t = med_t = 2.0 * float(edt[m].max())
        filled = ndi.binary_fill_holes(m)
        has_holes = bool(filled.sum() > m.sum())
        perimeter = float(measure.perimeter(m.astype(float), neighborhood=8)) * mm_px
        x_c, y_c = px_to_xy(cr, cc, H, mm_px)
        out.append(Island(
            label=lab, area_mm2=area_px * mm_px ** 2, area_px=area_px,
            centroid_xy=(float(x_c), float(y_c)),
            bbox_px=(int(rmin), int(cmin), int(rmax), int(cmax)),
            dims_mm=((cmax - cmin + 1) * mm_px, (rmax - rmin + 1) * mm_px),
            elong=elong, major_dir_xy=(float(major[1]), float(-major[0])),
            min_thick_mm=min_t * mm_px, med_thick_mm=med_t * mm_px, has_holes=has_holes,
            perimeter_mm=perimeter, outline_xy=np.zeros((0, 2))))
    log(f"  islas (4-conexas): {len(out)}")
    return out


# --- Etapa D — anclajes ----------------------------------------------------

def _max_edt_pixel(m, edt):
    vals = np.where(m, edt, -1.0)
    idx = np.argmax(vals)
    return np.unravel_index(idx, m.shape)


def select_anchors(mask, islands, scale, cfg, log) -> tuple:
    """Coloca anclajes por cobertura.

    En cada isla (incluida la exterior) se siembran puntos de forma que todo punto de la isla
    quede a <= un paso de uno de ellos. El paso de una isla es
    min(anchors.max_spacing_mm, anchors.spacing_width_factor * ancho_nominal), de modo que las
    islas grandes (nervio mas grueso) reciben mas puntos. El primer punto va en el pixel de mayor
    EDT (mas centrado) y los siguientes en el pixel elegible mas alejado del conjunto ya sembrado.
    El radio de cada anclaje se limita con la EDT local, asi ningun cilindro sale de la isla.
    """
    H, W = mask.shape
    mm_px = scale["mm_px"]
    labels, _ = ndi.label(mask, structure=STRUCT4)
    edt = ndi.distance_transform_edt(mask)
    a_marg = cfg.anchors.anchor_margin_mm
    K = cfg.anchors.spacing_width_factor
    half_min_px = max(cfg.part.nozzle_mm / 2.0, cfg.part.min_wall_mm / 2.0) / mm_px
    cap = cfg.anchors.max_anchors_per_island
    anchors = []
    just = []
    w_by_label = {}
    rib_paths = {}
    for isl in islands:
        lab = isl.label
        m = labels == lab
        w_nom = island_nominal_width(isl.area_mm2, cfg)
        w_by_label[lab] = w_nom
        spacing_mm = min(cfg.anchors.max_spacing_mm, K * w_nom)
        spacing_px = spacing_mm / mm_px
        r0, c0, r1, c1 = (int(v) for v in isl.bbox_px)
        sub = m[r0:r1 + 1, c0:c1 + 1]
        edt_sub = edt[r0:r1 + 1, c0:c1 + 1]
        elig = sub & (edt_sub >= half_min_px)
        seeds = []
        if sub.any():
            pr, pc = _max_edt_pixel(sub, edt_sub)
            seeds.append((int(pr), int(pc)))
        while seeds and len(seeds) < cap:
            seedmask = np.zeros(sub.shape, bool)
            sarr = np.array(seeds, dtype=int)
            seedmask[sarr[:, 0], sarr[:, 1]] = True
            d = ndi.distance_transform_edt(~seedmask)
            d_sub = np.where(sub, d, -1.0)
            if d_sub.max() <= spacing_px:
                break
            d_el = np.where(elig, d, -1.0)
            if not (d_el > 0.0).any():
                break
            idx = int(np.argmax(d_el))
            seeds.append(tuple(int(v) for v in np.unravel_index(idx, sub.shape)))
        n_ok = 0
        for (pr, pc) in seeds:
            r_local = float(edt_sub[pr, pc]) * mm_px
            r_use = min(r_local * 0.98, max(r_local - a_marg, r_local * 0.5))
            width = min(w_nom, 2.0 * r_use)
            if width <= 0.0:
                continue
            x, y = px_to_xy(pr + r0, pc + c0, H, mm_px)
            anchors.append(Anchor(float(x), float(y), int(pr + r0), int(pc + c0), lab,
                                  "cobertura", width, 2.0 * r_local))
            n_ok += 1
        just.append({
            "label": lab, "area": isl.area_mm2, "elong": isl.elong, "width": w_nom, "n": n_ok,
            "kind": (f"cobertura (paso {spacing_mm:g} mm = min({cfg.anchors.max_spacing_mm:g}, "
                     f"{K:g}x ancho {w_nom:.2f} mm)): {n_ok} anclajes, radio limitado por el "
                     f"ancho local")})
    log(f"  anclajes: {len(anchors)} (cobertura; paso = min("
        f"{cfg.anchors.max_spacing_mm:g} mm, {cfg.anchors.spacing_width_factor:g}x ancho de "
        f"nervio); w en [{cfg.ribs.width_min_mm}, {cfg.ribs.width_max_mm}] mm)")
    return anchors, just, w_by_label, rib_paths


# --- Etapa E — placa, risers, marco ----------------------------------------

def build_plate(polygons, cfg, log):
    t = cfg.part.stencil_thickness_mm
    meshes = []
    for p in polygons:
        try:
            m = trimesh.creation.extrude_polygon(p, height=t)
        except Exception as exc:
            log(f"  aviso: poligono no extruible ({exc}); se omite")
            continue
        if len(m.faces) > 0:
            meshes.append(m)
    return meshes


def build_risers(anchors, cfg, tree=None):
    """Un unico cilindro por anclaje, nunca mas alto que la base.

    Altura h0 = max(espesor del stencil, ribs.min_height_mm); con el default (min_height_mm = 0)
    coincide con el espesor del stencil. El radio se toma del nervio que arranca en ese anclaje
    (ya limitado por la EDT local) para tapar su tapa inferior sin que el cilindro salga de la isla.
    """
    h0 = _effective_h0(cfg)
    sec = cfg.ribs.sections
    leaf_w = {}
    if tree is not None:
        has_kids = {id(s.parent) for s in tree.struts}
        for s in tree.struts:
            if id(s.child) in has_kids:
                continue
            k = (round(s.child.xy[0], 3), round(s.child.xy[1], 3))
            leaf_w[k] = max(leaf_w.get(k, 0.0), s.width_mm)
    out = []
    for a in anchors:
        k = (round(a.x, 3), round(a.y, 3))
        w = max(a.width_mm, leaf_w.get(k, 0.0))
        r = w / 2.0
        m = trimesh.creation.cylinder(radius=r, height=h0, sections=sec)
        m.apply_translation([a.x, a.y, h0 / 2.0])
        out.append(m)
    return out


def build_frame(polygons, scale, cfg, log):
    if cfg.frame.mode == "none":
        return []
    bg = None
    for p in polygons:
        b = p.bounds
        if (abs(b[0]) < 0.5 * scale["mm_px"] + 1e-6 and
                abs(b[1]) < 0.5 * scale["mm_px"] + 1e-6 and
                abs(b[2] - scale["width_mm"]) < 0.5 * scale["mm_px"] + 1e-6 and
                abs(b[3] - scale["height_mm"]) < 0.5 * scale["mm_px"] + 1e-6):
            bg = p
            break
    if bg is None:
        return []
    fw = cfg.frame.width_mm
    ext = Polygon(bg.exterior.coords)
    inner = ext.buffer(-fw, join_style=1, cap_style=2)
    if inner.is_empty:
        return []
    ring = ext.difference(inner).intersection(bg)
    if ring.is_empty:
        return []
    h = (cfg.part.stencil_thickness_mm + cfg.frame.extra_mm
         if cfg.frame.mode == "low" else _effective_h0(cfg))
    out = []
    for g in _explode(ring):
        try:
            out.append(trimesh.creation.extrude_polygon(g, height=h))
        except Exception:
            continue
    return out


# --- Etapa F — arbol / abanico y struts ------------------------------------

def _make_node(xy, z, width, cluster, area, label="", max_width=float("inf")):
    return Node(xy=xy, z=z, width_mm=width, cluster=cluster, subtree_area_mm2=area,
                label=label, max_width_mm=max_width)


def _slender(L, cfg):
    d = cfg.ribs.slenderness_denom
    return (L / d) if d and d > 0 else 0.0


def _leaf_tilt(parent_xyz, child_xyz):
    dx = parent_xyz[0] - child_xyz[0]
    dy = parent_xyz[1] - child_xyz[1]
    dz = parent_xyz[2] - child_xyz[2]
    if dz <= 1e-9:
        return None, 0.0
    return math.atan2(math.hypot(dx, dy), dz), dz


def _cone_half_angle(R1, R2, d):
    """Semiangulo del tronco de cono tangente (ver cono_truncado.md).

    Solo penaliza si el cono se ensancha hacia el padre (R2 > R1).
    """
    if d <= 1e-9 or R2 <= R1:
        return 0.0
    if d > R1 + R2 and d * d - R2 * R2 > 1e-9:
        S = math.sqrt(d * d + R2 * R2 - R1 * R1)
        m = (R2 * S - d * R1) / (d * d - R2 * R2)
    else:
        m = (R2 - R1) / d
    return math.atan(max(0.0, m))


def _min_parent_z(run, child_z, R1, R2, limit_rad):
    """Menor z del padre para que (inclinacion + semiangulo del cono) no supere ``limit_rad``;
    asi la cara inferior del tronco no es voladizo."""
    if run <= 1e-9:
        return child_z
    tanT = max(math.tan(limit_rad), 1e-6)
    z = child_z + run / tanT
    step = max(0.25, 0.05 * run)
    for _ in range(500):
        dz = z - child_z
        d = math.hypot(run, dz)
        tilt = math.atan2(run, dz)
        if tilt + _cone_half_angle(R1, R2, d) <= limit_rad:
            break
        z += step
    return z


def _cap_leaf_struts(struts, cfg):
    """Limita el radio de los nervios-hoja para que su tapa inferior quede enterrada en la placa
    (no genera voladizo) sin salirse de la isla.

    El nervio se prolonga hacia abajo una longitud d = r*tan(a) para que su tapa quede por debajo
    del plano z=t; su extension horizontal maxima es r*(1 + sin^2(a)/cos(a)) = r*f(a). Exigimos
    r*f(a) <= 0.98 * r_local, donde r_local = max_width_mm/2 es la mayor circunferencia inscrita
    en el anclaje, y r*sin(a) <= h0 para no atravesar la cama.
    """
    h0 = _effective_h0(cfg)
    parents = {id(s.parent) for s in struts}
    for s in struts:
        if id(s.child) in parents:
            continue
        mw = s.child.max_width_mm
        if not math.isfinite(mw) or mw <= 0:
            continue
        a, _ = _leaf_tilt(s.parent.xyz, s.child.xyz)
        if a is None:
            continue
        ca = math.cos(a)
        sa = math.sin(a)
        if ca < 1e-6:
            continue
        f = 1.0 + sa * sa / ca
        rmax = 0.98 * (mw / 2.0) / f
        if sa > 1e-9:
            rmax = min(rmax, max(0.0, (h0 - 0.05) / (2.0 * sa)))
        r = max(min(s.width_mm / 2.0, rmax), 0.05)
        s.width_mm = 2.0 * r


def _enforce_cone_overhang(all_nodes, struts, limit_rad):
    """Eleva los nudos internos lo necesario para que la cara inferior de cada tronco de cono
    (inclinacion + semiangulo) no supere ``limit_rad``.

    ``all_nodes`` debe venir en orden de creacion (hijos antes que padres); asi basta una pasada
    para propagar la elevacion hacia arriba."""
    children = {}
    for s in struts:
        children.setdefault(id(s.parent), []).append(s)
    changed = False
    for nd in all_nodes:
        kids = children.get(id(nd))
        if not kids:
            continue
        R2 = nd.width_mm / 2.0
        zreq = nd.z
        for s in kids:
            c = s.child
            run = _dist(c.xy, nd.xy)
            zreq = max(zreq, _min_parent_z(run, c.z, s.width_mm / 2.0, R2, limit_rad))
        if zreq > nd.z + 1e-9:
            nd.z = zreq
            changed = True
    return changed


def _relax_tree(all_nodes, struts, root, cfg, theta, h0):
    """Acerca cada nudo interno al centro de la pieza todo lo que permitan los angulos
    (tilt+semiangulo <= theta) y luego baja su z al minimo viable. Comprime lateralmente el arbol,
    de modo que las alturas requeridas bajan sin cambiar la topologia ni anadir nervios."""
    children = {}
    parent_of = {}
    for s in struts:
        children.setdefault(id(s.parent), []).append(s)
        parent_of[id(s.child)] = s
    internal = [n for n in all_nodes if children.get(id(n))]
    if not internal:
        return False
    leaves = [n for n in all_nodes if not children.get(id(n))]
    cxy = np.mean([n.xy for n in leaves], axis=0) if leaves else np.array(root.xy, float)

    def feasible(p, sstep, ux, uy):
        x = p.xy[0] + sstep * ux
        y = p.xy[1] + sstep * uy
        for st in children.get(id(p), []):
            c = st.child
            run = math.hypot(x - c.xy[0], y - c.xy[1])
            rise = p.z - c.z
            if rise <= 1e-9:
                return False
            d = math.hypot(run, rise)
            if (math.atan2(run, rise) +
                    _cone_half_angle(st.width_mm / 2.0, p.width_mm / 2.0, d) > theta + 1e-9):
                return False
        st = parent_of.get(id(p))
        if st is not None:
            q = st.parent
            run = math.hypot(q.xy[0] - x, q.xy[1] - y)
            rise = q.z - p.z
            if rise <= 1e-9:
                return False
            d = math.hypot(run, rise)
            if (math.atan2(run, rise) +
                    _cone_half_angle(p.width_mm / 2.0, q.width_mm / 2.0, d) > theta + 1e-9):
                return False
        return True

    moved = False
    for _ in range(60):
        any_move = False
        for p in sorted(internal, key=lambda n: n.z):
            dx = float(cxy[0] - p.xy[0])
            dy = float(cxy[1] - p.xy[1])
            dist = math.hypot(dx, dy)
            if dist > 1e-6:
                ux, uy = dx / dist, dy / dist
                lo, hi = 0.0, dist
                for _ in range(30):
                    sm = 0.5 * (lo + hi)
                    if feasible(p, sm, ux, uy):
                        lo = sm
                    else:
                        hi = sm
                if lo > 1e-4:
                    p.xy = (p.xy[0] + lo * ux, p.xy[1] + lo * uy)
                    any_move = True
            zmin = h0
            for st in children.get(id(p), []):
                c = st.child
                run = _dist(c.xy, p.xy)
                zmin = max(zmin, _min_parent_z(run, c.z, st.width_mm / 2.0, p.width_mm / 2.0, theta))
            if zmin < p.z - 1e-9:
                p.z = zmin
                any_move = True
        if not any_move:
            break
        moved = True
    return moved


def _merge_close_nodes(all_nodes, struts, root, eps):
    """Fusiona un nudo interno con su padre cuando el nervio que los une mide menos que ``eps``:
    reparenta los hijos del nudo directamente al padre y elimina el nervio y el nudo intermedio
    (dos esferas practicamente pegadas son una sola)."""
    if eps <= 0:
        return False
    changed = False
    while True:
        hit = None
        for s in struts:
            rs = (s.child.width_mm + s.parent.width_mm) / 2.0
            if any(t.parent is s.child for t in struts) and s.length <= eps + rs:
                hit = s
                break
        if hit is None:
            break
        c, p = hit.child, hit.parent
        for t in struts:
            if t.parent is c:
                t.parent = p
        struts.remove(hit)
        if c in all_nodes:
            all_nodes.remove(c)
        changed = True
    return changed


def _grow_widths_for_buckling(all_nodes, struts, cfg, A_tot):
    """Engorda cada nervio lo justo para cumplir pandeo/plastificado en su longitud final (tras
    haber elevado nudos por voladizo)."""
    child_strut = {id(s.child): s for s in struts}
    changed = False
    for nd in all_nodes:
        s = child_strut.get(id(nd))
        if s is None:
            continue
        P = cfg.structural.press_force_n * nd.subtree_area_mm2 / A_tot
        need = max(w_required(P, s.length, cfg), _slender(s.length, cfg))
        if nd.max_width_mm != float("inf"):
            need = min(need, nd.max_width_mm)
        if need > nd.width_mm + 1e-9:
            nd.width_mm = need
            s.width_mm = max(s.width_mm, need)
            changed = True
    return changed


def _cover_root(pts, step_mm=1.0):
    if len(pts) == 1:
        return tuple(pts[0])
    lo = pts.min(axis=0) - step_mm
    hi = pts.max(axis=0) + step_mm
    gx = np.arange(lo[0], hi[0] + 1e-9, step_mm)
    gy = np.arange(lo[1], hi[1] + 1e-9, step_mm)
    best = None
    for x in gx:
        dx2 = (pts[:, 0] - x) ** 2
        for y in gy:
            m = float(np.sqrt(dx2 + (pts[:, 1] - y) ** 2).max())
            if best is None or m < best[0] - 1e-12:
                best = (m, float(x), float(y))
    return (best[1], best[2])


def _finalize_tree(nodes, struts, root, mode, cfg, A_tot, log):
    for n in nodes:
        n.load_n = cfg.structural.press_force_n * n.subtree_area_mm2 / A_tot
    for n in nodes:
        out = ([s.length for s in struts if s.parent is root] if n is root
               else [s.length for s in struts if s.child is n])
        L = max(out) if out else 0.0
        n.p_cr_n = p_crit(n.width_mm, L, cfg.structural.e_pla_mpa, cfg.structural.k_effective)
        n.p_tope_n = p_bearing(n.width_mm, cfg.structural.sigma_bearing_mpa)
        n.margin = min(n.p_cr_n, n.p_tope_n) / max(n.load_n, 1e-9)
    total_len = sum(s.length for s in struts)
    total_vol = sum(PI * (s.width_mm / 2.0) ** 2 * s.length for s in struts)
    margins = [n.margin for n in nodes]
    return Tree(nodes=nodes, struts=struts, root=root, mode=mode, total_length_mm=total_len,
                total_material_mm3=total_vol, max_margin=max(margins), min_margin=min(margins))


def build_tree(anchors, islands_by_label, cfg, mode, log) -> Tree:
    h0 = _effective_h0(cfg)
    theta = math.radians(max(5.0, cfg.convergence.max_overhang_deg - 1.0))
    tanT = math.tan(theta)
    A_tot = sum(isl.area_mm2 for isl in islands_by_label.values())
    if A_tot <= 0:
        A_tot = 1.0
    cnt = count(1)
    per_label_count = {}
    for a in anchors:
        per_label_count[a.island_label] = per_label_count.get(a.island_label, 0) + 1
    leaves = []
    for a in anchors:
        isl = islands_by_label[a.island_label]
        sub = isl.area_mm2 / max(per_label_count[a.island_label], 1)
        maxw = a.max_width_mm if a.max_width_mm > 0 else float("inf")
        w0 = min(a.width_mm, maxw)
        leaves.append(_make_node((a.x, a.y), h0, w0, next(cnt), sub,
                                 label=f"isla {a.island_label}", max_width=maxw))
    struts = []

    if mode == "fan":
        pts = np.array([l.xy for l in leaves], float)
        step = max(1.0, float(np.ptp(pts, axis=0).max()) / 60.0)
        cx, cy = _cover_root(pts, step)
        z_root = max(h0 + _dist(l.xy, (cx, cy)) / tanT for l in leaves)
        R2g = max(l.width_mm for l in leaves) / 2.0
        for _ in range(8):
            z_new = z_root
            for l in leaves:
                z_new = max(z_new, _min_parent_z(_dist(l.xy, (cx, cy)), h0, l.width_mm / 2.0, R2g, theta))
            z_root = z_new
            for l in leaves:
                run = _dist(l.xy, (cx, cy))
                L = math.hypot(run, z_root - h0)
                P_l = cfg.structural.press_force_n * l.subtree_area_mm2 / A_tot
                w = max(l.width_mm, w_required(P_l, L, cfg), _slender(L, cfg))
                l.width_mm = min(w, l.max_width_mm)
            nr = max(l.width_mm for l in leaves) / 2.0
            if abs(nr - R2g) < 1e-9:
                R2g = nr
                break
            R2g = nr
        root = _make_node((cx, cy), z_root, max(l.width_mm for l in leaves), next(cnt), A_tot,
                          label="raiz")
        for l in leaves:
            struts.append(Strut(l, root, l.width_mm))
        nodes = leaves + [root]
        all_nodes = list(nodes)
    else:
        nodes = list(leaves)
        all_nodes = list(leaves)
        while len(nodes) > 1:
            xy = np.array([n.xy for n in nodes], dtype=float)
            d = np.sqrt(((xy[:, None, :] - xy[None, :, :]) ** 2).sum(axis=2))
            zv = np.array([n.z for n in nodes])
            zc = np.maximum(zv[:, None], zv[None, :]) + d / (2.0 * tanT)
            np.fill_diagonal(zc, np.inf)
            flat = int(np.argmin(zc))
            i, j = divmod(flat, len(nodes))
            if i > j:
                i, j = j, i
            a, b = nodes[i], nodes[j]
            mid = ((a.xy[0] + b.xy[0]) / 2.0, (a.xy[1] + b.xy[1]) / 2.0)
            run_a = _dist(a.xy, mid)
            run_b = _dist(b.xy, mid)
            A_p = a.subtree_area_mm2 + b.subtree_area_mm2
            P_p = cfg.structural.press_force_n * A_p / A_tot
            P_a = cfg.structural.press_force_n * a.subtree_area_mm2 / A_tot
            P_b = cfg.structural.press_force_n * b.subtree_area_mm2 / A_tot
            z_p = max(a.z + run_a / tanT, b.z + run_b / tanT, h0)
            R2g = max(a.width_mm, b.width_mm) / 2.0
            for _ in range(8):
                z_p = max(z_p, _min_parent_z(run_a, a.z, a.width_mm / 2.0, R2g, theta),
                          _min_parent_z(run_b, b.z, b.width_mm / 2.0, R2g, theta))
                La = math.hypot(run_a, z_p - a.z)
                Lb = math.hypot(run_b, z_p - b.z)
                L_p = max(La, Lb)
                a.width_mm = min(max(a.width_mm, w_required(P_a, La, cfg), _slender(La, cfg)),
                                 a.max_width_mm)
                b.width_mm = min(max(b.width_mm, w_required(P_b, Lb, cfg), _slender(Lb, cfg)),
                                 b.max_width_mm)
                w_p = max(a.width_mm, b.width_mm, w_required(P_p, L_p, cfg), _slender(L_p, cfg))
                nr = w_p / 2.0
                if abs(nr - R2g) < 1e-9:
                    R2g = nr
                    break
                R2g = nr
            parent = _make_node(mid, z_p, w_p, next(cnt), A_p, label=f"fusion {len(struts)//2 + 1}")
            struts.append(Strut(a, parent, a.width_mm))
            struts.append(Strut(b, parent, b.width_mm))
            nodes = [nodes[k] for k in range(len(nodes)) if k not in (i, j)]
            nodes.append(parent)
            all_nodes.append(parent)
        root = nodes[0]

    if cfg.convergence.optimize and mode == "tree":
        for _ in range(3):
            _relax_tree(all_nodes, struts, root, cfg, theta, h0)
            if not _merge_close_nodes(all_nodes, struts, root, cfg.convergence.merge_eps_mm):
                break

    for _ in range(8):
        _cap_leaf_struts(struts, cfg)
        grew = _grow_widths_for_buckling(all_nodes, struts, cfg, A_tot)
        moved = _enforce_cone_overhang(all_nodes, struts, theta)
        if not grew and not moved:
            break
    _cap_leaf_struts(struts, cfg)
    tree = _finalize_tree(all_nodes, struts, root, mode, cfg, A_tot, log)
    log(f"  modo {mode}: nodos={len(all_nodes)} struts={len(struts)} altura_arbol={root.z:.1f} mm  "
        f"long={tree.total_length_mm:.0f} mm  mat={tree.total_material_mm3/1000:.1f} cm3  "
        f"margen_min={tree.min_margin:.2f}")
    return tree


def _strut_mesh(p0, p1, r0, r1, sections):
    """Tronco de cono (o cilindro si r0==r1) de p0 a p1."""
    v = p1 - p0
    L = float(np.linalg.norm(v))
    if L < 1e-9 or min(r0, r1) <= 0:
        return None
    n = max(3, int(sections))
    ang = np.linspace(0.0, 2.0 * PI, n, endpoint=False)
    bot = np.column_stack([r0 * np.cos(ang), r0 * np.sin(ang), np.zeros(n)])
    top = np.column_stack([r1 * np.cos(ang), r1 * np.sin(ang), np.full(n, L)])
    ib, it = 2 * n, 2 * n + 1
    verts = np.vstack([bot, top, np.array([[0.0, 0.0, 0.0]]), np.array([[0.0, 0.0, L]])])
    faces = []
    for i in range(n):
        j = (i + 1) % n
        faces.append([i, j, n + j])
        faces.append([i, n + j, n + i])
        faces.append([ib, j, i])
        faces.append([it, n + i, n + j])
    m = trimesh.Trimesh(vertices=verts, faces=np.array(faces), process=False)
    trimesh.repair.fix_normals(m)
    m.apply_transform(trimesh.geometry.align_vectors([0, 0, 1], v / L))
    m.apply_translation(p0)
    return m


def _tangent_cone(c, p, R1, R2):
    """Tronco de cono tangente a dos esferas (ver cono_truncado.md).

    Esfera pequena R1 en ``c``, esfera grande R2 en ``p``. El cono arranca en el ecuador de R1
    (radio R1 en el centro ``c``) y llega tangente a R2 en un punto a distancia ``h`` de ``c`` con
    radio ``r < R2``. Devuelve ``(p0, p1, r0, r1)`` para el tronco, o ``None`` si degenera.
    """
    c = np.asarray(c, float)
    p = np.asarray(p, float)
    v = p - c
    d = float(np.linalg.norm(v))
    if d < 1e-9 or R1 <= 0 or R2 <= 0:
        return None
    u = v / d
    R1 = min(R1, R2)
    denom = d * d - R2 * R2
    if denom <= 1e-9:
        return (c, p, R1, R2)
    S = math.sqrt(max(0.0, d * d + R2 * R2 - R1 * R1))
    m = (R2 * S - d * R1) / denom
    if m < 0.0:
        m = 0.0
    root = math.sqrt(1.0 + m * m)
    r = R2 / root
    h = d - R2 * m / root
    if h <= 1e-9:
        return (c, p, R1, R2)
    return (c, c + u * h, R1, r)


def build_struts_meshes(tree, cfg, log):
    sec = cfg.ribs.sections
    h0 = _effective_h0(cfg)
    parents = {id(s.parent) for s in tree.struts}
    out = []
    for s in tree.struts:
        c = np.array(s.child.xyz, float)
        p = np.array(s.parent.xyz, float)
        is_leaf = id(s.child) not in parents
        R1 = s.width_mm / 2.0
        R2 = s.parent.width_mm / 2.0
        geom = _tangent_cone(c, p, R1, R2)
        if geom is None:
            continue
        p0, p1, r0, r1 = geom
        if is_leaf:
            a, _ = _leaf_tilt(p, p0)
            if a is not None:
                ca = math.cos(a)
                delta = r0 * math.tan(a) + 0.02
                if ca > 1e-9:
                    delta = min(delta, max(0.0, (h0 - 0.05) / ca))
                if delta > 0:
                    dd = p - p0
                    dd = dd / max(float(np.linalg.norm(dd)), 1e-9)
                    p0 = p0 - dd * delta
        m = _strut_mesh(p0, p1, r0, r1, sec)
        if m is None:
            continue
        out.append(m)
    if out and not all(m.is_watertight for m in out):
        raise UserError("Strut no watertight (bug interno).")
    return out


def build_joints(tree, cfg, log):
    """Esfera en cada nudo interno (ni en la base ni en la boveda).

    Cada nervio es un tronco de cono que va de la esfera de su nodo hijo a la esfera de su nodo
    padre; asi los extremos quedan siempre dentro de una esfera y no hay tapas planas colgando.
    """
    children = {}
    for s in tree.struts:
        children.setdefault(id(s.parent), []).append(s)
    root = tree.root
    out = []
    done = set()
    for s in tree.struts:
        p = s.parent
        if id(p) == id(root) or id(p) in done:
            continue
        if not children.get(id(p)):
            continue
        done.add(id(p))
        R = p.width_mm / 2.0
        m = trimesh.creation.icosphere(subdivisions=2, radius=R)
        m.apply_translation([p.xy[0], p.xy[1], p.z])
        if p.z - R < -1e-6:
            big = max(4.0 * R, 100.0)
            box_m = trimesh.creation.box(extents=[big, big, big])
            box_m.apply_translation([p.xy[0], p.xy[1], big / 2.0])
            m = trimesh.boolean.intersection([m, box_m], engine="manifold")
            if m is None or len(m.faces) == 0:
                continue
        out.append(m)
    return out


# --- Etapa G — boveda ------------------------------------------------------

def _vault_base_points(shape, r, n):
    if shape == "ellipse" or r <= 0:
        t = np.linspace(0, 2 * PI, n, endpoint=False)
        return np.column_stack([r * np.cos(t), r * np.sin(t)])
    c = 0.35 * r
    p = box(-(r - c), -(r - c), (r - c), (r - c)).buffer(c, join_style=1)
    pts = np.array(p.exterior.coords[:-1])
    if len(pts) < 3:
        t = np.linspace(0, 2 * PI, n, endpoint=False)
        return np.column_stack([r * np.cos(t), r * np.sin(t)])
    return pts


def build_vault(root: Node, cfg: Config):
    theta = math.radians(max(5.0, cfg.convergence.max_overhang_deg - 1.5))
    tanT = math.tan(theta)
    r_min = max(root.width_mm / 2.0, cfg.part.min_wall_mm / 2.0)
    r1 = max(cfg.vault.footprint_mm / 2.0, math.sqrt(cfg.vault.top_area_mm2 / PI))
    r1 = max(r1, r_min * 1.05)
    base = _vault_base_points(cfg.vault.shape, r1, cfg.vault.n_sec)
    r_eff = max(r_min * 1.05, float(np.linalg.norm(base, axis=1).max()))
    z_apex = root.z - r_min / tanT
    H = z_apex + r_eff / tanT
    m = len(base)
    verts = [[root.xy[0], root.xy[1], z_apex]]
    rings = []
    for k in range(1, cfg.vault.n_rings + 1):
        f = k / cfg.vault.n_rings
        z = z_apex + f * (H - z_apex)
        start = len(verts)
        for pt in base:
            verts.append([root.xy[0] + pt[0] * f, root.xy[1] + pt[1] * f, z])
        rings.append((start, m))
    faces = []
    s0, _ = rings[0]
    for i in range(m):
        faces.append([0, s0 + i, s0 + (i + 1) % m])
    for k in range(len(rings) - 1):
        sA, _ = rings[k]
        sB, _ = rings[k + 1]
        for i in range(m):
            jj = (i + 1) % m
            faces.append([sA + i, sA + jj, sB + jj])
            faces.append([sA + i, sB + jj, sB + i])
    st, _ = rings[-1]
    cap_i = len(verts)
    verts.append([root.xy[0], root.xy[1], H])
    for i in range(m):
        faces.append([cap_i, st + i, st + (i + 1) % m])
    mesh = trimesh.Trimesh(vertices=np.array(verts, float), faces=np.array(faces, int), process=True)
    if mesh.volume < 0:
        mesh.invert()
    trimesh.repair.fix_normals(mesh)
    return mesh, H


# --- Etapa H — union -------------------------------------------------------

def union_all(meshes, log):
    meshes = [m for m in meshes if m is not None and len(m.faces) > 0]
    if not meshes:
        raise UserError("No hay geometria que unir.")
    log(f"  uniendo {len(meshes)} cuerpos...")
    try:
        out = trimesh.boolean.union(meshes, engine="manifold")
    except Exception as exc:
        log(f"  union en lote fallida ({exc}); incremental...")
        out = meshes[0]
        for m in meshes[1:]:
            out = trimesh.boolean.union([out, m], engine="manifold")
    if isinstance(out, (list, tuple)):
        out = trimesh.util.concatenate(out)
    if not out.is_watertight:
        out.merge_vertices()
        trimesh.repair.fix_normals(out)
    return out


# --- Etapa I — verificacion ------------------------------------------------

def verify(mesh, cfg, log) -> dict:
    rep = {"watertight": bool(mesh.is_watertight),
           "winding_consistent": bool(mesh.is_winding_consistent),
           "euler_number": int(mesh.euler_number)}
    try:
        parts = mesh.split(only_watertight=False)
        rep["body_count"] = len(parts)
    except Exception:
        rep["body_count"] = 1
    rep["volume_mm3"] = float(mesh.volume)
    rep["triangles"] = int(len(mesh.faces))
    rep["vertices"] = int(len(mesh.vertices))
    rep["grams"] = float(mesh.volume) / 1000.0 * PLA_DENSITY_G_CM3

    v = mesh.vertices
    zmin = float(v[:, 2].min())
    zmax = float(v[:, 2].max())
    rep["z_min"] = zmin
    rep["z_max"] = zmax

    normals = mesh.face_normals
    nz = normals[:, 2]
    downward = nz < -1e-6
    tri = mesh.triangles
    on_bed = np.all(tri[:, :, 2] < Z_TOL, axis=1)
    area = mesh.area_faces
    e0 = np.linalg.norm(tri[:, 0] - tri[:, 1], axis=1)
    e1 = np.linalg.norm(tri[:, 1] - tri[:, 2], axis=1)
    e2 = np.linalg.norm(tri[:, 2] - tri[:, 0], axis=1)
    perim = e0 + e1 + e2
    inradius = np.where(perim > 0, 2.0 * area / perim, 0.0)
    area_min = max(1e-3, (cfg.part.nozzle_mm / 2.0) ** 2)
    r_res = cfg.part.nozzle_mm / 2.0
    resolvable = (area >= area_min) & (inradius >= r_res)
    # Facetas degeneradas (area practicamente nula): las genera la union booleana en las costuras
    # entre cuerpos y no representan superficie imprimible alguna, por lo que se excluyen de las
    # estadisticas de voladizo (pero se cuentan y se informan aparte).
    degenerate = area < 1e-6
    dn = downward & ~on_bed & ~degenerate
    ang_all = np.where(dn, np.degrees(np.arccos(np.clip(nz, -1, 1))) - 90.0, 0.0)
    lim = cfg.convergence.max_overhang_deg + ANGLE_TOL
    exceed = dn & (ang_all > lim)
    micro_exceed = exceed & ~resolvable
    real_exceed = exceed & resolvable
    rep["overhang_area_min_mm2"] = area_min
    rep["overhang_resolving_radius_mm"] = r_res
    rep["overhang_degenerate_faces"] = int((degenerate & (downward & ~on_bed)).sum())
    rep["overhang_micro_faces"] = int(micro_exceed.sum())
    rep["overhang_micro_max"] = float(ang_all[micro_exceed].max()) if micro_exceed.any() else 0.0
    rep["overhang_max_all"] = float(ang_all[dn].max()) if dn.any() else 0.0
    sig = dn & resolvable
    if sig.any():
        ang = ang_all[sig]
        rep["overhang_max"] = float(ang.max())
        rep["overhang_p99"] = float(np.percentile(ang, 99))
        rep["overhang_faces_gt"] = int(real_exceed.sum())
        k = int(np.argmax(np.where(sig, ang_all, -999.0)))
        rep["overhang_at"] = mesh.triangles[k].mean(axis=0).tolist()
    else:
        rep["overhang_max"] = 0.0
        rep["overhang_p99"] = 0.0
        rep["overhang_faces_gt"] = int(real_exceed.sum())
    return rep


# --- Export + informe + PNGs -----------------------------------------------

def export_model(mesh, cfg, out_dir, base, log) -> str:
    if cfg.output.format == "3mf":
        path = os.path.join(out_dir, base + "_model.3mf")
        mesh.export(path, file_type="3mf")
    else:
        path = os.path.join(out_dir, base + "_model.stl")
        data = mesh.export(file_type="stl")
        with open(path, "wb") as fh:
            fh.write(data)
    log(f"  modelo -> {path}")
    return path


def _fmt_table(rows, headers):
    widths = [len(h) for h in headers]
    for r in rows:
        for i, c in enumerate(r):
            widths[i] = max(widths[i], len(str(c)))
    lines = ["  ".join(str(h).ljust(widths[i]) for i, h in enumerate(headers))]
    lines.append("  ".join("-" * widths[i] for i in range(len(headers))))
    for r in rows:
        lines.append("  ".join(str(c).ljust(widths[i]) for i, c in enumerate(r)))
    return "\n".join(lines)


def write_report(path, cfg, scale, mask_rep, poly_stats, islands, anchor_just, anchors, trees, tree,
                 verify_rep, H, warnings, log, mode_used):
    s = cfg.structural
    L = ["=" * 72, "INFORME vineteado.py", "=" * 72]
    if warnings:
        L.append("")
        L.append("AVISOS DE CONFIGURACION:")
        for w in warnings:
            L.append("  - " + w)
    L.append("")
    L.append("1. IMAGEN")
    L.append(f"  tamano: {mask_rep['img_shape'][1]} x {mask_rep['img_shape'][0]} px")
    L.append(f"  umbral: {mask_rep['threshold']:.1f} ({mask_rep['threshold_mode']})")
    L.append(f"  solido: {mask_rep['solid_pct']:.1f} %")
    L.append(f"  poros rellenados: {mask_rep.get('poros_rellenados_px', 0)} px")
    L.append("")
    L.append("2. MINIMO IMPRIMIBLE (politica: eliminar + avisar)")
    L.append(f"  min_wall = {cfg.part.min_wall_mm} mm (apertura r="
             f"{mask_rep.get('apertura_r_px', 0):.2f} px -> "
             f"{mask_rep.get('apertura_r_px_entero', 0)} px entero; ancho "
             f"garantizado >= {mask_rep.get('ancho_garantizado_mm', 0):.2f} mm)")
    L.append(f"  islas antes/despues: {mask_rep.get('islas_antes_apertura')} "
             f"-> {mask_rep.get('islas_despues_apertura')}")
    L.append(f"  area eliminada por apertura: "
             f"{mask_rep.get('area_antes_apertura_px', 0) - mask_rep.get('area_despues_apertura_px', 0)} px")
    L.append(f"  islas diminutas eliminadas: {mask_rep.get('islas_eliminadas_peq', 0)}"
             f" ({mask_rep.get('area_eliminada_peq_px', 0)} px)")
    if mask_rep.get("islas_eliminadas_post", 0):
        L.append(f"  fragmentos < area_min creados por la apertura y eliminados: "
                 f"{mask_rep['islas_eliminadas_post']} "
                 f"({mask_rep.get('area_eliminada_post_px', 0)} px)")
    if "grosor_min_mm_post" in mask_rep:
        L.append(f"  grosor solido resultante: min {mask_rep['grosor_min_mm_post']:.2f}, "
                 f"p5 {mask_rep['grosor_p5_mm_post']:.2f}, "
                 f"mediana {mask_rep['grosor_med_mm_post']:.2f} mm")
    if "hueco_min_mm" in mask_rep:
        L.append(f"  hueco (negro): min {mask_rep['hueco_min_mm']:.2f}, "
                 f"p5 {mask_rep['hueco_p5_mm']:.2f} mm; "
                 f"{mask_rep.get('huecos_bajo_min_pct', 0):.1f} % del esqueleto "
                 f"de huecos < min_slot ({cfg.part.min_slot_mm} mm) [solo aviso]")
    L.append("")
    L.append("3. ESCALA")
    L.append(f"  mm/px = {scale['mm_px']:.5f}")
    L.append(f"  pieza: {scale['width_mm']:.1f} x {scale['height_mm']:.1f} mm")
    L.append(f"  altura final H = {H:.1f} mm")
    L.append("")
    L.append("4. ISLAS Y ANCLAJES")
    L.append(f"  contornos: {poly_stats['n_contornos']} ({poly_stats['puntos_crudos']} pts crudos), "
             f"poligonos finales: {poly_stats['n_poligonos']}, vertices: {poly_stats['vertices']}")
    rows = [[isl.label, f"{isl.area_mm2:.1f}", f"{isl.elong:.2f}",
             f"{isl.min_thick_mm:.2f}/{isl.med_thick_mm:.2f}", jus["n"], f"{jus['width']:.2f}",
             jus["kind"]] for isl, jus in zip(islands, anchor_just)]
    L.append(_fmt_table(rows, ["isla", "area mm2", "elong", "grosor min/med", "n_ancl", "w mm",
                               "justificacion"]))
    L.append("")
    L.append("5. SUPUESTOS ESTRUCTURALES DECLARADOS (no FEA)")
    L.append(f"  caso: COMPRESION en la boveda, P = {s.press_force_n} N, FS = {s.safety_factor}")
    L.append(f"  E_PLA = {s.e_pla_mpa} MPa, K_efectivo = {s.k_effective}, "
             f"sigma_aplastamiento = {s.sigma_bearing_mpa} MPa")
    L.append(f"  reparto de carga por AREA de isla del subarbol.")
    L.append(f"  theta_max voladizo = {cfg.convergence.max_overhang_deg} deg, "
             f"w_min = {cfg.part.min_wall_mm} mm")
    L.append(f"  caso de despegue (peel): {'ACTIVADO' if s.peel_check else 'desactivado'}")
    L.append("  LIMITACION: modelo de dos fuerzas; ignora momentos de uniones")
    L.append("  rigidas y pandeo global; E en FDM es anisotropo. Es COTA INFERIOR.")
    L.append("")
    L.append("6. NERVIOS")
    if anchors:
        ws = [a.width_mm for a in anchors]
        L.append(f"  anclajes: {len(anchors)}; w min/med/max = "
                 f"{min(ws):.2f}/{np.median(ws):.2f}/{max(ws):.2f} mm")
    L.append(f"  altura vertical h0 = {_effective_h0(cfg):.2f} mm "
             f"(= max(espesor stencil, ribs.min_height_mm); "
             f"cilindro nunca mas alto que la base)")
    L.append("")
    L.append("7. ARBOL DE CONVERGENCIA (modo usado: %s)" % mode_used)
    L.append(f"  nodos: {len(tree.nodes)}, struts: {len(tree.struts)}")
    L.append(f"  altura del arbol (raiz): {tree.root.z:.1f} mm")
    L.append(f"  longitud total de nervios: {tree.total_length_mm:.0f} mm")
    L.append(f"  material de nervios: {tree.total_material_mm3/1000.0:.1f} cm3")
    L.append("  comparativa de modos:")
    for t in trees:
        L.append(f"    - {t.mode}: altura_raiz={t.root.z:.1f} mm, long={t.total_length_mm:.0f} mm, "
                 f"mat={t.total_material_mm3/1000.0:.1f} cm3, margen_min={t.min_margin:.2f}")
    L.append("")
    L.append("8. TABLA ESTRUCTURAL POR NODO (cota inferior)")
    rows = [[nd.label, f"{nd.xy[0]:.1f}", f"{nd.xy[1]:.1f}", f"{nd.z:.1f}", f"{nd.width_mm:.2f}",
             f"{nd.load_n:.2f}", f"{nd.p_cr_n:.2f}", f"{nd.p_tope_n:.2f}", f"{nd.margin:.2f}"]
            for nd in tree.nodes]
    L.append(_fmt_table(rows, ["nodo", "x", "y", "z", "w", "P_N", "P_cr", "P_tope", "margen"]))
    L.append("")
    L.append("9. BOVEDA")
    L.append(f"  forma {cfg.vault.shape}, huella {cfg.vault.footprint_mm} mm, "
             f"area superior objetivo {cfg.vault.top_area_mm2} mm2, cima z={H:.1f}")
    L.append("")
    L.append("10. VERIFICACION (trimesh)")
    L.append(f"  watertight: {verify_rep['watertight']}")
    L.append(f"  normales coherentes: {verify_rep['winding_consistent']}")
    L.append(f"  componentes conexas: {verify_rep['body_count']}")
    L.append(f"  Euler: {verify_rep['euler_number']}")
    L.append(f"  z_min = {verify_rep['z_min']:.4f} (debe ser ~0)  z_max = {verify_rep['z_max']:.2f}")
    L.append(f"  voladizo maximo (superficies resolubles): {verify_rep['overhang_max']:.2f} deg "
             f"(p99 {verify_rep['overhang_p99']:.2f}); "
             f"caras > limite: {verify_rep['overhang_faces_gt']}")
    if verify_rep.get("overhang_at"):
        a = verify_rep["overhang_at"]
        L.append(f"  voladizo max en ({a[0]:.1f}, {a[1]:.1f}, {a[2]:.1f})")
    L.append(f"  voladizo maximo global (incluye micro-facetas sub-boquilla, "
             f"excluye degeneradas): {verify_rep['overhang_max_all']:.2f} deg")
    L.append(f"  criterio de resolubilidad: area >= {verify_rep['overhang_area_min_mm2']:.4f} mm2 "
             f"y radio inscrito >= {verify_rep['overhang_resolving_radius_mm']:.2f} mm "
             f"(media boquilla)")
    L.append(f"  micro-facetas que superan el limite pero son sub-boquilla: "
             f"{verify_rep['overhang_micro_faces']} "
             f"(voladizo max {verify_rep['overhang_micro_max']:.2f} deg; "
             f"no imprimibles, artefactos de costura de la union booleana)")
    L.append(f"  facetas degeneradas (area ~0, no imprimibles, generadas por "
             f"la union): {verify_rep.get('overhang_degenerate_faces', 0)}")
    L.append("")
    L.append("11. MATERIAL")
    L.append(f"  volumen: {verify_rep['volume_mm3']/1000.0:.2f} cm3")
    L.append(f"  PLA (rho={PLA_DENSITY_G_CM3} g/cm3): {verify_rep['grams']:.1f} g")
    L.append(f"  triangulos: {verify_rep['triangles']} ({verify_rep['vertices']} vertices)")
    L.append("")
    L.append("=" * 72)
    text = "\n".join(L) + "\n"
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(text)
    log(f"  informe -> {path}")
    return text


# --- PNGs de inspeccion ----------------------------------------------------

def _new_canvas(size):
    img = Image.new("RGB", (size, size), (255, 255, 255))
    return img, ImageDraw.Draw(img)


def _transform(vals, bounds, size, margin=20, flip_y=True):
    (x0, y0, x1, y1) = bounds
    w = max(x1 - x0, 1e-9)
    h = max(y1 - y0, 1e-9)
    s = (size - 2 * margin) / max(w, h)
    pts = (np.asarray(vals) - np.array([x0, y0])) * s + margin
    if flip_y:
        pts[:, 1] = size - pts[:, 1]
    return pts


def png_top(mask, scale, anchors, tree, vault_footprint, path, size, mm_px):
    H = scale["H_px"]
    W = scale["W_px"]
    img = Image.new("RGB", (W, H), (255, 255, 255))
    px = img.load()
    ys, xs = np.nonzero(mask)
    for y, x in zip(ys, xs):
        px[x, y] = (60, 60, 60)
    d = ImageDraw.Draw(img)
    colors = {"cobertura": (0, 170, 0)}
    for a in anchors:
        c = colors.get(a.kind, (0, 170, 0))
        r_px = max(2.0, (a.width_mm / 2.0) / mm_px)
        d.ellipse([a.col - r_px, a.row - r_px, a.col + r_px, a.row + r_px], fill=c)
    if tree is not None:
        for s in tree.struts:
            x0 = s.child.xy[0] / mm_px
            y0 = H - 1 - s.child.xy[1] / mm_px
            x1 = s.parent.xy[0] / mm_px
            y1 = H - 1 - s.parent.xy[1] / mm_px
            d.line([x0, y0, x1, y1], fill=(255, 140, 0), width=1)
        rx = tree.root.xy[0] / mm_px
        ry = H - 1 - tree.root.xy[1] / mm_px
        d.ellipse([rx - 4, ry - 4, rx + 4, ry + 4], outline=(200, 0, 200), width=2)
    if vault_footprint:
        fx, fy, r = vault_footprint
        cx = fx / mm_px
        cy = H - 1 - fy / mm_px
        rr = r / mm_px
        d.ellipse([cx - rr, cy - rr, cx + rr, cy + rr], outline=(200, 0, 200), width=2)
    img.save(path)


def png_silhouette(mesh, axes, path, size):
    tris = mesh.triangles[:, :, axes]
    flat = tris.reshape(-1, 2)
    bounds = (flat[:, 0].min(), flat[:, 1].min(), flat[:, 0].max(), flat[:, 1].max())
    img, d = _new_canvas(size)
    pts = _transform(flat, bounds, size).reshape(-1, 3, 2)
    for tri in pts:
        d.polygon([tuple(p) for p in tri], fill=(70, 70, 70))
    img.save(path)


def _draw_path_section(path3d, d, bounds, size, color):
    for entity in path3d.entities:
        try:
            pts = entity.discrete(path3d.vertices)[:, :2]
        except Exception:
            continue
        if len(pts) < 2:
            continue
        p = _transform(pts, bounds, size)
        d.line([tuple(q) for q in p], fill=color, width=1, joint="curve")


def png_sections(mesh, scale, path_dir, size, log):
    lo = mesh.bounds[0]
    hi = mesh.bounds[1]
    levels = [scale["mm_px"] * 0.5, scale["mm_px"] * 1.5, hi[2] * 0.25, hi[2] * 0.5, hi[2] * 0.75,
              hi[2] - 0.2]
    for z in levels:
        if z <= 0 or z >= hi[2]:
            continue
        try:
            sec = mesh.section(plane_origin=[0, 0, z], plane_normal=[0, 0, 1])
        except Exception:
            continue
        if sec is None:
            continue
        img, d = _new_canvas(size)
        _draw_path_section(sec, d, (lo[0], lo[1], hi[0], hi[1]), size, (20, 20, 20))
        img.save(os.path.join(path_dir, f"corte_z_{z:.1f}mm.png"))
    cx = (lo[0] + hi[0]) / 2.0
    cy = (lo[1] + hi[1]) / 2.0
    for axis, name in ((0, "x"), (1, "y")):
        origin = [0, 0, 0]
        origin[axis] = cx if axis == 0 else cy
        normal = [0, 0, 0]
        normal[axis] = 1
        try:
            sec = mesh.section(plane_origin=origin, plane_normal=normal)
        except Exception:
            continue
        if sec is None:
            continue
        img, d = _new_canvas(size)
        bounds = (lo[1], lo[2], hi[1], hi[2]) if axis == 0 else (lo[0], lo[2], hi[0], hi[2])
        _draw_path_section(sec, d, bounds, size, (20, 20, 20))
        img.save(os.path.join(path_dir, f"corte_vertical_{name}.png"))


# --- Pipeline --------------------------------------------------------------

def run(config_path, image_path, out_dir, only_analysis=False, mode_override=None, log=print):
    cfg = load_config(config_path)
    warnings = cfg.raw.pop("_warnings", [])
    for w in warnings:
        log("  AVISO: " + w)
    os.makedirs(out_dir, exist_ok=True)
    base = os.path.splitext(os.path.basename(image_path))[0]

    log("[A] imagen -> mascara")
    gray = load_gray(image_path)
    H, W = gray.shape
    mm_px_pre = cfg.part.width_cm * 10.0 / W
    mask, mask_rep = binarize_and_clean(gray, cfg, mm_px_pre, log)
    if not mask.any():
        raise UserError("La mascara esta vacia tras umbral y limpieza "
                        "(imagen toda hueco o detalles bajo el minimo).")
    scale = compute_scale(gray, cfg)
    mm_px = scale["mm_px"]

    log("[B] contornos/poligonos")
    polygons, poly_stats = extract_polygons(mask, scale, cfg, log)
    if not polygons:
        raise UserError("No se genero ningun poligono.")

    log("[C] analisis de islas")
    islands = analyze_islands(mask, scale, cfg, log)
    islands_by_label = {i.label: i for i in islands}
    if not islands:
        raise UserError("No hay islas tras la limpieza.")

    log("[D] anclajes")
    anchors, anchor_just, w_by_label, rib_paths = select_anchors(mask, islands, scale, cfg, log)

    if only_analysis:
        _analysis_report(out_dir, base, cfg, scale, mask_rep, poly_stats, islands, anchor_just,
                         anchors, warnings, log)
        png_top(mask, scale, anchors, None, None, os.path.join(out_dir, base + "_top.png"),
                cfg.output.png_size_px, mm_px)
        return

    log("[E] placa / marco")
    plate = build_plate(polygons, cfg, log)
    frame = build_frame(polygons, scale, cfg, log)

    log("[F] convergencia")
    mode = mode_override or cfg.convergence.mode
    trees = []
    for m in ("tree", "fan"):
        trees.append(build_tree(anchors, islands_by_label, cfg, m, log))
    tree = next(t for t in trees if t.mode == mode)
    risers = build_risers(anchors, cfg, tree)
    log(f"  placa={len(plate)} risers={len(risers)} marco={len(frame)}")
    strut_meshes = build_struts_meshes(tree, cfg, log)
    joint_meshes = build_joints(tree, cfg, log)

    log("[G] boveda")
    vault_mesh, H_final = build_vault(tree.root, cfg)
    log(f"  boveda: cima z={H_final:.1f} mm")

    log("[H] union")
    all_meshes = plate + risers + strut_meshes + joint_meshes + frame + [vault_mesh]
    mesh = union_all(all_meshes, log)

    log("[I] verificacion")
    verify_rep = verify(mesh, cfg, log)
    log(f"  watertight={verify_rep['watertight']} componentes={verify_rep['body_count']} "
        f"voladizo_max={verify_rep['overhang_max']:.2f} deg triangulos={verify_rep['triangles']}")
    if verify_rep["body_count"] != 1:
        raise UserError(f"FALLO: el modelo tiene {verify_rep['body_count']} componentes "
                        f"(debe ser 1).")
    if verify_rep["overhang_faces_gt"] > 0:
        msg = (f"AVISO: {verify_rep['overhang_faces_gt']} caras superan theta_max; voladizo max "
               f"{verify_rep['overhang_max']:.1f} deg")
        log("  " + msg)
        warnings.append(msg)
        if cfg.output.strict:
            raise UserError(msg)

    log("[J] export")
    model_path = export_model(mesh, cfg, out_dir, base, log)

    if cfg.output.report:
        write_report(os.path.join(out_dir, base + "_informe.txt"), cfg, scale, mask_rep, poly_stats,
                     islands, anchor_just, anchors, trees, tree, verify_rep, H_final, warnings, log, mode)

    if cfg.output.inspection_pngs:
        pdir = os.path.join(out_dir, base + "_inspeccion")
        os.makedirs(pdir, exist_ok=True)
        png_top(mask, scale, anchors, tree,
                (tree.root.xy[0], tree.root.xy[1],
                 max(cfg.vault.footprint_mm / 2.0, math.sqrt(cfg.vault.top_area_mm2 / PI))),
                os.path.join(pdir, "proyeccion_superior.png"), cfg.output.png_size_px, mm_px)
        png_silhouette(mesh, [0, 2], os.path.join(pdir, "proyeccion_frente.png"), cfg.output.png_size_px)
        png_silhouette(mesh, [1, 2], os.path.join(pdir, "proyeccion_lado.png"), cfg.output.png_size_px)
        png_sections(mesh, scale, pdir, cfg.output.png_size_px, log)
        log(f"  PNGs -> {pdir}")
    return mesh


def _analysis_report(out_dir, base, cfg, scale, mask_rep, poly_stats, islands, anchor_just, anchors,
                     warnings, log):
    L = ["INFORME (solo analisis)", "=" * 60, ""]
    for w in warnings:
        L.append("AVISO: " + w)
    L.append(f"imagen {mask_rep['img_shape'][1]}x{mask_rep['img_shape'][0]} "
             f"umbral {mask_rep['threshold']:.1f} solido {mask_rep['solid_pct']:.1f}%")
    L.append(f"escala {scale['mm_px']:.5f} mm/px  pieza "
             f"{scale['width_mm']:.1f}x{scale['height_mm']:.1f} mm")
    L.append(f"islas {len(islands)} anclajes {len(anchors)}")
    L.append(f"poligonos {poly_stats['n_poligonos']} vertices {poly_stats['vertices']}")
    rows = [[j["label"], f"{j['area']:.1f}", f"{j['elong']:.2f}", j["n"], f"{j['width']:.2f}",
             j["kind"]] for j in anchor_just]
    L.append(_fmt_table(rows, ["isla", "area", "elong", "n", "w", "tipo"]))
    txt = "\n".join(L) + "\n"
    p = os.path.join(out_dir, base + "_informe.txt")
    with open(p, "w", encoding="utf-8") as fh:
        fh.write(txt)
    log(f"  informe -> {p}")


# --- CLI -------------------------------------------------------------------

def main(argv=None):
    p = argparse.ArgumentParser(
        description="Genera un stencil de spray imprimible a partir de una imagen B/N.")
    p.add_argument("config", nargs="?", help="fichero YAML de configuracion")
    p.add_argument("image", nargs="?", help="imagen PNG/JPG B/N")
    p.add_argument("--example-config", action="store_true",
                   help="vuelca un YAML de ejemplo comentado y sale")
    p.add_argument("--out-dir", default=".", help="directorio de salida")
    p.add_argument("--only-analysis", action="store_true",
                   help="solo Etapas A-D: informe y PNG, sin malla")
    p.add_argument("--mode", choices=["tree", "fan"], help="fuerza el modo de convergencia")
    args = p.parse_args(argv)

    if args.example_config:
        print(EXAMPLE_CONFIG)
        return 0
    if not args.config or not args.image:
        p.error("Se requieren <config.yaml> e <image.png> (o usa --example-config).")
    try:
        run(args.config, args.image, args.out_dir, only_analysis=args.only_analysis,
            mode_override=args.mode)
    except UserError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())