84 lines
3.3 KiB
Python
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)
|