From 465ef60f59af739b2cb8d2c315a3e1fb1615141b Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 09:03:25 +0100 Subject: [PATCH 1/2] feat(mesh-ops): intent-driven presets for printing, game and web Add prepare_for_printing, prepare_for_game and prepare_for_web as registry ops in a new "presets" category. Each composes the existing ops (repair_mesh, semantic transforms, decimate) plus a normals recompute, writes a single output file and reports every step in its details. They are exposed to the chat agent, and the system prompt routes printing/game/web intents to them in one call. Closes #299 --- api/routers/agent.py | 1 + api/services/mesh_ops/agent_format.py | 22 ++ api/services/mesh_ops/builtin.py | 123 ++++++++++- api/services/mesh_ops/presets.py | 224 +++++++++++++++++++ api/tests/test_mesh_ops_presets.py | 299 ++++++++++++++++++++++++++ api/tests/test_mesh_ops_registry.py | 8 +- 6 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 api/services/mesh_ops/presets.py create mode 100644 api/tests/test_mesh_ops_presets.py diff --git a/api/routers/agent.py b/api/routers/agent.py index 1eabd6b3..2bdd04c1 100644 --- a/api/routers/agent.py +++ b/api/routers/agent.py @@ -110,6 +110,7 @@ def registry_tool_lines(registry: MeshOpsRegistry) -> str: - Always use tools to act on the scene — never just describe what you would do. - If you need the current mesh path, call get_mesh_info first. +- To prepare a mesh for 3D printing, a game engine or the web, call prepare_for_printing, prepare_for_game or prepare_for_web ONCE: each runs every step itself, so never chain repair_mesh, arrange_mesh or decimate for these intents, and no suggest_fixes is needed first. Pass the size or triangle budget only if the user gave one. Report any remaining issue it lists. - To repair or fix a mesh, call repair_mesh. Report the issues it could not fix, and warn the user when it says the textures were dropped. - To center, ground, lay flat or resize a mesh, call arrange_mesh ONCE with every requested step — never one call per step. - To answer questions about a mesh's quality or printability, call inspect_mesh — never guess. Before repairing a mesh, call suggest_fixes to pick the right operations. diff --git a/api/services/mesh_ops/agent_format.py b/api/services/mesh_ops/agent_format.py index 1940cee6..b0f5a4eb 100644 --- a/api/services/mesh_ops/agent_format.py +++ b/api/services/mesh_ops/agent_format.py @@ -58,3 +58,25 @@ def format_repair(data: Mapping[str, Any]) -> str: if data.get("textures_dropped"): lines.append("The textures and UVs were dropped: the repaired mesh is geometry only.") return "\n".join(lines) + + +def format_preset(data: Mapping[str, Any]) -> str: + """Render a prepare_for_* preset result: each step, then the outcome.""" + lines = [f"- {step['summary']}" for step in data.get("steps") or [] if step.get("summary")] + remaining = data.get("remaining_issues") or [] + if "watertight" in data: + if not data["watertight"]: + lines.append("The mesh is NOT watertight yet.") + else: + lines.append("The mesh is watertight" + ("." if remaining else " and ready to slice.")) + if remaining: + lines.append("Still remaining: " + "; ".join(remaining) + ".") + if data.get("dimensions_mm"): + lines.append("Size: " + " × ".join(f"{d:.4g}" for d in data["dimensions_mm"]) + " mm.") + elif data.get("dimensions"): + lines.append("Size: " + " × ".join(f"{d:.4g}" for d in data["dimensions"]) + " m.") + faces = data.get("face_count") + lines.append(f"New mesh path: {data['path']}" + (f" ({faces:,} faces)." if faces else ".")) + if data.get("textures_dropped"): + lines.append("The textures and UVs were dropped: the repaired mesh is geometry only.") + return "\n".join(lines) diff --git a/api/services/mesh_ops/builtin.py b/api/services/mesh_ops/builtin.py index 07678b63..68bc1530 100644 --- a/api/services/mesh_ops/builtin.py +++ b/api/services/mesh_ops/builtin.py @@ -1,8 +1,9 @@ """Built-in mesh operation definitions.""" -from .agent_format import format_fixes, format_inspection, format_repair +from .agent_format import format_fixes, format_inspection, format_preset, format_repair from .inspection import inspect_mesh, suggest_fixes from .operations import decimate_mesh, repair_mesh, smooth_mesh +from .presets import prepare_for_game, prepare_for_printing, prepare_for_web from .repair import auto_repair_mesh from .transforms import auto_orient, drop_to_ground, normalize_scale, recenter from .types import AgentTool, MeshOp @@ -177,6 +178,73 @@ ) +DIMENSION_PARAM = { + "id": "dimension", + "label": "Dimension", + "type": "select", + "default": "longest", + "options": [ + {"value": "longest", "label": "Longest side"}, + {"value": "x", "label": "Width (X)"}, + {"value": "y", "label": "Height (Y)"}, + {"value": "z", "label": "Depth (Z)"}, + ], + "tooltip": "Which bounding-box dimension is set to the target size.", +} + + +PREPARE_FOR_PRINTING_PARAMS = ( + { + "id": "size_mm", + "label": "Size (mm)", + "type": "float", + "default": 100.0, + "min": 1.0, + "max": 10000.0, + "step": 1.0, + "tooltip": "Printed size of the chosen dimension, in millimetres.", + }, + DIMENSION_PARAM, +) + +PREPARE_FOR_GAME_PARAMS = ( + { + "id": "target_faces", + "label": "Poly Budget", + "type": "int", + "default": 20000, + "min": 100, + "max": 1000000, + "tooltip": "Maximum number of triangles the game asset may keep.", + }, + { + "id": "size_m", + "label": "Size (m)", + "type": "float", + "default": 1.0, + "min": 0.01, + "max": 1000.0, + "step": 0.1, + "tooltip": "In-game size of the chosen dimension, in metres.", + }, + DIMENSION_PARAM, +) + +PREPARE_FOR_WEB_PARAMS = ( + { + "id": "target_faces", + "label": "Poly Budget", + "type": "int", + "default": 5000, + "min": 100, + "max": 200000, + "tooltip": "Maximum number of triangles: fewer means a lighter download.", + }, +) + +DIMENSION_HINT = "Which side gets the size: longest, x (width), y (height) or z (depth)." + + BUILTIN_MESH_OPS = ( MeshOp( id="repair", @@ -287,4 +355,57 @@ fn=normalize_scale, category="transform", ), + MeshOp( + id="prepare_for_printing", + label="Prepare for 3D Printing", + params_schema=PREPARE_FOR_PRINTING_PARAMS, + fn=prepare_for_printing, + category="presets", + agent=AgentTool( + description=( + "Make a mesh ready for 3D printing in one call: repair it until it is " + "watertight and manifold, lay it flat, scale it in millimetres and rest it " + "on the build plate. Writes a new mesh without textures." + ), + params={ + "size_mm": "Printed size of `dimension`, in millimetres.", + "dimension": DIMENSION_HINT, + }, + format_result=format_preset, + ), + ), + MeshOp( + id="prepare_for_game", + label="Prepare for Game", + params_schema=PREPARE_FOR_GAME_PARAMS, + fn=prepare_for_game, + category="presets", + agent=AgentTool( + description=( + "Make a mesh ready for a game engine in one call: decimate it to a " + "triangle budget, scale it in metres and recompute its normals." + ), + params={ + "target_faces": "Triangle budget of the game asset.", + "size_m": "In-game size of `dimension`, in metres.", + "dimension": DIMENSION_HINT, + }, + format_result=format_preset, + ), + ), + MeshOp( + id="prepare_for_web", + label="Prepare for Web", + params_schema=PREPARE_FOR_WEB_PARAMS, + fn=prepare_for_web, + category="presets", + agent=AgentTool( + description=( + "Make a mesh light enough for the web in one call: decimate it " + "aggressively and recompute its normals. Keeps its textures and size." + ), + params={"target_faces": "Triangle budget; lower means a lighter download."}, + format_result=format_preset, + ), + ), ) diff --git a/api/services/mesh_ops/presets.py b/api/services/mesh_ops/presets.py new file mode 100644 index 00000000..f2715188 --- /dev/null +++ b/api/services/mesh_ops/presets.py @@ -0,0 +1,224 @@ +"""Intent-driven presets: one call chains the ops a common goal needs. + +Each preset composes the registry's own op functions; intermediate files live +in a temporary directory, so the preset writes a single output mesh. +""" + +import tempfile +from dataclasses import replace +from pathlib import Path +from typing import Any, Mapping, Optional + +from .geometry import merged_geometry, scene_meshes +from .operations import _output_path, decimate_mesh +from .repair import auto_repair_mesh +from .transforms import UNIT_SCALE, _trimesh, apply_transforms +from .types import MeshOpContext, MeshOpExecutionError, MeshOpResult + + +DIMENSION_NAMES = {"longest": "longest side", "x": "width", "y": "height", "z": "depth"} + + +def _stage( + context: MeshOpContext, + start: int, + end: int, + output_path: Optional[Path] = None, +) -> MeshOpContext: + """A context for one step: its 0-100 progress maps onto start-end.""" + progress_cb = None + if context.progress_cb is not None: + def progress_cb(percent: int, label: str) -> None: + context.progress(start + (end - start) * int(percent) // 100, label) + + return replace(context, output_path=output_path, progress_cb=progress_cb) + + +def _scale(size: float, unit: str, dimension: str) -> dict[str, Any]: + return {"size": float(size), "unit": unit, "dimension": dimension} + + +def _arrange_steps(applied, requested: Mapping[str, Mapping[str, Any]]) -> list[dict[str, Any]]: + summaries = { + "auto_orient": lambda _: "Laid flat on its widest side.", + "recenter": lambda _: "Centred on the origin.", + "drop_to_ground": lambda _: "Dropped onto the ground (lowest point at height 0).", + "normalize_scale": lambda p: ( + f"Scaled the {DIMENSION_NAMES[p['dimension']]} to {p['size']:g} {p['unit']}." + ), + } + return [ + {"op": step_id, **requested[step_id], "summary": summaries[step_id](requested[step_id])} + for step_id in applied + ] + + +def recompute_normals(input_path: Path, output_path: Path) -> Path: + """Rewrite every vertex normal from the face winding, keeping UVs and materials. + + Decimation moves vertices but keeps their old normals, and generated meshes + often ship with none or with inverted ones; area-weighted normals derived + from the faces are what game engines and web viewers shade with. + """ + import numpy as np + + trimesh = _trimesh() + loaded = trimesh.load(str(input_path)) + geometries = ( + loaded.geometry.values() if isinstance(loaded, trimesh.Scene) else [loaded] + ) + for geometry in geometries: + if not isinstance(geometry, trimesh.Trimesh) or not len(geometry.faces): + continue + triangles = geometry.triangles + face_normals = trimesh.util.unitize( + np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]) + ) + geometry.vertex_normals = trimesh.geometry.mean_vertex_normals( + len(geometry.vertices), geometry.faces, face_normals + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + loaded.export(str(output_path), include_normals=True) + return output_path + + +def _measure(path: Path) -> tuple[int, list[float]]: + trimesh = _trimesh() + parts = scene_meshes(trimesh.load(str(path)), trimesh) + if not parts: + raise MeshOpExecutionError(f"preset: {path.name} contains no triangle mesh") + world = merged_geometry(parts, trimesh, process=False) + return int(len(world.faces)), [float(value) for value in world.extents] + + +def prepare_for_printing( + input_path: Path, + params: Mapping[str, Any], + context: MeshOpContext, +) -> MeshOpResult: + """Repair until watertight, lay flat, scale in millimetres, rest on the plate.""" + output_path = _output_path(context, "prepare-for-printing") + with tempfile.TemporaryDirectory(dir=str(context.temp_dir)) as scratch: + context.log("Preset: prepare for printing") + repaired = auto_repair_mesh( + input_path, + {"fill_holes": True}, + _stage(context, 0, 60, Path(scratch) / "repaired.glb"), + ) + repair = dict(repaired.details) + + requested = { + "auto_orient": {}, + "normalize_scale": _scale(params["size_mm"], "mm", params["dimension"]), + "drop_to_ground": {}, + } + arranged = apply_transforms( + repaired.file_path or input_path, + list(requested.items()), + output_path, + _stage(context, 60, 100), + ) + + face_count, dimensions = _measure(output_path) + return MeshOpResult( + file_path=output_path, + details={ + "steps": [ + { + "op": "repair_mesh", + "summary": ( + f"Repaired in {repair['passes']} pass(es) ({', '.join(repair['steps'])})." + if repair.get("repaired") + else "Nothing to repair: the mesh was already closed and manifold." + ), + "repaired": bool(repair.get("repaired")), + "passes": repair.get("passes", 0), + "steps": list(repair.get("steps") or []), + }, + *_arrange_steps(arranged.details["applied"], requested), + ], + "watertight": bool(repair["after"]["is_watertight"]), + "remaining_issues": list(repair.get("remaining_issues") or []), + "face_count": face_count, + "dimensions": dimensions, + "dimensions_mm": [value / UNIT_SCALE["mm"] for value in dimensions], + "textures_dropped": bool(repair.get("textures_dropped")), + }, + ) + + +def _decimate(input_path: Path, target_faces: int, scratch: Path, context: MeshOpContext): + result = decimate_mesh( + input_path, + {"target_faces": int(target_faces)}, + _stage(context, 0, 60, scratch / "decimated.glb"), + ) + faces = result.details.get("face_count") + step = { + "op": "decimate", + "target_faces": int(target_faces), + "face_count": faces, + "summary": ( + f"Decimated to {faces:,} triangles (budget {int(target_faces):,})." + if faces is not None + else f"Decimated to a {int(target_faces):,}-triangle budget." + ), + } + return result.file_path, step + + +NORMALS_STEP = { + "op": "recompute_normals", + "summary": "Recomputed smooth vertex normals from the faces.", +} + + +def _finish(output_path: Path, steps: list[dict[str, Any]]) -> MeshOpResult: + face_count, dimensions = _measure(output_path) + return MeshOpResult( + file_path=output_path, + details={"steps": steps, "face_count": face_count, "dimensions": dimensions}, + ) + + +def prepare_for_game( + input_path: Path, + params: Mapping[str, Any], + context: MeshOpContext, +) -> MeshOpResult: + """Decimate to a poly budget, scale in metres, recompute the normals.""" + output_path = _output_path(context, "prepare-for-game") + with tempfile.TemporaryDirectory(dir=str(context.temp_dir)) as scratch_dir: + scratch = Path(scratch_dir) + context.log("Preset: prepare for game") + decimated, decimate_step = _decimate(input_path, params["target_faces"], scratch, context) + + requested = {"normalize_scale": _scale(params["size_m"], "m", params["dimension"])} + scaled = apply_transforms( + decimated, list(requested.items()), scratch / "scaled.glb", _stage(context, 60, 85) + ) + context.progress(85, "Recomputing normals…") + recompute_normals(scaled.file_path, output_path) + context.progress(100, "Done") + return _finish( + output_path, + [decimate_step, *_arrange_steps(scaled.details["applied"], requested), NORMALS_STEP], + ) + + +def prepare_for_web( + input_path: Path, + params: Mapping[str, Any], + context: MeshOpContext, +) -> MeshOpResult: + """Decimate aggressively for a light download, then recompute the normals.""" + output_path = _output_path(context, "prepare-for-web") + with tempfile.TemporaryDirectory(dir=str(context.temp_dir)) as scratch_dir: + context.log("Preset: prepare for web") + decimated, decimate_step = _decimate( + input_path, params["target_faces"], Path(scratch_dir), context + ) + context.progress(80, "Recomputing normals…") + recompute_normals(decimated, output_path) + context.progress(100, "Done") + return _finish(output_path, [decimate_step, NORMALS_STEP]) diff --git a/api/tests/test_mesh_ops_presets.py b/api/tests/test_mesh_ops_presets.py new file mode 100644 index 00000000..8c874151 --- /dev/null +++ b/api/tests/test_mesh_ops_presets.py @@ -0,0 +1,299 @@ +import asyncio +import hashlib +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import httpx +import numpy as np +import trimesh + +import routers.agent as agent +from services.mesh_ops import MeshOpContext, MeshOpResult, mesh_ops_registry +from services.mesh_ops import presets +from services.mesh_ops.inspection import _file_has_normals + + +HAS_PYMESHLAB = importlib.util.find_spec("pymeshlab") is not None +PRESET_IDS = ("prepare_for_printing", "prepare_for_game", "prepare_for_web") + + +def _holed_tilted_plate() -> trimesh.Trimesh: + """A 2 × 0.2 × 1 plate missing one face, tilted 30° and moved off the origin.""" + plate = trimesh.creation.box(extents=[2.0, 0.2, 1.0]) + plate.update_faces(np.arange(1, len(plate.faces))) + plate.apply_transform( + trimesh.transformations.rotation_matrix(np.radians(30), [0, 0, 1]) + ) + plate.apply_translation([3.0, 4.0, 5.0]) + return plate + + +def _sphere_with_inverted_normals(radius: float = 1.5) -> trimesh.Trimesh: + sphere = trimesh.creation.icosphere(subdivisions=3, radius=radius) + sphere.vertex_normals = -sphere.vertex_normals + return sphere + + +def _fake_decimate(calls: list): + """Stands in for the Node/meshoptimizer backend: copies the mesh as is.""" + + def decimate(input_path, params, context): + calls.append({"input": Path(input_path), "params": dict(params)}) + loaded = trimesh.load(str(input_path)) + loaded.export(str(context.output_path)) + faces = sum(len(g.faces) for g in loaded.geometry.values()) + return MeshOpResult(file_path=Path(context.output_path), details={"face_count": faces}) + + return decimate + + +def _stored_normals(path: Path) -> tuple[np.ndarray, np.ndarray]: + """The vertices and the normals written in the file (not recomputed).""" + scene = trimesh.load(str(path), process=False) + geometry = next(iter(scene.geometry.values())) + return np.asarray(geometry.vertices), np.asarray(geometry.vertex_normals) + + +class PresetRegistryTests(unittest.TestCase): + def test_presets_are_registered_in_their_own_category(self) -> None: + descriptions = {d["id"]: d for d in mesh_ops_registry.describe()} + for preset_id in PRESET_IDS: + with self.subTest(preset_id): + description = descriptions[preset_id] + self.assertEqual(description["category"], "presets") + self.assertTrue(description["undoable"]) + self.assertFalse(description["destructive"]) + self.assertIsNotNone(mesh_ops_registry.get(preset_id).agent) + + def test_param_schemas_expose_the_intent_knobs(self) -> None: + def params(op_id): + return {p["id"]: p for p in mesh_ops_registry.get(op_id).params_schema} + + printing = params("prepare_for_printing") + self.assertEqual(set(printing), {"size_mm", "dimension"}) + self.assertEqual(printing["size_mm"]["default"], 100.0) + + game = params("prepare_for_game") + self.assertEqual(set(game), {"target_faces", "size_m", "dimension"}) + + web = params("prepare_for_web") + self.assertEqual(set(web), {"target_faces"}) + # "Aggressive": the web budget is well below the game one. + self.assertLess(web["target_faces"]["default"], game["target_faces"]["default"]) + + def test_presets_become_agent_tools_and_are_named_in_the_prompt(self) -> None: + tools = {t["function"]["name"]: t["function"] for t in agent.registry_tools(mesh_ops_registry)} + for preset_id in PRESET_IDS: + with self.subTest(preset_id): + self.assertIn(preset_id, tools) + self.assertIn(preset_id, agent.SYSTEM_PROMPT) + self.assertIn("size_mm", tools["prepare_for_printing"]["parameters"]["properties"]) + self.assertIn("target_faces", tools["prepare_for_web"]["parameters"]["properties"]) + + +class _PresetCase(unittest.TestCase): + def setUp(self) -> None: + self._directory = tempfile.TemporaryDirectory() + self.workspace = Path(self._directory.name) + self.context = MeshOpContext(workspace_dir=self.workspace, temp_dir=self.workspace) + + def tearDown(self) -> None: + self._directory.cleanup() + + def _export(self, geometry, name="mesh.glb", **kwargs) -> Path: + path = self.workspace / name + geometry.export(str(path), **kwargs) + return path + + def _run(self, op_id: str, path: Path, **params) -> MeshOpResult: + files_before = set(self.workspace.rglob("*")) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + result = mesh_ops_registry.run(op_id, path, params, self.context) + + self.assertIsNotNone(result.file_path) + self.assertTrue(result.file_path.is_file()) + # One output file, no leftover intermediates, input untouched. + new_files = {p for p in self.workspace.rglob("*") if p.is_file()} - files_before + self.assertEqual(new_files, {result.file_path}) + self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), digest) + json.dumps(dict(result.details)) + return result + + +@unittest.skipUnless(HAS_PYMESHLAB, "pymeshlab is not installed") +class PrepareForPrintingTests(_PresetCase): + def test_holed_tilted_mesh_becomes_printable_flat_grounded_and_sized(self) -> None: + result = self._run("prepare_for_printing", self._export(_holed_tilted_plate()), size_mm=50) + details = result.details + + self.assertEqual( + [step["op"] for step in details["steps"]], + ["repair_mesh", "auto_orient", "normalize_scale", "drop_to_ground"], + ) + self.assertTrue(details["steps"][0]["repaired"]) + self.assertTrue(details["watertight"]) + self.assertEqual(details["remaining_issues"], []) + + report = mesh_ops_registry.run( + "inspect_mesh", result.file_path, {"check_self_intersections": False}, self.context + ).details + self.assertTrue(report["is_watertight"], report["issues"]) + self.assertTrue(report["is_manifold"], report["issues"]) + bounds = np.asarray([report["bounds"]["min"], report["bounds"]["max"]]) + extents = bounds[1] - bounds[0] + # Longest side 50 mm (glTF stores metres), lying flat on the ground. + self.assertAlmostEqual(float(extents.max()), 0.05, places=6) + self.assertAlmostEqual(float(bounds[0][1]), 0.0, places=6) + self.assertAlmostEqual(float(extents[1]), 0.005, places=5) + np.testing.assert_allclose(details["dimensions_mm"], extents * 1000, atol=1e-6) + + def test_sound_mesh_skips_the_repair_but_is_still_arranged(self) -> None: + box = trimesh.creation.box(extents=[1.0, 2.0, 3.0]) + box.apply_translation([0.0, 5.0, 0.0]) + result = self._run("prepare_for_printing", self._export(box)) + + repair = result.details["steps"][0] + self.assertEqual(repair["op"], "repair_mesh") + self.assertFalse(repair["repaired"]) + self.assertTrue(result.details["watertight"]) + bounds = trimesh.load(str(result.file_path)).bounds + self.assertAlmostEqual(float((bounds[1] - bounds[0]).max()), 0.1, places=6) + self.assertAlmostEqual(float(bounds[0][1]), 0.0, places=6) + + +class PrepareForGameAndWebTests(_PresetCase): + def setUp(self) -> None: + super().setUp() + self.decimate_calls: list = [] + patcher = mock.patch.object(presets, "decimate_mesh", _fake_decimate(self.decimate_calls)) + patcher.start() + self.addCleanup(patcher.stop) + + def _assert_outward_normals(self, path: Path) -> None: + self.assertTrue(_file_has_normals(path)) + vertices, normals = _stored_normals(path) + centre = vertices.mean(axis=0) + dots = np.einsum("ij,ij->i", normals, vertices - centre) + self.assertTrue(np.all(dots > 0), "normals were not recomputed outwards") + + def test_game_decimates_to_the_budget_scales_in_metres_and_fixes_normals(self) -> None: + path = self._export(_sphere_with_inverted_normals(), include_normals=True) + result = self._run("prepare_for_game", path, target_faces=640, size_m=2.0) + + self.assertEqual(len(self.decimate_calls), 1) + self.assertEqual(self.decimate_calls[0]["input"], path) + self.assertEqual(self.decimate_calls[0]["params"], {"target_faces": 640}) + self.assertEqual( + [step["op"] for step in result.details["steps"]], + ["decimate", "normalize_scale", "recompute_normals"], + ) + self.assertEqual(result.details["face_count"], 1280) + extents = trimesh.load(str(result.file_path)).extents + self.assertAlmostEqual(float(extents.max()), 2.0, places=6) + np.testing.assert_allclose(result.details["dimensions"], extents, atol=1e-6) + self._assert_outward_normals(result.file_path) + + def test_web_decimates_aggressively_and_keeps_the_scale(self) -> None: + path = self._export(_sphere_with_inverted_normals(radius=1.5), include_normals=True) + result = self._run("prepare_for_web", path) + + default_budget = mesh_ops_registry.resolve_params("prepare_for_web", {})["target_faces"] + self.assertEqual(self.decimate_calls[0]["params"], {"target_faces": default_budget}) + self.assertEqual( + [step["op"] for step in result.details["steps"]], + ["decimate", "recompute_normals"], + ) + extents = trimesh.load(str(result.file_path)).extents + self.assertAlmostEqual(float(extents.max()), 3.0, places=2) + self._assert_outward_normals(result.file_path) + + def test_textures_survive_the_web_preset(self) -> None: + from PIL import Image + + box = trimesh.creation.box() + image = Image.new("RGB", (4, 4), (255, 0, 0)) + uv = np.random.default_rng(0).random((len(box.vertices), 2)) + box.visual = trimesh.visual.TextureVisuals(uv=uv, image=image) + result = self._run("prepare_for_web", self._export(box)) + + geometry = next(iter(trimesh.load(str(result.file_path)).geometry.values())) + self.assertIsInstance(geometry.visual, trimesh.visual.TextureVisuals) + self.assertIsNotNone(geometry.visual.uv) + + +class PresetAgentToolTests(unittest.TestCase): + def _run(self, name: str, arguments: dict, response: dict): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=response) + + real_client = httpx.AsyncClient + + def factory(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport(handler) + return real_client(*args, **kwargs) + + with mock.patch.object(agent.httpx, "AsyncClient", factory): + text, payload = asyncio.run(agent.execute_tool(name, arguments, {})) + return text, payload, requests + + def test_printing_preset_runs_in_one_call_and_reports_each_step(self) -> None: + text, payload, requests = self._run( + "prepare_for_printing", + {"path": "Default/mesh.glb", "size_mm": 80}, + { + "path": "Default/mesh_prepare_for_printing_1.glb", + "url": "/workspace/Default/mesh_prepare_for_printing_1.glb", + "steps": [ + {"op": "repair_mesh", "summary": "Repaired in 1 pass(es) (fill_holes).", "repaired": True}, + {"op": "auto_orient", "summary": "Laid flat on its widest side."}, + {"op": "normalize_scale", "summary": "Scaled the longest side to 80 mm."}, + {"op": "drop_to_ground", "summary": "Dropped onto the build plate."}, + ], + "watertight": True, + "remaining_issues": [], + "face_count": 12, + "dimensions_mm": [80.0, 8.0, 40.0], + "textures_dropped": True, + }, + ) + + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0].url.path, "/optimize/op/prepare_for_printing") + self.assertEqual( + json.loads(requests[0].content), + {"path": "Default/mesh.glb", "params": {"size_mm": 80}}, + ) + self.assertIn("Repaired in 1 pass(es)", text) + self.assertIn("Scaled the longest side to 80 mm", text) + self.assertIn("watertight", text) + self.assertIn("80 × 8 × 40 mm", text) + self.assertIn("New mesh path: Default/mesh_prepare_for_printing_1.glb", text) + self.assertIn("textures and UVs were dropped", text) + self.assertEqual(payload["type"], "mesh_update") + + def test_open_issues_left_after_printing_prep_are_reported(self) -> None: + text, _, _ = self._run( + "prepare_for_printing", + {"path": "Default/mesh.glb"}, + { + "path": "Default/out.glb", + "url": "/workspace/Default/out.glb", + "steps": [], + "watertight": False, + "remaining_issues": ["1 hole(s) (40 open boundary edges)"], + "face_count": 12, + }, + ) + self.assertIn("NOT watertight", text) + self.assertIn("1 hole(s)", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_mesh_ops_registry.py b/api/tests/test_mesh_ops_registry.py index 333c7d89..bc2e5733 100644 --- a/api/tests/test_mesh_ops_registry.py +++ b/api/tests/test_mesh_ops_registry.py @@ -30,10 +30,16 @@ def test_builtin_metadata_is_serializable_and_complete(self) -> None: "drop_to_ground", "auto_orient", "normalize_scale", + "prepare_for_printing", + "prepare_for_game", + "prepare_for_web", ], ) for description in descriptions: - self.assertIn(description["category"], {"repair", "optimization", "analysis", "transform"}) + self.assertIn( + description["category"], + {"repair", "optimization", "analysis", "transform", "presets"}, + ) self.assertFalse(description["destructive"]) self.assertTrue(description["undoable"]) self.assertIsInstance(description["params_schema"], list) From 27bd96060a7a83b8831df2c4beb4ce0e6b593bb7 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 09:08:58 +0100 Subject: [PATCH 2/2] fix(mesh-ops): turn inside-out faces outwards and create a missing temp dir in presets recompute_normals now fixes the face winding before deriving normals, so a mesh exported inside out comes out with outward normals. The presets also create context.temp_dir when a workflow points it at a folder that does not exist yet. --- api/services/mesh_ops/presets.py | 19 +++++++++++++++---- api/tests/test_mesh_ops_presets.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/api/services/mesh_ops/presets.py b/api/services/mesh_ops/presets.py index f2715188..13d3cfbf 100644 --- a/api/services/mesh_ops/presets.py +++ b/api/services/mesh_ops/presets.py @@ -34,6 +34,12 @@ def progress_cb(percent: int, label: str) -> None: return replace(context, output_path=output_path, progress_cb=progress_cb) +def _scratch(context: MeshOpContext) -> tempfile.TemporaryDirectory: + """A scratch directory for intermediate files, under a temp_dir that may not exist yet.""" + Path(context.temp_dir).mkdir(parents=True, exist_ok=True) + return tempfile.TemporaryDirectory(dir=str(context.temp_dir)) + + def _scale(size: float, unit: str, dimension: str) -> dict[str, Any]: return {"size": float(size), "unit": unit, "dimension": dimension} @@ -54,7 +60,9 @@ def _arrange_steps(applied, requested: Mapping[str, Mapping[str, Any]]) -> list[ def recompute_normals(input_path: Path, output_path: Path) -> Path: - """Rewrite every vertex normal from the face winding, keeping UVs and materials. + """Orient the faces outwards, then rewrite every vertex normal from them. + + UVs and materials are kept. Decimation moves vertices but keeps their old normals, and generated meshes often ship with none or with inverted ones; area-weighted normals derived @@ -70,6 +78,9 @@ def recompute_normals(input_path: Path, output_path: Path) -> Path: for geometry in geometries: if not isinstance(geometry, trimesh.Trimesh) or not len(geometry.faces): continue + # Make the winding consistent and outward first: normals derived from + # inside-out faces would point inwards too. + trimesh.repair.fix_normals(geometry, multibody=True) triangles = geometry.triangles face_normals = trimesh.util.unitize( np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]) @@ -98,7 +109,7 @@ def prepare_for_printing( ) -> MeshOpResult: """Repair until watertight, lay flat, scale in millimetres, rest on the plate.""" output_path = _output_path(context, "prepare-for-printing") - with tempfile.TemporaryDirectory(dir=str(context.temp_dir)) as scratch: + with _scratch(context) as scratch: context.log("Preset: prepare for printing") repaired = auto_repair_mesh( input_path, @@ -188,7 +199,7 @@ def prepare_for_game( ) -> MeshOpResult: """Decimate to a poly budget, scale in metres, recompute the normals.""" output_path = _output_path(context, "prepare-for-game") - with tempfile.TemporaryDirectory(dir=str(context.temp_dir)) as scratch_dir: + with _scratch(context) as scratch_dir: scratch = Path(scratch_dir) context.log("Preset: prepare for game") decimated, decimate_step = _decimate(input_path, params["target_faces"], scratch, context) @@ -213,7 +224,7 @@ def prepare_for_web( ) -> MeshOpResult: """Decimate aggressively for a light download, then recompute the normals.""" output_path = _output_path(context, "prepare-for-web") - with tempfile.TemporaryDirectory(dir=str(context.temp_dir)) as scratch_dir: + with _scratch(context) as scratch_dir: context.log("Preset: prepare for web") decimated, decimate_step = _decimate( input_path, params["target_faces"], Path(scratch_dir), context diff --git a/api/tests/test_mesh_ops_presets.py b/api/tests/test_mesh_ops_presets.py index 8c874151..e48107ed 100644 --- a/api/tests/test_mesh_ops_presets.py +++ b/api/tests/test_mesh_ops_presets.py @@ -38,6 +38,13 @@ def _sphere_with_inverted_normals(radius: float = 1.5) -> trimesh.Trimesh: return sphere +def _sphere_with_inverted_winding(radius: float = 1.5) -> trimesh.Trimesh: + """Faces wound inside out, as some generators export them.""" + sphere = trimesh.creation.icosphere(subdivisions=3, radius=radius) + sphere.invert() + return sphere + + def _fake_decimate(calls: list): """Stands in for the Node/meshoptimizer backend: copies the mesh as is.""" @@ -211,6 +218,14 @@ def test_web_decimates_aggressively_and_keeps_the_scale(self) -> None: self.assertAlmostEqual(float(extents.max()), 3.0, places=2) self._assert_outward_normals(result.file_path) + def test_web_turns_inside_out_faces_outwards(self) -> None: + path = self._export(_sphere_with_inverted_winding(), include_normals=True) + result = self._run("prepare_for_web", path) + + self._assert_outward_normals(result.file_path) + mesh = trimesh.load(str(result.file_path), force="mesh") + self.assertGreater(mesh.volume, 0, "faces are still wound inside out") + def test_textures_survive_the_web_preset(self) -> None: from PIL import Image