feat: implementar 12 mejoras, tests, docs y optimizaciones
- 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
This commit is contained in:
208
app/parsers.py
Normal file
208
app/parsers.py
Normal file
@@ -0,0 +1,208 @@
|
||||
import struct
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
import os
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def parse_stl_file(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:
|
||||
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)
|
||||
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)
|
||||
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 parse_3mf_file(file_path: str):
|
||||
"""Parse a 3MF file (zip with XML) and return mesh data + metadata."""
|
||||
vertices = []
|
||||
tri_count = 0
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, 'r') as zf:
|
||||
model_path = None
|
||||
for name in zf.namelist():
|
||||
if name.endswith('3dmodel.model') or name.endswith('.model'):
|
||||
model_path = name
|
||||
break
|
||||
if not model_path:
|
||||
raise ValueError("No 3D model found in 3MF archive")
|
||||
|
||||
with zf.open(model_path) as mf:
|
||||
tree = ET.parse(mf)
|
||||
root = tree.getroot()
|
||||
|
||||
# Find namespace
|
||||
ns = {'m': root.tag.split('}')[0].strip('{') if '}' in root.tag else ''}
|
||||
if ns['m']:
|
||||
ns = {'m': ns['m']}
|
||||
mesh = root.find('.//m:mesh', ns)
|
||||
else:
|
||||
mesh = root.find('.//mesh')
|
||||
|
||||
if mesh is None:
|
||||
raise ValueError("No mesh found in 3MF model")
|
||||
|
||||
verts_elem = mesh.find('m:vertices', ns) if ns else mesh.find('vertices')
|
||||
if verts_elem is not None:
|
||||
tag = 'm:vertex' if ns else 'vertex'
|
||||
for v in verts_elem.findall(tag, ns) if ns else verts_elem.findall(tag):
|
||||
x = float(v.get('x', 0))
|
||||
y = float(v.get('y', 0))
|
||||
z = float(v.get('z', 0))
|
||||
vertices.append([x, y, z])
|
||||
|
||||
tris_elem = mesh.find('m:triangles', ns) if ns else mesh.find('triangles')
|
||||
if tris_elem is not None:
|
||||
tag = 'm:triangle' if ns else 'triangle'
|
||||
tri_verts = []
|
||||
for t in tris_elem.findall(tag, ns) if ns else tris_elem.findall(tag):
|
||||
v1 = int(t.get('v1', 0))
|
||||
v2 = int(t.get('v2', 0))
|
||||
v3 = int(t.get('v3', 0))
|
||||
tri_verts.extend([vertices[v1], vertices[v2], vertices[v3]])
|
||||
vertices = tri_verts
|
||||
tri_count = len(tris_verts) // 3
|
||||
else:
|
||||
vertices = []
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse 3MF: {str(e)}")
|
||||
|
||||
vertices = np.array(vertices, dtype=np.float32)
|
||||
return _compute_metadata(vertices, tri_count)
|
||||
|
||||
|
||||
def parse_model_file(file_path: str):
|
||||
"""Auto-detect format and parse."""
|
||||
lower = file_path.lower()
|
||||
if lower.endswith('.stl'):
|
||||
return parse_stl_file(file_path)
|
||||
elif lower.endswith('.3mf'):
|
||||
return parse_3mf_file(file_path)
|
||||
else:
|
||||
raise ValueError("Unsupported file format. Only STL and 3MF are supported.")
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
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))
|
||||
|
||||
fmt = 'WEBP' if output_path.lower().endswith('.webp') else 'PNG'
|
||||
img.save(output_path, fmt)
|
||||
|
||||
|
||||
def generate_generic_thumbnail(output_path: str, size: int = 256, label: str = "3D"):
|
||||
"""Generate a generic thumbnail for unsupported previews."""
|
||||
img = Image.new('RGB', (size, size), color=(30, 30, 30))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([40, 40, size-40, size-40], outline=(6, 182, 212), width=3)
|
||||
try:
|
||||
from PIL import ImageFont
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0,0), label, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
draw.text(((size - text_w) // 2, (size - text_h) // 2), label, fill=(6, 182, 212), font=font)
|
||||
img.save(output_path, 'PNG')
|
||||
Reference in New Issue
Block a user