Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion api/services/mesh_ops/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@
from . import MeshOpContext, mesh_ops_registry


# A workflow-only sentinel: direct mesh-op/API calls retain their original
# decimation behavior and schema. Bound node params may provide an int or HTML
# <select> string, so normalize the value before dispatching to the registry.
WORKFLOW_FULL_DETAIL = 1_000_000


def _normalize_workflow_target_faces(value: object) -> int:
if isinstance(value, bool):
raise ValueError("mesh-optimizer: target_faces must be an integer between 100 and 1000000")
if isinstance(value, int):
target = value
elif isinstance(value, float) and value.is_integer():
target = int(value)
elif isinstance(value, str) and value.strip().isascii() and value.strip().isdecimal():
target = int(value.strip())
else:
raise ValueError("mesh-optimizer: target_faces must be an integer between 100 and 1000000")
if not 100 <= target <= WORKFLOW_FULL_DETAIL:
raise ValueError("mesh-optimizer: target_faces must be between 100 and 1000000")
return target


def _emit(message: dict) -> None:
print(json.dumps(message), flush=True)

Expand All @@ -32,6 +54,20 @@ def run_processor(operation_id: str, processor_id: str) -> None:
f"{processor_id}: input file not found: {input_path}"
)

params = data.get("params") or {}
if operation_id == "decimate" and processor_id == "mesh-optimizer":
target_faces = _normalize_workflow_target_faces(
params.get("target_faces", WORKFLOW_FULL_DETAIL)
)
if target_faces == WORKFLOW_FULL_DETAIL:
_emit({"type": "log", "message": "Full detail selected: original mesh preserved"})
_emit({"type": "progress", "percent": 100, "label": "Done"})
_emit({"type": "done", "result": {"filePath": str(input_path)}})
return
# Canonical mesh-op registry expects numeric values, not HTML
# select strings. Keep other params for workflow compatibility.
params = {**params, "target_faces": target_faces}

workspace_dir = Path(
data.get("workspaceDir")
or os.environ.get("WORKSPACE_DIR")
Expand All @@ -53,7 +89,7 @@ def run_processor(operation_id: str, processor_id: str) -> None:
result = mesh_ops_registry.run(
operation_id,
Path(input_path),
data.get("params") or {},
params,
context,
)
_emit(
Expand Down
74 changes: 74 additions & 0 deletions api/tests/test_mesh_ops_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,79 @@ def test_missing_input_uses_existing_node_error_contract(self) -> None:
self.assertIn("mesh-repair: input file not found: None", message["message"])


def test_optimizer_full_detail_skips_decimation_and_keeps_input(self) -> None:
with tempfile.TemporaryDirectory() as directory:
original = Path(directory) / "original.glb"
original.write_bytes(b"precious mesh")
registry = _FakeRegistry(Path(directory) / "should-not-exist.glb")
for params in ({}, {"target_faces": 1000000}, {"target_faces": "1000000"}):
with self.subTest(params=params):
request = {"input": {"filePath": str(original)}, "params": params}
stdout = io.StringIO()
with (
patch.object(processor, "mesh_ops_registry", registry),
patch.object(processor.sys, "stdin", io.StringIO(json.dumps(request))),
redirect_stdout(stdout),
):
processor.run_processor("decimate", "mesh-optimizer")
output = [json.loads(line) for line in stdout.getvalue().splitlines()]
self.assertEqual([m["type"] for m in output], ["log", "progress", "done"])
self.assertEqual(output[-1]["result"]["filePath"], str(original))
self.assertEqual(original.read_bytes(), b"precious mesh")
self.assertEqual(registry.calls, [])

def test_optimizer_budget_coerces_html_select_values_and_bound_inputs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
original = Path(directory) / "original.glb"
original.write_bytes(b"mesh")
registry = _FakeRegistry(Path(directory) / "optimized.glb")
for value in ("50000", 10000, "12000", 30000.0):
with self.subTest(value=value):
request = {"input": {"filePath": str(original)}, "params": {"target_faces": value}}
out = io.StringIO()
with (
patch.object(processor, "mesh_ops_registry", registry),
patch.object(processor.sys, "stdin", io.StringIO(json.dumps(request))),
redirect_stdout(out),
):
processor.run_processor("decimate", "mesh-optimizer")
messages = [json.loads(line) for line in out.getvalue().splitlines()]
self.assertEqual(messages[-1]["type"], "done")
self.assertEqual(registry.calls[-1][2]["target_faces"], int(value))

def test_optimizer_invalid_budgets_fail_closed_before_registry(self) -> None:
with tempfile.TemporaryDirectory() as directory:
original = Path(directory) / "original.glb"
original.write_bytes(b"mesh")
registry = _FakeRegistry(Path(directory) / "optimized.glb")
for value in ("NaN", "1e6", True, 99, 1000001, -1, 3.5):
with self.subTest(value=value):
request = {"input": {"filePath": str(original)}, "params": {"target_faces": value}}
out = io.StringIO()
with (
patch.object(processor, "mesh_ops_registry", registry),
patch.object(processor.sys, "stdin", io.StringIO(json.dumps(request))),
redirect_stdout(out),
):
processor.run_processor("decimate", "mesh-optimizer")
messages = [json.loads(line) for line in out.getvalue().splitlines()]
self.assertEqual(messages[-1]["type"], "error")
self.assertNotIn('"type": "done"', out.getvalue())
self.assertEqual(registry.calls, [])

def test_optimizer_requires_real_input_even_when_full_detail(self) -> None:
with tempfile.TemporaryDirectory() as directory:
request = {"input": {"filePath": str(Path(directory) / "missing.glb")}, "params": {}}
out = io.StringIO()
with (
patch.object(processor.sys, "stdin", io.StringIO(json.dumps(request))),
redirect_stdout(out),
):
processor.run_processor("decimate", "mesh-optimizer")
messages = [json.loads(line) for line in out.getvalue().splitlines()]
self.assertEqual(messages[-1]["type"], "error")



if __name__ == "__main__":
unittest.main()
21 changes: 17 additions & 4 deletions api/tests/test_mesh_ops_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,23 @@ def test_workflow_manifests_share_registry_schemas_and_thin_adapters(self) -> No
(extension_dir / "manifest.json").read_text(encoding="utf-8")
)
self.assertEqual(manifest["entry"], "processor.py")
self.assertEqual(
manifest["nodes"][0]["params_schema"],
descriptions[operation_id]["params_schema"],
)
workflow_schema = manifest["nodes"][0]["params_schema"]
api_schema = descriptions[operation_id]["params_schema"]
if operation_id == "decimate":
# A workflow-only quality selector now maps to the unchanged
# shared mesh-op API (numeric target_faces with old defaults).
field = workflow_schema[0]
self.assertEqual(field["id"], "target_faces")
self.assertEqual(field["type"], "select")
self.assertEqual(field["default"], 1000000)
self.assertEqual(
[option["value"] for option in field["options"]],
[1000000, 12000, 30000, 60000, 120000],
)
self.assertEqual(api_schema[0]["type"], "int")
self.assertEqual(api_schema[0]["default"], 10000)
else:
self.assertEqual(workflow_schema, api_schema)

wrapper = (extension_dir / "processor.py").read_text(encoding="utf-8")
self.assertIn(
Expand Down
20 changes: 14 additions & 6 deletions docs/experimental-library-integration-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ Modly repository. The adaptations in this fork do not supersede their source PRs
- **#246 (Chat + asset context):** its old Ollama agent backend does not match
current 0.4.3 llama.cpp/chat architecture. The workflow runner input routing
also changed. Do not paste the old agent router or silently drop context.
- **#250 (Optimizer preset semantics):** the source PR targets a legacy
`processor.ts`, whereas 0.4.3 uses a Python workflow adapter feeding a shared
mesh-op registry. A "full detail/no simplification" preset needs a reviewed
end-to-end contract that does not alter unrelated direct mesh API calls.
- **#251 (Workflow parameter binding and library sync):** stack depends on the
#250 behavior. Port only after defining stable override precedence and tests.
- **#250** is now staged in child branch `integration/optimizer-presets-2026-10-10`.
The current Python workflow adapter implements full-detail pass-through without
changing the shared mesh-op API, and normalizes numeric budgets from HTML selects.
Four regression tests were added to the project's Python suite; five isolated
Python checks passed on a reproduction of the changed adapter.
- **#251** is now staged on the same child branch in two focused commits:
freshest-value parameter bindings, plus shared Library/viewer animation state
and responsive Library placement. Seven isolated Node tests passed for the
binding helper, including orphaned/self references and prototype-safe keys.
Native Electron/browser smoke tests and combined project CI remain unrun.
- **#252 (Artist intent record and texture hot reload):** much larger divergent
patch (workflow and API changes, new watcher services, extra proof assets).
Requires separate rebase and validation against 0.4.3 and #246/#251.
Expand All @@ -45,6 +49,10 @@ Modly repository. The adaptations in this fork do not supersede their source PRs
- [ ] Check Library thumbnails, previews, project/lineage filters and persistent
panel size across restart, with missing/corrupt metadata.
- [ ] Export rigged GLB/OBJ/STL/PLY, verify transforms/tags and project lineage.
- [ ] Verify binding source parameter updates propagate through actual paused
and repeated workflows without hiding missing or invalid source questions.
- [ ] Verify 3D motion-bar and Library previews remain synchronized, including
changing models and project/lineage views.
- [ ] Retry free-text params and object-shaped manifest inputs.
- [ ] Verify source #370 destructive-storage safeguards and related tests in
the final combined branch.
Expand Down
Loading