ventforge 0.1.1__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.1.1 → ventforge-0.2.0}/PKG-INFO +16 -5
- {ventforge-0.1.1 → ventforge-0.2.0}/README.md +15 -4
- {ventforge-0.1.1 → ventforge-0.2.0}/pyproject.toml +1 -1
- {ventforge-0.1.1 → ventforge-0.2.0}/tests/test_export.py +4 -1
- ventforge-0.2.0/tests/test_mcp.py +94 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/tests/test_pattern.py +103 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/export.py +24 -3
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/gui.py +108 -9
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/mcp_server.py +127 -23
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/model.py +62 -8
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/pattern.py +121 -11
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/PKG-INFO +16 -5
- ventforge-0.1.1/tests/test_mcp.py +0 -12
- {ventforge-0.1.1 → ventforge-0.2.0}/LICENSE +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/setup.cfg +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/tests/test_gui_smoke.py +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/__init__.py +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/__main__.py +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/halftone_fade.vent.json +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/hex_honeycomb.vent.json +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/spiral_dashes.vent.json +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/sunflower.vent.json +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/voronoi_stone.vent.json +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/wavy_flowlines.vent.json +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/SOURCES.txt +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/dependency_links.txt +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/entry_points.txt +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/requires.txt +0 -0
- {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ventforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Parametric vent pattern designer with native GUI and MCP server
|
|
5
5
|
License-Expression: GPL-3.0-or-later
|
|
6
6
|
Keywords: vent,pattern,3d-printing,cnc,cad,mcp,parametric
|
|
@@ -43,10 +43,20 @@ MCP server. Both work on the same design file.
|
|
|
43
43
|
- **Gradients:** vary size, spacing, angle or shape across the plate. Drive them with points, lines or
|
|
44
44
|
curves, with noise, or with a grayscale image.
|
|
45
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.
|
|
46
53
|
- **Export:** PNG, SVG, DXF, STEP, STL. You can also wrap the pattern onto a tube or cut it into an
|
|
47
54
|
existing STEP part.
|
|
48
|
-
- **Checks:** hole count, open area,
|
|
49
|
-
|
|
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`).
|
|
50
60
|
|
|
51
61
|
## Install
|
|
52
62
|
|
|
@@ -124,8 +134,9 @@ edges, and export it as STEP."*
|
|
|
124
134
|
The assistant works on `~/Documents/VentForge/current.vent.json`. If you open that file in the editor
|
|
125
135
|
(or ask the assistant to "open it in the GUI"), you can watch the design change live.
|
|
126
136
|
|
|
127
|
-
**Tools the server offers:** `new_design`, `open_design`, `list_presets`, `get_design`,
|
|
128
|
-
`describe_parameters`, `set_params`, `add_control`, `clear_controls`, `
|
|
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`,
|
|
129
140
|
`wrap_cylinder`, `cut_into_part`, `open_in_gui`.
|
|
130
141
|
|
|
131
142
|
## Project layout
|
|
@@ -12,10 +12,20 @@ MCP server. Both work on the same design file.
|
|
|
12
12
|
- **Gradients:** vary size, spacing, angle or shape across the plate. Drive them with points, lines or
|
|
13
13
|
curves, with noise, or with a grayscale image.
|
|
14
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.
|
|
15
22
|
- **Export:** PNG, SVG, DXF, STEP, STL. You can also wrap the pattern onto a tube or cut it into an
|
|
16
23
|
existing STEP part.
|
|
17
|
-
- **Checks:** hole count, open area,
|
|
18
|
-
|
|
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`).
|
|
19
29
|
|
|
20
30
|
## Install
|
|
21
31
|
|
|
@@ -93,8 +103,9 @@ edges, and export it as STEP."*
|
|
|
93
103
|
The assistant works on `~/Documents/VentForge/current.vent.json`. If you open that file in the editor
|
|
94
104
|
(or ask the assistant to "open it in the GUI"), you can watch the design change live.
|
|
95
105
|
|
|
96
|
-
**Tools the server offers:** `new_design`, `open_design`, `list_presets`, `get_design`,
|
|
97
|
-
`describe_parameters`, `set_params`, `add_control`, `clear_controls`, `
|
|
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`,
|
|
98
109
|
`wrap_cylinder`, `cut_into_part`, `open_in_gui`.
|
|
99
110
|
|
|
100
111
|
## Project layout
|
|
@@ -170,7 +170,10 @@ def _box_step(path, lx, ly, lz):
|
|
|
170
170
|
ex._write(BRepPrimAPI_MakeBox(gp_Pnt(-lx / 2, -ly / 2, 0), lx, ly, lz).Shape(), path)
|
|
171
171
|
|
|
172
172
|
|
|
173
|
-
|
|
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)
|
|
174
177
|
_box_step(tmp / "box.step", 120, 120, 10)
|
|
175
178
|
pts = _grid(10, 8.0)
|
|
176
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
|
|
|
@@ -305,6 +306,14 @@ def test_unknown_choice_rejected():
|
|
|
305
306
|
Design().patched({"pattern": {"crosslines": {"rotate_by": "row"}}})
|
|
306
307
|
|
|
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
|
+
|
|
308
317
|
def test_fibonacci_even_density_with_hole():
|
|
309
318
|
d = Design().patched({"canvas": {"width": 200, "height": 200}, "dot": {"diameter": 1},
|
|
310
319
|
"pattern": {"mode": "fibonacci", "fibonacci": {
|
|
@@ -324,3 +333,97 @@ def test_huge_concentric_radius_and_path_layers_stay_fast():
|
|
|
324
333
|
t = time.perf_counter()
|
|
325
334
|
assert generate(Design().patched(patch)).holes
|
|
326
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
|
|
|
@@ -618,14 +618,35 @@ def cut_into_part(result: Result, design: Design, part_path, out_path, origin=(0
|
|
|
618
618
|
|
|
619
619
|
|
|
620
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
|
+
|
|
621
642
|
def export(result: Result, design: Design, path) -> None:
|
|
622
643
|
ext = Path(path).suffix.lower()
|
|
623
644
|
if ext == ".svg":
|
|
624
|
-
to_svg(result, design, path)
|
|
645
|
+
to_svg(cut_paths(result, design), design, path)
|
|
625
646
|
elif ext == ".png":
|
|
626
647
|
to_png(result, design, path)
|
|
627
648
|
elif ext == ".dxf":
|
|
628
|
-
to_dxf(result, path)
|
|
649
|
+
to_dxf(cut_paths(result, design), path)
|
|
629
650
|
elif ext in (".step", ".stp"):
|
|
630
651
|
to_step(result, design, path)
|
|
631
652
|
elif ext == ".stl":
|
|
@@ -12,21 +12,21 @@ from dataclasses import fields, is_dataclass
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import get_type_hints
|
|
14
14
|
|
|
15
|
-
from PySide6.QtCore import QPointF, QRectF, QSettings, QSize, Qt, QThread, QTimer, QFileSystemWatcher, Signal
|
|
15
|
+
from PySide6.QtCore import QPointF, QRectF, QSettings, QSize, QSizeF, Qt, QThread, QTimer, QFileSystemWatcher, Signal
|
|
16
16
|
from PySide6.QtGui import (QAction, QBrush, QColor, QIcon, QKeySequence, QPainter, QPainterPath,
|
|
17
17
|
QPainterPathStroker, QPalette, QPen, QPixmap, QPolygonF, QTransform)
|
|
18
18
|
from PySide6.QtWidgets import (QAbstractSpinBox, QApplication, QButtonGroup, QCheckBox, QColorDialog, QComboBox,
|
|
19
19
|
QDialog, QDialogButtonBox, QDockWidget, QDoubleSpinBox, QFileDialog, QFormLayout,
|
|
20
20
|
QGraphicsEllipseItem, QGraphicsItem, QGraphicsPixmapItem, QGraphicsScene,
|
|
21
|
-
QGraphicsView, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
|
21
|
+
QGraphicsView, QGridLayout, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget,
|
|
22
22
|
QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QScrollArea,
|
|
23
23
|
QSizePolicy, QSlider, QSpinBox, QStackedWidget, QStyle, QTabWidget, QToolButton,
|
|
24
24
|
QVBoxLayout, QWidget)
|
|
25
25
|
|
|
26
|
-
from .model import (CHOICES, MODES, SHAPES, Control, Design, DotStyle, Gradient,
|
|
26
|
+
from .model import (CHOICES, FANS, GRADS, MODES, SHAPES, Control, Design, DotStyle, Gradient, Keepout,
|
|
27
|
+
Machining, Pattern, fan_grille)
|
|
27
28
|
|
|
28
29
|
PRESETS = Path(__file__).resolve().parent / "presets"
|
|
29
|
-
GRADS = ("size", "spacing", "angle", "shape")
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def pattern(): # imported lazily so the GUI module loads even while pattern.py is missing
|
|
@@ -45,6 +45,7 @@ META = { # name: (min, max, step, suffix)
|
|
|
45
45
|
"opacity": (0, 1, 0.05, ""), "offset_x": (-500, 500, 0.5, " mm"), "offset_y": (-500, 500, 0.5, " mm"),
|
|
46
46
|
"turns": (0.1, 200, 0.5, ""), "spread": (0.1, 50, 0.1, ""), "width": (1, 5000, 1, " mm"),
|
|
47
47
|
"height": (1, 5000, 1, " mm"), "thickness": (0.1, 100, 0.1, " mm"), "diameter": (0.1, 500, 0.1, " mm"),
|
|
48
|
+
"kerf": (0, 2, 0.01, " mm"), "tool_radius": (0, 20, 0.05, " mm"), "min_feature": (0, 20, 0.05, " mm"),
|
|
48
49
|
}
|
|
49
50
|
MM_WORDS = ("spacing", "radius", "diameter", "distance", "width", "margin", "size", "amplitude",
|
|
50
51
|
"wavelength", "scale", "min_wall")
|
|
@@ -73,7 +74,8 @@ SOFT_PATH = {
|
|
|
73
74
|
"pattern.concentric.inner_radius": (0, 60), "pattern.concentric.outer_radius": (0, 70),
|
|
74
75
|
"pattern.fibonacci.count": (10, 700), "pattern.fibonacci.spread": (1, 10),
|
|
75
76
|
"pattern.fibonacci.inner_radius": (0, 40), "pattern.crosslines.rotation": (-90, 90),
|
|
76
|
-
"pattern.voronoi.corner_radius": (0, 5), "
|
|
77
|
+
"pattern.voronoi.corner_radius": (0, 5), "boundary.vent_diameter": (0, 300),
|
|
78
|
+
"machining.kerf": (0, 0.5), "machining.tool_radius": (0, 3), "machining.min_feature": (0, 3), "pattern.flowlines.line_spacing": (2, 30),
|
|
77
79
|
}
|
|
78
80
|
SOFT = {"diameter": (0.3, 20), "thickness": (0.5, 10), "margin": (0, 20), "corner_radius": (0, 20),
|
|
79
81
|
"aspect": (0.2, 5), "sides": (3, 12), "star_points": (3, 12), "star_inner": (0.1, 0.9),
|
|
@@ -196,6 +198,13 @@ def _add_geom(path, g):
|
|
|
196
198
|
path.closeSubpath()
|
|
197
199
|
|
|
198
200
|
|
|
201
|
+
def add_keepout_path(path, k):
|
|
202
|
+
if k.shape == "rectangle":
|
|
203
|
+
path.addRect(QRectF(S(k.x - k.width / 2, k.y + k.height / 2), QSizeF(k.width, k.height)))
|
|
204
|
+
else:
|
|
205
|
+
path.addEllipse(S(k.x, k.y), k.width / 2, k.width / 2)
|
|
206
|
+
|
|
207
|
+
|
|
199
208
|
def result_paths(r):
|
|
200
209
|
holes = QPainterPath()
|
|
201
210
|
holes.setFillRule(Qt.OddEvenFill)
|
|
@@ -540,6 +549,12 @@ class Canvas(QGraphicsView):
|
|
|
540
549
|
self.decor.setZValue(3)
|
|
541
550
|
self.draft = sc.addPath(QPainterPath(), QPen(QColor(255, 120, 120), 0))
|
|
542
551
|
self.draft.setZValue(4)
|
|
552
|
+
self.keep = sc.addPath(QPainterPath(), QPen(QColor(255, 170, 60), 0, Qt.DashLine))
|
|
553
|
+
self.keep.setZValue(3)
|
|
554
|
+
self.weak = sc.addEllipse(-7, -7, 14, 14, QPen(QColor(255, 80, 80), 2)) # thinnest wall marker, in px
|
|
555
|
+
self.weak.setFlag(QGraphicsItem.ItemIgnoresTransformations)
|
|
556
|
+
self.weak.setZValue(6)
|
|
557
|
+
self.weak.setVisible(False)
|
|
543
558
|
self.ruler = sc.addLine(0, 0, 0, 0, QPen(QColor(255, 255, 255), 0))
|
|
544
559
|
self.ruler.setZValue(5)
|
|
545
560
|
self.ruler_txt = sc.addSimpleText("")
|
|
@@ -574,6 +589,16 @@ class Canvas(QGraphicsView):
|
|
|
574
589
|
self.holes.setBrush(QColor(color))
|
|
575
590
|
self.plate.setPath(outline)
|
|
576
591
|
|
|
592
|
+
def set_overlays(self, keepouts, weak, bad):
|
|
593
|
+
path = QPainterPath()
|
|
594
|
+
for k in keepouts:
|
|
595
|
+
add_keepout_path(path, k)
|
|
596
|
+
self.keep.setPath(path)
|
|
597
|
+
self.weak.setVisible(weak is not None)
|
|
598
|
+
if weak is not None:
|
|
599
|
+
self.weak.setPos(S(*weak))
|
|
600
|
+
self.weak.setPen(QPen(QColor(255, 80, 80) if bad else QColor(240, 210, 90), 2))
|
|
601
|
+
|
|
577
602
|
def set_background(self, pm, w, h, opacity):
|
|
578
603
|
self.bg.setVisible(pm is not None)
|
|
579
604
|
if pm is not None:
|
|
@@ -692,6 +717,11 @@ class Canvas(QGraphicsView):
|
|
|
692
717
|
if self.tool == "ruler" and e.button() == Qt.LeftButton:
|
|
693
718
|
self.ruler_start = pos
|
|
694
719
|
self._ruler(pos)
|
|
720
|
+
elif self.tool == "keepout":
|
|
721
|
+
if e.button() == Qt.RightButton:
|
|
722
|
+
self.win.remove_keepout(pos.x(), -pos.y())
|
|
723
|
+
elif e.button() == Qt.LeftButton:
|
|
724
|
+
self.ruler_start = pos
|
|
695
725
|
elif self.tool == "draw":
|
|
696
726
|
if e.button() == Qt.RightButton:
|
|
697
727
|
self.finish_draw()
|
|
@@ -715,6 +745,12 @@ class Canvas(QGraphicsView):
|
|
|
715
745
|
self._ruler(pos)
|
|
716
746
|
elif self.tool == "draw" and self.draft_pts:
|
|
717
747
|
self._draft(pos)
|
|
748
|
+
elif self.tool == "keepout" and self.ruler_start is not None:
|
|
749
|
+
path = QPainterPath()
|
|
750
|
+
if k := self._keepout(pos, e.modifiers()):
|
|
751
|
+
self.win.statusBar().showMessage(f"{k.shape} {k.width:.1f} × {k.height:.1f} mm at ({k.x:.1f}, {k.y:.1f})")
|
|
752
|
+
add_keepout_path(path, k)
|
|
753
|
+
self.draft.setPath(path)
|
|
718
754
|
else:
|
|
719
755
|
super().mouseMoveEvent(e)
|
|
720
756
|
|
|
@@ -723,6 +759,13 @@ class Canvas(QGraphicsView):
|
|
|
723
759
|
self.pan_pos = None
|
|
724
760
|
self.viewport().setCursor(Qt.CrossCursor if self.tool else Qt.OpenHandCursor)
|
|
725
761
|
return
|
|
762
|
+
if self.tool == "keepout" and self.ruler_start is not None and e.button() == Qt.LeftButton:
|
|
763
|
+
k = self._keepout(self.mapToScene(e.position().toPoint()), e.modifiers())
|
|
764
|
+
self.ruler_start = None
|
|
765
|
+
self.draft.setPath(QPainterPath())
|
|
766
|
+
if k:
|
|
767
|
+
self.win.add_keepout(k)
|
|
768
|
+
return
|
|
726
769
|
super().mouseReleaseEvent(e)
|
|
727
770
|
if self.tool is None:
|
|
728
771
|
self.win.commit() # end of a handle drag
|
|
@@ -761,6 +804,18 @@ class Canvas(QGraphicsView):
|
|
|
761
804
|
self.ruler_txt.setPos(pos + QPointF(2 / self.transform().m11(), 0))
|
|
762
805
|
self.win.statusBar().showMessage(f"Distance {d:.2f} mm, dx {pos.x() - a.x():.2f}, dy {a.y() - pos.y():.2f}")
|
|
763
806
|
|
|
807
|
+
def _keepout(self, pos, mods):
|
|
808
|
+
"""Drag corner to corner for a rectangle; with Shift, from the centre for a circle."""
|
|
809
|
+
a = self.ruler_start
|
|
810
|
+
if mods & Qt.ShiftModifier:
|
|
811
|
+
r = math.hypot(pos.x() - a.x(), pos.y() - a.y())
|
|
812
|
+
return Keepout("circle", round(a.x(), 2), round(-a.y(), 2), round(2 * r, 2), round(2 * r, 2)) if r > .25 else None
|
|
813
|
+
w, h = abs(pos.x() - a.x()), abs(pos.y() - a.y())
|
|
814
|
+
if min(w, h) < .5:
|
|
815
|
+
return None
|
|
816
|
+
return Keepout("rectangle", round((a.x() + pos.x()) / 2, 2), round(-(a.y() + pos.y()) / 2, 2),
|
|
817
|
+
round(w, 2), round(h, 2))
|
|
818
|
+
|
|
764
819
|
def _draft(self, cursor):
|
|
765
820
|
path = QPainterPath(self.draft_pts[0])
|
|
766
821
|
for q in self.draft_pts[1:] + [cursor]:
|
|
@@ -841,7 +896,9 @@ class MainWindow(QMainWindow):
|
|
|
841
896
|
tb.addSeparator()
|
|
842
897
|
self.ruler_act = act("Ruler", lambda on: self.set_tool("ruler" if on else None), "R", True)
|
|
843
898
|
self.draw_act = act("Draw outline", lambda on: self.set_tool("draw" if on else None), "B", True)
|
|
899
|
+
self.keep_act = act("Keep-out", lambda on: self.set_tool("keepout" if on else None), "K", True)
|
|
844
900
|
act("Zoom to fit", self.canvas.zoom_fit, "Z")
|
|
901
|
+
act("Fan grille…", self.fan_dialog).setToolTip("Plate for a standard fan: blade opening and 4 screw holes")
|
|
845
902
|
tb.addSeparator()
|
|
846
903
|
menu_btn = QToolButton(text=" Export ", popupMode=QToolButton.InstantPopup)
|
|
847
904
|
menu_btn.setObjectName("export")
|
|
@@ -964,6 +1021,10 @@ class MainWindow(QMainWindow):
|
|
|
964
1021
|
bf.addRow(row(button("Draw outline", lambda: self.draw_act.trigger(),
|
|
965
1022
|
"Click points on the canvas; double-click, Enter or right-click to finish"),
|
|
966
1023
|
button("Clear outline", lambda: self.set_param("boundary.points", []))))
|
|
1024
|
+
bf.addRow("Keep-outs", self.field_widget("keepouts", "keepouts", list))
|
|
1025
|
+
bf.addRow(row(button("Clear keep-outs", lambda: self.set_param("keepouts", []),
|
|
1026
|
+
"Screw holes and hole-free areas (set by Fan grille or the MCP server)")))
|
|
1027
|
+
mcw, _, _ = self.form("machining", Machining)
|
|
967
1028
|
# background
|
|
968
1029
|
gw = QWidget()
|
|
969
1030
|
gf = QFormLayout(gw)
|
|
@@ -974,7 +1035,7 @@ class MainWindow(QMainWindow):
|
|
|
974
1035
|
gf.addRow(row(button("Load image…", self.load_background),
|
|
975
1036
|
button("Clear", lambda: self.set_param("background.image", ""))))
|
|
976
1037
|
self._dock("Design", [Section("Library", lib), Section("Canvas", cw), Section("Boundary", bw),
|
|
977
|
-
Section("Background image", gw, False)], Qt.LeftDockWidgetArea)
|
|
1038
|
+
Section("Machining", mcw, False), Section("Background image", gw, False)], Qt.LeftDockWidgetArea)
|
|
978
1039
|
|
|
979
1040
|
def _right_dock(self):
|
|
980
1041
|
self.grad_tabs = QTabWidget()
|
|
@@ -1049,7 +1110,9 @@ class MainWindow(QMainWindow):
|
|
|
1049
1110
|
elif isinstance(w, QLineEdit):
|
|
1050
1111
|
w.setText(str(v))
|
|
1051
1112
|
elif isinstance(w, QLabel):
|
|
1052
|
-
w.setText(f"{len(v)} point(s)
|
|
1113
|
+
w.setText(f"{len(v)} {'keep-out' if path == 'keepouts' else 'point'}(s)" + (" — draw with K, right-click to remove"
|
|
1114
|
+
if path == 'keepouts' else " — edit via file / MCP")
|
|
1115
|
+
if v else "none (default)")
|
|
1053
1116
|
w.blockSignals(False)
|
|
1054
1117
|
for w in self.findChildren(SliderField): # canvas-relative slider ranges
|
|
1055
1118
|
if getattr(w, "path", None) in SCALE:
|
|
@@ -1275,11 +1338,39 @@ class MainWindow(QMainWindow):
|
|
|
1275
1338
|
def set_tool(self, tool):
|
|
1276
1339
|
self.ruler_act.setChecked(tool == "ruler")
|
|
1277
1340
|
self.draw_act.setChecked(tool == "draw")
|
|
1341
|
+
self.keep_act.setChecked(tool == "keepout")
|
|
1278
1342
|
self.canvas.set_tool(tool)
|
|
1343
|
+
if tool == "keepout":
|
|
1344
|
+
self.statusBar().showMessage("Drag a hole-free rectangle (Shift: circle from its centre); right-click one to"
|
|
1345
|
+
" remove it, Esc to finish")
|
|
1279
1346
|
if tool == "draw":
|
|
1280
1347
|
self.statusBar().showMessage("Click to add outline points; double-click / Enter / right-click to finish,"
|
|
1281
1348
|
" Esc to cancel")
|
|
1282
1349
|
|
|
1350
|
+
def fan_dialog(self):
|
|
1351
|
+
sizes = [f"{s} mm" for s in FANS]
|
|
1352
|
+
s, ok = QInputDialog.getItem(self, "Fan grille", "Fan size (the hole pattern stays):", sizes, sizes.index("120 mm"),
|
|
1353
|
+
False)
|
|
1354
|
+
if ok:
|
|
1355
|
+
self.need_fit = True
|
|
1356
|
+
self.set_design(fan_grille(clone(self.design), int(s.split()[0])))
|
|
1357
|
+
self.dirty = True
|
|
1358
|
+
self.autosave()
|
|
1359
|
+
|
|
1360
|
+
def add_keepout(self, k):
|
|
1361
|
+
self.design.keepouts.append(k)
|
|
1362
|
+
self.changed(sync=True)
|
|
1363
|
+
self.commit()
|
|
1364
|
+
|
|
1365
|
+
def remove_keepout(self, x, y):
|
|
1366
|
+
from .pattern import _keepout_geom
|
|
1367
|
+
from shapely import Point
|
|
1368
|
+
hit = [i for i, k in enumerate(self.design.keepouts) if _keepout_geom(k).buffer(1).contains(Point(x, y))]
|
|
1369
|
+
if hit:
|
|
1370
|
+
del self.design.keepouts[hit[-1]] # the topmost (last drawn)
|
|
1371
|
+
self.changed(sync=True)
|
|
1372
|
+
self.commit()
|
|
1373
|
+
|
|
1283
1374
|
def set_custom_boundary(self, pts):
|
|
1284
1375
|
self.design.boundary.points = pts
|
|
1285
1376
|
self.design.boundary.type = "custom"
|
|
@@ -1322,9 +1413,17 @@ class MainWindow(QMainWindow):
|
|
|
1322
1413
|
self.canvas.set_result(holes, outline, self.design.dot.color)
|
|
1323
1414
|
s = r.stats
|
|
1324
1415
|
self.st["count"].setText(f"{s.get('count', len(r.holes))} dots")
|
|
1325
|
-
self.st["open"].setText(f"open {100 * s.get('open_area_ratio', 0):.1f} %"
|
|
1416
|
+
self.st["open"].setText(f"open {100 * s.get('open_area_ratio', 0):.1f} %"
|
|
1417
|
+
+ (f" · fan {100 * s['fan_open_ratio']:.0f} % open" if "fan_open_ratio" in s else "")
|
|
1418
|
+
+ (f" · {s['dropped_by_tool']} too small for the tool" if s.get("dropped_by_tool") else ""))
|
|
1326
1419
|
v = s.get("min_wall_violations", 0)
|
|
1327
|
-
|
|
1420
|
+
tw = s.get("thinnest_wall")
|
|
1421
|
+
self.st["viol"].setText(f"min-wall violations {v}" + (f" · thinnest wall {tw:.2f} mm" if tw else "")
|
|
1422
|
+
+ (f" · {s['below_min_feature']} holes below min feature"
|
|
1423
|
+
if s.get("below_min_feature") else "")
|
|
1424
|
+
+ (f" · airflow loss {s['pressure_drop_pa']:.0f} Pa @2 m/s"
|
|
1425
|
+
if "pressure_drop_pa" in s else ""))
|
|
1426
|
+
self.canvas.set_overlays(self.design.keepouts, s.get("thinnest_wall_at"), tw is not None and tw < self.design.min_wall)
|
|
1328
1427
|
self.st["viol"].setStyleSheet("color:#ff7070" if v else "")
|
|
1329
1428
|
self.st["time"].setText(f"{dt * 1000:.0f} ms")
|
|
1330
1429
|
warn = f" ⚠ {v} walls thinner than {self.design.min_wall:g} mm" if v else ""
|