diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index af9c2be..7d68b56 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -1,5 +1,6 @@ """Asset management router.""" +import json import os from pathlib import Path from typing import Optional @@ -64,6 +65,177 @@ def _sanitize_filename(filename: str) -> str: return safe or "file" +def _validate_gltf_json(content: bytes) -> dict: + """Validate that content is a valid .gltf file (JSON-based glTF). + + Returns the parsed JSON dict on success. + Raises ValueError if the file is not valid glTF. + """ + try: + data = json.loads(content) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in .gltf file: {e}") + + if not isinstance(data, dict): + raise ValueError("glTF file root must be a JSON object") + + if "asset" not in data: + raise ValueError("Missing required 'asset' property in glTF file") + + if "scenes" not in data and "meshes" not in data and "nodes" not in data: + # glTF 2.0 spec: at least one of scenes, meshes, nodes, accessors, + # bufferViews, buffers, materials, textures, samplers, skin, + # animations, or extensions must be present. + # For simplicity, we require at least one of scenes/meshes/nodes. + raise ValueError( + "glTF file must contain at least one of: scenes, meshes, or nodes" + ) + + return data + + +def _validate_glb(content: bytes) -> dict: + """Validate that content is a valid .glb file (binary glTF). + + Returns the parsed JSON chunk data on success. + Raises ValueError if the file is not valid glTF. + """ + if len(content) < 12: + raise ValueError("glb file too short") + + # Check magic bytes: must start with b'glTF' + magic = content[:4] + if magic != b'glTF': + raise ValueError( + f"Invalid glb magic bytes: expected b'glTF', got {magic!r}" + ) + + # Version check (we support version 2) + version = int.from_bytes(content[4:8], byteorder='little') + if version != 2: + raise ValueError(f"Unsupported glTF version: {version} (expected 2)") + + # Total length check + total_length = int.from_bytes(content[8:12], byteorder='little') + if total_length != len(content): + raise ValueError( + f"glb total length mismatch: header says {total_length}, " + f"actual is {len(content)}" + ) + + # Parse JSON chunk + # Chunk format: length (4 bytes) + type (4 bytes) + data + offset = 12 + json_chunk_data = None + + while offset < len(content): + if offset + 8 > len(content): + raise ValueError("Incomplete chunk header in glb file") + + chunk_length = int.from_bytes(content[offset:offset+4], byteorder='little') + chunk_type = content[offset+4:offset+8] + + if chunk_length + 8 > len(content) - offset: + raise ValueError("Chunk extends beyond file end") + + if chunk_type == b'JSON': + json_chunk_data = content[offset+8:offset+8+chunk_length] + # JSON chunk may have padding + break + elif chunk_type == b'BIN': + # Skip binary chunk + offset += 8 + chunk_length + continue + else: + # Unknown chunk type - skip it + offset += 8 + chunk_length + continue + + if json_chunk_data is None: + raise ValueError("No JSON chunk found in glb file") + + try: + data = json.loads(json_chunk_data) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in glb chunk: {e}") + + if not isinstance(data, dict): + raise ValueError("glTF file root must be a JSON object") + + if "asset" not in data: + raise ValueError("Missing required 'asset' property in glTF file") + + return data + + +def _extract_metadata(data: dict) -> dict: + """Extract metadata from parsed glTF data. + + Returns a dict with: + - bounding_box: {"min": [...], "max": [...]} or null + - vertex_count: int + - material_count: int + - has_animation: bool + """ + metadata = { + "bounding_box": None, + "vertex_count": 0, + "material_count": 0, + "has_animation": False, + } + + # Count materials + if "materials" in data and isinstance(data["materials"], list): + metadata["material_count"] = len(data["materials"]) + + # Check for animations + if "animations" in data and isinstance(data["animations"], list): + metadata["has_animation"] = len(data["animations"]) > 0 + + # Count vertices from meshes + vertex_count = 0 + bounds_min = None + bounds_max = None + + if "meshes" in data and isinstance(data["meshes"], list): + for mesh in data["meshes"]: + if "primitives" in mesh and isinstance(mesh["primitives"], list): + for primitive in mesh["primitives"]: + # Accessor index for positions + pos_accessor_idx = primitive.get("attributes", {}).get("POSITION") + if pos_accessor_idx is not None: + accessors = data.get("accessors", []) + if isinstance(accessors, list) and 0 <= pos_accessor_idx < len(accessors): + accessor = accessors[pos_accessor_idx] + count = accessor.get("count", 0) + vertex_count += count + + # Extract bounding box from min/max + if "min" in accessor and "max" in accessor: + acc_min = accessor["min"] + acc_max = accessor["max"] + if bounds_min is None: + bounds_min = acc_min + bounds_max = acc_max + else: + bounds_min = [ + min(a, b) for a, b in zip(bounds_min, acc_min) + ] + bounds_max = [ + max(a, b) for a, b in zip(bounds_max, acc_max) + ] + + metadata["vertex_count"] = vertex_count + + if bounds_min is not None and bounds_max is not None: + metadata["bounding_box"] = { + "min": bounds_min, + "max": bounds_max, + } + + return metadata + + # --- Endpoints --- @router.post("/", response_model=AssetResponse, status_code=201) @@ -207,3 +379,86 @@ async def upload_asset_file( await db.commit() await db.refresh(asset) return asset + + +@router.post("/import-gltf", response_model=AssetResponse, status_code=201) +async def import_gltf( + current_user: CurrentUser, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), +): + """Import a glTF or glb file as an asset. + + Accepts a .gltf or .glb file, validates it, extracts metadata + (bounding box, vertex count, material count, animation flag), + stores the file, and returns an AssetResponse with the new asset's ID. + """ + filename = file.filename or "" + lower_filename = filename.lower() + + # Validate file extension + if not (lower_filename.endswith(".gltf") or lower_filename.endswith(".glb")): + raise HTTPException( + status_code=400, + detail="File must be a .gltf or .glb file", + ) + + # Validate file size against configured maximum. + max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + content = await file.read() + file_size = len(content) + if file_size > max_bytes: + raise HTTPException( + status_code=413, + detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes", + ) + + # Validate file content based on format + try: + if lower_filename.endswith(".gltf"): + gltf_data = _validate_gltf_json(content) + else: + gltf_data = _validate_glb(content) + except ValueError as e: + raise HTTPException( + status_code=400, + detail=f"Invalid glTF file: {e}", + ) + + # Extract metadata + metadata = _extract_metadata(gltf_data) + + # Create the asset record + asset = Asset( + name=Path(filename).stem, + description=None, + asset_type=AssetType.MODEL_GLTF, + file_path="", # Will be set after file storage + owner_id=current_user.id, + world_id=None, + is_public=False, + ) + db.add(asset) + await db.commit() + await db.refresh(asset) + + # Sanitize filename for storage + safe_filename = _sanitize_filename(filename) + extension = ".gltf" if lower_filename.endswith(".gltf") else ".glb" + stored_name = f"{asset.id}_{safe_filename}{extension}" + + # Write the file to the upload directory + upload_dir = Path(settings.UPLOAD_DIR) + upload_dir.mkdir(parents=True, exist_ok=True) + file_path = upload_dir / stored_name + file_path.write_bytes(content) + + # Update asset with file info and metadata + asset.file_path = str(file_path) + asset.file_size = file_size + asset.mime_type = "model/gltf+json" if lower_filename.endswith(".gltf") else "model/gltf+binary" + asset.metadata_json = json.dumps(metadata) + + await db.commit() + await db.refresh(asset) + return asset