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.
Files changed (32) hide show
  1. ventforge-0.2.0/PKG-INFO +160 -0
  2. ventforge-0.2.0/README.md +129 -0
  3. {ventforge-0.1.0 → ventforge-0.2.0}/pyproject.toml +1 -1
  4. {ventforge-0.1.0 → ventforge-0.2.0}/tests/test_export.py +6 -1
  5. ventforge-0.2.0/tests/test_mcp.py +94 -0
  6. {ventforge-0.1.0 → ventforge-0.2.0}/tests/test_pattern.py +129 -0
  7. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/export.py +30 -4
  8. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/gui.py +117 -24
  9. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/mcp_server.py +133 -24
  10. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/model.py +84 -11
  11. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/pattern.py +137 -17
  12. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/halftone_fade.vent.json +1 -1
  13. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/hex_honeycomb.vent.json +1 -1
  14. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/spiral_dashes.vent.json +1 -1
  15. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/sunflower.vent.json +1 -1
  16. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/voronoi_stone.vent.json +1 -1
  17. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/presets/wavy_flowlines.vent.json +1 -1
  18. ventforge-0.2.0/ventforge.egg-info/PKG-INFO +160 -0
  19. ventforge-0.1.0/PKG-INFO +0 -84
  20. ventforge-0.1.0/README.md +0 -53
  21. ventforge-0.1.0/tests/test_mcp.py +0 -12
  22. ventforge-0.1.0/ventforge.egg-info/PKG-INFO +0 -84
  23. {ventforge-0.1.0 → ventforge-0.2.0}/LICENSE +0 -0
  24. {ventforge-0.1.0 → ventforge-0.2.0}/setup.cfg +0 -0
  25. {ventforge-0.1.0 → ventforge-0.2.0}/tests/test_gui_smoke.py +0 -0
  26. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/__init__.py +0 -0
  27. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge/__main__.py +0 -0
  28. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/SOURCES.txt +0 -0
  29. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/dependency_links.txt +0 -0
  30. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/entry_points.txt +0 -0
  31. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/requires.txt +0 -0
  32. {ventforge-0.1.0 → ventforge-0.2.0}/ventforge.egg-info/top_level.txt +0 -0
@@ -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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ventforge"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Parametric vent pattern designer with native GUI and MCP server"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -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
- def test_cut_into_part(tmp):
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 MultiPolygon, Polygon
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
- w = STEPControl_Writer()
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":