- Fase A: license templates, search history, cost estimator - Fase B: import URL, bulk ZIP, batch download - Fase C: comparison mode, mesh validation, measurement tool - Fase D: cross-section clipping, overhang heatmap, layer animation - Refactor Pydantic/SQLAlchemy warnings - 24 tests pytest - README actualizado - WebP thumbnails, lazy loading, cache headers
128 lines
3.9 KiB
Python
128 lines
3.9 KiB
Python
import struct
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw
|
|
import os
|
|
|
|
|
|
def parse_stl(file_path: str):
|
|
"""Parse an STL file (binary or ASCII) and return mesh data + metadata."""
|
|
with open(file_path, 'rb') as f:
|
|
header = f.read(80)
|
|
|
|
is_binary = False
|
|
if not header.startswith(b'solid'):
|
|
is_binary = True
|
|
else:
|
|
# Some binary files also start with 'solid', check further
|
|
with open(file_path, 'rb') as f:
|
|
f.read(80)
|
|
tri_count_bytes = f.read(4)
|
|
if len(tri_count_bytes) == 4:
|
|
tri_count = struct.unpack('<I', tri_count_bytes)[0]
|
|
file_size = os.path.getsize(file_path)
|
|
expected = 80 + 4 + tri_count * 50
|
|
if file_size == expected:
|
|
is_binary = True
|
|
|
|
if is_binary:
|
|
return _parse_binary(file_path)
|
|
else:
|
|
return _parse_ascii(file_path)
|
|
|
|
|
|
def _parse_binary(file_path: str):
|
|
with open(file_path, 'rb') as f:
|
|
f.read(80) # skip header
|
|
tri_count = struct.unpack('<I', f.read(4))[0]
|
|
|
|
vertices = []
|
|
for _ in range(tri_count):
|
|
f.read(12) # normal
|
|
v1 = struct.unpack('<3f', f.read(12))
|
|
v2 = struct.unpack('<3f', f.read(12))
|
|
v3 = struct.unpack('<3f', f.read(12))
|
|
f.read(2) # attribute byte count
|
|
vertices.extend([v1, v2, v3])
|
|
|
|
vertices = np.array(vertices, dtype=np.float32)
|
|
return _compute_metadata(vertices, tri_count)
|
|
|
|
|
|
def _parse_ascii(file_path: str):
|
|
vertices = []
|
|
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
for line in f:
|
|
line = line.strip().lower()
|
|
if line.startswith('vertex'):
|
|
parts = line.split()
|
|
if len(parts) >= 4:
|
|
v = [float(parts[1]), float(parts[2]), float(parts[3])]
|
|
vertices.append(v)
|
|
|
|
vertices = np.array(vertices, dtype=np.float32)
|
|
tri_count = len(vertices) // 3
|
|
return _compute_metadata(vertices, tri_count)
|
|
|
|
|
|
def _compute_metadata(vertices: np.ndarray, tri_count: int):
|
|
if len(vertices) == 0:
|
|
return {
|
|
'vertices': vertices,
|
|
'faces': 0,
|
|
'width': 0.0,
|
|
'height': 0.0,
|
|
'depth': 0.0,
|
|
}
|
|
|
|
min_v = vertices.min(axis=0)
|
|
max_v = vertices.max(axis=0)
|
|
dims = max_v - min_v
|
|
|
|
return {
|
|
'vertices': vertices,
|
|
'faces': tri_count,
|
|
'width': float(dims[0]),
|
|
'height': float(dims[1]),
|
|
'depth': float(dims[2]),
|
|
}
|
|
|
|
|
|
def generate_thumbnail(vertices: np.ndarray, output_path: str, size: int = 256):
|
|
"""Generate a simple orthographic thumbnail from vertices."""
|
|
if len(vertices) == 0:
|
|
img = Image.new('RGB', (size, size), color=(30, 30, 30))
|
|
img.save(output_path)
|
|
return
|
|
|
|
# Project to XY plane, normalize to image coords
|
|
min_v = vertices.min(axis=0)
|
|
max_v = vertices.max(axis=0)
|
|
dims = max_v - min_v
|
|
scale = max(dims[0], dims[1])
|
|
if scale == 0:
|
|
scale = 1.0
|
|
|
|
margin = 20
|
|
img_size = size - 2 * margin
|
|
|
|
img = Image.new('RGB', (size, size), color=(30, 30, 30))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Draw triangles
|
|
for i in range(0, len(vertices), 3):
|
|
tri = vertices[i:i+3]
|
|
pts = []
|
|
for v in tri:
|
|
x = margin + int(((v[0] - min_v[0]) / scale) * img_size)
|
|
y = margin + int(((1.0 - (v[1] - min_v[1]) / scale)) * img_size)
|
|
pts.append((x, y))
|
|
if len(pts) == 3:
|
|
# Simple shading based on Z
|
|
z_avg = sum(v[2] for v in tri) / 3.0
|
|
z_norm = (z_avg - min_v[2]) / (dims[2] if dims[2] > 0 else 1)
|
|
brightness = int(80 + z_norm * 120)
|
|
color = (brightness, brightness, int(brightness * 1.1))
|
|
draw.polygon(pts, fill=color, outline=(50, 50, 60))
|
|
|
|
img.save(output_path, 'PNG')
|