zcbot/windows-node/adapters/freecad.model.author@v1/preview_renderer.py

84 lines
3.3 KiB
Python

"""Deterministic GPU-free isometric renderer for FreeCAD tessellation output."""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
MAX_TRIANGLES = 500_000
def render(
mesh_path: Path, output_path: Path, options: dict[str, object] | None = None
) -> None:
from PIL import Image, ImageColor, ImageDraw
payload = json.loads(mesh_path.read_text(encoding="utf-8"))
vertices = payload.get("vertices")
triangles = payload.get("triangles")
if not isinstance(vertices, list) or not isinstance(triangles, list):
raise TypeError("FREECAD_PREVIEW_MESH_INVALID")
if not vertices or not triangles or len(triangles) > MAX_TRIANGLES:
raise RuntimeError("FREECAD_PREVIEW_LIMIT_EXCEEDED")
settings = options or {}
width = int(settings.get("width", 1200))
height = int(settings.get("height", 900))
if not 320 <= width <= 1600 or not 240 <= height <= 1600:
raise RuntimeError("FREECAD_PREVIEW_SIZE_INVALID")
background = ImageColor.getrgb(str(settings.get("background", "#F7F9FC")))
projected: list[tuple[float, float, float]] = []
for item in vertices:
if not isinstance(item, list) or len(item) != 3:
raise RuntimeError("FREECAD_PREVIEW_MESH_INVALID")
x, y, z = map(float, item)
projected.append(
(0.8660254 * (x - y), 0.5 * (x + y) - z, 0.4082483 * (x + y + z))
)
xs = [item[0] for item in projected]
ys = [item[1] for item in projected]
span_x = max(xs) - min(xs)
span_y = max(ys) - min(ys)
scale = min(width * 0.82 / max(span_x, 1e-12), height * 0.82 / max(span_y, 1e-12))
center_x = (min(xs) + max(xs)) / 2
center_y = (min(ys) + max(ys)) / 2
points = [
((x - center_x) * scale + width / 2, (y - center_y) * scale + height / 2, depth)
for x, y, depth in projected
]
image = Image.new("RGB", (width, height), background)
draw = ImageDraw.Draw(image)
light = (0.3, -0.45, 0.84)
ordered = sorted(
triangles, key=lambda tri: sum(points[int(index)][2] for index in tri) / 3
)
for triangle in ordered:
if not isinstance(triangle, list) or len(triangle) != 3:
raise RuntimeError("FREECAD_PREVIEW_MESH_INVALID")
indices = [int(item) for item in triangle]
try:
a, b, c = (vertices[index] for index in indices)
except (IndexError, TypeError) as exc:
raise RuntimeError("FREECAD_PREVIEW_MESH_INVALID") from exc
ab = tuple(float(b[i]) - float(a[i]) for i in range(3))
ac = tuple(float(c[i]) - float(a[i]) for i in range(3))
normal = (
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
)
magnitude = max(math.sqrt(sum(item * item for item in normal)), 1e-12)
shade = 0.42 + 0.48 * abs(
sum(normal[i] * light[i] for i in range(3)) / magnitude
)
color = tuple(int(channel * shade) for channel in (96, 158, 210))
draw.polygon(
[(points[index][0], points[index][1]) for index in indices],
fill=color,
outline=(45, 65, 82),
)
temporary = output_path.with_name(output_path.name + ".tmp.png")
image.save(temporary, format="PNG", optimize=True)
os.replace(temporary, output_path)