diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 02545e6..126abbf 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5322d08 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/README.md b/README.md index af08c71..b82f946 100644 --- a/README.md +++ b/README.md @@ -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:** – 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 diff --git a/tests/test_browser.cjs b/tests/test_browser.cjs new file mode 100644 index 0000000..ebf2a3a --- /dev/null +++ b/tests/test_browser.cjs @@ -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}); diff --git a/tests/test_design_tools.py b/tests/test_design_tools.py new file mode 100644 index 0000000..dcb9cf1 --- /dev/null +++ b/tests/test_design_tools.py @@ -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() diff --git a/tests/test_web_state.cjs b/tests/test_web_state.cjs new file mode 100644 index 0000000..7770252 --- /dev/null +++ b/tests/test_web_state.cjs @@ -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}); diff --git a/tests/test_workspace.py b/tests/test_workspace.py new file mode 100644 index 0000000..fdef2e8 --- /dev/null +++ b/tests/test_workspace.py @@ -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(" 1) @@ -1122,7 +1143,7 @@ def redo(self): def surprise_me(self, seed=None): self._flush() seed = random.randint(1, 999_999) if seed is None or seed is False else int(seed) - self.params = surprise(self.params, seed) + self.params = locked_surprise(self.params, seed, self.workspace.data["locks"]) self._dirty = True self._refresh_widgets() self.regenerate() @@ -1424,9 +1445,10 @@ def _current_design(self): if d is None or {**d.asked.to_dict(), "name": ""} != {**self.params.to_dict(), "name": ""}: QMessageBox.warning(self, "Nothing to export", "The current settings do not make a vase.") return None - if not d.closed: - QMessageBox.warning(self, "Not exported", "The mesh is not closed, so it would not print as one " - "watertight wall. Please report this design.") + try: + require_exportable(d) + except ValueError as exc: + QMessageBox.warning(self, "Not exported", str(exc)) return None return d @@ -1540,8 +1562,10 @@ def save_lid(self): if d is None: return lid = Lid(d) - if lid.errors: - QMessageBox.warning(self, "No lid", " ".join(lid.errors)) + try: + require_exportable(lid) + except ValueError as exc: + QMessageBox.warning(self, "No lid", str(exc)) return base = self._safe_name() + " lid" path, _ = QFileDialog.getSaveFileName(self, "Save the lid for printing", @@ -1636,6 +1660,8 @@ def closeEvent(self, ev): if not self._confirm_discard(): ev.ignore() return + self.workspace.add("recovery", self.params) + self._save_workspace() self.settings.setValue("geometry", self.saveGeometry()) super().closeEvent(ev) diff --git a/vaseinator/design_tools.py b/vaseinator/design_tools.py new file mode 100644 index 0000000..5b0b60e --- /dev/null +++ b/vaseinator/design_tools.py @@ -0,0 +1,237 @@ +"""Desktop controls for the shared portable design tools.""" + +import json + +from PySide6.QtCore import Qt, QSize, QByteArray +from PySide6.QtGui import QIcon, QPixmap, QPainter +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtSvgWidgets import QSvgWidget +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QCheckBox, + QTabWidget, + QWidget, + QListWidget, + QListWidgetItem, + QInputDialog, + QFileDialog, + QMessageBox, +) + +from .workspace import LOCKS, comparison, calibration_parts, CALIBRATION_HELP, thumbnail +from .params import VaseParams +from .threemf import write_3mf + + +def show_comparison(win): + win._flush() + d = win._current_design() + if d is None: + return + data = comparison(d) + dlg = QDialog(win) + dlg.setWindowTitle("Original and softened") + dlg.resize(700, 500) + layout = QVBoxLayout(dlg) + label = QLabel(data["text"]) + label.setWordWrap(True) + layout.addWidget(label) + svg = QSvgWidget() + svg.load(QByteArray(data["svg"].encode())) + layout.addWidget(svg) + dlg.exec() + + +def save_calibration(win): + path, _ = QFileDialog.getSaveFileName( + win, "Save clearance samples", "clearance_samples.3mf", "3MF (*.3mf)" + ) + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + try: + write_3mf( + path, + "Clearance samples", + calibration_parts(), + (win.params.bed_x / 2, win.params.bed_y / 2, 0), + ) + except OSError as exc: + QMessageBox.warning(win, "Could not save samples", str(exc)) + return + QMessageBox.information(win, "How to use the samples", CALIBRATION_HELP) + + +class DesignTools(QDialog): + def __init__(self, win): + super().__init__(win) + self.win = win + self.setWindowTitle("Design library and profiles") + self.resize(760, 620) + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Keep these when using Surprise me (printer settings are always kept):" + ) + ) + row = QHBoxLayout() + self.lock_boxes = {} + for name in LOCKS: + box = QCheckBox(name) + box.setChecked(name in win.workspace.data["locks"]) + row.addWidget(box) + self.lock_boxes[name] = box + box.toggled.connect(lambda on, n=name: self.lock(n, on)) + layout.addLayout(row) + note = QLabel( + "Library and recovery stay on this computer. Export a library backup to transfer it to the web app. Recovery keeps the last 30 settled designs. Thumbnails are silhouette sketches." + ) + note.setWordWrap(True) + layout.addWidget(note) + tabs = QTabWidget() + layout.addWidget(tabs) + self.lists = {} + for category, title in [ + ("designs", "My designs"), + ("profiles", "Printer and material profiles"), + ("recovery", "Recovery"), + ]: + page = QWidget() + box = QVBoxLayout(page) + entries = QListWidget() + entries.setIconSize(QSize(80, 80)) + box.addWidget(entries) + self.lists[category] = entries + buttons = QHBoxLayout() + box.addLayout(buttons) + + def button(text, callback): + b = QPushButton(text) + b.clicked.connect(callback) + buttons.addWidget(b) + + if category == "designs": + button("Save current variant", lambda: self.save("designs")) + button("Favourite", lambda: self.selected("designs", "favorite")) + if category == "profiles": + button("Save printer", lambda: self.save("profiles", "printer")) + button("Save material", lambda: self.save("profiles", "material")) + button( + "Restore / apply", + lambda _=False, c=category: self.selected(c, "restore"), + ) + button("Remove", lambda _=False, c=category: self.selected(c, "delete")) + tabs.addTab(page, title) + row = QHBoxLayout() + layout.addLayout(row) + for text, fn in [ + ("Export library backup", self.export_file), + ("Import library", self.import_file), + ("Close", self.accept), + ]: + b = QPushButton(text) + b.clicked.connect(fn) + row.addWidget(b) + self.refresh() + + def lock(self, name, on): + locks = set(self.win.workspace.data["locks"]) + locks.add(name) if on else locks.discard(name) + self.win.workspace.command("locks", self.win.params, {"locks": list(locks)}) + self.win._save_workspace() + + def refresh(self): + for name, box in self.lock_boxes.items(): + box.blockSignals(True) + box.setChecked(name in self.win.workspace.data["locks"]) + box.blockSignals(False) + for category, widget in self.lists.items(): + widget.clear() + for e in sorted( + self.win.workspace.data[category], + key=lambda e: (e["favorite"], e["created"]), + reverse=True, + ): + text = ( + ("★ " if e["favorite"] else "") + e["name"] + " — " + e["created"] + ) + if category == "profiles": + text = e["kind"].title() + ": " + text + item = QListWidgetItem(text) + item.setData(Qt.UserRole, e["id"]) + if category == "designs": + renderer = QSvgRenderer( + QByteArray( + thumbnail(VaseParams.from_dict(e["params"])).encode() + ) + ) + pix = QPixmap(80, 80) + pix.fill(Qt.transparent) + painter = QPainter(pix) + renderer.render(painter) + painter.end() + item.setIcon(QIcon(pix)) + widget.addItem(item) + + def save(self, category, kind="printer"): + name, ok = QInputDialog.getText( + self, + "Save variant" if category == "designs" else "Save profile", + "Name:", + text=self.win.params.name, + ) + if ok and name.strip(): + self.run("save", dict(category=category, name=name, kind=kind)) + + def selected(self, category, op): + item = self.lists[category].currentItem() + if item is None: + return + if ( + op == "delete" + and QMessageBox.question(self, "Remove entry", "Remove this saved entry?") + != QMessageBox.Yes + ): + return + self.run(op, dict(category=category, id=item.data(Qt.UserRole))) + + def run(self, op, payload): + try: + self.win._flush() + result = self.win.workspace.command(op, self.win.params, payload) + if result["params"]: + if payload.get("category") != "profiles": + self.win.current_file = None + self.win._restore(result["params"]) + self.win._save_workspace() + self.refresh() + except (ValueError, KeyError, StopIteration) as exc: + QMessageBox.warning(self, "Design tools", str(exc)) + + def export_file(self): + path, _ = QFileDialog.getSaveFileName( + self, "Export library", "vaseinator-library.json", "JSON (*.json)" + ) + if path: + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write(self.win.workspace.dumps()) + except OSError as exc: + QMessageBox.warning(self, "Export failed", str(exc)) + + def import_file(self): + path, _ = QFileDialog.getOpenFileName( + self, "Import library", "", "JSON (*.json)" + ) + if path: + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + self.run("import", data) + except (ValueError, OSError) as exc: + QMessageBox.warning(self, "Import failed", str(exc)) diff --git a/vaseinator/geometry.py b/vaseinator/geometry.py index a818cdb..0ca1709 100644 --- a/vaseinator/geometry.py +++ b/vaseinator/geometry.py @@ -54,6 +54,15 @@ "nose_depth", "nose_up", "mouth_width", "mouth_height", "mouth_up", "mouth_smile", "strap_up") +def require_exportable(design): + """One export gate for desktop and browser, including lids.""" + if design.errors: + raise ValueError("; ".join(design.errors)) + if not design.closed: + raise ValueError("The mesh is not closed. Please report this design before printing it.") + return design + + # ===================================================================== meshes def grid_side(z, theta, R): """The wall from a grid: rows of radii at shared angles. diff --git a/vaseinator/params.py b/vaseinator/params.py index 03bccca..e46e816 100644 --- a/vaseinator/params.py +++ b/vaseinator/params.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import math from dataclasses import asdict, dataclass, field, fields FILE_FORMAT = "vaseinator" @@ -202,12 +203,16 @@ def colors(self) -> dict: @classmethod def from_dict(cls, d: dict) -> "VaseParams": + if not isinstance(d, dict): + raise ValueError("Design settings must be an object.") p = cls() fresh = cls() for f in fields(cls): if f.name not in d: continue cur, v = getattr(p, f.name), d[f.name] + if isinstance(v, float) and not math.isfinite(v): + raise ValueError(f"{f.name} must be a finite number.") try: if isinstance(cur, bool): v = bool(v) @@ -221,8 +226,10 @@ def from_dict(cls, d: dict) -> "VaseParams": continue else: v = str(v) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): continue + if isinstance(v, float) and not math.isfinite(v): + raise ValueError(f"{f.name} must be a finite number.") setattr(p, f.name, v) allowed = {"shape1": SHAPES, "shape2": (SAME,) + SHAPES, "morph": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), @@ -297,7 +304,10 @@ def clean_curve(points): Returns None if there is nothing usable. """ try: - pts = sorted((min(max(float(h), 0.0), 1.0), float(v)) for h, v in points) + raw = [(float(h), float(v)) for h, v in points] + if not all(math.isfinite(h) and math.isfinite(v) for h, v in raw): + return None + pts = sorted((min(max(h, 0.0), 1.0), v) for h, v in raw) except (TypeError, ValueError): return None out = [] diff --git a/vaseinator/workspace.py b/vaseinator/workspace.py new file mode 100644 index 0000000..9d603de --- /dev/null +++ b/vaseinator/workspace.py @@ -0,0 +1,313 @@ +"""Portable design tools shared by desktop and web; storage belongs to each UI.""" + +from __future__ import annotations + +import copy +import json +import math +import uuid +from datetime import datetime, timezone +from html import escape + +import numpy as np + +from .params import VaseParams +from .presets import PRINTER_KEYS, surprise +from .shapes import pchip + +LOCKS = { + "Dimensions": ("height",), + "Silhouette": ("profile", "silhouette"), + "Cross-section": tuple( + f"{key}{n}" + for n in (1, 2) + for key in ("shape", "count", "depth", "sharp", "turn", "drawn", "repeat") + ) + + ("morph", "morph_swings", "morph_curve"), + "Twist": ("twist", "twist_style", "swings", "twist_curve", "lattice"), + "Colours": ( + "color1", + "color2", + "color3", + "color4", + "colour_style", + "colours_used", + "band_count", + "band_spacing", + "stripe_height", + "gradient_length", + "look", + ), +} +MATERIAL_KEYS = ("max_flow", "density", "filament_d", "speed") +PROFILE_KEYS = { + "printer": tuple(k for k in PRINTER_KEYS if k not in MATERIAL_KEYS), + "material": MATERIAL_KEYS, +} + + +def locked_surprise(p, seed, locks): + q = surprise(p, seed) + width = max(v for _, v in p.silhouette) + for group in locks: + for key in LOCKS.get(group, ()): + setattr(q, key, copy.deepcopy(getattr(p, key))) + if "Dimensions" in locks: + factor = width / max(v for _, v in q.silhouette) + q.silhouette = [[h, v * factor] for h, v in q.silhouette] + elif "Silhouette" in locks: + # A locked silhouette includes its width, rather than distorting it. + q.silhouette = copy.deepcopy(p.silhouette) + return q + + +def thumbnail(p): + """A portable silhouette thumbnail, explicitly a sketch rather than a render.""" + raw = np.asarray(p.silhouette, dtype=float) + heights = np.linspace(0, 1, 50) + points = list(zip(heights, pchip(raw[:, 0], raw[:, 1], heights))) + width = max(v for _, v in points) + right = [(60 + 42 * v / width, 112 - 104 * h) for h, v in points] + left = [(120 - x, y) for x, y in reversed(right)] + coords = " ".join(f"{x:.2f},{y:.2f}" for x, y in right + left) + return f'Silhouette sketch' + + +class Workspace: + def __init__(self, data=None): + self.data = { + "format": "vaseinator-library", + "version": 1, + "designs": [], + "profiles": [], + "recovery": [], + "locks": [], + } + if data is not None: + if ( + not isinstance(data, dict) + or data.get("format") != self.data["format"] + or data.get("version") != 1 + ): + raise ValueError("Not a supported Vaseinator library file.") + for kind in ("designs", "profiles", "recovery"): + entries = data.get(kind, []) + if not isinstance(entries, list) or len(entries) > 500: + raise ValueError( + "A library may hold at most 500 entries per category." + ) + for entry in entries: + if not isinstance(entry, dict) or not isinstance( + entry.get("params"), dict + ): + raise ValueError("Invalid library entry.") + p = VaseParams.from_dict(entry["params"]) + self.data[kind].append( + { + "id": str(entry.get("id") or uuid.uuid4()), + "name": str(entry.get("name", p.name))[:120], + "created": str(entry.get("created", "")), + "favorite": bool(entry.get("favorite", False)), + "kind": entry.get("kind", "printer") + if entry.get("kind") in PROFILE_KEYS + else "printer", + "params": p.to_dict(), + } + ) + self.data["locks"] = [k for k in data.get("locks", []) if k in LOCKS] + self.data["recovery"] = self.data["recovery"][-30:] + + def entry(self, category, ident): + return next(e for e in self.data[category] if e["id"] == ident) + + def add(self, category, p, name=None, kind="printer"): + if category not in ("designs", "profiles", "recovery"): + raise ValueError("Unknown library category.") + if category != "recovery" and len(self.data[category]) >= 500: + raise ValueError( + "This category is full. Export a backup and remove an entry first." + ) + if ( + category == "recovery" + and self.data[category] + and self.data[category][-1]["params"] == p.to_dict() + ): + return + entry = dict( + id=str(uuid.uuid4()), + name=(name or p.name).strip()[:120] or "Untitled", + created=datetime.now(timezone.utc).isoformat(timespec="seconds"), + favorite=False, + kind=kind, + params=p.to_dict(), + ) + self.data[category].append(entry) + if category == "recovery": + self.data[category] = self.data[category][-30:] + return entry + + def apply_profile(self, p, ident): + entry = self.entry("profiles", ident) + q = p.to_dict() + q.update({k: entry["params"][k] for k in PROFILE_KEYS[entry["kind"]]}) + return VaseParams.from_dict(q) + + def merge(self, other): + incoming = Workspace(other) + for category in ("designs", "profiles", "recovery"): + ids = {e["id"] for e in self.data[category]} + additions = [e for e in incoming.data[category] if e["id"] not in ids] + if ( + category != "recovery" + and len(self.data[category]) + len(additions) > 500 + ): + raise ValueError("The imported library would exceed 500 entries.") + for category in ("designs", "profiles", "recovery"): + ids = {e["id"] for e in self.data[category]} + self.data[category].extend( + e for e in incoming.data[category] if e["id"] not in ids + ) + self.data["recovery"] = sorted( + self.data["recovery"], key=lambda e: e["created"] + )[-30:] + self.data["locks"] = [ + k for k in LOCKS if k in self.data["locks"] or k in incoming.data["locks"] + ] + + def dumps(self): + return json.dumps(self.data, allow_nan=False) + + def command(self, op, p, payload=None): + payload = payload or {} + result = None + if op == "locks": + self.data["locks"] = [k for k in payload["locks"] if k in LOCKS] + elif op == "save": + self.add( + payload["category"], + p, + payload.get("name"), + payload.get("kind", "printer"), + ) + elif op == "favorite": + e = self.entry("designs", payload["id"]) + e["favorite"] = not e["favorite"] + elif op == "delete": + category = payload["category"] + self.data[category] = [ + e for e in self.data[category] if e["id"] != payload["id"] + ] + elif op == "restore": + category = payload["category"] + result = ( + self.apply_profile(p, payload["id"]) + if category == "profiles" + else VaseParams.from_dict(self.entry(category, payload["id"])["params"]) + ) + elif op == "import": + self.merge(payload) + elif op == "recover": + self.add("recovery", p) + elif op != "read": + raise ValueError("Unknown library action.") + return dict( + workspace=self.data, + params=result.to_dict() if result else None, + locks=list(LOCKS), + thumbnails={ + e["id"]: thumbnail(VaseParams.from_dict(e["params"])) + for e in self.data["designs"] + }, + ) + + +def comparison(d): + """Same original/final side envelope and movement values in both interfaces.""" + if d.errors: + raise ValueError("; ".join(d.errors)) + if d.free: + before = np.linalg.norm(d.P_design, axis=2) + after = np.linalg.norm(d.P, axis=2) + z = d.z + else: + before, after, z = d.R_design, d.R, d.z + old, new = before.max(axis=1), after.max(axis=1) + pick = np.unique(np.linspace(0, len(z) - 1, min(160, len(z))).astype(int)) + scale = 240 / max(float(old.max()), float(new.max()), 1) + + def line(values, side): + return " ".join( + f"{300 + side * float(values[i]) * scale:.2f},{330 - 300 * float(z[i]) / d.H:.2f}" + for i in pick + ) + + paths = [] + for side in (-1, 1): + for values, colour in ((old, "#008fa8"), (new, "#e88224")): + paths.append( + f'' + ) + for i in pick: + if abs(old[i] - new[i]) > 0.05: + y = 330 - 300 * float(z[i]) / d.H + for side in (-1, 1): + paths.append( + f'' + ) + svg = ( + '' + + "".join(paths) + + "" + ) + return dict( + svg=svg, + moved=float(d.softened), + text="Blue: requested surface. Orange: softened surface. Side envelopes are shown; angular changes may overlap. Maximum surface movement: " + + f"{d.softened:.2f} mm.", + ) + + +def calibration_parts(): + """Five closed sockets and matching pins; gaps are radial, in millimetres.""" + from .geometry import grid_side, fan, annulus, edge_check + + angles = np.arange(96) * 2 * math.pi / 96 + parts = [] + for k, gap in enumerate((0.1, 0.2, 0.3, 0.4, 0.5)): + z = np.array([0.0, 3.0]) + v, f, _, first, last = grid_side(z, angles, np.full((2, 96), 8.0)) + vi, fi, _, a, b = grid_side(z, angles, np.full((2, 96), 5 + gap)) + n = len(v) + faces = np.concatenate( + [f, fi[:, ::-1] + n, annulus(last, b + n), annulus(first, a + n)[:, ::-1]] + ) + verts = np.concatenate([v, vi]) + verts[:, 0] += (k - 2) * 19 + assert edge_check(faces)[0] + parts.append( + ( + f"Socket {k + 1}: radial gap {gap:.1f} mm", + verts, + faces, + 1, + (0.1, 0.5, 0.6), + ) + ) + v, f, _, a, b = grid_side(z, angles, np.full((2, 96), 5.0)) + n = len(v) + v = np.concatenate([v, [[0, 0, 0], [0, 0, 3]]]) + f = np.concatenate([f, fan(n, a, False), fan(n + 1, b, True)]) + v[:, 0] += (k - 2) * 19 + v[:, 1] -= 20 + assert edge_check(f)[0] + parts.append((f"Pin {k + 1}: 10 mm diameter", v, f, 1, (0.1, 0.5, 0.6))) + return parts + + +CALIBRATION_HELP = ( + "Print all five socket-and-pin pairs with the material and settings intended for your design. " + "Left to right: 0.1, 0.2, 0.3, 0.4 and 0.5 mm radial clearance (diametral gaps are twice these). " + "Pins are 10 mm across; every piece is 3 mm tall. Try each pin in its socket. " + "Choose the clearance that slides or grips as you need. This is a general fit test, not a lid-latch test; " + "it does not automatically change the design. Keep the pairs in order when arranging them in the slicer." +) diff --git a/web/app.js b/web/app.js index 9336f7a..58f84b1 100644 --- a/web/app.js +++ b/web/app.js @@ -6,6 +6,7 @@ import { CurveEditor } from "./curves.js"; import { OutlineEditor } from "./outline.js"; import { drawSide, drawTop } from "./plots.js"; import { View } from "./view.js"; +import { libraryDialog } from "./library.js"; const $ = (id) => document.getElementById(id); @@ -33,7 +34,11 @@ class Python { if (ok) w.resolve(result); else w.reject(new Error(lastLine(error))); }; - this.worker.onerror = (event) => failed(event.message || "Python stopped"); + this.worker.onerror = (event) => { + for (const w of this.waiting.values()) w.reject(new Error(event.message || "Python stopped")); + this.waiting.clear(); + failed(event.message || "Python stopped"); + }; } call(name, ...args) { @@ -58,12 +63,23 @@ const helper = new Python(); let C = null; // the settings, choices, constants and help, from Python let K = null; // C.constants let params = null; // the design's settings +let libraryState = store.get("library"); +let recoveryTimer = 0; let meta = null; // what the last build says about it let shown = new Set(); // the Advanced settings shown for it const undoStack = []; const redoStack = []; let liveFrom = null; // the settings before a drag began, for Undo let queue = Promise.resolve(); +let pendingText = null; +function flushText() { + if (pendingText) pendingText(); +} +function enqueue(work) { + const result = queue.then(work); + queue = result.catch((err) => showProblem(err.message)); + return queue; +} function pushUndo(snapshot) { undoStack.push(snapshot); @@ -86,16 +102,18 @@ function needsNormal(p) { return p.holes !== 0 || p.open_bottom || p.around < 360 || p.eyes !== 0 || p.mouth !== 0 || p.straps || p.box; } -function change(updates) { +function change(updates) { return enqueue(() => changeNow(updates)); } +function changeNow(updates) { // several settings at once, as a picture in Simple mode sets them if (!C) return; pushUndo(JSON.stringify(params)); - Object.assign(params, updates); + Object.assign(params, typeof updates === "function" ? updates(params) : updates); if (params.vase_mode === K.REAL_VASE && needsNormal(params)) params.vase_mode = K.FAKE_VASE; settled({}); } -function commit(key, value, live = false) { +function commit(key, value, live = false) { return enqueue(() => commitNow(key, value, live)); } +async function commitNow(key, value, live = false) { // one setting, as the desktop app's panel changes it: Python applies the app's own rules if (!C) return Promise.resolve(); if (live) { @@ -105,7 +123,7 @@ function commit(key, value, live = false) { rebuild({ live: true }); return Promise.resolve(); } - queue = queue.then(async () => { + { const before = liveFrom !== null ? liveFrom : JSON.stringify(params); liveFrom = null; try { @@ -118,16 +136,21 @@ function commit(key, value, live = false) { } catch (err) { showProblem(err.message); } - }); - return queue; + } } function settled({ refit = false, known = false }) { store.set("design", params); + rememberPrinter(); + clearTimeout(recoveryTimer); + const snapshot = JSON.stringify(params); + recoveryTimer = setTimeout(() => enqueue(() => libraryWork("recover", {}, snapshot)), 1500); refreshSimple(); refreshAdvanced(); if (!known) { - helper.call("shown", JSON.stringify(params)).then((keys) => { + const snapshot = JSON.stringify(params); + helper.call("shown", snapshot).then((keys) => { + if (JSON.stringify(params) !== snapshot) return; shown = new Set(JSON.parse(keys)); refreshAdvanced(); }).catch(() => {}); @@ -135,13 +158,15 @@ function settled({ refit = false, known = false }) { rebuild({ refit }); } -function replace(next, refit = true) { +function replace(next, refit = true) { return enqueue(() => replaceNow(next, refit)); } +function replaceNow(next, refit = true) { pushUndo(JSON.stringify(params)); params = next; settled({ refit }); } -function step(from, to) { +function step(from, to) { return enqueue(() => stepNow(from, to)); } +function stepNow(from, to) { if (!from.length) return; to.push(JSON.stringify(params)); params = JSON.parse(from.pop()); @@ -410,10 +435,10 @@ function buildSimple() { tiles($("above"), C.shapes.map((s) => ({ label: s.name, icon: aboveIcon(s.outline) })), (k) => change({ shape2: K.SAME, turn1: 0, ...C.shapes[k].settings })); tiles($("side"), C.profiles.map((s) => ({ label: s.name, icon: sideIcon(s.widths), tip: s.full })), - (k) => { + (k) => change(() => { const w = widest(); - change({ profile: k, silhouette: C.profiles[k].points.map(([h, f]) => [h, Math.round(f * w * 100) / 100]) }); - }); + return { profile: k, silhouette: C.profiles[k].points.map(([h, f]) => [h, Math.round(f * w * 100) / 100]) }; + })); tiles($("pattern"), C.patterns.map((s) => ({ label: s.name, icon: patternIcon(s.name) })), (k) => change({ ...C.no_pattern, ...C.patterns[k].settings })); const colourItems = C.colours.map((c) => ({ label: c.name, icon: swatch([c.colour]) })) @@ -450,16 +475,15 @@ function buildSimple() { $("twistwords").textContent = twistWords(15 * e.target.value); }); $("twist").addEventListener("change", (e) => { - const updates = { twist: 15 * Number(e.target.value) }; - if (params.twist_style === K.OWN) updates.twist_style = K.EVENLY; - change(updates); + const twist = 15 * Number(e.target.value); + change((p) => ({twist, ...(p.twist_style === K.OWN ? {twist_style: K.EVENLY} : {})})); }); for (const input of document.querySelectorAll("#printer input")) { input.addEventListener("change", () => { const v = parseFloat(input.value); if (!(v > 0)) return; change({ [input.dataset.key]: input.dataset.key === "walls" ? Math.round(v) : v }); - rememberPrinter(); + // settled() persists the final queued settings. }); } $("drawown").addEventListener("click", async () => { @@ -472,11 +496,11 @@ function rememberPrinter() { store.set("printer", Object.fromEntries(C.printer_keys.map((k) => [k, params[k]]))); } -async function fitHead(cm) { +async function fitHead(cm) { return enqueue(async () => { await withBusy("Fitting it to the head…", async () => - replace(JSON.parse(await helper.call("fit", JSON.stringify(params), cm * 10)), false)); + replaceNow(JSON.parse(await helper.call("fit", JSON.stringify(params), cm * 10)), false)); status(`Sized for a head ${cm} cm round — Undo to go back`); -} +}); } // ================================================================== Advanced: every setting const widgets = new Map(); // key -> {rows: [elements], set(p), input} @@ -656,7 +680,13 @@ function makeRow(row, rowsBox) { let timer = 0; text.addEventListener("input", () => { clearTimeout(timer); - timer = setTimeout(() => commit(key, text.value), 600); + const value = text.value; + pendingText = () => { + clearTimeout(timer); + pendingText = null; + commit(key, value); + }; + timer = setTimeout(flushText, 600); }); examples.addEventListener("change", () => { if (examples.value) setFormula(examples.value); @@ -715,11 +745,12 @@ function startFromMenu(k) { return start; } -function useOutline(k, points, repeat) { +function useOutline(k, points, repeat) { return enqueue(() => useOutlineNow(k, points, repeat)); } +function useOutlineNow(k, points, repeat) { // start drawn outline k again from given control points liveFrom = JSON.stringify(params); params[`repeat${k}`] = repeat; - return commit(`drawn${k}`, points); + return commitNow(`drawn${k}`, points); } let traceFor = 1; @@ -729,7 +760,8 @@ function tracePicture(k) { } async function traceFile(file) { - await withBusy("Tracing the picture…", async () => { + const target = traceFor; + return enqueue(() => withBusy("Tracing the picture…", async () => { const bitmap = await createImageBitmap(file); const scale = Math.min(1, 1080 / Math.max(bitmap.width, bitmap.height)); const w = Math.max(Math.round(bitmap.width * scale), 1); @@ -741,12 +773,14 @@ async function traceFile(file) { let text = ""; for (let i = 0; i < pixels.length; i += 0x8000) text += String.fromCharCode(...pixels.subarray(i, i + 0x8000)); const pts = JSON.parse(await helper.call("trace", btoa(text), w, h)); - await useOutline(traceFor, pts, 1); + bitmap.close(); + await useOutlineNow(target, pts, 1); status(`Traced ${file.name}: ${pts.length} points, ${pts.filter((q) => q[2]).length} sharp corners.`); - }).catch(() => {}); + }).catch(() => {})); } function setFormula(text) { + flushText(); const w = widgets.get("expression"); if (w && w.input) w.input.value = text; commit("expression", text); @@ -878,8 +912,8 @@ function buildGallery() { } function chooseDesign(title) { - return withBusy("Getting it ready…", async () => - replace(JSON.parse(await helper.call("design_of", title, JSON.stringify(params))))); + return enqueue(() => withBusy("Getting it ready…", async () => + replaceNow(JSON.parse(await helper.call("design_of", title, JSON.stringify(params)))))); } function openHelp(which) { @@ -950,8 +984,45 @@ function effectiveLight() { return t ? t === "light" : window.matchMedia("(prefers-color-scheme: light)").matches; } +async function libraryWork(op, payload = {}, text = JSON.stringify(params)) { + const result = JSON.parse(await helper.call("workspace_action", libraryState ? JSON.stringify(libraryState) : "", text, op, JSON.stringify(payload))); + libraryState = result.workspace; + // Unlike optional preferences, library failures must be visible to avoid silent data loss. + try { localStorage.setItem("vaseinator.library", JSON.stringify(libraryState)); } + catch { showProblem("The library could not be saved in this browser. Export a backup before closing the page."); } + if (result.params) replaceNow(result.params); + return result; +} + +function openLibrary() { + libraryDialog({ + current: () => params, + read: () => enqueue(() => libraryWork("read")), + command: (op, data) => { flushText(); return enqueue(() => libraryWork(op, data)); }, + exportFile: () => enqueue(() => download(JSON.stringify(libraryState, null, 2), "vaseinator-library.json", "application/json")), + importFile: (refresh) => { + const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; + input.onchange = async () => { if (input.files[0]) { await enqueue(async () => libraryWork("import", JSON.parse(await input.files[0].text()))); refresh(); } }; + input.click(); + }, + }); +} + const actions = { - new: () => replace({ ...C.defaults, ...Object.fromEntries(C.printer_keys.map((k) => [k, params[k]])) }), + library: openLibrary, + compare: () => enqueue(() => withBusy("Comparing the surfaces…", async () => { + const data = JSON.parse(await builder.call("compare_design", JSON.stringify(params))); + $("helptitle").textContent = "Original and softened"; + $("helptext").replaceChildren(); + const text = document.createElement("p"); text.textContent = data.text; + const image = document.createElement("img"); image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(data.svg); image.alt = data.text; image.style.width = "100%"; + $("helptext").append(text, image); $("helpexamples").hidden = true; $("help").showModal(); + })), + calibration: () => enqueue(() => withBusy("Making clearance samples…", async () => { + const {data, steps} = await builder.call("export_calibration", JSON.stringify(params)); + offer(data, "clearance_samples.3mf", "model/3mf", "How to use the samples", steps); + })), + new: () => enqueue(() => replaceNow({ ...C.defaults, ...Object.fromEntries(C.printer_keys.map((k) => [k, params[k]])) })), plain: () => chooseDesign("Plain cylinder"), open: () => $("openfile").click(), savevase: async () => download(await helper.call("save_vase", JSON.stringify(params)), fileName() + ".vase", "application/json"), @@ -974,8 +1045,8 @@ const actions = { picture: async () => download(await view.picture(), fileName() + "_3d.png", "image/png"), undo: () => step(undoStack, redoStack), redo: () => step(redoStack, undoStack), - surprise: () => withBusy("Thinking of something…", async () => - replace(JSON.parse(await helper.call("surprise_me", JSON.stringify(params), Math.floor(Math.random() * 1e6))))).catch(() => {}), + surprise: () => enqueue(() => withBusy("Thinking of something…", async () => + replaceNow(JSON.parse(await helper.call("surprise_me", JSON.stringify(params), Math.floor(Math.random() * 1e6), JSON.stringify(libraryState?.locks || []))))).catch(() => {})), plan: async () => { if (!meta) return; try { @@ -1021,6 +1092,15 @@ const actions = { "help-about": () => openHelp("about"), }; +for (const key of ["savevase", "print", "lid", "stl", "3mf", "picture", "plan"]) { + const action = actions[key]; + actions[key] = () => enqueue(action); +} +for (const key of Object.keys(actions)) { + const action = actions[key]; + actions[key] = () => { flushText(); return action(); }; +} + function closeMenus(except) { document.querySelectorAll(".menu[open]").forEach((m) => { if (m !== except) m.open = false; }); } @@ -1043,7 +1123,7 @@ function wire() { const file = e.target.files[0]; e.target.value = ""; if (!file) return; - await withBusy("Opening it…", async () => replace(JSON.parse(await helper.call("open_vase", await file.text())))).catch(() => {}); + await enqueue(() => withBusy("Opening it…", async () => replaceNow(JSON.parse(await helper.call("open_vase", await file.text()))))); }); $("picturefile").addEventListener("change", (e) => { const file = e.target.files[0]; @@ -1096,7 +1176,7 @@ async function boot() { buildOutlineWindow(); buildGallery(); const printer = store.get("printer") || {}; - params = { ...C.defaults, ...(store.get("design") || {}), ...printer }; + params = { ...C.defaults, ...printer, ...(store.get("design") || {}) }; $("loading").hidden = true; undoButtons(); setMode(Boolean(store.get("advanced"))); diff --git a/web/bridge.py b/web/bridge.py index 07a9c84..b9c8647 100644 --- a/web/bridge.py +++ b/web/bridge.py @@ -21,7 +21,7 @@ from vaseinator.choices import (COLOURS, MANY_COLOURS, NO_PATTERN, PATTERNS, SHAPES_FROM_ABOVE, TISSUE_HOLES, pattern_of, twist_words) from vaseinator.expr import EXAMPLES -from vaseinator.geometry import TOLERANCE, VaseDesign, fit_head +from vaseinator.geometry import TOLERANCE, VaseDesign, fit_head, require_exportable from vaseinator.lid import Lid from vaseinator.params import (CIRCLE, DRAWN, EVENLY, FAKE_VASE, FILE_FORMAT, FILE_VERSION, ONE_COLOUR, OWN, OWN_PROFILE, POLYGON, REAL_VASE, SAME, TISSUE_OVAL, TISSUE_ROUND, VaseParams) @@ -29,6 +29,7 @@ from vaseinator.report import ABOUT, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps from vaseinator.settings import COLOUR_STYLES, LIVE_KEYS, REAL_COLOUR_STYLES, SPEC, TEXT_KEYS, change, wanted from vaseinator.shapes import PALETTES, PROFILES, SHAPE_LABELS, pchip, shape_table +from vaseinator.workspace import Workspace, locked_surprise, comparison, calibration_parts, CALIBRATION_HELP from vaseinator.stl import write_stl from vaseinator.threemf import write_3mf @@ -137,8 +138,8 @@ def design_of(title: str, keep: str) -> str: return _dump(plain_design(k) if title == "Plain cylinder" else gallery_design(title, keep=k)) -def surprise_me(keep: str, seed: int) -> str: - return _dump(surprise(_params(keep), int(seed))) +def surprise_me(keep: str, seed: int, locks: str = "[]") -> str: + return _dump(locked_surprise(_params(keep), int(seed), json.loads(locks))) def fit(text: str, head_mm: float) -> str: @@ -235,8 +236,7 @@ def export_3mf(text: str): Returns (the file, the steps to print it, as HTML).""" p = _params(text) d = VaseDesign(p) - if d.errors: - raise ValueError("; ".join(d.errors)) + require_exportable(d) colors = d.p.colors() parts = d.export_parts() base = _name(p) @@ -248,7 +248,7 @@ def export_3mf(text: str): data = fh.read() steps = print_steps(d, f"{base}.3mf", len(parts)) steps = re.sub(r"^

Saved .*?

", "", steps, flags=re.S) # saved by the button above the steps - return data, steps.replace(" (the button below, or File ▸ Import)", " (File ▸ Import)") + return data, steps.replace(" (the button below, or File â–¸ Import)", " (File â–¸ Import)") def export_lid(text: str): @@ -256,25 +256,22 @@ def export_lid(text: str): Returns (the file, the steps to print it and put it on, as HTML).""" p = _params(text) d = VaseDesign(p) - if d.errors: - raise ValueError("; ".join(d.errors)) + require_exportable(d) lid = Lid(d) - if lid.errors: - raise ValueError(" ".join(lid.errors)) + require_exportable(lid) base = _name(p) + " lid" path = os.path.join(tempfile.gettempdir(), "lid.3mf") write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) with open(path, "rb") as fh: data = fh.read() steps = re.sub(r"^

Saved .*?

", "", lid_steps(lid, f"{base}.3mf"), flags=re.S) - return data, steps.replace(" (the button below, or File ▸ Import)", " (File ▸ Import)") + return data, steps.replace(" (the button below, or File â–¸ Import)", " (File â–¸ Import)") def export_stl(text: str) -> bytes: p = _params(text) d = VaseDesign(p) - if d.errors: - raise ValueError("; ".join(d.errors)) + require_exportable(d) path = os.path.join(tempfile.gettempdir(), "vase.stl") write_stl(path, *d.export_mesh(), _name(p)) with open(path, "rb") as fh: @@ -292,3 +289,19 @@ def open_vase(text: str) -> str: if data.get("format") != FILE_FORMAT: raise ValueError("That is not a Vaseinator design file.") return _dump(VaseParams.from_dict(data.get("params", {}))) + + +def workspace_action(state: str, text: str, op: str, payload: str = "{}") -> str: + return json.dumps(Workspace(json.loads(state) if state else None).command(op, _params(text), json.loads(payload))) + + +def compare_design(text: str) -> str: + return json.dumps(comparison(VaseDesign(_params(text)))) + + +def export_calibration(text: str): + p = _params(text) + path = os.path.join(tempfile.gettempdir(), "calibration.3mf") + write_3mf(path, "Clearance samples", calibration_parts(), (p.bed_x/2, p.bed_y/2, 0)) + with open(path, "rb") as fh: + return fh.read(), "

" + CALIBRATION_HELP + "

" diff --git a/web/index.html b/web/index.html index ead9b42..cb61192 100644 --- a/web/index.html +++ b/web/index.html @@ -24,6 +24,7 @@

Vaseinator

+
@@ -41,11 +42,13 @@

Vaseinator