Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ concurrency:
cancel-in-progress: true

jobs:
verify:
uses: ./.github/workflows/tests.yml
deploy:
needs: verify
runs-on: ubuntu-latest
environment:
name: github-pages
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Regression checks
on:
pull_request:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
shared:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: python -m pip install numpy pyfqmr
- run: python -m tests.test_vase
- run: python -m unittest tests.test_workspace -v
- run: node tests/test_web_state.cjs
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,52 @@ ready to slice.

## Download and run

### Design library and profiles (desktop and web)

Open **Designs → Library, profiles and Surprise locks** on desktop or in the browser.
Both interfaces use the same Python rules and portable library format:

* **Surprise locks:** preserve Dimensions (height and widest diameter), Silhouette (including its width),
Cross-section, Twist, or Colours while exploring new random variations. Printer settings are always kept.
Locks preserve the requested settings; softening can still alter the printable surface. Existing masks
and tissue-box modes are not locks: Surprise me still creates a vase.
* **My designs:** save named variants, browse silhouette sketches, mark favourites, restore, or remove them.
Restoring a variant remains undoable. These are silhouette thumbnails, not full 3D renders.
* **Printer and material profiles:** name and apply printer settings separately from material properties.
Printer profiles store bed/build height, line/layer widths, bottom, walls, overhang limit and mesh detail.
Material profiles store filament diameter, density, speed and maximum flow. Applying a profile keeps the
design's shape and colours and can be undone.
* **Recovery:** retains up to 30 settled snapshots, beyond the current session's undo history. It is local
to this computer/browser. Export a library backup before clearing browser storage or moving computers.
* **Export/import library:** transfer variants, profiles, recovery and locks between desktop and browser.
Import merges entries by identity, leaves existing entries intact, and combines locks. Each library can
hold up to 500 saved designs and 500 profiles. There is no automatic cloud synchronisation.

In **View → Compare original and softened**, blue shows the requested side envelope and orange shows
the softened envelope. Connecting strokes highlight changed widths; the maximum surface movement is
also reported. This side view can overlap where changes are angular rather than changes in outer width.

**File → Save clearance samples** exports five socket-and-pin pairs as one 3MF. Left to right, the radial
clearances are 0.1, 0.2, 0.3, 0.4 and 0.5 mm (diametral clearances are double those values). Print with the
intended material and settings, keep the pairs in order, and test the fit before committing to a larger
print. These are general clearance samples, not replicas of the lid latches; they do not change a design's
clearance automatically. The sample layout is approximately 92 × 33 × 3 mm.

### Regression checks

```text
python -m tests.test_vase
python -m unittest tests.test_workspace -v
node tests/test_web_state.cjs
python -m tests.test_app
python -m unittest tests.test_design_tools -v
```

The desktop tests require the packages in `requirements.txt`. They can run with
`QT_QPA_PLATFORM=offscreen`. The optional `node tests/test_browser.cjs` end-to-end test needs Playwright,
a Chrome/Chromium executable in `BROWSER_PATH`, and a built site served at `http://127.0.0.1:8765`
(or `VASE_URL`). Shared geometry, library and browser-state regressions run before Pages deployment.

* **In your browser:** <https://rickusinggithub.github.io/vaseinator/> – the whole app, Simple and Advanced:
every setting, the curves, drawing and tracing your own cross-section (in a full window too), your own
function, the Vase, Section, Overhangs and Print layout views, Spin and Watch it print, the side and
Expand Down
40 changes: 40 additions & 0 deletions tests/test_browser.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Optional end-to-end test: NODE_PATH points to Playwright; BROWSER_PATH to Chrome/Edge.
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:process.env.BROWSER_PATH});
try {
const page=await browser.newPage({viewport:{width:1440,height:1000}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(process.env.VASE_URL||'http://127.0.0.1:8765');
await page.waitForFunction(()=>window.vaseinator?.meta,{timeout:180000});
console.log('PASS: browser boots and builds');
await page.locator('#advancedbtn').click();
await page.locator('#set-line_width').evaluate(el=>{el.value='.8';el.dispatchEvent(new Event('change'))});
await page.waitForFunction(()=>window.vaseinator.params.line_width===.8);
await page.reload();await page.waitForFunction(()=>window.vaseinator?.meta,{timeout:180000});
assert.equal(await page.evaluate(()=>window.vaseinator.params.line_width),.8);
await page.locator('[data-do="library"]').evaluate(el=>el.click());
const dialog=page.locator('dialog.library');await dialog.getByText('My designs',{exact:true}).waitFor();
await dialog.getByLabel('Colours',{exact:true}).check();
page.once('dialog',d=>d.accept('Browser test variant'));
await dialog.getByRole('button',{name:'Save current variant',exact:true}).click();
await dialog.getByText(/Browser test variant —/).waitFor();
await dialog.getByRole('button',{name:'Favourite',exact:true}).click();
await dialog.getByRole('button',{name:'Unfavourite',exact:true}).waitFor();
page.once('dialog',d=>d.accept('Test printer'));
await dialog.getByRole('button',{name:'Save printer',exact:true}).click();
await dialog.getByText(/Test printer —/).waitFor();
await page.screenshot({path:'work-temp/browser-library.png'});
await dialog.getByRole('button',{name:'Close',exact:true}).click();
await page.locator('[data-do="compare"]').evaluate(el=>el.click());
await page.locator('#help[open] img').waitFor({timeout:180000});
await page.screenshot({path:'work-temp/browser-comparison.png'});
await page.locator('#help').evaluate(el=>el.close());
await page.locator('[data-do="calibration"]').evaluate(el=>el.click());
await page.locator('#steps[open]').waitFor({timeout:60000});
assert.equal(await page.locator('#stepsfile').getAttribute('download'),'clearance_samples.3mf');
assert.deepEqual(errors,[]);
console.log('PASS: printer reload, library, favourites, profile, locks, comparison and sample export; no browser errors');
} finally {await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1});
78 changes: 78 additions & 0 deletions tests/test_design_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Desktop feature controls; runs without a display using QT_QPA_PLATFORM=offscreen."""

import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from PySide6.QtCore import QSettings
from PySide6.QtGui import QFontDatabase, QFont
from PySide6.QtWidgets import QApplication, QCheckBox, QMessageBox
from vaseinator.app import MainWindow
from vaseinator.design_tools import DesignTools, save_calibration
from vaseinator.threemf import read_3mf


class DesktopToolsTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
font = Path("C:/Windows/Fonts/segoeui.ttf")
if font.exists():
ident = QFontDatabase.addApplicationFont(str(font))
families = QFontDatabase.applicationFontFamilies(ident)
if families:
cls.app.setFont(QFont(families[0], 10))

def test_library_profile_recovery_and_samples(self):
with tempfile.TemporaryDirectory() as tmp:
settings = QSettings(str(Path(tmp) / "test.ini"), QSettings.IniFormat)
win = MainWindow(settings)
win.params.facet_size = 2
dialog = DesignTools(win)
dialog.show()
self.app.processEvents()
colour = next(
c for c in dialog.findChildren(QCheckBox) if c.text() == "Colours"
)
colour.setChecked(True)
self.assertIn("Colours", win.workspace.data["locks"])
dialog.run("save", dict(category="designs", name="Variant"))
entry = win.workspace.data["designs"][0]
dialog.run("favorite", {"id": entry["id"]})
self.assertTrue(entry["favorite"])
win.params.line_width = 0.8
dialog.run(
"save", dict(category="profiles", name="Printer", kind="printer")
)
profile = win.workspace.data["profiles"][0]
win.params.line_width = 0.4
dialog.run("restore", dict(category="profiles", id=profile["id"]))
self.assertEqual(win.params.line_width, 0.8)
win.current_file = "old.vase"
dialog.run("restore", dict(category="designs", id=entry["id"]))
self.assertIsNone(win.current_file)
self.assertEqual(win.params.line_width, entry["params"]["line_width"])
self.assertTrue(win.workspace.data["recovery"])
self.assertEqual(
json.loads(settings.value("workspace")), win.workspace.data
)
path = str(Path(tmp) / "samples.3mf")
with (
patch(
"vaseinator.design_tools.QFileDialog.getSaveFileName",
return_value=(path, ""),
),
patch.object(QMessageBox, "information"),
):
save_calibration(win)
self.assertEqual(len(read_3mf(path)), 10)
dialog.grab().save("work-temp/desktop-library.png")
dialog.close()
win._dirty = False
win.close()


if __name__ == "__main__":
unittest.main()
32 changes: 32 additions & 0 deletions tests/test_web_state.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Exercise the actual state functions with a delayed Python worker and minimal presentation stubs.
const {readFileSync}=require('node:fs');
const vm=require('node:vm');
const assert=require('node:assert/strict');
const source=readFileSync('web/app.js','utf8');
const context=vm.createContext({console,setTimeout:()=>0,clearTimeout(){}});
vm.runInContext(`
let C={printer_keys:['line_width']}, K={REAL_VASE:0,FAKE_VASE:1}, params={height:180,color1:'blue',line_width:.6,vase_mode:1};
let liveFrom=null,shown,reply,recoveryTimer=0;
const undoStack=[],redoStack=[],saved={};
const store={set(k,v){saved[k]=JSON.parse(JSON.stringify(v))},get(k){return saved[k]}};
function pushUndo(s){undoStack.push(s);redoStack.length=0}
function undoButtons(){} function status(){} function showProblem(e){throw new Error(e)}
function refreshSimple(){} function refreshAdvanced(){} function rebuild(){}
const helper={call(name,text,key,value){if(name==='shown')return Promise.resolve('[]');return new Promise(resolve=>{reply=()=>resolve(JSON.stringify({params:{...JSON.parse(text),[key]:JSON.parse(value)},shown:[]}))})}};
`+source.slice(source.indexOf('let queue ='),source.indexOf('function pushUndo'))+
source.slice(source.indexOf('function needsNormal'),source.indexOf('// ================================================================== building it'))+
source.slice(source.indexOf('function rememberPrinter'),source.indexOf('async function fitHead')),context);
const run=s=>vm.runInContext(s,context);
const tick=()=>new Promise(resolve=>setImmediate(resolve));
(async()=>{
run(`commit('height',200)`);await tick();run(`change({color1:'red'});reply()`);await run('queue');
assert.equal(run('params.height'),200);assert.equal(run('params.color1'),'red');
run(`commit('line_width',.8)`);await tick();run('reply()');await run('queue');
run(source.slice(source.indexOf(' const printer = store.get'),source.indexOf(' $("loading").hidden = true;')));
assert.equal(run('params.line_width'),.8);
run('step(undoStack,redoStack)');await run('queue');assert.equal(run('params.line_width'),.6);assert.equal(run('saved.printer.line_width'),.6);
run('step(redoStack,undoStack)');await run('queue');assert.equal(run('params.line_width'),.8);
run(`commit('height',250)`);await tick();run('step(undoStack,redoStack);reply()');await run('queue');assert.equal(run('params.height'),200);
run(`commit('height',260)`);await tick();run(`replace({height:99,color1:'green',line_width:.4,vase_mode:1});reply()`);await run('queue');assert.equal(run('params.height'),99);
console.log('PASS: delayed edits, colours, reload, undo/redo and replacement ordering');
})().catch(e=>{console.error(e);process.exitCode=1});
136 changes: 136 additions & 0 deletions tests/test_workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Shared feature and export regression tests: python -m unittest tests.test_workspace."""

import json
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace

import numpy as np

from vaseinator.params import VaseParams, clean_curve
from vaseinator.presets import plain_design
from vaseinator.geometry import VaseDesign, edge_check, mesh_volume, require_exportable
from vaseinator.workspace import (
Workspace,
LOCKS,
locked_surprise,
comparison,
calibration_parts,
)
from vaseinator.threemf import write_3mf, read_3mf
from web import bridge


class WorkspaceTests(unittest.TestCase):
def test_bad_numbers(self):
for values in (
{"height": "NaN"},
{"height": "Infinity"},
{"count1": float("inf")},
):
with self.subTest(values=values), self.assertRaises(ValueError):
VaseParams.from_dict(values)
self.assertIsNone(clean_curve([[0, 2], [1, float("nan")]]))

def test_export_gate(self):
for design in (
SimpleNamespace(errors=["bad"], closed=True),
SimpleNamespace(errors=[], closed=False),
):
with self.assertRaises(ValueError):
require_exportable(design)

def test_export_gate_in_browser(self):
from unittest.mock import patch

with patch.object(
bridge, "VaseDesign", return_value=SimpleNamespace(errors=[], closed=False)
):
for export in (bridge.export_stl, bridge.export_3mf, bridge.export_lid):
with self.assertRaises(ValueError):
export("{}")

def test_locks_and_bridge_parity(self):
p = VaseParams(height=123, line_width=0.8)
for group, keys in LOCKS.items():
q = locked_surprise(p, 42, [group])
for key in keys:
self.assertEqual(getattr(p, key), getattr(q, key))
self.assertEqual(q.line_width, 0.8)
q = locked_surprise(p, 42, ["Dimensions"])
self.assertAlmostEqual(
max(v for _, v in q.silhouette), max(v for _, v in p.silhouette)
)
q = locked_surprise(p, 42, list(LOCKS))
self.assertEqual(
q.to_dict(),
json.loads(
bridge.surprise_me(json.dumps(p.to_dict()), 42, json.dumps(list(LOCKS)))
),
)

def test_library_roundtrip_profiles_favourites_and_recovery(self):
w = Workspace()
p = VaseParams(name="My variant", height=140, line_width=0.8, density=1.27)
entry = w.add("designs", p)
w.command("favorite", p, {"id": entry["id"]})
printer = w.add("profiles", p, "Printer", kind="printer")
material = w.add("profiles", p, "PETG", kind="material")
base = VaseParams(height=99, density=1.1)
q = w.apply_profile(base, printer["id"])
self.assertEqual(q.height, 99)
self.assertEqual(q.line_width, 0.8)
self.assertEqual(q.density, 1.1)
q = w.apply_profile(base, material["id"])
self.assertEqual(q.density, 1.27)
self.assertEqual(q.line_width, base.line_width)
for n in range(35):
w.add("recovery", VaseParams(height=100 + n))
w.add("recovery", VaseParams(height=134))
self.assertEqual(len(w.data["recovery"]), 30)
restored = Workspace(json.loads(w.dumps()))
self.assertEqual(restored.data, w.data)
restored.merge(w.data)
self.assertEqual(restored.data, w.data)
result = json.loads(
bridge.workspace_action(
w.dumps(),
json.dumps(base.to_dict()),
"restore",
json.dumps({"category": "designs", "id": entry["id"]}),
)
)
self.assertEqual(result["params"], p.to_dict())
self.assertTrue(result["workspace"]["designs"][0]["favorite"])
with self.assertRaises(ValueError):
Workspace({"format": "other"})

def test_comparison_and_calibration(self):
p = plain_design()
p.facet_size = 2
d = VaseDesign(p)
data = comparison(d)
self.assertEqual(data["moved"], 0)
self.assertIn("<svg", data["svg"])
parts = calibration_parts()
self.assertEqual(len(parts), 10)
for _, v, f, _, _ in parts:
self.assertTrue(edge_check(f)[0])
self.assertGreater(mesh_volume(v, f), 0)
self.assertTrue(np.isfinite(v).all())
for k in range(5):
v = parts[2 * k][1]
inner = v[192:288].copy()
inner[:, 0] -= (k - 2) * 19
self.assertTrue(
np.allclose(np.linalg.norm(inner[:, :2], axis=1), 5 + 0.1 * (k + 1))
)
with tempfile.TemporaryDirectory() as tmp:
path = str(Path(tmp) / "samples.3mf")
write_3mf(path, "samples", parts)
self.assertEqual(len(read_3mf(path)), 10)


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion tools/build_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# the package's modules that need no Qt: the geometry, the designs, the settings, the report and the exports
MODULES = ("__init__", "choices", "expr", "geometry", "hollow", "lid", "params", "presets", "report", "sections",
"settings", "shapes", "stl", "threemf")
"settings", "shapes", "stl", "threemf", "workspace")


def main(out=None):
Expand Down
Loading
Loading