ventforge 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ventforge-0.2.0/PKG-INFO +160 -0
- ventforge-0.2.0/README.md +129 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/pyproject.toml +1 -1
- {ventforge-0.1.0 → ventforge-0.2.0}/tests/test_export.py +6 -1
- ventforge-0.2.0/tests/test_mcp.py +94 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/tests/test_pattern.py +129 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/export.py +30 -4
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/gui.py +117 -24
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/mcp_server.py +133 -24
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/model.py +84 -11
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/pattern.py +137 -17
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/halftone_fade.vent.json +1 -1
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/hex_honeycomb.vent.json +1 -1
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/spiral_dashes.vent.json +1 -1
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/sunflower.vent.json +1 -1
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/voronoi_stone.vent.json +1 -1
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/wavy_flowlines.vent.json +1 -1
- ventforge-0.2.0/ventforge.egg-info/PKG-INFO +160 -0
- ventforge-0.1.0/PKG-INFO +0 -84
- ventforge-0.1.0/README.md +0 -53
- ventforge-0.1.0/tests/test_mcp.py +0 -12
- ventforge-0.1.0/ventforge.egg-info/PKG-INFO +0 -84
- {ventforge-0.1.0 → ventforge-0.2.0}/LICENSE +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/setup.cfg +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/tests/test_gui_smoke.py +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/__init__.py +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/__main__.py +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/SOURCES.txt +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/dependency_links.txt +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/entry_points.txt +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/requires.txt +0 -0
- {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/top_level.txt +0 -0
ventforge-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ventforge
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Parametric vent pattern designer with native GUI and MCP server
|
|
5
|
+
License-Expression: GPL-3.0-or-later
|
|
6
|
+
Keywords: vent,pattern,3d-printing,cnc,cad,mcp,parametric
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Manufacturing
|
|
9
|
+
Classifier: Topic :: Multimedia :: Graphics :: Editors :: Vector-Based
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: numpy
|
|
17
|
+
Requires-Dist: shapely>=2
|
|
18
|
+
Requires-Dist: ezdxf
|
|
19
|
+
Requires-Dist: pillow
|
|
20
|
+
Requires-Dist: mcp<3,>=2
|
|
21
|
+
Provides-Extra: gui
|
|
22
|
+
Requires-Dist: PySide6-Essentials>=6.5; extra == "gui"
|
|
23
|
+
Provides-Extra: cad
|
|
24
|
+
Requires-Dist: cadquery-ocp-novtk<8.1,>=8.0; extra == "cad"
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Requires-Dist: ventforge[cad,gui]; extra == "all"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: ventforge[all]; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# VentForge
|
|
33
|
+
|
|
34
|
+
Design vent and perforation patterns for 3D printing, laser cutting and CNC, then export them as ready-to-use
|
|
35
|
+
files. Use the desktop editor, or let an AI assistant such as Claude do the designing through the built-in
|
|
36
|
+
MCP server. Both work on the same design file.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **10 layouts:** grid (square, diamond, hexagon), random, path, spiral, concentric, fibonacci, crosslines,
|
|
41
|
+
voronoi stone cracks, flow lines, and custom points.
|
|
42
|
+
- **Hole shapes:** circle, rounded rectangle, hexagon, polygon, star, or your own outline.
|
|
43
|
+
- **Gradients:** vary size, spacing, angle or shape across the plate. Drive them with points, lines or
|
|
44
|
+
curves, with noise, or with a grayscale image.
|
|
45
|
+
- **Boundary:** none, circle, rounded rectangle, or a drawn outline, with margin and clipping.
|
|
46
|
+
- **Fan grilles:** one click for a 40–140 mm fan: holes only over the blades, 4 screw holes, and the
|
|
47
|
+
share of the fan opening left open (airflow).
|
|
48
|
+
- **Keep-outs:** circles or rectangles that stay free of holes (bosses, logos, screws), optionally cut as holes.
|
|
49
|
+
Draw them on the canvas with the Keep-out tool (K; Shift for a circle, right-click to remove).
|
|
50
|
+
- **Machining:** CNC tool radius (rounds inside corners, drops holes the cutter cannot reach) and laser
|
|
51
|
+
kerf compensation for SVG/DXF. Set a minimum feature (about 2× nozzle for 3D printing) to flag holes
|
|
52
|
+
that would close up.
|
|
53
|
+
- **Export:** PNG, SVG, DXF, STEP, STL. You can also wrap the pattern onto a tube or cut it into an
|
|
54
|
+
existing STEP part.
|
|
55
|
+
- **Checks:** hole count, open area, the thinnest wall in mm (marked on the canvas), and warnings where walls
|
|
56
|
+
get thinner than your minimum.
|
|
57
|
+
- **Airflow estimate:** pressure loss of the grille (Idelchik thin perforated plate) at 2 m/s, so two designs
|
|
58
|
+
can be compared by airflow. A rough figure, not a flow simulation.
|
|
59
|
+
- Undo/redo, presets, and a personal library (also from the AI assistant: `save_to_library`).
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
You need Python 3.11 or newer. Install the full app with [uv](https://docs.astral.sh/uv/) (recommended)
|
|
64
|
+
or pipx:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv tool install "ventforge[all]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pipx install "ventforge[all]"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Pick a smaller install if you don't need everything:
|
|
75
|
+
|
|
76
|
+
| You want | Install |
|
|
77
|
+
|-----------------------------------------------------------|-------------------------------|
|
|
78
|
+
| Everything | `ventforge[all]` |
|
|
79
|
+
| Desktop editor | `ventforge[gui]` |
|
|
80
|
+
| STEP/STL export, tube wrap, cut into part (OpenCascade) | `ventforge[cad]` |
|
|
81
|
+
| Only the MCP server with SVG, DXF and PNG export | `ventforge` |
|
|
82
|
+
|
|
83
|
+
## Use the desktop editor
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
ventforge
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
To open a saved design, pass its file: `ventforge my_vent.vent.json`.
|
|
90
|
+
|
|
91
|
+
1. Pick a preset or a layout mode in the side panel.
|
|
92
|
+
2. Drag the sliders. The preview updates as you go.
|
|
93
|
+
3. Pick a file type from the **Export** menu.
|
|
94
|
+
|
|
95
|
+
**Canvas controls**
|
|
96
|
+
|
|
97
|
+
| Action | How |
|
|
98
|
+
|-------------------|---------------------------------------------------------------|
|
|
99
|
+
| Zoom | Mouse wheel, or `+` / `-` |
|
|
100
|
+
| Pan | Drag empty space, middle-drag, or hold `Space` and drag |
|
|
101
|
+
| Zoom to fit | `Z`, or double-click empty space |
|
|
102
|
+
| Measure | `R` (ruler) |
|
|
103
|
+
| Draw an outline | `B`, click the points, `Enter` to finish, `Esc` to cancel |
|
|
104
|
+
| Undo / redo | `Ctrl+Z` / `Ctrl+Y` |
|
|
105
|
+
|
|
106
|
+
Your design saves itself shortly after each change.
|
|
107
|
+
|
|
108
|
+
## Use it with an AI assistant (MCP)
|
|
109
|
+
|
|
110
|
+
You don't need to install anything first, because uv downloads VentForge on demand.
|
|
111
|
+
|
|
112
|
+
**Claude Code:**
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
claude mcp add ventforge -- uvx --from "ventforge[all]" ventforge --mcp
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Claude Desktop:** add this to `claude_desktop_config.json`, then restart the app:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"mcpServers": {
|
|
123
|
+
"ventforge": {
|
|
124
|
+
"command": "uvx",
|
|
125
|
+
"args": ["--from", "ventforge[all]", "ventforge", "--mcp"]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Then ask, for example: *"Make a 120 × 80 mm hexagon vent with 4 mm holes that get smaller toward the
|
|
132
|
+
edges, and export it as STEP."*
|
|
133
|
+
|
|
134
|
+
The assistant works on `~/Documents/VentForge/current.vent.json`. If you open that file in the editor
|
|
135
|
+
(or ask the assistant to "open it in the GUI"), you can watch the design change live.
|
|
136
|
+
|
|
137
|
+
**Tools the server offers:** `new_design`, `open_design`, `list_presets`, `save_to_library`, `get_design`,
|
|
138
|
+
`describe_parameters`, `set_params`, `add_control`, `clear_controls`, `fan_grille`,
|
|
139
|
+
`fit_open_area` (solve a parameter for a target open area), `preview`, `export`,
|
|
140
|
+
`wrap_cylinder`, `cut_into_part`, `open_in_gui`.
|
|
141
|
+
|
|
142
|
+
## Project layout
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
ventforge/
|
|
146
|
+
├── model.py design file format: every parameter and its default
|
|
147
|
+
├── pattern.py geometry: places and shapes the holes
|
|
148
|
+
├── export.py PNG, SVG, DXF, STEP and STL output, tube wrap, cut into part
|
|
149
|
+
├── gui.py desktop editor
|
|
150
|
+
├── mcp_server.py MCP tools for AI assistants
|
|
151
|
+
├── __main__.py `ventforge` command (editor, or MCP server with --mcp)
|
|
152
|
+
└── presets/ example designs shipped with the package
|
|
153
|
+
tests/ pytest suite, including a real MCP client/server run
|
|
154
|
+
.github/workflows/ CI on Windows, macOS and Linux; publishes to PyPI on version tags
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Licence
|
|
158
|
+
|
|
159
|
+
GPL-3.0-or-later, see [LICENSE](LICENSE). You may use, change and sell VentForge. Versions you distribute
|
|
160
|
+
must stay open source under the same licence.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# VentForge
|
|
2
|
+
|
|
3
|
+
Design vent and perforation patterns for 3D printing, laser cutting and CNC, then export them as ready-to-use
|
|
4
|
+
files. Use the desktop editor, or let an AI assistant such as Claude do the designing through the built-in
|
|
5
|
+
MCP server. Both work on the same design file.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **10 layouts:** grid (square, diamond, hexagon), random, path, spiral, concentric, fibonacci, crosslines,
|
|
10
|
+
voronoi stone cracks, flow lines, and custom points.
|
|
11
|
+
- **Hole shapes:** circle, rounded rectangle, hexagon, polygon, star, or your own outline.
|
|
12
|
+
- **Gradients:** vary size, spacing, angle or shape across the plate. Drive them with points, lines or
|
|
13
|
+
curves, with noise, or with a grayscale image.
|
|
14
|
+
- **Boundary:** none, circle, rounded rectangle, or a drawn outline, with margin and clipping.
|
|
15
|
+
- **Fan grilles:** one click for a 40–140 mm fan: holes only over the blades, 4 screw holes, and the
|
|
16
|
+
share of the fan opening left open (airflow).
|
|
17
|
+
- **Keep-outs:** circles or rectangles that stay free of holes (bosses, logos, screws), optionally cut as holes.
|
|
18
|
+
Draw them on the canvas with the Keep-out tool (K; Shift for a circle, right-click to remove).
|
|
19
|
+
- **Machining:** CNC tool radius (rounds inside corners, drops holes the cutter cannot reach) and laser
|
|
20
|
+
kerf compensation for SVG/DXF. Set a minimum feature (about 2× nozzle for 3D printing) to flag holes
|
|
21
|
+
that would close up.
|
|
22
|
+
- **Export:** PNG, SVG, DXF, STEP, STL. You can also wrap the pattern onto a tube or cut it into an
|
|
23
|
+
existing STEP part.
|
|
24
|
+
- **Checks:** hole count, open area, the thinnest wall in mm (marked on the canvas), and warnings where walls
|
|
25
|
+
get thinner than your minimum.
|
|
26
|
+
- **Airflow estimate:** pressure loss of the grille (Idelchik thin perforated plate) at 2 m/s, so two designs
|
|
27
|
+
can be compared by airflow. A rough figure, not a flow simulation.
|
|
28
|
+
- Undo/redo, presets, and a personal library (also from the AI assistant: `save_to_library`).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
You need Python 3.11 or newer. Install the full app with [uv](https://docs.astral.sh/uv/) (recommended)
|
|
33
|
+
or pipx:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv tool install "ventforge[all]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pipx install "ventforge[all]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Pick a smaller install if you don't need everything:
|
|
44
|
+
|
|
45
|
+
| You want | Install |
|
|
46
|
+
|-----------------------------------------------------------|-------------------------------|
|
|
47
|
+
| Everything | `ventforge[all]` |
|
|
48
|
+
| Desktop editor | `ventforge[gui]` |
|
|
49
|
+
| STEP/STL export, tube wrap, cut into part (OpenCascade) | `ventforge[cad]` |
|
|
50
|
+
| Only the MCP server with SVG, DXF and PNG export | `ventforge` |
|
|
51
|
+
|
|
52
|
+
## Use the desktop editor
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ventforge
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
To open a saved design, pass its file: `ventforge my_vent.vent.json`.
|
|
59
|
+
|
|
60
|
+
1. Pick a preset or a layout mode in the side panel.
|
|
61
|
+
2. Drag the sliders. The preview updates as you go.
|
|
62
|
+
3. Pick a file type from the **Export** menu.
|
|
63
|
+
|
|
64
|
+
**Canvas controls**
|
|
65
|
+
|
|
66
|
+
| Action | How |
|
|
67
|
+
|-------------------|---------------------------------------------------------------|
|
|
68
|
+
| Zoom | Mouse wheel, or `+` / `-` |
|
|
69
|
+
| Pan | Drag empty space, middle-drag, or hold `Space` and drag |
|
|
70
|
+
| Zoom to fit | `Z`, or double-click empty space |
|
|
71
|
+
| Measure | `R` (ruler) |
|
|
72
|
+
| Draw an outline | `B`, click the points, `Enter` to finish, `Esc` to cancel |
|
|
73
|
+
| Undo / redo | `Ctrl+Z` / `Ctrl+Y` |
|
|
74
|
+
|
|
75
|
+
Your design saves itself shortly after each change.
|
|
76
|
+
|
|
77
|
+
## Use it with an AI assistant (MCP)
|
|
78
|
+
|
|
79
|
+
You don't need to install anything first, because uv downloads VentForge on demand.
|
|
80
|
+
|
|
81
|
+
**Claude Code:**
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
claude mcp add ventforge -- uvx --from "ventforge[all]" ventforge --mcp
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Claude Desktop:** add this to `claude_desktop_config.json`, then restart the app:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"mcpServers": {
|
|
92
|
+
"ventforge": {
|
|
93
|
+
"command": "uvx",
|
|
94
|
+
"args": ["--from", "ventforge[all]", "ventforge", "--mcp"]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Then ask, for example: *"Make a 120 × 80 mm hexagon vent with 4 mm holes that get smaller toward the
|
|
101
|
+
edges, and export it as STEP."*
|
|
102
|
+
|
|
103
|
+
The assistant works on `~/Documents/VentForge/current.vent.json`. If you open that file in the editor
|
|
104
|
+
(or ask the assistant to "open it in the GUI"), you can watch the design change live.
|
|
105
|
+
|
|
106
|
+
**Tools the server offers:** `new_design`, `open_design`, `list_presets`, `save_to_library`, `get_design`,
|
|
107
|
+
`describe_parameters`, `set_params`, `add_control`, `clear_controls`, `fan_grille`,
|
|
108
|
+
`fit_open_area` (solve a parameter for a target open area), `preview`, `export`,
|
|
109
|
+
`wrap_cylinder`, `cut_into_part`, `open_in_gui`.
|
|
110
|
+
|
|
111
|
+
## Project layout
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
ventforge/
|
|
115
|
+
├── model.py design file format: every parameter and its default
|
|
116
|
+
├── pattern.py geometry: places and shapes the holes
|
|
117
|
+
├── export.py PNG, SVG, DXF, STEP and STL output, tube wrap, cut into part
|
|
118
|
+
├── gui.py desktop editor
|
|
119
|
+
├── mcp_server.py MCP tools for AI assistants
|
|
120
|
+
├── __main__.py `ventforge` command (editor, or MCP server with --mcp)
|
|
121
|
+
└── presets/ example designs shipped with the package
|
|
122
|
+
tests/ pytest suite, including a real MCP client/server run
|
|
123
|
+
.github/workflows/ CI on Windows, macOS and Linux; publishes to PyPI on version tags
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Licence
|
|
127
|
+
|
|
128
|
+
GPL-3.0-or-later, see [LICENSE](LICENSE). You may use, change and sell VentForge. Versions you distribute
|
|
129
|
+
must stay open source under the same licence.
|
|
@@ -97,6 +97,8 @@ def test_step_roundtrip_volume(tmp, make, t):
|
|
|
97
97
|
assert len(sols) == 1
|
|
98
98
|
ex._check(shape, "reimport") # full BRepCheck when small, else volume/solid checks
|
|
99
99
|
assert ex.volume(shape) == pytest.approx(expected_volume(res, t), rel=0.005)
|
|
100
|
+
# body named after the file (OCC appends a per-process counter)
|
|
101
|
+
assert f"PRODUCT('{make.__name__} " in p.read_text(errors="ignore")
|
|
100
102
|
|
|
101
103
|
|
|
102
104
|
def test_step_multipart(tmp):
|
|
@@ -168,7 +170,10 @@ def _box_step(path, lx, ly, lz):
|
|
|
168
170
|
ex._write(BRepPrimAPI_MakeBox(gp_Pnt(-lx / 2, -ly / 2, 0), lx, ly, lz).Shape(), path)
|
|
169
171
|
|
|
170
172
|
|
|
171
|
-
|
|
173
|
+
@pytest.mark.parametrize("tiled", [False, True])
|
|
174
|
+
def test_cut_into_part(tmp, monkeypatch, tiled):
|
|
175
|
+
if tiled: # force the split-cut-merge path that big vents (> UNTILED_MAX holes) take
|
|
176
|
+
monkeypatch.setattr(ex, "UNTILED_MAX", 0)
|
|
172
177
|
_box_step(tmp / "box.step", 120, 120, 10)
|
|
173
178
|
pts = _grid(10, 8.0)
|
|
174
179
|
res = _result([Point(x, y).buffer(2, 64) for x, y in pts] + [_hex(0, 0, 3)],
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
"""Runs the MCP server over real stdio, the way Claude talks to it."""
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from mcp_e2e import main
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_mcp_server_end_to_end(tmp_path):
|
|
9
|
+
asyncio.run(main(tmp_path))
|
|
10
|
+
assert (tmp_path / "e2e_preview.png").stat().st_size > 0
|
|
11
|
+
for ext in ("svg", "dxf", "step", "stl"):
|
|
12
|
+
assert (tmp_path / f"e2e.{ext}").stat().st_size > 0
|
|
13
|
+
assert (tmp_path / "e2e_tube.step").stat().st_size > 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_bad_patch_leaves_file_untouched(tmp_path):
|
|
17
|
+
import pytest
|
|
18
|
+
from ventforge import mcp_server as m
|
|
19
|
+
p = tmp_path / "d.vent.json"
|
|
20
|
+
m.new_design(path=str(p))
|
|
21
|
+
before = p.read_bytes()
|
|
22
|
+
with pytest.raises(ValueError):
|
|
23
|
+
m.set_params({"dot": {"shape": "blob"}})
|
|
24
|
+
assert p.read_bytes() == before
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_set_params_keeps_an_edit_saved_meanwhile(tmp_path, monkeypatch):
|
|
28
|
+
"""The editor saving while set_params generates must not be overwritten."""
|
|
29
|
+
import os
|
|
30
|
+
from ventforge import mcp_server as m
|
|
31
|
+
from ventforge.model import Design
|
|
32
|
+
p = tmp_path / "d.vent.json"
|
|
33
|
+
m.new_design(path=str(p))
|
|
34
|
+
real, calls = m._generate, []
|
|
35
|
+
|
|
36
|
+
def generate_while_editor_saves(d):
|
|
37
|
+
if not calls:
|
|
38
|
+
e = Design.load(p)
|
|
39
|
+
e.dot.diameter = 2.5
|
|
40
|
+
e.save(p)
|
|
41
|
+
os.utime(p, ns=(p.stat().st_atime_ns, p.stat().st_mtime_ns + 10**9)) # coarse clocks
|
|
42
|
+
calls.append(1)
|
|
43
|
+
return real(d)
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr(m, "_generate", generate_while_editor_saves)
|
|
46
|
+
m.set_params({"canvas": {"width": 140}})
|
|
47
|
+
d = Design.load(p)
|
|
48
|
+
assert d.dot.diameter == 2.5 and d.canvas.width == 140
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_fit_open_area_hits_target(tmp_path):
|
|
52
|
+
from ventforge import mcp_server as m
|
|
53
|
+
m.new_design(path=str(tmp_path / "f.vent.json"))
|
|
54
|
+
out = m.fit_open_area(0.3)
|
|
55
|
+
assert abs(out["stats"]["open_area_ratio"] - 0.3) < 0.01
|
|
56
|
+
assert m.get_design()["design"]["dot"]["diameter"] == out["dot.diameter"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_fit_open_area_refuses_unreachable_target(tmp_path):
|
|
60
|
+
from ventforge import mcp_server as m
|
|
61
|
+
m.new_design(path=str(tmp_path / "f.vent.json"))
|
|
62
|
+
before = (tmp_path / "f.vent.json").read_text()
|
|
63
|
+
with pytest.raises(ValueError, match="outside"):
|
|
64
|
+
m.fit_open_area(0.3, low=0.5, high=0.6)
|
|
65
|
+
with pytest.raises(ValueError, match="ratio"):
|
|
66
|
+
m.fit_open_area(30)
|
|
67
|
+
with pytest.raises(ValueError, match="unknown parameter"):
|
|
68
|
+
m.fit_open_area(0.3, param="dot.nope")
|
|
69
|
+
assert (tmp_path / "f.vent.json").read_text() == before
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_fan_grille_tool_saves_and_reports_airflow(tmp_path):
|
|
73
|
+
from ventforge import mcp_server as m
|
|
74
|
+
m.new_design(path=str(tmp_path / "f.vent.json"))
|
|
75
|
+
out = m.fan_grille(80)
|
|
76
|
+
d = m.get_design()["design"]
|
|
77
|
+
assert d["canvas"]["width"] == 80 and len(d["keepouts"]) == 4
|
|
78
|
+
assert 0 < out["stats"]["fan_open_ratio"] < 1
|
|
79
|
+
fit = m.fit_open_area(0.5) # targets the fan opening now
|
|
80
|
+
assert abs(fit["stats"]["fan_open_ratio"] - 0.5) < 0.01
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_library_round_trip(tmp_path, monkeypatch):
|
|
84
|
+
from ventforge import mcp_server as m
|
|
85
|
+
monkeypatch.setattr(m, "HOME", tmp_path)
|
|
86
|
+
m.new_design(path=str(tmp_path / "work.vent.json"))
|
|
87
|
+
m.fan_grille(92)
|
|
88
|
+
m.save_to_library("92mm case X")
|
|
89
|
+
assert "92mm case X" in m.list_presets()["library"]
|
|
90
|
+
m.new_design(path=str(tmp_path / "other.vent.json"), preset="92mm case X")
|
|
91
|
+
assert len(m.get_design()["design"]["keepouts"]) == 4
|
|
92
|
+
for bad in ("../evil", "a/b", ""):
|
|
93
|
+
with pytest.raises(ValueError):
|
|
94
|
+
m.save_to_library(bad)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import math
|
|
1
2
|
import time
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
@@ -288,6 +289,40 @@ def test_image_paths_resolve_against_design_file(tmp_path, monkeypatch):
|
|
|
288
289
|
assert loaded.gradients.size.image == str((tmp_path / "fade.png").resolve())
|
|
289
290
|
assert loaded.background.image == loaded.gradients.size.image
|
|
290
291
|
assert generate(loaded).holes
|
|
292
|
+
loaded.save(tmp_path / "x.vent.json") # saving again keeps the path relative (folder stays movable)
|
|
293
|
+
assert '"image": "fade.png"' in (tmp_path / "x.vent.json").read_text()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_missing_image_is_a_clear_error(tmp_path):
|
|
297
|
+
d = Design().patched({"gradients": {"size": {"enabled": True, "image": str(tmp_path / "gone.png")}}})
|
|
298
|
+
with pytest.raises(FileNotFoundError, match="gone.png"):
|
|
299
|
+
generate(d)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_unknown_choice_rejected():
|
|
303
|
+
with pytest.raises(ValueError, match="dot.shape"):
|
|
304
|
+
Design().patched({"dot": {"shape": "hexgon"}})
|
|
305
|
+
with pytest.raises(ValueError, match="rotate_by"):
|
|
306
|
+
Design().patched({"pattern": {"crosslines": {"rotate_by": "row"}}})
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_old_file_with_bad_choice_still_loads(tmp_path):
|
|
310
|
+
# 0.1.0 advertised row/col for crosslines; such files must open, falling back to the default
|
|
311
|
+
p = tmp_path / "old.vent.json"
|
|
312
|
+
p.write_text('{"pattern": {"mode": "crosslines", "crosslines": {"rotate_by": "row"}}}')
|
|
313
|
+
d = Design.load(p)
|
|
314
|
+
assert d.pattern.crosslines.rotate_by == "both" and d.pattern.mode == "crosslines"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_fibonacci_even_density_with_hole():
|
|
318
|
+
d = Design().patched({"canvas": {"width": 200, "height": 200}, "dot": {"diameter": 1},
|
|
319
|
+
"pattern": {"mode": "fibonacci", "fibonacci": {
|
|
320
|
+
"count": 1500, "spread": 2.0, "inner_radius": 30}}})
|
|
321
|
+
r = np.hypot(*np.array([h.centroid.coords[0] for h in generate(d).holes]).T)
|
|
322
|
+
assert r.min() >= 29
|
|
323
|
+
inner = ((r >= 30) & (r < 45)).sum() / (np.pi * (45 ** 2 - 30 ** 2))
|
|
324
|
+
outer = ((r >= 60) & (r < 75)).sum() / (np.pi * (75 ** 2 - 60 ** 2))
|
|
325
|
+
assert inner == pytest.approx(outer, rel=0.15)
|
|
291
326
|
|
|
292
327
|
|
|
293
328
|
def test_huge_concentric_radius_and_path_layers_stay_fast():
|
|
@@ -298,3 +333,97 @@ def test_huge_concentric_radius_and_path_layers_stay_fast():
|
|
|
298
333
|
t = time.perf_counter()
|
|
299
334
|
assert generate(Design().patched(patch)).holes
|
|
300
335
|
assert time.perf_counter() - t < 4, patch # was out-of-memory / 5 s
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@pytest.mark.parametrize("patch", [
|
|
339
|
+
{"canvas": {"width": 2000, "height": 2000}, "pattern": {"grid": {"spacing_x": 1, "spacing_y": 1}}},
|
|
340
|
+
{"canvas": {"width": 3000, "height": 3000}, "pattern": {"mode": "voronoi", "voronoi": {"cell_size": 1}}},
|
|
341
|
+
{"canvas": {"width": 3000, "height": 3000}, "pattern": {"mode": "concentric"}},
|
|
342
|
+
])
|
|
343
|
+
def test_huge_layouts_refused_fast(patch):
|
|
344
|
+
t = time.perf_counter()
|
|
345
|
+
with pytest.raises(ValueError, match="points"):
|
|
346
|
+
generate(Design().patched(patch))
|
|
347
|
+
assert time.perf_counter() - t < 2 # was minutes and gigabytes
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_bad_types_rejected():
|
|
351
|
+
with pytest.raises(ValueError, match="boundary.clip"):
|
|
352
|
+
Design().patched({"boundary": {"clip": "false"}}) # bool("false") would switch clipping on
|
|
353
|
+
with pytest.raises(ValueError, match="dot must be an object"):
|
|
354
|
+
Design().patched({"dot": 5})
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_fan_grille_holes_over_blades_and_screw_holes():
|
|
358
|
+
from ventforge.model import FANS, fan_grille
|
|
359
|
+
d = fan_grille(Design(), 120)
|
|
360
|
+
r = generate(d)
|
|
361
|
+
pitch, screw = FANS[120]
|
|
362
|
+
screws = {(round(c[0], 2), round(c[1], 2)) for c in r.circles if c and abs(c[2] - screw / 2) < 1e-9}
|
|
363
|
+
assert screws == {(sx * pitch / 2, sy * pitch / 2) for sx in (-1, 1) for sy in (-1, 1)}
|
|
364
|
+
opening = shapely.Point(0, 0).buffer(d.boundary.vent_diameter / 2 + 1e-6)
|
|
365
|
+
assert sum(opening.contains(h) for h in r.holes) == len(r.holes) - 4
|
|
366
|
+
assert 0.2 < r.stats["fan_open_ratio"] < 0.8
|
|
367
|
+
with pytest.raises(ValueError, match="fan size"):
|
|
368
|
+
fan_grille(Design(), 77)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_keepout_stays_free():
|
|
372
|
+
d = Design().patched({"keepouts": [{"shape": "rectangle", "x": 10, "y": -5, "width": 30, "height": 12,
|
|
373
|
+
"clearance": 2}]})
|
|
374
|
+
zone = shapely.box(10 - 17, -5 - 8, 10 + 17, -5 + 8).buffer(-1e-6) # touching the edge is fine
|
|
375
|
+
holes = generate(d).holes
|
|
376
|
+
assert holes and not any(zone.intersects(h) for h in holes)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_tool_radius_rounds_corners_and_drops_too_small():
|
|
380
|
+
d = Design().patched({"dot": {"shape": "rounded_rectangle", "corner_radius": 0, "diameter": 4}})
|
|
381
|
+
sharp = generate(d)
|
|
382
|
+
d.machining.tool_radius = 1.0
|
|
383
|
+
r = generate(d)
|
|
384
|
+
assert len(r.holes) == len(sharp.holes)
|
|
385
|
+
assert r.holes[0].area == pytest.approx(16 - (4 - math.pi) * 1.0, rel=0.02) # 4 corners lose (1 - pi/4) r^2
|
|
386
|
+
d.machining.tool_radius = 2.5 # wider than the 4 mm holes
|
|
387
|
+
assert generate(d).stats["dropped_by_tool"] == len(sharp.holes)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_kerf_offsets_cut_paths():
|
|
391
|
+
from ventforge.export import cut_paths
|
|
392
|
+
d = Design()
|
|
393
|
+
d.machining.kerf = 0.2
|
|
394
|
+
r = generate(d)
|
|
395
|
+
c = cut_paths(r, d)
|
|
396
|
+
assert c.circles[0][2] == pytest.approx(r.circles[0][2] - 0.1)
|
|
397
|
+
assert c.outline.area > r.outline.area
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_keepout_hole_is_cut_with_free_clearance_around_it():
|
|
401
|
+
d = Design().patched({"keepouts": [{"shape": "rectangle", "x": 0, "y": 0, "width": 10, "height": 6,
|
|
402
|
+
"hole": True}]})
|
|
403
|
+
r = generate(d)
|
|
404
|
+
assert any(h.equals_exact(shapely.box(-5, -3, 5, 3), 1e-6) or abs(h.area - 60) < 1e-6 for h in r.holes)
|
|
405
|
+
assert not any(h.intersects(shapely.box(-6.4, -4.4, 6.4, 4.4)) for h in r.holes if abs(h.area - 60) > 1e-6)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_thinnest_wall_found_and_located():
|
|
409
|
+
d = Design().patched({"pattern": {"mode": "custom_points", "custom_points": {"points": [[0, 0], [5, 0], [30, 30]]}},
|
|
410
|
+
"dot": {"diameter": 4}})
|
|
411
|
+
s = generate(d).stats
|
|
412
|
+
assert s["thinnest_wall"] == pytest.approx(1.0, abs=0.01) # centres 5 apart, radius 2 each
|
|
413
|
+
assert s["thinnest_wall_at"] == pytest.approx([2.5, 0], abs=0.05)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_airflow_loss_drops_as_open_area_grows():
|
|
417
|
+
small = generate(Design().patched({"dot": {"diameter": 2}})).stats
|
|
418
|
+
big = generate(Design().patched({"dot": {"diameter": 4}})).stats
|
|
419
|
+
assert big["open_area_ratio"] > small["open_area_ratio"]
|
|
420
|
+
assert 0 < big["pressure_drop_pa"] < small["pressure_drop_pa"]
|
|
421
|
+
assert big["pressure_drop_pa"] == pytest.approx(big["loss_coefficient"] * 1.2 * 4 / 2)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def test_min_feature_flags_narrow_holes():
|
|
425
|
+
d = Design().patched({"dot": {"diameter": 3}})
|
|
426
|
+
assert "below_min_feature" not in generate(d).stats
|
|
427
|
+
d.machining.min_feature = 3.5
|
|
428
|
+
s = generate(d).stats
|
|
429
|
+
assert s["below_min_feature"] == s["count"]
|
|
@@ -12,7 +12,7 @@ import time
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
import shapely
|
|
15
|
-
from shapely.geometry import
|
|
15
|
+
from shapely.geometry import Polygon
|
|
16
16
|
from shapely.geometry.polygon import orient
|
|
17
17
|
from shapely.strtree import STRtree
|
|
18
18
|
|
|
@@ -280,8 +280,10 @@ def _write(shape, path, tolerance: float = 0.05):
|
|
|
280
280
|
from OCP.Interface import Interface_Static
|
|
281
281
|
from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer
|
|
282
282
|
|
|
283
|
+
w = STEPControl_Writer() # registers the write.step.* statics, so set them after this
|
|
283
284
|
Interface_Static.SetCVal_s("write.step.unit", "MM")
|
|
284
|
-
|
|
285
|
+
# body name in CAD; OCC's default is "Open CASCADE STEP translator 8.0 1"
|
|
286
|
+
Interface_Static.SetCVal_s("write.step.product.name", path.stem)
|
|
285
287
|
w.Transfer(shape, STEPControl_AsIs)
|
|
286
288
|
if w.Write(str(path)) != IFSelect_RetDone:
|
|
287
289
|
raise RuntimeError(f"STEP write failed: {path}")
|
|
@@ -480,10 +482,13 @@ def _cut(base, tools, what: str, planes=()):
|
|
|
480
482
|
|
|
481
483
|
|
|
482
484
|
TOOLS_PER_CELL = 50
|
|
485
|
+
UNTILED_MAX = 600 # up to this many holes one cut is as fast as tiling, and leaves no seams on the part
|
|
483
486
|
|
|
484
487
|
|
|
485
488
|
def _grid_planes(n_tools, u_range, v_range, plane_u, plane_v):
|
|
486
489
|
"""k-1 cut planes along each axis, k chosen for ~TOOLS_PER_CELL tools per cell."""
|
|
490
|
+
if n_tools <= UNTILED_MAX:
|
|
491
|
+
return []
|
|
487
492
|
k = math.ceil(math.sqrt(n_tools / TOOLS_PER_CELL))
|
|
488
493
|
out = []
|
|
489
494
|
for i in range(1, k):
|
|
@@ -613,14 +618,35 @@ def cut_into_part(result: Result, design: Design, part_path, out_path, origin=(0
|
|
|
613
618
|
|
|
614
619
|
|
|
615
620
|
# --------------------------------------------------------------------------- dispatch
|
|
621
|
+
def cut_paths(result: Result, design: Design) -> Result:
|
|
622
|
+
"""Laser/waterjet kerf: the beam centre runs kerf/2 inside each hole and outside the outline, so the cut
|
|
623
|
+
part has the designed size. Holes narrower than the kerf are dropped."""
|
|
624
|
+
k = design.machining.kerf / 2
|
|
625
|
+
if k <= 0:
|
|
626
|
+
return result
|
|
627
|
+
holes, circles = [], []
|
|
628
|
+
for i, h in enumerate(result.holes):
|
|
629
|
+
c = _circle(result, i)
|
|
630
|
+
if c:
|
|
631
|
+
if c[2] > k:
|
|
632
|
+
holes.append(h)
|
|
633
|
+
circles.append((c[0], c[1], c[2] - k))
|
|
634
|
+
continue
|
|
635
|
+
for p in _parts(h.buffer(-k, join_style="mitre")):
|
|
636
|
+
holes.append(p)
|
|
637
|
+
circles.append(None)
|
|
638
|
+
return Result(holes=holes, outline=result.outline.buffer(k, join_style="mitre"), stats=result.stats,
|
|
639
|
+
circles=circles)
|
|
640
|
+
|
|
641
|
+
|
|
616
642
|
def export(result: Result, design: Design, path) -> None:
|
|
617
643
|
ext = Path(path).suffix.lower()
|
|
618
644
|
if ext == ".svg":
|
|
619
|
-
to_svg(result, design, path)
|
|
645
|
+
to_svg(cut_paths(result, design), design, path)
|
|
620
646
|
elif ext == ".png":
|
|
621
647
|
to_png(result, design, path)
|
|
622
648
|
elif ext == ".dxf":
|
|
623
|
-
to_dxf(result, path)
|
|
649
|
+
to_dxf(cut_paths(result, design), path)
|
|
624
650
|
elif ext in (".step", ".stp"):
|
|
625
651
|
to_step(result, design, path)
|
|
626
652
|
elif ext == ".stl":
|