diff --git a/manuals/render_stl.md b/manuals/render_stl.md index 43f7542..28ca849 100644 --- a/manuals/render_stl.md +++ b/manuals/render_stl.md @@ -103,5 +103,6 @@ 2. **Фиксированное разрешение** — 400×300, не настраивается. 3. **PNG сохраняются рядом с STL** — в той же директории, с суффиксом ракурса. 4. **Всегда preview mode** — быстрый рендер, не полный CSG. -5. **Не склеивает в сетку** — каждый ракурс — отдельный файл. PNG обычно нужны для внутренней проверки; публикуйте их только по явной просьбе пользователя. +5. **Не склеивает в сетку** — каждый ракурс — отдельный файл. PNG нужны для проверки геометрии, и хотя бы один рендер публикуется вместе с STL. 6. **Всегда передавайте `session_id`** — без него инструмент не знает, в какой директории искать файлы. +7. **Кадр нормализуется** — `--autocenter --viewall` подгоняет модель под картинку. По PNG проверяются топология, поза, связность и пропорции, но не абсолютные миллиметры: размеры подтверждаются параметрами, `assert()` и `echo()`. diff --git a/mcp-servers/navi-3d/app/model_compile.py b/mcp-servers/navi-3d/app/model_compile.py index 9f43fd2..4021921 100644 --- a/mcp-servers/navi-3d/app/model_compile.py +++ b/mcp-servers/navi-3d/app/model_compile.py @@ -8,6 +8,45 @@ from .config import Settings +# OpenSCAD writes its cache statistics and timing to stderr on every run. They +# describe the process, not the model, and would drown out ``ECHO:`` output and +# CGAL warnings — the two things a caller can act on. +_NOISE_PREFIXES = ( + "geometries in cache:", + "geometry cache size in bytes:", + "cgal polyhedrons in cache:", + "cgal cache size in bytes:", + "cgal cache insert:", + "cgal cache hit:", + "total rendering time:", +) + +_DIAGNOSTICS_LIMIT = 4000 + + +def _collect_diagnostics(stderr: str, limit: int = _DIAGNOSTICS_LIMIT) -> str: + """Keep the actionable part of OpenSCAD's stderr. + + ``ECHO:`` lines carry values the model asked for with ``echo()``; the + ``Top level object`` block reports whether geometry exists and how many + facets it has; ``WARNING``/``ERROR`` lines report CGAL problems. Without + them a successful compile looks identical for a valid model and for one + that silently produced an empty or broken mesh. + """ + kept: list[str] = [] + for line in stderr.splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.lower().startswith(_NOISE_PREFIXES): + continue + kept.append(line.rstrip()) + + text = "\n".join(kept).strip() + if len(text) > limit: + text = text[:limit].rstrip() + "\n… (diagnostics truncated)" + return text + async def compile_scad( settings: Settings, @@ -87,16 +126,23 @@ } size_kb = stl_path.stat().st_size / 1024 + diagnostics = _collect_diagnostics(stderr.decode(errors="replace")) + + output = ( + f"Generated: {stl_path.name}\n" + f"Path: {stl_path}\n" + f"Size: {size_kb:.1f} KB" + ) + if diagnostics: + output += f"\nDiagnostics:\n{diagnostics}" + return { "success": True, - "output": ( - f"Generated: {stl_path.name}\n" - f"Path: {stl_path}\n" - f"Size: {size_kb:.1f} KB" - ), + "output": output, "error": None, "metadata": { "output_path": str(stl_path), "size_kb": round(size_kb, 1), + "diagnostics": diagnostics, }, } diff --git a/mcp-servers/navi-3d/app/scad_analyze.py b/mcp-servers/navi-3d/app/scad_analyze.py index ec02bd5..ca232c8 100644 --- a/mcp-servers/navi-3d/app/scad_analyze.py +++ b/mcp-servers/navi-3d/app/scad_analyze.py @@ -8,12 +8,18 @@ from .config import Settings _BUILTINS = { - "abs", "acos", "asin", "atan", "atan2", "ceil", "chr", "concat", "cos", "cross", - "cube", "cylinder", "difference", "echo", "else", "exp", "false", "floor", "for", - "function", "hull", "if", "import", "include", "intersection", "len", "let", "ln", - "log", "lookup", "max", "min", "mirror", "module", "norm", "PI", "polyhedron", - "pow", "projection", "rotate", "round", "scale", "search", "sign", "sin", "sphere", - "sqrt", "str", "surface", "tan", "translate", "true", "undef", "union", "use", + "abs", "acos", "asin", "assert", "assign", "atan", "atan2", "ceil", "children", + "chr", "circle", "color", "concat", "cos", "cross", "cube", "cylinder", + "difference", "each", "echo", "else", "exp", "false", "floor", "for", "function", + "group", "hull", "if", "import", "include", "intersection", "intersection_for", + "is_bool", "is_function", "is_list", "is_num", "is_string", "is_undef", "len", + "let", "linear_extrude", "ln", "log", "lookup", "matches", "max", "min", + "minkowski", "mirror", "module", "multmatrix", "norm", "offset", "ord", + "parent_module", "PI", "polygon", "polyhedron", "pow", "preview", "projection", + "rands", "render", "resize", "rotate", "rotate_extrude", "round", "scale", + "search", "sign", "sin", "sphere", "sqrt", "square", "str", "surface", "t", + "tan", "text", "textmetrics", "translate", "true", "undef", "union", "use", + "version", "version_num", "vpd", "vpf", "vpr", "vpt", } _NAMED_ARGS = { "center", "d", "d1", "d2", "h", "r", "r1", "r2", "$fa", "$fn", "$fs", @@ -126,7 +132,7 @@ declared = set(re.findall(r"\b([A-Za-z_]\w*)\s*=", code)) modules = set(re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code)) functions = set(re.findall(r"\bfunction\s+([A-Za-z_]\w*)\s*\(", code)) - loop_vars = set(re.findall(r"\bfor\s*\(\s*([A-Za-z_]\w*)\s*=", code)) + loop_vars = set(re.findall(r"for\s*\(\s*([A-Za-z_]\w*)\s*=", code)) module_params = set() for params in re.findall(r"\bmodule\s+[A-Za-z_]\w*\s*\(([^)]*)\)", code): module_params.update(re.findall(r"\b([A-Za-z_]\w*)\b", params)) diff --git a/mcp-servers/navi-3d/tests/unit/test_model_compile.py b/mcp-servers/navi-3d/tests/unit/test_model_compile.py new file mode 100644 index 0000000..0e566f6 --- /dev/null +++ b/mcp-servers/navi-3d/tests/unit/test_model_compile.py @@ -0,0 +1,84 @@ +"""Unit tests for model_compile.""" + +import asyncio +import shutil + +import pytest + +from app.config import Settings +from app.model_compile import _collect_diagnostics, compile_scad + +HAS_OPENSCAD = shutil.which("openscad") is not None + + +def test_collect_diagnostics_keeps_actionable_lines(): + stderr = ( + "Geometries in cache: 1\n" + "Geometry cache size in bytes: 728\n" + "ECHO: \"bbox_x\", 52\n" + "WARNING: Object may not be a valid 2-manifold and may need repair!\n" + "CGAL Polyhedrons in cache: 0\n" + "CGAL cache size in bytes: 0\n" + "CGAL Cache insert: difference(){cube(size=[40,20,4],center= (46928 bytes)\n" + "CGAL Cache hit: difference(){cube(size=[40,20,4],center= (46928 bytes)\n" + "Total rendering time: 0:00:00.123\n" + " Top level object is a 3D object:\n" + " Facets: 6\n" + ) + text = _collect_diagnostics(stderr) + + assert 'ECHO: "bbox_x", 52' in text + assert "WARNING" in text + assert "Top level object is a 3D object" in text + assert "Facets:" in text + for noise in ( + "Geometries in cache", + "cache size in bytes", + "CGAL Cache insert", + "CGAL Cache hit", + "Total rendering time", + ): + assert noise not in text + + +def test_collect_diagnostics_truncates(): + text = _collect_diagnostics("ECHO: \"x\", 1\n" * 1000, limit=100) + + assert len(text) <= 100 + len("\n… (diagnostics truncated)") + assert text.endswith("(diagnostics truncated)") + + +@pytest.mark.skipif(not HAS_OPENSCAD, reason="openscad is not installed") +def test_compile_surfaces_echo_and_facets(tmp_path, monkeypatch): + monkeypatch.setenv("SESSION_FILES_DIR", str(tmp_path)) + session = tmp_path / "s" + session.mkdir() + (session / "part.scad").write_text( + 'echo("bbox_x", 12);\ncube([12, 8, 4]);\n', + encoding="utf-8", + ) + + result = asyncio.run(compile_scad(Settings(), "s", "part.scad", "part.stl")) + + assert result["success"] is True + assert 'ECHO: "bbox_x", 12' in result["output"] + assert "Facets:" in result["output"] + assert "ECHO" in result["metadata"]["diagnostics"] + + +@pytest.mark.skipif(not HAS_OPENSCAD, reason="openscad is not installed") +def test_failed_assert_stops_compile_with_its_message(tmp_path, monkeypatch): + monkeypatch.setenv("SESSION_FILES_DIR", str(tmp_path)) + session = tmp_path / "s" + session.mkdir() + (session / "part.scad").write_text( + "assert(52 <= 50, \"interface does not fit: 52 > 50\");\ncube([10, 10, 10]);\n", + encoding="utf-8", + ) + + result = asyncio.run(compile_scad(Settings(), "s", "part.scad", "part.stl")) + + assert result["success"] is False + assert result["error"] == "openscad_compile_error" + assert "interface does not fit: 52 > 50" in result["output"] + assert not (session / "part.stl").exists() diff --git a/mcp-servers/navi-3d/tests/unit/test_scad_analyze.py b/mcp-servers/navi-3d/tests/unit/test_scad_analyze.py index 70960f5..545b68f 100644 --- a/mcp-servers/navi-3d/tests/unit/test_scad_analyze.py +++ b/mcp-servers/navi-3d/tests/unit/test_scad_analyze.py @@ -30,3 +30,32 @@ assert not errors assert warnings assert "unused` is defined but never called" in warnings[0] + + +def test_lint_accepts_common_openscad_builtins(): + source = ( + "module part() { children(); }\n" + "linear_extrude(height = 5) offset(r = 1) square(10);\n" + "minkowski() { cube(1); sphere(1); }\n" + "rotate_extrude() polygon([[0, 0], [1, 0], [1, 1]]);\n" + "intersection_for (i = [0 : 2]) rotate([0, 0, i]) cube(1);\n" + 'color("red") render() text("x");\n' + "resize([2, 2, 2]) multmatrix([[1, 0, 0, 0], [0, 1, 0, 0], " + "[0, 0, 1, 0]]) cube(1);\n" + "projection() cube(1);\n" + "part();\n" + ) + errors, _ = _lint_scad_source(source) + assert not errors + + +def test_lint_accepts_assert_and_each(): + source = ( + "values = [1, 2, 3];\n" + "doubled = [each values, each values];\n" + 'assert(len(doubled) > 0, "values must not be empty");\n' + 'echo("sum", doubled);\n' + "for (v = doubled) translate([v, 0, 0]) cube(1);\n" + ) + errors, _ = _lint_scad_source(source) + assert not errors