diff --git a/README.md b/README.md index 4ef505a..5bd5f04 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ ├── exceptions.py # доменные исключения ├── llm/ # LLM бэкенды: ollama.py, openai_backend.py ├── tools/ # встроенные инструменты (~20 шт.) -├── profiles/ # профили агентов: secretary, server_admin, smart_home, developer +├── profiles/ # профили агентов: secretary, server_admin, developer, tool_developer, discuss, modeler_3d ├── core/ # Agent, registry, session, compressor, events ├── memory/ # долгосрочная память (PostgreSQL + pgvector) ├── workers/ # post-turn workers (CompressionWorker, MemoryWorker) @@ -107,6 +107,7 @@ | `developer` | Разработка, анализ кода, архитектура | 0.45 | ✓ | | `tool_developer` | Написание, тестирование и отладка пользовательских инструментов | 0.35 | ✓ | | `discuss` | Свободное обсуждение, мозговой штурм, лёгкие беседы | 0.85 | — | +| `modeler_3d` | 3D-моделирование для 3D-печати (OpenSCAD → STL) | 0.35 | ✓ | `tool_developer` — единственный профиль с `reload_tools`, `delete_tool` и `test_tool`. diff --git a/docs/profiles.md b/docs/profiles.md index 70d0a61..6943065 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -85,6 +85,7 @@ | `developer` | Developer | gemma4:31b-cloud → gemma4:26b-a4b-it-q4_K_M | 0.45 | Yes | | `tool_developer` | Tool Developer | gemma4:31b-cloud → gemma4:26b-a4b-it-q4_K_M | 0.35 | Yes | | `discuss` | Discussion | gemma4:31b-cloud → gemma4:26b-a4b-it-q4_K_M | 0.85 | No | +| `modeler_3d` | 3D Modeler | gemma4:26b-a4b-it-q4_K_M → gemma4:31b-cloud | 0.35 | Yes | All profiles share a base tool set. User tools from `tools/enabled.json` are merged in at runtime. diff --git a/docs/tools.md b/docs/tools.md index 21b8426..84f7131 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -32,6 +32,8 @@ | `ListProfilesTool` | `list_profiles` | List all available profiles | | `ShareFileTool` | `share_file` | Copy an existing local file into session files and return a download link | | `ContentPublishTool` | `content_publish` | Register an existing session file for inline viewing in chat | +| `Model3DTool` | `model_3d` | Compile an OpenSCAD script into a binary STL file | +| `Render3DTool` | `render_3d` | Render preview PNG images from an STL file (up to 3 views) | | `DeleteToolTool` | `delete_tool` | Delete a user tool file | | `TestToolTool` | `test_tool` | Run a user tool and verify its output | | `ReflectTool` | `reflect` | Self-reflection and analysis | diff --git a/manuals/model_3d.md b/manuals/model_3d.md new file mode 100644 index 0000000..7ccfa71 --- /dev/null +++ b/manuals/model_3d.md @@ -0,0 +1,78 @@ +# model_3d + +## Что делает + +Компилирует существующий OpenSCAD-скрипт (`.scad`) в binary STL-файл. + +**Требует:** OpenSCAD установлен в системе (`openscad` в PATH). + +## Предпосылки + +1. **Файл `.scad` уже должен существовать.** Напишите его заранее через `filesystem write`. +2. **OpenSCAD должен быть установлен.** Если нет — инструмент вернёт ошибку `openscad_not_found`. + +## Формат вызова + +```python +model_3d( + scad_path="session_files/{session_id}/handle.scad", + output_path="session_files/{session_id}/handle.stl" +) +``` + +## Параметры + +| Параметр | Обязательно | Описание | +|---|---|---| +| `scad_path` | Да | Путь к существующему `.scad`-файлу | +| `output_path` | Да | Путь, куда записать сгенерированный `.stl`. Родительские директории создаются автоматически. | + +## Workflow + +### 1. Написать OpenSCAD-скрипт + +``` +filesystem write session_files/sess-abc/bracket.scad ' +difference() { + cube([40, 20, 5], center=true); + translate([15, 0, 0]) cylinder(h=6, d=4, center=true); + translate([-15, 0, 0]) cylinder(h=6, d=4, center=true); +} +' +``` + +### 2. Скомпилировать в STL + +``` +model_3d( + scad_path="session_files/sess-abc/bracket.scad", + output_path="session_files/sess-abc/bracket.stl" +) +``` + +### 3. Показать пользователю + +``` +content_publish(filename="bracket.stl", title="Bracket") +``` + +## Что возвращает + +При успехе: +``` +Generated: bracket.stl +Path: /home/.../session_files/sess-abc/bracket.stl +Size: 12.4 KB +``` + +При ошибке: +- `openscad_not_found` — OpenSCAD не установлен +- `scad_not_found` — исходный файл не найден +- `openscad_compile_error` — ошибка компиляции (невалидный CSG, деление на ноль и т.д.) +- `no_output` — OpenSCAD завершился без ошибок, но файл не создался + +## Важные правила + +1. **Всегда binary STL** — нет параметра ASCII/binary, всегда используется `--export-format binstl`. +2. **Скрипт должен существовать до вызова** — `model_3d` не пишет `.scad`, только компилирует. +3. **Ошибки OpenSCAD** читаемы: строка, символ, тип ошибки — всё в stderr. diff --git a/manuals/render_3d.md b/manuals/render_3d.md new file mode 100644 index 0000000..60b8161 --- /dev/null +++ b/manuals/render_3d.md @@ -0,0 +1,97 @@ +# render_3d + +## Что делает + +Рендерит PNG-скриншоты ракурсов из STL-файла через OpenSCAD CLI. + +**Требует:** OpenSCAD установлен в системе (`openscad` в PATH). + +## Предпосылки + +1. **STL-файл уже должен существовать.** Сгенерируйте его через `model_3d` заранее. +2. **OpenSCAD должен быть установлен.** + +## Формат вызова + +```python +render_3d( + source="session_files/{session_id}/bracket.stl", + views=["iso", "front", "top"] +) +``` + +## Параметры + +| Параметр | Обязательно | Описание | +|---|---|---| +| `source` | Да | Путь к существующему `.stl`-файлу | +| `views` | Нет | Список ракурсов (макс. 3). Доступные: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`. По умолчанию `["iso"]`. | + +## Доступные ракурсы + +| Ракурс | Описание | +|---|---| +| `front` | Вид спереди | +| `back` | Вид сзади | +| `top` | Вид сверху | +| `bottom` | Вид снизу | +| `left` | Вид слева | +| `right` | Вид справа | +| `iso` | Изометрия (по умолчанию) | + +## Workflow + +### 1. Сгенерировать STL + +``` +model_3d(scad_path="...", output_path="...") +``` + +### 2. Отрендерить ракурсы + +``` +render_3d( + source="session_files/sess-abc/bracket.stl", + views=["iso", "front", "top"] +) +``` + +### 3. Показать пользователю + +Каждый PNG сохраняется рядом с STL с суффиксом вида: +- `bracket.iso.png` +- `bracket.front.png` +- `bracket.top.png` + +Опубликуйте каждый отдельно: + +``` +content_publish(filename="bracket.iso.png", title="Bracket — Isometric") +content_publish(filename="bracket.front.png", title="Bracket — Front") +content_publish(filename="bracket.top.png", title="Bracket — Top") +``` + +## Что возвращает + +При успехе: +``` +Generated 3 image(s): + bracket.iso.png + bracket.front.png + bracket.top.png +``` + +При ошибке: +- `openscad_not_found` — OpenSCAD не установлен +- `stl_not_found` — исходный файл не найден +- `too_many_views` — больше 3 ракурсов за раз +- `invalid_views` — неизвестное имя ракурса +- `render_failed` — все рендеры не удались + +## Важные правила + +1. **Максимум 3 ракурса за вызов** — если нужно больше, разбейте на несколько вызовов. +2. **Фиксированное разрешение** — 400×300, не настраивается. +3. **PNG сохраняются рядом с STL** — в той же директории, с суффиксом ракурса. +4. **Всегда preview mode** — быстрый рендер, не полный CSG. +5. **Не склеивает в сетку** — каждый ракурс — отдельный файл. Нави сама публикует каждый через `content_publish`. diff --git a/navi/core/registry.py b/navi/core/registry.py index 99f6820..67b5321 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -33,6 +33,8 @@ from navi.tools.write_tool import WriteToolTool from navi.tools.share_file import ShareFileTool from navi.tools.content_publish import ContentPublishTool +from navi.tools.model_3d import Model3DTool +from navi.tools.render_3d import Render3DTool from navi.tools.loader import LoadResult, load_tools_from_dir from navi.tools.logging_middleware import LoggingMiddleware from navi.context_providers._loader import ContextProviderRegistry @@ -177,6 +179,7 @@ builtins = [WebSearchTool(), FilesystemTool(ai_helper=ai_helper), HttpRequestTool(), WebViewTool(), CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), ShareFileTool(), ContentPublishTool(), TestToolTool(), + Model3DTool(), Render3DTool(), TodoTool(), ScratchpadTool(), ReflectTool(ai_helper=ai_helper), reload_tool, write_tool, delete_tool, list_tool, manual_tool] if memory_tool: diff --git a/navi/profiles/modeler_3d/config.json b/navi/profiles/modeler_3d/config.json new file mode 100644 index 0000000..f47bd85 --- /dev/null +++ b/navi/profiles/modeler_3d/config.json @@ -0,0 +1,71 @@ +{ + "id": "modeler_3d", + "name": "3D Modeler", + "description": "Design 3D models for 3D printing: generate, edit, validate, and export STL files with printing-aware geometry.", + "short_description": "3D model design and STL generation for 3D printing — geometric shapes, mechanical parts, organic forms, supports, tolerances.", + "full_description": { + "specialization": "3D geometry and model generation for additive manufacturing (FDM, SLA, resin). Uses Python (CadQuery, trimesh, numpy-stl), OpenSCAD, or raw geometric primitives. Validates for printability: overhangs, wall thickness, support needs, tolerances.", + "when_to_use": "When the user needs a physical object modeled for 3D printing: replacement parts, mechanical assemblies, decorative items, functional prototypes, jigs, fixtures, or custom enclosures.", + "key_tools": "code_exec, filesystem, content_publish, terminal, web_search" + }, + "llm_backend": "ollama", + "model": [ + "gemma4:26b-a4b-it-q4_K_M", + "gemma4:31b-cloud", + "qwen3.6:27b" + ], + "temperature": 0.35, + "max_iterations": 25, + "planning_enabled": true, + "planning_mandatory": false, + "planning_phase1_enabled": true, + "planning_phase2_enabled": false, + "planning_phase3_enabled": true, + "think_enabled": true, + "iteration_budget_enabled": true, + "goal_anchoring_enabled": true, + "goal_anchoring_interval": 5, + "anti_stall_enabled": true, + "anti_stall_threshold": 6, + "step_validation_enabled": false, + "adaptive_replan_enabled": false, + "subagent_planning_enabled": false, + "subagent_tools": [ + "todo", + "scratchpad", + "reflect", + "web_search", + "web_view", + "filesystem", + "code_exec", + "terminal", + "image_view", + "content_publish", + "model_3d", + "render_3d" + ], + "enabled_tools": [ + "todo", + "scratchpad", + "reflect", + "switch_profile", + "list_profiles", + "web_search", + "web_view", + "filesystem", + "code_exec", + "terminal", + "image_view", + "memory", + "list_tools", + "tool_manual", + "spawn_agent", + "share_file", + "content_publish", + "model_3d", + "render_3d" + ], + "top_k": 30, + "top_p": 0.85, + "num_thread": 11 +} diff --git a/navi/profiles/modeler_3d/system_prompt.txt b/navi/profiles/modeler_3d/system_prompt.txt new file mode 100644 index 0000000..4fe47a4 --- /dev/null +++ b/navi/profiles/modeler_3d/system_prompt.txt @@ -0,0 +1,46 @@ +You are a 3D model designer specialized in additive manufacturing (3D printing). You create printable geometry — not artistic renders, but real-world objects that must survive slicing, support generation, and the physical print process. + +## Your tools + +You have dedicated tools for 3D modeling. Use them in this exact order: + +1. **`filesystem write`** — write the OpenSCAD script (`.scad`) to the session directory. +2. **`model_3d`** — compile the `.scad` into a binary `.stl`. +3. **`content_publish`** — show the user the STL in an interactive 3D viewer. +4. **`render_3d`** — generate PNG previews from different angles so both you and the user can inspect the model. +5. **`content_publish`** again — publish each PNG so the user sees the renders. + +## Workflow + +1. **Clarify the request** — ask the user for critical dimensions, tolerances, material (PLA/ABS/PETG/TPU/Resin), and printer capabilities (bed size, nozzle diameter) if not provided. +2. **Plan the geometry** — break the model into OpenSCAD primitives and boolean operations. Sketch dimensions. +3. **Write OpenSCAD** — use `filesystem write` to save the `.scad` script to the session directory. +4. **Compile STL** — call `model_3d(scad_path=..., output_path=...)`. +5. **Publish STL** — call `content_publish(filename="...stl")` so the user sees the 3D viewer. +6. **Render previews** — call `render_3d(source="...stl", views=["iso","front","top"])` to generate PNGs. +7. **Publish previews** — call `content_publish` on each PNG (e.g., `bracket.iso.png`). +8. **Validate** — if you need programmatic checks (watertight, manifold, dimensions), use `code_exec` with `trimesh` on the STL. + +## OpenSCAD is your primary engine + +Write `.scad` scripts using constructive solid geometry (CSG). You know the full OpenSCAD language: primitives (`cube`, `sphere`, `cylinder`, `polyhedron`), transformations (`translate`, `rotate`, `scale`, `mirror`), booleans (`union`, `difference`, `intersection`), modules, loops, conditionals. + +Do NOT write Python scripts to generate STL. OpenSCAD is the direct and reliable path. + +## Printability rules + +| Concern | Guideline | +|---|---| +| Overhangs | >45° needs supports; design away from them when possible | +| Bridges | Max ~10mm without support for 0.4mm nozzle | +| Wall thickness | Min 2× nozzle diameter (0.8mm for 0.4mm nozzle) | +| Hole tolerance | +0.2mm to +0.4mm clearance for press-fit parts | +| Bed adhesion | Add chamfers or fillets at base; avoid sharp points touching bed | +| Orientation | Design for the print orientation the user will actually use | + +## Output discipline + +- Always produce a **single STL file** per request unless the user explicitly asks for an assembly. +- Name files descriptively: `bracket_20x40_m3.stl`, not `model.stl`. +- After publishing, do NOT re-describe the geometry in text — the user sees the 3D preview and renders. Provide only dimensions, material notes, and print orientation advice. +- Do NOT paste OpenSCAD code into your text response after publishing — the user can inspect the file via filesystem if needed. diff --git a/navi/tools/model_3d.py b/navi/tools/model_3d.py new file mode 100644 index 0000000..e61a2cf --- /dev/null +++ b/navi/tools/model_3d.py @@ -0,0 +1,107 @@ +"""model_3d — generate an STL file from an OpenSCAD script. + +Requires OpenSCAD installed on the system: + Arch: sudo pacman -S openscad + Debian: sudo apt install openscad +""" + +import asyncio +import shutil +from pathlib import Path + +from .base import Tool, ToolResult + + +class Model3DTool(Tool): + name = "model_3d" + description = ( + "Generate a binary STL file from an existing OpenSCAD (.scad) script. " + "The script must already be written to disk; this tool only compiles it.\n\n" + "Workflow:\n" + "1. Write your .scad script with the filesystem tool.\n" + "2. Call model_3d with the scad_path and desired output_path.\n" + "3. The resulting STL is saved to output_path.\n\n" + "Use content_publish on the STL to show the user an interactive 3D viewer." + ) + parameters = { + "type": "object", + "properties": { + "scad_path": { + "type": "string", + "description": ( + "Absolute or relative path to the existing .scad file. " + "The file must already exist." + ), + }, + "output_path": { + "type": "string", + "description": ( + "Absolute or relative path where the STL should be written. " + "Parent directories are created automatically." + ), + }, + }, + "required": ["scad_path", "output_path"], + } + + async def execute(self, params: dict) -> ToolResult: + if not shutil.which("openscad"): + return ToolResult( + success=False, + output="OpenSCAD is not installed on this system.", + error="openscad_not_found", + ) + + scad_path = Path(params["scad_path"]).expanduser().resolve() + output_path = Path(params["output_path"]).expanduser().resolve() + + if not scad_path.exists(): + return ToolResult( + success=False, + output=f"SCAD file not found: {scad_path}", + error="scad_not_found", + ) + if not scad_path.is_file(): + return ToolResult( + success=False, + output=f"Path is not a file: {scad_path}", + error="not_a_file", + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + + proc = await asyncio.create_subprocess_exec( + "openscad", + "--export-format", "binstl", + "-o", str(output_path), + str(scad_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode != 0: + err = (stderr.decode(errors="replace") or "OpenSCAD exited with an error.").strip() + return ToolResult( + success=False, + output=f"OpenSCAD failed to compile STL:\n{err}", + error="openscad_compile_error", + ) + + if not output_path.exists(): + return ToolResult( + success=False, + output="OpenSCAD completed but no STL file was produced.", + error="no_output", + ) + + size_kb = output_path.stat().st_size / 1024 + return ToolResult( + success=True, + output=( + f"Generated: {output_path.name}\n" + f"Path: {output_path}\n" + f"Size: {size_kb:.1f} KB" + ), + metadata={"output_path": str(output_path), "size_kb": round(size_kb, 1)}, + ) diff --git a/navi/tools/render_3d.py b/navi/tools/render_3d.py new file mode 100644 index 0000000..968ac79 --- /dev/null +++ b/navi/tools/render_3d.py @@ -0,0 +1,159 @@ +"""render_3d — render preview images from an STL file using OpenSCAD. + +Requires OpenSCAD installed on the system: + Arch: sudo pacman -S openscad + Debian: sudo apt install openscad +""" + +import asyncio +import shutil +from pathlib import Path + +from .base import Tool, ToolResult + +# Camera presets: (rot_x, rot_y, rot_z) +_CAMERA_PRESETS: dict[str, tuple[int, int, int]] = { + "front": (0, 0, 0), + "back": (0, 0, 180), + "top": (90, 0, 0), + "bottom": (-90, 0, 0), + "left": (0, 0, -90), + "right": (0, 0, 90), + "iso": (55, 0, 45), +} + +_IMG_W, _IMG_H = 400, 300 +_MAX_VIEWS = 3 + + +class Render3DTool(Tool): + name = "render_3d" + description = ( + "Render preview images from an STL file using OpenSCAD. " + "Produces one PNG per requested view.\n\n" + "Workflow:\n" + "1. Generate the STL with model_3d.\n" + "2. Call render_3d with the STL path and desired views.\n" + "3. PNG files are saved next to the STL with view suffixes.\n" + "4. Use content_publish on each PNG to show the user the render.\n\n" + "Available views: front, back, top, bottom, left, right, iso." + ) + parameters = { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Path to the STL file to render. Must exist.", + }, + "views": { + "type": "array", + "items": {"type": "string", "enum": list(_CAMERA_PRESETS.keys())}, + "description": ( + "List of camera views to render. " + f"Maximum {_MAX_VIEWS} views per call. " + "Default: [\"iso\"]" + ), + }, + }, + "required": ["source"], + } + + async def execute(self, params: dict) -> ToolResult: + if not shutil.which("openscad"): + return ToolResult( + success=False, + output="OpenSCAD is not installed on this system.", + error="openscad_not_found", + ) + + source = Path(params["source"]).expanduser().resolve() + if not source.exists(): + return ToolResult( + success=False, + output=f"STL file not found: {source}", + error="stl_not_found", + ) + if not source.is_file(): + return ToolResult( + success=False, + output=f"Path is not a file: {source}", + error="not_a_file", + ) + + views = params.get("views") or ["iso"] + if len(views) > _MAX_VIEWS: + return ToolResult( + success=False, + output=f"Too many views: {len(views)} (max {_MAX_VIEWS}).", + error="too_many_views", + ) + + invalid = [v for v in views if v not in _CAMERA_PRESETS] + if invalid: + return ToolResult( + success=False, + output=f"Unknown views: {invalid}. Available: {list(_CAMERA_PRESETS.keys())}.", + error="invalid_views", + ) + + generated: list[str] = [] + errors: list[str] = [] + + for view in views: + rot_x, rot_y, rot_z = _CAMERA_PRESETS[view] + out_png = source.with_suffix(f".{view}.png") + + # Build a temporary SCAD that imports the STL + tmp_scad = source.with_suffix(f".{view}.tmp.scad") + tmp_scad.write_text(f'import("{source}");\n', encoding="utf-8") + + proc = await asyncio.create_subprocess_exec( + "openscad", + "--camera", f"0,0,0,{rot_x},{rot_y},{rot_z},500", + "--autocenter", + "--viewall", + "--imgsize", f"{_IMG_W},{_IMG_H}", + "--preview", + "-o", str(out_png), + str(tmp_scad), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + # Clean up temp SCAD immediately + try: + tmp_scad.unlink() + except Exception: + pass + + if proc.returncode != 0: + err = stderr.decode(errors="replace").strip() or "OpenSCAD render error" + errors.append(f"{view}: {err}") + continue + + if out_png.exists(): + generated.append(str(out_png)) + else: + errors.append(f"{view}: no PNG produced") + + if errors and not generated: + return ToolResult( + success=False, + output="All renders failed:\n" + "\n".join(errors), + error="render_failed", + ) + + lines = [f"Generated {len(generated)} image(s):"] + for p in generated: + lines.append(f" {Path(p).name}") + if errors: + lines.append("\nErrors:") + for e in errors: + lines.append(f" {e}") + + return ToolResult( + success=bool(generated), + output="\n".join(lines), + metadata={"generated": generated}, + )