diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77a9d89a..a0cd76e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,7 @@ jobs: - name: Install run: | python -m pip install --upgrade pip - pip install -e . - pip install pytest + pip install -e ".[test]" - name: Test run: python -m pytest -q diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 6dec9010..2fe01b93 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -58,24 +58,24 @@ jobs: name: snapmaker-studio-installer path: rc - - name: Download the published v1.1.0 installer, verify its checksum + - name: Download the published v1.5.0 installer, verify its checksum shell: pwsh run: | $ErrorActionPreference = "Stop" if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [string]::IsNullOrWhiteSpace($env:ImageOS) -or $env:GITHUB_REPOSITORY -ne 'DVOpenLabs/snapmaker-studio' ` -or $env:ACT -or $env:WDAGUtilityAccount) { throw 'Disposable preflight failed.' } - $url = "https://github.com/DVOpenLabs/snapmaker-studio/releases/download/v1.1.0/Snapmaker.Studio_1.1.0_x64-setup.exe" - $dest = "old\Snapmaker.Studio_1.1.0_x64-setup.exe" + $url = "https://github.com/DVOpenLabs/snapmaker-studio/releases/download/v1.5.0/Snapmaker.Studio_1.5.0_x64-setup.exe" + $dest = "old\Snapmaker.Studio_1.5.0_x64-setup.exe" New-Item -ItemType Directory -Force -Path old | Out-Null Invoke-WebRequest -Uri $url -OutFile $dest $hash = (Get-FileHash -Algorithm SHA256 $dest).Hash.ToLower() - $expected = "b41931774eac5586bc31746599d4a637e89eda195c620067dc1c756281b217ee" + $expected = "d443f64f5167239f1422c719882cf245cff5d00d40c55fb67fc5a1e497b84e82" if ($hash -ne $expected) { - throw "Downloaded v1.1.0 installer hash $hash does not match the published $expected" + throw "Downloaded v1.5.0 installer hash $hash does not match the published $expected" } - Write-Host "v1.1.0 installer verified: $hash" + Write-Host "v1.5.0 installer verified: $hash" - - name: Install v1.1.0 (default path), record its registration + - name: Install v1.5.0 (default path), record its registration shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -90,14 +90,14 @@ jobs: if ((Test-Path -LiteralPath $userKey) -or (Test-Path -LiteralPath $machine64Key) -or (Test-Path -LiteralPath $machine32Key) -or (Test-Path -LiteralPath $rememberedKey) -or (Test-Path ` -LiteralPath $manufacturerKey) -or (Test-Path -LiteralPath $defaultExe) -or (Get-Process -Name 'snapmaker-studio-desktop' -ErrorAction SilentlyContinue)) { throw ` 'Initial exact production state is not clean.' } - $old = (Resolve-Path "old\Snapmaker.Studio_1.1.0_x64-setup.exe").Path + $old = (Resolve-Path "old\Snapmaker.Studio_1.5.0_x64-setup.exe").Path $p = Start-Process -FilePath $old -ArgumentList '/S', '/NCRC' -PassThru -Wait - if ($p.ExitCode -ne 0) { throw "v1.1.0 silent install failed, exit $($p.ExitCode)" } + if ($p.ExitCode -ne 0) { throw "v1.5.0 silent install failed, exit $($p.ExitCode)" } Start-Sleep -Seconds 2 $beforePath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Studio' - if (-not (Test-Path -LiteralPath $beforePath)) { throw 'Exact v1.1.0 registration is absent' } + if (-not (Test-Path -LiteralPath $beforePath)) { throw 'Exact v1.5.0 registration is absent' } $beforeProps = Get-ItemProperty -LiteralPath $beforePath - if ($beforeProps.DisplayVersion -ne "1.1.0") { throw "v1.1.0 registration says DisplayVersion=$($beforeProps.DisplayVersion), expected 1.1.0" } + if ($beforeProps.DisplayVersion -ne "1.5.0") { throw "v1.5.0 registration says DisplayVersion=$($beforeProps.DisplayVersion), expected 1.5.0" } # NSIS can write InstallLocation wrapped in literal quote # characters (part of the string value itself, not just how it # displays) — trim them unconditionally so every later @@ -112,7 +112,7 @@ jobs: if ($installLocation -ne (Join-Path $env:LOCALAPPDATA 'Snapmaker Studio')) { throw 'First install did not use the exact default path.' } "owned_uninstaller=$ownedUninstaller" >> $env:GITHUB_ENV "owned_uninstaller_sha256=$((Get-FileHash -LiteralPath $ownedUninstaller -Algorithm SHA256).Hash)" >> $env:GITHUB_ENV - Write-Host "v1.1.0 installed at $installLocation, DisplayVersion 1.1.0" + Write-Host "v1.5.0 installed at $installLocation, DisplayVersion 1.5.0" pwsh -NoProfile -File .github/scripts/ci-assert-registry.ps1 -Action Assert -InstallDir $installLocation -OutFile $env:RUNNER_TEMP\rc-before.json if ($LASTEXITCODE -ne 0) { throw 'Registry assertion failed.' } diff --git a/CHANGELOG.md b/CHANGELOG.md index 768c6cc9..ff2bdfa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,58 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [1.5.1] - not yet released (the date is set when the release is published) + +**Clearer Prepare, safer plate and tower reporting, easier spool choices, and controls that work from the keyboard and with a screen reader.** + +### Added +- **Choose any spool from your inventory** (Project Materials). Each slot can open the provider's + whole spool list, searchable by vendor, material, colour or id, showing colour, vendor, material, + spool id, weight status and the status of the mapped Orca preset. When a spool is picked with no + Orca preset, the slot and the review say the project's existing filament preset will remain. +- **Forget saved mapping.** A mapping Studio remembered for one spool, or for similar spools, can be + forgotten from the slot. The confirmation says what is forgotten and what is not changed (your + inventory, your Orca presets and the project). +- **Why a setting changed.** The Prepare summary shows a short reason under each setting Studio + changed for the U1 and labels where the statement comes from: "Studio's check" or "Verify in + Snapmaker Orca". Lists Studio had to repair say exactly what was done. +- **User guide links.** Help and Get Started link to the task-first user guide, which shows where + each statement comes from and which Studio version it was checked against. Its screenshots were + captured from v1.5.0 and have not been retaken. + ### Fixed +- **Recommended now keeps the prime-tower shape it lists.** Wall type, purge-line spacing, rib length and cone angle are written from the U1 starter and now always declared, including when the source already held the same values, so Snapmaker Orca no longer resets them to its own preset. Prepare and the placement card share one prime-tower note: Studio cannot tell how far a tower reaches, Orca only builds one when the print needs it, and Orca can refuse paths beyond the plate if it does. The `z_hop_when_prime` value Studio writes is labelled as unverified in the settings list. +- **Preparing a multi-plate project from a printer with a different plate size now lays each plate's + objects out on the U1's plate grid** (translation only; rotation, scale and painting untouched), in + both modes, so Snapmaker Orca no longer lists the objects on later plates as outside. Studio only does + this when every object's whole footprint lies inside the plate area its project record names for it; + otherwise nothing is moved and the prepared copy says why. +- **Projects with more than one plate no longer show a green "every object is inside" when Studio + cannot check where the plates sit.** Studio checks that each plate fits the U1 on its own. The Object + placement card says that Orca can still list an object as Outside and to review the project in + Snapmaker Orca; the printer bed check reports "unknown" instead of a pass, so Ready Now does not count + such a project as ready; and the Project Doctor's "ready" line carries the same note. +- **A prime tower outside the printable area is reported, and never moved.** When the tower's own start + position is already outside the U1's printable area, the Object placement card, the Prepare summary, + the Project Doctor and the printer checks say so, because Snapmaker Orca can refuse to slice that + plate. In both modes Studio keeps the project's own tower position (the starter settings no longer + replace it); it does not estimate how far the tower reaches (that depends on the tool changes Snapmaker + Orca plans). Any other tower is "not verified": review it in Snapmaker Orca. +- **The plate count now comes from the plates the project declares**, not from slice-cache files, so + a project with several plates is no longer counted as one. +- **"Use Studio's U1 starter settings instead" keeps the support settings** (`enable_support`, support + type, support filament, interface filament) **when the model itself marks where it needs support** + (painted support enforcers, support-enforcer volumes, or support switched on for an object). + Explicitly disabled supports stay off, and blocker-only data does not turn supports on. When the + model marks nothing, the Prepare summary says before you open the copy that Snapmaker Orca will open + with supports off (and that painted support areas then do nothing). The summary no longer describes + the written settings as "kept" in that case, and the Validation Center only says your setting values + are kept for a copy prepared with Preserve creator settings. +- **Studio no longer recommends Arrange as a remedy.** Arrange was only ever observed failing to fix + an Outside object or an inherited prime tower, and was never tested otherwise, so every message and + guide page that told you to use it now says to review the project in Snapmaker Orca before slicing. +- Bed fit now reports each object's size and the instances' placement as separate facts; several small, separated objects are no longer summed into one "too big" object (#91). +- Object placement judges each placed copy of a repeated object on its own, so a copy that hangs off the plate is no longer hidden by one that does not (#93). - **More Bambu Studio projects now prepare.** Studio keeps six more per-object settings that Snapmaker Orca 2.4.0 reads in the same words: infill pattern, skeleton and skin infill density, top and bottom shell layers, and brim type (`no_brim`, `outer_only`, `inner_only`, @@ -38,6 +89,28 @@ All notable changes to this project are documented here. The format is based on - Preparing a copy no longer leaves a `.orig.3mf` file beside your original. The original was never modified; the extra copy was only a leftover. The command-line `repair` and `optimize` commands still keep their snapshot, and no longer leave one behind if they fail. +- **A saved-mappings file Studio could not read is no longer treated as damaged.** If another + program briefly holds the file, Studio waits and retries; if it still cannot read it, it changes + nothing and asks you to try again. +- **Printer-action confirmation is an accessible dialog.** Start, Cancel print and Emergency stop + now open a named, described dialog that starts on Cancel, is dismissed only by Escape or Cancel, + names the printer (and the file for Start), and is withdrawn if the printer changes or can no + longer take the action. +- **Tool tabs work from the keyboard.** The Compatibility / Source Check and Print Quality / First + Layer tab rows respond to the arrow keys, Home and End, show a focus outline and wrap on narrow + windows. Moving along the row does not open or re-run a tool. + +### Known limits (not changed) +- Studio does not move objects between plates, does not move the prime tower, and does not rearrange + a project's plates beyond the translation above. For a project made for a printer with a larger bed + that Studio cannot re-grid (an object's footprint does not match its recorded plate, or the project + has a single plate), the objects keep the creator's positions, so Snapmaker Orca can list an object as + Outside even when its plate fits, and in our test Arrange all plates did not fix it; the object had + to be moved by hand. +- The starter-settings mode resets a multi-nozzle flush table to zeros. Check the tower in Orca's + preview and open Flushing volumes if you use them. +- Both modes keep the creator's wipe-tower position, which can lie outside the U1's printable area for + a project made for a larger bed; Studio says so but does not move it. ## [1.5.0] - 2026-10-06 diff --git a/README.md b/README.md index 69e732b0..58ea4299 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ changed. Snapmaker Orca still does the slicing.** Free, open source, and local. No account, no cloud, nothing uploaded off your local network. Your original file is never modified. -### [▶ Watch it work — 66 seconds](https://github.com/DVOpenLabs/snapmaker-studio/blob/main/docs/media/snapmaker-studio-demo.mp4) · [⬇ Download for Windows or Linux](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.0) · [What it is, in 5 minutes](docs/innovation-fund/JUDGE_OVERVIEW.md) +### [▶ Watch it work — 66 seconds](https://github.com/DVOpenLabs/snapmaker-studio/blob/main/docs/media/snapmaker-studio-demo.mp4) · [⬇ Download for Windows or Linux](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.1) · [What it is, in 5 minutes](docs/innovation-fund/JUDGE_OVERVIEW.md) [![Watch the Snapmaker Studio demo](docs/media/demo-poster.jpg)](https://github.com/DVOpenLabs/snapmaker-studio/blob/main/docs/media/snapmaker-studio-demo.mp4) @@ -71,30 +71,31 @@ recording, so it also shows what Studio says when it cannot reach one: **Windows 10/11 (x64)** ✅ · **Linux x86_64 (.deb)** ✅ · **macOS** not supported. -**[⬇ Download for Windows](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.0)** +**[⬇ Download for Windows](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.1)** — one click, no Python, runs offline. -**[⬇ Download for Linux](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.0)** +**[⬇ Download for Linux](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.1)** — one `.deb`, no Python, no Node, no Rust. Install guide: [docs/linux-install.md](docs/linux-install.md). -**v1.5.0 is the current stable release** — not a prerelease, so this is also what -GitHub's [latest release](https://github.com/DVOpenLabs/snapmaker-studio/releases/latest) -points at. Every build ever published is on the +**v1.5.1 is a release candidate that has not been published yet.** The sizes and hashes below belong to that +candidate build, which has not passed its acceptance gates ([docs/TRUST_STATUS.md](docs/TRUST_STATUS.md)). Until +it is published, the [latest published release](https://github.com/DVOpenLabs/snapmaker-studio/releases/latest) is the one before it. +Once published, v1.5.1 is intended as a stable release, not a prerelease. Every build ever published is on the [Releases page](https://github.com/DVOpenLabs/snapmaker-studio/releases). Verify it before you run it: -- Release: [v1.5.0](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.0) -- Windows installer: `Snapmaker.Studio_1.5.0_x64-setup.exe` - — 21,944,474 bytes — SHA256: `d443f64f5167239f1422c719882cf245cff5d00d40c55fb67fc5a1e497b84e82` -- Linux package: `snapmaker-studio_1.5.0_amd64_5245cf9f9664.deb` - — 25,944,098 bytes — SHA256: `7fca28d9d4e2558f8a6bdc765b121e2290c54abd39e2a1b9b2efaeea6d923e5a` +- Release: [v1.5.1](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.1) +- Windows installer: `Snapmaker.Studio_1.5.1_x64-setup.exe` + — 22,563,613 bytes — SHA256: `703eac4f394ab92bc30c1eaa67733f1a7b274c23e2900089980fc2a1b59e4082` +- Linux package: `snapmaker-studio_1.5.1_amd64_b83e236148dc.deb` + — 26,119,758 bytes — SHA256: `75d88a0d64a67c89ad7ded45865359b1b6642309ace14cb9945f5f55863766ff` ```powershell -Get-FileHash -Algorithm SHA256 .\Snapmaker.Studio_1.5.0_x64-setup.exe +Get-FileHash -Algorithm SHA256 .\Snapmaker.Studio_1.5.1_x64-setup.exe ``` ```bash -sha256sum snapmaker-studio_1.5.0_amd64_5245cf9f9664.deb +sha256sum snapmaker-studio_1.5.1_amd64_b83e236148dc.deb ``` Neither installer is code-signed yet, so Windows SmartScreen will show "Unknown @@ -127,11 +128,11 @@ sliced job back**. | The problem, named exactly | The fix, in a new copy — and where Studio says it can't tell | |---|---| -| ![One object hangs 45 mm past the right edge](docs/screenshots/v1.5.0/problem.png) | ![The prepared copy, with what survived and what changed](docs/screenshots/v1.5.0/prepared.png) | +| ![One object hangs 45 mm past the right edge](docs/screenshots/v1.5.1/problem.png) | ![The prepared copy, with what survived and what changed](docs/screenshots/v1.5.1/prepared.png) | | **Painted colour, read before slicing** | **What to load — and where Studio says it cannot tell** | -| ![Which filaments the painting uses, and what that means for four toolheads](docs/screenshots/v1.5.0/painted.png) | ![Each slot named, with the unknowns marked as unknown](docs/screenshots/v1.5.0/what-to-load.png) | +| ![Which filaments the painting uses, and what that means for four toolheads](docs/screenshots/v1.5.1/painted.png) | ![Each slot named, with the unknowns marked as unknown](docs/screenshots/v1.5.1/what-to-load.png) | -From the v1.5.0 build's own installed-application run: the problem and prepared views on the sample project in [`examples/demo_u1_showcase.3mf`](examples/demo_u1_showcase.3mf); the painted view on the acceptance harness's dedicated multi-colour fixture; the what-to-load view on a sliced-job fixture the harness supplies to exercise that screen. Reproduce them with [docs/innovation-fund/JUDGE_WALKTHROUGH.md](docs/innovation-fund/JUDGE_WALKTHROUGH.md). Submission package: [docs/innovation-fund/FINAL_SUBMISSION.md](docs/innovation-fund/FINAL_SUBMISSION.md). +From an earlier v1.5.1 release candidate's installed-application run (a different build, not the current candidate): the problem and prepared views on the sample project in [`examples/demo_u1_showcase.3mf`](examples/demo_u1_showcase.3mf); the painted view on the acceptance harness's dedicated multi-colour fixture; the what-to-load view on a sliced-job fixture the harness supplies to exercise that screen. Reproduce them with [docs/innovation-fund/JUDGE_WALKTHROUGH.md](docs/innovation-fund/JUDGE_WALKTHROUGH.md). Submission package: [docs/innovation-fund/FINAL_SUBMISSION.md](docs/innovation-fund/FINAL_SUBMISSION.md). ## Why this isn't a slicer, a dashboard, or a converter @@ -165,38 +166,40 @@ its licence in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). ## Evidence -Everything below was run against the v1.5.0 release-candidate build — the -same application and Linux package published on the release page, checked by -SHA256 — not against a development build. The Windows installed-application -run used an acceptance copy of the release-candidate installer (same application -files, separate install identity); the real installer itself was exercised only on a -disposable GitHub-hosted runner. The installed-application -acceptance run used an acceptance-identity copy of the Windows installer on a -clean local account; the real installer's own upgrade and uninstall were -exercised on a disposable GitHub-hosted runner. The backend and desktop test -counts come from the source at the release commit, not from the installer. Commands, counts and full reports: -[docs/TRUST_STATUS.md](docs/TRUST_STATUS.md). +The v1.5.1 candidate named above has been built. It has passed the build, the Windows upgrade smoke test +and the Linux clean-image checks (49 of 49 on each of Ubuntu 22.04 and 24.04), and the tests that drive the installed +app (50 of 50, with two test-setup deviations recorded in the trust status). Six prepared copies opened in Snapmaker +Orca 2.4.1; opening a copy is not a slice. The real-printer checks have **not** been run on it. + +The table and the two paragraphs after it are results from an **earlier v1.5.1 release candidate** — a different +build, never published, made before the Prepare, plate and prime-tower changes in this one. They show how the checks +work and what that build passed; they are not acceptance of the current candidate and will be replaced by its own +results. The one exception is the last row (test counts), which comes from the current candidate's source. The Windows installed-application run used an acceptance copy of that earlier installer on a clean local +account; the real installer's own upgrade and uninstall were exercised on a disposable GitHub-hosted runner. Commands, +counts and full reports: [docs/TRUST_STATUS.md](docs/TRUST_STATUS.md). | What | Result | |---|---| -| Installed-application acceptance, driven through the real UI | **45/45** | -| Read-only checks against a real Snapmaker U1, from the Windows-installed app | **58/58** | +| Installed-application acceptance, driven through the real UI | **50/50** | +| Read-only checks against a real Snapmaker U1, from the Windows-installed app, with Spoolman and Bambuddy | **76/76** | | Linux clean-image validation, Ubuntu 22.04 + 24.04 | **49/49** each | | Regression tests against genuine OrcaSlicer / BambuStudio / PrusaSlicer projects | **36 tests** | | End-to-end pipeline self-check (`u1convert selfcheck`) | **27/27** | -| Backend / desktop / TypeScript / Rust | 2804 · 725 · clean · clean | - -The installed-application acceptance count (45/45) is a different total from -v1.2.0's: this release adds two checks for the SpoolEase provider and -three checks that the test lane itself leaves nothing behind, and drops one -older bookkeeping check — see [docs/TRUST_STATUS.md](docs/TRUST_STATUS.md). - -The real-U1 verification for this release ran read-only through the -Windows-installed application itself, 58/58, with no printer-control action -called. The Spoolman/Bambuddy-seeded provider-on-hardware checks were not run -this release, no real SpoolEase device has been tested yet, this printer's four -toolheads all carry the same 0.4 mm nozzle, and the Linux-installed app was not -run against the real printer. Full detail: +| Backend / desktop / TypeScript / Rust | 3716 · 902 · clean · clean | + +The installed-application acceptance count (50/50) is a different total from +the previous release's: that run was the upgrade variant, started from the previous +release's installer, and adds five checks that the previous version installs, leaves its state +to migrate, and that the upgrade keeps your data and registers the new version — see +[docs/TRUST_STATUS.md](docs/TRUST_STATUS.md). + +The real-U1 verification for that earlier release candidate ran read-only through the +Windows-installed application itself, 76/76, with no printer-control action +called. Both Spoolman (0.27.0) and Bambuddy (1.2.5.7) took part, as disposable +local containers seeded with anonymous spools, and the provider-on-hardware checks +for each ran and none were skipped. No real SpoolEase device has been tested yet, +this printer's four toolheads all carry the same 0.4 mm nozzle, and the +Linux-installed app was not run against the real printer. Full detail: [docs/TRUST_STATUS.md](docs/TRUST_STATUS.md). Reproduce any of it yourself: @@ -208,6 +211,32 @@ files produced structurally valid U1 profile copies ([PROOF.md](PROOF.md)); that number measures structure, not print success, and the checks above are the stronger evidence. +## What's new in v1.5.1 + +**Clearer Prepare, safer plate and tower reporting, easier spool choices, and controls that work from the keyboard and with a screen reader.** + +- **More Bambu Studio projects prepare, and refusals are in plain language.** Six more per-object settings are kept + (infill pattern, skeleton and skin infill density, top and bottom shell layers, brim type); anything Studio cannot + confirm is still refused rather than guessed, and files it will not prepare say "Review in Orca first" everywhere. +- **Honest plate and prime-tower checks.** Multi-plate projects from a larger bed are laid out on the U1 plate grid + only when every footprint lies in its recorded plate; Studio no longer shows a green placement for plates it + cannot check, reports a tower outside the printable area without moving it, and no longer recommends Arrange. + Whether Snapmaker Orca keeps a project's tower position in the starter-settings mode has not been verified. +- **Choose any spool from your inventory.** Each slot can open your provider's whole spool list, + searchable by vendor, material, colour or id. If you pick a spool but no Orca preset, the slot and + the review say the project's existing filament preset will remain. +- **Forget a saved mapping.** A remembered spool-to-preset mapping now has a **Forget saved mapping** + button; the confirmation says what is forgotten and what is not changed. +- **A saved-mappings file that is briefly locked is no longer treated as damaged.** Studio retries, + and if it still cannot read the file it changes nothing and asks you to try again. +- **Prepare says why a setting changed**, and whether the statement is Studio's own check or advice to + verify in Snapmaker Orca. +- **Accessible printer-action confirmation and tool tabs.** The Start, Cancel print and Emergency stop + confirmation is a proper dialog that starts on Cancel; the tool tab rows work with the arrow keys, + Home and End. +- **User guide links** from Help and Get Started. The guide's screenshots were captured from v1.5.0 + and have not been retaken. + ## What's new in v1.5.0 **Project Materials: the real spool and the real Snapmaker Orca preset, for each colour.** @@ -225,13 +254,6 @@ Presets you made yourself can be found and mapped, but are shown as **"Confirmed **"Manual check in Orca required"**: in testing Snapmaker Orca kept the project's own values for such a preset, so Studio does not say Orca will apply them. Check the filament in Orca before slicing. -| Suggested spools, nothing selected | Review before Prepare | Orca's presets and yours, told apart | -|---|---|---| -| ![Project Materials: top spool candidates with reasons, no spool selected](docs/screenshots/v1.5.0/pm-1-recommendations.png) | ![Review: each slot mapped, what changed, what the preset controls](docs/screenshots/v1.5.0/pm-3-fidelity-review.png) | ![A user preset marked Manual check in Orca required, next to a built-in preset](docs/screenshots/v1.5.0/pm-5-system-vs-user.png) | - -From the v1.5.0 release-candidate build's own installed-application run, against anonymised -disposable spools and presets. - ## What's new in v1.4.1 **A readable spool picker.** Settings → Materials provider now has a Studio list in place of the diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 304a6bcc..0d1d9d60 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "snapmaker-studio" -version = "1.5.0" +version = "1.5.1" description = "Snapmaker Studio engine — a local-first workflow platform for modern 3D printing: understand, validate, and prepare designs print-ready (Bambu/Orca/STL today; Snapmaker U1 as the first printer target)." readme = "README.md" requires-python = ">=3.13" @@ -22,6 +22,8 @@ classifiers = [ [project.optional-dependencies] dev = ["pytest>=8"] +# Test-only: validates the committed scene/1 JSON Schema with a real validator (never imported by the engine). +test = ["pytest>=8", "jsonschema>=4.18,<5"] [project.scripts] u1convert = "u1convert.cli:cli" diff --git a/backend/snapstudio_api/scene_jobs.py b/backend/snapstudio_api/scene_jobs.py new file mode 100644 index 00000000..78259d0d --- /dev/null +++ b/backend/snapstudio_api/scene_jobs.py @@ -0,0 +1,834 @@ +"""Scene jobs: the bounded, cancellable background build behind /scene/start|status|result|cancel. + +Model (frozen by plan v3/v4/v5/v6): + +* **One worker thread, at most one queued job.** A new start cancels and replaces the client's own + earlier work (the newest queued job wins; a running job is marked cancelled at once). The worker + slot is released only when the thread has actually exited; a replacement therefore waits, queued, + until the cancelled thread observes the flag (bounded by the cooperative-interruption units in + ``snapstudio_core.scene_limits``). +* **States** ``queued | running | succeeded | failed | cancelled``. Terminal states are immutable and + the FIRST terminal wins: a worker finishing after a cancel discards its result. Cancel is legal from + queued|running and a late cancel on a terminal job is a no-op. There is no stored "expired" state: + an evicted or unknown id answers 404 EXPIRED. +* **Retention.** A terminal job is kept for ``RESULT_TTL_SECONDS`` or until more than + ``MAX_TERMINAL_JOBS`` terminal jobs exist (oldest first). Only jobs whose worker has exited are evicted. +* **Deadline.** 60 s wall clock from the moment the job becomes ``running``, observed cooperatively. + Accepted limitation: a single stuck native/filesystem call cannot be interrupted in-process (there is + no multiprocessing in the frozen sidecar). The backstop is fail-closed: if the worker is still alive + ``WEDGE_GRACE_SECONDS`` after a cancel or after the deadline, the job is reported failed with TIMEOUT + (when it was the deadline) and every new start answers 503 WORKER_WEDGED until the thread exits. +* **Snapshot.** The source is copied once into a private file under + ``/scene-tmp/-/`` (streamed 1 MiB chunks, hard ceiling), hashed (the revision is the + SHA-256 of those bytes), and ONLY the copy is parsed. A scene is a consistent snapshot of the file as + copied. ``SOURCE_CHANGED`` therefore applies only to (a) a file that changed while it was being + copied, (b) a reused request_id+path whose file now hashes differently from that job's revision, and + (c) an ``expected_revision`` that differs from the job's revision. It does NOT apply to edits made + after the snapshot was taken. +* **Snapshot lifetime.** The WORKER deletes its own snapshot in its ``finally`` BEFORE setting + ``worker_done``. Eviction removes only the snapshot of a terminal job whose worker is done, with a + missing-file-tolerant unlink. Cancel never deletes a file. A startup sweep removes only + ``scene-tmp/-`` directories whose pid is dead (or older than 24 h) and tolerates sharing + violations; it never touches a live process's directory. +* **Nothing here touches the original file except to read it.** +""" +from __future__ import annotations + +import errno +import hashlib +import os +import re +import secrets +import shutil +import sys +import threading +import time +import uuid + +from snapstudio_core import paths as _paths +from snapstudio_core import scene +from snapstudio_core import scene_limits as L +from . import request_validation as rv + +ROUTES = ("/scene/session", "/scene/start", "/scene/status", "/scene/result", "/scene/cancel") +TERMINAL = ("succeeded", "failed", "cancelled") +_REQUEST_ID = re.compile(r"^[A-Za-z0-9_-]{8,%d}$" % L.MAX_REQUEST_ID_LENGTH) +_REVISION = re.compile(r"^[0-9a-f]{64}$") +_CLIENT_ID = re.compile(r"^[A-Za-z0-9_-]{8,64}$") +MAX_SEQ = 2 ** 53 - 1 +# (session capacity and idle TTL live in scene_limits: MAX_SESSIONS, SESSION_TTL_SECONDS) + + +class JobError(Exception): + """An answer the API gives instead of a result: HTTP status, stable code, safe message.""" + + def __init__(self, http: int, code: str, message: str, **extra) -> None: + self.http, self.code, self.message, self.extra = http, code, message, extra + super().__init__(message) + + def body(self) -> dict: + return {"error": self.code, "message": self.message, **self.extra} + + +class Session: + """O(1) ordering state of one client session. Nothing else is remembered about its starts. + + ``watermark``: the highest admitted seq. ``dead_through``: the highest cancelled seq (every start with + seq <= dead_through is refused for as long as the session lives). ``current``: (seq, request_id, path_key, + job_id) of the entry at seq == watermark, for the idempotent repeat. + """ + + def __init__(self, client_id: str, now: float) -> None: + self.client_id, self.last_touch = client_id, now + self.watermark = 0 + self.dead_through = 0 + self.current: tuple[int, str, str, str | None] | None = None + + +class Job: + def __init__(self, job_id: str, request_id: str, path: str, now: float) -> None: + self.job_id, self.request_id, self.path = job_id, request_id, path + self.source = path # the path as the client gave it (for reading) + self.client_id: str | None = None # the OWNING session (None for a legacy job) + self.seq: int | None = None # the start's seq inside that session + self.state = "queued" + self.stage: str | None = None + self.completed: int | None = None + self.total: int | None = None + self.error: dict | None = None + self.revision: str | None = None + self.result: bytes | None = None + self.snapshot: str | None = None + self.created = now + self.deadline: float | None = None + self.terminal_at: float | None = None + self.cancelled_at: float | None = None + self.worker_done = threading.Event() + self.cancel_flag = threading.Event() + + +class _Control(scene.Control): + def __init__(self, registry: "SceneJobs", job: Job) -> None: + self.registry, self.job = registry, job + + def check(self) -> None: + job = self.job + if job.cancel_flag.is_set() or job.state != "running": + raise scene.SceneCancelled() + if self.registry._clock() > job.deadline: + raise scene.SceneTimeout() + + def progress(self, stage, completed, total) -> None: + self.job.stage, self.job.completed, self.job.total = stage, completed, total + + +def pid_alive(pid: int) -> bool: + """Whether a process with this pid exists. Errs towards 'alive' when it cannot tell.""" + if pid == os.getpid(): + return True + if pid <= 0: + return False + if sys.platform == "win32": + import ctypes + from ctypes import wintypes + k32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined] + k32.OpenProcess.restype = wintypes.HANDLE + k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + k32.CloseHandle.argtypes = [wintypes.HANDLE] + handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION + if not handle: + return ctypes.get_last_error() == 5 # access denied: the process exists + try: + code = wintypes.DWORD() + if not k32.GetExitCodeProcess(handle, ctypes.byref(code)): + return True + return code.value == 259 # STILL_ACTIVE + finally: + k32.CloseHandle(handle) + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return True + return True + + +def sweep_stale(root: str | None = None, *, now: float | None = None) -> list[str]: + """Remove scene-tmp directories left by engines that are gone. Returns what it removed. + + A ``-`` folder is removed only when its owner pid is NOT running AND the folder is older + than ``SNAPSHOT_DIR_MIN_AGE_SECONDS``. A folder owned by a live process is never touched, however old + (pid reuse is an accepted cost: a stale folder next to a recycled pid waits until that pid exits). + Sharing violations are tolerated and retried at the next start.""" + base = root or os.path.join(_paths.data_dir(create=False), "scene-tmp") + removed: list[str] = [] + try: + names = os.listdir(base) + except OSError: + return removed + now = time.time() if now is None else now + for name in names: + m = re.match(r"^(\d+)-([0-9a-f]+)$", name) + full = os.path.join(base, name) + if not m or not os.path.isdir(full): + continue + pid = int(m.group(1)) + if pid == os.getpid(): + continue # our own directory, whatever else is true + try: + age = now - os.stat(full).st_mtime + except OSError: + continue + if pid_alive(pid): + continue # NEVER a live process's folder, whatever its age + if age < L.SNAPSHOT_DIR_MIN_AGE_SECONDS: + continue # a just-exited engine's folder is left alone a while + try: + shutil.rmtree(full) + removed.append(name) + except OSError: + pass # sharing violation / in use: leave it for next time + return removed + + +class SceneJobs: + def __init__(self, *, clock=time.monotonic, builder=None, snapshot_root: str | None = None, + deadline: float = L.JOB_DEADLINE_SECONDS, wedge_grace: float = L.WEDGE_GRACE_SECONDS, + ttl: float = L.RESULT_TTL_SECONDS, max_terminal: int = L.MAX_TERMINAL_JOBS, + session_ttl: float = L.SESSION_TTL_SECONDS, max_sessions: int = L.MAX_SESSIONS) -> None: + self._session_ttl, self._max_sessions = session_ttl, max_sessions + self._clock = clock + self._builder = builder or scene.build_scene + self._root = snapshot_root + self._deadline, self._grace, self._ttl, self._max_terminal = deadline, wedge_grace, ttl, max_terminal + self._lock = threading.RLock() + self._jobs: dict[str, Job] = {} + self._by_request: dict[tuple[str | None, str], Job] = {} # (client_id | None, request_id) -> job + self._queued: Job | None = None + self._running: Job | None = None + self._worker: threading.Thread | None = None + self._dir: str | None = None + self._pending: list[list] = [] # [path, attempts, next_try] snapshot files that resisted deletion + # Ordering at the engine boundary (guarded by self._lock, the same lock that registers jobs). + self._sessions: dict[str, Session] = {} + + # ------------------------------------------------------------------ public API + def _check_source(self, path: str) -> None: + """The pre-registration file check (a seam: it runs BEFORE the registry lock is taken).""" + if not os.path.isfile(path): + raise JobError(400, "INVALID_REQUEST", "That file could not be found.") + + def open_session(self) -> dict: + """Issue a session (server-chosen id). I4: at capacity with nothing prunable it fails EXPLICITLY.""" + with self._lock: + self._housekeeping() + if len(self._sessions) >= self._max_sessions: + raise JobError(503, "SESSION_LIMIT", "Too many scene sessions are open. Try again later.") + client_id = secrets.token_urlsafe(18) # 24 characters of [A-Za-z0-9_-] + self._sessions[client_id] = Session(client_id, self._clock()) + return {"client_id": client_id, "ttl_s": int(self._session_ttl)} + + def _session_locked(self, client_id: str) -> Session: + """The live session or SESSION_EXPIRED (I1/I4: a late request from an expired or never-issued session is + refused, never re-created). Touches the session.""" + session = self._sessions.get(client_id) + if session is None: + raise JobError(409, "SESSION_EXPIRED", "That scene session is no longer open. Open a new session.") + session.last_touch = self._clock() + return session + + def _prune_sessions_locked(self) -> None: + """Remove only IDLE-EXPIRED sessions that own no registered job; a live session is never evicted.""" + now = self._clock() + owners = {key[0] for key in self._by_request} + for cid in [c for c, sess in self._sessions.items() + if now - sess.last_touch > self._session_ttl and c not in owners]: + del self._sessions[cid] + + def _admit_locked(self, client_id: str | None, seq: int | None, request_id: str, path_key: str) -> "Job | None": + """THE admission function for every start (new job, idempotent repeat), under the registry lock. + + Returns the registered job to hand back (idempotent), or None when a new job should be created; raises + otherwise. Legacy starts (no client_id) keep today's request_id-only behaviour in their own namespace. + For a session start, in order: unknown/expired session -> SESSION_EXPIRED; seq <= dead_through -> + CANCELLED_BEFORE_START; seq < watermark -> STALE_START; seq == watermark -> the same request_id and path + returns the job if it is still registered (otherwise STALE_START: an exact retry of a start that was + already processed is refused) and anything else is INVALID_REQUEST; seq > watermark -> new, except that + a request_id already registered for this session is INVALID_REQUEST (the old job is never handed back), + and that attempt still raises the watermark so an older attempt still in flight is refused. + """ + if client_id is None: + return self._by_request.get((None, request_id)) + session = self._session_locked(client_id) + if seq <= session.dead_through: + raise JobError(409, "CANCELLED_BEFORE_START", "That scene start was cancelled before it arrived.") + if seq < session.watermark: + raise JobError(409, "STALE_START", "A newer scene start from this session has already been accepted.") + existing = self._by_request.get((client_id, request_id)) + if seq == session.watermark: + cur = session.current + if cur is None or cur[1] != request_id or cur[2] != path_key: + raise JobError(400, "INVALID_REQUEST", "That seq was already used for another request.", + reason="seq reused for another request") + if existing is None or existing.job_id != cur[3]: + raise JobError(409, "STALE_START", "That scene start was already processed and its job is gone.") + return existing + if existing is not None: + session.watermark, session.current = seq, (seq, request_id, path_key, None) + raise JobError(400, "INVALID_REQUEST", "That request_id was already used by this session.", + reason="request id reused") + return None + + def _advance_locked(self, client_id: str | None, seq: int | None, request_id: str, path_key: str, + job_id: str | None = None) -> None: + """Record an attempt that has been (or is being) processed, so no delayed OLDER attempt can overtake it. + Called for every admitted start BEFORE anything that can still reject it (WORKER_WEDGED, an unsupported + format, a missing file), and again with the job id once it is registered.""" + if client_id is None: + return + session = self._sessions.get(client_id) + # STRICTLY greater: only a NEW attempt sets `current`. An exact retry (seq == watermark) never replaces the + # job reference of the entry it repeats, whatever else happens to that retry. + if session is not None and seq > session.dead_through and seq > session.watermark: + session.watermark, session.current = seq, (seq, request_id, path_key, job_id) + + def _precheck(self, path: str, key: str, request_id: str, client_id: str | None, seq: int | None) -> None: + """The checks that run before registration. A rejection here still counts as a processed attempt for its + session (its seq is recorded), so a delayed older start cannot be accepted behind it.""" + try: + if os.path.splitext(key)[1].lower() not in (".3mf", ".stl"): + raise JobError(422, "UNSUPPORTED_FORMAT", "Only .3mf and .stl files can be shown.") + self._check_source(path) + except JobError: + if client_id is not None: + with self._lock: + self._housekeeping() + existing = self._admit_locked(client_id, seq, request_id, key) # stale / cancelled / expired first + if existing is None: # a NEW attempt: record it as processed + self._advance_locked(client_id, seq, request_id, key) + raise + + def start(self, path: str, request_id: str, client_id: str | None = None, seq: int | None = None) -> dict: + key = os.path.normcase(os.path.abspath(path)) + self._precheck(path, key, request_id, client_id, seq) + for _attempt in range(2): + with self._lock: + self._housekeeping() + existing = self._admit_locked(client_id, seq, request_id, key) + if existing is None: + return self._create_locked(path, key, request_id, client_id, seq) + self._same_path_or_raise(existing, key) + revision = existing.revision + # Hash OUTSIDE the lock (it can be slow), then decide under it. The admission rules run FIRST: if a + # newer start, a cancel or the end of the session happened meanwhile the answer is STALE_START / + # CANCELLED_BEFORE_START / SESSION_EXPIRED, not SOURCE_CHANGED. + changed = revision is not None and self._current_hash(path) != revision + with self._lock: + self._housekeeping() + current = self._admit_locked(client_id, seq, request_id, key) + if current is existing and self._jobs.get(existing.job_id) is existing: + if changed: + raise JobError(409, "SOURCE_CHANGED", "The file changed since that scene was built. Start a new one.") + return self._start_body(existing, None) + # evicted while hashing (a legacy start only: a session start was refused above): start a fresh job + with self._lock: + self._housekeeping() + existing = self._admit_locked(client_id, seq, request_id, key) + if existing is not None: + self._same_path_or_raise(existing, key) + return self._start_body(existing, None) + return self._create_locked(path, key, request_id, client_id, seq) + + def _create_locked(self, path: str, key: str, request_id: str, client_id: str | None = None, + seq: int | None = None) -> dict: + self._advance_locked(client_id, seq, request_id, key) # H2: BEFORE anything below can reject + if self._wedged(): + raise JobError(503, "WORKER_WEDGED", "The scene worker is stuck. Try again after it recovers.") + replaced = None + for old in (self._queued, self._running if self._running is not None and self._running.state == "running" else None): + if old is not None: + if old.client_id == client_id: # H1: another session's job id is never revealed + replaced = old.job_id + self._cancel_locked(old) + self._queued = None + job = Job(uuid.uuid4().hex, request_id, key, self._clock()) + job.source = path + job.client_id, job.seq = client_id, seq + self._jobs[job.job_id] = job + self._by_request[(client_id, request_id)] = job + if client_id is not None: # every registration records the job reference atomically + self._sessions[client_id].current = (seq, request_id, key, job.job_id) + self._queued = job + if self._worker is None: + self._worker = threading.Thread(target=self._worker_main, name="scene-worker", daemon=True) + self._worker.start() + return self._start_body(job, replaced) + + def _touch_owner_locked(self, job: Job) -> None: + session = self._sessions.get(job.client_id) if job.client_id is not None else None + if session is not None: + session.last_touch = self._clock() + + def _get_owned(self, job_id: str, client_id: str | None) -> Job: + """Resolve a job id for a caller. A job owned by a session needs that session's client_id; a missing or + different one answers EXACTLY like an unknown id (404 EXPIRED), so existence is never confirmed. A legacy + job (no owner) keeps today's behaviour.""" + job = self._jobs.get(job_id) + if job is None or (job.client_id is not None and job.client_id != client_id): + raise JobError(404, "EXPIRED", "That scene is no longer available.") + self._touch_owner_locked(job) + return job + + def status(self, job_id: str, client_id: str | None = None) -> dict: + with self._lock: + self._housekeeping() + return self._status_body(self._get_owned(job_id, client_id)) + + def result(self, job_id: str, expected_revision: str | None = None, + client_id: str | None = None) -> tuple[int, bytes | dict]: + with self._lock: + self._housekeeping() + job = self._get_owned(job_id, client_id) + if expected_revision is not None and job.revision is not None and job.revision != expected_revision: + raise JobError(409, "SOURCE_CHANGED", "That scene was built from a different version of the file.") + if job.state == "succeeded": + return 200, job.result + if job.state == "failed": + return 422, {"error": job.error["code"], "message": job.error["message"]} + if job.state == "cancelled": + return 409, {"error": "CANCELLED", "message": "That scene was cancelled."} + return 409, {"error": "NOT_READY", "message": "That scene is not ready yet.", "state": job.state} + + def cancel_request(self, client_id: str, request_id: str, seq: int) -> dict: + """Cancel the start (client_id, request_id, seq). The session must exist (SESSION_EXPIRED otherwise). + ``dead_through`` rises to ``seq`` so that start, and every lower seq, stays refused for as long as the + session lives (O(1) memory: no per-request set), even when no job is registered yet. The registered job + is cancelled only if it is the job of that EXACT attempt: client_id, request_id AND seq all equal (a delayed + cancel for an old attempt can never cancel a newer job that reused the request id, and a cancel for a + higher seq does not cancel an older job; the start of that seq and every lower one stays refused).""" + with self._lock: + self._housekeeping() + session = self._session_locked(client_id) + session.dead_through = max(session.dead_through, seq) + job = self._by_request.get((client_id, request_id)) + if job is not None and job.client_id == client_id and job.request_id == request_id and job.seq == seq: + if job.state in ("queued", "running"): + if job is self._queued: + self._queued = None + self._cancel_locked(job) + body = self._status_body(job) + self._housekeeping() + return body + return {"job_id": None, "request_id": request_id, "state": "cancelled", "stage": None, + "completed": None, "total": None, "revision": None, + "error": {"code": "CANCELLED", "message": "That scene was cancelled."}} + + def cancel(self, job_id: str, client_id: str | None = None) -> dict: + with self._lock: + self._housekeeping() + job = self._get_owned(job_id, client_id) # touches the owner session too + if job.state in ("queued", "running"): + if job is self._queued: + self._queued = None + self._cancel_locked(job) + body = self._status_body(job) + self._housekeeping() # a cancelled-while-queued job can push past the cap + return body + + # ------------------------------------------------------------------ introspection (tests, benchmark) + def snapshot_dir(self) -> str | None: + return self._dir + + def retained(self) -> int: + with self._lock: + return len(self._jobs) + + def wedged(self) -> bool: + with self._lock: + return self._wedged() + + def worker_alive(self) -> bool: + with self._lock: + return self._worker is not None and self._worker.is_alive() + + def close(self, timeout: float = 5.0) -> None: + """Cancel everything and wait for the worker to exit (tests / orderly shutdown).""" + with self._lock: + for job in list(self._jobs.values()): + if job.state in ("queued", "running"): + if job is self._queued: + self._queued = None + self._cancel_locked(job) + worker = self._worker + if worker is not None: + worker.join(timeout) + if worker.is_alive(): + return + with self._lock: + for entry in list(self._pending): + self._try_unlink(entry) + if self._dir and not self._pending: + shutil.rmtree(self._dir, ignore_errors=True) # best effort; a later start sweeps what is left + + # ------------------------------------------------------------------ internals (lock held) + def _same_path_or_raise(self, job: Job, key: str) -> None: + if job.path != key: + raise JobError(409, "INVALID_REQUEST", "That request_id is already used for another file.", + reason="request_id reused for another path") + + def _get(self, job_id: str) -> Job: + job = self._jobs.get(job_id) + if job is None: + raise JobError(404, "EXPIRED", "That scene is no longer available.") + return job + + def _cancel_locked(self, job: Job) -> None: + if job.state not in ("queued", "running"): + return + was_queued = job.state == "queued" + now = self._clock() + job.state = "cancelled" + job.error = {"code": "CANCELLED", "message": "That scene was cancelled."} + job.terminal_at = job.cancelled_at = now + job.cancel_flag.set() + if was_queued: + job.worker_done.set() # it never had a worker or a snapshot + + def _wedged(self) -> bool: + job = self._running + if job is None or job.worker_done.is_set(): + return False + now = self._clock() + if job.state == "cancelled" and job.cancelled_at is not None and now >= job.cancelled_at + self._grace: + return True + return job.deadline is not None and now >= job.deadline + self._grace + + def _housekeeping(self) -> None: + now = self._clock() + for entry in list(self._pending): + if now >= entry[2]: + self._try_unlink(entry) + job = self._running + if (job is not None and job.state == "running" and job.deadline is not None + and now >= job.deadline + self._grace and not job.worker_done.is_set()): + self._fail_locked(job, "TIMEOUT", "Building the scene took too long.") # backstop; the thread is wedged + evictable = [j for j in self._jobs.values() if j.state in TERMINAL and j.worker_done.is_set()] + for j in evictable: + if now - j.terminal_at > self._ttl: + self._drop(j) + terminal = [j for j in self._jobs.values() if j.state in TERMINAL] + excess = len(terminal) - self._max_terminal + if excess > 0: + for j in sorted((j for j in terminal if j.worker_done.is_set()), key=lambda j: j.terminal_at)[:excess]: + self._drop(j) + self._prune_sessions_locked() # AFTER the evictions: a session whose last job was just evicted can go now + + def _drop(self, job: Job) -> None: + if job.snapshot: + self._defer_unlink(job.snapshot) + job.snapshot = None + job.result = None + self._jobs.pop(job.job_id, None) + if self._by_request.get((job.client_id, job.request_id)) is job: + del self._by_request[(job.client_id, job.request_id)] + + def _defer_unlink(self, path: str) -> None: + """Delete a snapshot file now; if Windows (or anything) still holds it, KEEP the path on a retry list + with back-off. It is forgotten only when it is gone, or when the process exits (the startup sweep + of a later engine then removes the folder).""" + entry = [path, 0, self._clock()] + if not self._try_unlink(entry) and len(self._pending) < L.MAX_PENDING_UNLINKS: + self._pending.append(entry) + + def _try_unlink(self, entry: list) -> bool: + try: + os.unlink(entry[0]) + except FileNotFoundError: + pass + except OSError: + entry[1] += 1 + entry[2] = self._clock() + min(2.0 ** entry[1], 30.0) # 2 s, 4 s, ... capped at 30 s + return False + if entry in self._pending: + self._pending.remove(entry) + return True + + def _fail_locked(self, job: Job, code: str, message: str) -> None: + if job.state in TERMINAL: + return # the first terminal state wins + job.state = "failed" + job.error = {"code": code, "message": message} + job.terminal_at = self._clock() + job.cancel_flag.set() + + def _status_body(self, job: Job) -> dict: + running = job.state == "running" + return {"job_id": job.job_id, "request_id": job.request_id, "state": job.state, + "stage": job.stage if running else None, + "completed": job.completed if running else None, + "total": job.total if running else None, + "error": job.error, "revision": job.revision} + + def _start_body(self, job: Job, replaced: str | None) -> dict: + return {"job_id": job.job_id, "request_id": job.request_id, "state": job.state, + "revision": job.revision, "replaced_job_id": replaced} + + # ------------------------------------------------------------------ worker + def _worker_main(self) -> None: + try: + while True: + with self._lock: + job = self._queued + if job is None: + self._worker = None # the slot is released only here, as the thread exits + self._housekeeping() + return + self._queued = None + now = self._clock() + job.state = "running" + job.deadline = now + self._deadline # the clock starts when the job starts running + self._running = job + try: + self._execute(job) + finally: + with self._lock: + if self._running is job: + self._running = None + self._housekeeping() # retention does not depend on a client polling + except BaseException: # noqa: BLE001 - never leave the slot taken + with self._lock: + self._worker = None + raise + + def _execute(self, job: Job) -> None: + ctl = _Control(self, job) + try: + job.stage, job.completed, job.total = "reading", 0, None + snap = self._snapshot(job, ctl) + body = self._builder(snap, job.revision, ctl) + with self._lock: + if job.state == "running": + job.state = "succeeded" + job.result = body + job.terminal_at = self._clock() + # else: cancelled / failed first -- the late result is discarded + except scene.SceneCancelled: + with self._lock: + if job.state == "running": + self._cancel_locked(job) + except scene.SceneTimeout: + with self._lock: + self._fail_locked(job, "TIMEOUT", "Building the scene took too long.") + except scene.SceneError as exc: + with self._lock: + self._fail_locked(job, exc.code, exc.message) + except JobError as exc: + with self._lock: + self._fail_locked(job, exc.code, exc.message) + except BaseException as exc: # noqa: BLE001 + try: + from . import server + server._log_unexpected(exc) + except Exception: # noqa: BLE001 + pass + with self._lock: + self._fail_locked(job, "INTERNAL", "The scene could not be built.") + finally: + snap_path = job.snapshot + if snap_path: + try: + os.unlink(snap_path) # the worker deletes its own file ... + job.snapshot = None + except FileNotFoundError: + job.snapshot = None + except OSError: + pass # ... and eviction retries if Windows still holds it + job.worker_done.set() # ... BEFORE it signals that it is done + + # ------------------------------------------------------------------ snapshot + def _snapshot_directory(self) -> str: + with self._lock: + if self._dir is None: + base = self._root or os.path.join(_paths.data_dir(), "scene-tmp") + self._dir = os.path.join(base, f"{os.getpid()}-{secrets.token_hex(4)}") + os.makedirs(self._dir, exist_ok=True) # on EVERY job: something may have removed it + return self._dir + + def _snapshot(self, job: Job, ctl: _Control) -> str: + src = job.source + directory = self._snapshot_directory() + ext = os.path.splitext(src)[1].lower() + dest = os.path.join(directory, job.job_id + ext) + try: + fh = open(src, "rb") + except OSError as exc: + raise JobError(400, "INVALID_REQUEST", "That file could not be read.") from exc + try: + st0 = os.fstat(fh.fileno()) + size0 = st0.st_size + if size0 > L.SNAPSHOT_MAX_BYTES: + raise scene.SceneError("LIMIT_EXCEEDED", "The file is larger than Studio will open here.") + try: + free = shutil.disk_usage(directory).free + except OSError: + free = None + if free is not None and free < size0 + L.DISK_HEADROOM_BYTES: + raise scene.SceneError("INTERNAL", "There is not enough free disk space to read this file.") + digest = hashlib.sha256() + count = 0 + job.snapshot = dest + try: + with open(dest, "wb") as out: + while True: + ctl.check() + chunk = fh.read(L.SNAPSHOT_CHUNK_BYTES) + if not chunk: + break + count += len(chunk) + if count > L.SNAPSHOT_MAX_BYTES or count > size0: + break + digest.update(chunk) + out.write(chunk) + ctl.progress("reading", count, size0) + except OSError as exc: + if exc.errno == errno.ENOSPC: + raise scene.SceneError("INTERNAL", "There is not enough free disk space to read this file.") from exc + raise scene.SceneError("INTERNAL", "Studio could not write its temporary copy of this file.") from exc + st1 = os.fstat(fh.fileno()) + finally: + fh.close() + try: + st2 = os.stat(src) + except OSError: + st2 = None + changed = (count != size0 + or (st1.st_size, st1.st_mtime_ns) != (size0, st0.st_mtime_ns) + or st2 is None or (st2.st_size, st2.st_mtime_ns) != (size0, st0.st_mtime_ns) + or (st0.st_ino and st2 is not None and st2.st_ino and st0.st_ino != st2.st_ino)) + if changed: + raise scene.SceneError("SOURCE_CHANGED", "The file changed while Studio was reading it.") + job.revision = digest.hexdigest() + return dest + + @staticmethod + def _current_hash(path: str) -> str | None: + """SHA-256 of the file as it is now (None if it cannot be read or is over the ceiling).""" + digest = hashlib.sha256() + total = 0 + try: + with open(path, "rb") as fh: + while True: + chunk = fh.read(L.SNAPSHOT_CHUNK_BYTES) + if not chunk: + break + total += len(chunk) + if total > L.SNAPSHOT_MAX_BYTES: + return None + digest.update(chunk) + except OSError: + return None + return digest.hexdigest() + + +# ---------------------------------------------------------------------------------------------- +# HTTP glue (kept out of server.py so that file only gains a route branch) +# ---------------------------------------------------------------------------------------------- + +_default: SceneJobs | None = None +_default_lock = threading.Lock() + + +def registry() -> SceneJobs: + global _default + with _default_lock: + if _default is None: + _default = SceneJobs() + return _default + + +def reset_default_for_tests() -> None: + global _default + with _default_lock: + if _default is not None: + _default.close() + _default = None + + +def _request_id(data: dict) -> str: + value = rv.require_str(data, "request_id") + if not _REQUEST_ID.fullmatch(value): # fullmatch: a trailing newline is not an id + raise rv.ValidationError("Invalid 'request_id'") + return value + + +def _client_id(data: dict) -> str: + value = rv.require_str(data, "client_id") + if not _CLIENT_ID.fullmatch(value): + raise rv.ValidationError("Invalid 'client_id'") + return value + + +def _ordering(data: dict) -> tuple[str | None, int | None]: + """client_id and seq travel together or not at all. Presence is tested by KEY: only both keys absent is the + legacy, unordered start; a null or half-present pair is a malformed request, never a legacy one.""" + has_client, has_seq = "client_id" in data, "seq" in data + if not has_client and not has_seq: + return None, None + if not (has_client and has_seq): + raise rv.ValidationError("'client_id' and 'seq' must be given together") + seq = data["seq"] + if isinstance(seq, bool) or not isinstance(seq, int) or seq < 1 or seq > MAX_SEQ: + raise rv.ValidationError("Invalid 'seq'") + return _client_id(data), seq + + +def _credential(data: dict) -> str | None: + """The optional session credential for job-id routes: a job owned by a session needs it. Presence is tested + by key, so a null or malformed client_id is a 400, never silently 'no credential'.""" + return _client_id(data) if "client_id" in data else None + + +def _job_id(data: dict) -> str: + value = rv.require_str(data, "job_id") + if len(value) > 128: + raise rv.ValidationError("Invalid 'job_id'") + return value + + +def handle(route: str, data: dict, jobs: SceneJobs | None = None) -> tuple[int, bytes | dict]: + """Run one /scene/* request. Returns (HTTP status, dict body or pre-serialized bytes).""" + jobs = jobs or registry() + if not isinstance(data, dict): + return 400, {"error": "INVALID_REQUEST", "message": "The request body must be a JSON object."} + try: + if route == "/scene/session": + return 200, jobs.open_session() + if route == "/scene/start": + path = rv.require_bounded_str(data, "path", 4096) + client_id, seq = _ordering(data) + return 200, jobs.start(path, _request_id(data), client_id, seq) + if route == "/scene/status": + return 200, jobs.status(_job_id(data), _credential(data)) + if route == "/scene/result": + expected = data.get("expected_revision") + if expected is not None and (not isinstance(expected, str) or not _REVISION.fullmatch(expected)): + raise rv.ValidationError("Invalid 'expected_revision'") + return jobs.result(_job_id(data), expected, _credential(data)) + if route == "/scene/cancel": + if "job_id" in data and ("request_id" in data or "seq" in data): + raise rv.ValidationError("Give either 'job_id' (with 'client_id' for a session job) or 'client_id', 'request_id' and 'seq'") + if "job_id" not in data and ("client_id" in data or "seq" in data or "request_id" in data): + if not ("client_id" in data and "seq" in data and "request_id" in data): + raise rv.ValidationError("'client_id', 'request_id' and 'seq' must be given together") + client_id, seq = _ordering(data) + return 200, jobs.cancel_request(client_id, _request_id(data), seq) + return 200, jobs.cancel(_job_id(data), _credential(data)) + except rv.ValidationError as exc: + return 400, {"error": "INVALID_REQUEST", "message": str(exc)} + except JobError as exc: + return exc.http, exc.body() + return 404, {"error": "not found"} diff --git a/backend/snapstudio_api/server.py b/backend/snapstudio_api/server.py index 01d2cee9..4aad1fc3 100644 --- a/backend/snapstudio_api/server.py +++ b/backend/snapstudio_api/server.py @@ -20,6 +20,7 @@ from snapstudio_core.material_mapping import MappingFileUnavailable from . import _lifeline from . import service +from . import scene_jobs from . import request_validation as rv from .request_validation import ValidationError @@ -185,7 +186,11 @@ def _cors(self): self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token") def _send(self, code: int, obj: dict): - body = json.dumps(obj).encode("utf-8") + self._send_bytes(code, json.dumps(obj).encode("utf-8")) + + def _send_bytes(self, code: int, body: bytes): + """Send an already-serialized JSON body. The scene routes use this so a body that was + size-checked once (<= 8 MiB) is not serialized a second time.""" self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) @@ -1326,6 +1331,17 @@ def _or_none(v): self._send(404, {"error": "unknown job"}) else: self._send(200, status) + elif self.path in scene_jobs.ROUTES: + # scene/1: bounded, cancellable, authenticated (the token was checked above). + try: + status, body = scene_jobs.handle(self.path, data) + if isinstance(body, bytes): + self._send_bytes(status, body) + else: + self._send(status, body) + except Exception as exc: + _log_unexpected(exc) + self._send(500, {"error": "INTERNAL", "message": "The scene could not be built."}) elif self.path == "/shutdown": # Graceful half of the L4 zero-orphan sequence (see # sidecar::shutdown_sidecar): reply first, THEN stop the @@ -1390,6 +1406,10 @@ def serve(host: str = "127.0.0.1", port: int = 0) -> None: _watch_parent_then_exit() _lifeline.start_parent_lifeline() httpd, token = build_server(host, port) + try: + scene_jobs.sweep_stale() # leftovers of engines that are gone; never a live engine's folder + except Exception: # noqa: BLE001 - housekeeping must not stop the engine starting + pass actual_port = httpd.server_address[1] print(json.dumps({"port": actual_port, "token": token}), flush=True) # handshake line try: diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index 717968f8..a16f3219 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -487,13 +487,17 @@ def diff(a: str, b: str) -> dict: def insights(path: str) -> dict: """Rich read-only Project Intelligence (geometry, materials, readiness).""" from snapstudio_core.intelligence import project_info - return project_info(path) + return project_info(path, placement_aware=True) -def report(path: str) -> dict: - """Read-only Validation Center report: checks + preserved/changes/at-risk.""" +def report(path: str, prepare_mode: str | None = None) -> dict: + """Read-only Validation Center report: checks + preserved/changes/at-risk. + + `prepare_mode` is only for a caller that knows how the file was prepared; the + file itself does not say, and without it the report makes no promise about + whether the creator's setting values were kept.""" from snapstudio_core.validation_report import readiness_report - return readiness_report(path) + return readiness_report(path, prepare_mode) def canonical(path: str) -> dict: @@ -574,7 +578,10 @@ def prepare_placed(path: str, out_dir: str | None = None) -> dict: changes=[{"key": "object placement", "old": "off the U1 plate", "new": change.get("detail"), "reason": change.get("kept")} for change in (result.get("changes") or [])], - findings=[{"title": f"Object {item.get('object_id')} outside the plate", + findings=[{"title": (f"Object {item.get('object_id')}, instance " + f"{(item.get('instance_index') or 0) + 1} of {item['instance_count']} " + "outside the plate" if (item.get("instance_count") or 1) > 1 + else f"Object {item.get('object_id')} outside the plate"), "detail": item.get("edges") and f"past the {item['edges']} edge"} for item in (before.get("off_plate") or [])], validated=not ((result.get("after") or {}).get("off_plate"))) @@ -767,17 +774,8 @@ def preflight(path: str, host: str | None = None, port: int = 7125, facts["nozzle_revision"] = resolved["revision"] facts["nozzle_storage_error"] = resolved["storage_error"] - bed = None - dims = facts.get("bed_mm") or {} - if dims.get("x") and dims.get("y"): - bed = {"min_x": 0.0, "min_y": 0.0, - "max_x": float(dims["x"]), "max_y": float(dims["y"])} try: - # Name the plate after whatever supplied it, so a summary never describes a - # live bed as though it were the U1's. - placement = plate_placement.assess( - path, bed=bed, - bed_name=("this printer's" if bed else None)) + placement = _assess_against(path, _placement_target(facts)) except Exception: placement = None @@ -2054,28 +2052,57 @@ def mm_doctor(path: str, host: str | None = None, port: int = 7125) -> dict: def bed_fit(path: str, host: str | None = None, port: int = 7125) -> dict: - """Bed-Fit / Out-of-Bounds Doctor: does the model fit the U1 bed, and if not, - WHY (the cryptic 'out of bounds' error) and HOW to fix it. Uses the connected - U1's real bed when reachable, else the known U1 bed. Read-only; works offline.""" + """Bed-Fit / Out-of-Bounds Doctor: does each object fit the printer's printable area (by size), + do the placed instances sit on it (by placement), and if not, WHY (the cryptic 'out of bounds' + error) and HOW to fix it. Uses the profile of the connected printer when it identifies itself, + else the known U1 profile; a connected printer Studio cannot identify is reported as unknown. + Read-only; works offline.""" from snapstudio_core.intelligence import project_info from snapstudio_core import bed_fit as bf - info = project_info(path) + info = project_info(path, placement_aware=True) dims = info.get("dimensions_mm") object_count = info.get("objects") or 1 multi = (info.get("colors") or 0) > 1 bed = None bed_known = False + # Offline: the machine Studio prepares for (its profile polygon). Connected: the printable area of + # the printer that identifies itself; a printer's reported bed is axis TRAVEL (about 271 x 335 on a + # U1, anchored at 0,0), never used as a printable rectangle or size, so a printer Studio cannot + # identify gets neither a size nor a placement verdict. + target = _placement_target({"identity": {"printer_id": printer_profiles_target_id()}}) + profile = None if host: - from snapstudio_core import moonraker + from snapstudio_core import moonraker, printer_profiles + target = None try: caps = moonraker.capabilities(host, port) - bm = caps.get("bed_mm") - if bm and bm.get("x"): - bed, bed_known = bm, True + identity = printer_profiles.identify({"reachable": True, + "klipper_objects": caps.get("klipper_objects"), + "toolhead_count": caps.get("toolhead_count")}) + target = _placement_target({"identity": identity}) + if identity.get("printer_id"): + # the identified printer's RECORDED printable volume, labelled as the profile's, not as + # something the printer measured + profile = printer_profiles.load(identity["printer_id"]) + else: + # a printer Studio cannot identify reports axis travel, not a printable size: neither + # the size nor the placement can be judged + return {"schema_version": bf.SCHEMA_VERSION, "available": False, "basis": "size", + "reason": ("Studio does not know this connected printer's printable area (it " + "reports axis travel, which is not a printable size), so it cannot say " + "whether the objects fit.")} except Exception: - pass - return bf.assess(dims, bed=bed, bed_known=bed_known, - object_count=object_count, multi_material=multi) + target = _placement_target({"identity": {"printer_id": printer_profiles_target_id()}}) + # Two separate facts: how big each object is (size, per object) and where the instances sit + # (placement, per instance and plate). A combined extent is neither, and is never used here. + try: + placed = _assess_against(path, target) + except Exception: + placed = None + return bf.assess_objects(info.get("object_sizes_mm"), placed=placed, + unmeasured=info.get("objects_unmeasured") or 0, + bed=bed, bed_known=bed_known, profile=profile, object_count=object_count, + multi_material=multi) def predict_success(path: str, host: str | None = None, port: int = 7125) -> dict: @@ -2587,25 +2614,89 @@ def batch_status(job_id: str) -> dict | None: _ready_cache_lock = threading.Lock() -def _ready_analysis(path: str, bed: dict | None, +def printer_profiles_target_id() -> str: + from snapstudio_core import printer_profiles + + return printer_profiles.PREPARE_TARGET_ID + + +def _placement_target(facts: dict) -> dict | None: + """The printable area to judge placement against, or None when it is not known. + + A connected printer reports the toolhead's AXIS TRAVEL (about 271 x 335 on a U1, whose plate is + 270 x 270), which is not a printable area and is never used as one. What is used instead: + the U1's printable polygon when the machine identifies itself as the U1, or the recorded + printable volume of the profile it identifies as. A printer Studio cannot identify has no known + printable area: None, and the bed check says so rather than guessing. + """ + from snapstudio_core import printer_profiles + + printer_id = (facts.get("identity") or {}).get("printer_id") + if not printer_id: + return None + if printer_id == printer_profiles.PREPARE_TARGET_ID: + return {"key": ("profile", printer_id), "bed": None, "name": None, "height": None} + try: + profile = printer_profiles.load(printer_id) + volume = profile.get("build_volume_mm") or {} + except KeyError: + return None + if not (volume.get("x") and volume.get("y")): + return None + return {"key": ("profile", printer_id, float(volume["x"]), float(volume["y"])), + "bed": {"min_x": 0.0, "min_y": 0.0, "max_x": float(volume["x"]), "max_y": float(volume["y"])}, + "name": f"the {printer_profiles.display_name(profile)}'s", "height": float(volume["z"]) if volume.get("z") else None} + + +def _assess_against(path: str, target: dict | None) -> dict | None: + from snapstudio_core import plate_placement + + if target is None: + return None + return plate_placement.assess(path, bed=target["bed"], bed_name=target["name"], + height_mm=target["height"]) + + +_READY_ATTEMPTS = 3 + + +def _ready_analysis(path: str, target: dict | None, need_placement: bool = True) -> tuple[str, dict | None, dict | None]: """(file_state, traits, placement) for one file, cached by what was read. - Keyed by path, mtime_ns, size and the bed it was placed against, so an edited - file or a different printer bed is analysed again and nothing else is. + The file is identified before it is read and again after: if it changed in between, what was + read may mix two versions, so it is discarded and read again (a few times at most). A file that + will not hold still is reported as unreadable this time and nothing about it is cached. + """ + for _attempt in range(_READY_ATTEMPTS): + outcome = _ready_analysis_once(path, target, need_placement) + if outcome is not None: + return outcome + return "unreadable", None, None + + +def _ready_analysis_once(path: str, target: dict | None, need_placement: bool): + """One pass of `_ready_analysis`; None when the file changed while it was being read. + + Keyed by the file's content fingerprint (and the printable area it was placed against), so an + edited file or a different printer is analysed again and nothing else is. Placement (a full read of the model's geometry) is by far the slowest step, so it is only done when asked for: `need_placement=False` returns the cheap traits and leaves placement None, and a later `True` call fills it in on the same cache entry. """ - from snapstudio_core import plate_placement, project_traits + from snapstudio_core import fileid, plate_placement, project_traits try: st = os.stat(path) except OSError: return "missing", None, None - bed_key = (bed["max_x"], bed["max_y"]) if bed else None - key = (path, st.st_mtime_ns, st.st_size, bed_key) + # the content fingerprint is part of the key: a same-size replacement that kept its timestamp + # must not be served a placement computed for the old content + identity = fileid.file_identity(path) + if identity is None: + return "missing", None, None + key = (identity, target["key"] if target else None) with _ready_cache_lock: hit = _READY_CACHE.get(key) if hit is None: @@ -2613,15 +2704,18 @@ def _ready_analysis(path: str, bed: dict | None, hit = {"state": "ok" if traits.get("readable") else "unreadable", "traits": traits, "placement": None, "placed": False} foreign = (hit["traits"].get("foreign_printer") or {}).get("value") is True - if need_placement and not hit["placed"] and hit["state"] == "ok" and bed and not foreign: + if need_placement and not hit["placed"] and hit["state"] == "ok" and target and not foreign: # Work on a copy so concurrent scans never mutate a shared entry. A failed read is # not cached as done: it is unknown this time and tried again next scan. hit = dict(hit) try: - hit["placement"] = plate_placement.assess(path, bed=bed, bed_name="this printer's") - hit["placed"] = True + hit["placement"] = _assess_against(path, target) + # an unavailable result (unreadable, too large) is not "done": it is tried again next scan + hit["placed"] = bool(hit["placement"] and hit["placement"].get("available")) except Exception: hit["placement"] = None + if fileid.file_identity(path) != identity: + return None # replaced while it was being read: never cache or return a mix with _ready_cache_lock: if key not in _READY_CACHE and len(_READY_CACHE) >= _READY_CACHE_MAX: _READY_CACHE.pop(next(iter(_READY_CACHE)), None) # oldest first @@ -2634,21 +2728,21 @@ def _ready_analysis(path: str, bed: dict | None, _PLACEMENT_MATTERS = ("cant_determine", "one_change_away", "ready_now") -def _ready_one(row: dict, printer: dict, bed: dict | None) -> dict: +def _ready_one(row: dict, printer: dict, target: dict | None) -> dict: from snapstudio_core import preflight as pf from snapstudio_core import readiness project = {"path": row.get("source_path"), "name": row.get("name")} try: - state, traits, placement = _ready_analysis(project["path"], bed, need_placement=False) + state, traits, placement = _ready_analysis(project["path"], target, need_placement=False) foreign = ((traits or {}).get("foreign_printer") or {}).get("value") is True usable = state == "ok" and printer.get("reachable") and not foreign pre = pf.evaluate(traits, printer, placement) if usable else None result = readiness.classify_project(project, traits, printer, pre, state) - if usable and bed and result.get("bucket") in _PLACEMENT_MATTERS: + if usable and target and result.get("bucket") in _PLACEMENT_MATTERS: # Only now is the (slow) geometry read worth it, and it can only make the # result stricter, never claim more than the cheap pass did. - state, traits, placement = _ready_analysis(project["path"], bed, need_placement=True) + state, traits, placement = _ready_analysis(project["path"], target, need_placement=True) pre = pf.evaluate(traits, printer, placement) result = readiness.classify_project(project, traits, printer, pre, state) return result @@ -2694,13 +2788,11 @@ def worker() -> None: printer = printer_facts(host, port) if host else {"reachable": False} printer = _with_providers(printer, host, port, url, slot_map, slot_base, kind, provider_key) - dims = printer.get("bed_mm") or {} - bed = ({"min_x": 0.0, "min_y": 0.0, "max_x": float(dims["x"]), - "max_y": float(dims["y"])} if dims.get("x") and dims.get("y") else None) + target = _placement_target(printer) results: list[dict] = [] for row in rows: - results.append(_ready_one(row, printer, bed)) + results.append(_ready_one(row, printer, target)) publish(progress={"done": len(results), "total": len(rows)}, result={"results": list(results)}) results.sort(key=readiness.sort_key) # stable: newest first within a bucket diff --git a/backend/snapstudio_core/assignments.py b/backend/snapstudio_core/assignments.py index 6ae7120d..a369a5c1 100644 --- a/backend/snapstudio_core/assignments.py +++ b/backend/snapstudio_core/assignments.py @@ -66,6 +66,16 @@ "negative_part": NEGATIVE, "support_enforcer": SUPPORT_ENFORCER, "support_blocker": SUPPORT_BLOCKER, + # Orca's precise-seam helper volumes (written by Orca 2.4.2 and later). Like a modifier they steer the + # slicer and print nothing, so they are the modifier role. The strings are taken from Orca's own + # subtype list as reported to us; Snapmaker Orca 2.4.0 may not write them, and they are treated as + # known helper roles regardless. Any other word stays unknown (and so counts), see ``roles``. + "precise_seam_center": MODIFIER, + "precise_seam_left": MODIFIER, + "precise_seam_right": MODIFIER, + "precise_seam_enforced": MODIFIER, + "precise_seam_blocked": MODIFIER, + "precise_seam_neutral": MODIFIER, } #: Object-level keys that are the assignment itself or the object's identity, diff --git a/backend/snapstudio_core/bed_fit.py b/backend/snapstudio_core/bed_fit.py index 073543bc..77e07697 100644 --- a/backend/snapstudio_core/bed_fit.py +++ b/backend/snapstudio_core/bed_fit.py @@ -8,16 +8,31 @@ fits, a height/split warning, and — in multi-material mode — whether there's room left for the prime/wipe tower. -Read-only and offline-capable: it uses the connected printer's REAL bed when -known, else the printable volume recorded in the profile of the machine the file -is being prepared for. Honest: it explains only what the geometry proves, returns -unavailable when there are no dimensions, and says which bed it measured against -so a profile figure never reads as a measurement. +Two different questions are answered here and the text always says which one: + + by size can this object, as modelled, fit the bed at all (per object) + by placement do the placed instances sit on the plate (per instance, per plate) + +A size says nothing about a position, and a combined extent across several separated +objects or plates is neither: it is never used to say "too big" or to suggest scaling. + +Read-only and offline-capable: it uses the printable volume recorded in the profile of the +printer (the connected one when it identifies itself, else the machine the file is being +prepared for). A printer's reported axis travel is not a printable size and is never used as +one. Honest: it explains only what the geometry proves, returns unavailable when there are no +measurable dimensions or the printer's printable area is unknown, and says which bed it +measured against so a profile figure never reads as a measurement. """ from __future__ import annotations SCHEMA_VERSION = "bedfit/1" +#: The root model part: an object in any other part is named with it. +ROOT_PART = "3D/3dmodel.model" + +#: What every size consumer says when per-object sizes are missing. +UNMEASURED_TEXT = "By size, Studio could not measure each object, so it cannot say whether they fit." + # A typical multi-material prime/wipe tower footprint side (mm) — the clearance the # model must leave on the plate or Orca pushes the tower out of bounds. PRIME_TOWER_MM = 55.0 @@ -32,10 +47,16 @@ def _f(level: str, text: str) -> dict: def assess(dims, bed=None, bed_known: bool = False, object_count: int = 1, - multi_material: bool = False, profile: dict | None = None) -> dict: + multi_material: bool = False, profile: dict | None = None, + subject: str | None = None, placed: dict | None = None) -> dict: """Diagnose whether a model fits the bed and explain any "out of bounds". - dims: object bounding box {x, y, z} in mm (from geometry). + dims: ONE object's size {x, y, z} in mm, as modelled (``geometry.object_sizes``). + Every statement made from it starts "By size" and claims no position. + subject: what ``dims`` is the size of, e.g. "object 3" (default "this model"). + placed: a ``plate_placement.assess`` result. When given, where the instances sit + is reported separately as "By placement", and an instance off the plate makes + the overall result a risk even though each object is small enough by size. bed: the printer's real bed {x, y, z}; falls back to the profile's volume. bed_known: True when `bed` came from a connected printer. object_count: parts on the plate (the whole arrangement must fit). @@ -65,6 +86,7 @@ def assess(dims, bed=None, bed_known: bool = False, object_count: int = 1, findings: list[dict] = [] fixes: list[str] = [] worst = "ok" + who = subject or "this model" def bump(level: str) -> None: nonlocal worst @@ -77,7 +99,7 @@ def bump(level: str) -> None: # Too tall — the machine cannot reach the height. if over_z: bump("risk") - findings.append(_f("risk", f"Taller than {source} can print: {z:.0f} mm vs the " + findings.append(_f("risk", f"By size, {who} is taller than {source} can print: {z:.0f} mm vs the " f"{bz:.0f} mm max height — Orca reports this as out of bounds. " f"Scale to {bz / z * 100:.0f}% or split it into shorter parts.")) fixes.append(f"Scale to {bz / z * 100:.0f}% to fit the height, or split into parts.") @@ -89,19 +111,19 @@ def bump(level: str) -> None: # a zero-width/zero-depth axis (flat plane) which would ZeroDivision here. _factors = ([bx / x] if x > 0 else []) + ([by / y] if y > 0 else []) scale_pct = (min(_factors) if _factors else 0.0) * 100.0 - findings.append(_f("risk", f"Too big for the bed: {x:.0f}×{y:.0f} mm on a " + findings.append(_f("risk", f"By size, {who} is too big for the bed: {x:.0f}×{y:.0f} mm on a " f"{bx:.0f}×{by:.0f} mm bed — this is the “out of bounds” error " f"Orca shows without saying which way. Scale to {scale_pct:.0f}% to fit.")) fixes.append(f"Scale to {scale_pct:.0f}% so it fits the {bx:.0f}×{by:.0f} mm bed.") diag = (x + y) / (2 ** 0.5) if (over_x ^ over_y) and diag <= min(bx, by): fixes.append(f"Or it may fit rotated ~45° on the plate — the diagonal ({diag:.0f} mm) " - f"is within the bed; try Arrange in Orca to confirm.") + f"is within the bed; confirm it in Snapmaker Orca.") # Almost the whole plate — fits, but brim/skirt/tower can spill over. elif x > bx * EDGE_FRAC or y > by * EDGE_FRAC: bump("warn") - findings.append(_f("warn", f"Fills almost the whole bed ({x:.0f}×{y:.0f} of " + findings.append(_f("warn", f"By size, {who} fills almost the whole bed ({x:.0f}×{y:.0f} of " f"{bx:.0f}×{by:.0f} mm) — a skirt, brim, or prime tower can spill " f"past the edge and trigger out of bounds.")) fixes.append("Center the model, drop the brim/skirt, or scale down slightly.") @@ -111,27 +133,45 @@ def bump(level: str) -> None: mx, my = bx - x, by - y if mx < PRIME_TOWER_MM or my < PRIME_TOWER_MM: bump("warn") - findings.append(_f("warn", f"Little room for the multi-material prime/wipe tower " - f"(~{PRIME_TOWER_MM:.0f} mm, depending on your Orca tower settings): " - f"only {mx:.0f}×{my:.0f} mm is free beside the model, so Orca may push " - f"the tower off the bed (out of bounds).")) - fixes.append("Shrink the model a little, or reduce/disable the prime tower in Orca.") + findings.append(_f("warn", f"By size, {who} leaves little room for the multi-material " + f"prime/wipe tower (~{PRIME_TOWER_MM:.0f} mm, depending on your " + f"Snapmaker Orca tower settings): the bed is only {mx:.0f}×{my:.0f} mm " + f"bigger than it, so the tower may not fit beside it. " + f"This is about size; where the tower lands depends on the arrangement.")) + fixes.append("Shrink the model a little, or reduce/disable the prime tower in Snapmaker Orca.") # Multiple parts share the plate — the arrangement must fit, not just one part. if object_count and object_count > 1: - findings.append(_f("ok", f"{object_count} objects share the plate — the whole " - f"arrangement must fit. If Orca still says out of bounds, one object " - f"is off the plate; use Arrange to re-pack them.")) + findings.append(_f("ok", f"The project has {object_count} objects. Whatever sits on one plate " + f"must fit that plate together; if Snapmaker Orca still says out of " + f"bounds, one object is off its plate — review the plates in Snapmaker Orca.")) + + # Where the instances sit is a different fact from how big each object is. + placed_level = _placed_findings(placed, findings, fixes) + if placed_level: + bump(placed_level) if worst == "ok" and not any(f["level"] != "ok" for f in findings): - findings.insert(0, _f("ok", f"Fits {source}'s {bx:.0f}×{by:.0f}×{bz:.0f} mm bed " + findings.insert(0, _f("ok", f"By size, {who} fits {source}'s {bx:.0f}×{by:.0f}×{bz:.0f} mm bed " f"with room to spare ({x:.0f}×{y:.0f}×{z:.0f} mm).")) - overall_text = { - "ok": f"This model fits {source}'s bed.", - "warn": "It fits, but the edges are tight — see below before slicing.", - "risk": "It won't fit as-is — this is the out-of-bounds error, with the fix below.", - }[worst] + size_level = "risk" if (over_x or over_y or over_z) else ( + "warn" if (x > bx * EDGE_FRAC or y > by * EDGE_FRAC) else "ok") + checked_placement = bool(placed and placed.get("available")) + if size_level == "risk": + overall_text = f"By size, {who} won't fit as-is — this is the out-of-bounds error, with the fix below." + elif placed_level == "risk": + overall_text = (f"By size, {who} is small enough for {source}'s bed, but the placement is a " + "problem — see the placement finding below.") + elif size_level == "warn": + overall_text = "By size, it fits, but the edges are tight — see below before slicing." + elif worst == "warn": + overall_text = "By size, it fits, but there is a point to check — see below before slicing." + elif checked_placement: + overall_text = f"By size, {who} fits {source}'s bed, and the placement check found nothing off the plate." + else: + overall_text = (f"By size, {who} fits {source}'s bed. Where it sits on the plate was not " + "checked here.") return { "schema_version": SCHEMA_VERSION, @@ -142,8 +182,146 @@ def bump(level: str) -> None: "measured_against": printer_profiles.summarise(None if bed_known else profile), "bed_mm": {"x": bx, "y": by, "z": bz}, "dims_mm": {"x": round(x, 1), "y": round(y, 1), "z": round(z, 1)}, + "basis": "size_and_placement" if checked_placement else "size", + "subject": subject, + "placement_checked": checked_placement, "overall_level": worst, "overall_text": overall_text, "findings": findings, "fixes": fixes, } + + +def _placed_findings(placed: dict | None, findings: list, fixes: list) -> str | None: + """Add the "By placement" statements; returns the level they raised, or None. + + Placement has three parts: where the instances sit sideways, how high they stand (their placed Z, + build-item scale included: a 10 mm cube scaled to 300 is 300 mm tall where it is placed), and + which of them could not be judged at all.""" + if not placed or not placed.get("available"): + return None + from .plate_placement import below_plate, over_height + + tall = over_height(placed) + sunk = below_plate(placed) + level = _placed_sideways(placed, findings, fixes) + if sunk: + low = min(row["bounds_mm"]["min"][2] for row in sunk) + n = len(sunk) + findings.append(_f("warn", f"By placement, {n} placed instance{'s' if n != 1 else ''} reach down to " + f"{low:g} mm, below the build plate; Studio cannot confirm " + f"{'they print' if n != 1 else 'it prints'} as modelled.")) + fixes.append("Check the height of the sunk instance in Snapmaker Orca.") + level = level or "warn" + if tall: + top = max(row["bounds_mm"]["max"][2] for row in tall) + n = len(tall) + findings.append(_f("risk", f"By placement, {n} placed instance{'s' if n != 1 else ''} stand up to " + f"{top:.0f} mm, above the {placed['bed_height_mm']:.0f} mm height limit.")) + fixes.append("Scale the tall instance down or split it in Snapmaker Orca.") + return "risk" + return level + + +def _placed_sideways(placed: dict, findings: list, fixes: list) -> str | None: + plate_count = placed.get("plate_count") or 1 + if plate_count > 1: + # Plates share one grid with unrecorded spacing: each plate is judged on whether its own + # contents fit, never on a combined extent across plates. + oversized = placed.get("oversized_plates") or [] + if oversized: + for plate in oversized: + findings.append(_f("risk", f"By placement, plate {plate['plate']}: {plate['reason']}. " + "Scale it down or split it.")) + fixes.append("Scale the oversized plate down or split it, then review the plate in Snapmaker Orca.") + return "risk" + if placed.get("not_judged"): + findings.append(_f("warn", f"By placement: {placed.get('summary')}")) + return "warn" + findings.append(_f("ok", f"By placement, each of the {plate_count} plates fits on its own. " + "Positions across plates are not checked; review the plates in Snapmaker Orca.")) + return None + if not placed.get("off_plate"): + if placed.get("not_judged"): + findings.append(_f("warn", f"By placement: {placed.get('summary')}")) + return "warn" + findings.append(_f("ok", "By placement, every placed instance sits inside the plate.")) + return None + findings.append(_f("risk", f"By placement: {placed.get('summary')}")) + fixes.append("Move the arrangement onto the plate (Studio can save a corrected copy), " + "or review the plate in Snapmaker Orca." if placed.get("fixable") else + "Review the plate in Snapmaker Orca; Studio cannot move every instance onto it.") + return "risk" + + +_ORDER = {"ok": 0, "warn": 1, "risk": 2} + + +def assess_objects(objects: list[dict] | None, placed: dict | None = None, unmeasured: int = 0, + **kw) -> dict: + """Size every object on its own, report the worst, and add the placement separately. + + ``objects`` is ``intelligence.project_info()["object_sizes_mm"]``. The combined extents of + several objects are never used: a project of small, separated objects is not one big + object, and "scale to 40%" would shrink all of them for nothing. When no object could be + measured the answer is UNKNOWN, not a fall-back to the overall extents. Every object is + checked (there is no cap); ``unmeasured`` says how many build items had no measurable + object, which keeps the result from claiming that everything fits. + """ + objects = [o for o in (objects or []) if o.get("dimensions_mm")] + if not objects: + return {"schema_version": SCHEMA_VERSION, "available": False, "basis": "size", + "reason": UNMEASURED_TEXT} + many = len(objects) > 1 + + def label_of(entry): + if not many or entry.get("object_id") is None: + return None + where = entry.get("part") + return f"object {entry['object_id']}" + (f" in {where}" if where and where != ROOT_PART else "") + + # Size first, object by object and WITHOUT the placement, so a level here is purely about size. + sized = [] + for entry in objects: + res = assess(entry["dimensions_mm"], subject=label_of(entry), **kw) + if not res.get("available"): + return res + sized.append((entry, res)) + worst_entry, _ = max( + sized, key=lambda pair: (_ORDER[pair[1]["overall_level"]], + pair[0]["dimensions_mm"]["x"] * pair[0]["dimensions_mm"]["y"])) + # Then the worst object's result, with the placement added once ... + out = assess(worst_entry["dimensions_mm"], placed=placed, subject=label_of(worst_entry), **kw) + # ... and EVERY other object that is not fine reported too, each fix naming its object: scaling + # one object to fit says nothing about another that is too tall. + failing = [(e, r) for e, r in sized if r["overall_level"] != "ok"] + if many: + worst_res = next(r for e, r in sized if e is worst_entry) + placement_fixes = out["fixes"][len(worst_res["fixes"]):] # size fixes come first + out["fixes"] = [f"{label_of(e)}: {fix}" for e, r in failing for fix in r["fixes"]] + placement_fixes + for entry, res in failing: + if entry is worst_entry: + continue + out["findings"] += [f for f in res["findings"] if f["level"] != "ok"] + if _ORDER[res["overall_level"]] > _ORDER[out["overall_level"]]: + out["overall_level"] = res["overall_level"] + risky = [e for e, r in sized if r["overall_level"] == "risk"] + if len(risky) > 1: + out["overall_text"] = (f"By size, {len(risky)} objects won't fit as-is — this is the " + "out-of-bounds error, with a fix for each below.") + out["objects_checked"] = len(sized) + out["objects_unmeasured"] = unmeasured + out["objects_by_size"] = [{"object_id": e.get("object_id"), "part": e.get("part"), + "level": r["overall_level"], "dims_mm": r["dims_mm"], + "findings": [f["text"] for f in r["findings"] if f["level"] != "ok"], + "fixes": list(r["fixes"])} for e, r in sized] + if unmeasured: + out["findings"].append(_f("warn", f"By size, {unmeasured} build item" + f"{'s' if unmeasured != 1 else ''} could not be measured, so " + "Studio cannot say whether everything fits.")) + if out["overall_level"] != "risk": + out["overall_level"] = "warn" + out["overall_text"] = ("By size, it fits as far as Studio could measure, but " + f"{unmeasured} build item{'s' if unmeasured != 1 else ''} could not be " + "measured — see below before slicing.") + return out diff --git a/backend/snapstudio_core/community_knowledge.py b/backend/snapstudio_core/community_knowledge.py index c3889f0d..97d0ecac 100644 --- a/backend/snapstudio_core/community_knowledge.py +++ b/backend/snapstudio_core/community_knowledge.py @@ -35,7 +35,7 @@ "cause": "The model (or its skirt/brim/prime tower) extends past the U1's " "270×270×270 mm printable area; Orca refuses to slice but doesn't say which axis.", "community_fix": "Scale to fit (Studio's Project Doctor gives the exact %), rotate ~45° " - "if only one axis is over, or split the model. Re-arrange so every object is on the plate.", + "if only one axis is over, or split the model. Check in Snapmaker Orca that every object is on the plate.", "sources": ["r/snapmaker", "Snapmaker forum", "Facebook Snapmaker U1 group"], }, { diff --git a/backend/snapstudio_core/compatibility.py b/backend/snapstudio_core/compatibility.py index a63f42fa..057d5cd0 100644 --- a/backend/snapstudio_core/compatibility.py +++ b/backend/snapstudio_core/compatibility.py @@ -202,8 +202,8 @@ def check(path: str) -> dict: "Studio does not yet check object-to-object spacing for multi-part 3MF " "layouts, so it cannot confirm objects aren't too close or colliding.", "Object layout (plate)", - "Open in Snapmaker Orca and check for too-close / collision warnings; use " - "Arrange or move objects before slicing.", + "Review the project in Snapmaker Orca and look for too-close / collision " + "warnings before slicing.", f"{oc} object(s) on the plate; spacing unchecked by Studio", )) diff --git a/backend/snapstudio_core/convert.py b/backend/snapstudio_core/convert.py index bccb8d51..b16be379 100644 --- a/backend/snapstudio_core/convert.py +++ b/backend/snapstudio_core/convert.py @@ -94,12 +94,12 @@ def _starter_summary(*, geometry_only: bool) -> dict: } -def _action_reasons(report: dict) -> tuple[dict[str, str], set[str], set[str], dict[str, str]]: +def _action_reasons(report: dict) -> tuple[dict[str, dict], set[str], set[str]]: """Consume RepairOutcome detail while the independent diff remains authoritative. - The last item is the plain-language explanation some pipeline steps write next to their reason.""" - reasons: dict[str, str] = {} - explanations: dict[str, str] = {} + Each key has one atomic record. A later record replaces or clears all four + provenance fields together, so an explanation can never retain old source data.""" + records: dict[str, dict] = {} cleared: set[str] = set() mapped: set[str] = set() groups = [report.get("normalizations", []), report.get("profile_changes", []), @@ -113,25 +113,34 @@ def _action_reasons(report: dict) -> tuple[dict[str, str], set[str], set[str], d [report["preset_deviations_declared"]] if report.get("preset_deviations_declared") else []] groups.append(report.get("project_materials_changes", [])) + # the prime tower keys the layout correction moved: reported with their own reason + groups.append(report.get("placement_corrections", [])) identity = report.get("identity", {}) groups.append(identity.get("changed", []) if isinstance(identity, dict) else []) for group in groups: for item in group: if not isinstance(item, dict) or "key" not in item: continue + key = item["key"] reason = item.get("reason") if isinstance(reason, str) and reason: - reasons[item["key"]] = reason - explanations.pop(item["key"], None) # an explanation belongs to the reason it came with - explanation = item.get("explanation") - if isinstance(explanation, str) and explanation: - explanations[item["key"]] = explanation + # reason always; the other three only when this step supplied them, so a + # later record never inherits an earlier explanation, source or kind. + records[key] = {"reason": reason, **{field: item[field] for field in ("explanation", "source", "kind") + if isinstance(item.get(field), str) and item[field]}} + elif reason == "": + # An explicit empty reason clears the complete atomic record. + records.pop(key, None) + else: + # A later report item without a reason is not a clear marker. + # Preserve the prior complete record. + pass if item.get("category") == "mapped": mapped.add(item["key"]) for item in report.get("foreign_cleared", []): if isinstance(item, dict): cleared.add(item["key"]) - return reasons, cleared, mapped, explanations + return records, cleared, mapped def _strict_value_equal(a, b) -> bool: @@ -169,12 +178,49 @@ def _is_preserved_value_mapping(old, new) -> bool: return _strict_value_equal(new, expected) +def _is_on(value) -> bool: + """Whether a project-settings switch such as `enable_support` is on.""" + if isinstance(value, list): + value = value[0] if value else None + return str(value).strip().lower() in ("1", "true") + + +def _supports_note(tm: ThreeMF, before: dict, after: dict, prepare_mode: str) -> str | None: + """Say so when the creator's supports will not be in effect in Snapmaker Orca. + + Orca resets every process value the project does not declare in + `different_settings_to_system` to the U1 preset, where supports are off. + Recommended blanks the source's declaration and re-declares only what Studio + itself changed, so a creator's `enable_support=1` stays in the file but is not + used. Computed from the source and the written config, never asserted; Preserve + keeps the declaration, so it has nothing to say. + """ + from .preset_deviation import declared_process_keys + + if prepare_mode == "preserve" or not _is_on(before.get("enable_support")): + return None + if "enable_support" in declared_process_keys(after) and _is_on(after.get("enable_support")): + return None + note = ("Supports: the creator turned supports on. This mode does not keep them; " + "Snapmaker Orca will open with supports off. Turn them on in Orca's Support tab " + "or prepare with Preserve creator settings.") + try: + painted = sum(tm.read_part(p).count(b"paint_supports") + for p in tm.list_parts() if p.endswith(".model")) + except Exception: + painted = 0 + if painted: + note += (" The painted support areas stay in the file but do nothing while " + "supports are off.") + return note + + def _settings_summary(before: dict, after: dict, raw_config: bytes, outcome, prepare_mode: str, profile_name: str = "snapmaker_u1", recommended_after: dict | None = None) -> dict: profile = load_profile(profile_name) diffs = config_diff(before, after) # independent source of truth (A13) - reasons, cleared, mapped_keys, explanations = _action_reasons(outcome.report) + records, cleared, mapped_keys = _action_reasons(outcome.report) allowlist = machine_compat_keys(profile) slice_info = { item["key"]: item["reason"] @@ -189,19 +235,16 @@ def _settings_summary(before: dict, after: dict, raw_config: bytes, outcome, if key in cleared: # Values in this category were deliberately discarded. They may # be credentials or arbitrary creator G-code, so never echo them. - could_not_carry.append({"key": key, "reason": reasons[key]}) + could_not_carry.append({"key": key, "reason": records[key]["reason"]}) elif key in mapped_keys and _is_preserved_value_mapping(item["old"], item["new"]): mapped_to_u1.append({ "key": key, "old": display_value(item["old"], key=key), "new": display_value(item["new"], key=key), "reason": "carried over to U1 toolheads (values preserved)", }) - elif key in reasons: + elif key in records: entry = {"key": key, "old": display_value(item["old"], key=key), - "new": display_value(item["new"], key=key), - "reason": reasons[key]} - if key in explanations: - entry["explanation"] = explanations[key] + "new": display_value(item["new"], key=key), **records[key]} compat.append(entry) elif key in allowlist: compat.append({"key": key, "old": display_value(item["old"], key=key), @@ -246,6 +289,25 @@ def _settings_summary(before: dict, after: dict, raw_config: bytes, outcome, "recommendations_available": prepare_mode == "preserve", "recommended_changes": recommended_changes if prepare_mode == "preserve" else [], } + layout = outcome.report.get("layout") + if layout: + summary["layout"] = {k: layout.get(k) for k in ( + "status", "plates", "formula_check", "tower", "tower_note", "stride_source", "stride_target", + "cols", "items_moved", "unresolved")} + summary["placement_changes"] = list(layout.get("disclosure") or []) + summary["warnings"] = summary["warnings"] + [ + line for line in layout.get("disclosure") or [] + if line.startswith("Plate positions were left") + or ("prime tower is outside" in line) or ("prime tower sits on an object" in line)] + intent = outcome.report.get("support_intent") + if intent: + from . import support_intent as _support_intent + + summary["support_intent"] = { + "decision": intent["decision"], "kept": bool(intent["explicit"]), + "evidence": intent["evidence"], "blocker_evidence": intent["blocker_evidence"], + "disable_evidence": intent["disable_evidence"], "keys": intent["keys"], + "statement": _support_intent.statement(intent)} # Preservation invariant: every changed source setting needs a specific # repair-pipeline reason, a deliberate foreign-value clear, or the # profile-owned machine/compat allowlist. The independent diff is the @@ -425,7 +487,7 @@ def convert_to_u1(path: str, out_dir: str | None = None, prepare_mode: str = "pr internal_mode = "preserve" if prepare_mode == "preserve" else "u1" outcome = do_repair(tm, mode=internal_mode, remap=None, dry_run=dry_run, opt_profile=None, confirmed_presets=confirmed_presets, filament_catalog=filament_catalog, - confirmed_colours=confirmed_colours) + confirmed_colours=confirmed_colours, source_path=str(src)) after = load_project_settings(tm.read_part(SETTINGS)) if confirmed_presets: # Judged on what this mode will actually write, not on what the source declared: @@ -443,10 +505,21 @@ def convert_to_u1(path: str, out_dir: str | None = None, prepare_mode: str = "pr # results cannot diverge from a real recommended conversion or write. do_repair(recommended_tm, mode="u1", remap=None, dry_run=True, opt_profile=None, confirmed_presets=confirmed_presets, filament_catalog=filament_catalog, - confirmed_colours=confirmed_colours) + confirmed_colours=confirmed_colours, source_path=str(src)) recommended_after = load_project_settings(recommended_tm.read_part(SETTINGS)) summary = _settings_summary(before, after, raw_config, outcome, prepare_mode, recommended_after=recommended_after) + supports_note = _supports_note(tm, before, after, prepare_mode) + if supports_note: + summary["supports_note"] = supports_note + from . import prime_tower + from .plate_placement import u1_bed_rect + bed = u1_bed_rect() + tower_found = prime_tower.findings(after, compute_fingerprint(tm).plate_count, bed) + # A proven-outside message wins; otherwise say the reach is unknown rather than stay silent. + tower_note = prime_tower.message(tower_found, bed) or prime_tower.summary_note(after, tower_found) + if tower_note: + summary["prime_tower_note"] = tower_note if confirmed_presets or confirmed_colours or filament_catalog is not None: # Only when Project Materials is in use, so a plain Prepare reports exactly what it did. summary["project_materials"] = { diff --git a/backend/snapstudio_core/data/scene-1.schema.json b/backend/snapstudio_core/data/scene-1.schema.json new file mode 100644 index 00000000..dbebec7f --- /dev/null +++ b/backend/snapstudio_core/data/scene-1.schema.json @@ -0,0 +1,232 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://snapmaker-studio.invalid/schema/scene-1.schema.json", + "title": "Snapmaker Studio scene/1", + "description": "Read-only description of a project's geometry, placement and engine findings. FROZEN: every scene object is closed (additionalProperties false), every field is required, and unknown is null. Millimetres, right-handed, Z up. A scene is a consistent snapshot of the file as it was copied for the job. Matrices (local_to_parent_mm, world_mm) are 16 numbers in COLUMN-MAJOR order acting on COLUMN vectors (x' = M * x; the translation is elements 12..14). Mesh coordinates are LOCAL to the mesh resource (before any node matrix), in millimetres. A node's bounds_mm and printable concern its role=part volumes only. An instance's effective volume role is node.role_context when that is not null (Bambu/Orca part records are per use), otherwise the roles of the mesh volumes. PLACEMENT_OUTSIDE_BED.overhang_mm measures how far the printable bounds intrude past the bed polygon shrunk inward by edge_margin_mm, in X and Y only (never height).", + "type": "object", + "additionalProperties": false, + "required": ["schema", "revision", "status", "units", "axes", "sources", "bed", "meshes", "nodes", "plates", "findings", "limitations", "counts", "limits"], + "properties": { + "schema": {"const": "scene/1"}, + "revision": {"$ref": "#/$defs/sha256"}, + "status": {"enum": ["complete", "partial"]}, + "units": {"const": "mm"}, + "axes": {"const": "right-handed-z-up"}, + "sources": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/source"}}, + "bed": {"$ref": "#/$defs/bed"}, + "meshes": {"type": "array", "maxItems": 2000, "items": {"$ref": "#/$defs/mesh"}}, + "nodes": {"type": "array", "maxItems": 2000, "items": {"$ref": "#/$defs/node"}}, + "plates": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/plate"}}, + "findings": {"type": "array", "maxItems": 500, "items": {"$ref": "#/$defs/finding"}}, + "limitations": {"type": "array", "maxItems": 50, "items": {"$ref": "#/$defs/limitation"}}, + "counts": {"$ref": "#/$defs/counts"}, + "limits": {"$ref": "#/$defs/limits"} + }, + "$defs": { + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "uint": {"type": "integer", "minimum": 0, "maximum": 4294967295}, + "vec3": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}}, + "matrix16": {"type": "array", "minItems": 16, "maxItems": 16, "items": {"type": "number"}}, + "resource_key": { + "type": "object", "additionalProperties": false, "required": ["part", "object_id"], + "properties": {"part": {"type": "string", "minLength": 1, "maxLength": 256}, "object_id": {"$ref": "#/$defs/id"}} + }, + "unit_name": {"enum": ["micron", "millimeter", "centimeter", "inch", "foot", "meter"]}, + "source": { + "description": "One model part that was read. When the part declares a unit outside the six defined ones, unit and mm_per_unit are the FALLBACK millimetre values, and the UNSUPPORTED_UNIT limitation (whose target_ids are the nodes living in that part) says so; no fit statement is made for such a scene.", + "type": "object", "additionalProperties": false, "required": ["part", "unit", "mm_per_unit"], + "properties": { + "part": {"type": "string", "minLength": 1, "maxLength": 256}, + "unit": {"$ref": "#/$defs/unit_name"}, + "mm_per_unit": {"type": "number", "exclusiveMinimum": 0} + } + }, + "bed": { + "type": "object", "additionalProperties": false, "required": ["polygon_mm", "height_mm", "edge_margin_mm", "policy"], + "properties": { + "polygon_mm": {"type": "array", "minItems": 3, "maxItems": 16, + "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}}, + "height_mm": {"type": ["number", "null"]}, + "edge_margin_mm": {"type": "number", "minimum": 0}, + "policy": {"enum": ["u1_template", "fallback"]} + } + }, + "volume_role": {"enum": ["part", "modifier", "negative", "support_enforcer", "support_blocker", "unknown"]}, + "volume": { + "type": "object", "additionalProperties": false, "required": ["id", "triangle_start", "triangle_count", "role", "source"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "triangle_start": {"$ref": "#/$defs/uint"}, + "triangle_count": {"$ref": "#/$defs/uint"}, + "role": {"$ref": "#/$defs/volume_role"}, + "source": {"enum": ["prusa_range", "bambu_part", "object", null]} + } + }, + "mesh": { + "type": "object", "additionalProperties": false, + "required": ["key", "vertex_count", "triangle_count", "positions_f32le_base64", "indices_u32le_base64", "volumes"], + "properties": { + "key": {"$ref": "#/$defs/resource_key"}, + "vertex_count": {"$ref": "#/$defs/uint"}, + "triangle_count": {"$ref": "#/$defs/uint"}, + "positions_f32le_base64": {"type": "string", "pattern": "^[A-Za-z0-9+/]*={0,2}$"}, + "indices_u32le_base64": {"type": "string", "pattern": "^[A-Za-z0-9+/]*={0,2}$"}, + "volumes": {"type": "array", "minItems": 1, "maxItems": 4096, "items": {"$ref": "#/$defs/volume"}} + } + }, + "plate_ref": { + "type": "object", "additionalProperties": false, "required": ["state", "plate_id", "source"], + "properties": { + "state": {"enum": ["known", "ambiguous", "unknown"]}, + "plate_id": {"type": ["string", "null"], "maxLength": 128}, + "source": {"enum": ["plate_config", null]} + }, + "if": {"properties": {"state": {"const": "known"}}}, + "then": {"properties": {"plate_id": {"type": "string"}, "source": {"const": "plate_config"}}} + }, + "instance_ref": { + "type": "object", "additionalProperties": false, "required": ["build_index", "component_path"], + "properties": { + "build_index": {"type": ["integer", "null"], "minimum": 0}, + "component_path": {"type": "array", "maxItems": 64, "items": {"type": "integer", "minimum": 0}} + } + }, + "bounds": { + "type": ["object", "null"], "additionalProperties": false, "required": ["min", "max"], + "properties": {"min": {"$ref": "#/$defs/vec3"}, "max": {"$ref": "#/$defs/vec3"}} + }, + "node": { + "type": "object", "additionalProperties": false, + "required": ["id", "parent_id", "resource", "mesh_key", "local_to_parent_mm", "world_mm", "mirrored", "role_context", "build_index", "instance_ref", + "plate", "placement_state", "printable", "has_non_part_volumes", "bounds_mm", "finding_ids"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "parent_id": {"type": ["string", "null"], "maxLength": 128}, + "resource": {"$ref": "#/$defs/resource_key"}, + "mesh_key": {"oneOf": [{"$ref": "#/$defs/resource_key"}, {"type": "null"}]}, + "local_to_parent_mm": {"$ref": "#/$defs/matrix16"}, + "world_mm": {"$ref": "#/$defs/matrix16"}, + "mirrored": {"type": "boolean"}, + "role_context": {"enum": ["part", "modifier", "negative", "support_enforcer", "support_blocker", "unknown", null]}, + "build_index": {"type": ["integer", "null"], "minimum": 0}, + "instance_ref": {"$ref": "#/$defs/instance_ref"}, + "plate": {"$ref": "#/$defs/plate_ref"}, + "placement_state": {"enum": ["known", "unknown"]}, + "printable": {"type": ["boolean", "null"]}, + "has_non_part_volumes": {"type": "boolean"}, + "bounds_mm": {"$ref": "#/$defs/bounds"}, + "finding_ids": {"type": "array", "maxItems": 500, "items": {"$ref": "#/$defs/id"}} + } + }, + "plate": { + "type": "object", "additionalProperties": false, "required": ["id", "ui_number", "origin_mm"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "ui_number": {"type": ["integer", "null"], "minimum": 0}, + "origin_mm": {"oneOf": [{"$ref": "#/$defs/vec3"}, {"type": "null"}]} + } + }, + "finding": { + "type": "object", "additionalProperties": false, "required": ["id", "engine", "schema", "pointer", "scope", "kind", "target_ids", "value"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "engine": {"type": "string", "minLength": 1, "maxLength": 64}, + "schema": {"type": "string", "minLength": 1, "maxLength": 64}, + "pointer": {"type": "string", "maxLength": 128}, + "scope": {"enum": ["project", "plate", "instance"]}, + "kind": {"enum": ["placement", "size", "note"]}, + "target_ids": {"type": "array", "maxItems": 2000, "items": {"$ref": "#/$defs/id"}}, + "value": { + "type": "object", "additionalProperties": false, "required": ["code", "overhang_mm"], + "properties": { + "code": {"enum": ["PLACEMENT_OUTSIDE_BED", "SIZE_EXCEEDS_BED"]}, + "overhang_mm": { + "type": ["object", "null"], "additionalProperties": false, "required": ["left", "right", "front", "back"], + "properties": {"left": {"type": "number"}, "right": {"type": "number"}, "front": {"type": "number"}, "back": {"type": "number"}} + } + } + } + } + }, + "limitation_code": {"enum": [ + "UNKNOWN_VOLUME_ROLE", "UNKNOWN_PLACEMENT", "PLATE_MEMBERSHIP_AMBIGUOUS", "PLATE_MEMBERSHIP_UNKNOWN", + "PLATES_UNAVAILABLE", "BED_TEMPLATE_UNAVAILABLE", "REPEATED_INSTANCE_PLACEMENT_UNVERIFIED", + "NON_MM_SOURCE_UNIT", "UNSUPPORTED_UNIT", "MULTI_PLATE_PLACEMENT_UNCHECKED", "NO_BUILD_ITEMS"]}, + "limitation": { + "type": "object", "additionalProperties": false, "required": ["code", "target_ids"], + "properties": { + "code": {"$ref": "#/$defs/limitation_code"}, + "target_ids": {"type": "array", "maxItems": 2000, "items": {"$ref": "#/$defs/id"}} + } + }, + "counts": { + "type": "object", "additionalProperties": false, + "required": ["nodes", "meshes", "vertices", "triangles", "rendered_triangles", "plates", "findings", "limitations"], + "properties": { + "nodes": {"$ref": "#/$defs/uint"}, "meshes": {"$ref": "#/$defs/uint"}, "vertices": {"$ref": "#/$defs/uint"}, + "triangles": {"$ref": "#/$defs/uint"}, "rendered_triangles": {"$ref": "#/$defs/uint"}, + "plates": {"$ref": "#/$defs/uint"}, "findings": {"$ref": "#/$defs/uint"}, "limitations": {"$ref": "#/$defs/uint"} + } + }, + "limits": { + "type": "object", "additionalProperties": false, + "required": ["max_archive_bytes", "max_model_xml_bytes", "max_rendered_triangles", "max_vertices", "max_nodes", "max_depth", + "max_response_bytes", "max_findings", "max_limitations", "max_id_length"], + "properties": { + "max_archive_bytes": {"$ref": "#/$defs/uint"}, "max_model_xml_bytes": {"$ref": "#/$defs/uint"}, + "max_rendered_triangles": {"$ref": "#/$defs/uint"}, "max_vertices": {"$ref": "#/$defs/uint"}, + "max_nodes": {"$ref": "#/$defs/uint"}, "max_depth": {"$ref": "#/$defs/uint"}, + "max_response_bytes": {"$ref": "#/$defs/uint"}, "max_findings": {"$ref": "#/$defs/uint"}, + "max_limitations": {"$ref": "#/$defs/uint"}, "max_id_length": {"$ref": "#/$defs/uint"} + } + }, + "error_code": {"enum": [ + "INVALID_REQUEST", "UNSUPPORTED_FORMAT", "INVALID_ARCHIVE", "INVALID_GEOMETRY", "UNRESOLVED_REFERENCE", + "LIMIT_EXCEEDED", "SOURCE_CHANGED", "CANCELLED", "TIMEOUT", "EXPIRED", "NOT_READY", "WORKER_WEDGED", "INTERNAL", + "STALE_START", "CANCELLED_BEFORE_START", "SESSION_EXPIRED", "SESSION_LIMIT"]}, + "job_state": {"enum": ["queued", "running", "succeeded", "failed", "cancelled"]}, + "error_response": { + "type": "object", "additionalProperties": false, "required": ["error"], + "properties": { + "error": {"$ref": "#/$defs/error_code"}, + "message": {"type": "string", "maxLength": 512}, + "state": {"$ref": "#/$defs/job_state"}, + "reason": {"type": "string", "maxLength": 256} + } + }, + "job_status": { + "type": "object", "additionalProperties": false, + "required": ["job_id", "request_id", "state", "stage", "completed", "total", "error", "revision"], + "properties": { + "job_id": {"oneOf": [{"$ref": "#/$defs/id"}, {"type": "null"}]}, + "request_id": {"$ref": "#/$defs/id"}, + "state": {"$ref": "#/$defs/job_state"}, + "stage": {"enum": ["reading", "parsing", "encoding", null]}, + "completed": {"type": ["integer", "null"], "minimum": 0}, + "total": {"type": ["integer", "null"], "minimum": 0}, + "error": {"type": ["object", "null"], "additionalProperties": false, "required": ["code", "message"], + "properties": {"code": {"$ref": "#/$defs/error_code"}, "message": {"type": "string", "maxLength": 512}}}, + "revision": {"oneOf": [{"$ref": "#/$defs/sha256"}, {"type": "null"}]} + } + }, + "session": { + "type": "object", "additionalProperties": false, "required": ["client_id", "ttl_s"], + "properties": { + "client_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{24}$"}, + "ttl_s": {"type": "integer", "minimum": 1} + } + }, + "job_start": { + "type": "object", "additionalProperties": false, + "required": ["job_id", "request_id", "state", "revision", "replaced_job_id"], + "properties": { + "job_id": {"$ref": "#/$defs/id"}, + "request_id": {"$ref": "#/$defs/id"}, + "state": {"$ref": "#/$defs/job_state"}, + "revision": {"oneOf": [{"$ref": "#/$defs/sha256"}, {"type": "null"}]}, + "replaced_job_id": {"type": ["string", "null"], "maxLength": 128} + } + } + } +} diff --git a/backend/snapstudio_core/doctor.py b/backend/snapstudio_core/doctor.py index 0586da7b..bb3a6cf3 100644 --- a/backend/snapstudio_core/doctor.py +++ b/backend/snapstudio_core/doctor.py @@ -18,6 +18,12 @@ HIGH_RISK = "HIGH_RISK" # not a usable project (broken / not a 3MF) CONVERTIBLE = "CONVERTIBLE" # an STL - convert it with `repair` +# Appended to the READY action of a project with more than one plate. +MULTI_PLATE_READY_NOTE = ( + " This project has several plates and Studio has not checked where they sit; " + "Snapmaker Orca may list an object as Outside. Review it in Snapmaker Orca before slicing." +) + @dataclass class Diagnosis: @@ -76,6 +82,15 @@ def _filament_inconsistencies(cfg: dict) -> list[str]: return out +def _prime_tower_note(cfg: dict, plate_count: int) -> str | None: + """Computed from the project's own tower settings against the U1 printable area.""" + from . import prime_tower + from .plate_placement import u1_bed_rect + + bed = u1_bed_rect() + return prime_tower.message(prime_tower.findings(cfg, plate_count, bed), bed) + + def diagnose(tm: ThreeMF) -> Diagnosis: """Diagnose a 3MF project (in-memory). Read-only. Score calculation unchanged.""" src = detect_source(tm) @@ -134,6 +149,19 @@ def diagnose(tm: ThreeMF) -> Diagnosis: elif score == 100 and not compatibility_issues: verdict = READY action = "Ready for Snapmaker U1 - open it in Snapmaker Orca and slice." + review = "open it in Snapmaker Orca and review it before slicing." + if fp.plate_count > 1: + # The profile is ready; where the plates sit is not something Studio checks, so the + # line must not say "slice" and then warn that Orca may list an object as Outside. + action = action.replace("open it in Snapmaker Orca and slice.", review) + action += MULTI_PLATE_READY_NOTE + tower = _prime_tower_note(cfg, fp.plate_count) + if tower: + # The verdict stays a profile-compatibility verdict (setup readiness is the printer + # check's job, which says ATTENTION), but the line must not say "slice" and then warn + # that Orca can refuse to. + action = action.replace("open it in Snapmaker Orca and slice.", review) + action += " " + tower else: verdict = REPAIRABLE action = "Prepare a U1 profile copy, then review it in Snapmaker Orca before slicing." diff --git a/backend/snapstudio_core/fidelity.py b/backend/snapstudio_core/fidelity.py index 43421582..4ff5385a 100644 --- a/backend/snapstudio_core/fidelity.py +++ b/backend/snapstudio_core/fidelity.py @@ -192,7 +192,9 @@ def _model_part_row(part: str, before: bytes, after: bytes, if placement_applied: return _row(_human(part), CHANGED, part=part, detail="the same geometry, moved to different coordinates", - reason="a placement fix moved the objects onto the U1 plate") + reason=("the objects were repositioned for the U1 (a placement fix, or the plate " + "layout corrected for the U1's plate grid): translation only, the " + "geometry itself is unchanged")) if moved_after and not moved_before: return _row(_human(part), PRESERVED_SEMANTIC, part=part, detail="the same geometry, split into one mesh per part", @@ -747,12 +749,28 @@ def _placement_report(prepared: str) -> dict | None: from . import placement try: + if _plate_count(prepared) > 1: + # This report judges every object against ONE plate. A multi-plate project sits on a grid of + # plates (the second plate starts a plate-stride to the right), so it would call every object + # on plate 2 onward "outside" by hundreds of millimetres. The layout check Studio makes while + # preparing (per plate, against the U1's own cell) is the statement about those projects. + return None report = placement.assess(prepared) except Exception: # noqa: BLE001 — a report, never a crash return None return report if report.get("available") else None +def _plate_count(path: str) -> int: + """How many plates a prepared project records (0 when it cannot be read).""" + try: + from . import plate_placement + + return len(plate_placement._plates_from_model_settings(ThreeMF.open(path))) + except Exception: # noqa: BLE001 + return 0 + + def _settings_rows(a: ThreeMF, b: ThreeMF) -> list[dict]: return _settings_row(_json_or_none(a, PROJECT_SETTINGS), _json_or_none(b, PROJECT_SETTINGS)) diff --git a/backend/snapstudio_core/fileid.py b/backend/snapstudio_core/fileid.py new file mode 100644 index 00000000..314793a0 --- /dev/null +++ b/backend/snapstudio_core/fileid.py @@ -0,0 +1,46 @@ +"""What identifies a file's CONTENT for anything that remembers a result about it. + +A remembered result is only as good as its key. Size and modification time alone miss a same-size +replacement that kept its timestamp (a copy that preserves times, a tool that rewrites in place), so the +key also carries a fingerprint of the bytes, and a result is only stored if the file is unchanged after it +was computed. +""" +from __future__ import annotations + +import hashlib +import zipfile +from pathlib import Path + + +def stat_identity(path: str): + """``(resolved path, mtime, size)`` or None.""" + try: + resolved = Path(path).resolve() + st = resolved.stat() + except OSError: + return None + return (str(resolved), st.st_mtime_ns, st.st_size) + + +def file_identity(path: str): + """The stat identity PLUS a fingerprint of the content, or None if the file cannot be read. + + For a zip (every 3MF) the fingerprint is taken from the archive's own directory: each member's name, + size and CRC-32, which changes whenever any member's bytes do and costs a read of the directory only, + not of a 40 MB project. Anything else is hashed whole.""" + base = stat_identity(path) + if base is None: + return None + digest = hashlib.blake2b(digest_size=16) + try: + try: + with zipfile.ZipFile(path) as archive: + for info in archive.infolist(): + digest.update(f"{info.filename}|{info.file_size}|{info.CRC}|".encode("utf-8", "replace")) + except zipfile.BadZipFile: + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + except OSError: + return None + return base + (digest.hexdigest(),) diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index ea5eb845..7f021053 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -9,11 +9,18 @@ of stalling. """ from __future__ import annotations +import re from dataclasses import dataclass +import copy +import threading from pathlib import Path +from . import roles as _roles from .container import ThreeMF +from . import roles as _roles +from .fileid import file_identity, stat_identity # noqa: F401 (the one content key; see fileid) from .config_io import load_model_settings +from . import units as _units # Mirror intelligence.py's byte guard; plus a triangle cap for analysis cost. _MAX_BYTES = 80 * 1024 * 1024 @@ -44,6 +51,7 @@ def _parse_3mf_model(raw: bytes, verts: list, faces: list) -> None: hardened XML parser (no XXE/DTD/network). Triangle indices are local to each , so we offset by the vertex count at the start of that mesh.""" root = load_model_settings(raw) + scale = _units.mm_per_unit(raw) # the part's declared unit -> millimetres (1.0 for mm) for mesh in root.iter(f"{_3MF_CORE_NS}mesh"): vstart = len(verts) vnode = mesh.find(f"{_3MF_CORE_NS}vertices") @@ -51,7 +59,11 @@ def _parse_3mf_model(raw: bytes, verts: list, faces: list) -> None: if vnode is None or tnode is None: continue for v in vnode.iterfind(f"{_3MF_CORE_NS}vertex"): - verts.append((float(v.get("x", 0)), float(v.get("y", 0)), float(v.get("z", 0)))) + if scale == 1.0: + verts.append((float(v.get("x", 0)), float(v.get("y", 0)), float(v.get("z", 0)))) + else: + verts.append((float(v.get("x", 0)) * scale, float(v.get("y", 0)) * scale, + float(v.get("z", 0)) * scale)) for t in tnode.iterfind(f"{_3MF_CORE_NS}triangle"): faces.append((vstart + int(t.get("v1")), vstart + int(t.get("v2")), vstart + int(t.get("v3")))) if len(faces) > _MAX_TRIANGLES: @@ -97,24 +109,195 @@ def appA(v): t[0] + a[9], t[1] + a[10], t[2] + a[11]] +ROOT_MODEL = "3D/3dmodel.model" +SETTINGS_PART = "Metadata/model_settings.config" + + +#: What a malformed ``p:path`` becomes. It names no archive entry, so an item or component that carries +#: one is unresolved; it is never read as "no path" and so never quietly falls back to the root model. +INVALID_PART = "" + + +def normalize_part(path: str | None) -> str | None: + """A ``p:path`` as an archive entry name; None only when the attribute is absent. + + Like the scene reader, a production-extension path must be an absolute package path: an empty, + relative, drive-lettered, scheme-carrying or ``..``-climbing value is ``INVALID_PART``. + """ + if path is None: + return None + n = path.replace("\\", "/") + if not n.startswith("/") or any(ord(c) < 32 for c in n): + return INVALID_PART + segments = [s for s in n.split("/") if s not in ("", ".")] + if not segments or ".." in segments: + return INVALID_PART + return "/".join(segments) + + +def build_items(root: bytes | str) -> list[dict]: + """Every build item of a root model, in document order, read with the XML parser. + + The ONE reader of build items: ``item_index`` is the ordinal among all ```` + elements, so a commented-out tag or a namespace prefix cannot make two readers number + the items differently. ``part`` is the item's ``p:path`` (None when it has none) and + ``attributes`` is every attribute of the element, by name. + """ + data = root.encode("utf-8") if isinstance(root, str) else root + out = [] + for ordinal, it in enumerate(load_model_settings(data).iter(f"{_3MF_CORE_NS}item")): + out.append({"item_index": ordinal, + "object_id": it.get("objectid"), + "transform": it.get("transform"), + "part": normalize_part(it.get(f"{_3MF_PROD_NS}path")), + "attributes": dict(it.attrib)}) + return out + + +_OBJECT_TAG = re.compile(rb"]*>||]*/>| set: + """``{(part, object id)}`` of mesh objects that are reached ONLY as non-printing volumes. + + The same role rule as the footprint and the sizes (``roles``), for the callers that read meshes + straight from the archive (``intelligence``'s overall extents). A mesh reached by any printing path + still counts. Costs nothing when the project records no part role other than a normal part.""" + from .assignments import PART, role_of + + try: + settings = tm.read_part(SETTINGS_PART).decode("utf-8", "ignore") if tm.has_part(SETTINGS_PART) else "" + records = _roles.read_records(settings) + if not any(role_of(sub) != PART for subs in records.part_subtypes.values() for sub in subs.values()): + return set() + objects: dict = {} + for name in tm.list_parts(): + if not name.endswith(".model"): + continue + current = None + table = objects.setdefault(name, {}) + for m in _OBJECT_TAG.finditer(tm.read_part(name)): + token = m.group(0) + if token.startswith(b"": + current = None + elif token.startswith(b" 64: + return + if obj["mesh"]: + (printing if _roles.counts_toward_size(role) else helper).add((part, oid)) + for cpart, cid in obj["comps"]: + child = role + if part == ROOT_MODEL: + child = _roles.inherit(role, _roles.own_role( + records.part_subtypes, records.metadata_objects, oid, cid, missing=None)) + walk(cpart or part, cid, child, trail | {(part, oid)}) + + for item in build_items(tm.read_part(ROOT_MODEL)): + walk(item["part"] or ROOT_MODEL, item["object_id"], None, frozenset()) + return helper - printing + except Exception: # noqa: BLE001 - never make a size query fail; fall back to counting everything + return set() + + def build_item_dims(path: str) -> list[dict]: """Per-build-item bounding-box dimensions (mm), with the 3MF build transform and nested component transforms applied — i.e. each placed object's real on-plate - size. Read-only, exception-safe (returns [] on any failure). Used by the Scale - Doctor size-options ladder for per-plate dimensions.""" + size, one entry per build item (a repeated object appears once per item). + Read-only, exception-safe (returns [] on any failure). Used by the Scale Doctor + size-options ladder for per-plate dimensions. + + An item (or component) that names a ``p:path`` is looked up in THAT part and nowhere + else; one that cannot be found is left out here and reported by ``measure_items``.""" + return _measure(path)[0] + + +def measure_items(path: str) -> tuple[list[dict], list[dict]]: + """``(placed items, unresolved items)``. + + ``unresolved`` lists the build items whose object could not be found where the file says + it is (a ``p:path`` naming a part that is absent, or an object the part does not hold). + They are never measured against some other object that happens to share the id.""" + items, unresolved, _sizes = _measure(path) + return items, unresolved + + +def object_sizes(path: str) -> list[dict]: + """Per-object size (mm): each object as modelled, keyed by (part, object id), with its nested + component transforms applied but BEFORE any build item moves, turns or scales it. One entry per + distinct object (``instance_count`` says how many build items use it). This is a SIZE measure + and says nothing about where anything sits.""" + return _measure(path)[2] + + +def measure(path: str) -> tuple[list[dict], list[dict], list[dict]]: + """``(placed items, unresolved items, object sizes)`` from a single read of the project.""" + return _measure(path) + + +#: The last few projects measured, keyed by file identity: one request often asks for the size, the +#: placement and the layout of the same file, and each of those used to re-read every mesh. +_CACHE: dict = {} +_CACHE_SLOTS = 4 +_CACHE_LOCK = threading.Lock() # requests run on several threads; the dict is never touched unlocked + + +def _measure(path: str): + key = file_identity(path) + if key is not None: + with _CACHE_LOCK: + hit = _CACHE.get(key) + if hit is not None: + return copy.deepcopy(hit) + result = _measure_uncached(path) + # only remember it if the file is still the one that was read: a change during the measurement + # would otherwise be filed under the identity of the old content + if key is not None and stat_identity(path) == key[:3]: + stored = copy.deepcopy(result) + with _CACHE_LOCK: + while len(_CACHE) >= _CACHE_SLOTS: + _CACHE.pop(next(iter(_CACHE)), None) + _CACHE[key] = stored + return result + + +def _measure_uncached(path: str): try: tm = ThreeMF.open(path) except Exception: - return [] + return [], [], [] try: model_files = {p: tm.read_part(p) for p in tm.list_parts() if p.endswith(".model")} if sum(len(b) for b in model_files.values()) > _MAX_BYTES: - return [] + return [], [], [] - # object_id -> (verts, [(component_objectid, component_path, component_xform)]), per file + # (part, object_id) -> (verts, [(component_objectid, component_part|None, component_xform)]) parsed: dict[str, dict[str, tuple]] = {} + unit_scale: dict[str, float] = {} for fname, raw in model_files.items(): root = load_model_settings(raw) + scale = _units.mm_per_unit(raw) # this part's declared unit -> millimetres + unit_scale[fname] = scale objs: dict[str, tuple] = {} for ob in root.iter(f"{_3MF_CORE_NS}object"): oid = ob.get("id") @@ -124,67 +307,140 @@ def build_item_dims(path: str) -> list[dict]: vn = mesh.find(f"{_3MF_CORE_NS}vertices") if vn is not None: for v in vn.iterfind(f"{_3MF_CORE_NS}vertex"): - verts.append((float(v.get("x", 0)), float(v.get("y", 0)), float(v.get("z", 0)))) + if scale == 1.0: + verts.append((float(v.get("x", 0)), float(v.get("y", 0)), float(v.get("z", 0)))) + else: + verts.append((float(v.get("x", 0)) * scale, float(v.get("y", 0)) * scale, + float(v.get("z", 0)) * scale)) comps = [] cn = ob.find(f"{_3MF_CORE_NS}components") if cn is not None: for c in cn.iterfind(f"{_3MF_CORE_NS}component"): comps.append((c.get("objectid"), - c.get(f"{_3MF_PROD_NS}path"), - _xform(c.get("transform")))) + normalize_part(c.get(f"{_3MF_PROD_NS}path")), + _units.scale_translation(_xform(c.get("transform")), scale))) objs[oid] = (verts, comps) parsed[fname] = objs - root_file = "3D/3dmodel.model" + root_file = ROOT_MODEL if root_file not in parsed: - return [] + return [], [], [] - def find_obj(objid, prefer): - if prefer in parsed and objid in parsed[prefer]: - return prefer + def find_obj(objid, part, explicit): + """The part that holds ``objid``. A named part is the only place looked in; with no + ``p:path`` the current part is preferred and any other part is a legacy fallback.""" + if part in parsed and objid in parsed[part]: + return part + if explicit: + return None for f, oo in parsed.items(): if objid in oo: return f return None + # The size of the objects and the placing of the items are counted against the vertex limit + # separately, so measuring both does not halve what a project may hold. budget = [0] + placed_budget = [0] - def collect(objid, prefer_file, xform, acc, seen): - f = find_obj(objid, prefer_file) + def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, role=None, records=None): + f = find_obj(objid, part, explicit) if f is None: + bad.append(f"object {objid} is not in {part}") return key = (f, objid) if key in seen or len(seen) > 4096: # cycle / runaway-nesting guard return seen = seen | {key} verts, comps = parsed[f][objid] + # a volume whose role (its own, or inherited from an assembly above it) is a modifier, negative + # volume or support helper is an instruction to the slicer, not something that prints: it is in + # `acc` but not `printing`. One rule for this everywhere: see ``roles``. + prints = _roles.counts_toward_size(role) for v in verts: - acc.append(_apply(v, xform)) + moved = _apply(v, xform) + acc.append(moved) + if printing is not None and prints: + printing.append(moved) budget[0] += 1 if budget[0] > _MAX_VERTS: raise _TooLarge() - for cid, cpath, ctf in comps: - collect(cid, cpath or f, _compose(xform, ctf), acc, seen) + for cid, cpart, ctf in comps: + child_role = role + if records is not None and f == root_file: + # part records are matched against root-model objects; a missing record is a normal part + child_role = _roles.inherit(role, _roles.own_role( + records.part_subtypes, records.metadata_objects, objid, cid, missing=None)) + collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad, + printing, child_role, records) + + def box_of(points): + xs = [p[0] for p in points]; ys = [p[1] for p in points]; zs = [p[2] for p in points] + return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)) + + items = build_items(model_files[root_file]) + try: + settings_text = tm.read_part(SETTINGS_PART).decode("utf-8", "ignore") if tm.has_part(SETTINGS_PART) else "" + records = _roles.read_records(settings_text) + except Exception: + records = _roles.Records() + uses: dict[tuple, int] = {} + for it in items: + part = it["part"] or root_file + uses[(part, it["object_id"])] = uses.get((part, it["object_id"]), 0) + 1 + # Each distinct object is collected once, as modelled; an item then only moves those points. + collected: dict[tuple, tuple] = {} out = [] - for it in load_model_settings(model_files[root_file]).iter(f"{_3MF_CORE_NS}item"): - oid = it.get("objectid") - acc: list = [] - collect(oid, root_file, _xform(it.get("transform")), acc, frozenset()) - if not acc: + unresolved = [] + sizes: dict[tuple, dict] = {} + for it in items: + oid = it["object_id"] + part = it["part"] or root_file + key = (part, oid) + if key not in collected: + points: list = [] + printing: list = [] + bad: list = [] + collect(oid, part, it["part"] is not None, None, points, frozenset(), bad, + printing, None, records) + collected[key] = (points, printing, bad) + # the SIZE of an object is the size of what prints, like its placed footprint + if printing and not bad: + lo, hi = box_of(printing) + sizes[key] = {"object_id": oid, "part": part, "instance_count": uses[key], + "dimensions": {"x": round(hi[0] - lo[0], 2), + "y": round(hi[1] - lo[1], 2), + "z": round(hi[2] - lo[2], 2)}} + points, printing, bad = collected[key] + if bad: + unresolved.append({"item_index": it["item_index"], "object_id": oid, + "part": part, "reason": bad[0]}) continue - xs = [p[0] for p in acc]; ys = [p[1] for p in acc]; zs = [p[2] for p in acc] - lo = (min(xs), min(ys), min(zs)); hi = (max(xs), max(ys), max(zs)) + if not points: + continue + xform = _units.scale_translation(_xform(it["transform"]), unit_scale[root_file]) + # the placed bounds are those of what PRINTS; an object made only of helper volumes has nothing + # that prints, and keeps its full extent rather than vanishing from the checks + measured = printing or points + placed_budget[0] += len(measured) + if placed_budget[0] > _MAX_VERTS: + raise _TooLarge() + lo, hi = box_of([_apply(v, xform) for v in measured]) + # `item_index` is the build item's ordinal in the root model: one object used by two items is + # two entries, and this tells them apart (an item with no geometry leaves a gap in the sequence). out.append({"object_id": oid, + "item_index": it["item_index"], + "part": part, "dimensions": {"x": round(hi[0] - lo[0], 2), "y": round(hi[1] - lo[1], 2), "z": round(hi[2] - lo[2], 2)}, "bounds": {"min": lo, "max": hi}}) - return out + return out, unresolved, list(sizes.values()) except _TooLarge: - return [] + return [], [], [] except Exception: - return [] + return [], [], [] def load_mesh(path: str) -> Mesh | None: diff --git a/backend/snapstudio_core/intelligence.py b/backend/snapstudio_core/intelligence.py index 12fe2cbd..bd948866 100644 --- a/backend/snapstudio_core/intelligence.py +++ b/backend/snapstudio_core/intelligence.py @@ -10,6 +10,7 @@ from .doctor import diagnose_path, READY from .container import ThreeMF from .config_io import load_project_settings +from . import units as _units SCHEMA_VERSION = "insights/1" SETTINGS = "Metadata/project_settings.config" @@ -20,6 +21,13 @@ _VERT_RE = re.compile(rb']*\bx="(-?[\d.eE+]+)"[^>]*\by="(-?[\d.eE+]+)"[^>]*\bz="(-?[\d.eE+]+)"') _TRI_RE = re.compile(rb"]*>.*?", re.S) +_ID_RE = re.compile(rb'\bid="([^"]*)"') + + +def _object_id(block: bytes) -> str: + found = _ID_RE.search(block[:block.index(b">") + 1]) + return found.group(1).decode("utf-8", "replace") if found else "" def _bbox_and_triangles(tm: ThreeMF): @@ -35,11 +43,26 @@ def _bbox_and_triangles(tm: ThreeMF): lo = [float("inf")] * 3 hi = [float("-inf")] * 3 seen = False + # Meshes that are only ever non-printing volumes (modifiers, negative volumes, support helpers) are not + # part of the model's extents: the same role rule as the placed bounds and the sizes (see ``roles``). + from . import geometry + + helpers = geometry.nonprinting_meshes(tm) for p in model_parts: - for m in _VERT_RE.finditer(tm.read_part(p)): + raw = tm.read_part(p) + # Each part's coordinates are in the unit its own header declares; the result is + # millimetres. Millimetre parts (the default) are multiplied by exactly 1.0. + scale = _units.mm_per_unit(raw) + if any(part == p for part, _oid in helpers): + matches = (m for block in _OBJECT_BLOCK_RE.finditer(raw) + if (p, _object_id(block.group(0))) not in helpers + for m in _VERT_RE.finditer(block.group(0))) + else: + matches = _VERT_RE.finditer(raw) + for m in matches: seen = True for i in range(3): - v = float(m.group(i + 1)) + v = float(m.group(i + 1)) * scale if v < lo[i]: lo[i] = v if v > hi[i]: hi[i] = v if not seen: @@ -89,10 +112,15 @@ def _complexity(triangles: int | None) -> str | None: return "high" -def project_info(path: str) -> dict: +def project_info(path: str, placement_aware: bool = False) -> dict: """Rich, read-only insights for a design. Builds on the Doctor diagnosis and adds real geometry + material detail. Never raises on geometry — returns what - it can and leaves the rest null.""" + it can and leaves the rest null. + + ``placement_aware`` also reads every object's size and every instance's placed bounds + (``object_sizes_mm``, ``placed``). That means reading each mesh once more, so it is asked for + only by the callers that use it (Design Health, the bed-fit check, the validation report); the + rest keep the cost they had and get ``None`` for those three keys.""" diag = diagnose_path(path).to_dict() is_stl = str(path).lower().endswith(".stl") @@ -108,6 +136,40 @@ def project_info(path: str) -> dict: except Exception: pass # geometry/materials are best-effort; diagnosis still stands + # SIZE and PLACEMENT are different facts and are kept apart here. + # object_sizes_mm how big each object is (per object, millimetres, before any build item + # moves, turns or scales it). Says nothing about where anything sits. + # placed where each instance sits and on which plate (placed bounds). + # dimensions_mm kept for existing readers: the overall extents of all the mesh data + # together. A size-only figure; for several objects it is NOT one object's + # size and NOT a position (dimensions_basis says so). + object_sizes: list[dict] | None = [] + unmeasured: int | None = 0 # build items whose object could not be found, so have no size here + placed: dict | None + if not placement_aware: + object_sizes, unmeasured, placed = None, None, None + elif is_stl: + if dims: + object_sizes = [{"object_id": None, "instance_count": 1, "dimensions_mm": dict(dims)}] + placed = {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "reason": "A bare STL has no placement of its own; Studio centers it when it wraps " + "the model into a U1 project."} + else: + try: + from . import geometry, plate_placement + placed_items, unresolved, sizes = geometry.measure(path) + object_sizes = [{"object_id": s["object_id"], "part": s["part"], + "instance_count": s["instance_count"], + "dimensions_mm": {k: round(v, 1) for k, v in s["dimensions"].items()}} + for s in sizes] + unmeasured = len(unresolved) + placed = plate_placement.placed_instances(path, measured=(placed_items, unresolved)) + except Exception: + placed = {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "reason": "Studio could not read where the objects sit in this project."} + return { "schema_version": SCHEMA_VERSION, "name": Path(path).name, @@ -124,6 +186,10 @@ def project_info(path: str) -> dict: "painted": diag.get("painted"), "materials": materials, "dimensions_mm": dims, + "dimensions_basis": "overall_extents_by_size", + "object_sizes_mm": object_sizes, + "objects_unmeasured": unmeasured, + "placed": placed, "triangles": triangles, "complexity": _complexity(triangles), "issues": [*(diag.get("validation_issues") or []), *(diag.get("compatibility_issues") or [])], diff --git a/backend/snapstudio_core/intelligence_report.py b/backend/snapstudio_core/intelligence_report.py index 8f650fcf..011e8cd5 100644 --- a/backend/snapstudio_core/intelligence_report.py +++ b/backend/snapstudio_core/intelligence_report.py @@ -119,7 +119,8 @@ def build(predict=None, bed_fit=None, mm=None, first_layer=None, health=None, # --- risks + recommendations from every doctor --- risks: list = [] recs: list = [] - _push_findings(risks, recs, "Size fit (dimensions only)", bed_fit) + _push_findings(risks, recs, "Bed fit (size and placement)" + if (bed_fit or {}).get("placement_checked") else "Bed fit", bed_fit) _push_findings(risks, recs, "Multi-Material Doctor", mm) _push_findings(risks, recs, "First Layer Doctor", first_layer) if avail["health"]: diff --git a/backend/snapstudio_core/layout.py b/backend/snapstudio_core/layout.py index b458abde..1aba64d7 100644 --- a/backend/snapstudio_core/layout.py +++ b/backend/snapstudio_core/layout.py @@ -61,8 +61,8 @@ def assess_layout(path: str) -> dict: "plates": plates, "object_count": 0, "messages": [ - "Studio couldn't read object placement from this file — open it in " - "Snapmaker Orca and use Arrange all plates to check the layout before slicing." + "Studio couldn't read object placement from this file — review the project in " + "Snapmaker Orca before slicing; Studio could not establish where the objects sit." ], } @@ -81,8 +81,8 @@ def assess_layout(path: str) -> dict: status = "warn" messages.append( f"Multi-plate project ({plates} plates) — Studio can't fully verify each plate's " - "object positions from the file. Open in Snapmaker Orca and use Arrange all plates " - "before slicing; objects may sit outside a plate." + "object positions from the file. Review it in Snapmaker Orca before slicing; " + "objects may sit outside a plate, and Arrange all plates did not fix that in our test." ) else: (xlo, xhi), (ylo, yhi) = plate_window(items) @@ -96,7 +96,7 @@ def assess_layout(path: str) -> dict: status = "fail" messages.append( f"{len(off)} object(s) sit outside the U1 build plate ({int(U1_PLATE_MM)} mm). " - "Open in Snapmaker Orca and use Arrange all plates before slicing." + "Review the project in Snapmaker Orca before slicing." ) if oversized: diff --git a/backend/snapstudio_core/orca_import.py b/backend/snapstudio_core/orca_import.py index 95250f80..e7c40bc1 100644 --- a/backend/snapstudio_core/orca_import.py +++ b/backend/snapstudio_core/orca_import.py @@ -69,9 +69,11 @@ def _num(cfg: dict, key: str): return None -def _change(changes: list, key: str, old, new, reason: str, why: str) -> None: +def _change(changes: list, key: str, old, new, reason: str, why: str, + *, kind: str = "engine", source: str = "Studio's import rule for this setting") -> None: changes.append({"key": key, "old": old, "new": new, "reason": reason, - "explanation": why, "category": "orca-compatibility"}) + "explanation": why, "source": source, + "kind": kind, "category": "orca-compatibility"}) # --- individual rules ------------------------------------------------------- @@ -89,9 +91,7 @@ def _enable_exclude_object(cfg: dict, changes: list) -> None: cfg["exclude_object"] = "1" _change(changes, "exclude_object", old, "1", "enabled so the U1 can skip a failed object and probe adaptively", - "With Exclude Object off, the U1 cannot cancel one failed object " - "without losing the whole plate, and adaptive bed mesh has no object " - "outlines to work from.") + "Studio's import rule turns Exclude Object on for every U1 copy; the original had it off.") def _fix_auto_brim(cfg: dict, changes: list) -> None: @@ -107,10 +107,9 @@ def _fix_auto_brim(cfg: dict, changes: list) -> None: if brim == "auto_brim": cfg["brim_type"] = "no_brim" _change(changes, "brim_type", brim, "no_brim", - "the creator left the brim on automatic, and Snapmaker Orca decides " - "differently from the slicer this was made in", - "Automatic means the slicer chooses. Snapmaker Orca's choice can add " - "a brim this project never had. A brim you asked for yourself is kept.") + "Studio replaced an automatic brim choice with no brim for this U1 copy", + "Studio replaced the automatic brim choice with no brim. A brim you chose yourself is kept.", + kind="orca") def _fix_tree_support_with_adaptive_layers(cfg: dict, changes: list) -> None: @@ -128,11 +127,8 @@ def _fix_tree_support_with_adaptive_layers(cfg: dict, changes: list) -> None: if style in ("organic", "tree_slim", "tree_strong"): cfg["support_style"] = "tree_hybrid" _change(changes, "support_style", style, "tree_hybrid", - "tree support with variable layer height needs the hybrid style on the U1", - "This project uses variable layer height with tree supports. The " - "slicer it was made in silently switches that combination to hybrid " - "when it opens the file; Snapmaker Orca does not, so Studio applies " - "the same correction.") + "Studio replaced the tree support style with the hybrid style for this U1 copy", + "Studio replaced the tree support style with the hybrid style for this U1 copy.", kind="orca") def _fix_filament_array_validity(cfg: dict, changes: list, count: int) -> None: @@ -154,14 +150,38 @@ def pad_from_self(key: str, why: str, reason: str) -> None: filled = [v for v in value if str(v).strip() != ""] if not filled: return + # Only entries that survive truncation can have been filled, trimmed or converted; + # dropped entries are reported once, as a resize. + retained = value[:count] + had_empty = any(str(v).strip() == "" for v in retained) + trimmed = any(isinstance(v, str) and v.strip() != v for v in retained) new = [(str(v).strip() or str(filled[-1])) for v in value] if len(new) < count: new = new + [new[-1]] * (count - len(new)) elif len(new) > count: new = new[:count] if new != value: + resized = len(new) != len(value) + converted = any(not isinstance(v, str) for v in retained) + parts = [] + if had_empty: + parts.append("Studio filled an empty entry with the last non-empty value in this list.") + if resized: + parts.append("Studio resized this list to cover every filament slot.") + if trimmed: + parts.append("Studio trimmed whitespace around a string value.") + if converted: + parts.append("Studio wrote each value in this list as text.") + actual_why = " ".join(parts) + actual_reason = "; ".join(filter(None, [ + "filled an empty entry with the last non-empty value" if had_empty else "", + "resized the list" if resized else "", + "trimmed whitespace from a string" if trimmed else "", + "wrote each value in the list as text" if converted else "", + ])) cfg[key] = new - _change(changes, key, value, new, reason, why) + _change(changes, key, value, new, actual_reason, actual_why, + kind="engine", source="Studio's import rule for this setting") pad_from_self( "filament_adaptive_volumetric_speed", @@ -184,18 +204,15 @@ def pad_from_self(key: str, why: str, reason: str) -> None: cfg["filament_self_index"] = expected _change(changes, "filament_self_index", self_index, expected, "renumbered so every filament slot identifies itself correctly", - "Each filament slot has to carry its own position. When the " - "numbering is missing or out of step the slicer reports an " - "invalid project configuration.") + "Studio renumbered each filament slot so the list matches its position.") # The raft expansion has two owners: the U1 clamp table (data/u1_rules.json), which runs in every mode and fixes -1, and # the rule below, which runs in Preserve/U1/Optimize and fixes any negative value. They must agree on the value (the clamp's # "good" is the U1 template default, checked by a test) and say the same thing about why, so the reason shown to the person # does not depend on which of them acted. -RAFT_EXPANSION_REASON = "restored to the U1 default (a negative value is out of range)" -RAFT_EXPANSION_WHY = ("Snapmaker Orca and OrcaSlicer both reject a negative raft expansion " - "and warn when the project is opened.") +RAFT_EXPANSION_REASON = "replaced a negative raft expansion with the U1 base-profile value" +RAFT_EXPANSION_WHY = "A negative raft expansion was replaced with the value from Studio's U1 base profile." def _fix_negative_raft_expansion(cfg: dict, changes: list) -> None: @@ -212,7 +229,8 @@ def _fix_negative_raft_expansion(cfg: dict, changes: list) -> None: old = cfg.get("raft_first_layer_expansion") cfg["raft_first_layer_expansion"] = default _change(changes, "raft_first_layer_expansion", old, default, - RAFT_EXPANSION_REASON, RAFT_EXPANSION_WHY) + RAFT_EXPANSION_REASON, RAFT_EXPANSION_WHY, kind="orca", + source="Studio's import rule for this setting") # --- entry points ----------------------------------------------------------- diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 8f2915c2..2790cb2e 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -23,8 +23,11 @@ import json import re +import threading import zipfile +from . import units as _units + SCHEMA_VERSION = "placement/1" #: The states an object's footprint can be in against the target polygon. They are @@ -48,10 +51,6 @@ TOLERANCE = 5e-4 _VERTEX = re.compile(r']*x="([^"]*)"[^>]*y="([^"]*)"[^>]*z="([^"]*)"') -_OBJECT_WITH_ID = re.compile(r']* id="([0-9]+)"[^>]*>.*?', re.S) -_OBJECT_BLOCK = re.compile(r"]*>.*?", re.S) -_COMPONENT = re.compile(r']* objectid="([0-9]+)"[^>]*' - r'(?: transform="([^"]*)")?[^>]*/>') _BUILD_ITEM = re.compile(r']* objectid="([0-9]+)"[^>]*' r' transform="([^"]*)"') @@ -212,7 +211,38 @@ def _read(archive: zipfile.ZipFile, name: str) -> str: return "" -def read_objects(path: str) -> dict: +_READ_CACHE: dict = {} +_READ_SLOTS = 4 +_READ_LOCK = threading.Lock() + + +def read_objects(path: str, keep_points: bool = True) -> dict: + """See ``_read_objects``. With ``keep_points=False`` the per-vertex outline is not collected and + the result is remembered for the next call on the same, unchanged file: footprints are all most + callers need, and they no longer re-read every mesh to get them.""" + import copy + + from . import geometry + + if keep_points: + return _read_objects(path, True) + key = geometry.file_identity(path) + if key is not None: + with _READ_LOCK: + hit = _READ_CACHE.get(key) + if hit is not None: + return copy.deepcopy(hit) + result = _read_objects(path, False) + if key is not None and geometry.stat_identity(path) == key[:3]: + stored = copy.deepcopy(result) + with _READ_LOCK: + while len(_READ_CACHE) >= _READ_SLOTS: + _READ_CACHE.pop(next(iter(_READ_CACHE)), None) + _READ_CACHE[key] = stored + return result + + +def _read_objects(path: str, keep_points: bool = True) -> dict: """Every logical object in a prepared project, with its footprint on the plate. The footprint is the printable geometry only. A modifier or a support blocker @@ -221,60 +251,105 @@ def read_objects(path: str) -> dict: problem the plate does not have. Whether the target agrees is a question for the target, and it is recorded in the project's own documentation. """ - from . import multipart - - with zipfile.ZipFile(path) as archive: - names = archive.namelist() - root = _read(archive, ROOT_MODEL) - settings_text = _read(archive, MODEL_SETTINGS) - try: - project = json.loads(_read(archive, PROJECT_SETTINGS) or "{}") - except json.JSONDecodeError: - project = {} + from . import geometry, multipart, roles + from .container import ThreeMF + + # The same bounded reader and limits as the rest of the engine: a hostile or merely huge archive + # is refused (with an exception the callers turn into "could not measure"), never read whole. + tm = ThreeMF.open(path) + names = tm.list_parts() + if sum(len(tm.read_part(n)) for n in names if n.endswith(".model")) > geometry._MAX_BYTES: + raise TooLargeToMeasure("the model data is larger than Studio will measure") + budget = _Budget(geometry._MAX_VERTS) + + def _text(name): + return tm.read_part(name).decode("utf-8", "ignore") if name in names else "" + + root_bytes = tm.read_part(ROOT_MODEL) if ROOT_MODEL in names else b"" + root = root_bytes.decode("utf-8", "ignore") + root_scale = _units.mm_per_unit(root) # the unit the root model declares -> mm + settings_text = _text(MODEL_SETTINGS) + try: + project = json.loads(_text(PROJECT_SETTINGS) or "{}") + except json.JSONDecodeError: + project = {} + # (part, object id) -> its points / its components. An object id is only unique within + # its own part: object 1 in one file and object 1 in another are different objects. + parts: dict[str, dict] = {} + for name in sorted(n for n in names if n.endswith(".model")): + body = _text(name) + scale = _units.mm_per_unit(body) # each part's own declared unit meshes: dict[str, list[tuple]] = {} - for name in sorted(n for n in names if n.startswith(OBJECTS_DIR)): - body = _read(archive, name) - for mesh_id in re.findall(r'', body, re.S) - if block: - meshes[mesh_id] = _points(block.group(0)) - if not meshes: - # A project whose geometry is still in the root model. - for mesh_id, block in zip(_OBJECT_WITH_ID.findall(root), - _OBJECT_BLOCK.findall(root)): - meshes[mesh_id] = _points(block) + components: dict[str, list] = {} + for block in _OBJECT_BLOCK.findall(body): + tag = block[:block.index(">") + 1] + found = re.search(r'\bid="([0-9]+)"', tag) + if not found: + continue + meshes[found.group(1)] = _scaled(_points(block, budget), scale) + components[found.group(1)] = _components_of(block) + parts[name] = {"scale": scale, "meshes": meshes, "components": components} polygon = polygon_of(project) parts_by_object = multipart._parts_by_object(settings_text) if settings_text else {} - placements = dict(_BUILD_ITEM.findall(root)) - components = _components_by_object(root) + records = roles.read_records(settings_text) + # One entry per BUILD ITEM, in document order. Keying by object id would keep only the last + # transform of an object that several items use, and every instance would then be reported + # at the last one's position. objects = [] - for object_id, transform_text in placements.items(): - item = parse_transform(transform_text) + seen_per_object: dict[tuple, int] = {} + totals: dict[tuple, int] = {} + build = geometry.build_items(root_bytes) if root_bytes else [] + # An object id is only unique within its part: instance 1 of 2 means two uses of the SAME object. + for entry in build: + who = (entry["part"] or ROOT_MODEL, entry["object_id"]) + totals[who] = totals.get(who, 0) + 1 + for entry in build: + object_id, transform_text = entry["object_id"], entry["transform"] + who = (entry["part"] or ROOT_MODEL, object_id) + instance_index = seen_per_object.get(who, 0) + seen_per_object[who] = instance_index + 1 + item = _units.scale_translation(parse_transform(transform_text), root_scale) part_ids = [part_id for part_id, _subtype in parts_by_object.get(object_id, [])] - roles = dict(parts_by_object.get(object_id, [])) - own_components = components.get(object_id) or [ - (part_id, None) for part_id in part_ids] - printable: list[tuple] = [] - every: list[tuple] = [] - for mesh_id, component_transform in own_components: - points = meshes.get(mesh_id) - if points is None: - continue - whole = compose(item, parse_transform(component_transform)) - moved = [apply(whole, point) for point in points] - every.extend(moved) - if roles.get(mesh_id, "normal_part") == "normal_part": - printable.extend(moved) + item_part = entry["part"] or ROOT_MODEL + explicit = entry["part"] is not None + top = parts.get(item_part, {}).get("components", {}) + printable = _Extent(keep_points) + every = _Extent(False) + resolved = True + if explicit and object_id not in parts.get(item_part, {}).get("meshes", {}): + resolved = False # a named part that does not hold the object: not the root's object + entries = top.get(object_id) or [(None, part_id, None) for part_id in part_ids] + leaves, ok = _leaves(entries, parts, item_part, explicit, root_scale if item_part == ROOT_MODEL + else parts.get(item_part, {}).get("scale", 1.0), + owner=(item_part, object_id), records=records) + resolved = resolved and ok + for leaf_part, mesh_id, component_transform, role in leaves: + points = parts[leaf_part]["meshes"][mesh_id] + budget.take_moved(len(points)) + whole = compose(item, component_transform) + # the one role rule (see ``roles``): a volume counts toward the footprint unless a + # non-printing role, its own or inherited from an assembly above it, says it does not print + is_part = roles.counts_toward_size(role) + for point in points: + moved = apply(whole, point) + every.add(moved) + if is_part: + printable.add(moved) objects.append({ "object_id": object_id, + "part": item_part, + "item_index": entry["item_index"], + "instance_index": instance_index, + "instance_count": totals[who], "name": _name_of(settings_text, object_id), "transform": transform_text, "part_ids": part_ids, - "footprint": _footprint(printable), - "footprint_with_helpers": _footprint(every), - "printable_points": [(x, y) for x, y, _z in printable], + "resolved": resolved, + "footprint": printable.footprint() if resolved else None, + "footprint_with_helpers": every.footprint() if resolved else None, + "printable_points": printable.xy() if resolved else [], }) return {"schema_version": SCHEMA_VERSION, "objects": objects, @@ -282,21 +357,101 @@ def read_objects(path: str) -> dict: "polygon_source": PROJECT_SETTINGS if polygon else None} -def _components_by_object(root: str) -> dict: - out: dict[str, list] = {} - for match in re.finditer( - r']*>\s*(.*?)', root, re.S): - entries = [] - for component in re.findall(r"]*/>", match.group(2)): - mesh_id = re.search(r'objectid="([0-9]+)"', component) - transform = re.search(r'transform="([^"]*)"', component) - if mesh_id: - entries.append((mesh_id.group(1), - transform.group(1) if transform else None)) - out[match.group(1)] = entries +_OBJECT_BLOCK = re.compile(r"]*>.*?", re.S) +_COMPONENT_TAG = re.compile(r"]*/>") +_ATTR_OBJECTID = re.compile(r'\bobjectid="([0-9]+)"') +_ATTR_TRANSFORM = re.compile(r'\btransform="([^"]*)"') +_ATTR_PATH = re.compile(r'\b(?:[A-Za-z_][\w.-]*:)?path="([^"]*)"') +_MAX_COMPONENT_DEPTH = 16 + + +def build_items(root: str) -> list[dict]: + """Every build item of a root model, in document order (see ``geometry.build_items``). + + One reader serves the whole engine, so ``item_index`` means the same thing in every + module. ``transform`` is the item's text, or None when it has none (the identity). + """ + from . import geometry + + return geometry.build_items(root) + + +def geometry_normalize(path: str) -> str: + from . import geometry + + return geometry.normalize_part(path) + + +def _components_of(block: str) -> list: + """(part path or None, object id, transform text or None) for each component of an object.""" + out = [] + for tag in _COMPONENT_TAG.findall(block): + mesh_id = _ATTR_OBJECTID.search(tag) + if not mesh_id: + continue + transform = _ATTR_TRANSFORM.search(tag) + path = _ATTR_PATH.search(tag) + out.append((geometry_normalize(path.group(1)) if path else None, mesh_id.group(1), + transform.group(1) if transform else None)) return out +def _leaves(entries: list, parts: dict, part: str, explicit: bool, scale: float, + depth: int = 0, outer: tuple | None = None, seen: frozenset = frozenset(), + owner: tuple | None = None, records=None, role=None): + """``([(part, mesh id, transform in mm, role)], resolved)`` for every mesh an object reaches. + + ``role`` is what the volume inherits from the assemblies above it combined with the part record + for it under ``owner`` (the (part, id) of the object whose components ``entries`` are): see ``roles``. + + A component may itself be an object made of components, with its own transform; the + transforms compose down the path rather than being applied one after the other. A + component that names a part is looked up in that part and nowhere else: if it is not there + the object is NOT resolved, and nothing is measured from some other part's object that + happens to share the id. + """ + found: list = [] + ok = True + for cpart, oid, text in entries: + here_part = cpart if cpart is not None else part + strict = cpart is not None or explicit + holder = parts.get(here_part) + if holder is None or oid not in holder["meshes"]: + if strict: + ok = False + continue + # no p:path: the object is expected in this part; otherwise in the only part that has it + owners = [name for name, held in parts.items() if oid in held["meshes"]] + if len(owners) != 1: + continue # absent (nothing to measure) or ambiguous (not guessed) + here_part, holder = owners[0], parts[owners[0]] + here = compose(outer, _units.scale_translation(parse_transform(text), scale)) + child_role = role + if records is not None and owner is not None and owner[0] == ROOT_MODEL: + from . import roles as _roles + + child_role = _roles.inherit(role, _roles.own_role( + records.part_subtypes, records.metadata_objects, owner[1], oid, missing=None)) + points = holder["meshes"][oid] + if points: + found.append((here_part, oid, here, child_role)) + elif holder["components"].get(oid) and (here_part, oid) not in seen \ + and depth < _MAX_COMPONENT_DEPTH: + more, more_ok = _leaves(holder["components"][oid], parts, here_part, False, + holder["scale"], depth + 1, here, seen | {(here_part, oid)}, + owner=(here_part, oid), records=records, role=child_role) + found.extend(more) + ok = ok and more_ok + return found, ok + + +def _scaled(points: list[tuple], factor: float) -> list[tuple]: + """Mesh points in millimetres. A millimetre part is returned untouched.""" + if factor == 1.0: + return points + return [(x * factor, y * factor, z * factor) for x, y, z in points] + + def _name_of(settings: str, object_id: str) -> str | None: match = re.search(rf'(.*?)(?=)', settings, re.S) @@ -306,8 +461,69 @@ def _name_of(settings: str, object_id: str) -> str | None: return found.group(1) if found else None -def _points(block: str) -> list[tuple]: - return [(float(x), float(y), float(z)) for x, y, z in _VERTEX.findall(block)] +class TooLargeToMeasure(Exception): + """A project holds more geometry than Studio will measure.""" + + +class _Budget: + """Vertices read from meshes plus vertices moved onto the plate, counted while they are collected, + so a huge file stops early instead of being materialised and then dropped.""" + + def __init__(self, limit: int) -> None: + self.limit, self.read, self.moved = limit, 0, 0 + + def take_read(self, n: int = 1) -> None: + self.read += n + if self.read > self.limit: + raise TooLargeToMeasure("too many vertices to measure") + + def take_moved(self, n: int) -> None: + self.moved += n + if self.moved > self.limit: + raise TooLargeToMeasure("too many vertices to measure") + + +def _points(block: str, budget: "_Budget | None" = None) -> list[tuple]: + out = [] + for x, y, z in _VERTEX.findall(block): + if budget is not None: + budget.take_read() + out.append((float(x), float(y), float(z))) + return out + + +class _Extent: + """The footprint of the points added to it, kept as a running min/max (and, only when asked, the + points themselves).""" + + def __init__(self, keep: bool) -> None: + self.keep, self.points = keep, [] + self.lo_x = self.lo_y = self.lo_z = float("inf") + self.hi_x = self.hi_y = self.hi_z = float("-inf") + self.n = 0 + + def add(self, point: tuple) -> None: + x, y = point[0], point[1] + z = point[2] if len(point) > 2 else 0.0 + if z < self.lo_z: self.lo_z = z + if z > self.hi_z: self.hi_z = z + if x < self.lo_x: self.lo_x = x + if x > self.hi_x: self.hi_x = x + if y < self.lo_y: self.lo_y = y + if y > self.hi_y: self.hi_y = y + self.n += 1 + if self.keep: + self.points.append((x, y)) + + def xy(self) -> list[tuple]: + return list(self.points) + + def footprint(self) -> dict | None: + if not self.n: + return None + return {"min_x": self.lo_x, "min_y": self.lo_y, "max_x": self.hi_x, "max_y": self.hi_y, + "width": self.hi_x - self.lo_x, "depth": self.hi_y - self.lo_y, + "min_z": self.lo_z, "max_z": self.hi_z} def _footprint(points: list[tuple]) -> dict | None: @@ -456,15 +672,24 @@ def assess(path: str, printer: str = "Snapmaker U1") -> dict: objects = [] for entry in read["objects"]: verdict = classify(entry["footprint"], polygon, entry.get("printable_points")) + label = entry["name"] + if entry["instance_count"] > 1: + # Two instances of one object need telling apart, or the sentence about the + # one that is off the plate reads as if it were about both. + label = (f"{label or 'This object'} (instance {entry['instance_index'] + 1} " + f"of {entry['instance_count']})") objects.append({ "object_id": entry["object_id"], + "item_index": entry["item_index"], + "instance_index": entry["instance_index"], + "instance_count": entry["instance_count"], "name": entry["name"], "transform": entry["transform"], "footprint_mm": _rounded(entry["footprint"]), "status": verdict["status"], "excess_mm": verdict.get("excess_mm") or {}, "fits_by_translation": verdict.get("fits_by_translation"), - "message": describe(entry["name"], verdict, printer), + "message": describe(label, verdict, printer), "reason": verdict.get("reason"), }) diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index 574fe055..c6165430 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -6,7 +6,7 @@ never catch it, because nothing is too big — it is in the wrong place. Snapmaker Orca's answer to this is a bare "out of bounds", and the community's -answer is "hit Arrange and hope". This module gives the specific answer instead: +answer is a guess. This module gives the specific answer instead: which object is off the plate, by how much and on which edge, whether one translation would bring the whole arrangement back on, and — when it would — writes a new copy with exactly that translation applied. @@ -23,7 +23,7 @@ all: the spacing between plates is not recorded in the file, so any move would be a guess. They are still *checked* — each plate is judged on whether its own contents fit a U1 plate, which does not depend on the grid — and the answer is - "open this in Orca and use Arrange". + "review this in Orca before slicing". """ from __future__ import annotations @@ -32,6 +32,7 @@ from importlib.resources import files from pathlib import Path +from . import units as _units from .container import ThreeMF SCHEMA_VERSION = "placement/1" @@ -54,6 +55,56 @@ def _u1_printable_area() -> list[str]: return template.get("printable_area") or [] +def over_height(placement: dict | None) -> list[dict]: + """The placed instances whose top is above the printable height, from a ``assess`` result. + + The one rule for "too tall": preflight and the bed-fit check both use it, so neither can read + a tall instance as fine because it only looked sideways. Empty when the height is not known.""" + if not placement or not placement.get("available"): + return [] + limit = placement.get("bed_height_mm") + if not limit: + return [] + out = [] + for row in placement.get("items") or []: + top = ((row.get("bounds_mm") or {}).get("max") or [None, None, None])[2] + if top is not None and top > limit + 1e-6: + out.append(row) + return out + + +#: Below this a placed part is under the build plane (float noise stays above it). +BELOW_PLATE_MM = -0.01 + + +def below_plate(placement: dict | None) -> list[dict]: + """The placed instances that reach below the build plane (placed Z under zero). + + Studio does not treat a sunk part as fitting. A slicer prints what is above the plane, and nothing in + the project says the parts under it are meant to be cut off, so "fits" cannot be confirmed: the + instance is reported instead, and the person decides. (Z is placed Z: the build item's own scale and + translation count.)""" + if not placement or not placement.get("available"): + return [] + out = [] + for row in placement.get("items") or []: + low = ((row.get("bounds_mm") or {}).get("min") or [None, None, None])[2] + if low is not None and low < BELOW_PLATE_MM: + out.append(row) + return out + + +def u1_printable_height() -> float | None: + """The U1's printable height (mm) from Studio's own U1 profile template; None if it records none.""" + try: + template = json.loads( + (files("snapstudio_core.data") / "templates" / "u1_base_project_settings.json") + .read_text("utf-8")) + return float(template.get("printable_height")) + except Exception: + return None + + def parse_printable_area(area) -> dict | None: """Turn a slicer's printable_area polygon into an axis-aligned rectangle. @@ -101,6 +152,25 @@ def _source_bed(tm: ThreeMF) -> dict | None: return parse_printable_area(cfg.get("printable_area")) +def _prime_tower(tm: ThreeMF, plate_count: int, target: dict, whose: str) -> dict: + """The prime tower against the printable area, for a project that is already a + U1 project (a foreign project's tower is replaced or reported by Prepare).""" + from . import prime_tower + + part = "Metadata/project_settings.config" + try: + cfg = json.loads(tm.read_part(part).decode("utf-8", "ignore")) if tm.has_part(part) else {} + except Exception: + cfg = {} + if cfg.get("printer_model") != "Snapmaker U1": + return {"outside": [], "message": None, "note": None} + found = prime_tower.findings(cfg, plate_count, target) + return {"outside": found, + "message": prime_tower.message(found, target, whose), + # Informational only: the depth is not in the file, so "inside" is never claimed. + "note": prime_tower.unverified_note(cfg, found)} + + def _overhang(bounds: dict, bed: dict) -> dict: """How far an item pokes past each edge, in mm. Zero means inside.""" lo, hi = bounds["min"], bounds["max"] @@ -112,6 +182,17 @@ def _overhang(bounds: dict, bed: dict) -> dict: } +def _instance_list(rows: list[dict]) -> str: + """'object 1, instance 1 of 2; object 3' — which placed items a sentence is about.""" + named = [] + for row in rows: + text = f"object {row['object_id']}" + if row.get("instance_count", 1) > 1: + text += f", instance {row['instance_index'] + 1} of {row['instance_count']}" + named.append(text) + return "; ".join(named) + + def _edges_text(over: dict) -> str: named = [name for name, mm in over.items() if mm > 0] return ", ".join(named) @@ -139,8 +220,8 @@ def _centering_offset(cluster: dict, bed: dict) -> dict: high_x = bed["max_x"] - EDGE_MARGIN_MM - cluster["max_x"] low_y = bed["min_y"] + EDGE_MARGIN_MM - cluster["min_y"] high_y = bed["max_y"] - EDGE_MARGIN_MM - cluster["max_y"] - return {"x": round(_nearest_to_zero(low_x, high_x), 3), - "y": round(_nearest_to_zero(low_y, high_y), 3)} + return {"x": round(_nearest_to_zero(low_x, high_x), 6), + "y": round(_nearest_to_zero(low_y, high_y), 6)} def _nearest_to_zero(low: float, high: float) -> float: @@ -175,10 +256,12 @@ def _printable_only(path: str, items: list[dict]) -> list[dict]: from . import placement try: - read = placement.read_objects(path) + read = placement.read_objects(path, keep_points=False) except Exception: return items - footprints = {entry["object_id"]: entry["footprint"] + # Keyed by build item, not by object id: one object can be used by several items, each + # at its own place, and a table keyed by object id gives all of them the last one's. + footprints = {entry["item_index"]: entry["footprint"] for entry in read.get("objects") or () if entry.get("footprint")} if not footprints: @@ -186,20 +269,93 @@ def _printable_only(path: str, items: list[dict]) -> list[dict]: out = [] for item in items: - box = footprints.get(str(item.get("object_id"))) + box = footprints.get(item.get("item_index")) if box is None: out.append(item) continue low, high = item["bounds"]["min"], item["bounds"]["max"] entry = dict(item) - entry["bounds"] = {"min": (box["min_x"], box["min_y"], low[2]), - "max": (box["max_x"], box["max_y"], high[2])} - entry["dimensions"] = dict(item["dimensions"], - x=round(box["width"], 3), y=round(box["depth"], 3)) + # X, Y AND Z come from the parts that print: a tall modifier, negative volume or support + # blocker is an instruction to the slicer, not height the print has. + z_low, z_high = box.get("min_z", low[2]), box.get("max_z", high[2]) + entry["bounds"] = {"min": (box["min_x"], box["min_y"], z_low), + "max": (box["max_x"], box["max_y"], z_high)} + entry["dimensions"] = dict(item["dimensions"], x=round(box["width"], 3), + y=round(box["depth"], 3), z=round(z_high - z_low, 3)) out.append(entry) return out +def placed_instances(path: str, measured: tuple | None = None) -> dict: + """Where each build item sits, with the plate it belongs to: PLACED bounds. + + This is the placed counterpart of a size. It answers "where is each instance" and + "which plate is it on", never "is the object small enough" (that is a size, per + object, from ``geometry.object_sizes``). Bounds are per instance in millimetres, + measured from the parts that print. ``measured`` may carry ``geometry.measure_items`` output + already read. ``not_judged`` counts the build items that could not be found or placed on a plate; + they are not in ``instances`` and are never counted as fitting. When the project has more than one plate the + plates sit on one shared grid whose spacing the file does not record, so no extent + is combined across them: ``plate_extents`` gives each plate its own, and + ``combined_extent_mm`` is None. Read-only, never raises. + """ + from . import geometry + + try: + tm = ThreeMF.open(path) + except Exception: + return {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "reason": "Studio could not open this file as a 3MF project."} + raw_items, geometry_unresolved = measured if measured is not None else geometry.measure_items(path) + items = _printable_only(path, raw_items) + if not items: + return {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "not_judged": len(geometry_unresolved), + "reason": "Studio could not read where the objects sit in this project."} + + plates = _plates_from_model_settings(tm) + numbers = sorted({p["ui_number"] for p in plates if p.get("ui_number") is not None}) + plate_count = max(len(numbers), 1) + grouped, plate_unresolved = _group_items_by_plate(items, plates) if plates else ({}, []) + not_judged = len(geometry_unresolved) + (len(plate_unresolved) if plate_count > 1 else 0) + plate_of = {i.get("item_index"): number for number, members in grouped.items() for i in members} + + totals: dict[tuple, int] = {} + for item in items: + who = (item.get("part"), str(item["object_id"])) + totals[who] = totals.get(who, 0) + 1 + seen: dict[tuple, int] = {} + instances = [] + for item in items: + oid = (item.get("part"), str(item["object_id"])) + ordinal = seen.get(oid, 0) + seen[oid] = ordinal + 1 + lo, hi = item["bounds"]["min"], item["bounds"]["max"] + instances.append({ + "item_index": item.get("item_index"), + "object_id": item["object_id"], + "instance_index": ordinal, + "instance_count": totals[oid], + "plate": plate_of.get(item.get("item_index")), + "bounds_mm": {"min": [round(v, 2) for v in lo], "max": [round(v, 2) for v in hi]}, + "dimensions_mm": dict(item["dimensions"]), + }) + + def extent(members: list[dict]) -> dict: + cluster = _cluster_bounds(members) + return {"x": round(cluster["max_x"] - cluster["min_x"], 1), + "y": round(cluster["max_y"] - cluster["min_y"], 1)} + + plate_extents = [dict(plate=number, instances=len(members), **extent(members)) + for number, members in sorted(grouped.items())] + return {"available": True, "basis": "placed_bounds", "instances": instances, + "plate_count": plate_count, "plate_extents": plate_extents, + "combined_extent_mm": extent(items) if plate_count <= 1 else None, + "not_judged": not_judged, "reason": None} + + def _unavailable(reason: str) -> dict: return {"schema_version": SCHEMA_VERSION, "available": False, "reason": reason, "items": [], "off_plate": [], "fixable": False} @@ -226,24 +382,58 @@ def _unavailable(reason: str) -> dict: # object at X=900 on plate 3 is not "off the plate", and Studio no longer says it # is. # -# Moving them is Snapmaker Orca's Arrange, and Studio says so. +# Studio says it cannot place them and points the user to Snapmaker Orca. MULTI_PLATE_REFUSAL = ( "Studio does not reposition multi-plate projects. The spacing between plates " - "is not recorded in the file, so any move would be a guess — open the project " - "in Snapmaker Orca and use Arrange." + "is not recorded in the file, so any move would be a guess — review the plates " + "in Snapmaker Orca." +) + +# What Studio can say about a multi-plate project when every plate fits on its own. +# It is deliberately not "every object is inside": the plates sit on one grid that +# Studio neither moves nor checks, and Snapmaker Orca can list an object as Outside. +MULTI_PLATE_UNVERIFIED = ( + "Each plate fits on its own, but Studio cannot check where the plates sit. In our " + "test with a two-plate project made for a larger-bed printer, Snapmaker Orca listed " + "one object as Outside and would not slice until it was moved by hand; " + "Arrange all plates did not fix that in our test. " + "Review the project in Snapmaker Orca before slicing." ) +_MODEL_INSTANCE_RE = re.compile(r"(.*?)", re.S) +_METADATA_RE = re.compile(r'key="([^"]*)"\s+value="([^"]*)"') + + def _plates_from_model_settings(tm: ThreeMF) -> list[dict]: - """UI plate number -> the object ids on it, from the project's own records.""" + """UI plate number -> the objects on it, from the project's own records. + + `object_ids` is every object named; `members` is the same list as + ``(object id, instance id or None)`` so a repeated object can be placed per instance. + """ part = "Metadata/model_settings.config" if not tm.has_part(part): return [] try: from .plate_remap import _parse_plates - return _parse_plates(tm.read_part(part).decode("utf-8", "ignore")) + text = tm.read_part(part).decode("utf-8", "ignore") + plates = _parse_plates(text) + bodies = re.findall(r"(.*?)", text, re.S) + # `_parse_plates` sorts by plate number; pair each body with its plate by number. + by_number: dict = {} + for body in bodies: + number = re.search(r'key="plater_id"\s+value="(\d+)"', body) + members = [] + for instance in _MODEL_INSTANCE_RE.findall(body): + meta = dict(_METADATA_RE.findall(instance)) + if meta.get("object_id") is not None: + members.append((meta["object_id"], meta.get("instance_id"))) + by_number.setdefault(int(number.group(1)) if number else None, []).extend(members) + for plate in plates: + plate["members"] = by_number.get(plate["ui_number"], []) + return plates except Exception: return [] @@ -251,21 +441,47 @@ def _plates_from_model_settings(tm: ThreeMF) -> list[dict]: def _group_items_by_plate(items: list[dict], plates: list[dict]): """Split build items across plates. Returns (grouped, unresolved). - An item whose object is on no plate record is 'unresolved': Studio cannot say - which plate it belongs to, and therefore cannot judge it. + Judged per instance: the n-th build item that uses an object is matched to the plate + record that names that instance. A record that names an object without saying which + instance can only place it when the object is used once. An item that no record (or + more than one plate) claims is 'unresolved': Studio cannot say which plate it is on, + and therefore cannot judge it. """ - owner: dict[str, int] = {} - for plate in plates: - for oid in plate.get("object_ids") or []: - owner[str(oid)] = plate["ui_number"] + totals: dict[str, int] = {} + for item in items: + totals[str(item["object_id"])] = totals.get(str(item["object_id"]), 0) + 1 + parts_of: dict[str, set] = {} + for item in items: + parts_of.setdefault(str(item["object_id"]), set()).add(item.get("part")) + seen: dict[str, int] = {} grouped: dict[int, list[dict]] = {} unresolved: list[dict] = [] for item in items: - plate_no = owner.get(str(item["object_id"])) - if plate_no is None: + oid = str(item["object_id"]) + ordinal = seen.get(oid, 0) + seen[oid] = ordinal + 1 + owners: set = set() + # plate records name a BARE object id: when build items in different parts share it, a + # record cannot prove which one it means + ambiguous = len(parts_of[oid]) > 1 + for plate in plates: + members = plate.get("members") + if members is None: # a plate described by object ids alone + members = [(str(o), None) for o in plate.get("object_ids") or []] + for member_oid, instance in members: + if str(member_oid) != oid: + continue + if instance is None: + if totals[oid] == 1: + owners.add(plate["ui_number"]) + else: + ambiguous = True + elif str(instance).isdigit() and int(instance) == ordinal: + owners.add(plate["ui_number"]) + if ambiguous or len(owners) != 1 or None in owners: unresolved.append(item) else: - grouped.setdefault(plate_no, []).append(item) + grouped.setdefault(next(iter(owners)), []).append(item) return grouped, unresolved @@ -286,6 +502,7 @@ def _plate_fit(grouped: dict[int, list[dict]], bed: dict, "width": round(width, 2), "depth": round(depth, 2), "object_ids": [i["object_id"] for i in grouped[number]], + "item_indexes": [i.get("item_index") for i in grouped[number]], "reason": None if fits else ( f"the objects on this plate span {width:.0f} × {depth:.0f} mm, which is " f"larger than {whose} {usable_x:.0f} × {usable_y:.0f} mm plate"), @@ -293,7 +510,8 @@ def _plate_fit(grouped: dict[int, list[dict]], bed: dict, return out -def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> dict: +def assess(path: str, bed: dict | None = None, bed_name: str | None = None, + height_mm: float | None = None) -> dict: """Where every object sits relative to the bed. Read-only, never raises. `bed` is the printer's real printable rectangle when one has been read from a @@ -316,8 +534,8 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d traits = project_traits.extract(path) plate_count = (traits.get("plate_count") or {}).get("value") or 1 - items = geometry.build_item_dims(path) - items = _printable_only(path, items) + measured, geometry_unresolved = geometry.measure_items(path) + items = _printable_only(path, measured) if not items: return _unavailable( "Studio could not read where the objects sit in this project, so it " @@ -326,18 +544,33 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d source_bed = _source_bed(tm) multi_plate = bool(plate_count and plate_count > 1) - unresolved: list[dict] = [] + # Instances Studio cannot judge are listed, counted and never treated as harmless: a build item + # whose object is not where the file says it is, or that no plate record places. + unresolved: list[dict] = [dict(entry, reason="Studio could not find this item's object: " + + entry["reason"]) for entry in geometry_unresolved] grouped: dict[int, list[dict]] = {} plate_fit: list[dict] = [] if multi_plate: plates = _plates_from_model_settings(tm) grouped, unresolved_items = _group_items_by_plate(items, plates) - unresolved = [{"object_id": i["object_id"]} for i in unresolved_items] + unresolved += [{"object_id": i["object_id"], "item_index": i.get("item_index"), + "reason": "no plate record places this instance"} + for i in unresolved_items] plate_fit = _plate_fit(grouped, target, whose) + not_judged = len(unresolved) + # instance numbers count uses of the SAME object: an id is only unique within its part + totals: dict[tuple, int] = {} + for item in items: + who = (item.get("part"), str(item["object_id"])) + totals[who] = totals.get(who, 0) + 1 + seen: dict[tuple, int] = {} reported = [] for item in items: lo, hi = item["bounds"]["min"], item["bounds"]["max"] + oid = (item.get("part"), str(item["object_id"])) + instance_index = seen.get(oid, 0) + seen[oid] = instance_index + 1 if multi_plate: # A plate's absolute coordinates on a multi-plate grid are an artefact # of the authoring slicer, not a fault. Judge the plate's *size*. @@ -348,6 +581,10 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d off = any(mm > 0 for mm in over.values()) reported.append({ "object_id": item["object_id"], + "item_index": item.get("item_index"), + "instance_index": instance_index, + "instance_count": totals[oid], + "bounds_mm": {"min": [round(v, 4) for v in lo], "max": [round(v, 4) for v in hi]}, "dimensions": item["dimensions"], "position": {"x": round((lo[0] + hi[0]) / 2.0, 2), "y": round((lo[1] + hi[1]) / 2.0, 2)}, @@ -358,9 +595,9 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d oversized_plates = [p for p in plate_fit if not p["fits"]] if multi_plate: - oversized_ids = {oid for p in oversized_plates for oid in p["object_ids"]} + oversized_items = {ix for p in oversized_plates for ix in p["item_indexes"]} for row in reported: - if row["object_id"] in oversized_ids: + if row["item_index"] in oversized_items: row["off_plate"] = True off_plate = [r for r in reported if r["off_plate"]] @@ -375,42 +612,72 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d # Studio never repositions a multi-plate project: the plate spacing is not in # the file, so any move would be a guess. - fixable = (not multi_plate) and bool(off_plate) and would_fit + # Nor is a move offered while some instance could not be judged: the move would carry it + # somewhere nobody has checked. + fixable = (not multi_plate) and bool(off_plate) and would_fit and not not_judged + + # A repeated object is one object at several places; the sentence counts the places, so the + # instance that is off the plate is never averaged away by the one that is not. + repeated = any(r["instance_count"] > 1 for r in reported) + k = len(off_plate) + if repeated: + count_text = f"{k} of {len(reported)} placed instances ({_instance_list(off_plate)})" + else: + count_text = f"{k} object" + ("" if k == 1 else "s") + fall = "falls" if k == 1 else "fall" + unjudged = "" + if not_judged: + unjudged = (f" {not_judged} placed instance{'s' if not_judged != 1 else ''} could not be " + "judged, so Studio cannot say whether everything is on the plate.") if multi_plate and not off_plate: - summary = (f"All {len(plate_fit)} plates fit {whose} printable area. Studio does " - "not reposition multi-plate projects — open the project in Snapmaker " - "Orca to arrange the plates.") + if plate_fit: + summary = (MULTI_PLATE_UNVERIFIED if not not_judged else + f"None of the plates Studio could assign is too big for {whose} printable area, " + "and Studio cannot check where the plates sit.") + else: + summary = "" + summary = (summary + unjudged).strip() elif multi_plate and oversized_plates: names = ", ".join(str(p["plate"]) for p in oversized_plates) summary = (f"Plate {names} does not fit {whose} printable area: " - f"{oversized_plates[0]['reason']}. Scale it down or split it. " - + MULTI_PLATE_REFUSAL) + f"{oversized_plates[0]['reason']}. Scale it down or split it." + + unjudged + " " + MULTI_PLATE_REFUSAL) elif multi_plate: - summary = MULTI_PLATE_REFUSAL + summary = (MULTI_PLATE_REFUSAL + unjudged).strip() elif not off_plate: - summary = (f"Every object sits inside {whose} printable area." - if len(reported) > 1 else - f"The object sits inside {whose} printable area.") + if not_judged: + summary = f"Every placed instance Studio could measure sits inside {whose} printable area.{unjudged}" + else: + summary = (f"Every placed instance sits inside {whose} printable area." + if repeated else + f"Every object sits inside {whose} printable area." + if len(reported) > 1 else + f"The object sits inside {whose} printable area.") elif too_wide: - summary = (f"{len(off_plate)} object(s) fall outside {whose} plate, and the " + summary = (f"{count_text} {fall} outside {whose} plate, and the " "whole arrangement is wider than the plate — moving it cannot fix " - "this. Scale it down or split it across plates.") - elif would_fit: - summary = (f"{len(off_plate)} object(s) fall outside {whose} plate, but the " + "this. Scale it down or split it across plates." + unjudged) + elif would_fit and not not_judged: + summary = (f"{count_text} {fall} outside {whose} plate, but the " "whole arrangement fits — moving it as one piece brings everything " "back on, keeping the creator's layout, rotation and scale.") else: - summary = (f"{len(off_plate)} object(s) fall outside {whose} plate and one " - "move will not fix it. Open it in Snapmaker Orca and use Arrange.") + summary = (f"{count_text} {fall} outside {whose} plate and one " + "move will not fix it. Review the project in Snapmaker Orca before slicing." + unjudged) return { "schema_version": SCHEMA_VERSION, "available": True, "bed": target, + "bed_name": whose, + "bed_height_mm": height_mm if height_mm is not None else (u1_printable_height() if not bed else None), "source_bed": source_bed, "source_printer": (traits.get("target_printer") or {}).get("value"), "plate_count": plate_count, + "prime_tower": _prime_tower(tm, plate_count, target, whose), + # False whenever positions across plates are not established by Studio. + "placement_established": not multi_plate, "item_count": len(reported), "items": reported, "off_plate": off_plate, @@ -419,6 +686,7 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d "plate_fit": plate_fit, "oversized_plates": oversized_plates, "unresolved_objects": unresolved, + "not_judged": not_judged, "fixable": fixable, "summary": summary, } @@ -430,8 +698,9 @@ def _shift_transform(value: str, dx: float, dy: float) -> str | None: """Add (dx, dy) to a 3MF transform's translation. None if not a 3x4 matrix. 3MF stores the matrix row-major with the translation as the final row, so - only entries 10 and 11 (1-indexed 12-value form) move. Everything else — - rotation, scale, shear — is copied through unchanged. + only entries 10 and 11 (1-indexed 12-value form) move. Every other token — + rotation, scale, shear, the Z translation — is copied through VERBATIM, not + re-formatted, so a rotation such as 0.7071067812 is not rounded. """ parts = value.split() if len(parts) != 12: @@ -440,9 +709,15 @@ def _shift_transform(value: str, dx: float, dy: float) -> str | None: nums = [float(p) for p in parts] except ValueError: return None - nums[9] += dx - nums[10] += dy - return " ".join(f"{n:.6g}" for n in nums) + parts[9] = _written(nums[9] + dx) + parts[10] = _written(nums[10] + dy) + return " ".join(parts) + + +def _written(value: float) -> str: + """A translation to a micron, without trailing zeros.""" + text = format(round(value, 6), ".6f").rstrip("0").rstrip(".") + return "0" if text in ("", "-0") else text def _identity_shifted(dx: float, dy: float) -> str: @@ -490,6 +765,45 @@ def fix_item(match: re.Match) -> str: return out.encode("utf-8"), moved +_COMMENT_RE = re.compile(r"", re.S) + + +def rewrite_items_by_index(raw: bytes, offsets: dict) -> tuple[bytes, int]: + """Translate build items by ordinal: ``offsets`` maps an item's index among the root model's ```` + elements to ``(dx, dy)`` in the root model's own unit. + + Like ``_rewrite_items`` it is byte-surgical (only the translation tokens of the matched tags change, every + other token is copied verbatim), but it addresses an INSTANCE, not an object id, so two uses of one object + on different plates move independently. Commented-out tags are not items and are skipped.""" + text = raw.decode("utf-8", "strict") + spans = [(m.start(), m.end()) for m in _COMMENT_RE.finditer(text)] + out, last, ordinal, moved = [], 0, 0, 0 + for match in _ITEM_TAG_RE.finditer(text): + if any(start <= match.start() < end for start, end in spans): + continue + index, ordinal = ordinal, ordinal + 1 + offset = offsets.get(index) + if offset is None: + continue + tag = match.group(0) + dx, dy = offset + attr = _TRANSFORM_ATTR_RE.search(tag) + if attr is None: + insert = f' transform="{_identity_shifted(dx, dy)}"' + new = tag[:-2] + insert + "/>" if tag.endswith("/>") else tag[:-1] + insert + ">" + else: + shifted = _shift_transform(attr.group(2), dx, dy) + if shifted is None: + continue + new = tag[:attr.start()] + attr.group(1) + shifted + attr.group(3) + tag[attr.end():] + out.append(text[last:match.start()]) + out.append(new) + last = match.end() + moved += 1 + out.append(text[last:]) + return "".join(out).encode("utf-8"), moved + + def _uniform_offset(dx: float, dy: float): return lambda _oid: (dx, dy) @@ -504,6 +818,15 @@ def _unique_output(src: Path, out_dir: Path | None) -> Path: return out +#: The numbers a build item carries are written to six significant digits, so two items moved by the +#: same amount can differ by a few thousandths of a millimetre. Anything within this is "the same". +MOVE_TOLERANCE_MM = 0.01 + + +def _without_transform(attributes: dict) -> dict: + return {key: value for key, value in attributes.items() if key != "transform"} + + def verify_only_placement_moved(source: str, moved: str) -> dict: """Prove the copy differs from the original in where the objects sit, and in nothing else. @@ -514,7 +837,7 @@ def verify_only_placement_moved(source: str, moved: str) -> dict: """ import zipfile - from . import multipart, painted_color, placement + from . import geometry, multipart, painted_color, placement checks: list[dict] = [] @@ -538,27 +861,43 @@ def check(name: str, ok: bool, detail: str = "") -> bool: check("the root model's geometry and components are unchanged", strip(root_before) == strip(root_after)) - items_before = dict(placement._BUILD_ITEM.findall(root_before)) - items_after = dict(placement._BUILD_ITEM.findall(root_after)) - check("every object is still placed", set(items_before) == set(items_after)) - - deltas = set() - same_basis = True - for object_id, text in items_before.items(): - one = placement.parse_transform(text) - two = placement.parse_transform(items_after.get(object_id)) + # Per build item, in order: an object used by two items is two items to compare. Everything + # about an item except where it sits (its object, its p:path, its printable flag, anything + # else it carries) must be unchanged, and its turn and scale with it. + items_before = geometry.build_items(root_before) + items_after = geometry.build_items(root_after) + same = len(items_before) == len(items_after) and all( + _without_transform(one["attributes"]) == _without_transform(two["attributes"]) + for one, two in zip(items_before, items_after)) + check("every build item is still there, unchanged apart from where it sits", same, + f"{len(items_before)} item(s) before, {len(items_after)} after") + + root_scale = _units.mm_per_unit(root_before) + deltas = [] + same_basis = len(items_before) == len(items_after) + for before_item, after_item in zip(items_before, items_after): + # an item with no transform is at the identity + one = (placement.parse_transform(before_item["transform"]) + if before_item["transform"] else placement.IDENTITY) + two = (placement.parse_transform(after_item["transform"]) + if after_item["transform"] else placement.IDENTITY) if one is None or two is None: same_basis = False break if one[0] != two[0] or one[1] != two[1] or one[2] != two[2]: same_basis = False # a rotation or a scale crept in break - deltas.add((round(two[3][0] - one[3][0], 4), round(two[3][1] - one[3][1], 4), - round(two[3][2] - one[3][2], 4))) + deltas.append(tuple((two[3][axis] - one[3][axis]) * root_scale for axis in range(3))) check("nothing was rotated or rescaled", same_basis) + # What "moved" means: every item's translation changed by the SAME non-zero amount in X and Y + # (to within what the written numbers can carry) and not at all in Z. A copy that moved one + # instance, or none, is not a moved copy. + uniform = bool(deltas) and all( + all(abs(d[axis] - deltas[0][axis]) <= MOVE_TOLERANCE_MM for axis in range(3)) for d in deltas) + moved_xy = bool(deltas) and (abs(deltas[0][0]) > MOVE_TOLERANCE_MM or abs(deltas[0][1]) > MOVE_TOLERANCE_MM) check("every object moved by the same amount, in X and Y only", - len(deltas) == 1 and abs(next(iter(deltas))[2]) <= 1e-6 if deltas else False, - str(sorted(deltas))) + uniform and moved_xy and abs(deltas[0][2]) <= MOVE_TOLERANCE_MM, + str([tuple(round(v, 4) for v in d) for d in deltas])) structure = multipart.validate_archive(ThreeMF.open(moved)) check("the moved copy still describes itself consistently", @@ -570,7 +909,9 @@ def check(name: str, ok: bool, detail: str = "") -> bool: one_paint.get("painted_triangle_count") == two_paint.get("painted_triangle_count") and one_paint.get("slots_referenced") == two_paint.get("slots_referenced")) - return {"passed": all(entry["pass"] for entry in checks), "checks": checks} + return {"passed": all(entry["pass"] for entry in checks), "checks": checks, + "delta_mm": ({"x": round(deltas[0][0], 4), "y": round(deltas[0][1], 4)} + if deltas and uniform else None)} def prepare_placed_copy(path: str, out_dir: str | None = None, @@ -606,7 +947,11 @@ def prepare_placed_copy(path: str, out_dir: str | None = None, "before": before} offset = before["suggested_offset"] - offset_for = _uniform_offset(offset["x"], offset["y"]) + # The geometry above is measured in millimetres, but a build item's translation is written in the + # ROOT model's own unit. Convert the offset back before it is added, or an inch project would be + # shifted by 25.4 times too little (and the move would silently not land). + root_scale = _units.mm_per_unit(tm.read_part(ROOT_MODEL)) + offset_for = _uniform_offset(offset["x"] / root_scale, offset["y"] / root_scale) try: rewritten, moved = _rewrite_items(tm.read_part(ROOT_MODEL), offset_for) @@ -631,6 +976,13 @@ def prepare_placed_copy(path: str, out_dir: str | None = None, # actually written — not against what the code intended to write. after = assess(str(out), bed=bed) proof = verify_only_placement_moved(str(src), str(out)) + delta = proof.get("delta_mm") + if delta is not None: + # the copy must have moved by what was asked, not merely by something uniform + asked = abs(delta["x"] - offset["x"]) <= MOVE_TOLERANCE_MM and abs(delta["y"] - offset["y"]) <= MOVE_TOLERANCE_MM + proof["checks"].append({"check": "the move is the one that was asked for", "pass": asked, + "detail": f"moved {delta}, asked {offset}"}) + proof["passed"] = proof["passed"] and asked if not proof["passed"]: out.unlink(missing_ok=True) return { @@ -643,14 +995,13 @@ def prepare_placed_copy(path: str, out_dir: str | None = None, } ok = bool(after.get("available")) and not after.get("off_plate") if not ok: - # The copy is left in place so a user can inspect it, but the result says - # plainly that the move did not achieve what it was supposed to. + # A copy that did not achieve what it was supposed to is not kept on disk: the + # result says plainly that the move did not work and names no file. + out.unlink(missing_ok=True) return { "schema_version": SCHEMA_VERSION, "ok": False, - "reason": ("Studio moved the arrangement but the copy still has objects " - "off the plate, so it is not safe to rely on. Open the original " - "in Snapmaker Orca and use Arrange."), - "output_path": str(out), "output_name": out.name, + "reason": ("Studio could not move the objects onto the plate, so it did not " + "keep a copy. Review the original in Snapmaker Orca before slicing."), "before": before, "after": after, } diff --git a/backend/snapstudio_core/preflight.py b/backend/snapstudio_core/preflight.py index bd6264da..28e42c0d 100644 --- a/backend/snapstudio_core/preflight.py +++ b/backend/snapstudio_core/preflight.py @@ -354,35 +354,117 @@ def _bed(project: dict, printer: dict, placement: dict | None) -> dict: f"{_fallback_name()} instead."), action="Connect the printer to check against its real bed.", source="Klipper toolhead axis limits") - size = f"{bed.get('x')} × {bed.get('y')} × {bed.get('z')} mm" + # The printer's reported bed is the toolhead's AXIS TRAVEL (about 271 x 335 on a U1), which is not + # a printable area. Placement is judged against the printable area of the machine Studio prepares + # for, and the evidence names that area, not the travel figure. + plate = placement.get("bed") if placement else None + owner = (placement.get("bed_name") if placement else None) or "the printer's" + area = (f"{plate['max_x'] - plate['min_x']:g} × {plate['max_y'] - plate['min_y']:g} mm printable area" + if plate else "printable area") if not placement or not placement.get("available"): return _check( "bed.fit", "Fits the printer's bed", UNKNOWN, - evidence=f"printer bed is {size}; Studio could not read where the objects sit", + evidence=("Studio could not read where the objects sit, or does not know this printer's " + "printable area (a printer reports its axis travel, which is not a printable area)"), confidence=INFORMATIONAL, consequence="Studio cannot confirm the objects land on this printer's plate.", action="Open the project in the slicer to see the plate.", - source="Klipper toolhead axis limits") + source="project geometry vs the printer's printable area") off = placement.get("off_plate") or [] - if not off: + height = placement.get("bed_height_mm") # the printable height, when it is known + from .plate_placement import below_plate, over_height + + tops = [(item.get("bounds_mm") or {}).get("max") for item in placement.get("items") or []] + tops = [t[2] for t in tops if t and len(t) > 2] + tall = [row["bounds_mm"]["max"][2] for row in over_height(placement)] + if not off and placement.get("not_judged"): + n = placement["not_judged"] return _check( - "bed.fit", "Fits the printer's bed", OK, - evidence=f"every object sits inside this printer's {size} area", + "bed.fit", "Fits the printer's bed", UNKNOWN, + evidence=(f"{n} placed instance{'s' if n != 1 else ''} could not be judged, so Studio " + "cannot say whether everything is on the plate"), + confidence=INFORMATIONAL, + consequence="Studio cannot confirm the objects land on this printer's plate.", + action="Review the project in Snapmaker Orca before slicing.", + source=f"project geometry vs {owner} printable area") + if not off and tall: + return _check( + "bed.fit", "Objects are taller than this printer can print", ATTENTION, + evidence=(f"{len(tall)} placed instance{'s' if len(tall) != 1 else ''} stand up to " + f"{max(tall):g} mm, above the {height:g} mm height limit"), confidence=CONFIRMED, - consequence="Nothing is placed off the plate.", + consequence="The slicer will refuse to slice it, or the print will not fit.", + action="Scale the model down or split it in Snapmaker Orca.", + source=f"project geometry vs {owner} printable height") + sunk = below_plate(placement) + if not off and sunk: + low = min(row["bounds_mm"]["min"][2] for row in sunk) + return _check( + "bed.fit", "Objects reach below the build plate", ATTENTION, + evidence=(f"{len(sunk)} placed instance{'s' if len(sunk) != 1 else ''} reach down to " + f"{low:g} mm, below the build plate"), + confidence=LIKELY, + consequence="A slicer prints what is above the plate; Studio cannot confirm this prints as modelled.", + action="Check the height of these objects in Snapmaker Orca.", + source="project geometry vs the build plane") + multi_plate = (placement.get("plate_count") or 1) > 1 + if not off and multi_plate: + # Each plate may fit, but where the plates sit on the slicer's grid is not + # something Studio establishes. Not OK, and not an error either: unknown. + return _check( + "bed.fit", "Fits the printer's bed", UNKNOWN, + evidence=(f"printable area is {area}; the project has {placement.get('plate_count')} " + "plates and each fits on its own, but Studio does not check where the " + "plates sit"), + confidence=INFORMATIONAL, + consequence=("Snapmaker Orca can list an object as Outside even when its plate " + "fits, so Studio cannot confirm the objects land on this printer's plates."), + action=("Review the project in Snapmaker Orca before slicing; an object may be " + "listed as Outside."), source="project geometry vs printer bed") + if not off: + if tops and height: + return _check( + "bed.fit", "Fits the printer's bed", OK, + evidence=f"every placed instance sits inside the {area} and under the " + f"{height:g} mm height limit", + confidence=CONFIRMED, + consequence="Nothing is placed off the plate or above its height.", + source=f"project geometry vs {owner} printable area and height") + return _check( + "bed.fit", "Fits the printer's bed", OK, + evidence=f"every placed instance sits inside the {area} (X and Y only; " + "height was not checked)", + confidence=INFORMATIONAL, + consequence="Nothing is placed off the plate sideways.", + source=f"project geometry vs {owner} printable area") return _check( "bed.fit", "Objects sit outside this printer's bed", ATTENTION, - evidence=f"{len(off)} object(s) fall outside this printer's {size} area", + evidence=f"{len(off)} object(s) fall outside this printer's {area}", confidence=CONFIRMED, consequence=("The slicer will refuse to slice, or the print will start off the " "plate."), action=("Use Studio's object-placement fix to move them on — it saves a copy and " "leaves your original alone." if placement.get("fixable") - else "Open the project in Snapmaker Orca and use Arrange."), + else "Review the project in Snapmaker Orca before slicing; Studio could not " + "bring every object onto the plate."), source="project geometry vs printer bed") +def _prime_tower(placement: dict | None) -> dict | None: + """Only reported when the project's own settings put the tower outside the area.""" + tower = (placement or {}).get("prime_tower") or {} + if not tower.get("outside"): + return None + return _check( + "prime_tower.fit", "Prime tower sits outside the printable area", ATTENTION, + evidence=tower.get("message"), confidence=CONFIRMED, + consequence=("Snapmaker Orca can refuse to slice a plate whose prime tower reaches " + "past the printable area."), + action="Move the prime tower in Snapmaker Orca before slicing.", + source="project settings vs printable area") + + def _object_exclusion(project: dict, printer: dict) -> dict: """Only reported when the project actually depends on it.""" objects = printer.get("klipper_objects") or [] @@ -508,6 +590,7 @@ def evaluate(project: dict, printer: dict, placement: dict | None = None) -> dic checks = [ _printer_found(printer), _bed(project, printer, placement), + _prime_tower(placement), _toolheads_vs_filaments(project, printer), _nozzle(project, printer), _nozzle_conflict(printer), diff --git a/backend/snapstudio_core/prime_tower.py b/backend/snapstudio_core/prime_tower.py new file mode 100644 index 00000000..5892fdd5 --- /dev/null +++ b/backend/snapstudio_core/prime_tower.py @@ -0,0 +1,153 @@ +"""Where the prime (wipe) tower sits, judged against the printable area. + +Detection only. Studio never moves the tower. A project made for a printer with a +larger bed keeps that bed's tower position, and Snapmaker Orca can refuse to slice a +plate whose tower reaches past the U1's printable area ("a G-code path goes beyond +plate boundaries"). Whether it does is computed here from the project's own +settings, never asserted. + +This module is the single place the tower is judged: ``regrid`` (Prepare) and the +placement check both call ``assess``, so they cannot disagree. + +What the file proves, and nothing more +-------------------------------------- +``wipe_tower_x`` / ``wipe_tower_y`` are plate-local lists; a plate with no entry of its +own uses the FIRST entry (Orca's ``get_at``), never the last. They are the tower's +start corner, which is part of its footprint. So: + +* ``outside_proven``: the start corner itself lies outside the printable area. +* ``unknown``: everything else. The tower's depth comes from the tool changes and + flush volumes of the sliced print; its wall type (rectangle, cone, rib), brim and + ``wipe_tower_rotation_angle`` all change the footprint, and none of that is in the + file. ``prime_tower_width`` is NOT used as an extent and no depth is ever inferred + from it: the width is a nominal setting whose meaning differs by wall type. + +A tower is never proven *inside*, never "clear" of objects, and its clearances are +never estimated. A rotated tower is not judged at all. Unresolved cases need review +in Snapmaker Orca. +""" +from __future__ import annotations + +OUTSIDE = "outside_proven" +UNKNOWN = "unknown" + + +def _num(value, default=None): + if isinstance(value, (list, tuple)): + value = value[0] if value else None + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return default + + +def _off(value) -> bool: + if isinstance(value, (list, tuple)): + value = value[0] if value else None + return str(value).strip().lower() in ("0", "false") + + +def _as_list(value) -> list: + if isinstance(value, list): + return value + return [] if value in (None, "") else [value] + + +def _entry(values, plate: int): + """The plate's own entry, else the first one (Orca's ``get_at``).""" + values = _as_list(values) + if not values: + return None + return _num(values[plate] if plate < len(values) else values[0]) + + +def enabled(cfg: dict) -> bool: + """False only when the project switches the tower off. An absent key is not "off".""" + return not _off((cfg or {}).get("enable_prime_tower")) + + +def _stated_on(cfg: dict) -> bool: + """The project explicitly says the tower is on (a missing key proves nothing).""" + value = (cfg or {}).get("enable_prime_tower") + if isinstance(value, (list, tuple)): + value = value[0] if value else None + if value is None or str(value).strip().lower() in ("", "none", "nil"): + return False + return enabled(cfg) + + +def assess(cfg: dict, plate_count: int, bed: dict) -> list[dict]: + """One record per plate: ``plate`` (1-based), ``x``, ``y``, ``status``, ``reason``. + + Empty when there is no enabled tower or the project states no position at all. + """ + cfg = cfg or {} + if not enabled(cfg): + return [] + xs, ys = _as_list(cfg.get("wipe_tower_x")), _as_list(cfg.get("wipe_tower_y")) + if not xs or not ys: + return [] + rotated = (_num(cfg.get("wipe_tower_rotation_angle"), 0.0) or 0.0) != 0.0 + out = [] + for i in range(max(int(plate_count or 1), 1)): + x, y = _entry(xs, i), _entry(ys, i) + record = {"plate": i + 1, "x": x, "y": y, "status": UNKNOWN, "reason": None} + if x is None or y is None: + record["reason"] = "the project states no usable tower position for this plate" + elif rotated: + record["reason"] = "the tower is rotated, so its corners are not known from the file" + elif not _stated_on(cfg): + record["reason"] = "the project does not state that the prime tower is enabled" + elif x < bed["min_x"] or x > bed["max_x"] or y < bed["min_y"] or y > bed["max_y"]: + record.update(status=OUTSIDE, + reason="its start corner already lies outside the printable area") + else: + record["reason"] = ("how far the tower reaches is decided when Snapmaker Orca " + "plans the tool changes, so it is not known whether it fits") + out.append(record) + return out + + +def findings(cfg: dict, plate_count: int, bed: dict) -> list[dict]: + """Towers (one per plate) proven to start outside `bed`.""" + return [r for r in assess(cfg, plate_count, bed) if r["status"] == OUTSIDE] + + +def message(found: list[dict], bed: dict, whose: str = "the U1's") -> str | None: + if not found: + return None + plates = ", ".join(str(f["plate"]) for f in found) + first = found[0] + label = f"plate {plates}" if len(found) == 1 else f"plates {plates}" + return (f"The prime tower ({label}) sits outside {whose} printable area: it starts at " + f"X {first['x']:g}, Y {first['y']:g}, while the area is X {bed['min_x']:g} to " + f"{bed['max_x']:g}, Y {bed['min_y']:g} to {bed['max_y']:g} mm. Snapmaker Orca can " + "refuse to slice that plate until the tower is moved. Studio left it where it is; " + "review it in Snapmaker Orca.") + + +UNVERIFIED_NOTE = ( + "Studio could not verify how far the prime tower reaches: its depth, wall type, brim and rotation " + "are only known when Snapmaker Orca slices, and Orca only builds a tower when the print needs one. " + "If it builds one at the position stated in this project, Orca can refuse paths that leave the plate " + "(\"A G-code path goes beyond plate boundaries\"). Check the tower in Orca's preview, and adjust its " + "position or size there if needed. Studio does not claim any tower position is safe." +) + +# One wording for the Prepare summary and the placement card, so the two never disagree. +SUMMARY_NOTE = UNVERIFIED_NOTE + + +def summary_note(cfg: dict, found: list[dict]) -> str | None: + """Prepare-summary warning: a tower is stated and none was proven outside.""" + return SUMMARY_NOTE if unverified_note(cfg, found) else None + + +def unverified_note(cfg: dict, found: list[dict]) -> str | None: + """Informational: a tower exists in the settings and none was proven outside.""" + cfg = cfg or {} + if found or not enabled(cfg): + return None + if not _as_list(cfg.get("wipe_tower_x")) or not _as_list(cfg.get("wipe_tower_y")): + return None + return UNVERIFIED_NOTE diff --git a/backend/snapstudio_core/profile.py b/backend/snapstudio_core/profile.py index 66fbc028..1d2c995f 100644 --- a/backend/snapstudio_core/profile.py +++ b/backend/snapstudio_core/profile.py @@ -6,6 +6,36 @@ # never overwrite per-design / per-filament data during a profile swap _PROTECTED_PREFIXES = ("filament_colour", "filament_type", "filament_settings_id") +# Where the prime tower sits is placement, not tuning: the profile's value is a constant written for no +# particular model, so a project that states its own position keeps it (Studio never moves a tower). +_PLACEMENT_KEYS = frozenset({"wipe_tower_x", "wipe_tower_y"}) + + +# The tower's shape settings Studio writes from the starter profile. Orca resets an undeclared value to its +# own preset, so Recommended declares these (and only these) so the values it lists are the ones Orca opens with. +TOWER_SHAPE_KEYS = frozenset({"wipe_tower_wall_type", "wipe_tower_extra_spacing", + "wipe_tower_extra_rib_length", "wipe_tower_cone_angle"}) + + +def tower_shape_keys(profile: dict) -> set[str]: + """Tower-shape keys the starter profile writes, for Recommended to declare. + + Taken from the profile, not from the changed-values report: a source that already equals the starter + still needs the declaration, or Orca resets the value to its preset.""" + return TOWER_SHAPE_KEYS & set(profile["keys"]) + + +# Written from the starter, but Studio has not verified that Orca takes them from the project file. +UNVERIFIED_KEYS = {"z_hop_when_prime": ( + "Studio wrote the U1 value; whether Snapmaker Orca applies it from the project file is unverified")} + + +def kept_placement_keys(cfg: dict, profile: dict) -> set[str]: + """Placement keys the project states itself that differ from the profile's constant.""" + return {k for k in _PLACEMENT_KEYS + if cfg.get(k) not in (None, "", []) and k in profile["keys"] and cfg.get(k) != profile["keys"][k]} + + def load_profile(name: str) -> dict: return json.loads((files("snapstudio_core.data.profiles") / f"{name}.json").read_text("utf-8")) @@ -21,8 +51,10 @@ def apply_swap(cfg: dict, profile: dict, *, preserve_creator_settings: bool = Fa continue if preserve_creator_settings and k in CATEGORY_A: continue + if k in _PLACEMENT_KEYS and cfg.get(k) not in (None, "", []): + continue if cfg.get(k) != v: changes.append({"key": k, "old": cfg.get(k), "new": v, - "reason": "U1 machine profile setting applied"}) + "reason": UNVERIFIED_KEYS.get(k, "U1 machine profile setting applied")}) cfg[k] = v return changes diff --git a/backend/snapstudio_core/regrid.py b/backend/snapstudio_core/regrid.py new file mode 100644 index 00000000..9001a91d --- /dev/null +++ b/backend/snapstudio_core/regrid.py @@ -0,0 +1,283 @@ +"""Prepare-time layout correction: the plate grid and the prime tower. + +A multi-plate project from another printer carries its build items on THAT printer's plate grid. Snapmaker +Orca rebuilds plate membership from geometry when it opens a file, so an item that is not inside the U1's +cell for its plate is listed under *Outside* and the plate will not slice. The same project carries a prime +(wipe) tower position written for the source bed, which can lie outside the U1 plate. + +The plate grid is corrected here, in BOTH prepare modes (where things sit is not a creator's setting), by +translating build items. The tower is only assessed, never moved: ``profile.apply_swap`` leaves the +project's own ``wipe_tower_x/y`` in place in every mode. Nothing else changes: not a mesh, not a paint +attribute, not a part transform, not a rotation or scale token. + +The grid rule (Orca 2.4.0 ``PartPlate.cpp``; confirmed on 24 of 24 build items of the four multi-plate +benchmark projects, and by the creator's own Orca-made U1 file): + + stride_x = int(bed width) * 1.2 stride_y = int(bed depth) * 1.2 + cols = compute_colum_count(plates) origin(i) = ((i % cols) * stride_x, -(i // cols) * stride_y) + +An item's position is that origin plus its position in the bed's own frame. The rule is only used when it +can be cross-checked: every build item's printing footprint must sit inside the cell the project's own plate +record names for it. If any item fails that check, or its plate cannot be established, nothing is moved and +the condition is reported as unresolved. Studio never guesses a grid. + +The tower is NOT moved and its footprint is NOT estimated. The depth of a prime tower is decided by +Snapmaker Orca when it plans the tool changes (purge volumes, wall type, brim, rotation), so from the file +Studio can prove one thing only: a tower whose start corner already lies outside the printable area +(``outside_proven``). Everything else is ``unknown`` and needs review in Snapmaker Orca. A tower is never +reported as inside or clear, and Studio never writes a tower position of its own. The judgement is +``prime_tower.assess``. +""" +from __future__ import annotations + +import math +import re + +from . import geometry, placement, plate_placement as pp + +#: Orca's ``LOGICAL_PART_PLATE_GAP``: the stride is the plate size times (1 + gap). +PLATE_GAP = 1.0 / 5.0 + +_TOLERANCE = 1e-6 + + +# --- Orca's plate grid --------------------------------------------------------------------------- + +def column_count(plates: int) -> int: + """``compute_colum_count`` from Orca's ``PartPlate.hpp``: the grid is as square as it can be.""" + value = math.sqrt(float(plates)) + rounded = round(value) + return int(rounded + 1) if value > rounded else int(rounded) + + +def stride_of(polygon: list[tuple] | None): + """``(stride_x, stride_y, (min_x, min_y, max_x, max_y))`` of a bed polygon, or None. + + Orca keeps the plate size as an integer (``m_plate_width``/``m_plate_depth``), so the size is + truncated before the gap is applied.""" + if not polygon or len(polygon) < 3: + return None + left, front, right, back = placement.bounds_of(polygon) + width, depth = int(right - left), int(back - front) + if width <= 0 or depth <= 0: + return None + return width * (1.0 + PLATE_GAP), depth * (1.0 + PLATE_GAP), (left, front, right, back) + + +def origin(index: int, cols: int, stride: tuple) -> tuple[float, float]: + """Where plate ``index`` (0-based) starts on the grid: columns go right, rows go to NEGATIVE Y.""" + return (index % cols) * stride[0], -(index // cols) * stride[1] + + +# --- the correction ------------------------------------------------------------------------------ + +def correct(tm, work: dict, source_cfg: dict, source_path: str) -> dict: + """Re-grid the plates and place the prime tower, on ``tm`` (build items) and ``work`` (tower keys). + + Returns the report (``status``, per-plate cells and deltas, the formula check, the tower decisions, + ``setting_changes`` for the keys it wrote and ``disclosure`` for people). Never raises: whatever it + cannot establish is reported as unresolved and left as it was. + """ + report: dict = {"status": "not_applicable", "plates": [], "formula_check": None, "tower": [], + "setting_changes": [], "disclosure": [], "unresolved": []} + try: + _correct(tm, work, source_cfg, source_path, report) + except Exception as exc: # noqa: BLE001 - a prepared copy must still be produced + report["status"] = "unresolved" + report["unresolved"].append(f"Studio could not check the plate layout ({type(exc).__name__})") + report["items_moved"] = 0 + return report + + +def _correct(tm, work, source_cfg, source_path, report) -> None: + items, geometry_unresolved = geometry.measure_items(source_path) + by_index = {item["item_index"]: item for item in items} + plates = pp._plates_from_model_settings(tm) + numbers = sorted({p["ui_number"] for p in plates if p.get("ui_number") is not None}) + multi = len(numbers) > 1 + target_polygon = placement.polygon_of(work) + target = stride_of(target_polygon) + source = stride_of(placement.polygon_of(source_cfg)) + report["items_moved"] = 0 + + # which plate (index) each item sits on, from the project's own records + plate_index: dict[int, int] = {} + cols = 1 + unresolved: list[str] = list(f"item {u['item_index']}: {u['reason']}" for u in geometry_unresolved) + if multi: + if numbers != list(range(1, len(numbers) + 1)): + unresolved.append("the plate numbers are not 1..n, so the plate grid cannot be established") + grouped, loose = pp._group_items_by_plate(items, plates) + for number, members in grouped.items(): + for member in members: + plate_index[member["item_index"]] = numbers.index(number) + if loose: + unresolved.append(f"{len(loose)} build item(s) are on no plate record, or on more than one") + cols = column_count(len(numbers)) + else: + for item in items: + plate_index[item["item_index"]] = 0 + + if source is None or target is None: + unresolved.append("the source or the U1 printable area is not a rectangle Studio can read") + + # --- cross-check: the item's whole footprint is inside the cell its own record names --------- + check = {"items": 0, "matched": 0, "mismatches": []} + cells_ok = source is not None and not unresolved + if multi and source is not None: + left, front, right, back = source[2] + for index, item in by_index.items(): + if index not in plate_index: + continue + check["items"] += 1 + ox, oy = origin(plate_index[index], cols, source) + lo, hi = item["bounds"]["min"], item["bounds"]["max"] + if ox + left - _TOLERANCE <= lo[0] and hi[0] <= ox + right + _TOLERANCE \ + and oy + front - _TOLERANCE <= lo[1] and hi[1] <= oy + back + _TOLERANCE: + check["matched"] += 1 + else: + check["mismatches"].append( + {"item_index": index, "plate": plate_index[index] + 1, + "footprint": [round(lo[0], 2), round(lo[1], 2), round(hi[0], 2), round(hi[1], 2)], + "cell": [round(ox + left, 2), round(oy + front, 2), round(ox + right, 2), + round(oy + back, 2)]}) + report["formula_check"] = check + if check["mismatches"]: + unresolved.append(f"{len(check['mismatches'])} of {check['items']} build item(s) are not in " + "the plate cell their record names on the source grid") + cells_ok = False + + offsets: dict[int, tuple[float, float]] = {} + plate_report: dict[int, dict] = {} + if multi: + report["status"] = "unresolved" if unresolved else "same_grid" + if not unresolved and source is not None and target is not None: + same = abs(source[0] - target[0]) < _TOLERANCE and abs(source[1] - target[1]) < _TOLERANCE + if not same: + offsets, plate_report = _plan_regrid(by_index, plate_index, cols, source, target, work) + report["status"] = "regridded" if any( + abs(dx) > _TOLERANCE or abs(dy) > _TOLERANCE for dx, dy in offsets.values()) else "same_grid" + report["stride_source"] = [round(source[0], 4), round(source[1], 4)] if source else None + report["stride_target"] = [round(target[0], 4), round(target[1], 4)] if target else None + report["cols"] = cols + else: + report["status"] = "not_multi_plate" + report["unresolved"].extend(unresolved) + + # --- apply the item translations (translation only, per build item) --------------------------- + if offsets and report["status"] == "regridded": + _apply_offsets(tm, offsets, report) + report["plates"] = [plate_report[i] for i in sorted(plate_report)] + for index in list(offsets): + item = by_index.get(index) + if item is not None: + dx, dy = offsets[index] + item["bounds"] = { + "min": (item["bounds"]["min"][0] + dx, item["bounds"]["min"][1] + dy, item["bounds"]["min"][2]), + "max": (item["bounds"]["max"][0] + dx, item["bounds"]["max"][1] + dy, item["bounds"]["max"][2])} + + # --- the prime tower, per plate, against the footprints as they now are ---------------------- + _assess_towers(work, by_index, plate_index, multi, cols, target if target else None, report, + plates_known=cells_ok or not multi) + _disclose(report, multi) + + +def _plan_regrid(by_index, plate_index, cols, source, target, work): + """Per item: the translation that puts its plate on the U1 grid, then the plate's own minimal fit.""" + plate_items: dict[int, list[int]] = {} + for index in by_index: + if index in plate_index: + plate_items.setdefault(plate_index[index], []).append(index) + u1 = pp.u1_bed_rect() + offsets: dict[int, tuple[float, float]] = {} + plates: dict[int, dict] = {} + for idx, members in plate_items.items(): + sox, soy = origin(idx, cols, source) + tox, toy = origin(idx, cols, target) + dx, dy = tox - sox, toy - soy + # the plate's content in the TARGET cell's own frame, to see whether it needs a nudge to fit + boxes = [by_index[m]["bounds"] for m in members] + cluster = {"min_x": min(b["min"][0] for b in boxes) + dx - tox, + "min_y": min(b["min"][1] for b in boxes) + dy - toy, + "max_x": max(b["max"][0] for b in boxes) + dx - tox, + "max_y": max(b["max"][1] for b in boxes) + dy - toy} + fit = pp._centering_offset(cluster, u1) + fits = pp._fits_after(cluster, u1, fit) + for member in members: + offsets[member] = (dx + fit["x"], dy + fit["y"]) + plates[idx] = {"plate": idx + 1, "items": len(members), + "cell_source": [round(sox, 3), round(soy, 3)], + "cell_target": [round(tox, 3), round(toy, 3)], + "delta": [round(dx, 3), round(dy, 3)], + "fit_shift": [fit["x"], fit["y"]], "fits_u1_plate": bool(fits)} + return offsets, plates + + +def _apply_offsets(tm, offsets: dict, report: dict) -> None: + raw = tm.read_part(pp.ROOT_MODEL) + from . import units as _units + + scale = _units.mm_per_unit(raw) + rewritten, moved = pp.rewrite_items_by_index( + raw, {index: (dx / scale, dy / scale) for index, (dx, dy) in offsets.items()}) + before, after = geometry.build_items(raw), geometry.build_items(rewritten) + same = len(before) == len(after) and all( + {k: v for k, v in a["attributes"].items() if k != "transform"} + == {k: v for k, v in b["attributes"].items() if k != "transform"} + for a, b in zip(before, after)) + if not same or moved != len(offsets): + # the writer and the reader disagree about the build items: write nothing + report["status"] = "unresolved" + report["unresolved"].append("Studio could not rewrite the build items safely, so it left them") + offsets.clear() + return + tm.replace_part(pp.ROOT_MODEL, rewritten) + report["items_moved"] = moved + + +# --- the prime tower (assessed, never moved) ----------------------------------------------------- + +def _assess_towers(work, by_index, plate_index, multi, cols, target, report, *, plates_known: bool) -> None: + """Per plate: what the file PROVES about the prime tower, and nothing it does not. + + The judgement itself lives in ``prime_tower.assess`` (shared with the placement check), so this + module and that one cannot disagree: ``outside_proven`` only when the tower's start corner lies + outside the U1 printable area, otherwise ``unknown``. A missing per-plate value falls back to the + FIRST entry. No depth is inferred from the width, no rectangle is estimated, no clearance is claimed. + """ + from . import prime_tower + + if not prime_tower.enabled(work): + return + n_plates = (len(set(plate_index.values())) or 1) if multi else 1 + for record in prime_tower.assess(work, n_plates, pp.u1_bed_rect()): + out = {"plate": record["plate"], "position": [record["x"], record["y"]], + "status": record["status"], "reason": record["reason"]} + if multi and not plates_known: + out.update(status="unknown", reason="the plate layout could not be established") + report["tower"].append(out) + report["tower_note"] = ("Studio did not move any prime tower: its full footprint (depth, wall type, " + "brim, rotation) is only fixed when Snapmaker Orca plans the tool changes. " + "Review the tower in Snapmaker Orca.") + + +def _disclose(report: dict, multi: bool) -> None: + lines = report["disclosure"] + if report.get("status") == "regridded": + moved = [p for p in report["plates"] if abs(p["delta"][0]) > _TOLERANCE or abs(p["delta"][1]) > _TOLERANCE + or abs(p["fit_shift"][0]) > _TOLERANCE or abs(p["fit_shift"][1]) > _TOLERANCE] + for p in moved: + lines.append(f"Repositioned plate {p['plate']} objects to the U1 plate layout " + f"(X {p['delta'][0] + p['fit_shift'][0]:+g} mm, Y {p['delta'][1] + p['fit_shift'][1]:+g} mm; " + "positions inside the plate, rotation and scale are unchanged).") + for p in report["plates"]: + if not p["fits_u1_plate"]: + lines.append(f"Plate {p['plate']}'s objects are larger than the U1 plate, so they still " + "will not fit it; Snapmaker Orca will show them outside.") + for t in report["tower"]: + if t["status"] == "outside_proven": + lines.append(f"Plate {t['plate']}'s prime tower is outside the U1 printable area ({t['reason']}); " + "Studio left it where it is because it cannot establish the tower's full footprint. " + "Move it in Snapmaker Orca's prime tower settings.") + for reason in report["unresolved"]: + lines.append(f"Plate positions were left as the source had them: {reason}.") diff --git a/backend/snapstudio_core/repair.py b/backend/snapstudio_core/repair.py index bd117ce9..eee1aee2 100644 --- a/backend/snapstudio_core/repair.py +++ b/backend/snapstudio_core/repair.py @@ -2,8 +2,8 @@ import copy from .container import ThreeMF from .config_io import load_project_settings, dump_project_settings -from .rules import load_rules, apply_clamps, clamp_explanation -from .profile import load_profile, apply_swap +from .rules import load_rules, apply_clamps +from .profile import load_profile, apply_swap, kept_placement_keys, tower_shape_keys from .filaments import apply_remap, filament_count, conform_filament_arrays from .preserve import machine_compat_keys, prepare_preserved_values from .detect import detect_source @@ -22,7 +22,8 @@ def repair(tm: ThreeMF, mode: str = "u1", remap: dict | None = None, dry_run: bool = False, profile_name: str = "snapmaker_u1", opt_profile: str | None = None, confirmed_presets: dict | None = None, - filament_catalog=None, confirmed_colours: dict | None = None) -> RepairOutcome: + filament_catalog=None, confirmed_colours: dict | None = None, + source_path: str | None = None) -> RepairOutcome: assert mode in ("safe", "preserve", "u1", "optimize") remap = remap or {} source = detect_source(tm) @@ -39,7 +40,8 @@ def repair(tm: ThreeMF, mode: str = "u1", remap: dict | None = None, change["reason"] = ( orca_import.RAFT_EXPANSION_REASON if change["key"] == "raft_first_layer_expansion" else f"U1 compatibility clamp: {change['old']} → {change['new']}") - change["explanation"] = clamp_explanation(change) + # Explanation and provenance are one record. apply_clamps creates the + # complete evidence pair before this step adds the action reason. # U1 supports >4 filament colours (verified: real U1 files carry 8 colours # with 4 toolheads). Never auto-cap; preserve all filament arrays in every @@ -51,8 +53,16 @@ def repair(tm: ThreeMF, mode: str = "u1", remap: dict | None = None, profile = load_profile(profile_name) compat_keys = machine_compat_keys(profile) preserve_creator_settings = mode == "preserve" + # The project's own prime tower position survives the starter swap; Orca resets an undeclared + # value to the preset it names, so Recommended declares it (Preserve leaves the creator's + # declarations as they are). + tower_keys = set() if preserve_creator_settings else kept_placement_keys(work, profile) report["profile_changes"] = apply_swap( work, profile, preserve_creator_settings=preserve_creator_settings) + # Tower shape written from the starter profile is declared too (Recommended only), whether or not + # the source already matched it, or Orca would reset it to its preset. + if not preserve_creator_settings: + tower_keys = tower_keys | tower_shape_keys(profile) # Foreign projects often carry per-filament arrays longer than the real # colour count (e.g. 10 slots, 5 colours). Per-extruder vectors are # explicitly excluded by conform_filament_arrays and are resized later @@ -123,6 +133,26 @@ def repair(tm: ThreeMF, mode: str = "u1", remap: dict | None = None, report["orca_compatibility"] = orca_import.apply_compatibility( work, filament_count(work)) + # Where things sit is not a creator's setting, so the plate grid is corrected in both modes (see + # ``regrid``): translation of build items only. The prime tower is assessed and reported, never moved. + # It reads the original file for geometry, so it runs only when the caller says where that is. + layout = None + if mode in ("preserve", "u1", "optimize") and source_path: + from . import regrid + layout = regrid.correct(tm, work, cfg, source_path) + report["layout"] = layout + report["placement_corrections"] = layout["setting_changes"] + + # A model's own statement of where it needs support survives the starter settings (Recommended only; + # Preserve keeps the creator's declarations as they are). See ``support_intent``. + support_keys: set[str] = set() + if mode in ("u1", "optimize"): + from . import support_intent + intent = support_intent.detect(tm, cfg) + report["support_intent"] = intent + if intent["explicit"]: + support_keys = set(intent["keys"]) + # Everything above states values. This says which of them Snapmaker Orca is # not to take from the preset the project names. # @@ -147,7 +177,8 @@ def repair(tm: ThreeMF, mode: str = "u1", remap: dict | None = None, report.get("preserved_value_changes"), report.get("optimizations"), report.get("orca_compatibility"), - ), + report.get("placement_corrections"), + ) | support_keys | tower_keys, filaments=filament_count(work), ) if declared: diff --git a/backend/snapstudio_core/roles.py b/backend/snapstudio_core/roles.py new file mode 100644 index 00000000..58aa1fa0 --- /dev/null +++ b/backend/snapstudio_core/roles.py @@ -0,0 +1,89 @@ +"""Which volumes print: ONE rule for the scene, the sizes and the placed bounds. + +A project says what each part of an object is for in its ``model_settings.config`` part records +(``normal_part``, ``modifier_part``, ``negative_part``, ``support_blocker``, ``support_enforcer``). Only a +normal part prints; the others are instructions to the slicer. The rule, in one place so that the scene, an +object's size, its placed footprint and its height cannot disagree: + +* **A part record belongs to one (object, component) pair.** The record for component C under object O says + what C is *for the purpose of O*. +* **A non-printing role is inherited by everything beneath it.** A modifier set on an assembly applies to + every descendant; a descendant cannot print again by being a plain part. A child's own record is resolved + against what it inherits: ``inherit``. +* **No record means a normal part** when the project has no record for that component (``missing`` says + what the caller wants instead, the scene reports it as unknown). An object with no part records at all + passes its inherited role down unchanged. +* **Conflicting records** (two records for one (object, part) that disagree) and **unrecognised words** are + role ``unknown``. The scene reports unknown. A size or a footprint cannot prove such a volume does not + print, so it COUNTS (``counts_toward_size``): the answer can be too big, never falsely small. +* Part records are matched against objects of the root model. A build item whose object lives in another part + (``p:path``) has no provable records; its volumes inherit nothing and count. +""" +from __future__ import annotations + +#: Stands in for a subtype when two records for one (object, part) disagree; ``role_of`` reads it as unknown. +CONFLICTING_SUBTYPE = "conflicting_records" + + +def merge_record(parts: dict, part_id: str, subtype: str | None) -> None: + """Record ``part_id``'s subtype; a second, different record makes it the conflicting marker.""" + if part_id in parts and parts[part_id] != subtype: + subtype = CONFLICTING_SUBTYPE + parts[part_id] = subtype + + +class Records: + """The part records of a settings file: ``subtypes[object id][part id]`` and the objects that have any.""" + + def __init__(self) -> None: + self.part_subtypes: dict[str, dict[str, str | None]] = {} + self.metadata_objects: set[str] = set() + + +def read_records(settings_xml: str) -> Records: + """Part records from the text of a ``model_settings.config`` (empty if it cannot be read).""" + from .config_io import load_model_settings + + out = Records() + if not settings_xml: + return out + try: + root = load_model_settings(settings_xml.encode("utf-8")) + for obj in root.iter("object"): + oid = obj.get("id") or "" + parts = out.part_subtypes.setdefault(oid, {}) + for part in obj.iterchildren("part"): + out.metadata_objects.add(oid) + pid = part.get("id") + if pid is not None: + merge_record(parts, pid, part.get("subtype")) + except Exception: # noqa: BLE001 - unreadable settings mean no records, never a crash + return Records() + return out + + +def own_role(part_subtypes: dict, metadata_objects, object_id: str, via: str, *, missing): + """The role the project's records give component ``via`` of object ``object_id``, or None if the + object has no part records. ``missing`` is returned for an object that HAS records but none for ``via``.""" + from .assignments import role_of + + subtypes = part_subtypes.get(object_id) + if object_id in metadata_objects and subtypes is not None: + return role_of(subtypes[via]) if via in subtypes else missing + return None + + +def inherit(inherited, own): + """The role of a node from the role it inherits and its own record: a non-printing inherited role wins.""" + from .assignments import PART + + if inherited not in (None, PART): + return inherited + return own if own is not None else inherited + + +def counts_toward_size(role) -> bool: + """Whether a volume with this role counts when measuring what prints (see the module docstring).""" + from .assignments import PART, ROLE_UNKNOWN + + return role in (None, PART, ROLE_UNKNOWN) diff --git a/backend/snapstudio_core/rules.py b/backend/snapstudio_core/rules.py index d367d10a..bb8f3112 100644 --- a/backend/snapstudio_core/rules.py +++ b/backend/snapstudio_core/rules.py @@ -10,7 +10,13 @@ def apply_clamps(cfg: dict, rules: dict) -> list[dict]: for c in rules["clamps"]: k = c["key"] if k in cfg and str(cfg[k]) == c["bad"]: - findings.append({"key": k, "old": cfg[k], "new": c["good"]}) + change = {"key": k, "old": cfg[k], "new": c["good"], + "explanation": clamp_explanation({"key": k, "old": cfg[k], "new": c["good"]}), + "source": ("Studio's import rule for this setting" if k == "raft_first_layer_expansion" + else "the Compatibility check's valid range"), + # the raft explanation ends with advice to check the raft in Snapmaker Orca + "kind": "orca" if k == "raft_first_layer_expansion" else "engine"} + findings.append(change) cfg[k] = c["good"] return findings diff --git a/backend/snapstudio_core/scale_doctor.py b/backend/snapstudio_core/scale_doctor.py index e561c0db..6ea72724 100644 --- a/backend/snapstudio_core/scale_doctor.py +++ b/backend/snapstudio_core/scale_doctor.py @@ -102,7 +102,7 @@ def preview(path: str, scale_percent: float) -> dict: risks = [ "Fit by SIZE only — Snapmaker Orca can still reject a scale because where the object " - "sits on the plate also matters. Confirm placement in Orca (Arrange all plates) after scaling.", + "sits on the plate also matters. Check placement in Snapmaker Orca after scaling.", "Holes, threads, snap-fits, joints and tolerances may not scale correctly — " "check fit-critical features after scaling.", "Thin-wall safety check is not available here — verify wall thickness after scaling (approximate).", @@ -335,7 +335,7 @@ def by_part(pct): if not verified: warnings.append("Sizes below are a theoretical fit BY SIZE only. Snapmaker Orca can still " "reject a scale because where the object sits on the plate also matters. " - "After scaling, open in Orca and use Arrange all plates to confirm it fits.") + "After scaling, check in Snapmaker Orca that it fits.") if multi: warnings.append("Use the same scale on all related parts — do not scale plates " "differently if they need to fit together.") @@ -343,7 +343,7 @@ def by_part(pct): headline = (f"Largest verified scale that stays on the plate: {safe_pct:.0f}%." if verified else f"Largest size-only fit is {safe_pct:.0f}% — verify placement in Snapmaker Orca " - "(Arrange all plates) after scaling.") + "after scaling.") next_steps = [ headline, "Apply the same scale to all related parts in your slicer.", diff --git a/backend/snapstudio_core/scene.py b/backend/snapstudio_core/scene.py new file mode 100644 index 00000000..b08f9773 --- /dev/null +++ b/backend/snapstudio_core/scene.py @@ -0,0 +1,1455 @@ +"""scene/1 -- a read-only, bounded description of a project's geometry, placement and findings. + +The contract is the committed JSON Schema ``data/scene-1.schema.json`` (frozen: closed objects, +every field required, unknown is null). This module is the only producer of it. + +What a scene IS: a consistent snapshot of the file as it was copied for the job. It is not a live +view. ``SOURCE_CHANGED`` therefore only ever applies to a reused request_id+path or an +``expected_revision`` mismatch (see ``snapstudio_api.scene_jobs``); it does not apply to edits made +after the snapshot was taken. + +Design rules, in order of importance: + +* **Untrusted input.** The archive is opened by a SELECTIVE reader that owns its own checks: + colliding or traversal-looking entry names, external relationships, DTD/ENTITY declarations and + any text encoding other than UTF-8 are rejected BEFORE parsing; only the ``.model``, ``.rels`` and + the two metadata configs the scene needs are ever read (never thumbnails or embedded G-code); + every read is metered against the budgets in ``scene_limits`` while it happens. + ``ThreeMF.open()`` and ``config_io`` are deliberately untouched. +* **Cooperative interruption.** Archive reads are <= 1 MiB, lxml ``iterparse`` checks the cancel / + deadline hook every <= 1,000 events (and on every read), geometry encode/transform runs in + batches of <= 5,000 triangles. The worst uninterruptible call is one such unit. Accepted + limitation: a single stuck native/filesystem call cannot be interrupted in-process; the backstop + is the job registry's fail-closed WORKER_WEDGED state (no multiprocessing in v1). +* **Honest unknowns.** Placement, plate membership and volume roles that the file does not prove + are reported as ``unknown``/``ambiguous`` with a limitation, never guessed. Bed fit is only + claimed when the bed is Studio's U1 template, placement is known and the instance is not one of + several build items of the same object id (the engine's own placement answer for those is + unverified, see ``REPEATED_INSTANCE_PLACEMENT_UNVERIFIED``). +* **Never silent truncation.** Over any budget the build stops with ``LIMIT_EXCEEDED``. +""" +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import math +import posixpath +import re +import sys +import zipfile +import zlib +from array import array +from importlib.resources import files + +from lxml import etree + +from . import scene_limits as L +from . import roles as _roles +from . import units as _units +from .errors import SnapStudioError + +SCHEMA_ID = "scene/1" +SCHEMA_RESOURCE = "scene-1.schema.json" + +CORE_NS = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" +PROD_NS = "http://schemas.microsoft.com/3dmanufacturing/production/2015/06" +_T = {n: f"{{{CORE_NS}}}{n}" for n in ( + "model", "resources", "object", "mesh", "vertices", "vertex", "triangles", "triangle", + "components", "component", "build", "item")} +_PROD_PATH = f"{{{PROD_NS}}}path" + +ROOT_MODEL_DEFAULT = "3D/3dmodel.model" +MODEL_SETTINGS = "Metadata/model_settings.config" +PRUSA_CONFIG = "Metadata/Slic3r_PE_model.config" +_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +_MODEL_REL_TYPE = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" + +# limitation codes that make a scene "partial" rather than "complete" +PARTIAL_LIMITATIONS = frozenset({ + "UNKNOWN_VOLUME_ROLE", "UNKNOWN_PLACEMENT", "PLATE_MEMBERSHIP_AMBIGUOUS", "PLATE_MEMBERSHIP_UNKNOWN", "UNSUPPORTED_UNIT", + "BED_TEMPLATE_UNAVAILABLE", "REPEATED_INSTANCE_PLACEMENT_UNVERIFIED", "NON_MM_SOURCE_UNIT", + "NO_BUILD_ITEMS"}) +#: Information only: the scene is still as complete as the file allows. +INFORMATIONAL_LIMITATIONS = frozenset({"PLATES_UNAVAILABLE", "MULTI_PLATE_PLACEMENT_UNCHECKED"}) + +ERROR_CODES = ( + "INVALID_REQUEST", "UNSUPPORTED_FORMAT", "INVALID_ARCHIVE", "INVALID_GEOMETRY", + "UNRESOLVED_REFERENCE", "LIMIT_EXCEEDED", "SOURCE_CHANGED", "CANCELLED", "TIMEOUT", + "EXPIRED", "NOT_READY", "WORKER_WEDGED", "INTERNAL", "STALE_START", "CANCELLED_BEFORE_START", "SESSION_EXPIRED", "SESSION_LIMIT") + + +class SceneError(SnapStudioError): + """A refusal with a stable machine code (one of ERROR_CODES) and a message safe to show.""" + + def __init__(self, code: str, message: str) -> None: + assert code in ERROR_CODES, code + self.code = code + self.message = message + super().__init__(message) + + +class SceneCancelled(Exception): + """Raised by the job control hook when the job was cancelled.""" + + +class SceneTimeout(Exception): + """Raised by the job control hook when the job's deadline passed.""" + + +class Control: + """The cancel/deadline/progress hook. The default does nothing.""" + + def check(self) -> None: # pragma: no cover - trivial + return None + + def progress(self, stage: str, completed: int | None, total: int | None) -> None: # pragma: no cover + return None + + +def load_schema() -> dict: + return json.loads((files("snapstudio_core.data") / SCHEMA_RESOURCE).read_text("utf-8")) + + +# -------------------------------------------------------------------------------------------- +# Selective archive reader +# -------------------------------------------------------------------------------------------- + +_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]") +_DRIVE = re.compile(r"^[A-Za-z]:") +_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") # http:, file:, C: ... never a package-relative path +_XML_DECL_ENCODING = re.compile(rb"""^\s*<\?xml[^>]*?encoding\s*=\s*["']([^"']+)["']""", re.I) +_BOMS_REJECTED = (b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff", b"\xff\xfe", b"\xfe\xff") +_READ_ERRORS = (zipfile.BadZipFile, NotImplementedError, zlib.error, EOFError, RuntimeError, OSError) + + +def normalize_entry_name(name: str) -> tuple[str, bool]: + """A zip entry name as a normalized archive-relative path, and whether it is a directory. + + Rejected: control characters, rooted names, drive letters, any ``..`` segment. Backslashes are + read as separators, so ``a\\b`` and ``a/b`` collide (and are rejected as a pair by the caller). + """ + n = name.replace("\\", "/") + if _CONTROL_CHARS.search(n): + raise SceneError("INVALID_ARCHIVE", "The archive has an entry name with control characters.") + if n.startswith("/") or _DRIVE.match(n): + raise SceneError("INVALID_ARCHIVE", "The archive has an entry with an absolute path.") + segments = n.split("/") + if ".." in segments: + raise SceneError("INVALID_ARCHIVE", "The archive has an entry that escapes its own folder.") + clean = [s for s in segments if s not in ("", ".")] + if not clean: + raise SceneError("INVALID_ARCHIVE", "The archive has an entry with an empty name.") + return "/".join(clean), n.endswith("/") + + +def resolve_reference(target: str, base_dir: str = "") -> str: + """Resolve a relationship Target / component path to a normalized part name. + + A leading ``/`` means "from the package root", which is how every slicer writes them, so it is + accepted. A reference that climbs above the package root is rejected. + """ + t = target.replace("\\", "/") + if _CONTROL_CHARS.search(t) or _SCHEME.match(t): + raise SceneError("INVALID_ARCHIVE", "The project has a malformed internal reference.") + parts = [] if t.startswith("/") else [s for s in base_dir.split("/") if s] + for seg in t.split("/"): + if seg in ("", "."): + continue + if seg == "..": + if not parts: + raise SceneError("INVALID_ARCHIVE", "A reference in the project escapes the archive.") + parts.pop() + else: + parts.append(seg) + if not parts: + raise SceneError("INVALID_ARCHIVE", "The project has an empty internal reference.") + return "/".join(parts) + + +class GuardedStream: + """A file-like over one archive member that meters, scans and honours cancellation. + + Reads at most ``CHUNK`` bytes per call (bounded uninterruptible work). The DTD/ENTITY scan and + the encoding check run on the raw bytes BEFORE the parser sees them. + """ + + def __init__(self, archive: "Archive", info: zipfile.ZipInfo, kind: str, cap: int, scan: bool = True): + self.archive, self.kind, self.cap, self.scan = archive, kind, cap, scan + self.bytes_read = 0 + self._carry = b"" + self._first = True + try: + self._fh = archive.zf.open(info, "r") + except _READ_ERRORS as exc: + raise SceneError("INVALID_ARCHIVE", "A part of the archive could not be opened.") from exc + + def close(self) -> None: + try: + self._fh.close() + except Exception: # noqa: BLE001 + pass + + def read(self, size: int = -1) -> bytes: + self.archive.ctl.check() + n = L.SNAPSHOT_CHUNK_BYTES if size is None or size < 0 else min(size, L.SNAPSHOT_CHUNK_BYTES) + try: + chunk = self._fh.read(n) + if self._first and chunk and len(chunk) < 4: + chunk += self._fh.read(4 - len(chunk)) + except _READ_ERRORS as exc: + raise SceneError("INVALID_ARCHIVE", "A part of the archive is damaged.") from exc + if not chunk: + return chunk + self.bytes_read += len(chunk) + self.archive.account(len(chunk), self.kind) + if self.bytes_read > self.cap: + raise SceneError("LIMIT_EXCEEDED", "A part of the archive expands past what Studio will read.") + if self.scan: + if self._first: + self._check_encoding(chunk) + hay = (self._carry + chunk).lower() + if b" None: + if head.startswith(b"\xef\xbb\xbf"): + head = head[3:] + elif head.startswith(_BOMS_REJECTED) or b"\x00" in head[:4]: + raise SceneError("INVALID_ARCHIVE", "The project is not UTF-8 text, which is the only encoding Studio accepts.") + found = _XML_DECL_ENCODING.match(head) + if found and found.group(1).strip().lower() not in (b"utf-8", b"utf8"): + raise SceneError("INVALID_ARCHIVE", "The project is not UTF-8 text, which is the only encoding Studio accepts.") + + +class Archive: + """Read-only, selective, metered access to one 3MF.""" + + def __init__(self, path: str, ctl: Control): + self.ctl = ctl + self.total_read = 0 + self.model_read = 0 + self.model_declared = 0 + try: + self.zf = zipfile.ZipFile(path) + except (zipfile.BadZipFile, OSError, EOFError) as exc: + raise SceneError("INVALID_ARCHIVE", "This file is not a readable 3MF archive.") from exc + infos = self.zf.infolist() + if len(infos) > L.ARCHIVE_MAX_ENTRIES: + raise SceneError("LIMIT_EXCEEDED", "The archive has more entries than Studio will read.") + self.index: dict[str, tuple[str, zipfile.ZipInfo]] = {} + for info in infos: + norm, is_dir = normalize_entry_name(info.filename) + key = norm.casefold() + if key in self.index: + raise SceneError("INVALID_ARCHIVE", "The archive has duplicate or colliding entry names.") + if info.flag_bits & 0x1: + raise SceneError("INVALID_ARCHIVE", "The archive is encrypted.") + self.index[key] = (norm, info) + if is_dir: + self.index[key] = (norm + "/", info) + + def close(self) -> None: + self.zf.close() + + def account(self, n: int, kind: str) -> None: + self.total_read += n + if self.total_read > L.ARCHIVE_EXPANSION_BYTES: + raise SceneError("LIMIT_EXCEEDED", "The archive expands past what Studio will read.") + if kind == "model": + self.model_read += n + if self.model_read > L.MODEL_XML_BYTES: + raise SceneError("LIMIT_EXCEEDED", "The model data is larger than Studio will read.") + + def has(self, name: str) -> bool: + entry = self.index.get(name.casefold()) + return entry is not None and not entry[0].endswith("/") + + def names(self) -> list[str]: + return [norm for norm, _ in self.index.values() if not norm.endswith("/")] + + def open(self, name: str, kind: str, scan: bool = True) -> GuardedStream: + entry = self.index.get(name.casefold()) + if entry is None or entry[0].endswith("/"): + raise SceneError("UNRESOLVED_REFERENCE", "The project refers to a part that is not in the archive.") + info = entry[1] + if len(entry[0]) > L.MAX_PART_NAME_LENGTH: + # part names are carried on nodes, meshes and sources: bound them before they are placed + raise SceneError("LIMIT_EXCEEDED", f"A part name is longer than the {L.MAX_PART_NAME_LENGTH} characters Studio carries.") + cap = L.MODEL_XML_BYTES if kind == "model" else L.CONFIG_PART_BYTES + if info.file_size > cap: + raise SceneError("LIMIT_EXCEEDED", "A part of the archive is larger than Studio will read.") + if kind == "model": + self.model_declared += info.file_size + return GuardedStream(self, info, kind, cap, scan) + + def read_small(self, name: str) -> bytes: + stream = self.open(name, "config") + try: + out = [] + while True: + chunk = stream.read(L.SNAPSHOT_CHUNK_BYTES) + if not chunk: + break + out.append(chunk) + return b"".join(out) + finally: + stream.close() + + +def iter_xml(stream: GuardedStream, ctl: Control, known=None, consume=frozenset(), local=False): + """Yield lxml ``(event, element)`` pairs with the hardened options. + + The parser is also asked, on its first event, whether it saw a doctype / internal DTD or a + non-UTF-8 declaration, so rejection does not depend on the raw byte scan alone. + + Every completed element is COUNTED against ``MAX_XML_ELEMENTS`` (so a part under its byte cap that is + made of millions of tiny elements is refused early). When ``known`` (the local names the caller reads) + is given, any completed element outside it is released as soon as the caller has seen it, so unknown + junk never accumulates (an element in the namespace the caller does not read is unknown too), and so is + any element named in ``consume`` (self-contained records the caller has read at their own end event); + the caller releases the recognized parents it consumed. ``local=True`` matches by local name. + """ + try: + it = etree.iterparse(stream, events=("start", "end"), resolve_entities=False, + load_dtd=False, no_network=True, huge_tree=False) + first, count, elements = True, 0, 0 + for event, elem in it: + if first: + first = False + info = elem.getroottree().docinfo + if info.doctype or info.internalDTD is not None or info.system_url or info.public_id: + raise SceneError("INVALID_ARCHIVE", "The project contains a DTD, which Studio does not accept.") + if (info.encoding or "UTF-8").upper() not in ("UTF-8", "UTF8"): + raise SceneError("INVALID_ARCHIVE", "The project is not UTF-8 text, which is the only encoding Studio accepts.") + count += 1 + if count % L.XML_EVENTS_PER_CHECK == 0: + ctl.check() + if event == "end": + elements += 1 + if elements > L.MAX_XML_ELEMENTS: + raise SceneError("LIMIT_EXCEEDED", f"A part of the project has more than {L.MAX_XML_ELEMENTS:,} elements.") + yield event, elem + if known is not None and event == "end": + tag = etree.QName(elem).localname if local else elem.tag + if tag not in known or tag in consume: + _free(elem) + except etree.LxmlError as exc: + raise SceneError("INVALID_ARCHIVE", "A part of the project is not well-formed XML.") from exc + finally: + stream.close() + + +def _free(elem) -> None: + """Release ONE completed element: clear it and detach it from its parent. + + Never its siblings. Records that a parent consumes later (the ````/````/```` + children of a settings ````/````) are siblings of whatever unknown element happens to follow + them, and deleting them would silently turn a negative part into a printable one. The caller must have + read everything it needs from ``elem`` (as plain data) BEFORE calling this. + """ + elem.clear() + parent = elem.getparent() + if parent is not None: + parent.remove(elem) + + +# -------------------------------------------------------------------------------------------- +# Matrices (column-major 4x4 as 16 floats; a 3MF transform is 12 numbers: 3 columns + translation) +# -------------------------------------------------------------------------------------------- + +IDENTITY16 = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] + + +def parse_transform(text: str | None, mm_per_unit: float) -> list[float] | None: + """A 3MF ``transform`` attribute as a column-major 16-float matrix in millimetres.""" + if text is None: + return None + parts = text.split() + if len(parts) != 12: + raise SceneError("INVALID_GEOMETRY", "A transform does not have twelve numbers.") + try: + v = [float(p) for p in parts] + except ValueError as exc: + raise SceneError("INVALID_GEOMETRY", "A transform has a value that is not a number.") from exc + if not all(math.isfinite(x) for x in v): + raise SceneError("INVALID_GEOMETRY", "A transform has a value that is not finite.") + return [v[0], v[1], v[2], 0.0, v[3], v[4], v[5], 0.0, v[6], v[7], v[8], 0.0, + v[9] * mm_per_unit, v[10] * mm_per_unit, v[11] * mm_per_unit, 1.0] + + +def mat_mul(a: list[float], b: list[float]) -> list[float]: + out = [0.0] * 16 + for c in range(4): + for r in range(4): + out[c * 4 + r] = (a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]) + return out + + +def mat_det3(m: list[float]) -> float: + return (m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2]) + + m[8] * (m[1] * m[6] - m[5] * m[2])) + + +def _round_matrix(m: list[float]) -> list[float]: + return [float(f"{x:.9g}") + 0.0 for x in m] # + 0.0 turns -0.0 into 0.0 + + +# -------------------------------------------------------------------------------------------- +# Model parts +# -------------------------------------------------------------------------------------------- + +class Budget: + """Counters shared by every model part of one scene, enforced WHILE parsing (never after).""" + + def __init__(self) -> None: + self.vertices = 0 + self.triangles = 0 + self.elements = 0 + self.objects = 0 + + +class MeshData: + __slots__ = ("pos", "idx", "nverts", "ntris", "max_index") + + def __init__(self) -> None: + self.pos = array("d") + self.idx = array("I") + self.nverts = 0 + self.ntris = 0 + self.max_index = -1 + + +class Component: + __slots__ = ("object_id", "part", "transform") + + def __init__(self, object_id: str, part: str, transform): + self.object_id, self.part, self.transform = object_id, part, transform + + +class ObjectDef: + __slots__ = ("id", "mesh", "components") + + def __init__(self, oid: str): + self.id = oid + self.mesh: MeshData | None = None + self.components: list[Component] = [] + + +class BuildItem: + """A build item. ``part`` is the model FILE its object lives in: the root unless the item carries a + production-extension ``p:path`` (so identity everywhere is the pair part + object id).""" + __slots__ = ("object_id", "part", "transform") + + def __init__(self, object_id: str, part: str, transform): + self.object_id, self.part, self.transform = object_id, part, transform + + +class ModelFile: + def __init__(self, part: str): + self.part = part + self.unit = _units.DEFAULT_UNIT + self.mm_per_unit = 1.0 + self.unit_recognized = True + self.objects: dict[str, ObjectDef] = {} + self.build: list[BuildItem] = [] + + +def _check_id(value: str | None, what: str) -> str: + if value is None or not value.strip(): + raise SceneError("INVALID_ARCHIVE", f"A {what} has no id.") + if len(value) > L.MAX_ID_LENGTH: + raise SceneError("LIMIT_EXCEEDED", f"A {what} id is longer than Studio will carry.") + return value + + +def _package_path(ref: str) -> str: + """A production-extension ``p:path``: an ABSOLUTE package path (leading ``/``); relative ones are refused.""" + if not ref.startswith("/"): + raise SceneError("UNRESOLVED_REFERENCE", "A p:path reference must be an absolute package path.") + return resolve_reference(ref) + + +def parse_model(arc: Archive, part: str, budget: Budget, ctl: Control, is_root: bool) -> ModelFile: + """Stream one ``.model`` part into objects, meshes, components and (root only) build items.""" + mf = ModelFile(part) + stream = arc.open(part, "model") + cur: ObjectDef | None = None + mesh: MeshData | None = None + saw_root = False + t_vertex, t_triangle, t_component, t_item = _T["vertex"], _T["triangle"], _T["component"], _T["item"] + t_object, t_mesh, t_model = _T["object"], _T["mesh"], _T["model"] + limit_v, limit_t, max_coord = L.MAX_VERTICES, L.MAX_PARSED_TRIANGLES, L.MAX_COORDINATE_MM + for event, elem in iter_xml(stream, ctl): + tag = elem.tag + if event == "start": + if not saw_root: + saw_root = True + if tag != t_model: + raise SceneError("INVALID_ARCHIVE", "A model part does not start with a model element.") + # The SAME function the legacy readers use, on the real root element. + info = _units.resolve_unit(elem.get("unit")) + mf.unit_recognized = info.recognized + mf.unit = info.name if info.recognized else _units.DEFAULT_UNIT # documented fallback + mf.mm_per_unit = info.factor + elif tag == t_object: + budget.objects += 1 + if budget.objects > L.MAX_OBJECT_DEFINITIONS: + raise SceneError("LIMIT_EXCEEDED", f"The project defines more than {L.MAX_OBJECT_DEFINITIONS:,} objects.") + oid = _check_id(elem.get("id"), "object") + if oid in mf.objects: + raise SceneError("INVALID_ARCHIVE", "A model part defines the same object id twice.") + cur = ObjectDef(oid) + mf.objects[oid] = cur + elif tag == t_mesh and cur is not None: + mesh = MeshData() + continue + # --- end events + budget.elements += 1 + if budget.elements > L.MAX_XML_ELEMENTS: + raise SceneError("LIMIT_EXCEEDED", f"The model data has more than {L.MAX_XML_ELEMENTS:,} elements.") + if tag == t_vertex: + if mesh is None: + continue + try: + x, y, z = float(elem.get("x")), float(elem.get("y")), float(elem.get("z")) + except (TypeError, ValueError) as exc: + raise SceneError("INVALID_GEOMETRY", "A vertex has a coordinate that is missing or not a number.") from exc + scale = mf.mm_per_unit + x, y, z = x * scale, y * scale, z * scale + if not (math.isfinite(x) and math.isfinite(y) and math.isfinite(z)): + raise SceneError("INVALID_GEOMETRY", "A vertex has a coordinate that is not finite.") + if abs(x) > max_coord or abs(y) > max_coord or abs(z) > max_coord: + raise SceneError("INVALID_GEOMETRY", "A vertex coordinate is outside the range Studio can represent.") + budget.vertices += 1 + if budget.vertices > limit_v: + raise SceneError("LIMIT_EXCEEDED", "The project has more vertices than Studio will draw.") + mesh.pos.append(x) + mesh.pos.append(y) + mesh.pos.append(z) + mesh.nverts += 1 + elif tag == t_triangle: + if mesh is None: + continue + try: + a, b, c = int(elem.get("v1")), int(elem.get("v2")), int(elem.get("v3")) + except (TypeError, ValueError) as exc: + raise SceneError("INVALID_GEOMETRY", "A triangle has an index that is missing or not a whole number.") from exc + if a < 0 or b < 0 or c < 0 or a > 0xFFFFFFFF or b > 0xFFFFFFFF or c > 0xFFFFFFFF: + raise SceneError("INVALID_GEOMETRY", "A triangle has a vertex index outside the supported range.") + budget.triangles += 1 + if budget.triangles > limit_t: + raise SceneError("LIMIT_EXCEEDED", f"The project has at least {budget.triangles:,} triangles; Studio draws at most {limit_t:,}.") + top = max(a, b, c) + if top > mesh.max_index: + mesh.max_index = top + mesh.idx.append(a) + mesh.idx.append(b) + mesh.idx.append(c) + mesh.ntris += 1 + elif tag == t_mesh: + if mesh is not None and cur is not None: + if mesh.nverts == 0 or mesh.ntris == 0: + raise SceneError("INVALID_GEOMETRY", "A mesh has no vertices or no triangles.") + if mesh.max_index >= mesh.nverts: + raise SceneError("INVALID_GEOMETRY", "A triangle refers to a vertex that does not exist.") + cur.mesh = mesh + mesh = None + elif tag == t_component: + if cur is None: + continue + oid = _check_id(elem.get("objectid"), "component") + ref = elem.get(_PROD_PATH) + if ref is not None and not is_root: + raise SceneError("UNRESOLVED_REFERENCE", "A component path is only honoured in the root model part.") + target = _package_path(ref) if ref is not None else part + cur.components.append(Component(oid, target, parse_transform(elem.get("transform"), mf.mm_per_unit))) + if len(cur.components) > L.MAX_NODES: + raise SceneError("LIMIT_EXCEEDED", "An object has more components than Studio will draw.") + elif tag == t_object: + if cur is not None and cur.mesh is not None and cur.components: + raise SceneError("INVALID_ARCHIVE", "An object has both a mesh and components.") + cur = None + elif tag == t_item and is_root: + oid = _check_id(elem.get("objectid"), "build item") + ref = elem.get(_PROD_PATH) + item_part = _package_path(ref) if ref is not None else part + mf.build.append(BuildItem(oid, item_part, parse_transform(elem.get("transform"), mf.mm_per_unit))) + if len(mf.build) > L.MAX_NODES: + raise SceneError("LIMIT_EXCEEDED", "The build has more items than Studio will draw.") + # Every completed element is released, whatever it is (unknown extensions, junk, containers): + # memory stays flat however many elements a model part holds, and the counters above stop it. + _free(elem) + if not saw_root: + raise SceneError("INVALID_ARCHIVE", "A model part is empty.") + return mf + + +_RELS_KNOWN = frozenset({"Relationships", "Relationship"}) +_BAMBU_KNOWN = frozenset({"config", "object", "part", "metadata", "plate", "model_instance", "mesh_stat"}) +_PRUSA_KNOWN = frozenset({"config", "object", "volume", "metadata", "mesh"}) + + +def find_root_model(arc: Archive) -> str: + """The root model part named by ``_rels/.rels`` (default ``3D/3dmodel.model``). + + Every ``.rels`` part is read and an external relationship in any of them is a rejection. + """ + rels = [n for n in arc.names() if n.lower().endswith(".rels")] + if len(rels) > L.MAX_RELS_PARTS: + raise SceneError("LIMIT_EXCEEDED", "The archive has more relationship parts than Studio will read.") + root = None + for name in rels: + stream = arc.open(name, "config") + for event, elem in iter_xml(stream, arc.ctl, known=_RELS_KNOWN, consume=frozenset({"Relationship"}), local=True): + if event != "end" or etree.QName(elem).localname != "Relationship": + continue + if (elem.get("TargetMode") or "").lower() == "external": + raise SceneError("INVALID_ARCHIVE", "The project has an external relationship, which Studio does not follow.") + target = elem.get("Target") + if not target: + raise SceneError("INVALID_ARCHIVE", "A relationship has no target.") + # a relationship Target is relative to the folder that holds the PART the .rels describes + parent = posixpath.dirname(name) + base = posixpath.dirname(parent) if posixpath.basename(parent).casefold() == "_rels" else parent + resolved = resolve_reference(target, base) + if name.casefold() == "_rels/.rels" and elem.get("Type") == _MODEL_REL_TYPE and root is None: + root = resolved + root = root or ROOT_MODEL_DEFAULT + if not arc.has(root): + raise SceneError("UNRESOLVED_REFERENCE", "The project has no root model part.") + return arc.index[root.casefold()][0] + + +#: Stands in for a subtype when two records for one (object, part) disagree; ``role_of`` reads it as unknown. +#: Defined once, in ``roles``, with the rest of the role rule the sizes and footprints share. +CONFLICTING_SUBTYPE = _roles.CONFLICTING_SUBTYPE + + +class SettingsInfo: + """What model_settings.config / Slic3r_PE_model.config say, reduced to what the scene uses.""" + + def __init__(self) -> None: + self.dialect: str | None = None # "bambu" | "prusa" | None + self.part_subtypes: dict[str, dict[str, str | None]] = {} # object id -> {part id: subtype} + self.metadata_objects: set[str] = set() # object ids with ANY part record (even one that is malformed) + self.plates: list[dict] = [] # {"id","ui_number","members":[(oid, inst|None)]} + self.prusa_volumes: dict[str, list[dict]] = {} + + +def parse_settings(arc: Archive, ctl: Control) -> SettingsInfo: + from .assignments import role_of # imported lazily: assignments is a heavy module + + info = SettingsInfo() + if arc.has(MODEL_SETTINGS): + info.dialect = "bambu" + stream = arc.open(arc.index[MODEL_SETTINGS.casefold()][0], "config") + objects = 0 + for event, elem in iter_xml(stream, ctl, known=_BAMBU_KNOWN): + if event != "end": + continue + if elem.tag == "object": + objects += 1 + if objects > 5 * L.MAX_NODES: + raise SceneError("LIMIT_EXCEEDED", "The settings describe more objects than Studio will read.") + oid = elem.get("id") or "" + # a duplicated record may not silently overwrite another: two records for the same + # (object id, part id) that disagree make the role unknown (identical ones are fine) + parts = info.part_subtypes.setdefault(oid, {}) + for p in elem.iterchildren("part"): + info.metadata_objects.add(oid) # the object HAS metadata, whatever this record says + pid = p.get("id") + if pid is None: + continue + _roles.merge_record(parts, pid, p.get("subtype")) + _free(elem) + elif elem.tag == "plate": + if len(info.plates) >= 256: + raise SceneError("LIMIT_EXCEEDED", "The settings describe more plates than Studio will read.") + meta = {m.get("key"): m.get("value") for m in elem.iterchildren("metadata")} + number = meta.get("plater_id") + members = [] + for mi in elem.iterchildren("model_instance"): + mm = {m.get("key"): m.get("value") for m in mi.iterchildren("metadata")} + if mm.get("object_id") is not None: + members.append((mm["object_id"], mm.get("instance_id"))) + if len(members) > 10 * L.MAX_NODES: + raise SceneError("LIMIT_EXCEEDED", "A plate lists more objects than Studio will read.") + ui = (int(number) if number is not None and number.isascii() and number.isdigit() + and len(number) <= 6 else None) + info.plates.append({"ui_number": ui, "members": members}) + _free(elem) + elif arc.has(PRUSA_CONFIG): + info.dialect = "prusa" + stream = arc.open(arc.index[PRUSA_CONFIG.casefold()][0], "config") + count = 0 + for event, elem in iter_xml(stream, ctl, known=_PRUSA_KNOWN): + if event != "end" or elem.tag != "object": + continue + count += 1 + if count > L.MAX_OBJECT_DEFINITIONS: + raise SceneError("LIMIT_EXCEEDED", "The settings describe more objects than Studio will read.") + entries = [] + for vol in elem.iterchildren("volume"): + # attributes are read BY NAME: their order in the tag does not matter + first, last = vol.get("firstid"), vol.get("lastid") + valid = (first is not None and last is not None and first.isascii() and last.isascii() + and first.isdigit() and last.isdigit() and len(first) <= 9 and len(last) <= 9) + kind = None + for m in vol.iterchildren("metadata"): + if m.get("key") == "volume_type" and m.get("type") in (None, "volume"): + kind = m.get("value") + entries.append({"range": (int(first), int(last)) if valid else None, "role": role_of(kind)}) + if len(entries) > L.MAX_VOLUMES_PER_MESH: + raise SceneError("LIMIT_EXCEEDED", "An object has more volumes than Studio will read.") + if entries: + info.prusa_volumes.setdefault(elem.get("id") or "", []).extend(entries) + _free(elem) + return info + + +# -------------------------------------------------------------------------------------------- +# Bed +# -------------------------------------------------------------------------------------------- + +def _bed(limitations: list[dict]) -> tuple[dict, dict]: + """(bed object for the scene, rectangle used for findings). Surfaces a fallback, never hides it.""" + from . import plate_placement as pp + + rect = None + try: + rect = pp.parse_printable_area(pp._u1_printable_area()) + except Exception: # noqa: BLE001 - a missing/corrupt template is a limitation, not a crash + rect = None + policy = "u1_template" + if rect is None: + policy = "fallback" + rect = {"min_x": 0.0, "min_y": 0.0, "max_x": 270.0, "max_y": 270.0} + limitations.append({"code": "BED_TEMPLATE_UNAVAILABLE", "target_ids": []}) + height = None + try: + data = json.loads((files("snapstudio_core.data") / "templates" / "u1_base_project_settings.json").read_text("utf-8")) + height = float(data.get("printable_height")) + except Exception: # noqa: BLE001 + height = None + poly = [[rect["min_x"], rect["min_y"]], [rect["max_x"], rect["min_y"]], + [rect["max_x"], rect["max_y"]], [rect["min_x"], rect["max_y"]]] + return ({"polygon_mm": poly, "height_mm": height, "edge_margin_mm": pp.EDGE_MARGIN_MM, "policy": policy}, rect) + + + + +# -------------------------------------------------------------------------------------------- +# Traversal +# -------------------------------------------------------------------------------------------- + +class Node: + __slots__ = ("id", "parent", "resource", "mesh_key", "local", "world", "mirrored", "build_index", + "path", "plate", "placement_known", "printable", "has_non_part", "bounds", + "finding_ids", "children", "top", "repeated", "role_ctx") + + def __init__(self) -> None: + self.parent: Node | None = None + self.children: list[Node] = [] + self.finding_ids: list[str] = [] + self.bounds = None + self.printable = None + self.has_non_part = False + self.mesh_key = None + self.role_ctx = None + + +class TopContext: + """What every node under one build item inherits.""" + + def __init__(self, ordinal: int, build_index, plate: dict, placement_known: bool, repeated: bool): + self.ordinal, self.build_index, self.plate = ordinal, build_index, plate + self.placement_known, self.repeated = placement_known, repeated + self.node: Node | None = None + self.top_part: str | None = None + + +class MeshInfo: + """Volumes and printable-geometry facts for one emitted mesh.""" + + def __init__(self, key: tuple[str, str], data: MeshData): + self.key, self.data = key, data + self.volumes: list[dict] = [] + self.part_verts: list[int] = [] + self.lo = self.hi = None + self.has_part = False + self.has_non_part = False + self.has_unknown = False + + +class Traversal: + def __init__(self, files_by_part: dict[str, ModelFile], settings: SettingsInfo, root_part: str, ctl: Control): + self.files, self.settings, self.root_part, self.ctl = files_by_part, settings, root_part, ctl + self.nodes: list[Node] = [] + self.rendered = 0 + self.mesh_order: list[tuple[str, str]] = [] + self._ids: set[str] = set() + self.mesh_users: dict[tuple[str, str], list[str]] = {} + + def add_tree(self, key: tuple[str, str], transform, ctx: TopContext) -> None: + ctx.top_part = key[0] + self._expand(key, transform, None, [], ctx, ()) + + def _context_role(self, parent: Node | None, via: str | None, inherited: str | None, + ctx: "TopContext | None" = None) -> str | None: + """The role this USE imposes (Bambu/Orca ````), following the traversal context. + + A part record belongs to one (object, component) pair, so a mesh shared by two objects can be a + negative part under one and an ordinary part under the other, and a role given to a component + ASSEMBLY applies to everything beneath it (``inherited`` wins unless it is a plain part). An object + that HAS part records but none for this component gives ``unknown``, never a printable part. + """ + if ctx is not None and self.settings.dialect is not None and ctx.top_part != self.root_part: + # Part records are matched against ROOT-file objects. A build item whose object lives in another + # file (p:path) cannot be associated provably with any record, so every part beneath it is + # unknown (fail closed): never printable, never judged against the bed. + return "unknown" + own = None + if parent is not None and via is not None and self.settings.dialect == "bambu" and parent.resource[0] == self.root_part: + # the one shared role rule; here a component an object's records do not mention is unknown + own = _roles.own_role(self.settings.part_subtypes, self.settings.metadata_objects, + parent.resource[1], via, missing="unknown") + return _roles.inherit(inherited, own) + + def _expand(self, key, local, parent: Node | None, path: list[int], ctx: TopContext, + stack: tuple, via: str | None = None, inherited: str | None = None) -> Node: + self.ctl.check() + mf = self.files.get(key[0]) + obj = mf.objects.get(key[1]) if mf else None + if obj is None: + raise SceneError("UNRESOLVED_REFERENCE", "The project refers to an object that is not defined.") + if key in stack: + raise SceneError("INVALID_ARCHIVE", "The project's components refer to themselves.") + if len(path) >= L.MAX_DEPTH: + raise SceneError("LIMIT_EXCEEDED", "The project nests objects deeper than Studio will follow.") + if len(self.nodes) >= L.MAX_NODES: + raise SceneError("LIMIT_EXCEEDED", "The project has more objects than Studio will draw.") + node = Node() + node.parent = parent + node.resource = key + node.path = list(path) + node.local = local if local is not None else IDENTITY16 + node.world = node.local if parent is None else mat_mul(parent.world, node.local) + if not all(math.isfinite(x) for x in node.world): + raise SceneError("INVALID_GEOMETRY", "A combined transform is not finite.") + node.mirrored = mat_det3(node.world) < 0 + node.id = self._node_id(ctx, parent, path) + if node.id in self._ids: # digested ids give 128-bit practical uniqueness; a collision is detected + raise SceneError("INTERNAL", "Two nodes would share an id.") + self._ids.add(node.id) + node.role_ctx = self._context_role(parent, via, inherited, ctx) + node.build_index, node.plate = ctx.build_index, ctx.plate + node.placement_known, node.repeated, node.top = ctx.placement_known, ctx.repeated, ctx + self.nodes.append(node) + if parent is None: + ctx.node = node + else: + parent.children.append(node) + if obj.mesh is not None: + node.mesh_key = key + self.rendered += obj.mesh.ntris + if self.rendered > L.MAX_RENDERED_TRIANGLES: + raise SceneError("LIMIT_EXCEEDED", f"The project has at least {self.rendered:,} triangles counting repeated objects; Studio draws at most {L.MAX_RENDERED_TRIANGLES:,}.") + if key not in self.mesh_users: + self.mesh_users[key] = [] + self.mesh_order.append(key) + self.mesh_users[key].append(node.id) + elif obj.components: + for i, comp in enumerate(obj.components): + self._expand((comp.part, comp.object_id), comp.transform, node, path + [i], ctx, + stack + (key,), via=comp.object_id, inherited=node.role_ctx) + else: + raise SceneError("INVALID_GEOMETRY", "An object has neither a mesh nor components.") + return node + + @staticmethod + def _node_id(ctx: TopContext, parent: Node | None, path: list[int]) -> str: + """``b`` plus ``.c`` per component level. When that would not fit in + ``MAX_ID_LENGTH`` the ancestry is replaced by a deterministic digest of it (``b3.~<32 hex>``), + which is unique per ancestry, so 128 characters always suffice at the deepest allowed nesting.""" + natural = f"b{ctx.ordinal}" + "".join(f".c{i}" for i in path) + if len(natural) <= L.MAX_ID_LENGTH: + return natural + return f"b{ctx.ordinal}.~" + hashlib.sha256(natural.encode("ascii")).hexdigest()[:32] + + # ---- volumes ------------------------------------------------------------------------- + def mesh_data(self, key) -> MeshData: + return self.files[key[0]].objects[key[1]].mesh + + def build_mesh_info(self, key) -> MeshInfo: + data = self.mesh_data(key) + info = MeshInfo(key, data) + total = data.ntris + if (self.settings.dialect == "prusa" and key[0] == self.root_part + and key[1] in self.settings.prusa_volumes): + info.volumes = self._prusa_ranges(self.settings.prusa_volumes[key[1]], total) + else: + info.volumes = [{"id": "v0", "triangle_start": 0, "triangle_count": total, "role": "part", "source": "object"}] + if len(info.volumes) > L.MAX_VOLUMES_PER_MESH: + raise SceneError("LIMIT_EXCEEDED", "A mesh has more volumes than Studio will carry.") + used: set[int] = set() + for vol in info.volumes: + if vol["role"] == "part": + info.has_part = True + s, c = vol["triangle_start"], vol["triangle_count"] + used.update(data.idx[3 * s:3 * (s + c)]) + else: + info.has_non_part = True + if vol["role"] == "unknown": + info.has_unknown = True + if used: + pos = data.pos + lo = [math.inf] * 3 + hi = [-math.inf] * 3 + info.part_verts = sorted(used) + for n, vi in enumerate(info.part_verts): + if n % L.VERTEX_BATCH == 0: + self.ctl.check() + b = 3 * vi + for ax in range(3): + v = pos[b + ax] + if v < lo[ax]: + lo[ax] = v + if v > hi[ax]: + hi[ax] = v + info.lo, info.hi = lo, hi + return info + + @staticmethod + def _prusa_ranges(entries: list[dict], total: int) -> list[dict]: + """Partition ALL triangles into volumes. An unmapped gap is an ``unknown`` volume; ranges that + overlap or leave the mesh make the whole mesh one ``unknown`` volume (never a guess).""" + whole = [{"id": "v0", "triangle_start": 0, "triangle_count": total, "role": "unknown", "source": "prusa_range"}] + if any(e["range"] is None for e in entries): + return whole # a range Studio cannot read: never a printable guess + spans = sorted((e["range"][0], e["range"][1], e["role"]) for e in entries) + out: list[dict] = [] + cursor = 0 + for first, last, role in spans: + if first < cursor or last < first or last >= total: + return whole + if first > cursor: + out.append({"id": f"v{len(out)}", "triangle_start": cursor, "triangle_count": first - cursor, + "role": "unknown", "source": "prusa_range"}) + out.append({"id": f"v{len(out)}", "triangle_start": first, "triangle_count": last - first + 1, + "role": role, "source": "prusa_range"}) + cursor = last + 1 + if cursor < total: + out.append({"id": f"v{len(out)}", "triangle_start": cursor, "triangle_count": total - cursor, + "role": "unknown", "source": "prusa_range"}) + return out or whole + + +def _world_bounds(info: MeshInfo, world: list[float], ctl: Control): + """Exact world-space bounds of the printable (role=part) vertices of one instance.""" + if not info.part_verts: + return None + m = world + # Rows with at most one non-zero entry map each output axis from a single input axis, so the + # transformed local box is exact (no rotation). Anything else transforms the vertices. + rows = [] + simple = True + for i in range(3): + nz = [(j, m[j * 4 + i]) for j in range(3) if abs(m[j * 4 + i]) > 1e-12] + if len(nz) > 1: + simple = False + break + rows.append(nz[0] if nz else None) + if simple: + lo_out = [0.0] * 3 + hi_out = [0.0] * 3 + for i in range(3): + t = m[12 + i] + if rows[i] is None: + lo_out[i] = hi_out[i] = t + else: + j, c = rows[i] + a, b = c * info.lo[j] + t, c * info.hi[j] + t + lo_out[i], hi_out[i] = (a, b) if a <= b else (b, a) + return lo_out, hi_out + pos = info.data.pos + lo_out = [math.inf] * 3 + hi_out = [-math.inf] * 3 + m0, m1, m2, m4, m5, m6, m8, m9, m10, t0, t1, t2 = (m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10], m[12], m[13], m[14]) + for n, vi in enumerate(info.part_verts): + if n % L.VERTEX_BATCH == 0: + ctl.check() + b = 3 * vi + x, y, z = pos[b], pos[b + 1], pos[b + 2] + px = m0 * x + m4 * y + m8 * z + t0 + py = m1 * x + m5 * y + m9 * z + t1 + pz = m2 * x + m6 * y + m10 * z + t2 + if px < lo_out[0]: lo_out[0] = px + if px > hi_out[0]: hi_out[0] = px + if py < lo_out[1]: lo_out[1] = py + if py > hi_out[1]: hi_out[1] = py + if pz < lo_out[2]: lo_out[2] = pz + if pz > hi_out[2]: hi_out[2] = pz + return lo_out, hi_out + + +# -------------------------------------------------------------------------------------------- +# Plates +# -------------------------------------------------------------------------------------------- + +def _plate_assignment(settings: SettingsInfo, oid: str, ordinal: int, instances_of_object: int) -> tuple[dict, bool]: + """(plate ref, ambiguous?) for the ``ordinal``-th build item of object ``oid``.""" + if not settings.plates: + return {"state": "unknown", "plate_id": None, "source": None}, False + found: set[str] = set() + for idx, plate in enumerate(settings.plates): + pid = f"p{plate['ui_number']}" if plate["ui_number"] is not None else f"plate-{idx}" + for moid, inst in plate["members"]: + if moid != oid: + continue + if inst is None: + # a record that does not say which instance covers all of them only if there is one + if instances_of_object == 1: + found.add(pid) + else: + return {"state": "ambiguous", "plate_id": None, "source": "plate_config"}, True + elif inst.isascii() and inst.isdigit() and int(inst) == ordinal: + found.add(pid) + if len(found) == 1: + return {"state": "known", "plate_id": next(iter(found)), "source": "plate_config"}, False + if len(found) > 1: + return {"state": "ambiguous", "plate_id": None, "source": "plate_config"}, True + return {"state": "unknown", "plate_id": None, "source": None}, False + + +# -------------------------------------------------------------------------------------------- +# Assembly and encoding +# -------------------------------------------------------------------------------------------- + +class _Limitations: + ORDER = ("UNKNOWN_VOLUME_ROLE", "UNKNOWN_PLACEMENT", "PLATE_MEMBERSHIP_AMBIGUOUS", "PLATE_MEMBERSHIP_UNKNOWN", + "PLATES_UNAVAILABLE", "BED_TEMPLATE_UNAVAILABLE", "REPEATED_INSTANCE_PLACEMENT_UNVERIFIED", + "NON_MM_SOURCE_UNIT", "UNSUPPORTED_UNIT", "MULTI_PLATE_PLACEMENT_UNCHECKED", "NO_BUILD_ITEMS") + + def __init__(self) -> None: + self._by: dict[str, list[str]] = {} + + def has(self, code: str) -> bool: + return code in self._by + + def add(self, code: str, target_ids=()) -> None: + bucket = self._by.setdefault(code, []) + for t in target_ids: + if t not in bucket: + bucket.append(t) + + def render(self) -> list[dict]: + out = [{"code": c, "target_ids": self._by[c][:2000]} for c in self.ORDER if c in self._by] + if len(out) > L.MAX_LIMITATIONS: + raise SceneError("LIMIT_EXCEEDED", "The scene has more limitations than Studio will carry.") + return out + + +def _b64_len(n: int) -> int: + return 4 * ((n + 2) // 3) + + +def _encode_f32(pos: array, ctl: Control) -> str: + step = 3 * L.GEOMETRY_BATCH_TRIANGLES + out = [] + for s in range(0, len(pos), step): + ctl.check() + chunk = array("f", pos[s:s + step]) + if sys.byteorder == "big": + chunk.byteswap() + out.append(base64.b64encode(chunk.tobytes()).decode("ascii")) + return "".join(out) + + +def _encode_u32(idx: array, ctl: Control) -> str: + assert idx.itemsize == 4 + step = 3 * L.GEOMETRY_BATCH_TRIANGLES + out = [] + for s in range(0, len(idx), step): + ctl.check() + chunk = idx[s:s + step] + if sys.byteorder == "big": + chunk = array("I", chunk) + chunk.byteswap() + out.append(base64.b64encode(chunk.tobytes()).decode("ascii")) + return "".join(out) + + +def _rounded(v) -> list[float]: + return [round(float(x), 6) + 0.0 for x in v] + + +def _finish(revision: str, sources: list[dict], bed: dict, rect: dict, trav: Traversal, tops: list[TopContext], + plates: list[dict], plate_count: int, lims: _Limitations, ctl: Control, *, + repeated_tops: list[str]) -> dict: + from . import plate_placement as pp + + ctl.progress("encoding", 0, trav.rendered) + infos = {key: trav.build_mesh_info(key) for key in trav.mesh_order} + + # per-node printable facts, children before parents + for node in reversed(trav.nodes): + if node.mesh_key is not None: + info = infos[node.mesh_key] + if node.role_ctx not in (None, "part"): + # this USE is a modifier / negative part / support helper (or unknown): nothing prints + node.printable = None if node.role_ctx == "unknown" else False + node.has_non_part = True + node.bounds = None + else: + node.printable = True if info.has_part else (None if info.has_unknown else False) + node.has_non_part = info.has_non_part + node.bounds = _world_bounds(info, node.world, ctl) if info.has_part else None + else: + kids = node.children + node.has_non_part = any(k.has_non_part for k in kids) + node.printable = True if any(k.printable is True for k in kids) else ( + None if any(k.printable is None for k in kids) else False) + boxes = [k.bounds for k in kids if k.bounds is not None] + node.bounds = ([min(b[0][i] for b in boxes) for i in range(3)], + [max(b[1][i] for b in boxes) for i in range(3)]) if boxes else None + for node in trav.nodes: + if node.mesh_key is not None and node.role_ctx == "unknown": + lims.add("UNKNOWN_VOLUME_ROLE", [node.id]) + elif node.mesh_key is not None and node.role_ctx in (None, "part") and infos[node.mesh_key].has_unknown: + lims.add("UNKNOWN_VOLUME_ROLE", [node.id]) + + # findings -- only what the geometry plus the file prove + findings: list[dict] = [] + all_mm = all(s["unit"] == "millimeter" for s in sources) and not lims.has("UNSUPPORTED_UNIT") + if not all(s["unit"] == "millimeter" for s in sources): + lims.add("NON_MM_SOURCE_UNIT") + if plate_count > 1: + lims.add("MULTI_PLATE_PLACEMENT_UNCHECKED") + usable_x = rect["max_x"] - rect["min_x"] - 2 * pp.EDGE_MARGIN_MM + usable_y = rect["max_y"] - rect["min_y"] - 2 * pp.EDGE_MARGIN_MM + enabled = bed["policy"] == "u1_template" and plate_count <= 1 + all_mm_or_known = not lims.has("UNSUPPORTED_UNIT") + index_of = {id(n): i for i, n in enumerate(trav.nodes)} + for ctx in tops: + node = ctx.node + if node is None or not enabled or node.bounds is None or node.printable is not True: + continue + if plate_count > 0 and node.plate["state"] != "known": + continue # the project has plates but this instance is provably on none / on two + if ctx.repeated or not ctx.placement_known: + # REPEATED_INSTANCE_PLACEMENT_UNVERIFIED / unknown placement: geometry stays visible, + # but no bed-fit statement of any kind is attached. + continue + lo, hi = node.bounds + pointer = f"/nodes/{index_of[id(node)]}" + if all_mm: + over = pp._overhang({"min": lo, "max": hi}, rect) + if any(v > 0 for v in over.values()): + fid = f"f{len(findings)}" + findings.append({"id": fid, "engine": "scene", "schema": SCHEMA_ID, "pointer": pointer, + "scope": "instance", "kind": "placement", "target_ids": [node.id], + "value": {"code": "PLACEMENT_OUTSIDE_BED", "overhang_mm": over}}) + node.finding_ids.append(fid) + if all_mm_or_known and ((hi[0] - lo[0]) > usable_x + 1e-9 or (hi[1] - lo[1]) > usable_y + 1e-9): + fid = f"f{len(findings)}" + findings.append({"id": fid, "engine": "scene", "schema": SCHEMA_ID, "pointer": pointer, + "scope": "instance", "kind": "size", "target_ids": [node.id], + "value": {"code": "SIZE_EXCEEDS_BED", "overhang_mm": None}}) + node.finding_ids.append(fid) + if len(findings) > L.MAX_FINDINGS: + raise SceneError("LIMIT_EXCEEDED", "The scene has more findings than Studio will carry.") + if repeated_tops: + lims.add("REPEATED_INSTANCE_PLACEMENT_UNVERIFIED", repeated_tops) + + # response preflight: the geometry alone decides this exactly, before any encoding work + geom = 0 + for info in infos.values(): + geom += _b64_len(info.data.nverts * 12) + _b64_len(info.data.ntris * 12) + if geom > L.MAX_RESPONSE_BYTES: + raise SceneError("LIMIT_EXCEEDED", "The geometry alone is larger than the response Studio will send.") + + meshes = [] + done = 0 + for key in trav.mesh_order: + info = infos[key] + d = info.data + meshes.append({ + "key": {"part": key[0], "object_id": key[1]}, + "vertex_count": d.nverts, "triangle_count": d.ntris, + "positions_f32le_base64": _encode_f32(d.pos, ctl), + "indices_u32le_base64": _encode_u32(d.idx, ctl), + "volumes": info.volumes}) + done += d.ntris + ctl.progress("encoding", done, trav.rendered) + + nodes = [] + for node in trav.nodes: + nodes.append({ + "id": node.id, + "parent_id": node.parent.id if node.parent else None, + "resource": {"part": node.resource[0], "object_id": node.resource[1]}, + "mesh_key": ({"part": node.mesh_key[0], "object_id": node.mesh_key[1]} if node.mesh_key else None), + "local_to_parent_mm": _round_matrix(node.local), + "world_mm": _round_matrix(node.world), + "mirrored": node.mirrored, + "role_context": node.role_ctx, + "build_index": node.build_index, + "instance_ref": {"build_index": node.build_index, "component_path": node.path}, + "plate": node.plate, + "placement_state": "known" if node.placement_known else "unknown", + "printable": node.printable, + "has_non_part_volumes": node.has_non_part, + "bounds_mm": ({"min": _rounded(node.bounds[0]), "max": _rounded(node.bounds[1])} if node.bounds else None), + "finding_ids": node.finding_ids}) + limitations = lims.render() + status = "partial" if any(x["code"] in PARTIAL_LIMITATIONS for x in limitations) else "complete" + return { + "schema": SCHEMA_ID, "revision": revision, "status": status, "units": "mm", "axes": "right-handed-z-up", + "sources": sources, "bed": bed, "meshes": meshes, "nodes": nodes, "plates": plates, + "findings": findings, "limitations": limitations, + "counts": {"nodes": len(nodes), "meshes": len(meshes), + "vertices": sum(i.data.nverts for i in infos.values()), + "triangles": sum(i.data.ntris for i in infos.values()), + "rendered_triangles": trav.rendered, "plates": len(plates), + "findings": len(findings), "limitations": len(limitations)}, + "limits": dict(L.LIMITS_ECHO)} + + +def serialize(scene: dict) -> bytes: + """The whole response body, once, compact ASCII JSON, with the cap enforced AS IT IS BUILT. + + Each top-level member (and each element of a list member) is serialized separately and the running + length is checked before it is kept, so an over-cap scene is refused after at most one element past + the cap: the full body (or a UTF-8 copy of it) is never built first. + """ + cap = L.MAX_RESPONSE_BYTES + + def dump(value) -> str: + try: + return json.dumps(value, separators=(",", ":"), allow_nan=False) + except ValueError as exc: + raise SceneError("INVALID_GEOMETRY", "The scene contains a value that is not finite.") from exc + + def over() -> SceneError: + return SceneError("LIMIT_EXCEEDED", "The scene is larger than the response Studio will send.") + + total = 2 # the braces; every other byte is added exactly, before it is kept + members = [] + for key, value in scene.items(): + head = dump(key) + ":" + total += len(head) + (1 if members else 0) # the comma between members + if total > cap: + raise over() + if isinstance(value, list) and value and isinstance(value[0], dict): + items = [] + total += 2 # [ ] + for item in value: + text = dump(item) + total += len(text) + (1 if items else 0) + if total > cap: + raise over() + items.append(text) + members.append(head + "[" + ",".join(items) + "]") + else: + text = dump(value) + total += len(text) + if total > cap: + raise over() + members.append(head + text) + return ("{" + ",".join(members) + "}").encode("ascii") + + +def _sha256_file(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(L.SNAPSHOT_CHUNK_BYTES), b""): + h.update(chunk) + return h.hexdigest() + + +# -------------------------------------------------------------------------------------------- +# Front ends +# -------------------------------------------------------------------------------------------- + +def _build_3mf(path: str, revision: str, ctl: Control) -> dict: + lims = _Limitations() + bed, rect = _bed([]) + if bed["policy"] == "fallback": + lims.add("BED_TEMPLATE_UNAVAILABLE") + arc = Archive(path, ctl) + try: + ctl.progress("parsing", 0, None) + root_part = find_root_model(arc) + budget = Budget() + files_by_part: dict[str, ModelFile] = {} + queue = [root_part] + while queue: + part = queue.pop(0) + if part in files_by_part: + continue + if len(files_by_part) >= L.MAX_MODEL_PARTS: + raise SceneError("LIMIT_EXCEEDED", "The project uses more model parts than Studio will read.") + if not arc.has(part): + raise SceneError("UNRESOLVED_REFERENCE", "The project refers to a model part that is not in the archive.") + canon = arc.index[part.casefold()][0] + mf = parse_model(arc, canon, budget, ctl, canon == root_part) + files_by_part[canon] = mf + ctl.progress("parsing", arc.model_read, arc.model_declared) + for item in mf.build: # a build item's p:path names the FILE its object lives in + if not arc.has(item.part): + raise SceneError("UNRESOLVED_REFERENCE", "A build item refers to a model part that is not in the archive.") + item.part = arc.index[item.part.casefold()][0] + if item.part not in files_by_part: + queue.append(item.part) + for obj in mf.objects.values(): + for comp in obj.components: + if not arc.has(comp.part): + raise SceneError("UNRESOLVED_REFERENCE", "A component refers to a model part that is not in the archive.") + comp.part = arc.index[comp.part.casefold()][0] + if comp.part not in files_by_part: + queue.append(comp.part) + settings = parse_settings(arc, ctl) + root = files_by_part[root_part] + bad_unit_parts = {mf.part for mf in files_by_part.values() if not mf.unit_recognized} + sources = [{"part": mf.part, "unit": mf.unit, "mm_per_unit": mf.mm_per_unit} + for mf in files_by_part.values()] + plates = [] + seen_ui = set() + for idx, plate in enumerate(settings.plates): + ui = plate["ui_number"] + if ui is not None and ui in seen_ui: + continue + seen_ui.add(ui) + plates.append({"id": f"p{ui}" if ui is not None else f"plate-{idx}", "ui_number": ui, "origin_mm": None}) + trav = Traversal(files_by_part, settings, root_part, ctl) + counts: dict[tuple[str, str], int] = {} + for item in root.build: + counts[(item.part, item.object_id)] = counts.get((item.part, item.object_id), 0) + 1 + part_by_bare: dict[str, set[str]] = {} + for item in root.build: + part_by_bare.setdefault(item.object_id, set()).add(item.part) + tops: list[TopContext] = [] + seen_n: dict[tuple[str, str], int] = {} + repeated_tops: list[str] = [] + ambiguous_ids: list[str] = [] + cross_file_ambiguous: list = [] + unknown_plate_ids: list[str] = [] + if root.build: + for bi, item in enumerate(root.build): + ikey = (item.part, item.object_id) + n = seen_n.get(ikey, 0) + seen_n[ikey] = n + 1 + plate, ambiguous = _plate_assignment(settings, item.object_id, n, counts[ikey]) + if len(part_by_bare[item.object_id]) > 1 and settings.plates: + # plate records name a BARE object id: when two build resources in different files share + # it, a record cannot prove membership for either of them + plate, ambiguous = {"state": "ambiguous", "plate_id": None, "source": "plate_config"}, False + cross_file_ambiguous.append(None) + ctx = TopContext(bi, bi, plate, True, counts[ikey] > 1) + trav.add_tree(ikey, item.transform, ctx) + tops.append(ctx) + if ctx.repeated: + repeated_tops.append(ctx.node.id) + if ambiguous: + ambiguous_ids.append(ctx.node.id) + elif plate["state"] == "unknown" and settings.plates: + unknown_plate_ids.append(ctx.node.id) + elif cross_file_ambiguous and plate["state"] == "ambiguous" and ctx.node.id not in unknown_plate_ids: + unknown_plate_ids.append(ctx.node.id) + else: + lims.add("NO_BUILD_ITEMS") + for ordinal, oid in enumerate(root.objects): + ctx = TopContext(ordinal, None, {"state": "unknown", "plate_id": None, "source": None}, False, False) + trav.add_tree((root_part, oid), None, ctx) + tops.append(ctx) + lims.add("UNKNOWN_PLACEMENT", [ctx.node.id]) + if bad_unit_parts: + # target_ids name the NODES that live in the affected part(s); for such a source `unit` and + # `mm_per_unit` in sources[] are the fallback millimetre values (the limitation says so) + # a build item's transform is written in the ROOT model's unit, so when the root is the bad part the + # top node of every item is affected too, even if its mesh lives in another file + lims.add("UNSUPPORTED_UNIT", [n.id for n in trav.nodes + if n.resource[0] in bad_unit_parts or (n.parent is None and root_part in bad_unit_parts)]) + if not settings.plates: + lims.add("PLATES_UNAVAILABLE") + if ambiguous_ids: + lims.add("PLATE_MEMBERSHIP_AMBIGUOUS", ambiguous_ids) + if unknown_plate_ids: + lims.add("PLATE_MEMBERSHIP_UNKNOWN", unknown_plate_ids) + return _finish(revision, sources, bed, rect, trav, tops, plates, len(plates), lims, ctl, + repeated_tops=repeated_tops) + finally: + arc.close() + + +def _build_stl(path: str, revision: str, ctl: Control) -> dict: + import os + import struct + + from .stl_io import StlTooLarge, decode_stl_stream + + lims = _Limitations() + bed, rect = _bed([]) + if bed["policy"] == "fallback": + lims.add("BED_TEMPLATE_UNAVAILABLE") + size = os.path.getsize(path) + ctl.progress("parsing", 0, size) + try: + with open(path, "rb") as fh: + # bounded and incremental: the binary header count is judged BEFORE any triangle is decoded, + # decoding runs in batches with the cancel/deadline hook between them, ASCII is line-streamed + verts, tris = decode_stl_stream(fh, size, max_triangles=L.MAX_PARSED_TRIANGLES, + max_vertices=L.MAX_VERTICES, check=ctl.check, + batch=L.GEOMETRY_BATCH_TRIANGLES) + except StlTooLarge as exc: + raise SceneError("LIMIT_EXCEEDED", f"The model has {exc.triangles:,} {exc.what}; Studio draws at most {exc.limit:,}.") from exc + except (SnapStudioError, ValueError, struct.error) as exc: + raise SceneError("INVALID_GEOMETRY", "This STL file could not be read.") from exc + mesh = MeshData() + for n, v in enumerate(verts): + if n % L.VERTEX_BATCH == 0: + ctl.check() + if not all(math.isfinite(c) and abs(c) <= L.MAX_COORDINATE_MM for c in v): + raise SceneError("INVALID_GEOMETRY", "A vertex coordinate is not usable.") + mesh.pos.extend(v) + for n, t in enumerate(tris): + if n % L.VERTEX_BATCH == 0: + ctl.check() + mesh.idx.extend(t) + mesh.nverts, mesh.ntris, mesh.max_index = len(verts), len(tris), len(verts) - 1 + part = "model.stl" + mf = ModelFile(part) + obj = ObjectDef("0") + obj.mesh = mesh + mf.objects["0"] = obj + trav = Traversal({part: mf}, SettingsInfo(), part, ctl) + ctx = TopContext(0, None, {"state": "unknown", "plate_id": None, "source": None}, False, False) + trav.add_tree((part, "0"), None, ctx) + lims.add("UNKNOWN_PLACEMENT", [ctx.node.id]) + lims.add("PLATES_UNAVAILABLE") + return _finish(revision, [{"part": part, "unit": "millimeter", "mm_per_unit": 1.0}], bed, rect, trav, [ctx], [], 0, + lims, ctl, repeated_tops=[]) + + +def build_scene_dict(path: str, revision: str | None = None, ctl: Control | None = None) -> dict: + """Build the scene/1 object for ``path`` (a private snapshot, when called by a job).""" + ctl = ctl or Control() + low = str(path).lower() + if not (low.endswith(".3mf") or low.endswith(".stl")): + raise SceneError("UNSUPPORTED_FORMAT", "Only .3mf and .stl files can be shown.") + revision = revision or _sha256_file(path) + if low.endswith(".stl"): + return _build_stl(path, revision, ctl) + return _build_3mf(path, revision, ctl) + + +def build_scene(path: str, revision: str | None = None, ctl: Control | None = None) -> bytes: + """The serialized scene (compact UTF-8 JSON), with the whole-body cap enforced.""" + return serialize(build_scene_dict(path, revision, ctl)) diff --git a/backend/snapstudio_core/scene_limits.py b/backend/snapstudio_core/scene_limits.py new file mode 100644 index 00000000..c46b02a5 --- /dev/null +++ b/backend/snapstudio_core/scene_limits.py @@ -0,0 +1,66 @@ +"""Every numeric budget, cap, deadline and TTL behind the scene/1 contract, in one place. + +Nothing else in the scene code carries a literal limit. These are PROVISIONAL budgets (see +docs/testing/scene-contract/README.md for the corpus they were measured against). Each one is +enforced while the work happens, never after, and exceeding one is a clear LIMIT_EXCEEDED, never +a silently shortened scene. +""" +from __future__ import annotations + +MIB = 1024 * 1024 + +# --- what the engine will copy and read ------------------------------------- +SNAPSHOT_MAX_BYTES = 128 * MIB # hard ceiling on the private copy of the source file +SNAPSHOT_CHUNK_BYTES = 1 * MIB # streamed copy / archive read unit (bounded uninterruptible work) +DISK_HEADROOM_BYTES = 64 * MIB # free space required beyond the source size +ARCHIVE_EXPANSION_BYTES = 128 * MIB # total decompressed bytes the reader will pull from one archive +MODEL_XML_BYTES = 32 * MIB # decompressed bytes of all .model parts together +CONFIG_PART_BYTES = 8 * MIB # one metadata/config part (model_settings, Slic3r_PE_model, .rels) +ARCHIVE_MAX_ENTRIES = 20_000 # zip directory entries +MAX_MODEL_PARTS = 256 # distinct .model files parsed for one scene +MAX_RELS_PARTS = 64 # .rels parts examined + +# --- what the scene may contain --------------------------------------------- +MAX_RENDERED_TRIANGLES = 250_000 # counting every repetition of an instanced mesh +MAX_PARSED_TRIANGLES = 250_000 # distinct triangles accepted while parsing +MAX_VERTICES = 300_000 # distinct decoded vertices +MAX_NODES = 2_000 +MAX_OBJECT_DEFINITIONS = 20_000 # elements across all model parts (10x the node budget) +MAX_XML_ELEMENTS = 1_000_000 # every completed XML element, counted while parsing +MAX_PART_NAME_LENGTH = 256 # archive part names carried on nodes/meshes/sources (schema maxLength) +MAX_DEPTH = 64 +MAX_ID_LENGTH = 128 +MAX_FINDINGS = 500 +MAX_LIMITATIONS = 50 +MAX_VOLUMES_PER_MESH = 4_096 +MAX_RESPONSE_BYTES = 8 * MIB # the whole serialized body +MAX_COORDINATE_MM = 1.0e7 # keeps every coordinate representable as float32 + +# --- cooperative interruption (v4 item 2) ----------------------------------- +XML_EVENTS_PER_CHECK = 1_000 # lxml iterparse events between cancel/deadline checks +GEOMETRY_BATCH_TRIANGLES = 5_000 # decode/encode batch; 5_000 * 12 bytes is a multiple of 3 (base64-safe) +VERTEX_BATCH = 15_000 # transformed vertices between checks + +# --- jobs ------------------------------------------------------------------- +JOB_DEADLINE_SECONDS = 60.0 # wall clock from the moment the job becomes running +WEDGE_GRACE_SECONDS = 5.0 # worker still alive this long after cancel/TIMEOUT => WORKER_WEDGED +RESULT_TTL_SECONDS = 120.0 # a terminal job is kept this long +MAX_TERMINAL_JOBS = 8 # ... or until more than this many terminal jobs exist +MAX_REQUEST_ID_LENGTH = 64 +MAX_SESSIONS = 32 # open scene sessions (an idle-expired one that owns no job is pruned first) +SESSION_TTL_SECONDS = 15 * 60 # idle time after which a session without a registered job is pruned +SNAPSHOT_DIR_MIN_AGE_SECONDS = 600 # a dead engine's scene-tmp folder is swept only once it is this old (a live engine's NEVER is) +MAX_PENDING_UNLINKS = 1024 # snapshot files that could not be deleted yet and are retried with back-off + +LIMITS_ECHO = { + "max_archive_bytes": ARCHIVE_EXPANSION_BYTES, + "max_model_xml_bytes": MODEL_XML_BYTES, + "max_rendered_triangles": MAX_RENDERED_TRIANGLES, + "max_vertices": MAX_VERTICES, + "max_nodes": MAX_NODES, + "max_depth": MAX_DEPTH, + "max_response_bytes": MAX_RESPONSE_BYTES, + "max_findings": MAX_FINDINGS, + "max_limitations": MAX_LIMITATIONS, + "max_id_length": MAX_ID_LENGTH, +} diff --git a/backend/snapstudio_core/stl_io.py b/backend/snapstudio_core/stl_io.py index 06928fc5..579e08a7 100644 --- a/backend/snapstudio_core/stl_io.py +++ b/backend/snapstudio_core/stl_io.py @@ -1,4 +1,5 @@ from __future__ import annotations +import re import struct from .errors import SnapStudioError @@ -46,3 +47,132 @@ def parse_stl(data: bytes): if not tris: raise SnapStudioError("STL contained no triangles") return verts, tris + + +# --- bounded, incremental decoder (additive; parse_stl above is unchanged) ----------------------------- + +class StlTooLarge(SnapStudioError): + """The file declares or contains more triangles (or vertices) than the caller allows.""" + + def __init__(self, triangles: int, limit: int, what: str = "triangles") -> None: + self.triangles, self.limit, self.what = triangles, limit, what + super().__init__(f"{triangles} {what}; limit {limit}") + + +_BATCH = 5000 + + +def _decode_binary_batch(raw: bytes, vmap: dict, verts: list, tris: list) -> None: + for rec in struct.iter_unpack("<12fH", raw): + tris.append((_add(vmap, verts, rec[3:6]), _add(vmap, verts, rec[6:9]), _add(vmap, verts, rec[9:12]))) + + +_CHUNK = 1024 * 1024 +_MAX_LINE = 4096 +_LINE_SPLIT = re.compile(rb"\r\n|\n|\r") # \n, \r\n and a lone \r, like parse_stl's splitlines + + +def _plain_text(head: bytes) -> bool: + """Plausibly TEXT: no NUL in the first KB and valid UTF-8 (a multi-byte character cut by the end of the + window is fine). A name such as ``solid Würfel`` is text; random binary is not.""" + first = head[:1024] + if b"\x00" in first: + return False + try: + first.decode("utf-8") + except UnicodeDecodeError as exc: + return exc.reason == "unexpected end of data" and exc.end >= len(first) + return True + + +def _looks_like_ascii_stl(head: bytes) -> bool: + """A "solid"/"facet" start on a file that is plausibly TEXT.""" + if not _plain_text(head): + return False + low = head.lstrip().lower() + return low.startswith(b"solid") or b"facet" in head.lower() + + +def decode_stl_stream(fh, size: int, *, max_triangles: int, max_vertices: int, check=lambda: None, + batch: int = _BATCH): + """Decode an STL from an open binary file without ever holding more than the caller's budget. + + The kind is decided from the 84-byte header AND the file size: a size equal to ``84 + 50 * N`` is + binary; so is a file whose first KB is not plausible text, and for those a declared ``N`` over + ``max_triangles`` is rejected IMMEDIATELY, before any triangle (or any large buffer) exists, whether or + not the size matches. Only plausible ASCII goes down the text path, which reads <= 1 MiB at a time, + refuses any line over 4 KiB, splits on LF, CRLF and a lone CR, keeps a running triangle counter and + calls ``check()`` (the cancel/deadline hook) per chunk. Binary decoding runs in batches of ``batch`` + triangles with ``check()`` between them. Vertices are de-duplicated exactly as ``parse_stl`` does. + Raises ``StlTooLarge`` or ``SnapStudioError``. + """ + head = fh.read(84) + verts: list[tuple] = [] + tris: list[tuple] = [] + vmap: dict = {} + if len(head) >= 84: + (n,) = struct.unpack_from(" max_triangles: + raise StlTooLarge(n, max_triangles) + if not consistent: + raise SnapStudioError("STL size does not match its header") + remaining = n + while remaining: + check() + take = min(batch, remaining) + raw = fh.read(take * 50) + if len(raw) != take * 50: + raise SnapStudioError("STL ended early") + _decode_binary_batch(raw, vmap, verts, tris) + if len(verts) > max_vertices: + raise StlTooLarge(len(verts), max_vertices, "vertices") + remaining -= take + if not tris: + raise SnapStudioError("STL contained no triangles") + return verts, tris + fh.seek(0) + head_for_text = first + else: + fh.seek(0) + head_for_text = head + if not _looks_like_ascii_stl(head_for_text): + raise SnapStudioError("unrecognized STL (not binary-size-consistent or ASCII)") + fh.seek(0) + cur: list[tuple] = [] + carry = b"" + + def feed(line: bytes) -> None: + nonlocal cur + if len(line) > _MAX_LINE: + raise SnapStudioError("STL has a line longer than 4096 bytes") + s = line.strip() + if s.startswith(b"vertex"): + _, x, y, z = s.split()[:4] + cur.append((float(x), float(y), float(z))) + if len(cur) == 3: + tris.append(tuple(_add(vmap, verts, p) for p in cur)) + cur = [] + if len(tris) > max_triangles: + raise StlTooLarge(len(tris), max_triangles) + if len(verts) > max_vertices: + raise StlTooLarge(len(verts), max_vertices, "vertices") + + while True: + check() + chunk = fh.read(_CHUNK) + if not chunk: + break + parts = _LINE_SPLIT.split(carry + chunk) + carry = parts.pop() + if len(carry) > _MAX_LINE: + raise SnapStudioError("STL has a line longer than 4096 bytes") + for line in parts: + feed(line) + if carry: + feed(carry) + if not tris: + raise SnapStudioError("STL contained no triangles") + return verts, tris diff --git a/backend/snapstudio_core/support_intent.py b/backend/snapstudio_core/support_intent.py new file mode 100644 index 00000000..88dfc399 --- /dev/null +++ b/backend/snapstudio_core/support_intent.py @@ -0,0 +1,135 @@ +"""Whether the source expresses explicit support intent, which Recommended must not silently discard. + +Recommended is the "U1 starter settings" mode: speed, temperature, retraction, supports and cooling are the +starter's, and the support policy of a creator's print profile is rightly reset. The one exception is a +model's own, positive statement of where it needs support. The rule, in order of precedence (the first that +applies decides): + +1. **Explicit disable wins.** ``enable_support`` = 0 in the project's settings, or ``enable_support`` = 0 on any + object, means supports are off and stay off. The model's painting or enforcers are then disclosed as + unused; nothing is declared. +2. **Positive intent is kept.** Painted support *enforcers* (``paint_supports`` facets whose paint has the + enforcer state), ``support_enforcer`` volumes, or ``enable_support`` = 1 on an object. The creator's + ``enable_support`` / ``support_type`` / ``support_filament`` / ``support_interface_filament`` are declared + as the project's own and kept. +3. **Otherwise the starter reset stands** (this is what the mode says it does): no support data at all, or + blocker-only data. Blockers (``support_blocker`` volumes, blocker paint) say where NOT to support, which + is not a request for supports, so they never keep anything. +4. Whatever is not decided above takes the creator's value (Preserve mode never reaches this module). + +Paint states are the codec's: leaf state 1 is "enforcer", 2 is "blocker" (Orca's ``EnforcerBlockerType``). +""" +from __future__ import annotations + +import re + +from .container import ThreeMF + +SUPPORT_KEYS = ("enable_support", "support_type", "support_filament", "support_interface_filament") + +ENFORCER_STATE = 1 +BLOCKER_STATE = 2 + +_PAINT = re.compile(rb'paint_supports="([^"]+)"') +_OBJECT_ENABLE = re.compile(r' bool: + return str(value).strip().lower() in ("0", "false", "off", "no") + + +def _on(value) -> bool: + return str(value).strip().lower() in ("1", "true", "on", "yes") + + +def _paint_states(tm: ThreeMF) -> set[int]: + from . import paint_codec + + states: set[int] = set() + seen: set[bytes] = set() + for part in tm.list_parts(): + if not part.endswith(".model"): + continue + for match in _PAINT.finditer(tm.read_part(part)): + attribute = match.group(1) + if attribute in seen or len(seen) >= _MAX_DISTINCT_PAINT: + continue + seen.add(attribute) + try: + states |= set(paint_codec.states(attribute.decode("ascii", "ignore"))) + except Exception: # noqa: BLE001 - paint Studio cannot decode is not evidence of intent + continue + if ENFORCER_STATE in states and BLOCKER_STATE in states: + return states + return states + + +def detect(tm: ThreeMF, source_cfg: dict) -> dict: + """The decision (``kept`` / ``disabled`` / ``starter_reset``) and the evidence for it.""" + from . import roles + from .assignments import SUPPORT_BLOCKER, SUPPORT_ENFORCER, role_of + + positive: list[str] = [] + blockers: list[str] = [] + disabled: list[str] = [] + + try: + states = _paint_states(tm) + if ENFORCER_STATE in states: + positive.append("painted support enforcers") + if BLOCKER_STATE in states: + blockers.append("painted support blockers") + except Exception: # noqa: BLE001 + pass + try: + if tm.has_part(MODEL_SETTINGS): + text = tm.read_part(MODEL_SETTINGS).decode("utf-8", "ignore") + subtypes = [role_of(sub) for subs in roles.read_records(text).part_subtypes.values() + for sub in subs.values()] + if SUPPORT_ENFORCER in subtypes: + positive.append("support-enforcer volumes") + if SUPPORT_BLOCKER in subtypes: + blockers.append("support-blocker volumes") + values = _OBJECT_ENABLE.findall(text) + if any(_on(v) for v in values): + positive.append("support switched on for an individual object") + if any(_off(v) for v in values): + disabled.append("support switched off for an individual object (enable_support = 0)") + except Exception: # noqa: BLE001 + pass + if "enable_support" in (source_cfg or {}) and _off(source_cfg["enable_support"]): + disabled.append("support switched off in the project's settings (enable_support = 0)") + + keys = [k for k in SUPPORT_KEYS if k in (source_cfg or {})] + if disabled: + decision = DISABLED + elif positive and keys: + decision = KEPT + else: + decision = STARTER_RESET + return {"decision": decision, "explicit": decision == KEPT, "evidence": positive, + "blocker_evidence": blockers, "disable_evidence": disabled, "keys": keys} + + +def statement(intent: dict) -> str: + """One plain sentence for the summary.""" + decision = intent["decision"] + if decision == KEPT: + return ("This model marks where it needs support (" + ", ".join(intent["evidence"]) + "), so its " + "support settings (" + ", ".join(intent["keys"]) + ") were kept as the creator set them " + "rather than reset to the U1 starter's.") + if decision == DISABLED: + extra = (f" The model also has {', '.join(intent['evidence'])}, which are not used while supports " + "are off." if intent["evidence"] else "") + return ("Supports stay off: " + "; ".join(intent["disable_evidence"]) + "." + extra) + if intent["blocker_evidence"]: + return ("Supports use the U1 starter settings. The model's " + ", ".join(intent["blocker_evidence"]) + + " only say where not to support, so they do not turn supports on.") + return "Supports use the U1 starter settings, as this mode states." diff --git a/backend/snapstudio_core/units.py b/backend/snapstudio_core/units.py new file mode 100644 index 00000000..a4413899 --- /dev/null +++ b/backend/snapstudio_core/units.py @@ -0,0 +1,102 @@ +"""One place that knows what a 3MF `unit` means in millimetres. + +A 3MF model part states its unit on the root `model` element; when the attribute is absent the format says +millimetre. Every number in that part (vertex coordinates AND the translation of any transform written in +it) is in that unit. Several Studio readers used to ignore the header, so a 12 inch part was reported as +12 mm. This table and the readers below are the single shared fix: nothing else may keep its own copy of +the factors, and the scene parser calls the SAME ``resolve_unit`` on the SAME root element. + +Three outcomes are kept apart on purpose: + +* **absent** attribute: the specification default, millimetre (``declared`` is False); +* **recognized** value: one of the six defined units; +* **unrecognized** value: NOT silently the same as absent. ``UnitInfo.recognized`` is False. Legacy + dimension readers still fall back to millimetres (they always have), the scene reports a limitation + (``UNSUPPORTED_UNIT``) and withholds fit findings. +""" +from __future__ import annotations + +import io +from typing import NamedTuple + +from lxml import etree + +#: The six units the 3MF core specification defines, in millimetres per unit. +MM_PER_UNIT: dict[str, float] = { + "micron": 0.001, + "millimeter": 1.0, + "centimeter": 10.0, + "inch": 25.4, + "foot": 304.8, + "meter": 1000.0, +} +DEFAULT_UNIT = "millimeter" + + +class UnitInfo(NamedTuple): + name: str # the declared value, or the default when absent + factor: float # millimetres per unit; 1.0 (the documented fallback) when not recognized + recognized: bool # False only for a declared value outside the six units + declared: bool # False when the attribute is absent (the default applies) + + +def resolve_unit(value: str | None) -> UnitInfo: + """Interpret the root element's ``unit`` attribute value (None = absent).""" + if value is None: + return UnitInfo(DEFAULT_UNIT, MM_PER_UNIT[DEFAULT_UNIT], True, False) + factor = MM_PER_UNIT.get(value) + if factor is None: + return UnitInfo(value, 1.0, False, True) + return UnitInfo(value, factor, True, True) + + +def read_unit(raw: bytes | str) -> UnitInfo: + """The unit declared by the REAL root element of a model part, read with the XML parser. + + Only the first start event is consumed, so comments, processing instructions, a DOCTYPE-free prolog + and a long preamble are all irrelevant, and a ```` inside a comment is not seen. A part + that cannot be parsed, or whose root is not ``model``, is NOT a legitimately absent attribute: it reads + as ``recognized=False`` (millimetre factor for the legacy readers, with the flag set); entities are never + resolved and the network is never touched. + """ + data = raw.encode("utf-8") if isinstance(raw, str) else raw + unreadable = UnitInfo(DEFAULT_UNIT, MM_PER_UNIT[DEFAULT_UNIT], False, False) + try: + for _event, elem in etree.iterparse(io.BytesIO(data), events=("start",), resolve_entities=False, + load_dtd=False, no_network=True, huge_tree=False): + if etree.QName(elem).localname != "model": + return unreadable + return resolve_unit(elem.get("unit")) + except (etree.LxmlError, ValueError): + pass + return unreadable + + +def unit_of(raw: bytes | str) -> tuple[str | None, float | None]: + """``(name, mm_per_unit)``; the factor is None for an unrecognized value.""" + info = read_unit(raw) + return info.name, (info.factor if info.recognized else None) + + +def mm_per_unit(raw: bytes | str) -> float: + """Millimetres per unit for a model part; an unrecognized unit reads as millimetres (see UnitInfo).""" + return read_unit(raw).factor + + +def scale_translation(transform, factor: float): + """A 3MF transform with its translation moved into millimetres (or back, with ``1/factor``). + + Accepts the 12-number list form (translation = entries 9..11) and the parsed row form + ``((a,b,c),(d,e,f),(g,h,i),(tx,ty,tz))``. Only the translation carries a length; the 3x3 part is + dimensionless. Factor 1.0 returns the input unchanged, so millimetre behaviour is exactly as before. + """ + if transform is None or factor == 1.0: + return transform + if isinstance(transform[0], (tuple, list)): + tx, ty, tz = transform[3] + return (transform[0], transform[1], transform[2], (tx * factor, ty * factor, tz * factor)) + out = list(transform) + out[9] *= factor + out[10] *= factor + out[11] *= factor + return out diff --git a/backend/snapstudio_core/validation_report.py b/backend/snapstudio_core/validation_report.py index 558e117d..587b4226 100644 --- a/backend/snapstudio_core/validation_report.py +++ b/backend/snapstudio_core/validation_report.py @@ -31,8 +31,25 @@ def _check(name, ok, detail): return {"name": name, "status": "pass" if ok else "warn", "detail": detail} -def readiness_report(path: str) -> dict: - info = project_info(path) +def _preset_markers_line(prepare_mode: str | None) -> str: + """What the cleared preset markers mean for the user's setting values. + + Only Preserve keeps the creator's values in effect. In Recommended Studio writes + its U1 starter settings, and Snapmaker Orca resets anything not declared as the + creator's to the U1 preset. A file alone does not say which mode made it, so + without a mode the line makes no promise about the values. + """ + base = "Customized-preset markers cleared so Orca opens it clean" + if prepare_mode == "preserve": + return base + " — your setting values are kept" + if prepare_mode in ("recommended", "u1"): + return base + " — your settings were replaced by Studio's U1 starter settings" + return (base + " — setting values are kept only when prepared with Preserve creator " + "settings; Studio's U1 starter settings replace them") + + +def readiness_report(path: str, prepare_mode: str | None = None) -> dict: + info = project_info(path, placement_aware=True) diag = diagnose_path(path).to_dict() is_stl = diag.get("input_type") == "stl" verdict = diag.get("verdict") @@ -49,13 +66,45 @@ def readiness_report(path: str) -> dict: ("Studio cannot prepare a copy of this file yet; review it in Snapmaker Orca" if diag.get("prepare_blocked") else "Prepare a U1 copy and review in Orca") if verdict != READY else "Review in Orca before slicing")) - if dims: - fits = dims["x"] <= U1_BUILD[0] and dims["y"] <= U1_BUILD[1] and dims["z"] <= U1_BUILD[2] - checks.append(_check( - "Fits the print bed", - fits, - f'{dims["x"]} × {dims["y"]} × {dims["z"]} mm ' - + ("fits 270 × 270 × 270" if fits else "is larger than the U1 bed — scale or split"))) + # SIZE, not position: each object against the bed on its own. Where the objects sit on the plate is + # judged below under "Layout / plate fit", and the combined extents of several separated objects or + # plates are never read as one big object. No per-object size means UNKNOWN, not the overall extents. + sized = [o for o in (info.get("object_sizes_mm") or []) if o.get("dimensions_mm")] + unmeasured = info.get("objects_unmeasured") or 0 + if sized: + def _over(entry): + d = entry["dimensions_mm"] + return d["x"] > U1_BUILD[0] or d["y"] > U1_BUILD[1] or d["z"] > U1_BUILD[2] + too_big = [o for o in sized if _over(o)] + many = len(sized) > 1 + + def _name(entry): + return f'object {entry["object_id"]} ' if many and entry.get("object_id") is not None else "" + + bed_text = f"{U1_BUILD[0]:.0f} × {U1_BUILD[1]:.0f} × {U1_BUILD[2]:.0f} mm bed" + if too_big: + d = too_big[0]["dimensions_mm"] + detail = (f'By size, {_name(too_big[0])}({d["x"]} × {d["y"]} × {d["z"]} mm) is larger than the U1 ' + "bed — scale it down or split it." + if many else + f'By size, {d["x"]} × {d["y"]} × {d["z"]} mm is larger than the U1 bed — ' + "scale it down or split it.") + if len(too_big) > 1: + detail += f" {len(too_big)} objects are larger than the bed." + else: + largest = max(sized, key=lambda o: o["dimensions_mm"]["x"] * o["dimensions_mm"]["y"]) + d = largest["dimensions_mm"] + detail = (f'By size, the largest object ({_name(largest).strip()}, {d["x"]} × {d["y"]} × ' + f'{d["z"]} mm) fits the U1\'s {bed_text}.' + if many else + f'By size, {d["x"]} × {d["y"]} × {d["z"]} mm fits the U1\'s {bed_text}.') + if unmeasured: + detail += (f" {unmeasured} build item{'s' if unmeasured != 1 else ''} could not be measured, " + "so Studio cannot say whether everything fits.") + checks.append(_check("Fits the print bed", not too_big and not unmeasured, detail)) + elif dims: + from .bed_fit import UNMEASURED_TEXT + checks.append(_check("Fits the print bed", False, UNMEASURED_TEXT)) if colors: colors_ok = colors <= U1_TOOLHEADS checks.append(_check( @@ -89,7 +138,7 @@ def readiness_report(path: str) -> dict: changes.append(f'Printer identity → Snapmaker U1 (was {cfg.get("printer_model")})') dss = cfg.get("different_settings_to_system") if (isinstance(dss, list) and any(str(x) for x in dss)) or (isinstance(dss, str) and dss): - changes.append("Customized-preset markers cleared so Orca opens it clean — your setting values are kept") + changes.append(_preset_markers_line(prepare_mode)) if cfg.get("print_sequence") not in (None, "by layer"): at_risk.append('Print order changes from "by object" to "by layer" (avoids a collision warning; re-enable in Orca if you need by-object)') if not changes and verdict == READY: @@ -139,8 +188,8 @@ def readiness_report(path: str) -> dict: # A crash in layout analysis must NOT silently let the file pass as ready — # fall back to an explicit "unknown" that blocks readiness. layout = {"status": "unknown", "plates": 0, "object_count": 0, - "messages": ["Layout couldn't be checked — open in Snapmaker Orca and use " - "Arrange all plates to confirm placement before slicing."]} + "messages": ["Layout couldn't be checked — review the project in " + "Snapmaker Orca before slicing."]} if layout["status"] != "pass": checks.append(_check( "Layout / plate fit", diff --git a/backend/tests/fixtures/scene/golden-scene-1.json b/backend/tests/fixtures/scene/golden-scene-1.json new file mode 100644 index 00000000..579dfc85 --- /dev/null +++ b/backend/tests/fixtures/scene/golden-scene-1.json @@ -0,0 +1,166 @@ +{ + "schema": "scene/1", + "revision": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "status": "complete", + "units": "mm", + "axes": "right-handed-z-up", + "sources": [ + { + "part": "3D/3dmodel.model", + "unit": "millimeter", + "mm_per_unit": 1.0 + } + ], + "bed": { + "polygon_mm": [ + [ + 0.5, + 1.0 + ], + [ + 270.5, + 1.0 + ], + [ + 270.5, + 271.0 + ], + [ + 0.5, + 271.0 + ] + ], + "height_mm": 270.05, + "edge_margin_mm": 0.5, + "policy": "u1_template" + }, + "meshes": [ + { + "key": { + "part": "3D/3dmodel.model", + "object_id": "1" + }, + "vertex_count": 8, + "triangle_count": 12, + "positions_f32le_base64": "AAAAAAAAAAAAAAAAAAAgQQAAAAAAAAAAAAAgQQAAIEEAAAAAAAAAAAAAIEEAAAAAAAAAAAAAAAAAACBBAAAgQQAAAAAAACBBAAAgQQAAIEEAACBBAAAAAAAAIEEAACBB", + "indices_u32le_base64": "AAAAAAEAAAACAAAAAAAAAAIAAAADAAAABAAAAAUAAAAGAAAABAAAAAYAAAAHAAAAAAAAAAEAAAAFAAAAAAAAAAUAAAAEAAAAAQAAAAIAAAAGAAAAAQAAAAYAAAAFAAAAAgAAAAMAAAAHAAAAAgAAAAcAAAAGAAAAAwAAAAAAAAAEAAAAAwAAAAQAAAAHAAAA", + "volumes": [ + { + "id": "v0", + "triangle_start": 0, + "triangle_count": 12, + "role": "part", + "source": "object" + } + ] + } + ], + "nodes": [ + { + "id": "b0", + "parent_id": null, + "resource": { + "part": "3D/3dmodel.model", + "object_id": "1" + }, + "mesh_key": { + "part": "3D/3dmodel.model", + "object_id": "1" + }, + "local_to_parent_mm": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 50.0, + 60.0, + 0.0, + 1.0 + ], + "world_mm": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 50.0, + 60.0, + 0.0, + 1.0 + ], + "mirrored": false, + "role_context": null, + "build_index": 0, + "instance_ref": { + "build_index": 0, + "component_path": [] + }, + "plate": { + "state": "unknown", + "plate_id": null, + "source": null + }, + "placement_state": "known", + "printable": true, + "has_non_part_volumes": false, + "bounds_mm": { + "min": [ + 50.0, + 60.0, + 0.0 + ], + "max": [ + 60.0, + 70.0, + 10.0 + ] + }, + "finding_ids": [] + } + ], + "plates": [], + "findings": [], + "limitations": [ + { + "code": "PLATES_UNAVAILABLE", + "target_ids": [] + } + ], + "counts": { + "nodes": 1, + "meshes": 1, + "vertices": 8, + "triangles": 12, + "rendered_triangles": 12, + "plates": 0, + "findings": 0, + "limitations": 1 + }, + "limits": { + "max_archive_bytes": 134217728, + "max_model_xml_bytes": 33554432, + "max_rendered_triangles": 250000, + "max_vertices": 300000, + "max_nodes": 2000, + "max_depth": 64, + "max_response_bytes": 8388608, + "max_findings": 500, + "max_limitations": 50, + "max_id_length": 128 + } +} diff --git a/backend/tests/fixtures/scene/make_golden.py b/backend/tests/fixtures/scene/make_golden.py new file mode 100644 index 00000000..ddbed05e --- /dev/null +++ b/backend/tests/fixtures/scene/make_golden.py @@ -0,0 +1,53 @@ +"""Writes golden-scene-1.json by hand, independently of snapstudio_core.scene. + +The golden scene is the frozen reference for scene/1: a 10 mm cube, authored in millimetres at +(50, 60, 0) in a plain 3MF with no slicer metadata. It is derived here from first principles (struct + +base64 + the U1 bed numbers), so a test can require the real builder to reproduce it exactly. + +Run: py -3.13 make_golden.py (rewrites golden-scene-1.json next to this file) +""" +import base64 +import json +import struct +from pathlib import Path + +REVISION = "0123456789abcdef" * 4 +VERTS = [(0, 0, 0), (10, 0, 0), (10, 10, 0), (0, 10, 0), (0, 0, 10), (10, 0, 10), (10, 10, 10), (0, 10, 10)] +TRIS = [(0, 1, 2), (0, 2, 3), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4), + (1, 2, 6), (1, 6, 5), (2, 3, 7), (2, 7, 6), (3, 0, 4), (3, 4, 7)] +PART = "3D/3dmodel.model" + + +def b64(fmt, values): + return base64.b64encode(struct.pack("<" + fmt * len(values), *values)).decode("ascii") + + +matrix = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 50.0, 60.0, 0.0, 1.0] +scene = { + "schema": "scene/1", "revision": REVISION, "status": "complete", "units": "mm", "axes": "right-handed-z-up", + "sources": [{"part": PART, "unit": "millimeter", "mm_per_unit": 1.0}], + "bed": {"polygon_mm": [[0.5, 1.0], [270.5, 1.0], [270.5, 271.0], [0.5, 271.0]], + "height_mm": 270.05, "edge_margin_mm": 0.5, "policy": "u1_template"}, + "meshes": [{ + "key": {"part": PART, "object_id": "1"}, "vertex_count": 8, "triangle_count": 12, + "positions_f32le_base64": b64("f", [c for v in VERTS for c in v]), + "indices_u32le_base64": b64("I", [i for t in TRIS for i in t]), + "volumes": [{"id": "v0", "triangle_start": 0, "triangle_count": 12, "role": "part", "source": "object"}]}], + "nodes": [{ + "id": "b0", "parent_id": None, "resource": {"part": PART, "object_id": "1"}, + "mesh_key": {"part": PART, "object_id": "1"}, "local_to_parent_mm": matrix, "world_mm": matrix, + "mirrored": False, "role_context": None, "build_index": 0, "instance_ref": {"build_index": 0, "component_path": []}, + "plate": {"state": "unknown", "plate_id": None, "source": None}, "placement_state": "known", + "printable": True, "has_non_part_volumes": False, + "bounds_mm": {"min": [50.0, 60.0, 0.0], "max": [60.0, 70.0, 10.0]}, "finding_ids": []}], + "plates": [], "findings": [], + "limitations": [{"code": "PLATES_UNAVAILABLE", "target_ids": []}], + "counts": {"nodes": 1, "meshes": 1, "vertices": 8, "triangles": 12, "rendered_triangles": 12, + "plates": 0, "findings": 0, "limitations": 1}, + "limits": {"max_archive_bytes": 134217728, "max_model_xml_bytes": 33554432, "max_rendered_triangles": 250000, + "max_vertices": 300000, "max_nodes": 2000, "max_depth": 64, "max_response_bytes": 8388608, + "max_findings": 500, "max_limitations": 50, "max_id_length": 128}} +if __name__ == "__main__": + out = Path(__file__).with_name("golden-scene-1.json") + out.write_text(json.dumps(scene, indent=2) + "\n", encoding="utf-8") + print("wrote", out) diff --git a/backend/tests/regrid_fixtures.py b/backend/tests/regrid_fixtures.py new file mode 100644 index 00000000..fca56859 --- /dev/null +++ b/backend/tests/regrid_fixtures.py @@ -0,0 +1,138 @@ +"""Multi-plate Bambu/Orca projects built at test time from the committed Orca-authored base. + +The base is ``fixtures/painted/orcaslicer-2.4.2-painted-cube.3mf``: one 20 mm painted cube (centred on its own +origin) on a single U1 plate. Everything else (more plates, a larger source bed, tower positions, support +intent) is written into a copy of it, so no third-party model is in the repository. +""" +from __future__ import annotations + +import json +import re +import zipfile +from pathlib import Path + +from snapstudio_core import regrid + +BASE = Path(__file__).parent / "fixtures" / "painted" / "orcaslicer-2.4.2-painted-cube.3mf" +ROOT = "3D/3dmodel.model" +SETTINGS = "Metadata/project_settings.config" +MODEL_SETTINGS = "Metadata/model_settings.config" +OBJECT_PART = "3D/Objects/sample_cube_1.model" + +IDENT = "1 0 0 0 1 0 0 0 1" + + +def bed_polygon(width: float, depth: float, min_x: float = 0.0, min_y: float = 0.0) -> list[str]: + return [f"{min_x:g}x{min_y:g}", f"{min_x + width:g}x{min_y:g}", + f"{min_x + width:g}x{min_y + depth:g}", f"{min_x:g}x{min_y + depth:g}"] + + +def source_origin(index: int, plates: int, bed: tuple[float, float]) -> tuple[float, float]: + """Where plate ``index`` starts on the SOURCE grid, by Orca's rule (the fixture's own copy of it).""" + stride = (int(bed[0]) * 1.2, int(bed[1]) * 1.2) + cols = regrid.column_count(plates) + return (index % cols) * stride[0], -(index // cols) * stride[1] + + +def read(path) -> dict: + with zipfile.ZipFile(path) as z: + return {n: z.read(n) for n in z.namelist()} + + +def write(path, entries: dict) -> str: + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + for name, data in entries.items(): + z.writestr(name, data) + return str(path) + + +def items_of(path) -> list[dict]: + """(transform text, other attributes) of every build item of a project, in order.""" + root = read(path)[ROOT].decode("utf-8") + return [{"transform": (re.search(r'transform="([^"]*)"', t) or [None, None])[1], "tag": t} + for t in re.findall(r"]*/>", root)] + + +def settings_of(path) -> dict: + return json.loads(read(path)[SETTINGS]) + + +def project(tmp_path, name="multi.3mf", *, plates=2, bed=(330.0, 320.0), local=(165.0, 160.0), + transforms=None, cfg=None, supports=None, instances=None): + """A ``plates``-plate project on a source bed of ``bed`` mm. + + ``local`` is each item's position in its plate's own frame (so it lands in the cell the formula predicts); + ``transforms`` overrides the full 12-token transform of item ``i``; ``cfg`` updates project settings; + ``supports`` is a token or a set of them ("paint" = enforcer paint, "paint_blocker", "enforcer" and + "blocker" volumes, "object" = enable_support 1 on the object, "object_off" = 0) written into the copy. + """ + entries = read(BASE) + root = entries[ROOT].decode("utf-8") + items = [] + for i in range(plates): + ox, oy = source_origin(i, plates, bed) + text = (transforms or {}).get(i) or f"{IDENT} {ox + local[0]:g} {oy + local[1]:g} 10" + items.append(f' ') + root = re.sub(r"]*)>.*?", + lambda m: f"\n" + "\n".join(items) + "\n ", root, flags=re.S) + entries[ROOT] = root.encode("utf-8") + + ms = entries[MODEL_SETTINGS].decode("utf-8") + blocks = [] + for i in range(plates): + blocks.append(f""" + + + + + + + + + + + + +""") + ms = re.sub(r" .*\n", "".join(blocks), ms, flags=re.S) + wanted = set([supports] if isinstance(supports, str) else (supports or ())) + helper_volumes = [t for t in ("enforcer", "blocker") if t in wanted] + for n, kind in enumerate(helper_volumes): + # a small extra mesh in the object file, referenced by the root object as component 3 / 4 + number = 3 + n + part = entries[OBJECT_PART].decode("utf-8") + block = re.search(r'', part, re.S).group(0) + clean = re.sub(r' paint_[a-z]+="[^"]*"', "", block).replace('id="1"', f'id="{number}"', 1) + clean = re.sub(r'p:UUID="[^"]*"', f'p:UUID="0003000{number}-81cb-4c03-9d28-80fed5dfa1dc"', clean, count=1) + clean = clean.replace('type="model"', 'type="other"', 1) # a helper volume is typed "other" + entries[OBJECT_PART] = part.replace("", clean + "\n ", 1).encode("utf-8") + root = entries[ROOT].decode("utf-8") + root = root.replace("", f'', 1) + entries[ROOT] = root.encode("utf-8") + ms = ms.replace(" ", f' \n' + f' \n \n ', 1) + for flag, value in (("object", "1"), ("object_off", "0")): + if flag in wanted: + ms = ms.replace('', + f'\n ', 1) + entries[MODEL_SETTINGS] = ms.encode("utf-8") + + for flag, state in (("paint", "4"), ("paint_blocker", "8")): + if flag in wanted: + part = entries[OBJECT_PART].decode("utf-8") + part = re.sub(r']*?)/>', rf' paint_supports="{state}"/>', part, count=1) + assert "paint_supports" in part + entries[OBJECT_PART] = part.encode("utf-8") + + settings = json.loads(entries[SETTINGS]) + settings["printable_area"] = bed_polygon(*bed) + settings.update({"enable_support": "1", "support_type": "tree(manual)", "support_filament": "2", + "support_interface_filament": "2"}) + settings["different_settings_to_system"] = [ + "enable_support;support_filament;support_interface_filament;support_type", "", "", "", "", ""] + settings.update(cfg or {}) + entries[SETTINGS] = json.dumps(settings, indent=4).encode("utf-8") + return write(Path(tmp_path) / name, entries) diff --git a/backend/tests/scene_fixtures.py b/backend/tests/scene_fixtures.py new file mode 100644 index 00000000..e0405206 --- /dev/null +++ b/backend/tests/scene_fixtures.py @@ -0,0 +1,189 @@ +"""Authored 3MF / STL fixtures for the scene/1 tests and benchmark. + +Everything here is synthetic. Archives are written with ZIP_STORED and fixed timestamps so the bytes +are deterministic. Nothing here reads a real user file. +""" +from __future__ import annotations + +import struct +import zipfile +from pathlib import Path + +CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" +PROD = "http://schemas.microsoft.com/3dmanufacturing/production/2015/06" +REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +MODEL_REL = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" + +CUBE_TRIS = [(0, 1, 2), (0, 2, 3), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4), + (1, 2, 6), (1, 6, 5), (2, 3, 7), (2, 7, 6), (3, 0, 4), (3, 4, 7)] + + +def cube_vertices(size: float = 10.0, origin=(0.0, 0.0, 0.0)): + ox, oy, oz = origin + s = size + return [(ox, oy, oz), (ox + s, oy, oz), (ox + s, oy + s, oz), (ox, oy + s, oz), + (ox, oy, oz + s), (ox + s, oy, oz + s), (ox + s, oy + s, oz + s), (ox, oy + s, oz + s)] + + +def mesh_xml(vertices, triangles) -> str: + v = "".join(f'' for x, y, z in vertices) + t = "".join(f'' for a, b, c in triangles) + return f"{v}{t}" + + +def cube_object(oid, size=10.0, origin=(0.0, 0.0, 0.0)) -> str: + return f'{mesh_xml(cube_vertices(size, origin), CUBE_TRIS)}' + + +def composite_object(oid, comps) -> str: + """comps: [(objectid, path|None, transform|None)]""" + inner = "" + for cid, path, tf in comps: + attrs = f'objectid="{cid}"' + if path is not None: + attrs += f' p:path="{path}"' + if tf: + attrs += f' transform="{tf}"' + inner += f"" + return f'{inner}' + + +def model_xml(objects, items, unit="millimeter", prolog='') -> str: + build = "" + for item in items: # (objectid, transform) or (objectid, transform, p:path) + oid, tf = item[0], item[1] + path = item[2] if len(item) > 2 else None + build += (f'") + return (f'{prolog}' + f"{''.join(objects)}{build}") + + +def sub_model_xml(objects, unit="millimeter") -> str: + return (f'' + f"{''.join(objects)}") + + +def rels_xml(target="/3D/3dmodel.model", extra="") -> str: + return (f'' + f'{extra}') + + +def write_zip(path, entries: dict, order=None) -> Path: + path = Path(path) + with zipfile.ZipFile(path, "w", zipfile.ZIP_STORED) as z: + for name in (order or list(entries)): + data = entries[name] + if isinstance(data, str): + data = data.encode("utf-8") + info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_STORED + z.writestr(info, data) + return path + + +def three_mf(path, root_model: str, extra: dict | None = None, rels: str | None = None) -> Path: + # A project Studio reads needs its package parts; without them it is (rightly) unreadable. + entries = {"[Content_Types].xml": "", "_rels/.rels": rels or rels_xml(), + "3D/3dmodel.model": root_model} + entries.update(extra or {}) + return write_zip(path, entries) + + +IDENT = "1 0 0 0 1 0 0 0 1" + + +def tf(tx=0.0, ty=0.0, tz=0.0, lin=IDENT) -> str: + return f"{lin} {tx} {ty} {tz}" + + +def plain_cube_3mf(path, size=10.0, unit="millimeter", at=(50.0, 60.0, 0.0)) -> Path: + return three_mf(path, model_xml([cube_object("1", size)], [("1", tf(*at))], unit=unit)) + + +# --- Bambu/Orca style ------------------------------------------------------------------------ + +def model_settings_xml(objects: dict, plates: list) -> str: + """objects: {oid: [(part_id, subtype)]}; plates: [(plater_id, [(oid, instance_id|None)])]""" + out = [''] + for oid, parts in objects.items(): + out.append(f'') + for pid, sub in parts: + attr = f' subtype="{sub}"' if sub is not None else "" + out.append(f'') + out.append("") + for plater, members in plates: + out.append(f'') + for oid, inst in members: + inst_xml = f'' if inst is not None else "" + out.append(f'{inst_xml}') + out.append("") + out.append("") + return "".join(out) + + +def bambu_project(path, *, parts, items, plates, roles=None, unit="millimeter", sizes=None, origins=None, + omit_parts=()) -> Path: + """One composite root object `100` made of meshes 1..n in 3D/Objects/object_1.model. + + parts: number of mesh parts; roles: [subtype per part]; items: [(objectid, transform)]. + """ + roles = roles or ["normal_part"] * parts + sizes = sizes or [10.0] * parts + origins = origins or [(i * 20.0, 0.0, 0.0) for i in range(parts)] + sub = sub_model_xml([cube_object(str(i + 1), sizes[i], origins[i]) for i in range(parts)], unit=unit) + root = model_xml( + [composite_object("100", [(str(i + 1), "/3D/Objects/object_1.model", None) for i in range(parts)])], + items, unit=unit) + settings = model_settings_xml({"100": [(str(i + 1), roles[i]) for i in range(parts) if (i + 1) not in omit_parts]}, + plates) + rels3d = (f'' + f'') + return three_mf(path, root, {"3D/Objects/object_1.model": sub, "3D/_rels/3dmodel.model.rels": rels3d, + "Metadata/model_settings.config": settings}) + + +# --- Prusa style ----------------------------------------------------------------------------- + +def prusa_project(path, *, volumes, items=None, unit="millimeter", last_first=False) -> Path: + """One object `1` with 12 triangles per cube volume; volumes: [(first, last, volume_type)].""" + n = max(last for _first, last, _ in volumes) // 12 + 1 if volumes else 1 + verts, tris = [], [] + for k in range(n): + base = len(verts) + verts += cube_vertices(10.0, (k * 20.0, 0.0, 0.0)) + tris += [(a + base, b + base, c + base) for a, b, c in CUBE_TRIS] + obj = f'{mesh_xml(verts, tris)}' + root = model_xml([obj], items or [("1", tf(100, 100, 0))], unit=unit) + attrs = (lambda a, b: f'lastid="{b}" firstid="{a}"') if last_first else (lambda a, b: f'firstid="{a}" lastid="{b}"') + vol = "".join( + f'' + for a, b, vt in volumes) + cfg = f'{vol}' + return three_mf(path, root, {"Metadata/Slic3r_PE_model.config": cfg}) + + +# --- STL ------------------------------------------------------------------------------------- + +def binary_stl(path, size=10.0) -> Path: + verts = cube_vertices(size) + body = b"\x00" * 80 + struct.pack(" Path: + """A single mesh of about `triangles_target` triangles (a flat triangulated grid with shared vertices).""" + side = max(2, int((triangles_target / 2) ** 0.5)) + verts = [(x * 0.1, y * 0.1, 0.0) for y in range(side + 1) for x in range(side + 1)] + tris = [] + w = side + 1 + for y in range(side): + for x in range(side): + a = y * w + x + tris.append((a, a + 1, a + w)) + tris.append((a + 1, a + w + 1, a + w)) + return three_mf(path, model_xml([f'{mesh_xml(verts, tris)}'], + [("1", tf(*at))])) diff --git a/backend/tests/test_acceptance_fixture.py b/backend/tests/test_acceptance_fixture.py new file mode 100644 index 00000000..2ba38caa --- /dev/null +++ b/backend/tests/test_acceptance_fixture.py @@ -0,0 +1,73 @@ +"""The installed-build acceptance harness's generated painted project must be a valid 3MF package. + +`tools/acceptance/run.ps1` builds one small painted project at run time. When Prepare's readability rule began to +require the package's own parts (`[Content_Types].xml`, `_rels/.rels`), that file, which had only the model and two +config parts, became "not a project" and the harness's painted-colour route check failed against a healthy build. +This reads the fixture's text straight out of the harness, so a future edit cannot drift from what is tested here. +""" +from __future__ import annotations + +import io +import re +import zipfile +from pathlib import Path + +from lxml import etree + +from snapstudio_core import eligibility, project_traits +from snapstudio_core.container import ThreeMF + +RUN = Path(__file__).resolve().parents[2] / "tools" / "acceptance" / "run.ps1" + + +def _here_string(name: str) -> str: + text = RUN.read_text(encoding="utf-8") + found = re.search(r"\$" + name + r" = @'\r?\n(.*?)\r?\n'@", text, re.S) + assert found, f"{name} is not defined in the harness" + body = found.group(1).strip() + assert body, f"{name} is empty in the harness" + assert "'@" not in body, f"{name} swallowed another here-string" + return body + + +def _entries() -> dict[str, str]: + """The entries the harness writes, by name, exactly as its zip loop lists them.""" + text = RUN.read_text(encoding="utf-8") + loop = re.search(r'\$zip = \[System\.IO\.Compression\.ZipFile\]::Open\(\$paintedWork.*?foreach \(\$entry in @\((.*?)\)\) \{', + text, re.S) + assert loop, "the painted-project zip loop was not found" + pairs = re.findall(r'name = "([^"]+)"; body = \$(\w+)', loop.group(1)) + assert pairs, "no entries found in the painted-project zip loop" + return {name: _here_string(var) for name, var in pairs} + + +def _write(tmp_path, entries: dict[str, str], name: str) -> str: + path = tmp_path / name + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + for entry, body in entries.items(): + z.writestr(entry, body) + return str(path) + + +def test_the_harness_painted_project_is_a_readable_package(tmp_path): + entries = _entries() + assert {"[Content_Types].xml", "_rels/.rels", "3D/3dmodel.model"} <= set(entries) + types = etree.fromstring(entries["[Content_Types].xml"].encode("utf-8")) + assert types.tag == "{http://schemas.openxmlformats.org/package/2006/content-types}Types" + rels = etree.fromstring(entries["_rels/.rels"].encode("utf-8")) + targets = {(r.get("Type"), r.get("Target")) for r in rels} + assert ("http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel", "/3D/3dmodel.model") in targets + path = _write(tmp_path, entries, "painted.3mf") + assert eligibility.is_readable(ThreeMF.open(path)) + traits = project_traits.extract(path) + assert traits["readable"] is True + assert traits["has_painted_color"]["value"] is True # the assertion the harness's route check makes + + +def test_the_original_package_without_its_required_parts_is_refused(tmp_path): + original = {k: v for k, v in _entries().items() if k not in ("[Content_Types].xml", "_rels/.rels")} + path = _write(tmp_path, original, "malformed.3mf") + assert not eligibility.is_readable(ThreeMF.open(path)) + traits = project_traits.extract(path) + assert traits["readable"] is False + assert traits["has_painted_color"]["value"] is None diff --git a/backend/tests/test_guide_claims.py b/backend/tests/test_guide_claims.py index 8eb04f5b..f0494c0b 100644 --- a/backend/tests/test_guide_claims.py +++ b/backend/tests/test_guide_claims.py @@ -9,14 +9,23 @@ the engine really produces, the original stays byte-identical, and the guide's printer-name explanation matches the confidence the engine actually assigns. What they cannot establish: that the prose around those facts is well explained, -or anything about a physical printer or Snapmaker Orca. +that a source is the right source, honest review dates, paraphrased overclaims, +screen-reader behavior, or anything about a physical printer or Snapmaker Orca. """ import hashlib +import importlib +import importlib.util import json +import os import re import shutil +import subprocess +import sys +import tempfile from pathlib import Path +import pytest + from snapstudio_core import plate_placement as pp from snapstudio_core import post_slice @@ -48,6 +57,57 @@ def _sha(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() +ENGINE_FACTS = { + "setting-why": ("engine-reason", "backend:rules.apply_clamps"), + "size-or-placement": ("placement-move", "backend:plate_placement.EDGE_MARGIN_MM"), + "unknown-compatible": ("unknown-gap", "backend:post_slice._machine_match"), + "sliced-for-u1": ("sliced-header", "backend:post_slice._machine_match"), + "original-unchanged": ("copy-only", "test:backend/tests/test_guide_claims.py::test_original_unchanged_answer_uses_real_convert_path"), + "print-success": ("readiness-estimate", "backend:success_predict.predict"), +} + + +def check_answers(manifest, pages, tmp_path): + """Check code-derived tokens and ownership; this cannot judge prose clarity, source fitness, dates, paraphrased overclaims, physical behavior, Orca behavior, or screen readers.""" + from snapstudio_core import success_predict + from snapstudio_core.convert import convert_to_u1 + from snapstudio_core.rules import apply_clamps, load_rules + by_id = {answer["id"]: answer for answer in manifest["answers"]} + table = { + "setting-why": lambda: ("engine-reason", "backend:rules.apply_clamps", {"valid range", "U1 default"}), + "size-or-placement": lambda: ("placement-move", "backend:plate_placement.EDGE_MARGIN_MM", {"one move cannot fix the layout", "multi-plate"}), + "unknown-compatible": lambda: ("unknown-gap", "backend:post_slice._machine_match", {"does not guess", "no printer is connected"}), + "sliced-for-u1": lambda: ("sliced-header", "backend:post_slice._machine_match", {"Studio reads the header", "not simulated"}), + "original-unchanged": lambda: ("copy-only", "test:backend/tests/test_guide_claims.py::test_original_unchanged_answer_uses_real_convert_path", {"SnapmakerU1", "original file is not modified"}), + "print-success": lambda: ("readiness-estimate", "backend:success_predict.predict", {"takes points off for each risk signal", "not a probability of print success", "at least 75"}), + } + assert set(table) == set(ENGINE_FACTS) + assert set(ENGINE_FACTS) | {"kept-orca"} == set(by_id) + asserted = set() + for answer_id, (expected_id, expected_derive, expected_tokens) in ((a, table[a]()) for a in table): + answer = by_id[answer_id] + assert len(answer["requiredFacts"]) == 1 + fact = answer["requiredFacts"][0] + assert (fact["id"], fact["derive"]) == (expected_id, expected_derive) + assert set(fact["tokens"]) == expected_tokens + assert all(token in json.dumps(pages) for token in expected_tokens) + asserted.add(fact["id"]) + # Exercise real behavior for the facts whose tokens summarize it. + clamp = apply_clamps({"prime_tower_brim_width": "-1"}, load_rules()) + assert "valid range" in clamp[0]["explanation"] and "U1 default" in clamp[0]["explanation"] + assert clamp[0]["kind"] == "engine" + assert post_slice._machine_match({"printer_model": "Snapmaker U1"}, {})["confidence"] == post_slice.CONFIRMED + assert post_slice._machine_match({"printer_model": "not a known printer"}, {})["confidence"] == post_slice.LIKELY + assert success_predict.predict(readiness={"ready": True})["likelihood"] == 100 + src = tmp_path / "answer-source.3mf" + shutil.copy(ROOT / "examples" / "demo_offplate_foreign.3mf", src) + before = _sha(src) + out = convert_to_u1(str(src), out_dir=str(tmp_path), dry_run=False) + assert out.output_name.endswith("_SnapmakerU1.3mf") and _sha(src) == before + assert asserted == {fact["id"] for answer in manifest["answers"] for fact in answer["requiredFacts"] if fact["derive"].startswith(("backend:", "test:"))} + return True + + def test_placement_example_matches_the_engine(tmp_path): src = tmp_path / "demo_offplate_foreign.3mf" shutil.copy(ROOT / "examples" / "demo_offplate_foreign.3mf", src) @@ -98,3 +158,210 @@ def test_printer_name_confidence_matches_the_guide(): def test_matching_header_is_confirmed_and_looks_right(): ok = post_slice._machine_match({"printer_model": "Snapmaker U1"}, {}) assert ok["result"] == "ok" and ok["confidence"] == post_slice.CONFIRMED + + +def test_original_unchanged_answer_uses_real_convert_path(tmp_path): + """The conversion path preserves the input; this does not establish prose clarity, source choice, dates, physical behavior, paraphrased overclaims, or screen-reader behavior.""" + from snapstudio_core.convert import convert_to_u1 + src = tmp_path / "source.3mf" + shutil.copy(ROOT / "examples" / "demo_offplate_foreign.3mf", src) + before = _sha(src) + result = convert_to_u1(str(src), out_dir=str(tmp_path), dry_run=False) + assert result.output_name.endswith("_SnapmakerU1.3mf") + assert (tmp_path / result.output_name).exists() + assert _sha(src) == before + + +def _resolve_engine_derive(derive, package="snapstudio_core"): + """Resolve a backend: or test: derive for real. Raises AttributeError or returns None when it does not resolve.""" + if derive.startswith("backend:"): + module_name, symbol = derive[8:].rsplit(".", 1) + return getattr(importlib.import_module(f"{package}.{module_name}"), symbol) + if derive.startswith("test:"): + file_name, test_name = derive[5:].split("::", 1) + path = (ROOT / file_name) if not Path(file_name).is_absolute() else Path(file_name) + if not path.is_file(): + return None + if path.resolve() == Path(__file__).resolve(): + module = sys.modules[__name__] # this module is already loaded; never execute it twice + else: + module_name = "_derive_" + hashlib.sha1(str(path.resolve()).encode()).hexdigest()[:12] + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + member = getattr(module, test_name, None) + return member if callable(member) else None + + +def test_every_manifest_derive_resolves_to_code(): + """Resolution is mechanical; it cannot establish prose clarity, source fitness, honest dates, physical printer or Snapmaker Orca behavior, paraphrased overclaims, or screen-reader behavior. App derives are resolved in guideClaims.test.ts.""" + seen = 0 + for answer in _guide("answers")["answers"]: + for fact in answer["requiredFacts"]: + if fact["derive"].startswith(("backend:", "test:")): + assert _resolve_engine_derive(fact["derive"]) is not None, fact["derive"] + seen += 1 + else: + assert fact["derive"].startswith("app:"), fact["derive"] + assert seen >= 5 + + +def test_engine_derive_resolution_rejects_near_misses(tmp_path, monkeypatch): + """Real getattr resolution: a name that only appears in a comment, a comparison or a docstring is not a symbol.""" + package = tmp_path / "fixturepkg" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "mod.py").write_text( + '"""Module docstring mentions docstring_only."""\n' + "# comment_only is described here\n" + "REAL = 1\n" + "def real_fn():\n" + ' """real_fn mentions inner_docstring."""\n' + " return REAL == 1\n" + "async def real_async():\n" + " return 1\n" + "value = REAL\n" + "check = (missing_eq == 1) if False else None\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + assert _resolve_engine_derive("backend:mod.REAL", package="fixturepkg") == 1 + assert _resolve_engine_derive("backend:mod.real_async", package="fixturepkg") + for name in ["REA", "real_f", "comment_only", "docstring_only", "inner_docstring", "missing_eq", "real_fn_"]: + with pytest.raises(AttributeError): + _resolve_engine_derive(f"backend:mod.{name}", package="fixturepkg") + + +def test_test_derive_resolution_imports_the_real_function(tmp_path): + """A test: derive resolves by importing the file and calling getattr, so text that only looks like a definition fails.""" + ref = "test:backend/tests/test_guide_claims.py::" + assert callable(_resolve_engine_derive(ref + "test_original_unchanged_answer_uses_real_convert_path")) + assert _resolve_engine_derive(ref + "test_original_unchanged_answer_uses_real_convert_pat") is None + assert _resolve_engine_derive("test:backend/tests/no_such_file.py::test_x") is None + fixture = tmp_path / "fixture_tests.py" + fixture.write_text( + '"""def spoofed_in_docstring(): pass"""\n' + "TEXT = \"\"\"\ndef spoofed_test():\n pass\n\"\"\"\n" + "ESCAPED = \"\\\"\\\"\\\" def spoofed_escaped(): pass\"\n" + "# def spoofed_comment(): pass\n" + "NOT_CALLABLE = 5\n" + "def test_real():\n return 1\n", + encoding="utf-8", + ) + ref = f"test:{fixture}::" + assert _resolve_engine_derive(ref + "test_real")() == 1 + for name in ["spoofed_test", "spoofed_in_docstring", "spoofed_escaped", "spoofed_comment", "test_rea", "NOT_CALLABLE", "TEXT"]: + assert _resolve_engine_derive(ref + name) is None, name + + +def test_golden_answer_engine_assertion_table_has_exact_manifest_coverage(tmp_path): + """The table checks code facts only; it cannot establish prose clarity, source fitness, honest dates, physical printer or Snapmaker Orca behavior, paraphrased overclaims, or screen readers.""" + assert check_answers(_guide("answers"), {name: _guide(name) for name in ("path", "tasks", "problems")}, tmp_path) + + +def test_print_readiness_is_a_risk_signal_score_not_a_measured_chance(): + """The formula check cannot establish that the score predicts physical printer behavior, prose clarity, source fitness, honest dates, paraphrased overclaims, or screen-reader behavior.""" + from snapstudio_core import success_predict + baseline = success_predict.predict(readiness={"ready": True}) + one_warning = success_predict.predict(readiness={"ready": False, "warnings": ["one"]}) + two_warnings = success_predict.predict(readiness={"ready": False, "warnings": ["one", "two"]}) + assert baseline["likelihood"] == 100 + assert one_warning["likelihood"] == 100 - (20 + 5 * 1) == 75 + assert two_warnings["likelihood"] == 100 - (20 + 5 * 2) == 70 + assert one_warning["band"] == "likely" and two_warnings["band"] == "uncertain" + assert success_predict.predict(readiness={"ready": False, "warnings": []})["band"] == "likely" + assert success_predict._band(75) == "likely" and success_predict._band(74) == "uncertain" + assert "Likely to print" in success_predict._verdict(80, "likely") + + +def _run_guide_build(content, output): + env = os.environ.copy() + env["SNAPSTUDIO_GUIDE_CONTENT"] = str(content) + env["SNAPSTUDIO_GUIDE_OUTPUT"] = str(output) + return subprocess.run(["node", "tools/build.mjs"], cwd=ROOT / "site" / "guide", env=env, capture_output=True, text=True) + + +def test_build_rejects_invalid_answers_and_source_refs(): + """These negative cases test the validator; they cannot establish prose clarity, source fitness, honest dates, physical behavior, paraphrased overclaims, or screen readers.""" + with tempfile.TemporaryDirectory() as raw: + content = Path(raw) / "content" + shutil.copytree(GUIDE, content) + output = Path(raw) / "index.html" + answers_path = content / "answers.json" + original = json.loads(answers_path.read_text(encoding="utf-8")) + cases = [] + bad = json.loads(json.dumps(original)); bad["answers"][0]["requiredFacts"] = []; cases.append(bad) + bad = json.loads(json.dumps(original)); bad["answers"][0]["unknown"] = True; cases.append(bad) + bad = json.loads(json.dumps(original)); bad["answers"][1]["requiredFacts"][0]["id"] = bad["answers"][0]["requiredFacts"][0]["id"]; cases.append(bad) + for bad in cases: + answers_path.write_text(json.dumps(bad), encoding="utf-8") + assert _run_guide_build(content, output).returncode != 0 + # the build checks shape and file existence only; symbol resolution is tested separately above + for derive in ["bogus:thing", "backend:no_such_module.symbol", "backend:rules.not-an-identifier", "backend:rules", "app:desktop/src/lib/no_such_file.ts#X", "app:desktop/src/lib/fidelity.ts", "app:desktop/src/lib/fidelity.ts#not-an-id", "test:backend/tests/no_such_file.py::test_x", "test:backend/tests/test_guide_claims.py::"]: + bad = json.loads(json.dumps(original)); bad["answers"][0]["requiredFacts"][0]["derive"] = derive + answers_path.write_text(json.dumps(bad), encoding="utf-8") + assert _run_guide_build(content, output).returncode != 0 + path_path = content / "path.json" + path_data = json.loads(path_path.read_text(encoding="utf-8")) + source_page_index = next(i for i, page in enumerate(path_data) if page.get("sources")) + for ref in [ + "https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.0/backend/DOES_NOT_EXIST.py", + "https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.0/desktop/src/lib/fidelity.ts#DOES_NOT_EXIST", + "https://github.com/DVOpenLabs/snapmaker-studio/blob/main/backend/snapstudio_core/rules.py", + "https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.0/../backend/snapstudio_core/rules.py", + "/absolute/backend/snapstudio_core/rules.py", + "@does_not_exist", + ]: + bad_path = json.loads(json.dumps(path_data)); bad_path[source_page_index]["sources"][0]["ref"] = ref + path_path.write_text(json.dumps(bad_path), encoding="utf-8") + assert _run_guide_build(content, output).returncode != 0 + path_path.write_text(json.dumps(path_data), encoding="utf-8") + + +@pytest.mark.parametrize("answer_id,changed", [ + ("size-or-placement", "Studio shrinks the object to fit"), + ("sliced-for-u1", "Studio simulates every command"), + ("original-unchanged", "Studio edits your original in place"), +]) +def test_mutated_manifest_and_page_fails_real_engine_checker(tmp_path, answer_id, changed): + """Mutation checks call check_answers, the real engine-layer checker; they cannot judge general prose clarity or accessibility.""" + manifest = json.loads(json.dumps(_guide("answers"))) + pages = {name: _guide(name) for name in ("path", "tasks", "problems")} + answer = next(a for a in manifest["answers"] if a["id"] == answer_id) + answer["requiredFacts"][0]["tokens"] = [changed] + move = next(t for t in pages["tasks"]["tasks"] if t["id"] == "move-onto-plate") + prepare = next(t for t in pages["tasks"]["tasks"] if t["id"] == "prepare-u1-copy") + if answer_id == "size-or-placement": move["differs"][0]["then"] = changed + elif answer_id == "sliced-for-u1": pages["path"][5]["means"][1]["meaning"] = changed + else: prepare["success"][0] = changed + import pytest + with pytest.raises(AssertionError): + check_answers(manifest, pages, tmp_path) + + +def test_extra_contradictory_fact_fails_real_engine_checker(tmp_path): + """An extra fact must fail the real checker; this cannot judge general prose clarity or accessibility.""" + manifest = json.loads(json.dumps(_guide("answers"))) + pages = {name: _guide(name) for name in ("path", "tasks", "problems")} + manifest["answers"][0]["requiredFacts"].append({"id": "contradiction", "derive": "backend:rules.apply_clamps", "tokens": ["shrinks"]}) + import pytest + with pytest.raises(AssertionError): + check_answers(manifest, pages, tmp_path) + + +def test_renamed_fact_id_fails_real_engine_checker_from_fresh_manifest(tmp_path): + """A fact-id mutation calls the real checker; it cannot establish prose clarity, source fitness, dates, physical behavior, Orca behavior, paraphrased overclaims, or screen readers.""" + manifest = json.loads(json.dumps(_guide("answers"))) + pages = {name: _guide(name) for name in ("path", "tasks", "problems")} + manifest["answers"][0]["requiredFacts"][0]["id"] = "renamed-fact" + with pytest.raises(AssertionError): + check_answers(manifest, pages, tmp_path) + + +def test_dropped_token_fails_real_engine_checker_without_page_mutation(tmp_path): + """A pure token deletion calls the real checker; it cannot establish prose clarity, source fitness, dates, physical behavior, Orca behavior, paraphrased overclaims, or screen readers.""" + manifest = json.loads(json.dumps(_guide("answers"))) + pages = {name: _guide(name) for name in ("path", "tasks", "problems")} + manifest["answers"][5]["requiredFacts"][0]["tokens"] = ["SnapmakerU1"] + with pytest.raises(AssertionError): + check_answers(manifest, pages, tmp_path) diff --git a/backend/tests/test_honest_claims.py b/backend/tests/test_honest_claims.py new file mode 100644 index 00000000..956bc7e0 --- /dev/null +++ b/backend/tests/test_honest_claims.py @@ -0,0 +1,491 @@ +"""Release correction: Studio must not express more confidence than it has. + +Four claims, each checked through the real producers (service entry points), on +fixtures built at test time from the committed Bambu Studio-authored base project +(no third-party model file is committed): + +1. A multi-plate project is never reported as "every object is inside". +2. Preflight's bed check on such a project is unknown, and Ready Now does not + treat it as ready. +3. The Doctor's READY action carries the unresolved-placement note. +4. Use Studio's U1 starter settings (Recommended) says when it drops the creator's + supports, and no report says the creator's values are kept where they are not. +""" +from __future__ import annotations + +import json +import os +import re +import zipfile + +import pytest + +from snapstudio_api import service +from snapstudio_core import fingerprint, plate_placement +from snapstudio_core import preflight as pf +from snapstudio_core import readiness as rd +from snapstudio_core.container import ThreeMF +from snapstudio_core.filaments import PER_FILAMENT_KEYS +from tests.test_readiness import PROJECT, printer, spool, traits + +HERE = os.path.dirname(__file__) +BASE = os.path.join(HERE, "fixtures", "painted", "bambustudio-2.08.02.61-authored.3mf") + +MODEL = "3D/3dmodel.model" +MODEL_SETTINGS = "Metadata/model_settings.config" +SETTINGS = "Metadata/project_settings.config" + +# Bambu H2C-class bed, and the plate stride the authoring slicer uses for it +# (bed width x 1.2). The second plate's object sits one stride to the right. +BED_W, BED_D = 330, 320 +STRIDE = 396 + +NOTE = ("Each plate fits on its own, but Studio cannot check where the plates sit. In our " + "test with a two-plate project made for a larger-bed printer, Snapmaker Orca listed " + "one object as Outside and would not slice until it was moved by hand; Arrange all " + "plates did not fix that in our test. Review the project in Snapmaker Orca before slicing.") + + +def build(tmp_path, name="two-plate.3mf", *, plates=2, plate_json=0, supports=False, + painted_supports=False, source_declares_supports=True, tower=None, settings=None): + """The base project with `plates` plates, one slab each, at Bambu-stride offsets.""" + out = tmp_path / name + with zipfile.ZipFile(BASE) as src, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst: + for item in src.infolist(): + data = src.read(item.filename) + if item.filename == MODEL: + text = data.decode("utf-8") + objects, items = "", "" + for n in range(1, plates): + oid = 2 + n + objects += (f' ' + '\n') + items += (f' \n') + text = text.replace(" ", objects + " ") + text = text.replace(" ", items + " ") + data = text.encode("utf-8") + elif item.filename == MODEL_SETTINGS: + text = data.decode("utf-8") + block = re.search(r' .*?\n \n', text, re.S).group(0) + objs, pls = "", "" + for n in range(1, plates): + oid = 2 + n + objs += block.replace('id="2"', f'id="{oid}"') + pls += (f' \n \n' + ' \n' + f' \n \n' + ' \n' + ' \n \n') + text = text.replace(" ", objs + " ", 1) + text = text.replace(" ", pls + " ", 1) + data = text.encode("utf-8") + elif item.filename == SETTINGS: + cfg = json.loads(data) + # Four filaments, as a U1-shaped project has, so a prepared copy is + # internally consistent and the Doctor can judge it on its own merits. + for key in PER_FILAMENT_KEYS: + if isinstance(cfg.get(key), list) and cfg[key]: + cfg[key] = (cfg[key] + [cfg[key][-1]] * 4)[:4] + cfg["flush_volumes_matrix"] = ["0"] * 16 + if tower: # (x list, y list, width): values of the source's own bed + cfg["enable_prime_tower"] = "1" + cfg["wipe_tower_x"], cfg["wipe_tower_y"], cfg["prime_tower_width"] = tower + cfg.update(settings or {}) + cfg["printable_area"] = ["0x0", f"{BED_W}x0", f"{BED_W}x{BED_D}", f"0x{BED_D}"] + if supports: + cfg["enable_support"] = "1" + cfg["support_type"] = "tree(manual)" + if source_declares_supports: + cfg["different_settings_to_system"] = [ + "enable_support;support_type", "", "", "", ""] + data = json.dumps(cfg, indent=4).encode("utf-8") + elif item.filename == "3D/Objects/object_1.model" and painted_supports: + data = data.replace(b'', + b'') + dst.writestr(item, data) + for n in range(1, plate_json + 1): + dst.writestr(f"Metadata/plate_{n}.json", "{}") + return str(out) + + +def convert(path, mode, tmp_path): + out_dir = tmp_path / f"out-{mode}" + r = service.convert(path, out_dir=str(out_dir), prepare_mode=mode) + assert r["validated_ok"], r["errors"] + return r + + +# --- 1. the plate count and the placement card ------------------------------------ + +@pytest.mark.parametrize("plate_json", [0, 1]) +def test_plate_count_comes_from_the_plates_the_project_declares(tmp_path, plate_json): + path = build(tmp_path, plate_json=plate_json) # 2 plates, fewer than 2 plate_N.json + assert fingerprint.compute_fingerprint(ThreeMF.open(path)).plate_count == 2 + assert service.doctor(path)["plate_count"] == 2 + + +def test_plate_count_falls_back_to_the_slice_cache_without_plate_records(tmp_path): + path = tmp_path / "no-plates.3mf" + with zipfile.ZipFile(BASE) as src, zipfile.ZipFile(path, "w") as dst: + for item in src.infolist(): + if item.filename != MODEL_SETTINGS: + dst.writestr(item, src.read(item.filename)) + dst.writestr("Metadata/plate_1.json", "{}") + dst.writestr("Metadata/plate_2.json", "{}") + assert fingerprint.compute_fingerprint(ThreeMF.open(str(path))).plate_count == 2 + + +def test_multi_plate_placement_is_never_every_object_inside(tmp_path): + check = service.placement_check(build(tmp_path)) + assert check["available"] and check["plate_count"] == 2 + assert check["off_plate"] == [] # each plate fits on its own... + assert check["placement_established"] is False + assert check["summary"] == NOTE # ...and Studio says what it cannot check + assert "inside" not in check["summary"].lower() + assert "use Arrange" not in check["summary"] + + +def test_single_plate_placement_is_still_established(tmp_path): + check = service.placement_check(build(tmp_path, "one.3mf", plates=1)) + assert check["plate_count"] == 1 and check["placement_established"] is True + assert "sits inside" in check["summary"] + + +# --- 2. preflight and Ready Now ---------------------------------------------------- + +def _prepared_multi_plate(tmp_path): + out = convert(build(tmp_path), "preserve", tmp_path)["output_path"] + return out + + +def test_preflight_bed_fit_is_unknown_for_multi_plate_with_a_printer(tmp_path): + path = _prepared_multi_plate(tmp_path) + bed = {"min_x": 0.0, "min_y": 0.0, "max_x": 270.0, "max_y": 270.0} + placement = plate_placement.assess(path, bed=bed, bed_name="this printer's") + check = next(c for c in pf.evaluate(traits(), printer([spool()]), placement)["checks"] + if c["id"] == "bed.fit") + assert check["result"] == pf.UNKNOWN + assert check["confidence"] == pf.INFORMATIONAL + assert "Review the project in Snapmaker Orca" in check["action"] + assert "use Arrange" not in check["action"] + assert "Nothing is placed off the plate" not in json.dumps(check) + + +def test_preflight_bed_fit_stays_ok_for_a_single_plate_project(tmp_path): + one = convert(build(tmp_path, "one.3mf", plates=1), "preserve", tmp_path)["output_path"] + bed = {"min_x": 0.0, "min_y": 0.0, "max_x": 270.0, "max_y": 270.0} + placement = plate_placement.assess(one, bed=bed) + check = next(c for c in pf.evaluate(traits(), printer([spool()]), placement)["checks"] + if c["id"] == "bed.fit") + assert check["result"] == pf.OK + + +def test_ready_now_does_not_call_a_multi_plate_project_ready(tmp_path): + bed = {"min_x": 0.0, "min_y": 0.0, "max_x": 270.0, "max_y": 270.0} + p = printer([spool()]) + + def bucket(path): + placement = plate_placement.assess(path, bed=bed, bed_name="this printer's") + pre = pf.evaluate(traits(), p, placement) + return rd.classify_project(PROJECT, traits(), p, pre, "ok") + + one = convert(build(tmp_path, "one.3mf", plates=1), "preserve", tmp_path)["output_path"] + assert bucket(one)["bucket"] == rd.READY_NOW # control: the same flow, one plate + multi = bucket(_prepared_multi_plate(tmp_path)) + assert multi["bucket"] == rd.CANT_DETERMINE # unresolved, not blocked, not ready + assert "Fits the printer's bed" in multi["top_reason"] + + +# --- 3. the Doctor ------------------------------------------------------------------ + +def test_doctor_ready_carries_the_unresolved_placement_note_for_multi_plate(tmp_path): + prepared = _prepared_multi_plate(tmp_path) + d = service.doctor(prepared) + assert d["verdict"] == "READY" and d["plate_count"] == 2 + assert d["recommended_action"].startswith( + "Ready for Snapmaker U1 - open it in Snapmaker Orca and review it before slicing.") + assert "has not checked where they sit" in d["recommended_action"] + assert "Review it in Snapmaker Orca before slicing" in d["recommended_action"] + assert "use Arrange" not in d["recommended_action"] + + +def test_doctor_ready_for_a_single_plate_project_is_unchanged(tmp_path): + one = convert(build(tmp_path, "one.3mf", plates=1), "preserve", tmp_path)["output_path"] + d = service.doctor(one) + assert d["verdict"] == "READY" + assert d["recommended_action"] == "Ready for Snapmaker U1 - open it in Snapmaker Orca and slice." + + +# --- 4. Recommended mode and the creator's supports --------------------------------- + +def test_recommended_says_it_does_not_keep_the_creators_supports(tmp_path): + # Painted support enforcers are positive intent and are kept (see test_support_intent); the note is for + # supports the creator turned on without any such data, which the starter reset still drops. + src = build(tmp_path, supports=True) + summary = convert(src, "recommended", tmp_path)["settings_summary"] + note = summary["supports_note"] + assert note.startswith("Supports: the creator turned supports on. This mode does not keep them;") + assert "Snapmaker Orca will open with supports off" in note + assert "Support tab" in note and "Preserve creator settings" in note + + +def test_recommended_does_not_mention_painted_supports_that_do_not_exist(tmp_path): + summary = convert(build(tmp_path, supports=True), "recommended", tmp_path)["settings_summary"] + assert "painted" not in summary["supports_note"] + + +def test_the_note_is_computed_from_the_config_not_asserted(tmp_path): + # Supports off in the source: nothing to say, even in Recommended. + off = convert(build(tmp_path, "off.3mf"), "recommended", tmp_path)["settings_summary"] + assert "supports_note" not in off + + +def test_preserve_keeps_the_supports_and_says_nothing_about_dropping_them(tmp_path): + src = build(tmp_path, supports=True, painted_supports=True) + r = convert(src, "preserve", tmp_path) + assert "supports_note" not in r["settings_summary"] + with zipfile.ZipFile(r["output_path"]) as z: + cfg = json.loads(z.read(SETTINGS)) + assert cfg["enable_support"] == "1" + assert "enable_support" in cfg["different_settings_to_system"][0] # Orca will use it + + +def test_recommended_still_writes_what_it_always_wrote_for_supports(tmp_path): + # Disclosure only: Recommended's output is not changed to re-declare the support keys. + r = convert(build(tmp_path, supports=True), "recommended", tmp_path) + with zipfile.ZipFile(r["output_path"]) as z: + cfg = json.loads(z.read(SETTINGS)) + assert "enable_support" not in cfg["different_settings_to_system"][0] + + +def test_validation_report_only_says_values_are_kept_where_that_is_true(tmp_path): + src = build(tmp_path, supports=True) + rec = convert(src, "recommended", tmp_path)["output_path"] + pres = convert(src, "preserve", tmp_path)["output_path"] + + def changes(path, mode): + return " | ".join(service.report(path, mode)["changes"]) + + assert "your setting values are kept" in changes(pres, "preserve") + rec_text = changes(rec, "recommended") + assert "values are kept" not in rec_text + assert "replaced by Studio's U1 starter settings" in rec_text + # The file alone does not say how it was prepared: no promise either way. + unknown = changes(rec, None) + assert "your setting values are kept" not in unknown + assert "only when prepared with Preserve creator settings" in unknown + assert "your setting values are kept" not in changes(pres, None) + + +# --- 5. the prime tower, computed from the project's own settings ------------------- + +# A 330 x 320 source bed's tower position and width, as the C11 project carried them. +# Plate 1's start corner (x 296.9) is itself outside the 270.5 mm edge; plate 2 (165, 250) is inside. +SOURCE_TOWER = (["296.9", "165"], ["233.729", "250"], "60") +# The C11 project's real values: the corner is inside and the width is not an extent, so nothing is proven. +C11_TOWER = (["236.895", "165"], ["233.729", "250"], "60") + + +def test_preserve_reports_the_inherited_tower_outside_the_u1_area(tmp_path): + r = convert(build(tmp_path, tower=SOURCE_TOWER), "preserve", tmp_path) + note = r["settings_summary"]["prime_tower_note"] + assert note.startswith("The prime tower (plate 1) sits outside the") + assert "printable area" in note and "X 296.9, Y 233.729" in note + assert "wide" not in note and "review it in Snapmaker Orca" in note + with zipfile.ZipFile(r["output_path"]) as z: # detection only: nothing moved + cfg = json.loads(z.read(SETTINGS)) + assert cfg["wipe_tower_x"] == SOURCE_TOWER[0] + + +def test_recommended_tower_is_judged_on_what_it_wrote_not_on_the_source(tmp_path): + r = convert(build(tmp_path, tower=SOURCE_TOWER), "recommended", tmp_path) + with zipfile.ZipFile(r["output_path"]) as z: + cfg = json.loads(z.read(SETTINGS)) + from snapstudio_core import prime_tower + from snapstudio_core.plate_placement import u1_bed_rect + bed = u1_bed_rect() + expected = prime_tower.message(prime_tower.findings(cfg, 2, bed), bed) + assert expected and r["settings_summary"].get("prime_tower_note") == expected # proven-outside wins + + +def test_a_tower_inside_the_area_is_not_called_safe(tmp_path): + src = build(tmp_path, "in.3mf", plates=1, tower=(["13"], ["211"], "30")) + for mode in ("preserve", "recommended"): + note = convert(src, mode, tmp_path)["settings_summary"]["prime_tower_note"] + assert "could not verify how far the prime tower reaches" in note + assert "only builds a tower when the print needs one" in note # not claimed to exist + assert "at the position stated in this project" in note # not claimed to come from the source + assert "A G-code path goes beyond plate boundaries" in note + assert "Studio does not claim any tower position is safe" in note + low = note.lower() + assert not any(w in low for w in ("ready", "guarantee", "fits", "will fit", "safe to")) + out = convert(src, "preserve", tmp_path)["output_path"] + assert service.placement_check(out)["prime_tower"]["message"] is None + # Prepare and the placement card share one wording. + assert service.placement_check(out)["prime_tower"]["note"] == note + + +def _entry0(r): + with zipfile.ZipFile(r["output_path"]) as z: + cfg = json.loads(z.read(SETTINGS)) + return cfg, set(str(cfg["different_settings_to_system"][0]).split(";")) + + +TOWER_SHAPE = {"wipe_tower_wall_type", "wipe_tower_extra_spacing", + "wipe_tower_extra_rib_length", "wipe_tower_cone_angle"} + + +def test_recommended_declares_the_tower_shape_it_lists(tmp_path): + src = build(tmp_path, "in.3mf", plates=1, tower=(["13"], ["211"], "30")) + cfg, declared = _entry0(convert(src, "recommended", tmp_path)) + assert TOWER_SHAPE <= declared + assert cfg["wipe_tower_wall_type"] == "rectangle" and cfg["wipe_tower_extra_spacing"] == "100%" + assert "z_hop_when_prime" not in declared + + +def test_preserve_does_not_declare_tower_shape(tmp_path): + src = build(tmp_path, "in.3mf", plates=1, tower=(["13"], ["211"], "30")) + _, declared = _entry0(convert(src, "preserve", tmp_path)) + assert not (TOWER_SHAPE & declared) + + +STARTER_SHAPE = {"wipe_tower_wall_type": "rectangle", "wipe_tower_extra_spacing": "100%", + "wipe_tower_extra_rib_length": "0", "wipe_tower_cone_angle": "30"} +OTHER_SHAPE = {"wipe_tower_wall_type": "rib", "wipe_tower_extra_spacing": "120%", + "wipe_tower_extra_rib_length": "10", "wipe_tower_cone_angle": "45"} + + +def _tower_source(tmp_path, shape, **kw): + return build(tmp_path, "shape.3mf", plates=1, tower=(["13"], ["211"], "30"), settings=shape, **kw) + + +@pytest.mark.parametrize("shape", [OTHER_SHAPE, STARTER_SHAPE], ids=["source-differs", "source-already-matches"]) +def test_recommended_declares_the_tower_shape_whatever_the_source_held(tmp_path, shape): + # The declaration is for the values Recommended writes, not for the values it happened to change. + cfg, declared = _entry0(convert(_tower_source(tmp_path, shape), "recommended", tmp_path)) + assert TOWER_SHAPE <= declared + assert {k: cfg[k] for k in TOWER_SHAPE} == STARTER_SHAPE + + +def test_recommended_declares_the_tower_shape_again_on_an_already_prepared_copy(tmp_path): + first = convert(_tower_source(tmp_path, OTHER_SHAPE), "recommended", tmp_path)["output_path"] + again = tmp_path / "again" + r = service.convert(first, out_dir=str(again), prepare_mode="recommended") + assert r["validated_ok"], r["errors"] + cfg, declared = _entry0(r) + assert TOWER_SHAPE <= declared and {k: cfg[k] for k in TOWER_SHAPE} == STARTER_SHAPE + + +def test_tower_shape_is_declared_beside_ordinary_global_supports(tmp_path): + cfg, declared = _entry0(convert(_tower_source(tmp_path, STARTER_SHAPE, supports=True), + "recommended", tmp_path)) + assert TOWER_SHAPE <= declared + assert cfg["enable_support"] == "1" and "enable_support" not in declared # starter reset unchanged + + +def test_tower_shape_is_declared_beside_explicit_positive_support_intent(tmp_path): + cfg, declared = _entry0(convert( + _tower_source(tmp_path, STARTER_SHAPE, supports=True, painted_supports=True), "recommended", tmp_path)) + assert TOWER_SHAPE <= declared + assert "enable_support" in declared # positive intent kept, as before + + +def test_z_hop_when_prime_is_labelled_unverified_not_established(tmp_path): + r = convert(build( + tmp_path, "z.3mf", plates=1, settings={"z_hop_when_prime": ["1"] * 4}), "recommended", tmp_path) + change = next(c for c in r["settings_summary"]["compat_changed"] if c["key"] == "z_hop_when_prime") + assert "unverified" in change["reason"] + _, declared = _entry0(r) + assert "z_hop_when_prime" not in declared + + +def test_ordinary_supports_are_still_a_starter_reset(tmp_path): + src = build(tmp_path, "in.3mf", plates=1, tower=(["13"], ["211"], "30")) + _, declared = _entry0(convert(src, "recommended", tmp_path)) + assert "enable_support" not in declared + + +def test_no_tower_note_when_the_tower_is_disabled(tmp_path): + from snapstudio_core import prime_tower + assert prime_tower.summary_note({"enable_prime_tower": "0", "wipe_tower_x": ["1"], + "wipe_tower_y": ["1"]}, []) is None + assert prime_tower.summary_note({"enable_prime_tower": "1"}, []) is None + + +def test_placement_card_data_names_the_tower(tmp_path): + out = convert(build(tmp_path, tower=SOURCE_TOWER), "preserve", tmp_path)["output_path"] + tower = service.placement_check(out)["prime_tower"] + assert [t["plate"] for t in tower["outside"]] == [1] # plate 2 (165, 250) is inside + assert "Snapmaker Orca can refuse to slice" in tower["message"] + + +def test_doctor_ready_carries_the_tower_even_on_one_plate(tmp_path): + out = convert(build(tmp_path, "t1.3mf", plates=1, tower=SOURCE_TOWER), + "preserve", tmp_path)["output_path"] + d = service.doctor(out) + assert d["verdict"] == "READY" + assert d["recommended_action"].startswith("Ready for Snapmaker U1") + assert "The prime tower (plate 1) sits outside" in d["recommended_action"] + + +def test_tower_outside_is_attention_and_never_ready_now(tmp_path): + bed = {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0} + p = printer([spool()]) + + def run(path): + placement = plate_placement.assess(path, bed=bed, bed_name="the U1's") + pre = pf.evaluate(traits(), p, placement) + return pre, rd.classify_project(PROJECT, traits(), p, pre, "ok") + + one = convert(build(tmp_path, "t1.3mf", plates=1, tower=SOURCE_TOWER), "preserve", + tmp_path)["output_path"] + pre, result = run(one) + check = next(c for c in pre["checks"] if c["id"] == "prime_tower.fit") + assert check["result"] == pf.ATTENTION and check["confidence"] == pf.CONFIRMED + assert result["bucket"] == rd.NEEDS_ATTENTION + + ok = convert(build(tmp_path, "ok.3mf", plates=1, tower=(["13"], ["211"], "30")), + "preserve", tmp_path)["output_path"] + pre, result = run(ok) + assert not any(c["id"] == "prime_tower.fit" for c in pre["checks"]) + assert result["bucket"] == rd.READY_NOW + + +def test_a_tower_that_is_not_proven_outside_is_never_called_fine(tmp_path): + # Depth, brim and rotation are only known when Orca slices (Print.cpp + # wipe_tower_data().depth), so "inside" is never claimed: a note says so instead. + out = convert(build(tmp_path, "in.3mf", plates=1, tower=(["13"], ["211"], "30")), + "preserve", tmp_path)["output_path"] + tower = service.placement_check(out)["prime_tower"] + assert tower["message"] is None and tower["outside"] == [] + assert tower["note"].startswith("Studio could not verify how far the prime tower reaches") + # Informational only: it does not turn into a preflight check or a Doctor claim. + assert "prime tower" not in service.doctor(out)["recommended_action"].lower() + + +def test_a_rotated_tower_is_not_judged(tmp_path): + from snapstudio_core import prime_tower + cfg = {"wipe_tower_x": ["300"], "wipe_tower_y": ["300"], "prime_tower_width": "60", + "wipe_tower_rotation_angle": "30"} + bed = {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0} + assert prime_tower.findings(cfg, 1, bed) == [] + assert prime_tower.unverified_note(cfg, []) is not None + + +def test_recommended_keeps_supports_the_model_itself_marks_and_says_nothing_about_dropping_them(tmp_path): + # Positive support intent (painted enforcers) survives Recommended, so no "does not keep" note. + src = build(tmp_path, "painted.3mf", supports=True, painted_supports=True) + r = convert(src, "recommended", tmp_path) + assert "supports_note" not in r["settings_summary"] + with zipfile.ZipFile(r["output_path"]) as z: + cfg = json.loads(z.read(SETTINGS)) + assert cfg["enable_support"] == "1" + + +def test_a_tower_whose_corner_is_inside_is_not_proven_outside_by_its_width(tmp_path): + r = convert(build(tmp_path, "c11.3mf", plates=1, tower=C11_TOWER), "preserve", tmp_path) + assert "sits outside" not in r["settings_summary"]["prime_tower_note"] # not proven outside; unknown reach is said + tower = service.placement_check(r["output_path"])["prime_tower"] + assert tower["outside"] == [] and tower["note"].startswith("Studio could not verify") diff --git a/backend/tests/test_no_arrange_remedy.py b/backend/tests/test_no_arrange_remedy.py new file mode 100644 index 00000000..0faa348b --- /dev/null +++ b/backend/tests/test_no_arrange_remedy.py @@ -0,0 +1,45 @@ +"""Studio does not recommend Arrange as a remedy. + +In Snapmaker Orca 2.4.0, on a two-plate project made for a larger-bed printer, +Arrange all plates did not fix an Outside object or an inherited prime tower; for a +single plate it was never tested. A text that tells people Arrange fixes placement is +therefore unsupported. The one sentence that may name it says it did not work. +""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +ALLOWED = "Arrange all plates did not fix that in our test" +ARRANGE = re.compile(r"\barrange\b", re.IGNORECASE) # not "arrangement" + +SCAN = [ + (ROOT / "backend" / "snapstudio_core", "*.py"), + (ROOT / "backend" / "snapstudio_api", "*.py"), + (ROOT / "backend" / "u1convert", "*.py"), + (ROOT / "desktop" / "src", "*.ts"), + (ROOT / "desktop" / "src", "*.tsx"), + (ROOT / "site" / "guide" / "content", "*.json"), +] + + +def _files(): + for base, pattern in SCAN: + for path in sorted(base.rglob(pattern)): + if ".test." in path.name or "node_modules" in path.parts: + continue + yield path + + +def test_no_text_recommends_arrange(): + offenders = [] + for path in _files(): + for number, line in enumerate(path.read_text("utf-8").splitlines(), 1): + if ARRANGE.search(line.replace(ALLOWED, "")): + offenders.append(f"{path.relative_to(ROOT)}:{number}: {line.strip()[:120]}") + assert not offenders, "Arrange is not a tested remedy:\n" + "\n".join(offenders) + + +def test_the_guard_actually_scans_something(): + assert sum(1 for _ in _files()) > 100 diff --git a/backend/tests/test_orca_import.py b/backend/tests/test_orca_import.py index 5af8ce5b..e065626d 100644 --- a/backend/tests/test_orca_import.py +++ b/backend/tests/test_orca_import.py @@ -23,7 +23,9 @@ def test_exclude_object_is_enabled_when_off(): assert cfg["exclude_object"] == "1" change = next(c for c in changes if c["key"] == "exclude_object") assert change["old"] == "0" - assert "adaptive" in change["explanation"].lower() or "cancel" in change["explanation"].lower() + # the explanation describes only Studio's operation, not external slicer behavior + assert change["explanation"] == "Studio's import rule turns Exclude Object on for every U1 copy; the original had it off." + assert change["kind"] == "engine" def test_exclude_object_already_on_is_not_reported_as_a_change(): diff --git a/backend/tests/test_part_paths_and_move_verifier.py b/backend/tests/test_part_paths_and_move_verifier.py new file mode 100644 index 00000000..bc9593e9 --- /dev/null +++ b/backend/tests/test_part_paths_and_move_verifier.py @@ -0,0 +1,222 @@ +"""Build items and components that name a part (p:path), plate records that cannot place an instance, +and the verifier that proves a moved copy changed only where things sit (issue 93 repair round).""" +from __future__ import annotations + +import zipfile + +from snapstudio_core import geometry, placement, plate_placement as pp +from tests import scene_fixtures as fx + +SETTINGS = "Metadata/model_settings.config" +A, B = "3D/Objects/a.model", "3D/Objects/b.model" +MOVE = "every object moved by the same amount, in X and Y only" +KEPT = "every build item is still there, unchanged apart from where it sits" + + +def _rows(report): + return {row["item_index"]: row for row in report["items"]} + + +def _two_parts(tmp_path, items, objects=(), settings=None, a_size=10, b_size=50, name="ext.3mf"): + extra = {A: fx.sub_model_xml([fx.cube_object("1", a_size)]), + B: fx.sub_model_xml([fx.cube_object("1", b_size)])} + if settings: + extra[SETTINGS] = settings + return fx.three_mf(tmp_path / name, fx.model_xml(list(objects), items), extra) + + +# --- keyed by (part, object id) ------------------------------------------------------------------------ + +def test_object_one_in_two_parts_stays_two_objects(tmp_path): + path = _two_parts(tmp_path, [("1", fx.tf(100, 100, 0), "/" + A), ("1", fx.tf(500, 100, 0), "/" + B)]) + report = pp.assess(str(path)) + rows = _rows(report) + assert (rows[0]["bounds_mm"]["min"][0], rows[0]["bounds_mm"]["max"][0]) == (100.0, 110.0) + assert (rows[1]["bounds_mm"]["min"][0], rows[1]["bounds_mm"]["max"][0]) == (500.0, 550.0) + assert [row["item_index"] for row in report["off_plate"]] == [1] + + +def test_the_engine_reads_each_part_object_separately(tmp_path): + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0), ("1", 1)])]) + path = _two_parts(tmp_path, [("1", fx.tf(100, 100, 0), "/" + A), ("1", fx.tf(500, 100, 0), "/" + B)], + settings=settings) + objects = placement.read_objects(str(path))["objects"] + assert [(o["part"], o["footprint"]["min_x"], o["footprint"]["max_x"]) for o in objects] == [ + (A, 100.0, 110.0), (B, 500.0, 550.0)] + + +def test_component_references_across_files_are_keyed_by_part(tmp_path): + comp = fx.composite_object("5", [("1", "/" + A, None), ("1", "/" + B, fx.tf(100, 0, 0))]) + path = _two_parts(tmp_path, [("5", fx.tf(20, 20, 0))], objects=[comp], name="comp.3mf") + [item] = pp.assess(str(path))["items"] + # a.model's cube is 0..10; b.model's is 0..50 moved 100 by its component transform + assert (item["bounds_mm"]["min"][0], item["bounds_mm"]["max"][0]) == (20.0, 170.0) + [obj] = placement.read_objects(str(path))["objects"] + assert (obj["footprint"]["min_x"], obj["footprint"]["max_x"]) == (20.0, 170.0) + + +def test_an_unresolvable_part_path_is_unresolved_not_the_root_object(tmp_path): + # a decoy object 1 in the root model must NOT stand in for the missing part + root = fx.model_xml([fx.cube_object("1", 10)], + [("1", fx.tf(100, 100, 0)), ("1", fx.tf(100, 100, 0), "/3D/Objects/missing.model")]) + report = pp.assess(str(fx.three_mf(tmp_path / "m.3mf", root))) + assert [r["item_index"] for r in report["items"]] == [0] + assert [u["item_index"] for u in report["unresolved_objects"]] == [1] and report["not_judged"] == 1 + assert "1 placed instance could not be judged" in report["summary"] + assert report["summary"].startswith("Every placed instance Studio could measure") and report["fixable"] is False + only = fx.three_mf(tmp_path / "o.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0), "/3D/Objects/missing.model")])) + assert pp.assess(str(only))["available"] is False + assert [u["item_index"] for u in geometry.measure_items(str(only))[1]] == [0] + + +def test_item_numbering_ignores_commented_out_items(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + root = root.replace("", '') + path = fx.three_mf(tmp_path / "c.3mf", root) + assert [i["item_index"] for i in geometry.build_item_dims(str(path))] == [0, 1] + assert [o["item_index"] for o in placement.read_objects(str(path))["objects"]] == [0, 1] + + +# --- instances Studio cannot place on a plate are never harmless ------------------------------------------ + +def _plated(tmp_path, items, plates, objects=None, name="pl.3mf"): + objects = objects or {"1": [("1", "normal_part")]} + settings = fx.model_settings_xml(objects, plates) + return fx.three_mf(tmp_path / name, fx.model_xml([fx.cube_object(o, 10) for o in objects], items), + {SETTINGS: settings}) + + +def test_instances_with_no_instance_id_are_not_judged_and_never_reported_as_fitting(tmp_path): + path = _plated(tmp_path, [("1", fx.tf(50, 50, 0)), ("1", fx.tf(400, 50, 0))], + [(1, [("1", None)]), (2, [("1", None)])]) + report = pp.assess(str(path)) + assert report["not_judged"] == 2 and len(report["unresolved_objects"]) == 2 + assert "2 placed instances could not be judged" in report["summary"] + assert "All" not in report["summary"] and "fit" not in report["summary"].lower().replace("fits", "") + assert report["plate_fit"] == [] and report["fixable"] is False + + +def test_some_instances_judged_and_some_not_is_said_plainly(tmp_path): + objects = {"1": [("1", "normal_part")], "2": [("2", "normal_part")]} + path = _plated(tmp_path, [("1", fx.tf(50, 50, 0)), ("1", fx.tf(60, 50, 0)), ("2", fx.tf(400, 50, 0))], + [(1, [("1", None)]), (2, [("2", 0)])], objects=objects) + report = pp.assess(str(path)) + assert report["not_judged"] == 2 and "All" not in report["summary"] + assert report["summary"].startswith("None of the plates Studio could assign is too big") + assert "2 placed instances could not be judged" in report["summary"] + + +# --- the verifier: negative tests -------------------------------------------------------------------------- + +def _mutate(src, dest, edit): + with zipfile.ZipFile(src) as archive: + entries = {name: archive.read(name) for name in archive.namelist()} + entries["3D/3dmodel.model"] = edit(entries["3D/3dmodel.model"].decode("utf-8")).encode("utf-8") + return str(fx.write_zip(dest, entries)) + + +def _source(tmp_path): + return str(_two_parts(tmp_path, [("1", fx.tf(-5, 100, 0), "/" + A), ("1", fx.tf(100, 100, 0), "/" + B)], + name="v.3mf")) + + +def _moved(text): + return text.replace("1 -5 100 0", "1 1 100 0").replace("1 100 100 0", "1 106 100 0") + + +def _checks(result): + return {c["check"]: c["pass"] for c in result["checks"]} + + +def test_a_faithful_uniform_move_passes_the_verifier(tmp_path): + src = _source(tmp_path) + result = pp.verify_only_placement_moved(src, _mutate(src, tmp_path / "ok.3mf", _moved)) + assert result["passed"] is True and result["delta_mm"] == {"x": 6.0, "y": 0.0} + + +def test_switching_an_items_part_path_fails_the_verifier(tmp_path): + src = _source(tmp_path) + + def swap(text): + return _moved(text).replace("/" + A, "/@").replace("/" + B, "/" + A).replace("/@", "/" + B) + + result = pp.verify_only_placement_moved(src, _mutate(src, tmp_path / "swap.3mf", swap)) + assert result["passed"] is False and _checks(result)[KEPT] is False + + +def test_changing_another_item_attribute_fails_the_verifier(tmp_path): + src = _source(tmp_path) + result = pp.verify_only_placement_moved( + src, _mutate(src, tmp_path / "attr.3mf", lambda t: _moved(t).replace("", "" + "") + path = tmp_path / "bomb.3mf" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("_rels/.rels", fx.rels_xml()) + z.writestr("3D/3dmodel.model", padded) + assert path.stat().st_size < 1_000_000 + started = time.perf_counter() + report = pp.assess(str(path)) + assert report["available"] is False + assert time.perf_counter() - started < 20 + try: + placement.read_objects(str(path)) + raise AssertionError("expected a refusal") + except placement.TooLargeToMeasure: + pass diff --git a/backend/tests/test_plate_placement.py b/backend/tests/test_plate_placement.py index 3296b0eb..82a34596 100644 --- a/backend/tests/test_plate_placement.py +++ b/backend/tests/test_plate_placement.py @@ -311,7 +311,9 @@ def test_multi_plate_projects_are_never_repositioned(tmp_path): }, printable_area=SOURCE_350) out = pp.assess(p) assert out["fixable"] is False - assert "does not reposition" in out["summary"] + # Reworded: the old text claimed the plates "fit" the printable area and only said Studio + # does not reposition them; it now says what Studio cannot check (see test_honest_claims). + assert "cannot check where the plates sit" in out["summary"] res = pp.prepare_placed_copy(p, out_dir=str(out_dir)) assert res["ok"] is False assert not out_dir.exists() or list(out_dir.iterdir()) == [] @@ -322,7 +324,7 @@ def test_the_refusal_explains_why_rather_than_just_declining(tmp_path): 1: [(1, 170, 170)], 2: [(2, 540, 170)], }, printable_area=SOURCE_350) summary = pp.assess(p)["summary"] - assert "not recorded in the file" in summary or "does not reposition" in summary + assert "cannot check where the plates sit" in summary and "did not fix that in our test" in summary def test_a_plate_whose_contents_are_too_big_is_reported(tmp_path): diff --git a/backend/tests/test_preflight.py b/backend/tests/test_preflight.py index 7c769399..aa448c5a 100644 --- a/backend/tests/test_preflight.py +++ b/backend/tests/test_preflight.py @@ -28,9 +28,18 @@ def printer(**values): return base -def placement(off=0, fixable=True, available=True): - return {"available": available, "fixable": fixable, - "off_plate": [{"object_id": str(i)} for i in range(off)]} +def placement(off=0, fixable=True, available=True, **more): + out = {"available": available, "fixable": fixable, + "off_plate": [{"object_id": str(i)} for i in range(off)]} + out.update(more) + return out + + +U1_PLATE = {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0} + + +def item(top): + return {"object_id": "1", "bounds_mm": {"min": [10, 10, 0], "max": [20, 20, top]}} def by_id(result, check_id): @@ -225,12 +234,35 @@ def test_without_the_ordered_trait_falls_back_to_todays_set_comparison(): # --- the bed ---------------------------------------------------------------- -def test_bed_uses_the_printers_real_dimensions_in_its_evidence(): - out = pf.evaluate(traits(), printer(bed_mm={"x": 270, "y": 270, "z": 270}), - placement=placement(off=0)) +def test_bed_evidence_names_the_printable_area_not_the_travel_figure(): + out = pf.evaluate(traits(), printer(bed_mm={"x": 271, "y": 335, "z": 275}), + placement=placement(off=0, bed=U1_PLATE, bed_height_mm=270.05, items=[item(20)])) check = by_id(out, "bed.fit") - assert check["result"] == pf.OK - assert "270" in check["evidence"] + assert check["result"] == pf.OK and check["confidence"] == pf.CONFIRMED + assert "270 × 270 mm printable area" in check["evidence"] and "335" not in check["evidence"] + assert "height limit" in check["evidence"] + + +def test_a_tall_object_is_not_confirmed_to_fit(): + out = pf.evaluate(traits(), printer(), + placement=placement(off=0, bed=U1_PLATE, bed_height_mm=270.05, items=[item(300)])) + check = by_id(out, "bed.fit") + assert check["result"] == pf.ATTENTION and "300" in check["evidence"] and "height limit" in check["evidence"] + + +def test_without_height_data_the_claim_is_narrowed_to_x_and_y(): + out = pf.evaluate(traits(), printer(), placement=placement(off=0, bed=U1_PLATE)) + check = by_id(out, "bed.fit") + assert check["result"] == pf.OK and check["confidence"] == pf.INFORMATIONAL + assert "X and Y only" in check["evidence"] and "height was not checked" in check["evidence"] + + +def test_instances_that_could_not_be_judged_are_never_confirmed(): + out = pf.evaluate(traits(), printer(), + placement=placement(off=0, bed=U1_PLATE, bed_height_mm=270.05, items=[item(20)], not_judged=2)) + check = by_id(out, "bed.fit") + assert check["result"] == pf.UNKNOWN and check["confidence"] != pf.CONFIRMED + assert "2 placed instances could not be judged" in check["evidence"] def test_objects_off_the_real_bed_need_attention_and_point_at_the_fix(): @@ -240,9 +272,12 @@ def test_objects_off_the_real_bed_need_attention_and_point_at_the_fix(): assert "placement fix" in check["action"] or "placement" in check["action"] -def test_unfixable_placement_points_at_arrange_instead(): +def test_unfixable_placement_points_at_the_slicer_without_promising_a_fix(): + # Reworded: Arrange is not a tested remedy, so the action says to review the project. out = pf.evaluate(traits(), printer(), placement=placement(off=1, fixable=False)) - assert "Arrange" in by_id(out, "bed.fit")["action"] + action = by_id(out, "bed.fit")["action"] + assert "Review the project in Snapmaker Orca" in action + assert "Arrange" not in action def test_bed_is_unknown_when_placement_could_not_be_read(): diff --git a/backend/tests/test_public_claims.py b/backend/tests/test_public_claims.py index b65b802a..34473935 100644 --- a/backend/tests/test_public_claims.py +++ b/backend/tests/test_public_claims.py @@ -10,6 +10,7 @@ for every future release, not just one. """ import os +import json import re import pytest @@ -35,8 +36,7 @@ @pytest.mark.parametrize("rel", _PUBLIC_DOCS) def test_no_print_success_guarantees(rel): path = os.path.join(_ROOT, rel) - if not os.path.exists(path): - pytest.skip(f"{rel} not present") + assert os.path.exists(path), f"required public file is missing: {rel}" text = open(path, encoding="utf-8").read().lower() for phrase in _BANNED: assert phrase not in text, f"banned claim '{phrase}' found in {rel}" @@ -66,8 +66,7 @@ def test_no_print_success_guarantees(rel): ]) def test_public_docs_have_no_tooling_names(rel): path = os.path.join(_ROOT, rel) - if not os.path.exists(path): - pytest.skip(f"{rel} not present") + assert os.path.exists(path), f"required public file is missing: {rel}" text = open(path, encoding="utf-8").read().lower() for label, pattern in _BANNED_TOOLS: assert not re.search(pattern, text), f"tooling term '{label}' found in {rel}" @@ -106,8 +105,7 @@ def test_public_docs_have_no_tooling_names(rel): @pytest.mark.parametrize("rel", _RELEASE_SURFACES) def test_release_notes_have_no_internal_tooling_terms(rel): path = os.path.join(_ROOT, rel) - if not os.path.exists(path): - pytest.skip(f"{rel} not present") + assert os.path.exists(path), f"required public file is missing: {rel}" text = open(path, encoding="utf-8").read().lower() for label, pattern in _BANNED_INTERNAL: assert not re.search(pattern, text), f"internal/tooling term '{label}' found in {rel}" @@ -138,3 +136,25 @@ def test_intelligence_report_copy_is_advisory(): def test_success_predict_verdict_not_overconfident(): v = _sp.predict(readiness={"ready": True, "warnings": []})["verdict"].lower() assert "good to go" not in v + + +def test_readme_and_release_notes_keep_the_golden_answer_for_success_unknown(): + """Public-copy checks cannot detect a paraphrased overclaim or judge prose clarity.""" + for rel in ("README.md", os.path.join("docs", "RELEASE_NOTES.md")): + path = os.path.join(_ROOT, rel) + assert os.path.exists(path), f"required public file is missing: {rel}" + text = open(path, encoding="utf-8").read().lower() + assert "guaranteed print" not in text + assert "100% print success" not in text + + +def test_answers_forbidden_phrases_are_absent_from_all_public_guide_content(): + """Forbidden-phrase coverage cannot judge prose clarity, source fitness, honest dates, physical behavior, paraphrased overclaims, or screen readers.""" + answers = json.load(open(os.path.join(_ROOT, "site", "guide", "content", "answers.json"), encoding="utf-8")) + public = "\n".join( + json.dumps(json.load(open(os.path.join(_ROOT, "site", "guide", "content", name), encoding="utf-8"))) + for name in ("guide.json", "examples.json", "shots.json") + ).lower() + for answer in answers["answers"]: + for phrase in answer["forbidden"]: + assert phrase.lower() not in public, f"forbidden phrase '{phrase}' found in guide content" diff --git a/backend/tests/test_readiness.py b/backend/tests/test_readiness.py index 9aa5390a..1c822689 100644 --- a/backend/tests/test_readiness.py +++ b/backend/tests/test_readiness.py @@ -418,3 +418,19 @@ def test_colour_note_does_not_hide_a_possible_shortage(): assert r["colour_notes"] assert any("short" in u.lower() for u in r["unknowns"]) assert r["top_reason"] != rd.COLOUR_NOTE + + +def test_unjudged_instances_never_reach_ready_now(): + placement = {"available": True, "off_plate": [], "not_judged": 1, + "bed": {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0}, + "bed_height_mm": 270.05, "items": []} + r = classify(traits(), printer([spool()]), placement=placement) + assert r["bucket"] == rd.CANT_DETERMINE and r["bucket"] != rd.READY_NOW + + +def test_a_tall_object_does_not_reach_ready_now(): + placement = {"available": True, "off_plate": [], "bed_height_mm": 270.05, + "bed": {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0}, + "items": [{"object_id": "1", "bounds_mm": {"min": [10, 10, 0], "max": [20, 20, 300]}}]} + r = classify(traits(), printer([spool()]), placement=placement) + assert r["bucket"] != rd.READY_NOW diff --git a/backend/tests/test_ready_now_api.py b/backend/tests/test_ready_now_api.py index db95d943..b01e9a8e 100644 --- a/backend/tests/test_ready_now_api.py +++ b/backend/tests/test_ready_now_api.py @@ -45,7 +45,8 @@ def _printer(self, host=None, port=7125): return {"reachable": True, "host": host, "port": port, "toolhead_count": 4, "bed_mm": {"x": 270, "y": 270, "z": 270}, "nozzle_diameters": [0.4] * 4, "nozzle_confirmed_by": "printer", "print_state": self.state, - "klipper_objects": [], "loaded_filaments": None} + "klipper_objects": [], "loaded_filaments": None, + "identity": {"printer_id": "snapmaker_u1"}} def _provider(self, kind, url, slot_map=None, timeout=4.0, slot_base=None, key=None): self.provider_reads += 1 @@ -62,7 +63,7 @@ def _extract(self, path): name = path.replace("\\", "/").rsplit("/", 1)[-1] return self.traits_by_name.get(name) or traits() - def _assess(self, path, bed=None, bed_name=None): + def _assess(self, path, bed=None, bed_name=None, height_mm=None): self.placements += 1 return {"available": True, "off_plate": []} @@ -207,7 +208,7 @@ def test_second_scan_hits_the_cache_and_an_edited_file_does_not(tmp_path, monkey assert fakes.printer_reads == 3 -def test_cache_is_keyed_by_bed_size(tmp_path, monkeypatch): +def test_cache_is_keyed_by_the_printable_area(tmp_path, monkeypatch): fakes = Fakes(monkeypatch) add_project(tmp_path, "a.3mf") scan(provider_url=PROVIDER_URL) @@ -215,7 +216,7 @@ def test_cache_is_keyed_by_bed_size(tmp_path, monkeypatch): "reachable": True, "host": host, "port": port, "toolhead_count": 4, "bed_mm": {"x": 350, "y": 350, "z": 350}, "nozzle_diameters": [0.4] * 4, "nozzle_confirmed_by": "printer", "print_state": "standby", "klipper_objects": [], - "loaded_filaments": None}) + "loaded_filaments": None, "identity": {"printer_id": "voron_2_4_250"}}) monkeypatch.setattr(service, "printer_facts", fakes._printer) scan(provider_url=PROVIDER_URL) assert fakes.placements == 2 @@ -422,9 +423,101 @@ def boom(*a, **k): raise RuntimeError("transient") monkeypatch.setattr(plate_placement, "assess", boom) - bed = {"min_x": 0.0, "min_y": 0.0, "max_x": 270.0, "max_y": 270.0} + bed = {"key": ("test",), "bed": None, "name": None, "height": None} state1, _t, placement1 = service._ready_analysis(str(f), bed, need_placement=True) state2, _t, placement2 = service._ready_analysis(str(f), bed, need_placement=True) assert state1 == state2 == "ok" assert placement1 is None and placement2 is None assert calls["n"] == 2 + + +def test_an_unavailable_placement_is_not_cached_as_done(tmp_path, monkeypatch): + import shutil + from pathlib import Path + from snapstudio_api import service + from snapstudio_core import plate_placement + f = tmp_path / "a.3mf" + shutil.copy(Path(__file__).parent / "fixtures" / "painted" / "orcaslicer-2.4.2-painted-cube.3mf", f) + service._READY_CACHE.clear() + calls = {"n": 0} + + def unavailable(*a, **k): + calls["n"] += 1 + return {"available": False, "reason": "too large to measure", "items": [], "off_plate": []} + + monkeypatch.setattr(plate_placement, "assess", unavailable) + target = {"key": ("test",), "bed": None, "name": None, "height": None} + service._ready_analysis(str(f), target, need_placement=True) + service._ready_analysis(str(f), target, need_placement=True) + assert calls["n"] == 2 # tried again, not remembered as done + +def test_a_same_size_replacement_with_the_same_timestamp_is_not_served_stale(tmp_path, monkeypatch): + import os + from snapstudio_api import service + from snapstudio_core import plate_placement + from tests import scene_fixtures as fx + path = tmp_path / "a.3mf" + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))])) + before = os.stat(path) + service._READY_CACHE.clear() + seen = [] + + def spy(p, *a, **k): + seen.append(p) + return {"available": True, "off_plate": [], "items": [], "tag": len(seen)} + + monkeypatch.setattr(plate_placement, "assess", spy) + from snapstudio_core import project_traits + monkeypatch.setattr(project_traits, "extract", lambda p: {"readable": True}) + target = {"key": ("test",), "bed": None, "name": None, "height": None} + _s, _t, first = service._ready_analysis(str(path), target, need_placement=True) + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))])) + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) + assert os.stat(path).st_size == before.st_size and os.stat(path).st_mtime_ns == before.st_mtime_ns + _s, _t, second = service._ready_analysis(str(path), target, need_placement=True) + assert first["tag"] == 1 and second["tag"] == 2 # analysed again, not served from the cache + + +def test_a_file_replaced_while_it_is_being_read_is_read_again_never_mixed(tmp_path, monkeypatch): + import os + from snapstudio_api import service + from snapstudio_core import plate_placement, project_traits + from tests import scene_fixtures as fx + path = tmp_path / "a.3mf" + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))])) + service._READY_CACHE.clear() + reads = [] + + def extract(p): + reads.append(1) + if len(reads) == 1: # the file is replaced after Studio looked at version A + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))])) + return {"readable": True, "tag": "A"} + return {"readable": True, "tag": "B"} + + monkeypatch.setattr(project_traits, "extract", extract) + monkeypatch.setattr(plate_placement, "assess", lambda p, *a, **k: {"available": True, "off_plate": [], "items": []}) + target = {"key": ("test",), "bed": None, "name": None, "height": None} + state, traits, _placement = service._ready_analysis(str(path), target, need_placement=True) + assert state == "ok" and traits["tag"] == "B" and len(reads) == 2 + assert len(service._READY_CACHE) == 1 # only the consistent B read was cached + + +def test_a_file_that_never_holds_still_is_unreadable_and_not_cached(tmp_path, monkeypatch): + from snapstudio_api import service + from snapstudio_core import project_traits + from tests import scene_fixtures as fx + path = tmp_path / "a.3mf" + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))])) + service._READY_CACHE.clear() + count = {"n": 0} + + def extract(p): + count["n"] += 1 + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 10 + count["n"])], [("1", fx.tf(100, 100, 0))])) + return {"readable": True} + + monkeypatch.setattr(project_traits, "extract", extract) + target = {"key": ("test",), "bed": None, "name": None, "height": None} + assert service._ready_analysis(str(path), target, need_placement=False) == ("unreadable", None, None) + assert count["n"] == service._READY_ATTEMPTS and len(service._READY_CACHE) == 0 diff --git a/backend/tests/test_regrid.py b/backend/tests/test_regrid.py new file mode 100644 index 00000000..a79511d3 --- /dev/null +++ b/backend/tests/test_regrid.py @@ -0,0 +1,358 @@ +"""Per-plate re-grid, prime-tower placement and support intent, through the real producer +(``convert_to_u1``) on projects built at test time from the committed Orca-authored base.""" +from __future__ import annotations + +import pytest + +from snapstudio_core import regrid +from snapstudio_core.convert import convert_to_u1 +from tests import regrid_fixtures as rf + +MODES = ["preserve", "recommended"] + + +def run(tmp_path, src, mode): + return convert_to_u1(src, out_dir=str(tmp_path / f"out_{mode}"), prepare_mode=mode) + + +def tokens(path): + return [i["transform"].split() for i in rf.items_of(path)] + + +# --- Orca's grid rule ---------------------------------------------------------------------------- + +def test_column_count_matches_orcas_rule(): + assert [regrid.column_count(n) for n in (1, 2, 3, 4, 5, 6, 7, 9, 10)] == [1, 2, 2, 2, 3, 3, 3, 3, 4] + + +def test_origin_goes_right_then_down_with_negative_y(): + stride = (324.0, 324.0) + assert regrid.origin(0, 3, stride) == (0, 0) + assert regrid.origin(2, 3, stride) == (648.0, 0) + assert regrid.origin(3, 3, stride) == (0, -324.0) + + +# --- the plate grid ------------------------------------------------------------------------------ + +@pytest.mark.parametrize("mode", MODES) +def test_a_larger_bed_stride_is_regridded_per_plate_in_both_modes(tmp_path, mode): + src = rf.project(tmp_path, plates=2, bed=(330.0, 320.0)) + before = tokens(src) + result = run(tmp_path, src, mode) + after = tokens(result.output_path) + # plate 1 is where it was; plate 2 moved from 396 + 165 to 324 + 165 (the U1 stride) + assert after[0] == before[0] and after[1][9] == "489" and after[1][10] == "160" + layout = result.settings_summary["layout"] + assert layout["status"] == "regridded" and layout["formula_check"] == {"items": 2, "matched": 2, "mismatches": []} + assert layout["stride_source"] == [396.0, 384.0] and layout["stride_target"] == [324.0, 324.0] + assert any("Repositioned plate 2 objects to the U1 plate layout" in line + for line in result.settings_summary["placement_changes"]) + assert result.validated_ok + + +@pytest.mark.parametrize("mode", MODES) +def test_rotation_and_scale_tokens_survive_verbatim(tmp_path, mode): + rot = "0.7071067812 0.7071067812 0 -0.7071067812 0.7071067812 0 0 0 1.5" + ox, oy = rf.source_origin(1, 2, (330.0, 320.0)) + src = rf.project(tmp_path, plates=2, transforms={1: f"{rot} {ox + 165:g} {oy + 160:g} 10"}) + result = run(tmp_path, src, mode) + after = tokens(result.output_path)[1] + assert " ".join(after[:9]) == rot and after[11] == "10" # basis and Z, byte for byte + assert after[9] == "489" and after[10] == "160" + + +@pytest.mark.parametrize("mode", MODES) +def test_five_plates_use_negative_y_rows(tmp_path, mode): + src = rf.project(tmp_path, plates=5, bed=(330.0, 320.0)) + result = run(tmp_path, src, mode) + after = tokens(result.output_path) + layout = result.settings_summary["layout"] + assert layout["cols"] == 3 and layout["status"] == "regridded" + # plate 4 (index 3): row 1, col 0 -> y = -324 + 160 on the U1 grid (it was -384 + 160) + assert (after[3][9], after[3][10]) == ("165", "-164") + # plate 3 (index 2): col 2 -> x = 648 + 165 + assert (after[2][9], after[2][10]) == ("813", "160") + + +@pytest.mark.parametrize("mode", MODES) +def test_an_item_outside_its_recorded_cell_is_never_guessed(tmp_path, mode): + # item 2 is recorded on plate 2 but sits where plate 1's cell is: the formula does not hold + src = rf.project(tmp_path, plates=2, transforms={1: f"{rf.IDENT} 165 160 10"}) + before = tokens(src) + result = run(tmp_path, src, mode) + assert tokens(result.output_path) == before # nothing moved + layout = result.settings_summary["layout"] + assert layout["status"] == "unresolved" and layout["formula_check"]["mismatches"] + assert any("Plate positions were left" in w for w in result.settings_summary["warnings"]) + + +@pytest.mark.parametrize("mode", MODES) +def test_an_item_straddling_its_cell_edge_is_never_guessed(tmp_path, mode): + # item 2's CENTRE is inside plate 2's 330 mm cell (x 325) but its 20 mm footprint reaches 335 + ox, oy = rf.source_origin(1, 2, (330.0, 320.0)) + src = rf.project(tmp_path, plates=2, transforms={1: f"{rf.IDENT} {ox + 325:g} {oy + 160:g} 10"}) + before = tokens(src) + result = run(tmp_path, src, mode) + assert tokens(result.output_path) == before # nothing moved + layout = result.settings_summary["layout"] + assert layout["status"] == "unresolved" and layout["formula_check"]["mismatches"][0]["item_index"] == 1 + + +@pytest.mark.parametrize("mode", MODES) +def test_the_same_grid_is_left_alone(tmp_path, mode): + src = rf.project(tmp_path, plates=2, bed=(270.0, 270.0), local=(135.5, 136.0)) + before = tokens(src) + result = run(tmp_path, src, mode) + assert tokens(result.output_path) == before + assert result.settings_summary["layout"]["status"] == "same_grid" + + +@pytest.mark.parametrize("mode", MODES) +def test_a_plate_that_needs_a_nudge_gets_the_minimal_fit(tmp_path, mode): + # local y 290 on a 320 bed is outside the U1's 271 limit: plate 2 is nudged, plate 1 is not + ox, oy = rf.source_origin(1, 2, (330.0, 320.0)) + src = rf.project(tmp_path, plates=2, transforms={1: f"{rf.IDENT} {ox + 165:g} {oy + 290:g} 10"}) + result = run(tmp_path, src, mode) + after = tokens(result.output_path) + plate = result.settings_summary["layout"]["plates"][1] + assert plate["fits_u1_plate"] is True and plate["fit_shift"][1] < 0 + assert float(after[1][10]) <= 271 - 10 - 0.5 + 1e-6 and after[1][9] == "489" + + +@pytest.mark.parametrize("mode", MODES) +def test_the_original_is_never_modified(tmp_path, mode): + import hashlib + src = rf.project(tmp_path, plates=2) + before = hashlib.sha256(open(src, "rb").read()).hexdigest() + run(tmp_path, src, mode) + assert hashlib.sha256(open(src, "rb").read()).hexdigest() == before + + +# --- the prime tower: assessed, never moved, never called safe -------------------------------------- + +def tower_values(path): + cfg = rf.settings_of(path) + return cfg["wipe_tower_x"], cfg["wipe_tower_y"] + + +def tower_records(result): + return result.settings_summary["layout"]["tower"] + + +@pytest.mark.parametrize("mode", MODES) +def test_an_outside_tower_is_proven_and_reported_but_never_moved(tmp_path, mode): + cfg = {"wipe_tower_x": ["296.9", "165"], "wipe_tower_y": ["233.729", "250"], "prime_tower_width": "60"} + src = rf.project(tmp_path, plates=2, cfg=cfg) + result = run(tmp_path, src, mode) + records = tower_records(result) + # the creator's own values come through byte for byte in BOTH modes: Studio did not move them + assert tower_values(result.output_path) == (["296.9", "165"], ["233.729", "250"]) + # plate 1: the start corner (x 296.9) is itself past the 270.5 edge, which the file proves; + # plate 2's corner is inside, so how far it reaches is unknown + assert [r["status"] for r in records] == ["outside_proven", "unknown"] + assert all(r["status"] != "inside" and r["status"] != "safe" for r in records) + assert any("prime tower is outside" in w for w in result.settings_summary["warnings"]) + + +@pytest.mark.parametrize("mode", MODES) +def test_a_tower_that_looks_fine_is_unknown_not_safe(tmp_path, mode): + src = rf.project(tmp_path, plates=2, bed=(270.0, 270.0), local=(135.5, 136.0), + cfg={"wipe_tower_x": ["39.25", "39.25"], "wipe_tower_y": ["212.1", "212.1"], + "prime_tower_width": "30"}) + result = run(tmp_path, src, mode) + records = tower_records(result) + assert [r["status"] for r in records] == ["unknown", "unknown"] + assert all(set(r) == {"plate", "position", "status", "reason"} for r in records) # no estimate, no clearances + assert "Orca" in records[0]["reason"] and "tower_note" in result.settings_summary["layout"] + if mode == "preserve": + assert tower_values(result.output_path) == (["39.25", "39.25"], ["212.1", "212.1"]) # byte-identical + + +def test_a_tower_corner_inside_an_objects_bounding_box_is_not_proof_of_overlap(tmp_path): + # The footprint Studio has is a bounding box, not the part's outline: membership alone proves nothing. + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(165.0, 160.0), + cfg={"wipe_tower_x": ["165"], "wipe_tower_y": ["160"], "prime_tower_width": "30"}) + result = run(tmp_path, src, "preserve") + assert tower_records(result)[0]["status"] == "unknown" + assert tower_values(result.output_path) == (["165"], ["160"]) + + +def test_the_width_is_not_an_extent_and_no_depth_is_inferred_from_it(tmp_path): + # x 236.895 with a 60 mm width reaches past 270.5 only if the width were a known extent: it is not. + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0), + cfg={"wipe_tower_x": ["236.895"], "wipe_tower_y": ["233.729"], "prime_tower_width": "60"}) + record = tower_records(run(tmp_path, src, "preserve"))[0] + assert record["status"] == "unknown" and record["position"] == [236.895, 233.729] + assert not {"estimate", "known_x_extent", "known_y_min"} & set(record) + + +def test_regrid_and_the_placement_assessment_share_one_rule_and_first_entry_fallback(): + from snapstudio_core import prime_tower + bed = {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0} + cfg = {"enable_prime_tower": "1", "wipe_tower_x": ["100", "300"], "wipe_tower_y": ["50"], + "prime_tower_width": "30"} + rows = prime_tower.assess(cfg, 3, bed) + # plate 3 has no entry of its own: Orca uses the FIRST entry (100), not the last (300) + assert [(r["plate"], r["x"], r["y"], r["status"]) for r in rows] == [ + (1, 100.0, 50.0, "unknown"), (2, 300.0, 50.0, "outside_proven"), (3, 100.0, 50.0, "unknown")] + assert [r["plate"] for r in prime_tower.findings(cfg, 3, bed)] == [2] + + +@pytest.mark.parametrize("flag", ["", "none", "nil"]) +def test_a_blank_enable_flag_does_not_prove_an_outside_tower(flag): + from snapstudio_core import prime_tower + bed = {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0} + cfg = {"enable_prime_tower": flag, "wipe_tower_x": ["900"], "wipe_tower_y": ["50"]} + assert [r["status"] for r in prime_tower.assess(cfg, 1, bed)] == ["unknown"] + + +@pytest.mark.parametrize("mode", MODES) +def test_towers_are_never_written(tmp_path, mode): + src = rf.project(tmp_path, plates=2, cfg={"wipe_tower_x": ["236.895", "165"], + "wipe_tower_y": ["233.729", "250"]}) + before = rf.settings_of(src) + after = rf.settings_of(run(tmp_path, src, mode).output_path) + # both modes: the creator's positions come through byte for byte, never replaced by the starter constants + assert (after["wipe_tower_x"], after["wipe_tower_y"]) == (before["wipe_tower_x"], before["wipe_tower_y"]) + if mode == "recommended": + # ...and Recommended declares them, or Snapmaker Orca resets an undeclared value to its preset + declared = rf.settings_of(run(tmp_path, src, mode).output_path)["different_settings_to_system"][0].split(";") + assert "wipe_tower_x" in declared and "wipe_tower_y" in declared + + +def test_a_project_stating_no_tower_position_gets_the_starter_one(tmp_path): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0)) + cfg = rf.settings_of(src) + cfg.pop("wipe_tower_x", None), cfg.pop("wipe_tower_y", None) + entries = rf.read(src) + import json + entries[rf.SETTINGS] = json.dumps(cfg).encode("utf-8") + bare = rf.write(tmp_path / "bare.3mf", entries) + after = rf.settings_of(run(tmp_path, bare, "recommended").output_path) + assert after["wipe_tower_x"] == ["165"] and after["wipe_tower_y"] == ["200.821"] + + +def test_a_disabled_tower_is_not_assessed(tmp_path): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0), + cfg={"enable_prime_tower": "0", "wipe_tower_x": ["900"], "wipe_tower_y": ["900"]}) + result = run(tmp_path, src, "preserve") + assert tower_records(result) == [] + assert tower_values(result.output_path) == (["900"], ["900"]) + + +# --- support intent: explicit disable > positive intent > starter reset > creator values ------------- + +SUPPORT = ("enable_support", "support_type", "support_filament", "support_interface_filament") + + +def declared_first(path): + return set(filter(None, rf.settings_of(path)["different_settings_to_system"][0].split(";"))) + + +def support_case(tmp_path, supports, cfg=None, mode="recommended"): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0), supports=supports, cfg=cfg) + result = run(tmp_path, src, mode) + return result, result.settings_summary.get("support_intent"), declared_first(result.output_path) + + +@pytest.mark.parametrize("supports", ["paint", "enforcer", "object"]) +def test_positive_intent_survives_the_starter_settings(tmp_path, supports): + result, intent, declared = support_case(tmp_path, supports) + cfg = rf.settings_of(result.output_path) + assert intent["decision"] == "kept" and set(SUPPORT) <= declared + assert (cfg["enable_support"], cfg["support_type"], cfg["support_filament"]) == ("1", "tree(manual)", "2") + assert "kept as the creator set them" in intent["statement"] + + +@pytest.mark.parametrize("supports", ["blocker", "paint_blocker", {"blocker", "paint_blocker"}]) +def test_blocker_only_data_is_not_a_request_for_supports(tmp_path, supports): + result, intent, declared = support_case(tmp_path, supports) + assert intent["decision"] == "starter_reset" and not (set(SUPPORT) & declared) + assert intent["blocker_evidence"] and "do not turn supports on" in intent["statement"] + + +def test_enforcers_with_an_explicit_object_disable_stay_disabled_and_say_so(tmp_path): + result, intent, declared = support_case(tmp_path, {"enforcer", "object_off"}) + assert intent["decision"] == "disabled" and not (set(SUPPORT) & declared) + assert "Supports stay off" in intent["statement"] and "support-enforcer volumes" in intent["statement"] + + +@pytest.mark.parametrize("enable, decision", [("0", "disabled"), ("1", "kept")]) +def test_painted_supports_with_global_enable_support_zero_or_one(tmp_path, enable, decision): + result, intent, declared = support_case(tmp_path, "paint", cfg={"enable_support": enable}) + assert intent["decision"] == decision + assert bool(set(SUPPORT) & declared) == (decision == "kept") + assert rf.settings_of(result.output_path)["enable_support"] == enable # the value is the creator's + + +def test_with_no_support_data_the_starter_reset_stands(tmp_path): + result, intent, declared = support_case(tmp_path, None) + assert intent["decision"] == "starter_reset" and not (set(SUPPORT) & declared) + assert "starter settings" in intent["statement"] + + +@pytest.mark.parametrize("supports", ["paint", "enforcer", {"enforcer", "object_off"}]) +def test_preserve_is_unchanged_whatever_the_intent(tmp_path, supports): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0), supports=supports) + result = run(tmp_path, src, "preserve") + # the creator's own declaration comes through as it was; Studio adds none and removes none of the four + assert set(SUPPORT) <= declared_first(result.output_path) + assert result.settings_summary.get("support_intent") is None + + +# --- the audit accounts for it ------------------------------------------------------------------- + +@pytest.mark.parametrize("mode", MODES) +def test_the_fidelity_audit_does_not_call_a_regrid_a_bug(tmp_path, mode): + from snapstudio_core import fidelity + src = rf.project(tmp_path, plates=2) + result = run(tmp_path, src, mode) + report = fidelity.audit(src, result.output_path) + text = str(report) + assert "report this as a bug" not in text + + +def test_the_audit_makes_no_one_plate_outside_claim_about_a_multi_plate_copy(tmp_path): + from snapstudio_core import fidelity + src = rf.project(tmp_path, plates=2) + result = run(tmp_path, src, "preserve") + report = fidelity.audit(src, result.output_path) + assert report["placement"] is None # not "plate 2 is 228 mm outside" + row = next(r for r in report["rows"] if r["element"] == "Model geometry and object placement") + assert row["status"] == "changed" and "translation only" in row["reason"] + assert report["claims"]["geometry_unchanged"] is True + + +# --- already-U1 projects and preservation ---------------------------------------------------------- + +U1_POLYGON = ["0.5x1", "270.5x1", "270.5x271", "0.5x271"] + + +@pytest.mark.parametrize("mode", MODES) +def test_an_already_u1_multi_plate_project_comes_out_placement_identical(tmp_path, mode): + # the creator's own Orca-made U1 project: plates on the U1 stride (324), tower where Orca put it + src = rf.project(tmp_path, plates=2, bed=(270.0, 270.0), local=(135.5, 136.0), cfg={ + "printable_area": U1_POLYGON, "wipe_tower_x": ["39.25", "37.6"], "wipe_tower_y": ["212.1", "207.2"], + "prime_tower_width": "30"}) + result = run(tmp_path, src, mode) + assert [i["tag"] for i in rf.items_of(result.output_path)] == [i["tag"] for i in rf.items_of(src)] + cfg = rf.settings_of(result.output_path) + assert (cfg["wipe_tower_x"], cfg["wipe_tower_y"]) == (["39.25", "37.6"], ["212.1", "207.2"]) + assert result.settings_summary["layout"]["status"] == "same_grid" + assert not result.settings_summary["placement_changes"] + + +@pytest.mark.parametrize("mode", MODES) +def test_painting_helper_relationships_and_object_files_are_untouched(tmp_path, mode): + src = rf.project(tmp_path, plates=2, supports={"paint", "enforcer"}) + result = run(tmp_path, src, mode) + a, b = rf.read(src), rf.read(result.output_path) + for name in (n for n in a if n.startswith("3D/Objects/")): + assert a[name] == b[name] # meshes, paint_color, paint_supports + for token in (b"paint_color", b"paint_supports"): + assert a[name].count(token) == b[name].count(token) > 0 or a[name].count(token) == 0 + import re as _re + parts = lambda blob: _re.findall(rb'`` tag with a bare attribute, so a project built with +``supports="paint"`` lost a triangle and carried an invalid mesh; Snapmaker Orca refused it ("Loading of model file failed") while the +engine, which never parses the mesh for this, still reported it valid. This is a test-fixture defect, not an engine one. +""" +from __future__ import annotations + +import pytest +from lxml import etree + +from tests import regrid_fixtures as rf + + +def _triangles(path): + part = rf.read(path)[rf.OBJECT_PART] + root = etree.fromstring(part) # must parse + return [el for el in root.iter() if etree.QName(el).localname == "triangle"] + + +@pytest.mark.parametrize("flag,state", [("paint", "4"), ("paint_blocker", "8")]) +def test_support_paint_injection_keeps_the_mesh_well_formed(tmp_path, flag, state): + plain = _triangles(rf.project(tmp_path, name="plain.3mf", plates=1)) + painted = _triangles(rf.project(tmp_path, name="painted.3mf", plates=1, supports=flag)) + assert len(painted) == len(plain) # no triangle was lost + marked = [t for t in painted if t.get("paint_supports") is not None] + assert len(marked) == 1 and marked[0].get("paint_supports") == state + assert all(marked[0].get(v) is not None for v in ("v1", "v2", "v3")) # its vertices survived diff --git a/backend/tests/test_repeated_instance_placement.py b/backend/tests/test_repeated_instance_placement.py new file mode 100644 index 00000000..3666bb84 --- /dev/null +++ b/backend/tests/test_repeated_instance_placement.py @@ -0,0 +1,214 @@ +"""Issue 93: the placement check judges every build item, not every object id. + +One object used by two build items is two places on the plate. The check used to key by object id, so +every instance took the LAST item's footprint and an instance far off the plate could read "inside". +""" +from __future__ import annotations + +import zipfile + +import pytest + +from snapstudio_core import placement, plate_placement as pp, scene +from tests import scene_fixtures as fx + +REV = "ab" * 32 +SETTINGS = "Metadata/model_settings.config" + + +def _plain(tmp_path, items, name="p.3mf", with_part_record=True): + """One cube object `1` used by `items`, with the part record the issue's reproduction carries.""" + extra = {} + if with_part_record: + extra[SETTINGS] = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0), ("1", 1)])]) + return fx.three_mf(tmp_path / name, fx.model_xml([fx.cube_object("1", 10)], items), extra) + + +def _rows(report): + return {row["item_index"]: row for row in report["items"]} + + +@pytest.mark.parametrize("record", [True, False]) +@pytest.mark.parametrize("order", [(500, 100), (100, 500)]) +def test_an_in_bounds_instance_cannot_hide_an_off_plate_one(tmp_path, record, order): + path = _plain(tmp_path, [("1", fx.tf(order[0], 100, 0)), ("1", fx.tf(order[1], 100, 0))], + with_part_record=record) + report = pp.assess(str(path)) + off_item = order.index(500) + assert report["available"] and report["item_count"] == 2 + assert len(report["off_plate"]) == 1 and report["off_plate"][0]["item_index"] == off_item + rows = _rows(report) + assert rows[off_item]["off_plate"] is True and rows[1 - off_item]["off_plate"] is False + assert rows[off_item]["position"]["x"] == 505.0 and rows[1 - off_item]["position"]["x"] == 105.0 + assert rows[off_item]["edges"] == "right" + assert [rows[i]["instance_index"] for i in (0, 1)] == [0, 1] + assert rows[0]["instance_count"] == 2 and rows[0]["object_id"] == "1" + # the summary names the instance, and does not claim everything is inside + assert "1 of 2 placed instances" in report["summary"] + assert f"object 1, instance {off_item + 1} of 2" in report["summary"] + assert "inside" not in report["summary"] + + +def test_both_instances_off_the_plate_are_both_reported(tmp_path): + path = _plain(tmp_path, [("1", fx.tf(500, 100, 0)), ("1", fx.tf(-40, 100, 0))]) + report = pp.assess(str(path)) + assert [row["item_index"] for row in report["off_plate"]] == [0, 1] + assert {row["edges"] for row in report["off_plate"]} == {"right", "left"} + + +def test_every_instance_inside_says_so_per_instance(tmp_path): + report = pp.assess(str(_plain(tmp_path, [("1", fx.tf(50, 50, 0)), ("1", fx.tf(150, 50, 0))]))) + assert report["off_plate"] == [] and report["summary"] == ( + "Every placed instance sits inside the Snapmaker U1's printable area.") + + +def test_the_engine_placement_check_reports_each_instance(tmp_path): + path = _plain(tmp_path, [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + objects = placement.read_objects(str(path))["objects"] + assert [(o["item_index"], o["instance_index"], o["instance_count"]) for o in objects] == [(0, 0, 2), (1, 1, 2)] + assert objects[0]["footprint"]["min_x"] == 500.0 and objects[1]["footprint"]["min_x"] == 100.0 + # the part record is present, so read_objects reads the footprint from it for BOTH items + assert all(o["footprint"] for o in objects) + + +def test_multiple_components_are_measured_per_instance(tmp_path): + path = fx.bambu_project(tmp_path / "b.3mf", parts=2, items=[("100", fx.tf(500, 100, 0)), ("100", fx.tf(100, 100, 0))], + plates=[(1, [("100", 0), ("100", 1)])]) + report = pp.assess(str(path)) + rows = _rows(report) + # two 10 mm cubes 20 mm apart: 30 mm wide, starting at the item's translation + assert rows[0]["bounds_mm"]["min"][0] == 500.0 and rows[0]["bounds_mm"]["max"][0] == 530.0 + assert rows[1]["bounds_mm"]["min"][0] == 100.0 and rows[1]["bounds_mm"]["max"][0] == 130.0 + assert [row["item_index"] for row in report["off_plate"]] == [0] + + +def test_a_modifier_component_does_not_count_for_either_instance(tmp_path): + path = fx.bambu_project(tmp_path / "m.3mf", parts=2, roles=["normal_part", "modifier_part"], + items=[("100", fx.tf(100, 100, 0)), ("100", fx.tf(150, 100, 0))], + plates=[(1, [("100", 0), ("100", 1)])]) + rows = _rows(pp.assess(str(path))) + assert rows[0]["bounds_mm"]["max"][0] == 110.0 and rows[1]["bounds_mm"]["max"][0] == 160.0 + assert pp.assess(str(path))["off_plate"] == [] + + +def _nested(tmp_path, xs): + inner = fx.composite_object("5", [("1", None, "-1 0 0 0 1 0 0 0 1 0 0 0")]) # mirror x + outer = fx.composite_object("6", [("5", None, fx.tf(30, 0, 0))]) + root = fx.model_xml([fx.cube_object("1", 10), inner, outer], [("6", fx.tf(x, 50, 0)) for x in xs]) + return fx.three_mf(tmp_path / "n.3mf", root) + + +def test_nested_component_transforms_compose_per_instance(tmp_path): + path = _nested(tmp_path, [0, 400]) + rows = _rows(pp.assess(str(path))) + # mirror -> x in [-10, 0]; +30 -> [20, 30]; plus the item's own x + assert (rows[0]["bounds_mm"]["min"][0], rows[0]["bounds_mm"]["max"][0]) == (20.0, 30.0) + assert (rows[1]["bounds_mm"]["min"][0], rows[1]["bounds_mm"]["max"][0]) == (420.0, 430.0) + assert [row["item_index"] for row in pp.assess(str(path))["off_plate"]] == [1] + footprints = [o["footprint"] for o in placement.read_objects(str(path))["objects"]] + assert [(f["min_x"], f["max_x"]) for f in footprints] == [(20.0, 30.0), (420.0, 430.0)] + + +def _fixtures(tmp_path): + return { + "repeated": _plain(tmp_path, [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]), + "repeated_no_record": _plain(tmp_path, [("1", fx.tf(100, 100, 0)), ("1", fx.tf(-40, 90, 0))], + name="q.3mf", with_part_record=False), + "components": fx.bambu_project(tmp_path / "c.3mf", parts=3, + items=[("100", fx.tf(20, 30, 0)), ("100", fx.tf(300, 30, 0))], + plates=[(1, [("100", 0), ("100", 1)])]), + "nested": _nested(tmp_path, [0, 400]), + "single": fx.bambu_project(tmp_path / "s.3mf", parts=1, items=[("100", fx.tf(300, 60, 0))], + plates=[(1, [("100", 0)])]), + } + + +def test_per_instance_results_match_the_scene_contracts_world_bounds(tmp_path): + for name, path in _fixtures(tmp_path).items(): + sc = scene.build_scene_dict(str(path), REV) + report = pp.assess(str(path)) + by_id = {n["id"]: n for n in sc["nodes"]} + assert report["item_count"] == len([n for n in sc["nodes"] if n["parent_id"] is None]), name + for row in report["items"]: + top = by_id[f"b{row['item_index']}"] + assert top["bounds_mm"]["min"] == pytest.approx(row["bounds_mm"]["min"], abs=1e-3), name + assert top["bounds_mm"]["max"] == pytest.approx(row["bounds_mm"]["max"], abs=1e-3), name + lo, hi = top["bounds_mm"]["min"], top["bounds_mm"]["max"] + assert pp._overhang({"min": lo, "max": hi}, report["bed"]) == row["overhang_mm"], name + assert row["off_plate"] == any(v > 0 for v in row["overhang_mm"].values()), name + + +def test_plates_are_assigned_per_instance_when_the_object_is_repeated(tmp_path): + # one object, two instances, one on each plate + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0)]), (2, [("1", 1)])]) + path = fx.three_mf(tmp_path / "mp.3mf", fx.model_xml([fx.cube_object("1", 10)], + [("1", fx.tf(50, 50, 0)), ("1", fx.tf(400, 50, 0))]), {SETTINGS: settings}) + report = pp.assess(str(path)) + assert report["plate_count"] == 2 and report["unresolved_objects"] == [] + assert [(p["plate"], p["item_indexes"]) for p in report["plate_fit"]] == [(1, [0]), (2, [1])] + assert all(p["fits"] for p in report["plate_fit"]) + + +def test_an_oversized_plate_flags_only_its_own_instance(tmp_path): + big = fx.cube_object("1", 300) + settings = fx.model_settings_xml({"1": [("1", "normal_part")], "2": [("2", "normal_part")]}, + [(1, [("1", 0)]), (2, [("2", 0)])]) + path = fx.three_mf(tmp_path / "ov.3mf", fx.model_xml([big, fx.cube_object("2", 10)], + [("1", fx.tf(0, 0, 0)), ("2", fx.tf(400, 0, 0))]), {SETTINGS: settings}) + report = pp.assess(str(path)) + assert [row["item_index"] for row in report["off_plate"]] == [0] + + +def test_an_instance_the_plate_records_cannot_place_is_unresolved_not_guessed(tmp_path): + # the record names the object on a plate without saying which of its two instances + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", None)]), (2, [("2", 0)])]) + settings = settings.replace('', '', 1) + path = fx.three_mf(tmp_path / "amb.3mf", fx.model_xml([fx.cube_object("1", 10), fx.cube_object("2", 10)], + [("1", fx.tf(50, 50, 0)), ("1", fx.tf(80, 50, 0)), ("2", fx.tf(200, 50, 0))]), + {SETTINGS: settings}) + report = pp.assess(str(path)) + assert sorted(u["item_index"] for u in report["unresolved_objects"]) == [0, 1] + + +# --- the fix still works, instance by instance --------------------------------------------------- + +def _item_transforms(path): + with zipfile.ZipFile(path) as archive: + return placement.build_items(archive.read("3D/3dmodel.model").decode("utf-8")) + + +def test_prepare_placed_copy_moves_every_instance_by_the_same_amount(tmp_path): + src = _plain(tmp_path, [("1", fx.tf(-5, 100, 0)), ("1", fx.tf(100, 100, 0))]) + original = src.read_bytes() + result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) + assert result["ok"] is True, result.get("reason") + assert result["objects_moved"] == 2 and result["offset_mm"] == {"x": 6.0, "y": 0.0} + assert result["after"]["off_plate"] == [] + assert src.read_bytes() == original # the original is never modified + before = [placement.parse_transform(i["transform"]) for i in _item_transforms(src)] + after = [placement.parse_transform(i["transform"]) for i in _item_transforms(result["output_path"])] + assert [round(a[3][0] - b[3][0], 4) for a, b in zip(after, before)] == [6.0, 6.0] + assert result["verification"]["passed"] is True + + +def test_prepare_placed_copy_handles_an_item_with_no_transform(tmp_path): + src = _plain(tmp_path, [("1", None), ("1", fx.tf(60, 0.5, 0))]) + report = pp.assess(str(src)) + assert [row["item_index"] for row in report["off_plate"]] == [0, 1] + result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) + assert result["ok"] is True, result.get("reason") + assert result["verification"]["passed"] is True and result["after"]["off_plate"] == [] + + +def test_prepare_placed_copy_refuses_when_one_move_cannot_fix_instances(tmp_path): + src = _plain(tmp_path, [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) + assert result["ok"] is False and "wider than the plate" in result["reason"] + assert not (tmp_path / "out").exists() or not list((tmp_path / "out").iterdir()) + + +def test_a_millimetre_project_with_one_item_per_object_reports_as_before(tmp_path): + path = fx.plain_cube_3mf(tmp_path / "one.3mf", at=(300, 60, 0)) + report = pp.assess(str(path)) + assert report["summary"].startswith("1 object falls outside") + assert [row["instance_count"] for row in report["items"]] == [1] diff --git a/backend/tests/test_role_inheritance_placement.py b/backend/tests/test_role_inheritance_placement.py new file mode 100644 index 00000000..b5c1556f --- /dev/null +++ b/backend/tests/test_role_inheritance_placement.py @@ -0,0 +1,168 @@ +"""Printing roles propagate down through assemblies (one rule, ``roles``), seen through the service entry +points: preflight, Ready Now analysis, and the scene, for the placed footprint and height. + +A 300 mm column sits under a nested assembly next to a 10 mm printable sibling. If the volume's role says it +does not print, it must not make the object look too tall; if no role says so (missing record), or the +records conflict, it counts.""" +from __future__ import annotations + +import pytest + +from snapstudio_api import service +from snapstudio_core import plate_placement as pp, scene +from tests import scene_fixtures as fx +from tests.test_travel_and_move_writer import _facts + +SETTINGS = "Metadata/model_settings.config" +HELPER_ROLES = ["negative_part", "modifier_part", "support_blocker", "support_enforcer"] +REV = "ab" * 32 + + +def pillar(oid, height, origin=(40.0, 0.0, 0.0)): + """A 10 x 10 mm column `height` tall: tall, but small enough to sit on the plate sideways.""" + ox, oy, oz = origin + verts = [(ox, oy, oz), (ox + 10, oy, oz), (ox + 10, oy + 10, oz), (ox, oy + 10, oz), + (ox, oy, oz + height), (ox + 10, oy, oz + height), (ox + 10, oy + 10, oz + height), + (ox, oy + 10, oz + height)] + return f'{fx.mesh_xml(verts, fx.CUBE_TRIS)}' + + +def nested_assembly(tmp_path, records: dict, name="nested.3mf"): + """Root object 100 = printable mesh 1 (10 mm) + assembly 5; assembly 5 = mesh 3 (300 mm). + ``records`` is the settings objects: {object id: [(part id, subtype), ...]}.""" + sub = fx.sub_model_xml([fx.cube_object("1", 10), pillar("3", 300)]) + assembly = fx.composite_object("5", [("3", "/3D/Objects/o.model", None)]) + top = fx.composite_object("100", [("1", "/3D/Objects/o.model", None), ("5", None, None)]) + root = fx.model_xml([assembly, top], [("100", fx.tf(20, 20, 0))]) + settings = fx.model_settings_xml(records, [(1, [("100", 0)])]) + return str(fx.three_mf(tmp_path / name, root, {"3D/Objects/o.model": sub, SETTINGS: settings})) + + +def on_assembly(role): + return {"100": [("1", "normal_part"), ("5", role)]} + + +def on_nested_record(role): + return {"100": [("1", "normal_part"), ("5", "normal_part")], "5": [("3", role)]} + + +U1 = {"printer_id": "snapmaker_u1"} + + +def bed_fit_check(path, monkeypatch): + monkeypatch.setattr(service, "printer_facts", _facts(U1)) + return next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") + + +def ready_placement(path): + target = service._placement_target({"identity": U1}) + service._READY_CACHE.clear() + return service._ready_analysis(path, target, need_placement=True)[2] + + +@pytest.mark.parametrize("where", [on_assembly, on_nested_record], ids=["on_assembly", "on_nested_record"]) +@pytest.mark.parametrize("role", HELPER_ROLES) +def test_a_helper_role_applies_to_everything_beneath_it(tmp_path, monkeypatch, role, where): + path = nested_assembly(tmp_path, where(role)) + # preflight: the 300 mm helper volume is not height the print has + check = bed_fit_check(path, monkeypatch) + assert check["result"] == "ok" and check["confidence"] == "confirmed", check["evidence"] + # Ready Now: the same placement, from the cached analysis + placement = ready_placement(path) + [row] = placement["items"] + assert row["bounds_mm"]["max"][2] == 10.0 and row["bounds_mm"]["max"][0] == 30.0 + assert pp.over_height(placement) == [] and placement["off_plate"] == [] + + +@pytest.mark.parametrize("role", HELPER_ROLES) +def test_the_scene_and_the_footprint_agree_that_the_volume_does_not_print(tmp_path, role): + path = nested_assembly(tmp_path, on_assembly(role)) + sc = scene.build_scene_dict(path, REV) + leaf = next(n for n in sc["nodes"] if n["resource"]["object_id"] == "3") + assert leaf["printable"] is False and leaf["role_context"] not in (None, "part") + top = next(n for n in sc["nodes"] if n["id"] == "b0") + assert top["bounds_mm"]["max"][2] == 10.0 # the scene's world bounds: printing parts only + [row] = pp.assess(path)["items"] + assert row["bounds_mm"]["max"] == top["bounds_mm"]["max"] + + +def test_a_missing_role_is_a_normal_part_and_counts(tmp_path, monkeypatch): + path = nested_assembly(tmp_path, {"100": [("1", "normal_part")]}) # no record for the assembly + check = bed_fit_check(path, monkeypatch) + assert check["result"] == "attention" and "300" in check["evidence"] and "height limit" in check["evidence"] + assert pp.over_height(ready_placement(path)) + + +def test_conflicting_records_are_unknown_and_count_never_falsely_small(tmp_path, monkeypatch): + path = nested_assembly(tmp_path, {"100": [("1", "normal_part"), ("5", "modifier_part"), ("5", "normal_part")]}) + check = bed_fit_check(path, monkeypatch) + assert check["result"] == "attention" and "300" in check["evidence"] + sc = scene.build_scene_dict(path, REV) + leaf = next(n for n in sc["nodes"] if n["resource"]["object_id"] == "3") + assert leaf["printable"] is None # the scene reports unknown + assert pp.over_height(ready_placement(path)) # the footprint cannot prove it away + + +def test_a_child_cannot_print_again_beneath_a_non_printing_assembly(tmp_path, monkeypatch): + # the assembly is a modifier; the record for its child says normal_part: the modifier still wins + records = {"100": [("1", "normal_part"), ("5", "modifier_part")], "5": [("3", "normal_part")]} + path = nested_assembly(tmp_path, records) + assert bed_fit_check(path, monkeypatch)["result"] == "ok" + assert pp.assess(path)["items"][0]["bounds_mm"]["max"][2] == 10.0 + + +# --- whole-model extents and the other size consumers share the rule ------------------------------------- + +SEAM_WORDS = ["precise_seam_center", "precise_seam_left", "precise_seam_right", + "precise_seam_enforced", "precise_seam_blocked", "precise_seam_neutral"] + + +@pytest.mark.parametrize("role", HELPER_ROLES + SEAM_WORDS) +def test_every_consumer_of_the_extents_agrees_that_a_helper_volume_does_not_count(tmp_path, monkeypatch, role): + """One fixture (a 10 mm printable part beside a 300 mm helper volume under a nested assembly), every + consumer through its own service entry point: the overall extents, the placed items, the layout, preflight, + Ready Now, the Bed-Fit API (``service.bed_fit``), the validation report (``service.report``) and the scene. + At this head the Bed-Fit API and the report read the overall extents (``project_info``), which are + role-aware; the per-object size path is #91's and is asserted there.""" + from snapstudio_core import geometry, layout + from snapstudio_core.intelligence import project_info + path = nested_assembly(tmp_path, on_assembly(role)) + assert project_info(path)["dimensions_mm"] == {"x": 10.0, "y": 10.0, "z": 10.0} # overall extents + fit = service.bed_fit(path) # Bed-Fit API + assert fit["overall_level"] == "ok", fit["overall_text"] + assert fit["dims_mm"] == {"x": 10.0, "y": 10.0, "z": 10.0} + report = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert report["status"] == "pass" and "10.0 × 10.0 × 10.0 mm" in report["detail"] # validation report + assert [i["dimensions"]["z"] for i in geometry.build_item_dims(path)] == [10.0] # placed items + assert layout.assess_layout(path)["status"] == "pass" # plate-fit layout + check = bed_fit_check(path, monkeypatch) # preflight + assert check["result"] == "ok" and check["confidence"] == "confirmed" + assert pp.over_height(ready_placement(path)) == [] # Ready Now + sc = scene.build_scene_dict(path, REV) # scene + assert next(n for n in sc["nodes"] if n["resource"]["object_id"] == "3")["printable"] is False + + +def test_the_overall_extents_still_count_everything_when_no_role_says_otherwise(tmp_path): + from snapstudio_core.intelligence import project_info + path = nested_assembly(tmp_path, {"100": [("1", "normal_part")]}) + assert project_info(path)["dimensions_mm"]["z"] == 300.0 + assert service.bed_fit(path)["overall_level"] == "risk" # the control + report = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert report["status"] == "warn" + + +@pytest.mark.parametrize("word", SEAM_WORDS) +def test_orcas_precise_seam_helpers_are_known_non_printing_roles(tmp_path, monkeypatch, word): + from snapstudio_core.assignments import MODIFIER, ROLE_UNKNOWN, role_of + assert role_of(word) == MODIFIER and role_of(word) != ROLE_UNKNOWN + path = nested_assembly(tmp_path, on_nested_record(word)) + assert bed_fit_check(path, monkeypatch)["result"] == "ok" + sc = scene.build_scene_dict(path, REV) + assert "UNKNOWN_VOLUME_ROLE" not in [l["code"] for l in sc["limitations"]] + + +def test_an_unrecognised_role_word_stays_conservative(tmp_path, monkeypatch): + path = nested_assembly(tmp_path, on_assembly("precise_seam_sideways")) + assert bed_fit_check(path, monkeypatch)["result"] == "attention" # counts: 300 mm tall + from snapstudio_core.intelligence import project_info + assert project_info(path)["dimensions_mm"]["z"] == 300.0 diff --git a/backend/tests/test_role_inheritance_size.py b/backend/tests/test_role_inheritance_size.py new file mode 100644 index 00000000..16e5a9bf --- /dev/null +++ b/backend/tests/test_role_inheritance_size.py @@ -0,0 +1,68 @@ +"""Printing roles propagate down through assemblies, seen through the size entry points: the Bed-Fit API, +the validation report and Design Health's per-object sizes (same fixtures and rule as the placement tests).""" +from __future__ import annotations + +import pytest + +from snapstudio_api import service +from snapstudio_core import geometry +from snapstudio_core.intelligence import project_info +from tests.test_role_inheritance_placement import (HELPER_ROLES, REV, SEAM_WORDS, bed_fit_check, + nested_assembly, on_assembly, on_nested_record, + ready_placement) + + +def fits_check(path): + return next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + + +@pytest.mark.parametrize("where", [on_assembly, on_nested_record], ids=["on_assembly", "on_nested_record"]) +@pytest.mark.parametrize("role", HELPER_ROLES + SEAM_WORDS) +def test_a_helper_role_on_an_assembly_keeps_the_size_to_what_prints(tmp_path, role, where): + path = nested_assembly(tmp_path, where(role)) + [size] = geometry.object_sizes(path) + assert size["dimensions"]["z"] == 10.0 + info = project_info(path, placement_aware=True) + assert info["object_sizes_mm"][0]["dimensions_mm"]["z"] == 10.0 + result = service.bed_fit(path) + assert result["overall_level"] == "ok", result["overall_text"] + assert result["dims_mm"]["z"] == 10.0 + check = fits_check(path) + assert check["status"] == "pass" and "10.0 mm" in check["detail"] + + +def test_a_missing_role_is_a_normal_part_and_makes_the_object_too_tall(tmp_path): + path = nested_assembly(tmp_path, {"100": [("1", "normal_part")]}) + assert geometry.object_sizes(path)[0]["dimensions"]["z"] == 300.0 + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + assert fits_check(path)["status"] == "warn" + + +def test_conflicting_records_count_toward_the_size(tmp_path): + path = nested_assembly(tmp_path, {"100": [("1", "normal_part"), ("5", "modifier_part"), ("5", "normal_part")]}) + assert geometry.object_sizes(path)[0]["dimensions"]["z"] == 300.0 # unknown cannot be proven away + assert service.bed_fit(path)["overall_level"] == "risk" + + +def test_a_child_cannot_print_again_beneath_a_non_printing_assembly(tmp_path): + records = {"100": [("1", "normal_part"), ("5", "modifier_part")], "5": [("3", "normal_part")]} + path = nested_assembly(tmp_path, records) + assert geometry.object_sizes(path)[0]["dimensions"]["z"] == 10.0 + assert service.bed_fit(path)["overall_level"] == "ok" + + +@pytest.mark.parametrize("role", ["modifier_part", "precise_seam_center"]) +def test_one_fixture_every_consumer_agrees(tmp_path, monkeypatch, role): + """The 10 mm printable part beside a 300 mm helper volume under a nested assembly: preflight, Ready Now, + the Bed-Fit API, the validation report, the overall extents and the scene say the same thing.""" + from snapstudio_core import plate_placement as pp, scene + path = nested_assembly(tmp_path, on_assembly(role)) + assert bed_fit_check(path, monkeypatch)["result"] == "ok" # preflight + assert pp.over_height(ready_placement(path)) == [] # Ready Now + assert service.bed_fit(path)["overall_level"] == "ok" # Bed-Fit API + assert fits_check(path)["status"] == "pass" # validation report + assert project_info(path)["dimensions_mm"]["z"] == 10.0 # overall extents + sc = scene.build_scene_dict(path, REV) # scene + assert next(n for n in sc["nodes"] if n["id"] == "b0")["bounds_mm"]["max"][2] == 10.0 + assert not any(f["kind"] in ("size", "placement") for f in sc["findings"]) diff --git a/backend/tests/test_scene_api.py b/backend/tests/test_scene_api.py new file mode 100644 index 00000000..2be597b4 --- /dev/null +++ b/backend/tests/test_scene_api.py @@ -0,0 +1,168 @@ +"""The /scene/* HTTP surface: authentication, validation, status codes, pre-serialized body.""" +import http.client +import json +import threading +import time + +import pytest + +from snapstudio_api import scene_jobs as sj +from snapstudio_api.server import build_server +from snapstudio_core import scene_limits as L +from tests import scene_fixtures as fx +from tests.test_scene_contract import assert_valid, sub_validator + + +@pytest.fixture +def server(tmp_path, monkeypatch): + monkeypatch.setenv("SNAPSTUDIO_DATA_DIR", str(tmp_path / "data")) + sj.reset_default_for_tests() + httpd, token = build_server() + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + yield httpd.server_address[1], token + httpd.shutdown() + httpd.server_close() + sj.reset_default_for_tests() + + +def call(port, route, payload, token, *, raw=False): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=20) + try: + headers = {"Content-Type": "application/json"} + if token: + headers["X-Auth-Token"] = token + conn.request("POST", route, body=json.dumps(payload).encode(), headers=headers) + resp = conn.getresponse() + body = resp.read() + assert resp.getheader("Content-Length") == str(len(body)) + assert resp.getheader("Content-Type") == "application/json" + return resp.status, (body if raw else json.loads(body)) + finally: + conn.close() + + +def poll(port, token, job_id, *states, timeout=15): + end = time.monotonic() + timeout + while time.monotonic() < end: + code, body = call(port, "/scene/status", {"job_id": job_id}, token) + assert code == 200 + if body["state"] in states: + return body + time.sleep(0.02) + raise AssertionError("timeout") + + +def test_every_scene_route_requires_the_token(server, tmp_path): + port, token = server + for route in sj.ROUTES: + assert call(port, route, {}, None)[0] == 401 + assert call(port, route, {}, "wrong-token")[0] == 401 + + +def test_full_flow_returns_the_exact_serialized_scene(server, tmp_path): + port, token = server + path = fx.bambu_project(tmp_path / "p.3mf", parts=2, items=[("100", fx.tf(10, 10, 0))], plates=[(1, [("100", 0)])]) + code, started = call(port, "/scene/start", {"path": str(path), "request_id": "req-flow-0001"}, token) + assert code == 200 and not list(sub_validator("job_start").iter_errors(started)) + status = poll(port, token, started["job_id"], "succeeded") + assert not list(sub_validator("job_status").iter_errors(status)) + code, raw = call(port, "/scene/result", {"job_id": started["job_id"], "expected_revision": status["revision"]}, token, raw=True) + assert code == 200 + sc = json.loads(raw) + assert_valid(sc) + assert raw == json.dumps(sc, separators=(",", ":")).encode() # compact, serialized once, no re-dump + assert len(raw) <= L.MAX_RESPONSE_BYTES + + +def test_error_statuses_and_bodies(server, tmp_path): + port, token = server + err = sub_validator("error_response") + cases = [ + ("/scene/start", {"request_id": "req-err-00001"}, 400, "INVALID_REQUEST"), + ("/scene/start", {"path": str(tmp_path / "nope.3mf"), "request_id": "req-err-00001"}, 400, "INVALID_REQUEST"), + ("/scene/start", {"path": "x.3mf", "request_id": "short"}, 400, "INVALID_REQUEST"), + ("/scene/start", {"path": "x.3mf", "request_id": "has spaces in it!"}, 400, "INVALID_REQUEST"), + ("/scene/start", {"path": str(tmp_path / "a.obj"), "request_id": "req-err-00002"}, 422, "UNSUPPORTED_FORMAT"), + ("/scene/status", {}, 400, "INVALID_REQUEST"), + ("/scene/status", {"job_id": "0" * 32}, 404, "EXPIRED"), + ("/scene/result", {"job_id": "0" * 32}, 404, "EXPIRED"), + ("/scene/result", {"job_id": "0" * 32, "expected_revision": "nothex"}, 400, "INVALID_REQUEST"), + ("/scene/cancel", {"job_id": "0" * 32}, 404, "EXPIRED"), + ] + (tmp_path / "a.obj").write_text("v") + for route, payload, status, code in cases: + got, body = call(port, route, payload, token) + assert (got, body["error"]) == (status, code), (route, payload, got, body) + assert not list(err.iter_errors(body)) + + +def test_failed_job_result_is_422_and_cancelled_is_409(server, tmp_path): + port, token = server + bad = tmp_path / "bad.3mf" + bad.write_bytes(b"junk" * 50) + _, started = call(port, "/scene/start", {"path": str(bad), "request_id": "req-bad-00001"}, token) + poll(port, token, started["job_id"], "failed") + code, body = call(port, "/scene/result", {"job_id": started["job_id"]}, token) + assert (code, body["error"]) == (422, "INVALID_ARCHIVE") + ok = fx.plain_cube_3mf(tmp_path / "ok.3mf") + _, second = call(port, "/scene/start", {"path": str(ok), "request_id": "req-ok-000001"}, token) + code, status = call(port, "/scene/cancel", {"job_id": second["job_id"]}, token) + assert code == 200 and status["state"] in ("cancelled", "succeeded") + if status["state"] == "cancelled": + code, body = call(port, "/scene/result", {"job_id": second["job_id"]}, token) + assert (code, body["error"]) == (409, "CANCELLED") + + +def test_request_id_conflict_and_idempotency_over_http(server, tmp_path): + port, token = server + a = fx.plain_cube_3mf(tmp_path / "a.3mf") + b = fx.plain_cube_3mf(tmp_path / "b.3mf", size=11) + _, one = call(port, "/scene/start", {"path": str(a), "request_id": "req-same-0001"}, token) + _, again = call(port, "/scene/start", {"path": str(a), "request_id": "req-same-0001"}, token) + assert one["job_id"] == again["job_id"] + code, body = call(port, "/scene/start", {"path": str(b), "request_id": "req-same-0001"}, token) + assert (code, body["error"], body["reason"]) == (409, "INVALID_REQUEST", "request_id reused for another path") + + +def test_not_ready_and_wedged_over_http(server, tmp_path, monkeypatch): + port, token = server + gate = threading.Event() + started = threading.Event() + + def stuck(path, revision, ctl): + started.set() + gate.wait(10) + return b"{}" + jobs = sj.SceneJobs(builder=stuck, wedge_grace=0.2) + monkeypatch.setattr(sj, "_default", jobs) + path = fx.plain_cube_3mf(tmp_path / "c.3mf") + _, a = call(port, "/scene/start", {"path": str(path), "request_id": "req-wedge-001"}, token) + started.wait(5) + code, body = call(port, "/scene/result", {"job_id": a["job_id"]}, token) + assert (code, body["error"], body["state"]) == (409, "NOT_READY", "running") + call(port, "/scene/cancel", {"job_id": a["job_id"]}, token) + time.sleep(0.4) + code, body = call(port, "/scene/start", {"path": str(path), "request_id": "req-wedge-002"}, token) + assert (code, body["error"]) == (503, "WORKER_WEDGED") + gate.set() + for _ in range(200): + if not jobs.wedged(): + break + time.sleep(0.02) + code, _ = call(port, "/scene/start", {"path": str(path), "request_id": "req-wedge-003"}, token) + assert code == 200 + jobs.close() + + +def test_other_routes_still_behave(server): + port, token = server + assert call(port, "/definitely_not_a_route", {}, token)[0] == 404 + + +@pytest.mark.parametrize("body", [[1, 2], "text", 7, None, [], True]) +def test_a14_a_non_object_json_body_is_a_400_not_a_500(server, body): + port, token = server + for route in sj.ROUTES: + code, resp = call(port, route, body, token) + assert code == 400 and resp["error"] == "INVALID_REQUEST", (route, body, code, resp) diff --git a/backend/tests/test_scene_build.py b/backend/tests/test_scene_build.py new file mode 100644 index 00000000..2a2882e1 --- /dev/null +++ b/backend/tests/test_scene_build.py @@ -0,0 +1,310 @@ +"""scene/1 builder: units, ids, instances, transforms, volumes, plates, STL, bed, comparisons. + +Every scene a test builds is also validated against the committed JSON Schema (real validator) and +the invariants JSON Schema cannot express. +""" +import json +import math +import os +from pathlib import Path + +import pytest + +from snapstudio_core import plate_placement, scene, units +from tests import scene_fixtures as fx +from tests.test_scene_contract import assert_valid + +REV = "ab" * 32 +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + + +def build(path) -> dict: + sc = scene.build_scene_dict(str(path), REV) + assert_valid(sc) + return sc + + +def node(sc, nid): + return next(n for n in sc["nodes"] if n["id"] == nid) + + +def codes(sc): + return [l["code"] for l in sc["limitations"]] + + +# ------------------------------------------------------------------------------ units + +@pytest.mark.parametrize("unit,factor", sorted(units.MM_PER_UNIT.items())) +def test_all_six_units_convert_to_millimetres(tmp_path, unit, factor): + # a 12-unit cube, built 2 units along x. Both the vertex coordinates and the transform's + # translation are in the model's unit. + path = fx.three_mf(tmp_path / "u.3mf", fx.model_xml([fx.cube_object("1", 12)], [("1", fx.tf(2, 0, 0))], unit=unit)) + sc = build(path) + assert sc["sources"] == [{"part": "3D/3dmodel.model", "unit": unit, "mm_per_unit": factor}] + b = node(sc, "b0")["bounds_mm"] + assert b["min"][0] == pytest.approx(2 * factor, abs=1e-5) + assert b["max"][0] == pytest.approx(14 * factor, abs=1e-5) + assert b["max"][2] == pytest.approx(12 * factor, abs=1e-5) + assert node(sc, "b0")["world_mm"][12] == pytest.approx(2 * factor, rel=1e-8) + if unit != "millimeter": + assert "NON_MM_SOURCE_UNIT" in codes(sc) and sc["status"] == "partial" + assert not [f for f in sc["findings"] if f["kind"] == "placement"] + + +def test_twelve_inch_part_is_304_8_mm(tmp_path): + sc = build(fx.three_mf(tmp_path / "i.3mf", fx.model_xml([fx.cube_object("1", 12)], [("1", None)], unit="inch"))) + b = node(sc, "b0")["bounds_mm"] + assert b["max"][0] - b["min"][0] == pytest.approx(304.8, abs=1e-4) + assert any(f["kind"] == "size" for f in sc["findings"]) # larger than the 270 mm plate + + +def test_unrecognised_unit_is_a_limitation_not_a_silent_millimetre(tmp_path): + # far off the plate: with an unknown unit no fit statement of any kind may be made + path = fx.three_mf(tmp_path / "x.3mf", fx.model_xml([fx.cube_object("1", 400)], [("1", fx.tf(900, 900, 0))], unit="yard")) + sc = build(path) + assert "UNSUPPORTED_UNIT" in codes(sc) and sc["status"] == "partial" + assert sc["findings"] == [] + assert sc["sources"] == [{"part": "3D/3dmodel.model", "unit": "millimeter", "mm_per_unit": 1.0}] # the documented fallback + absent = build(fx.three_mf(tmp_path / "y.3mf", fx.model_xml([fx.cube_object("1", 400)], [("1", fx.tf(900, 900, 0))]).replace(' unit="millimeter"', ""))) + assert "UNSUPPORTED_UNIT" not in codes(absent) and absent["findings"] # absent = millimetre, judged normally + + +def test_sub_model_in_a_different_unit_uses_its_own_unit(tmp_path): + sub = fx.sub_model_xml([fx.cube_object("1", 1)], unit="centimeter") + root = fx.model_xml([fx.composite_object("10", [("1", "/3D/Objects/a.model", None)])], [("10", fx.tf(5, 0, 0))]) + sc = build(fx.three_mf(tmp_path / "m.3mf", root, {"3D/Objects/a.model": sub})) + b = node(sc, "b0")["bounds_mm"] + assert b["min"][0] == pytest.approx(5.0) # root (mm) translation + assert b["max"][0] == pytest.approx(15.0) # 1 cm cube + assert {s["unit"] for s in sc["sources"]} == {"millimeter", "centimeter"} + + +# ------------------------------------------------------------------------------ ids and instances + +def test_cross_file_duplicate_object_ids_stay_distinct(tmp_path): + a = fx.sub_model_xml([fx.cube_object("1", 10)]) + b = fx.sub_model_xml([fx.cube_object("1", 30)]) + root = fx.model_xml([fx.composite_object("9", [("1", "/3D/Objects/a.model", None), + ("1", "/3D/Objects/b.model", fx.tf(100, 0, 0))])], [("9", None)]) + sc = build(fx.three_mf(tmp_path / "d.3mf", root, {"3D/Objects/a.model": a, "3D/Objects/b.model": b})) + keys = {(m["key"]["part"], m["key"]["object_id"]): m for m in sc["meshes"]} + assert set(keys) == {("3D/Objects/a.model", "1"), ("3D/Objects/b.model", "1")} + assert [n["id"] for n in sc["nodes"]] == ["b0", "b0.c0", "b0.c1"] + assert node(sc, "b0.c0")["bounds_mm"]["max"][0] == 10.0 + assert node(sc, "b0.c1")["bounds_mm"]["min"][0] == 100.0 and node(sc, "b0.c1")["bounds_mm"]["max"][0] == 130.0 + assert node(sc, "b0")["bounds_mm"] == {"min": [0.0, 0.0, 0.0], "max": [130.0, 30.0, 30.0]} + + +def test_repeated_instances_share_a_mesh_and_disable_engine_findings(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0)), ("1", fx.tf(500, 100, 0))]) + sc = build(fx.three_mf(tmp_path / "r.3mf", root)) + assert len(sc["meshes"]) == 1 and [n["id"] for n in sc["nodes"]] == ["b0", "b1"] + assert sc["counts"]["rendered_triangles"] == 24 and sc["counts"]["triangles"] == 12 + assert node(sc, "b0")["bounds_mm"]["min"][0] == 100.0 + assert node(sc, "b1")["bounds_mm"]["min"][0] == 500.0 # geometry stays available + lim = next(l for l in sc["limitations"] if l["code"] == "REPEATED_INSTANCE_PLACEMENT_UNVERIFIED") + assert lim["target_ids"] == ["b0", "b1"] and sc["status"] == "partial" + assert sc["findings"] == [] and all(n["finding_ids"] == [] for n in sc["nodes"]) + # the same cube on its own IS judged: the second instance really is off the plate + single = build(fx.three_mf(tmp_path / "s.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0))]))) + assert [f["kind"] for f in single["findings"]] == ["placement"] + + +def test_nested_and_mirrored_transforms_compose(tmp_path): + inner = fx.composite_object("5", [("1", None, "-1 0 0 0 1 0 0 0 1 0 0 0")]) # mirror x + mesh = fx.cube_object("1", 10) + outer = fx.composite_object("6", [("5", None, fx.tf(30, 0, 0))]) + root = fx.model_xml([mesh, inner, outer], [("6", fx.tf(0, 50, 0))]) + sc = build(fx.three_mf(tmp_path / "n.3mf", root)) + assert [n["id"] for n in sc["nodes"]] == ["b0", "b0.c0", "b0.c0.c0"] + leaf = node(sc, "b0.c0.c0") + assert leaf["instance_ref"] == {"build_index": 0, "component_path": [0, 0]} + assert leaf["mirrored"] is True and node(sc, "b0")["mirrored"] is False + assert leaf["parent_id"] == "b0.c0" + # mirror -> x in [-10, 0]; +30 -> [20, 30]; y +50 + assert leaf["bounds_mm"] == {"min": [20.0, 50.0, 0.0], "max": [30.0, 60.0, 10.0]} + expected = scene.mat_mul(scene.mat_mul(scene.parse_transform(fx.tf(0, 50, 0), 1.0), + scene.parse_transform(fx.tf(30, 0, 0), 1.0)), + scene.parse_transform("-1 0 0 0 1 0 0 0 1 0 0 0", 1.0)) + assert leaf["world_mm"] == pytest.approx(expected) + + +def test_rotated_instance_bounds_are_tight_not_box_corners(tmp_path): + verts = [(0, 0, 0), (10, 0, 0), (0, 10, 0), (0, 0, 10)] + tris = [(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)] + c = math.cos(math.pi / 4) + lin = f"{c} {c} 0 {-c} {c} 0 0 0 1" # 45 degrees about z + obj = f'{fx.mesh_xml(verts, tris)}' + sc = build(fx.three_mf(tmp_path / "rot.3mf", fx.model_xml([obj], [("1", fx.tf(0, 0, 0, lin))]))) + b = node(sc, "b0")["bounds_mm"] + assert b["max"][1] == pytest.approx(10 * c, abs=1e-4) # a box-corner estimate would give 14.14 + assert b["min"][0] == pytest.approx(-10 * c, abs=1e-4) and b["max"][0] == pytest.approx(10 * c, abs=1e-4) + + +# ------------------------------------------------------------------------------ roles and volumes + +def test_bambu_roles_and_non_part_volumes_do_not_count_for_bounds(tmp_path): + path = fx.bambu_project(tmp_path / "b.3mf", parts=3, items=[("100", fx.tf(10, 10, 0))], plates=[(1, [("100", 0)])], + roles=["normal_part", "modifier_part", "negative_part"], + origins=[(0, 0, 0), (500, 0, 0), (900, 0, 0)]) + sc = build(path) + roles = {n["resource"]["object_id"]: n["role_context"] for n in sc["nodes"] if n["mesh_key"]} + assert roles == {"1": "part", "2": "modifier", "3": "negative"} # roles follow the USE (node), not the mesh + assert node(sc, "b0.c0")["printable"] is True + top = node(sc, "b0") + assert top["has_non_part_volumes"] is True and top["printable"] is True + assert top["bounds_mm"] == {"min": [10.0, 10.0, 0.0], "max": [20.0, 20.0, 10.0]} # the modifier at x=500 is ignored + assert node(sc, "b0.c1")["printable"] is False and node(sc, "b0.c1")["bounds_mm"] is None + assert sc["findings"] == [] # fits: modifiers are excluded from fit + + +def test_prusa_ranges_partition_all_triangles(tmp_path): + path = fx.prusa_project(tmp_path / "p.3mf", volumes=[(0, 11, "ModelPart"), (12, 23, "ParameterModifier")]) + sc = build(path) + vols = sc["meshes"][0]["volumes"] + assert [(v["triangle_start"], v["triangle_count"], v["role"], v["source"]) for v in vols] == [ + (0, 12, "part", "prusa_range"), (12, 12, "modifier", "prusa_range")] + assert node(sc, "b0")["has_non_part_volumes"] is True + assert node(sc, "b0")["bounds_mm"]["max"][0] == pytest.approx(100 + 10) # only the first cube counts + + +def test_prusa_gap_and_overlap_never_guess(tmp_path): + gap = build(fx.prusa_project(tmp_path / "g.3mf", volumes=[(0, 5, "ModelPart"), (12, 23, "ModelPart")])) + assert [v["role"] for v in gap["meshes"][0]["volumes"]] == ["part", "unknown", "part"] + assert "UNKNOWN_VOLUME_ROLE" in codes(gap) and gap["status"] == "partial" + overlap = build(fx.prusa_project(tmp_path / "o.3mf", volumes=[(0, 12, "ModelPart"), (10, 23, "ModelPart")])) + assert [v["role"] for v in overlap["meshes"][0]["volumes"]] == ["unknown"] + assert node(overlap, "b0")["printable"] is None and overlap["findings"] == [] + + +def test_unrecognised_bambu_subtype_is_unknown_not_a_part(tmp_path): + sc = build(fx.bambu_project(tmp_path / "u.3mf", parts=1, items=[("100", fx.tf(10, 10, 0))], plates=[(1, [("100", 0)])], + roles=["totally_new_part"])) + assert node(sc, "b0.c0")["role_context"] == "unknown" + lim = next(l for l in sc["limitations"] if l["code"] == "UNKNOWN_VOLUME_ROLE") + assert lim["target_ids"] == ["b0.c0"] and node(sc, "b0")["printable"] is None + + +def test_plain_mesh_gets_one_synthetic_part_volume(tmp_path): + sc = build(fx.plain_cube_3mf(tmp_path / "c.3mf")) + assert sc["meshes"][0]["volumes"] == [ + {"id": "v0", "triangle_start": 0, "triangle_count": 12, "role": "part", "source": "object"}] + + +# ------------------------------------------------------------------------------ plates + +def test_two_instances_of_one_resource_on_different_plates(tmp_path): + path = fx.bambu_project(tmp_path / "pl.3mf", parts=1, items=[("100", fx.tf(10, 10, 0)), ("100", fx.tf(20, 10, 0))], + plates=[(1, [("100", 0)]), (2, [("100", 1)])]) + sc = build(path) + assert node(sc, "b0")["plate"] == {"state": "known", "plate_id": "p1", "source": "plate_config"} + assert node(sc, "b1")["plate"] == {"state": "known", "plate_id": "p2", "source": "plate_config"} + assert [p["id"] for p in sc["plates"]] == ["p1", "p2"] and all(p["origin_mm"] is None for p in sc["plates"]) + assert "MULTI_PLATE_PLACEMENT_UNCHECKED" in codes(sc) + assert sc["findings"] == [] + + +def test_membership_ambiguity_is_reported_not_guessed(tmp_path): + both = build(fx.bambu_project(tmp_path / "a.3mf", parts=1, items=[("100", fx.tf(10, 10, 0))], + plates=[(1, [("100", 0)]), (2, [("100", 0)])])) + assert node(both, "b0")["plate"]["state"] == "ambiguous" and node(both, "b0")["plate"]["plate_id"] is None + assert "PLATE_MEMBERSHIP_AMBIGUOUS" in codes(both) + unspecific = build(fx.bambu_project(tmp_path / "b.3mf", parts=1, items=[("100", fx.tf(10, 10, 0)), ("100", fx.tf(40, 10, 0))], + plates=[(1, [("100", None)])])) + assert [node(unspecific, i)["plate"]["state"] for i in ("b0", "b1")] == ["ambiguous", "ambiguous"] + absent = build(fx.bambu_project(tmp_path / "c.3mf", parts=1, items=[("100", fx.tf(10, 10, 0))], plates=[(1, [])])) + assert node(absent, "b0")["plate"]["state"] == "unknown" + + +# ------------------------------------------------------------------------------ STL and no placement + +def test_stl_placement_is_unknown_and_never_judged(tmp_path): + path = fx.binary_stl(tmp_path / "huge.stl", size=900.0) + sc = build(path) + n = node(sc, "b0") + assert n["placement_state"] == "unknown" and n["plate"]["state"] == "unknown" and n["build_index"] is None + assert n["world_mm"] == scene.IDENTITY16 # an identity is a rendering default only + assert sc["findings"] == [] and "UNKNOWN_PLACEMENT" in codes(sc) and sc["status"] == "partial" + assert sc["sources"] == [{"part": "model.stl", "unit": "millimeter", "mm_per_unit": 1.0}] + assert sc["meshes"][0]["volumes"][0]["role"] == "part" + + +def test_model_without_build_items_has_unknown_placement(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], []) + sc = build(fx.three_mf(tmp_path / "nb.3mf", root)) + assert node(sc, "b0")["placement_state"] == "unknown" and node(sc, "b0")["build_index"] is None + assert {"NO_BUILD_ITEMS", "UNKNOWN_PLACEMENT"} <= set(codes(sc)) and sc["findings"] == [] + + +# ------------------------------------------------------------------------------ bed + +@pytest.mark.parametrize("breaker", ["empty", "raises"]) +def test_bed_template_problem_is_surfaced_and_suppresses_fit_findings(tmp_path, monkeypatch, breaker): + def broken(): + if breaker == "raises": + raise FileNotFoundError("template") + return [] + monkeypatch.setattr(plate_placement, "_u1_printable_area", broken) + sc = build(fx.three_mf(tmp_path / "f.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(900, 900, 0))]))) + assert sc["bed"]["policy"] == "fallback" and "BED_TEMPLATE_UNAVAILABLE" in codes(sc) + assert sc["findings"] == [] and sc["status"] == "partial" + + +def test_bed_matches_the_engines_u1_rectangle(): + lims = scene._Limitations() + bed, rect = scene._bed([]) + assert bed["policy"] == "u1_template" and rect == plate_placement.u1_bed_rect() + assert bed["polygon_mm"] == [[0.5, 1.0], [270.5, 1.0], [270.5, 271.0], [0.5, 271.0]] + assert bed["edge_margin_mm"] == plate_placement.EDGE_MARGIN_MM + + +# ------------------------------------------------------------------------------ scene vs the engine + +def _single_plate_cases(tmp_path): + cases = {} + for name, at in {"inside": (50, 60, 0), "past_right": (300, 60, 0), "edge_margin": (270.2, 60, 0), + "past_front": (50, -5, 0)}.items(): + cases[name] = fx.bambu_project(tmp_path / f"{name}.3mf", parts=1, items=[("100", fx.tf(*at))], plates=[(1, [("100", 0)])]) + cases["example_offplate"] = EXAMPLES / "demo_offplate_foreign.3mf" + cases["example_cube"] = EXAMPLES / "sample_cube_SnapmakerU1.3mf" + return cases + + +def test_scene_world_bounds_and_findings_agree_with_plate_placement_assess(tmp_path): + from snapstudio_core import geometry + for name, path in _single_plate_cases(tmp_path).items(): + sc = build(path) + assessed = plate_placement.assess(str(path)) + assert assessed["available"], name + dims = geometry.build_item_dims(str(path)) + assert len(dims) == 1 + top = node(sc, "b0") + assert top["bounds_mm"]["min"] == pytest.approx(list(dims[0]["bounds"]["min"]), abs=1e-4), name + assert top["bounds_mm"]["max"] == pytest.approx(list(dims[0]["bounds"]["max"]), abs=1e-4), name + scene_off = any(f["kind"] == "placement" for f in sc["findings"]) + assert scene_off == bool(assessed["off_plate"]), name + if scene_off: + f = next(f for f in sc["findings"] if f["kind"] == "placement") + assert f["value"]["overhang_mm"] == assessed["items"][0]["overhang_mm"], name + + +def test_repeated_instance_fixture_documents_why_no_finding_is_attached(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + path = fx.three_mf(tmp_path / "rep.3mf", root) + sc = build(path) + assert "REPEATED_INSTANCE_PLACEMENT_UNVERIFIED" in codes(sc) + assert sc["findings"] == [] and not any(n["finding_ids"] for n in sc["nodes"]) + + +# ------------------------------------------------------------------------------ originals are never touched + +@pytest.mark.parametrize("make", ["3mf", "stl", "example"]) +def test_original_file_is_not_modified(tmp_path, make): + path = {"3mf": lambda: fx.bambu_project(tmp_path / "o.3mf", parts=2, items=[("100", fx.tf(10, 10, 0))], plates=[(1, [("100", 0)])]), + "stl": lambda: fx.binary_stl(tmp_path / "o.stl"), + "example": lambda: tmp_path.joinpath("e.3mf").write_bytes((EXAMPLES / "demo_offplate_foreign.3mf").read_bytes()) and tmp_path / "e.3mf"}[make]() + before = (Path(path).read_bytes(), os.stat(path).st_mtime_ns) + os.utime(path, ns=(before[1] - 10**9, before[1] - 10**9)) # make a rewrite visible even on coarse clocks + before = (Path(path).read_bytes(), os.stat(path).st_mtime_ns) + scene.build_scene(str(path)) + assert (Path(path).read_bytes(), os.stat(path).st_mtime_ns) == before diff --git a/backend/tests/test_scene_contract.py b/backend/tests/test_scene_contract.py new file mode 100644 index 00000000..79eacaa2 --- /dev/null +++ b/backend/tests/test_scene_contract.py @@ -0,0 +1,150 @@ +"""scene/1 contract: the committed JSON Schema, the golden scene, and the fixture matrix. + +The schema is validated with the real `jsonschema` validator (a test-only extra); only the +invariants JSON Schema cannot express (volumes partition all triangles, index ranges, matrix +finiteness, base64 byte lengths) are checked by the small helper below. +""" +import base64 +import json +import math +import struct +from pathlib import Path + +import jsonschema +import pytest + +from snapstudio_core import scene, scene_limits as L +from tests import scene_fixtures as fx + +FIX = Path(__file__).parent / "fixtures" / "scene" +GOLDEN_REV = "0123456789abcdef" * 4 + + +def validator(): + s = scene.load_schema() + jsonschema.Draft202012Validator.check_schema(s) + return jsonschema.Draft202012Validator(s) + + +def sub_validator(name): + s = scene.load_schema() + return jsonschema.Draft202012Validator({"$schema": s["$schema"], "$defs": s["$defs"], "$ref": f"#/$defs/{name}"}) + + +def check_invariants(sc: dict) -> None: + """What JSON Schema cannot say.""" + keys = set() + for mesh in sc["meshes"]: + key = (mesh["key"]["part"], mesh["key"]["object_id"]) + assert key not in keys + keys.add(key) + pos = base64.b64decode(mesh["positions_f32le_base64"], validate=True) + idx = base64.b64decode(mesh["indices_u32le_base64"], validate=True) + assert len(pos) == mesh["vertex_count"] * 12 + assert len(idx) == mesh["triangle_count"] * 12 + floats = struct.unpack(f"<{mesh['vertex_count'] * 3}f", pos) + assert all(math.isfinite(f) for f in floats) + ints = struct.unpack(f"<{mesh['triangle_count'] * 3}I", idx) + assert all(i < mesh["vertex_count"] for i in ints) + cursor = 0 + for vol in mesh["volumes"]: # partition: no gap, no overlap + assert vol["triangle_start"] == cursor + cursor += vol["triangle_count"] + assert cursor == mesh["triangle_count"] + ids = [n["id"] for n in sc["nodes"]] + assert len(set(ids)) == len(ids) + for n in sc["nodes"]: + assert all(math.isfinite(x) for x in n["world_mm"] + n["local_to_parent_mm"]) + assert n["parent_id"] is None or n["parent_id"] in ids + if n["mesh_key"] is not None: + assert (n["mesh_key"]["part"], n["mesh_key"]["object_id"]) in keys + assert len(n["id"]) <= L.MAX_ID_LENGTH + fids = {f["id"] for f in sc["findings"]} + assert len(fids) == len(sc["findings"]) + for n in sc["nodes"]: + assert set(n["finding_ids"]) <= fids + for f in sc["findings"]: + assert all(t in ids for t in f["target_ids"]) + assert sc["counts"]["nodes"] == len(sc["nodes"]) + assert sc["counts"]["rendered_triangles"] == sum( + next(m for m in sc["meshes"] if (m["key"]["part"], m["key"]["object_id"]) == (n["mesh_key"]["part"], n["mesh_key"]["object_id"]))["triangle_count"] + for n in sc["nodes"] if n["mesh_key"]) + + +def assert_valid(sc: dict) -> None: + errors = sorted(validator().iter_errors(sc), key=lambda e: list(e.path)) + assert not errors, errors[0].message + check_invariants(sc) + + +def test_schema_is_a_valid_closed_schema(): + s = scene.load_schema() + jsonschema.Draft202012Validator.check_schema(s) + assert s["additionalProperties"] is False + # every scene object definition is closed and requires every property it declares + for name in ("source", "bed", "mesh", "volume", "node", "plate", "finding", "limitation", "counts", "limits", + "plate_ref", "instance_ref", "resource_key"): + d = s["$defs"][name] + assert d["additionalProperties"] is False, name + assert set(d["required"]) == set(d["properties"]), name + + +def test_schema_enums_match_the_code(): + d = scene.load_schema()["$defs"] + assert tuple(d["error_code"]["enum"]) == scene.ERROR_CODES + assert "WORKER_WEDGED" in scene.ERROR_CODES and "BUSY" not in scene.ERROR_CODES + assert set(d["limitation_code"]["enum"]) == set(scene._Limitations.ORDER) + assert set(scene.PARTIAL_LIMITATIONS | scene.INFORMATIONAL_LIMITATIONS) == set(scene._Limitations.ORDER) + assert set(d["finding"]["properties"]["kind"]["enum"]) == {"placement", "size", "note"} + from snapstudio_core import units + assert set(d["unit_name"]["enum"]) == set(units.MM_PER_UNIT) + limits = d["limits"]["properties"] + assert set(limits) == set(L.LIMITS_ECHO) + + +def test_golden_scene_validates_and_is_reproduced_exactly(tmp_path): + golden = json.loads((FIX / "golden-scene-1.json").read_text("utf-8")) + assert_valid(golden) + path = fx.plain_cube_3mf(tmp_path / "cube.3mf", size=10.0, at=(50.0, 60.0, 0.0)) + built = scene.build_scene_dict(str(path), GOLDEN_REV) + assert built == golden + assert json.loads(scene.serialize(built)) == golden + + +def test_finding_kind_is_required_and_closed(): + v = sub_validator("finding") + good = {"id": "f0", "engine": "scene", "schema": "scene/1", "pointer": "/nodes/0", "scope": "instance", + "kind": "placement", "target_ids": ["b0"], "value": {"code": "PLACEMENT_OUTSIDE_BED", "overhang_mm": None}} + assert not list(v.iter_errors(good)) + bad = dict(good) + del bad["kind"] + assert list(v.iter_errors(bad)) + assert list(v.iter_errors({**good, "kind": "opinion"})) + assert list(v.iter_errors({**good, "extra": 1})) + + +@pytest.mark.parametrize("name", ["error_response"]) +def test_error_response_accepts_every_code(name): + v = sub_validator(name) + for code in scene.ERROR_CODES: + assert not list(v.iter_errors({"error": code, "message": "x"})) + assert list(v.iter_errors({"error": "BUSY", "message": "x"})) + + +def test_schema_rejects_mutations_of_the_golden(): + golden = json.loads((FIX / "golden-scene-1.json").read_text("utf-8")) + v = validator() + for mutate in ( + lambda s: s.pop("limits"), + lambda s: s.update(extra=1), + lambda s: s["nodes"][0].pop("placement_state"), + lambda s: s["nodes"][0].update(world_mm=[1.0] * 15), + lambda s: s["nodes"][0]["plate"].update(state="maybe"), + lambda s: s["meshes"][0]["volumes"][0].update(role="solid"), + lambda s: s["bed"].update(policy="guess"), + lambda s: s.update(status="done"), + lambda s: s["sources"][0].update(unit="yard"), + ): + broken = json.loads(json.dumps(golden)) + mutate(broken) + assert list(v.iter_errors(broken)) diff --git a/backend/tests/test_scene_hostile.py b/backend/tests/test_scene_hostile.py new file mode 100644 index 00000000..e2518e2a --- /dev/null +++ b/backend/tests/test_scene_hostile.py @@ -0,0 +1,355 @@ +"""scene/1 against hostile and broken input: every case ends in a clear, coded refusal -- never a +crash, a hang, a partial scene or a silently shortened one.""" +import time +import zipfile + +import pytest + +from snapstudio_core import scene, scene_limits as L +from tests import scene_fixtures as fx + +REV = "cd" * 32 + + +def refuse(path, code): + with pytest.raises(scene.SceneError) as e: + scene.build_scene(str(path), REV) + assert e.value.code == code, (e.value.code, e.value.message) + return e.value + + +def model(objects=None, items=None, **kw): + return fx.model_xml(objects or [fx.cube_object("1")], items or [("1", None)], **kw) + + +# ------------------------------------------------------------------------------ format + +def test_wrong_extension_and_not_an_archive(tmp_path): + p = tmp_path / "x.obj" + p.write_bytes(b"v 0 0 0") + refuse(p, "UNSUPPORTED_FORMAT") + q = tmp_path / "x.3mf" + q.write_bytes(b"this is not a zip file at all" * 10) + refuse(q, "INVALID_ARCHIVE") + + +def test_missing_root_model_and_unresolved_references(tmp_path): + refuse(fx.write_zip(tmp_path / "a.3mf", {"_rels/.rels": fx.rels_xml()}), "UNRESOLVED_REFERENCE") + missing_file = model([fx.composite_object("5", [("1", "/3D/Objects/nope.model", None)])], [("5", None)]) + refuse(fx.three_mf(tmp_path / "b.3mf", missing_file), "UNRESOLVED_REFERENCE") + missing_obj = model([fx.composite_object("5", [("77", None, None)])], [("5", None)]) + refuse(fx.three_mf(tmp_path / "c.3mf", missing_obj), "UNRESOLVED_REFERENCE") + missing_item = model([fx.cube_object("1")], [("42", None)]) + refuse(fx.three_mf(tmp_path / "d.3mf", missing_item), "UNRESOLVED_REFERENCE") + + +# ------------------------------------------------------------------------------ archive structure + +@pytest.mark.parametrize("name", ["../evil.model", "/abs.model", "a/../../b.model", "..\\win.model", "C:/x.model", + "a/\x01b"]) +def test_traversal_looking_entry_names_are_rejected(tmp_path, name): + entries = {"_rels/.rels": fx.rels_xml(), "3D/3dmodel.model": model(), name: "x"} + refuse(fx.write_zip(tmp_path / "t.3mf", entries), "INVALID_ARCHIVE") + + +@pytest.mark.parametrize("second", ["3d/3DMODEL.model", "3D\\3dmodel.model", "3D//3dmodel.model", "./3D/3dmodel.model"]) +def test_colliding_normalized_names_are_rejected(tmp_path, second): + import warnings + entries = {"_rels/.rels": fx.rels_xml(), "3D/3dmodel.model": model(), second: model()} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + path = fx.write_zip(tmp_path / "c.3mf", entries) + refuse(path, "INVALID_ARCHIVE") + + +def test_exact_duplicate_entry_names_are_rejected(tmp_path): + import warnings + path = tmp_path / "dup.3mf" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with zipfile.ZipFile(path, "w", zipfile.ZIP_STORED) as z: + z.writestr("_rels/.rels", fx.rels_xml()) + z.writestr("3D/3dmodel.model", model()) + z.writestr("3D/3dmodel.model", model()) + refuse(path, "INVALID_ARCHIVE") + + +def test_external_relationships_are_rejected_anywhere(tmp_path): + ext = '' + refuse(fx.three_mf(tmp_path / "a.3mf", model(), rels=fx.rels_xml(extra=ext)), "INVALID_ARCHIVE") + rels3d = f'{ext}' + refuse(fx.three_mf(tmp_path / "b.3mf", model(), {"3D/_rels/3dmodel.model.rels": rels3d}), "INVALID_ARCHIVE") + + +def test_references_that_escape_the_package_are_rejected(tmp_path): + refuse(fx.three_mf(tmp_path / "a.3mf", model(), rels=fx.rels_xml(target="../../etc/passwd")), "INVALID_ARCHIVE") + escaping = model([fx.composite_object("5", [("1", "/../../x.model", None)])], [("5", None)]) + refuse(fx.three_mf(tmp_path / "b.3mf", escaping), "INVALID_ARCHIVE") + + +def test_relative_and_rooted_relationship_targets_resolve_but_urls_do_not(tmp_path): + ok = scene.build_scene_dict(str(fx.three_mf(tmp_path / "rel.3mf", model(), rels=fx.rels_xml(target="3D/3dmodel.model"))), REV) + assert ok["counts"]["nodes"] == 1 + for target in ("http://example.invalid/a.model", "file:///C:/x.model", "C:/x.model", "ftp://h/x"): + refuse(fx.three_mf(tmp_path / "url.3mf", model(), rels=fx.rels_xml(target=target)), "INVALID_ARCHIVE") + + +def test_only_the_needed_parts_are_ever_read(tmp_path): + big = "x" * (3 * 1024 * 1024) + path = fx.three_mf(tmp_path / "ok.3mf", model(), {"Metadata/thumbnail.png": big, "Metadata/plate_1.gcode": big, + "Metadata/project_settings.config": big}) + arcs = [] + real = scene.Archive.__init__ + + def spy(self, *a, **k): + real(self, *a, **k) + arcs.append(self) + scene.Archive.__init__ = spy + try: + scene.build_scene(str(path), REV) + finally: + scene.Archive.__init__ = real + assert arcs[0].total_read < 4096 # thumbnails, G-code and project settings were never read + + +# ------------------------------------------------------------------------------ XML hardening + +BILLION = (']>' + "&b;") + + +def test_dtd_and_entity_declarations_are_rejected_before_parsing(tmp_path): + refuse(fx.three_mf(tmp_path / "a.3mf", BILLION), "INVALID_ARCHIVE") + mixed = model(prolog=']>') + refuse(fx.three_mf(tmp_path / "b.3mf", mixed), "INVALID_ARCHIVE") + external = model(prolog='') + refuse(fx.three_mf(tmp_path / "c.3mf", external), "INVALID_ARCHIVE") + # the same refusal for the .rels and the settings parts + refuse(fx.three_mf(tmp_path / "d.3mf", model(), rels=BILLION), "INVALID_ARCHIVE") + settings = ']>' + refuse(fx.three_mf(tmp_path / "e.3mf", model(), {"Metadata/model_settings.config": settings}), "INVALID_ARCHIVE") + + +@pytest.mark.parametrize("encoding", ["utf-16", "utf-16-le", "utf-16-be", "utf-32"]) +def test_utf16_doctype_bomb_is_rejected_by_encoding_before_parsing(tmp_path, encoding): + bomb = BILLION.replace("", f'') + for with_bom in (True, False): + raw = bomb.encode(encoding) + if not with_bom and raw[:2] in (b"\xff\xfe", b"\xfe\xff") or (not with_bom and raw[:4] in (b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff")): + raw = raw[2:] if encoding != "utf-32" else raw[4:] + path = fx.write_zip(tmp_path / f"u-{encoding}-{with_bom}.3mf", + {"_rels/.rels": fx.rels_xml(), "3D/3dmodel.model": raw}) + err = refuse(path, "INVALID_ARCHIVE") + assert "UTF-8" in err.message + + +def test_declared_non_utf8_encoding_is_rejected(tmp_path): + xml = model(prolog='') + refuse(fx.three_mf(tmp_path / "a.3mf", xml), "INVALID_ARCHIVE") + ok = model(prolog='\ufeff') + scene.build_scene(str(fx.three_mf(tmp_path / "b.3mf", ok.encode("utf-8"))), REV) # UTF-8 with BOM is fine + + +def test_parser_level_doctype_check_is_a_second_line_of_defence(tmp_path, monkeypatch): + path = fx.three_mf(tmp_path / "x.3mf", BILLION.replace("", f'')) + original = scene.Archive.open + monkeypatch.setattr(scene.Archive, "open", lambda self, name, kind, scan=True: original(self, name, kind, scan=False)) + err = refuse(path, "INVALID_ARCHIVE") + assert "DTD" in err.message + + +def test_malformed_xml_is_a_clean_refusal(tmp_path): + refuse(fx.three_mf(tmp_path / "a.3mf", ""), "INVALID_ARCHIVE") + refuse(fx.three_mf(tmp_path / "b.3mf", ""), "INVALID_ARCHIVE") + refuse(fx.three_mf(tmp_path / "c.3mf", ""), "INVALID_ARCHIVE") + + +# ------------------------------------------------------------------------------ geometry validation + +def _mesh_model(vertices, triangles, transform=None): + obj = f'{fx.mesh_xml(vertices, triangles)}' + return fx.model_xml([obj], [("1", transform)]) + + +@pytest.mark.parametrize("bad", ["nan", "inf", "-inf", "abc", "", "1e999"]) +def test_non_finite_or_invalid_coordinates(tmp_path, bad): + v = [(0, 0, 0), (1, 0, 0), (0, bad, 0)] + refuse(fx.three_mf(tmp_path / "v.3mf", _mesh_model(v, [(0, 1, 2)])), "INVALID_GEOMETRY") + + +def test_coordinate_out_of_range(tmp_path): + refuse(fx.three_mf(tmp_path / "v.3mf", _mesh_model([(0, 0, 0), (1, 0, 0), (0, 1e30, 0)], [(0, 1, 2)])), "INVALID_GEOMETRY") + + +@pytest.mark.parametrize("tri", [(0, 1, 3), (0, 1, 99999999999), (0, -1, 2)]) +def test_invalid_triangle_indices(tmp_path, tri): + refuse(fx.three_mf(tmp_path / "t.3mf", _mesh_model([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [tri])), "INVALID_GEOMETRY") + + +def test_missing_attributes_and_empty_meshes(tmp_path): + body = ('') + refuse(fx.three_mf(tmp_path / "a.3mf", fx.model_xml([body], [("1", None)])), "INVALID_GEOMETRY") + empty = '' + refuse(fx.three_mf(tmp_path / "b.3mf", fx.model_xml([empty], [("1", None)])), "INVALID_GEOMETRY") + nothing = '' + refuse(fx.three_mf(tmp_path / "c.3mf", fx.model_xml([nothing], [("1", None)])), "INVALID_GEOMETRY") + + +@pytest.mark.parametrize("transform", ["1 0 0 0 1 0 0 0 1 0 0 nan", "1 0 0 0 1 0 0 0 1 0 0 inf", "1 0 0 0 1 0 0 0 1 0 0", + "a b c d e f g h i j k l", "1 0 0 0 1 0 0 0 1 0 0 0 0"]) +def test_invalid_transforms(tmp_path, transform): + refuse(fx.three_mf(tmp_path / "x.3mf", model(items=[("1", transform)])), "INVALID_GEOMETRY") + + +def test_transform_overflow_in_composition_is_refused(tmp_path): + big = "1e200 0 0 0 1e200 0 0 0 1e200 0 0 0" + objs = [fx.cube_object("1"), fx.composite_object("2", [("1", None, big)]), fx.composite_object("3", [("2", None, big)])] + refuse(fx.three_mf(tmp_path / "o.3mf", fx.model_xml(objs, [("3", None)])), "INVALID_GEOMETRY") + + +def test_component_cycles_and_duplicate_ids(tmp_path): + cyc = [fx.composite_object("1", [("2", None, None)]), fx.composite_object("2", [("1", None, None)])] + refuse(fx.three_mf(tmp_path / "c.3mf", fx.model_xml(cyc, [("1", None)])), "INVALID_ARCHIVE") + selfref = [fx.composite_object("1", [("1", None, None)])] + refuse(fx.three_mf(tmp_path / "s.3mf", fx.model_xml(selfref, [("1", None)])), "INVALID_ARCHIVE") + dup = [fx.cube_object("1"), fx.cube_object("1")] + refuse(fx.three_mf(tmp_path / "d.3mf", fx.model_xml(dup, [("1", None)])), "INVALID_ARCHIVE") + both = '' + refuse(fx.three_mf(tmp_path / "b.3mf", fx.model_xml([both], [("1", None)])), "INVALID_ARCHIVE") + + +# ------------------------------------------------------------------------------ budgets (never truncate) + +def test_zip_bomb_is_refused_without_reading_it(tmp_path): + path = tmp_path / "bomb.3mf" + head = f'' + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("_rels/.rels", fx.rels_xml()) + z.writestr("3D/3dmodel.model", head + " " * (40 * 1024 * 1024) + "") + assert path.stat().st_size < 200_000 + t = time.monotonic() + refuse(path, "LIMIT_EXCEEDED") + assert time.monotonic() - t < 2.0 + + +def test_total_expansion_budget_is_metered_while_reading(tmp_path, monkeypatch): + sub = fx.sub_model_xml([fx.cube_object("1")]) + root = model([fx.composite_object("9", [("1", "/3D/Objects/a.model", None)])], [("9", None)]) + path = fx.three_mf(tmp_path / "m.3mf", root, {"3D/Objects/a.model": sub}) + scene.build_scene(str(path), REV) + monkeypatch.setattr(L, "ARCHIVE_EXPANSION_BYTES", 1500) + refuse(path, "LIMIT_EXCEEDED") + monkeypatch.setattr(L, "ARCHIVE_EXPANSION_BYTES", 128 * 1024 * 1024) + monkeypatch.setattr(L, "MODEL_XML_BYTES", 1000) + refuse(path, "LIMIT_EXCEEDED") + + +def test_entry_count_and_part_count_budgets(tmp_path, monkeypatch): + path = fx.three_mf(tmp_path / "e.3mf", model(), {f"x/{i}.txt": "a" for i in range(20)}) + monkeypatch.setattr(L, "ARCHIVE_MAX_ENTRIES", 10) + refuse(path, "LIMIT_EXCEEDED") + + +@pytest.mark.parametrize("limit,value", [("MAX_PARSED_TRIANGLES", 11), ("MAX_VERTICES", 7)]) +def test_triangle_and_vertex_budgets_are_enforced_during_parsing(tmp_path, monkeypatch, limit, value): + path = fx.plain_cube_3mf(tmp_path / "c.3mf") + monkeypatch.setattr(L, limit, value) + refuse(path, "LIMIT_EXCEEDED") + + +def test_refusal_names_the_triangle_count_and_the_limit(tmp_path, monkeypatch): + path = fx.big_grid_3mf(tmp_path / "g.3mf", 400) + monkeypatch.setattr(L, "MAX_PARSED_TRIANGLES", 100) + err = refuse(path, "LIMIT_EXCEEDED") + assert "101" in err.message and "100" in err.message + monkeypatch.undo() + stl = fx.binary_stl(tmp_path / "c.stl") + monkeypatch.setattr(L, "MAX_PARSED_TRIANGLES", 11) + assert "12" in refuse(stl, "LIMIT_EXCEEDED").message + + +def test_rendered_triangles_count_every_repetition(tmp_path, monkeypatch): + path = fx.three_mf(tmp_path / "r.3mf", fx.model_xml([fx.cube_object("1")], [("1", fx.tf(i * 20, 0, 0)) for i in range(5)])) + monkeypatch.setattr(L, "MAX_RENDERED_TRIANGLES", 59) # 5 x 12 = 60 + refuse(path, "LIMIT_EXCEEDED") + monkeypatch.setattr(L, "MAX_RENDERED_TRIANGLES", 60) + assert scene.build_scene_dict(str(path), REV)["counts"]["rendered_triangles"] == 60 + + +def test_node_and_depth_budgets(tmp_path, monkeypatch): + path = fx.three_mf(tmp_path / "n.3mf", fx.model_xml([fx.cube_object("1")], [("1", fx.tf(i * 20, 0, 0)) for i in range(5)])) + monkeypatch.setattr(L, "MAX_NODES", 4) + refuse(path, "LIMIT_EXCEEDED") + chain = [fx.cube_object("0")] + [fx.composite_object(str(i), [(str(i - 1), None, None)]) for i in range(1, 70)] + deep = fx.three_mf(tmp_path / "d.3mf", fx.model_xml(chain, [("69", None)])) + refuse(deep, "LIMIT_EXCEEDED") + monkeypatch.setattr(L, "MAX_NODES", 2000) + shallow = fx.three_mf(tmp_path / "s.3mf", fx.model_xml(chain[:60], [("59", None)])) + assert scene.build_scene_dict(str(shallow), REV)["counts"]["nodes"] == 60 + + +def test_whole_body_cap_applies_to_the_exact_bytes(tmp_path, monkeypatch): + path = fx.plain_cube_3mf(tmp_path / "c.3mf") + size = len(scene.build_scene(str(path), REV)) + monkeypatch.setattr(L, "MAX_RESPONSE_BYTES", size) + assert len(scene.build_scene(str(path), REV)) == size + monkeypatch.setattr(L, "MAX_RESPONSE_BYTES", size - 1) + refuse(path, "LIMIT_EXCEEDED") + + +def test_geometry_preflight_rejects_before_any_encoding(tmp_path, monkeypatch): + path = fx.plain_cube_3mf(tmp_path / "c.3mf") + monkeypatch.setattr(L, "MAX_RESPONSE_BYTES", 100) + monkeypatch.setattr(scene, "_encode_f32", lambda *a: pytest.fail("encoded before the size check")) + refuse(path, "LIMIT_EXCEEDED") + + +def test_stl_budgets(tmp_path, monkeypatch): + stl = fx.binary_stl(tmp_path / "c.stl") + monkeypatch.setattr(L, "MAX_PARSED_TRIANGLES", 11) + refuse(stl, "LIMIT_EXCEEDED") + monkeypatch.undo() + text = tmp_path / "t.stl" + text.write_text("solid x\n" + "facet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n" * 5 + "endsolid\n") + assert scene.build_scene_dict(str(text), REV)["counts"]["triangles"] == 5 + monkeypatch.setattr(L, "MAX_PARSED_TRIANGLES", 4) + err = refuse(text, "LIMIT_EXCEEDED") # ASCII is line-streamed with a running counter + assert "5" in err.message and "4" in err.message + garbage = tmp_path / "g.stl" + garbage.write_bytes(b"not an stl at all" * 20) + monkeypatch.undo() + refuse(garbage, "INVALID_GEOMETRY") + + +# ------------------------------------------------------------------------------ cooperative interruption + +class CountingControl(scene.Control): + def __init__(self, cancel_at=None): + self.checks, self.cancel_at = 0, cancel_at + + def check(self): + self.checks += 1 + if self.cancel_at is not None and self.checks >= self.cancel_at: + raise scene.SceneCancelled() + + +def test_cancel_is_observed_inside_parsing_and_encoding(tmp_path): + path = fx.big_grid_3mf(tmp_path / "big.3mf", 30_000) + full = CountingControl() + scene.build_scene_dict(str(path), REV, full) + assert full.checks > 100 # checked far more often than once per phase + for at in (3, full.checks // 2, full.checks - 2): + c = CountingControl(cancel_at=at) + with pytest.raises(scene.SceneCancelled): + scene.build_scene_dict(str(path), REV, c) + assert c.checks == at # stops at the first check after the flag + + +def test_deadline_is_a_timeout_not_a_hang(tmp_path): + path = fx.big_grid_3mf(tmp_path / "big.3mf", 30_000) + + class Late(scene.Control): + def check(self): + raise scene.SceneTimeout() + with pytest.raises(scene.SceneTimeout): + scene.build_scene_dict(str(path), REV, Late()) diff --git a/backend/tests/test_scene_jobs.py b/backend/tests/test_scene_jobs.py new file mode 100644 index 00000000..c12bf68a --- /dev/null +++ b/backend/tests/test_scene_jobs.py @@ -0,0 +1,746 @@ +"""Scene jobs: state machine, worker model, snapshots, retention, cancellation, wedge backstop, leaks.""" +import hashlib +import json +import os +import subprocess +import sys +import threading +import time +from pathlib import Path + +import jsonschema +import pytest + +from snapstudio_api import scene_jobs as sj +from snapstudio_core import scene, scene_limits as L +from tests import scene_fixtures as fx +from tests.test_scene_contract import assert_valid, sub_validator + +RID = iter(range(10**6)) + + +def rid() -> str: + return f"req-{next(RID):08d}" + + +def validate(name, obj): + errors = list(sub_validator(name).iter_errors(obj)) + assert not errors, errors[0].message + + +def wait_for(fn, timeout=10.0, step=0.01): + end = time.monotonic() + timeout + while time.monotonic() < end: + value = fn() + if value: + return value + time.sleep(step) + raise AssertionError("timed out") + + +def wait_state(jobs, job_id, *states, timeout=10.0): + return wait_for(lambda: (lambda s: s if s["state"] in states else None)(jobs.status(job_id)), timeout) + + +def fake_builder(gate=None, honor_cancel=True, started=None, body=b'{"ok":true}'): + def builder(path, revision, ctl): + if started is not None: + started.set() + if gate is not None: + while not gate.wait(0.005): + if honor_cancel: + ctl.check() + return body + return builder + + +@pytest.fixture +def cube(tmp_path): + return str(fx.bambu_project(tmp_path / "cube.3mf", parts=2, items=[("100", fx.tf(10, 10, 0))], plates=[(1, [("100", 0)])])) + + +@pytest.fixture +def make(tmp_path): + made = [] + + def factory(**kw): + jobs = sj.SceneJobs(snapshot_root=str(tmp_path / "snap"), **kw) + made.append(jobs) + return jobs + yield factory + for jobs in made: + jobs.close() + + +def snapshot_files(jobs): + d = jobs.snapshot_dir() + return [] if d is None or not os.path.isdir(d) else os.listdir(d) + + +# ------------------------------------------------------------------------------ the happy path + +def test_start_status_result_and_snapshot_cleanup(make, cube): + jobs = make() + started = jobs.start(cube, rid()) + validate("job_start", started) + assert started["state"] in ("queued", "running") and started["replaced_job_id"] is None + status = wait_state(jobs, started["job_id"], "succeeded") + validate("job_status", status) + expected = hashlib.sha256(Path(cube).read_bytes()).hexdigest() + assert status["revision"] == expected and status["stage"] is None + code, body = jobs.result(started["job_id"]) + assert code == 200 and isinstance(body, bytes) + sc = json.loads(body) + assert_valid(sc) + assert sc["revision"] == expected and sc["counts"]["nodes"] == 3 + wait_for(lambda: not jobs.worker_alive()) + assert snapshot_files(jobs) == [] # the worker removed its own snapshot + assert body == scene.serialize(sc) # compact bytes, serialized exactly once + + +def test_original_file_bytes_and_mtime_unchanged_after_a_job(make, cube): + jobs = make() + old = os.stat(cube).st_mtime_ns - 5 * 10**9 + os.utime(cube, ns=(old, old)) + before = (Path(cube).read_bytes(), os.stat(cube).st_mtime_ns) + job = jobs.start(cube, rid()) + wait_state(jobs, job["job_id"], "succeeded") + jobs.result(job["job_id"]) + assert (Path(cube).read_bytes(), os.stat(cube).st_mtime_ns) == before + + +def test_result_while_not_ready_and_unknown_job(make, cube): + gate = threading.Event() + jobs = make(builder=fake_builder(gate)) + job = jobs.start(cube, rid()) + wait_state(jobs, job["job_id"], "running") + code, body = jobs.result(job["job_id"]) + assert code == 409 and body["error"] == "NOT_READY" and body["state"] == "running" + validate("error_response", body) + gate.set() + wait_state(jobs, job["job_id"], "succeeded") + with pytest.raises(sj.JobError) as e: + jobs.status("0" * 32) + assert (e.value.http, e.value.code) == (404, "EXPIRED") + + +def test_failed_job_reports_a_coded_error_with_422(make, tmp_path): + bad = tmp_path / "bad.3mf" + bad.write_bytes(b"not a zip" * 20) + jobs = make() + job = jobs.start(str(bad), rid()) + status = wait_state(jobs, job["job_id"], "failed") + assert status["error"]["code"] == "INVALID_ARCHIVE" + validate("job_status", status) + code, body = jobs.result(job["job_id"]) + assert code == 422 and body["error"] == "INVALID_ARCHIVE" + validate("error_response", body) + wait_for(lambda: not jobs.worker_alive()) + assert snapshot_files(jobs) == [] + + +def test_start_validation(make, tmp_path): + jobs = make() + txt = tmp_path / "a.txt" + txt.write_text("x") + with pytest.raises(sj.JobError) as e: + jobs.start(str(txt), rid()) + assert (e.value.http, e.value.code) == (422, "UNSUPPORTED_FORMAT") + with pytest.raises(sj.JobError) as e: + jobs.start(str(tmp_path / "missing.3mf"), rid()) + assert (e.value.http, e.value.code) == (400, "INVALID_REQUEST") + + +# ------------------------------------------------------------------------------ idempotency and request ids + +def test_same_request_id_and_path_returns_the_existing_job(make, cube): + jobs = make() + r = rid() + a = jobs.start(cube, r) + b = jobs.start(cube, r) + assert a["job_id"] == b["job_id"] and b["replaced_job_id"] is None + wait_state(jobs, a["job_id"], "succeeded") + c = jobs.start(cube, r) # still retained + assert c["job_id"] == a["job_id"] and c["state"] == "succeeded" + assert jobs.retained() == 1 + + +def test_request_id_reused_for_another_path_is_a_conflict(make, cube, tmp_path): + other = fx.plain_cube_3mf(tmp_path / "other.3mf") + jobs = make() + r = rid() + jobs.start(cube, r) + with pytest.raises(sj.JobError) as e: + jobs.start(str(other), r) + assert (e.value.http, e.value.code, e.value.extra["reason"]) == (409, "INVALID_REQUEST", "request_id reused for another path") + + +def test_reused_request_id_with_changed_file_is_source_changed(make, cube): + jobs = make() + r = rid() + job = jobs.start(cube, r) + wait_state(jobs, job["job_id"], "succeeded") + Path(cube).write_bytes(Path(cube).read_bytes() + b"\x00") + with pytest.raises(sj.JobError) as e: + jobs.start(cube, r) + assert (e.value.http, e.value.code) == (409, "SOURCE_CHANGED") + fresh = jobs.start(cube, rid()) # a fresh request_id starts a new scene + assert fresh["job_id"] != job["job_id"] + + +def test_expected_revision_mismatch_is_source_changed(make, cube): + jobs = make() + job = jobs.start(cube, rid()) + status = wait_state(jobs, job["job_id"], "succeeded") + assert jobs.result(job["job_id"], status["revision"])[0] == 200 + with pytest.raises(sj.JobError) as e: + jobs.result(job["job_id"], "f" * 64) + assert (e.value.http, e.value.code) == (409, "SOURCE_CHANGED") + + +def test_source_replaced_mid_job_cannot_tear_the_scene(make, cube): + """The snapshot is the single source: replacing the original after the copy changes nothing.""" + original = Path(cube).read_bytes() + seen = {} + gate = threading.Event() + started = threading.Event() + + def builder(path, revision, ctl): + started.set() + while not gate.wait(0.005): + ctl.check() + seen["snapshot"] = Path(path).read_bytes() + seen["revision"] = revision + return b"{}" + jobs = make(builder=builder) + job = jobs.start(cube, rid()) + started.wait(5) + Path(cube).write_bytes(b"replaced with something else entirely") + gate.set() + status = wait_state(jobs, job["job_id"], "succeeded") + assert seen["snapshot"] == original + assert seen["revision"] == status["revision"] == hashlib.sha256(original).hexdigest() + + +def test_file_changing_during_the_copy_is_source_changed(make, cube, monkeypatch): + monkeypatch.setattr(L, "SNAPSHOT_CHUNK_BYTES", 256) + calls = {"n": 0} + real = sj._Control.progress + + def tamper(self, stage, completed, total): + calls["n"] += 1 + if calls["n"] == 2 and stage == "reading": + with open(cube, "ab") as fh: + fh.write(b"appended while copying") + real(self, stage, completed, total) + monkeypatch.setattr(sj._Control, "progress", tamper) + jobs = make() + job = jobs.start(cube, rid()) + status = wait_state(jobs, job["job_id"], "failed") + assert status["error"]["code"] == "SOURCE_CHANGED" + wait_for(lambda: not jobs.worker_alive()) + assert snapshot_files(jobs) == [] + + +# ------------------------------------------------------------------------------ cancel, replace, races + +def test_replace_cancels_the_running_job_and_queues_behind_it(make, cube): + gate_a = threading.Event() + started = threading.Event() + order = [] + + def builder(path, revision, ctl): + order.append(os.path.basename(path)) + started.set() + if len(order) == 1: + while not gate_a.wait(0.005): + ctl.check() + return b"{}" + jobs = make(builder=builder) + a = jobs.start(cube, rid()) + started.wait(5) + b = jobs.start(cube, rid()) + assert b["replaced_job_id"] == a["job_id"] and b["state"] == "queued" + assert jobs.status(a["job_id"])["state"] == "cancelled" + assert jobs.result(a["job_id"])[1]["error"] == "CANCELLED" + wait_state(jobs, b["job_id"], "succeeded") # b ran only after a's thread observed the cancel + assert jobs.status(a["job_id"])["state"] == "cancelled" + assert len(order) == 2 and not gate_a.is_set() + + +def test_at_most_one_queued_job_newest_wins(make, cube): + gate = threading.Event() + started = threading.Event() + jobs = make(builder=fake_builder(gate, honor_cancel=False, started=started)) # holds the worker even when cancelled + a = jobs.start(cube, rid()) + started.wait(5) + b = jobs.start(cube, rid()) + c = jobs.start(cube, rid()) + assert c["replaced_job_id"] == b["job_id"] + assert jobs.status(b["job_id"])["state"] == "cancelled" and jobs.status(c["job_id"])["state"] == "queued" + assert jobs.status(a["job_id"])["state"] == "cancelled" + gate.set() + wait_state(jobs, c["job_id"], "succeeded") + + +def test_cancel_queued_running_and_terminal_is_idempotent(make, cube): + gate = threading.Event() + started = threading.Event() + jobs = make(builder=fake_builder(gate, started=started)) + a = jobs.start(cube, rid()) + started.wait(5) + s = jobs.cancel(a["job_id"]) + assert s["state"] == "cancelled" and s["error"]["code"] == "CANCELLED" + assert jobs.cancel(a["job_id"])["state"] == "cancelled" + wait_for(lambda: not jobs.worker_alive()) + b = jobs.start(cube, rid()) + gate.set() + wait_state(jobs, b["job_id"], "succeeded") + assert jobs.cancel(b["job_id"])["state"] == "succeeded" # a late cancel on a terminal job is a no-op + assert jobs.result(b["job_id"])[0] == 200 + + +def test_cancel_wins_the_race_against_a_worker_that_finishes_anyway(make, cube): + gate = threading.Event() + started = threading.Event() + jobs = make(builder=fake_builder(gate, honor_cancel=False, started=started, body=b'{"late":true}')) + a = jobs.start(cube, rid()) + started.wait(5) + jobs.cancel(a["job_id"]) + gate.set() # the worker now returns a result + wait_for(lambda: not jobs.worker_alive()) + assert jobs.status(a["job_id"])["state"] == "cancelled" # first terminal wins; the late result is discarded + assert jobs.result(a["job_id"]) == (409, {"error": "CANCELLED", "message": "That scene was cancelled."}) + assert jobs._jobs[a["job_id"]].result is None + + +def test_cancel_after_completion_does_not_change_the_result(make, cube): + jobs = make(builder=fake_builder()) + a = jobs.start(cube, rid()) + wait_state(jobs, a["job_id"], "succeeded") + jobs.cancel(a["job_id"]) + assert jobs.result(a["job_id"]) == (200, b'{"ok":true}') + + +def test_cancel_is_observed_within_two_seconds_on_a_real_scene(make, tmp_path): + big = fx.big_grid_3mf(tmp_path / "big.3mf", 90_000) + jobs = make() + job = jobs.start(str(big), rid()) + wait_for(lambda: jobs.status(job["job_id"])["state"] == "running" and jobs.status(job["job_id"])["stage"] in ("parsing", "encoding")) + t = time.monotonic() + jobs.cancel(job["job_id"]) + wait_for(lambda: not jobs.worker_alive(), timeout=5) + assert time.monotonic() - t <= 2.0 + assert jobs.status(job["job_id"])["state"] == "cancelled" + assert snapshot_files(jobs) == [] + + +# ------------------------------------------------------------------------------ deadline and wedge + +def test_deadline_is_a_timeout_and_the_clock_starts_when_running(make, cube): + gate = threading.Event() + started = threading.Event() + jobs = make(deadline=0.3, builder=fake_builder(gate, started=started)) + a = jobs.start(cube, rid()) + started.wait(5) + b = jobs.start(cube, rid()) # queued: its clock has not started + assert jobs.status(a["job_id"])["state"] == "cancelled" + wait_state(jobs, b["job_id"], "running") + time.sleep(0.05) + assert jobs.status(b["job_id"])["state"] == "running" + status = wait_state(jobs, b["job_id"], "failed", timeout=3) + assert status["error"]["code"] == "TIMEOUT" + assert jobs.result(b["job_id"])[0] == 422 + wait_for(lambda: not jobs.worker_alive()) + + +def test_worker_that_ignores_cancel_is_wedged_fails_closed_and_recovers(make, cube): + gate = threading.Event() + started = threading.Event() + jobs = make(wedge_grace=0.25, builder=fake_builder(gate, honor_cancel=False, started=started)) + a = jobs.start(cube, rid()) + started.wait(5) + jobs.cancel(a["job_id"]) + assert not jobs.wedged() + b = jobs.start(cube, rid()) # within the grace period: queued behind the cancelled job + assert b["state"] == "queued" + time.sleep(0.4) + assert jobs.wedged() + with pytest.raises(sj.JobError) as e: + jobs.start(cube, rid()) + assert (e.value.http, e.value.code) == (503, "WORKER_WEDGED") + validate("error_response", e.value.body()) + gate.set() # the stuck call finally returns + wait_state(jobs, b["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + assert not jobs.wedged() + assert jobs.start(cube, rid())["state"] in ("queued", "running") # recovered + + +def test_deadline_wedge_reports_timeout_and_rejects_new_starts(make, cube): + gate = threading.Event() + started = threading.Event() + jobs = make(deadline=0.15, wedge_grace=0.15, builder=fake_builder(gate, honor_cancel=False, started=started)) + a = jobs.start(cube, rid()) + started.wait(5) + wait_for(lambda: jobs.status(a["job_id"])["state"] == "failed", timeout=3) + assert jobs.status(a["job_id"])["error"]["code"] == "TIMEOUT" + assert jobs.wedged() + with pytest.raises(sj.JobError) as e: + jobs.start(cube, rid()) + assert e.value.code == "WORKER_WEDGED" + gate.set() + wait_for(lambda: not jobs.worker_alive()) + assert not jobs.wedged() and jobs.status(a["job_id"])["state"] == "failed" + + +# ------------------------------------------------------------------------------ retention + +def test_ttl_and_terminal_cap_with_an_injected_clock(make, cube): + now = [1000.0] + jobs = make(clock=lambda: now[0], builder=fake_builder(), ttl=120.0, max_terminal=8) + ids = [] + for _ in range(12): + j = jobs.start(cube, rid()) + wait_state(jobs, j["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + now[0] += 1.0 + ids.append(j["job_id"]) + assert jobs.retained() == 8 + with pytest.raises(sj.JobError) as e: + jobs.status(ids[0]) # oldest evicted + assert (e.value.http, e.value.code) == (404, "EXPIRED") + assert jobs.status(ids[-1])["state"] == "succeeded" + now[0] += 200.0 + with pytest.raises(sj.JobError): + jobs.status(ids[-1]) # TTL: 120 s after it became terminal + assert jobs.retained() == 0 + + +def test_failed_and_cancelled_jobs_are_retained_like_succeeded_ones(make, cube, tmp_path): + now = [0.0] + gate = threading.Event() + jobs = make(clock=lambda: now[0], builder=fake_builder(gate)) + a = jobs.start(cube, rid()) + wait_state(jobs, a["job_id"], "running") + jobs.cancel(a["job_id"]) + wait_for(lambda: not jobs.worker_alive()) + now[0] += 119.0 + assert jobs.status(a["job_id"])["state"] == "cancelled" + now[0] += 2.0 + with pytest.raises(sj.JobError): + jobs.status(a["job_id"]) + + +def test_eviction_removes_a_snapshot_the_worker_could_not_delete(make, cube, monkeypatch): + now = [0.0] + jobs = make(clock=lambda: now[0], builder=fake_builder(), ttl=10.0) + real_unlink = os.unlink + state = {"fail": True} + + def flaky(path, *a, **k): + if state["fail"] and str(path).endswith(".3mf"): + state["fail"] = False + raise PermissionError("sharing violation") + return real_unlink(path, *a, **k) + monkeypatch.setattr(sj.os, "unlink", flaky) + j = jobs.start(cube, rid()) + wait_state(jobs, j["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + assert len(snapshot_files(jobs)) == 1 # the worker's unlink failed + now[0] += 11.0 + with pytest.raises(sj.JobError): + jobs.status(j["job_id"]) + assert snapshot_files(jobs) == [] # eviction retried it (missing-file tolerant) + + +# ------------------------------------------------------------------------------ snapshot limits + +def test_snapshot_ceiling_and_disk_preflight(make, cube, monkeypatch): + jobs = make() + monkeypatch.setattr(L, "SNAPSHOT_MAX_BYTES", 1000) + a = jobs.start(cube, rid()) + assert wait_state(jobs, a["job_id"], "failed")["error"]["code"] == "LIMIT_EXCEEDED" + wait_for(lambda: not jobs.worker_alive()) + assert snapshot_files(jobs) == [] + monkeypatch.setattr(L, "SNAPSHOT_MAX_BYTES", 128 * 1024 * 1024) + monkeypatch.setattr(sj.shutil, "disk_usage", lambda p: type("U", (), {"free": 10})()) + b = jobs.start(cube, rid()) + status = wait_state(jobs, b["job_id"], "failed") + assert status["error"]["code"] == "INTERNAL" and "disk" in status["error"]["message"] + wait_for(lambda: not jobs.worker_alive()) + assert snapshot_files(jobs) == [] + + +def test_response_cap_surfaces_as_limit_exceeded(make, cube, monkeypatch): + monkeypatch.setattr(L, "MAX_RESPONSE_BYTES", 500) + jobs = make() + j = jobs.start(cube, rid()) + assert wait_state(jobs, j["job_id"], "failed")["error"]["code"] == "LIMIT_EXCEEDED" + assert jobs.result(j["job_id"])[0] == 422 + + +# ------------------------------------------------------------------------------ startup sweep + +def test_sweep_removes_only_dead_and_old_directories_and_never_a_live_ones(tmp_path): + root = tmp_path / "scene-tmp" + root.mkdir() + dead = subprocess.Popen([sys.executable, "-c", "pass"]) + dead.wait() + live = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + old = L.SNAPSHOT_DIR_MIN_AGE_SECONDS + 100 + try: + def mk(name, age_s=0): + d = root / name + d.mkdir() + (d / "x.3mf").write_bytes(b"x") + if age_s: + t = time.time() - age_s + os.utime(d, (t, t)) + return d + d_dead_old = mk(f"{dead.pid}-aaaa1111", old) + d_dead_young = mk(f"{dead.pid}-aaaa2222") + d_live = mk(f"{live.pid}-bbbb2222") + d_live_ancient = mk(f"{live.pid}-cccc3333", age_s=10 * 24 * 3600) # a live process's folder, however old + d_own = mk(f"{os.getpid()}-dddd4444", age_s=10 * 24 * 3600) + d_junk = mk("not-a-snapshot-dir", old) + removed = sj.sweep_stale(str(root)) + assert removed == [d_dead_old.name] + for survivor in (d_dead_young, d_live, d_live_ancient, d_own, d_junk): + assert survivor.exists(), survivor.name + finally: + live.kill() + live.wait() + assert sj.sweep_stale(str(tmp_path / "does-not-exist")) == [] + + +def test_sweep_tolerates_a_sharing_violation(tmp_path, monkeypatch): + root = tmp_path / "scene-tmp" + root.mkdir() + dead = subprocess.Popen([sys.executable, "-c", "pass"]) + dead.wait() + d = root / f"{dead.pid}-aaaa1111" + d.mkdir() + t = time.time() - L.SNAPSHOT_DIR_MIN_AGE_SECONDS - 10 + os.utime(d, (t, t)) + real = sj.shutil.rmtree + monkeypatch.setattr(sj.shutil, "rmtree", lambda *a, **k: (_ for _ in ()).throw(PermissionError("in use"))) + assert sj.sweep_stale(str(root)) == [] and d.exists() # no crash; left for the next start + monkeypatch.setattr(sj.shutil, "rmtree", real) + assert sj.sweep_stale(str(root)) == [d.name] + + +def test_posix_liveness_branch_by_monkeypatch(monkeypatch): + monkeypatch.setattr(sj.sys, "platform", "linux") + calls = [] + + def fake_kill(pid, sig): + calls.append((pid, sig)) + if pid == 111: + raise ProcessLookupError() + if pid == 222: + raise PermissionError() + monkeypatch.setattr(sj.os, "kill", fake_kill) + assert sj.pid_alive(111) is False # no such process + assert sj.pid_alive(222) is True # exists, not ours: alive + assert sj.pid_alive(333) is True + assert sj.pid_alive(os.getpid()) is True and sj.pid_alive(0) is False + assert (111, 0) in calls + + +def test_each_registry_uses_its_own_pid_nonce_directory(make, cube): + a, b = make(builder=fake_builder()), make(builder=fake_builder()) + for jobs in (a, b): + wait_state(jobs, jobs.start(cube, rid())["job_id"], "succeeded") + assert a.snapshot_dir() != b.snapshot_dir() + assert os.path.basename(a.snapshot_dir()).startswith(f"{os.getpid()}-") + + +# ------------------------------------------------------------------------------ leak gate + +def test_fifty_start_cancel_cycles_leave_nothing_behind(make, tmp_path): + big = str(fx.big_grid_3mf(tmp_path / "g.3mf", 20_000)) + small = str(fx.plain_cube_3mf(tmp_path / "s.3mf")) + baseline = threading.active_count() + jobs = make() + wedged_ever = False + for i in range(50): + job = jobs.start(big if i % 2 else small, rid()) + if i % 3 == 0: + wait_for(lambda: jobs.status(job["job_id"])["state"] != "queued") + jobs.cancel(job["job_id"]) + wedged_ever = wedged_ever or jobs.wedged() + assert jobs.retained() <= L.MAX_TERMINAL_JOBS + 2 # + the running/queued pair + jobs.close() + wait_for(lambda: not jobs.worker_alive(), timeout=10) + assert jobs.retained() <= L.MAX_TERMINAL_JOBS # retained jobs never exceed the cap once idle + assert all(j.state in sj.TERMINAL for j in jobs._jobs.values()) + assert snapshot_files(jobs) == [] # temp snapshots = 0 + assert all(j.snapshot is None for j in jobs._jobs.values()) + assert threading.active_count() == baseline # thread count back to baseline + assert not wedged_ever and not jobs.wedged() # WORKER_WEDGED never occurred + + +# ------------------------------------------------------------------------------ repair round 1 + +import contextlib +import errno + + +def poke(jobs): + """Any API call runs housekeeping; an unknown id is the cheapest one.""" + with contextlib.suppress(sj.JobError): + jobs.status("0" * 32) + + +def test_a10_a_retained_job_evicted_while_hashing_is_never_handed_back(make, cube, monkeypatch): + now = [0.0] + jobs = make(clock=lambda: now[0], builder=fake_builder(), ttl=120.0) + r = rid() + a = jobs.start(cube, r) + status = wait_state(jobs, a["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + + def hash_while_time_passes(path): + now[0] += 500.0 # the TTL expires during the hash + return status["revision"] + monkeypatch.setattr(jobs, "_current_hash", hash_while_time_passes) + b = jobs.start(cube, r) + assert b["job_id"] != a["job_id"] + assert jobs.status(b["job_id"])["request_id"] == r # the returned job is registered + with pytest.raises(sj.JobError): + jobs.status(a["job_id"]) + + +def test_a11_a_snapshot_that_resists_deletion_is_retried_with_backoff(make, cube, monkeypatch): + now = [0.0] + jobs = make(clock=lambda: now[0], builder=fake_builder(), ttl=10.0) + real = os.unlink + fails = {"n": 0} + + def flaky(path, *a, **k): + if str(path).endswith(".3mf") and fails["n"] < 3: + fails["n"] += 1 + raise PermissionError("sharing violation") + return real(path, *a, **k) + monkeypatch.setattr(sj.os, "unlink", flaky) + j = jobs.start(cube, rid()) + wait_state(jobs, j["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + assert len(snapshot_files(jobs)) == 1 # failure 1: the worker's own unlink + now[0] += 11.0 + poke(jobs) # TTL eviction: failure 2, path goes on the retry list + with pytest.raises(sj.JobError): + jobs.status(j["job_id"]) + assert len(snapshot_files(jobs)) == 1 and len(jobs._pending) == 1 + now[0] += 2.5 + poke(jobs) # first retry: failure 3, longer back-off + assert len(snapshot_files(jobs)) == 1 and jobs._pending[0][1] >= 2 + now[0] += 1.0 + poke(jobs) # not due yet: no attempt + assert fails["n"] == 3 + now[0] += 10.0 + poke(jobs) # now it succeeds and the path is forgotten + assert snapshot_files(jobs) == [] and jobs._pending == [] + + +def test_d2_the_snapshot_directory_is_recreated_for_every_job(make, cube): + jobs = make(builder=fake_builder()) + first = jobs.start(cube, rid()) + wait_state(jobs, first["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + import shutil + shutil.rmtree(jobs.snapshot_dir()) # something removed the per-process folder + second = jobs.start(cube, rid()) + assert wait_state(jobs, second["job_id"], "succeeded")["error"] is None + + +def test_d2_only_a_real_enospc_is_reported_as_a_disk_space_problem(make, cube, monkeypatch): + jobs = make(builder=fake_builder()) + real_open = open + + def failing(code): + def fake(path, mode="r", *a, **k): + if "w" in mode and str(path).endswith(".3mf"): + raise OSError(code, "boom") + return real_open(path, mode, *a, **k) + return fake + monkeypatch.setattr(sj, "open", failing(errno.EACCES), raising=False) + a = jobs.start(cube, rid()) + msg = wait_state(jobs, a["job_id"], "failed")["error"]["message"] + assert "disk space" not in msg and "temporary copy" in msg + wait_for(lambda: not jobs.worker_alive()) + monkeypatch.setattr(sj, "open", failing(errno.ENOSPC), raising=False) + b = jobs.start(cube, rid()) + assert "disk space" in wait_state(jobs, b["job_id"], "failed")["error"]["message"] + + +def test_d6a_a_cancelled_job_whose_worker_is_still_running_is_not_evicted(make, cube): + now = [0.0] + gate, started = threading.Event(), threading.Event() + jobs = make(clock=lambda: now[0], ttl=10.0, max_terminal=0, builder=fake_builder(gate, honor_cancel=False, started=started)) + a = jobs.start(cube, rid()) + started.wait(5) + jobs.cancel(a["job_id"]) + now[0] += 1000.0 + poke(jobs) + assert jobs.status(a["job_id"])["state"] == "cancelled" # worker not done: not evictable, whatever the TTL and cap + gate.set() + wait_for(lambda: not jobs.worker_alive()) + with pytest.raises(sj.JobError) as e: + jobs.status(a["job_id"]) + assert e.value.code == "EXPIRED" + + +def test_d6b_the_deadline_starts_when_the_job_runs_not_when_it_was_created(make, cube): + gate, started = threading.Event(), threading.Event() + calls = [] + + def builder(path, revision, ctl): + calls.append(1) + if len(calls) == 1: + started.set() + gate.wait(5) # the first job holds the worker, ignoring cancel + return b"{}" + ctl.check() + return b'{"second":true}' + jobs = make(deadline=0.4, wedge_grace=30.0, builder=builder) + a = jobs.start(cube, rid()) + started.wait(5) + b = jobs.start(cube, rid()) # queued behind a + time.sleep(0.7) # longer than the deadline, all of it spent queued + gate.set() + assert wait_state(jobs, b["job_id"], "succeeded", "failed")["state"] == "succeeded" + assert jobs.status(a["job_id"])["state"] == "cancelled" + + +def test_d6e_a_size_change_without_an_mtime_change_is_source_changed(make, cube, monkeypatch): + monkeypatch.setattr(L, "SNAPSHOT_CHUNK_BYTES", 256) + st = os.stat(cube) + calls = {"n": 0} + real = sj._Control.progress + + def tamper(self, stage, completed, total): + calls["n"] += 1 + if calls["n"] == 2 and stage == "reading": + with open(cube, "ab") as fh: + fh.write(b"grown") + os.utime(cube, ns=(st.st_atime_ns, st.st_mtime_ns)) # put the mtime back: only the size betrays it + real(self, stage, completed, total) + monkeypatch.setattr(sj._Control, "progress", tamper) + jobs = make() + job = jobs.start(cube, rid()) + assert wait_state(jobs, job["job_id"], "failed")["error"]["code"] == "SOURCE_CHANGED" + wait_for(lambda: not jobs.worker_alive()) + assert snapshot_files(jobs) == [] + + +def test_close_removes_the_per_process_directory_best_effort(make, cube): + jobs = make(builder=fake_builder()) + j = jobs.start(cube, rid()) + wait_state(jobs, j["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + d = jobs.snapshot_dir() + assert os.path.isdir(d) + jobs.close() + assert not os.path.exists(d) diff --git a/backend/tests/test_scene_ordering.py b/backend/tests/test_scene_ordering.py new file mode 100644 index 00000000..eb35d30d --- /dev/null +++ b/backend/tests/test_scene_ordering.py @@ -0,0 +1,876 @@ +"""Session-based start ordering at the engine boundary. + +Invariants (each test name carries its tag): + I1 Delayed starts cannot become valid through bookkeeping eviction. + I2 Cancellation cannot be forgotten while the corresponding start remains admissible. + I3 Idempotency is scoped to (session, request) and ownership is checked consistently on every path. + I4 Capacity exhaustion or session expiry fails EXPLICITLY; nothing old is ever silently revived. + +No sleep decides any outcome: the abandoned start A is held at the pre-registration seam +(`SceneJobs._check_source`, which runs before the registry lock) with a threading.Event, and time is a fake clock. +""" +import os +import threading + +import pytest + +from snapstudio_api import scene_jobs as sj +from snapstudio_core import scene_limits as L +from tests import scene_fixtures as fx +from tests.test_scene_api import call, server # noqa: F401 (the loopback server fixture) +from tests.test_scene_contract import sub_validator +from tests.test_scene_jobs import fake_builder, wait_for + + +def _owner(jobs, job_id): + job = jobs._jobs.get(job_id) + return job.client_id if job is not None else None + + +def st(jobs, job_id): + """status() presenting the job's own session credential (looked up here, in the TEST, not by the engine).""" + return jobs.status(job_id, _owner(jobs, job_id)) + + +def cn(jobs, job_id): + return jobs.cancel(job_id, _owner(jobs, job_id)) + + +def wait_state(jobs, job_id, *states, timeout=10.0): + return wait_for(lambda: (lambda r: r if r["state"] in states else None)(st(jobs, job_id)), timeout) + + +@pytest.fixture +def cube(tmp_path): + return str(fx.plain_cube_3mf(tmp_path / "cube.3mf")) + + +@pytest.fixture +def make(tmp_path): + made = [] + + def factory(**kw): + jobs = sj.SceneJobs(snapshot_root=str(tmp_path / "snap"), builder=kw.pop("builder", fake_builder()), **kw) + made.append(jobs) + return jobs + yield factory + for jobs in made: + jobs.close() + + +def sess(jobs) -> str: + return jobs.open_session()["client_id"] + + +class HeldStart: + """Run jobs.start(...) on a thread that blocks at the pre-registration seam until released.""" + + def __init__(self, jobs, *args, **kwargs): + self.entered, self.release = threading.Event(), threading.Event() + self.result = self.error = None + real = jobs._check_source + + def seam(path): + real(path) + self.entered.set() + assert self.release.wait(10) + jobs._check_source = seam + self.thread = threading.Thread(target=self._run, args=(jobs, args, kwargs), daemon=True) + self.thread.start() + assert self.entered.wait(10) + jobs._check_source = real # later starts are not held + + def _run(self, jobs, args, kwargs): + try: + self.result = jobs.start(*args, **kwargs) + except sj.JobError as exc: + self.error = exc + + def finish(self): + self.release.set() + self.thread.join(10) + assert not self.thread.is_alive() + return self + + +def refused(jobs, code, *args, http=None): + with pytest.raises(sj.JobError) as e: + jobs.start(*args) + assert e.value.code == code, (e.value.code, e.value.message) + if http: + assert e.value.http == http + assert not list(sub_validator("error_response").iter_errors(e.value.body())) + return e.value + + +# ------------------------------------------------------------------ the session + +def test_i4_open_session_shape_and_expiry_ttl(make): + jobs = make() + out = jobs.open_session() + assert set(out) == {"client_id", "ttl_s"} and len(out["client_id"]) == 24 and out["ttl_s"] == L.SESSION_TTL_SECONDS + assert not list(sub_validator("session").iter_errors(out)) + assert sess(jobs) != out["client_id"] + + +def test_the_per_session_state_is_constant_size(make, cube): + jobs = make() + s = sess(jobs) + for seq in range(1, 6): + jobs.start(cube, f"req-{seq:08d}", s, seq) + jobs.cancel_request(s, f"req-x-{seq:06d}", seq) + assert set(vars(jobs._sessions[s])) == {"client_id", "last_touch", "watermark", "dead_through", "current"} + assert not hasattr(jobs, "_tombstones") and not hasattr(jobs, "_clients") + + +# ------------------------------------------------------------------ I1: a delayed start never becomes valid + +@pytest.mark.parametrize("wanted", ["running", "completed", "cancelled", "evicted"]) +def test_i1_a_delayed_abandoned_start_is_refused_whatever_became_of_the_wanted_one(make, cube, wanted): + now = [0.0] + gate, started = threading.Event(), threading.Event() + builder = fake_builder(gate, started=started) if wanted == "running" else fake_builder() + jobs = make(clock=lambda: now[0], builder=builder, ttl=10.0) + s = sess(jobs) + a = HeldStart(jobs, cube, "req-A-000001", s, 1) # abandoned, delayed in flight + b = jobs.start(cube, "req-B-000002", s, 2) # the wanted start gets in first + if wanted == "running": + assert started.wait(10) and st(jobs, b["job_id"])["state"] == "running" + else: + wait_state(jobs, b["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + if wanted == "cancelled": + jobs.cancel_request(s, "req-B-000002", 2) + if wanted == "evicted": + now[0] += 60.0 + with pytest.raises(sj.JobError): + st(jobs, b["job_id"]) # B's job is gone from the registry + a.finish() + assert a.error is not None and a.error.code in ("STALE_START", "CANCELLED_BEFORE_START") + if wanted == "cancelled": + assert a.error.code == "CANCELLED_BEFORE_START" + elif wanted == "evicted": + assert a.error.code == "STALE_START" # eviction did not make the late start valid + if wanted == "running": + assert st(jobs, b["job_id"])["state"] == "running" # B was neither cancelled nor replaced + gate.set() + wait_state(jobs, b["job_id"], "succeeded") + elif wanted == "completed": + assert st(jobs, b["job_id"])["state"] == "succeeded" + + +def test_i1_astra_repro_a_repeat_of_b_at_a_higher_seq_then_the_delayed_a(make, cube): + jobs = make() + s = sess(jobs) + b = jobs.start(cube, "req-B-000001", s, 1) + wait_state(jobs, b["job_id"], "succeeded") + a = HeldStart(jobs, cube, "req-A-000002", s, 2) + err = refused(jobs, "INVALID_REQUEST", cube, "req-B-000001", s, 3, http=400) # B repeated as seq 3 + assert err.extra["reason"] == "request id reused" + a.finish() + assert a.error.code == "STALE_START" # the watermark had already moved to 3 + assert st(jobs, b["job_id"])["state"] == "succeeded" + refused(jobs, "STALE_START", cube, "req-B-000001", s, 3) # the refused attempt is not an idempotent repeat + ok = jobs.start(cube, "req-B-000004", s, 4) # recovery: fresh request_id, higher seq + assert wait_state(jobs, ok["job_id"], "succeeded")["state"] == "succeeded" + + +def test_i1_a_stale_start_cannot_replace_a_running_wanted_start(make, cube): + gate, started = threading.Event(), threading.Event() + jobs = make(builder=fake_builder(gate, started=started)) + s = sess(jobs) + a = HeldStart(jobs, cube, "req-A-000001", s, 1) + b = jobs.start(cube, "req-B-000002", s, 2) + started.wait(10) + a.finish() + assert a.error.code == "STALE_START" and st(jobs, b["job_id"])["state"] == "running" + gate.set() + + +def test_i1_an_exact_retry_of_an_evicted_start_is_stale_and_replaces_nothing(make, cube): + now = [0.0] + gate, started = threading.Event(), threading.Event() + calls = [] + + def builder(path, revision, ctl): + calls.append(1) + if len(calls) == 1: + return b"{}" + started.set() + while not gate.wait(0.005): + ctl.check() + return b"{}" + jobs = make(clock=lambda: now[0], ttl=10.0, builder=builder) + s, t = sess(jobs), sess(jobs) + first = jobs.start(cube, "req-E-000001", s, 1) + wait_state(jobs, first["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + other = jobs.start(cube, "req-O-000001", t, 1) # someone else's job is running + assert started.wait(10) + now[0] += 60.0 + with pytest.raises(sj.JobError): + st(jobs, first["job_id"]) + err = refused(jobs, "STALE_START", cube, "req-E-000001", s, 1, http=409) + assert st(jobs, other["job_id"])["state"] == "running" # and it did not replace the running job + gate.set() + wait_state(jobs, other["job_id"], "succeeded") + assert err + + +def test_i1_idempotent_repeat_while_registered_returns_the_same_job(make, cube): + jobs = make() + s = sess(jobs) + b = jobs.start(cube, "req-B-000002", s, 2) + again = jobs.start(cube, "req-B-000002", s, 2) + assert again["job_id"] == b["job_id"] and again["replaced_job_id"] is None + + +def test_i1_equal_seq_with_another_request_or_another_path_is_invalid(make, cube, tmp_path): + jobs = make() + s = sess(jobs) + jobs.start(cube, "req-B-000002", s, 2) + assert refused(jobs, "INVALID_REQUEST", cube, "req-X-000003", s, 2, http=400).extra["reason"] == "seq reused for another request" + other = str(fx.plain_cube_3mf(tmp_path / "other.3mf", size=11)) + refused(jobs, "INVALID_REQUEST", other, "req-B-000002", s, 2, http=400) + + +def test_i1_a_higher_seq_never_silently_returns_an_old_job(make, cube): + jobs = make() + s = sess(jobs) + j = jobs.start(cube, "req-R-000001", s, 1) + refused(jobs, "INVALID_REQUEST", cube, "req-R-000001", s, 2, http=400) + assert st(jobs, j["job_id"])["request_id"] == "req-R-000001" + + +def test_i1_stale_and_cancel_are_decided_before_source_changed(make, cube, monkeypatch): + for during, expected in (("newer", "STALE_START"), ("cancel", "CANCELLED_BEFORE_START"), ("expiry", "SESSION_EXPIRED")): + now = [0.0] + jobs = make(clock=lambda: now[0], session_ttl=100.0, ttl=10**9, max_terminal=100) + s = sess(jobs) + a = jobs.start(cube, "req-A-000001", s, 1) + wait_state(jobs, a["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + inside, release = threading.Event(), threading.Event() + + def hashing(path): # the file HAS changed, yet the answer is decided first + inside.set() + assert release.wait(10) + return "f" * 64 + monkeypatch.setattr(jobs, "_current_hash", hashing) + out = {} + + def repeat(): + try: + out["r"] = jobs.start(cube, "req-A-000001", s, 1) + except sj.JobError as exc: + out["e"] = exc + t = threading.Thread(target=repeat, daemon=True) + t.start() + assert inside.wait(10) + if during == "newer": + jobs.start(cube, "req-N-000002", s, 2) + elif during == "cancel": + jobs.cancel_request(s, "req-A-000001", 1) + else: + now[0] += 500.0 + jobs._jobs.pop(a["job_id"]) + jobs._by_request.pop((s, "req-A-000001")) # nothing registered: the session may now expire + jobs._housekeeping() + release.set() + t.join(10) + assert out["e"].code == expected, (during, out) + + +def test_without_any_newer_start_a_changed_file_is_still_source_changed(make, cube): + jobs = make() + s = sess(jobs) + a = jobs.start(cube, "req-A-000001", s, 1) + wait_state(jobs, a["job_id"], "succeeded") + with open(cube, "ab") as fh: + fh.write(b"\0") + refused(jobs, "SOURCE_CHANGED", cube, "req-A-000001", s, 1) + + +# ------------------------------------------------------------------ I2: cancellation is not forgotten + +def test_i2_a_cancel_that_arrives_before_the_start_refuses_it_and_every_lower_seq(make, cube): + jobs = make() + s = sess(jobs) + out = jobs.cancel_request(s, "req-A-000001", 3) # the client moved on from attempts 1..3 + assert out["job_id"] is None and out["state"] == "cancelled" and out["error"]["code"] == "CANCELLED" + assert not list(sub_validator("job_status").iter_errors(out)) + for seq, rid_ in ((3, "req-A-000001"), (2, "req-late-0002"), (1, "req-late-0001")): + refused(jobs, "CANCELLED_BEFORE_START", cube, rid_, s, seq, http=409) + assert jobs.retained() == 0 # nothing was registered + fresh = jobs.start(cube, "req-A-000004", s, 4) # a fresh request_id with a higher seq works + assert wait_state(jobs, fresh["job_id"], "succeeded")["state"] == "succeeded" + + +def test_i2_a_cancelled_start_stays_refused_after_its_job_is_evicted_and_after_unrelated_activity(make, cube): + now = [0.0] + jobs = make(clock=lambda: now[0], ttl=1.0) + s = sess(jobs) + a = jobs.start(cube, "req-A-000001", s, 1) + jobs.cancel_request(s, "req-A-000001", 1) + wait_for(lambda: not jobs.worker_alive()) + now[0] += 100.0 + with pytest.raises(sj.JobError): + st(jobs, a["job_id"]) # the cancelled job is long gone + for i in range(200): # a great deal of unrelated cancel traffic + jobs.cancel_request(s, f"req-noise-{i:05d}", 1) + refused(jobs, "CANCELLED_BEFORE_START", cube, "req-A-000001", s, 1) + assert len(vars(jobs._sessions[s])) == 5 # and the memory is still O(1) + + +def test_i2_cancelling_a_never_touches_b(make, cube): + gate, started = threading.Event(), threading.Event() + jobs = make(builder=fake_builder(gate, started=started)) + s = sess(jobs) + a = jobs.start(cube, "req-A-000001", s, 1) + started.wait(10) + b = jobs.start(cube, "req-B-000002", s, 2) # replaces A as today + assert st(jobs, a["job_id"])["state"] == "cancelled" + jobs.cancel_request(s, "req-A-000001", 1) # cancelling A... + assert st(jobs, b["job_id"])["state"] in ("queued", "running") # ...never touches B (exact request match) + jobs.cancel_request(s, "req-C-nonexistent", 1) + assert st(jobs, b["job_id"])["state"] in ("queued", "running") + out = jobs.cancel_request(s, "req-B-000002", 2) + assert out["job_id"] == b["job_id"] and out["state"] == "cancelled" + gate.set() + + +def test_i2_cancel_by_job_id_is_unchanged(make, cube): + jobs = make() + s = sess(jobs) + j = jobs.start(cube, "req-J-000001", s, 1) + wait_state(jobs, j["job_id"], "succeeded") + assert cn(jobs, j["job_id"])["state"] == "succeeded" + with pytest.raises(sj.JobError) as e: + cn(jobs, "0" * 32) + assert e.value.code == "EXPIRED" + + +# ------------------------------------------------------------------ I3: (session, request) scoping and ownership + +def test_i3_the_same_request_id_in_two_sessions_gives_each_its_own_job_and_cancel(make, cube): + gate, started = threading.Event(), threading.Event() + jobs = make(builder=fake_builder(gate, started=started)) + s, t = sess(jobs), sess(jobs) + a = jobs.start(cube, "req-same-0001", s, 1) + started.wait(10) + b = jobs.start(cube, "req-same-0001", t, 1) # same request_id and path, another session + assert a["job_id"] != b["job_id"] and b["replaced_job_id"] is None # H1: s's job id is not revealed to t + assert st(jobs, a["job_id"])["state"] == "cancelled" # (the newest start still replaces it, as before) + assert jobs.start(cube, "req-same-0001", t, 1)["job_id"] == b["job_id"] # idempotent inside its own scope + out = jobs.cancel_request(s, "req-same-0001", 1) + assert out["job_id"] == a["job_id"] + assert st(jobs, b["job_id"])["state"] in ("queued", "running") # t's job untouched: no leak across sessions + assert jobs.cancel_request(t, "req-same-0001", 1)["job_id"] == b["job_id"] + gate.set() + + +def test_i3_a_session_cannot_cancel_or_see_another_sessions_job_by_request(make, cube): + jobs = make() + s, t = sess(jobs), sess(jobs) + j = jobs.start(cube, "req-own-00001", s, 1) + wait_state(jobs, j["job_id"], "succeeded") + out = jobs.cancel_request(t, "req-own-00001", 1) + assert out["job_id"] is None + assert st(jobs, j["job_id"])["state"] == "succeeded" + assert jobs.start(cube, "req-own-00001", s, 1)["job_id"] == j["job_id"] + + +def test_i3_the_legacy_namespace_is_separate_and_unchanged(make, cube): + jobs = make() + s = sess(jobs) + legacy = jobs.start(cube, "req-ns-000001") + ordered = jobs.start(cube, "req-ns-000001", s, 1) + assert legacy["job_id"] != ordered["job_id"] + assert jobs.start(cube, "req-ns-000001")["job_id"] == legacy["job_id"] + + +def test_i3_many_sessions_start_concurrently_and_independently(make, cube): + jobs = make() + ids = [sess(jobs) for _ in range(8)] + barrier = threading.Barrier(len(ids)) + results, errors = {}, [] + + def go(i, cid): + barrier.wait(10) + try: + results[i] = jobs.start(cube, "req-same-0001", cid, 1) # every session reuses seq 1 AND the request_id + except sj.JobError as exc: + errors.append(exc) + threads = [threading.Thread(target=go, args=(i, c), daemon=True) for i, c in enumerate(ids)] + for t in threads: + t.start() + for t in threads: + t.join(10) + assert not errors and len(results) == len(ids) + assert len({r["job_id"] for r in results.values()}) == len(ids) + + +# ------------------------------------------------------------------ I4: expiry and capacity fail explicitly + +def test_i4_an_expired_session_refuses_start_and_cancel_and_a_late_delayed_start(make, cube): + now = [0.0] + jobs = make(clock=lambda: now[0], session_ttl=100.0) + s = sess(jobs) + late = HeldStart(jobs, cube, "req-A-000001", s, 1) # in flight when the session ends + now[0] += 500.0 + jobs.open_session() # any call prunes the idle-expired session + assert s not in jobs._sessions + late.finish() + assert late.error is not None and (late.error.http, late.error.code) == (409, "SESSION_EXPIRED") + assert s not in jobs._sessions and jobs.retained() == 0 # refused, never re-created, nothing registered + refused(jobs, "SESSION_EXPIRED", cube, "req-B-000002", s, 2) + with pytest.raises(sj.JobError) as e: + jobs.cancel_request(s, "req-B-000002", 2) + assert e.value.code == "SESSION_EXPIRED" + assert s not in jobs._sessions + + +@pytest.mark.parametrize("how", ["refused_start", "cancel", "status"]) +def test_i4_every_start_cancel_and_status_touches_the_session(make, cube, how): + """Each call keeps alive a session that would otherwise be idle-expired (the job it owned is already gone).""" + now = [0.0] + jobs = make(clock=lambda: now[0], session_ttl=100.0, ttl=100.0) + s = sess(jobs) + j = jobs.start(cube, "req-A-000002", s, 2) + wait_state(jobs, j["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + now[0] += 90.0 + if how == "status": + st(jobs, j["job_id"]) + elif how == "cancel": + jobs.cancel_request(s, "req-zzz-000001", 1) + else: + refused(jobs, "STALE_START", cube, "req-old-000001", s, 1) # a refused start still touches the session + now[0] += 90.0 # 180 s since creation, 90 s since the touch + jobs.open_session() # evicts the expired job (the session owned it until now) + jobs.open_session() # now the session is judged on its idle time alone + assert s in jobs._sessions + + +def test_i4_session_limit_is_explicit_when_nothing_is_prunable(make, cube): + jobs = make(max_sessions=3) + ids = [sess(jobs) for _ in range(3)] + with pytest.raises(sj.JobError) as e: + jobs.open_session() + assert (e.value.http, e.value.code) == (503, "SESSION_LIMIT") + assert not list(sub_validator("error_response").iter_errors(e.value.body())) + assert set(jobs._sessions) == set(ids) # a live session is never evicted to make room + + +def test_i4_an_idle_expired_session_without_a_job_is_pruned_to_make_room(make): + now = [0.0] + jobs = make(clock=lambda: now[0], max_sessions=2, session_ttl=100.0) + first, second = sess(jobs), sess(jobs) + now[0] += 50.0 + jobs._sessions[second].last_touch = now[0] # the second one stays active + now[0] += 60.0 # the first is idle 110 s (> ttl), the second 60 s + third = sess(jobs) + assert first not in jobs._sessions and second in jobs._sessions and third in jobs._sessions + + +def test_i4_a_session_that_owns_a_registered_job_is_never_pruned(make, cube): + now = [0.0] + jobs = make(clock=lambda: now[0], max_sessions=2, session_ttl=100.0, ttl=10**9, max_terminal=100) + s = sess(jobs) + j = jobs.start(cube, "req-A-000001", s, 1) + wait_state(jobs, j["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + other = sess(jobs) + now[0] += 10**6 # idle far beyond the ttl + with pytest.raises(sj.JobError) as e: + jobs.open_session() # `other` is prunable, `s` is not -> room for one + jobs.open_session() + assert e.value.code == "SESSION_LIMIT" + assert s in jobs._sessions and other not in jobs._sessions + + +def test_i4_an_engine_restart_drops_every_session_explicitly(make, cube): + old = make() + s = sess(old) + restarted = make() # a new SceneJobs is a new engine process + refused(restarted, "SESSION_EXPIRED", cube, "req-A-000001", s, 1, http=409) + with pytest.raises(sj.JobError) as e: + restarted.cancel_request(s, "req-A-000001", 1) + assert e.value.code == "SESSION_EXPIRED" + assert restarted.retained() == 0 and not restarted._sessions + + +def test_i4_an_unknown_session_id_is_not_created_by_a_start(make, cube): + jobs = make() + refused(jobs, "SESSION_EXPIRED", cube, "req-A-000001", "never-issued-session-id", 1) + assert not jobs._sessions + + +# ------------------------------------------------------------------ the legacy path + +def test_legacy_starts_without_the_fields_still_work(make, cube): + jobs = make() + a = jobs.start(cube, "req-L-000001") + wait_state(jobs, a["job_id"], "succeeded") + b = jobs.start(cube, "req-L-000002") + wait_state(jobs, b["job_id"], "succeeded") + s = sess(jobs) + c = jobs.start(cube, "req-O-000003", s, 7) + wait_state(jobs, c["job_id"], "succeeded") + d = jobs.start(cube, "req-L-000004") + assert wait_state(jobs, d["job_id"], "succeeded")["state"] == "succeeded" + + +# ------------------------------------------------------------------ validation + +@pytest.mark.parametrize("extra", [ + {"client_id": None, "seq": None}, {"client_id": "x" * 24, "seq": None}, {"client_id": None, "seq": 1}, + {"client_id": "x" * 24 + "\n", "seq": 1}, {"seq": None}, {"client_id": None}, {"seq": 1}, {"client_id": "x" * 24}, + {"client_id": "x" * 24, "seq": 0}, {"client_id": "x" * 24, "seq": -1}, {"client_id": "x" * 24, "seq": True}, + {"client_id": "x" * 24, "seq": 1.0}, {"client_id": "x" * 24, "seq": "1"}, {"client_id": "x" * 24, "seq": 2 ** 53}, + {"client_id": "short", "seq": 1}, {"client_id": 12345678, "seq": 1}, +]) +def test_validation_holes_are_closed_on_start(make, cube, extra): + jobs = make() + status, body = sj.handle("/scene/start", dict({"path": cube, "request_id": "req-V-000001"}, **extra), jobs) + assert (status, body["error"]) == (400, "INVALID_REQUEST"), (extra, status, body) + assert jobs.retained() == 0 and not jobs._sessions + + +def test_validation_holes_are_closed_on_cancel_and_ids(make, cube): + jobs = make() + s = sess(jobs) + bad = [ + {"client_id": s, "request_id": "req-V-000001"}, # seq missing + {"client_id": s, "request_id": "req-V-000001\n", "seq": 1}, {"client_id": s + "\n", "request_id": "req-V-000001", "seq": 1}, + {"client_id": s, "request_id": "req-V-000001", "seq": None}, {"client_id": None, "request_id": "req-V-000001", "seq": 1}, + {"client_id": s, "request_id": "req-V-000001", "seq": True}, {"client_id": s, "request_id": "req-V-000001", "seq": 1.5}, + {"job_id": "0" * 32, "client_id": s, "request_id": "req-V-000001", "seq": 1}, + {"request_id": "req-V-000001", "seq": 1}, + ] + for body in bad: + status, resp = sj.handle("/scene/cancel", body, jobs) + assert (status, resp["error"]) == (400, "INVALID_REQUEST"), (body, status, resp) + status, resp = sj.handle("/scene/result", {"job_id": "0" * 32, "expected_revision": "a" * 64 + "\n"}, jobs) + assert status == 400 + assert jobs._sessions[s].dead_through == 0 # no malformed cancel changed any state + + +def test_seq_upper_bound_is_two_to_the_53_minus_1(make, cube): + jobs = make() + s = sess(jobs) + status, _ = sj.handle("/scene/start", {"path": cube, "request_id": "req-S-000001", "client_id": s, "seq": 2 ** 53 - 1}, jobs) + assert status == 200 + + +# ------------------------------------------------------------------ the real HTTP route + +def test_http_session_start_stale_cancel_and_expiry_shapes(server, tmp_path): # noqa: F811 + port, token = server + path = str(fx.plain_cube_3mf(tmp_path / "h.3mf")) + code, opened = call(port, "/scene/session", {}, token) + assert code == 200 and set(opened) == {"client_id", "ttl_s"} and not list(sub_validator("session").iter_errors(opened)) + cid = opened["client_id"] + code, b = call(port, "/scene/start", {"path": path, "request_id": "req-B-http0002", "client_id": cid, "seq": 2}, token) + assert code == 200 and not list(sub_validator("job_start").iter_errors(b)) + code, body = call(port, "/scene/start", {"path": path, "request_id": "req-A-http0001", "client_id": cid, "seq": 1}, token) + assert (code, body["error"]) == (409, "STALE_START") and not list(sub_validator("error_response").iter_errors(body)) + code, status = call(port, "/scene/status", {"job_id": b["job_id"], "client_id": cid}, token) + assert code == 200 and status["state"] != "cancelled" + code, c = call(port, "/scene/cancel", {"client_id": cid, "request_id": "req-C-http0003", "seq": 3}, token) + assert code == 200 and c["job_id"] is None and c["state"] == "cancelled" and not list(sub_validator("job_status").iter_errors(c)) + code, body = call(port, "/scene/start", {"path": path, "request_id": "req-C-http0003", "client_id": cid, "seq": 3}, token) + assert (code, body["error"]) == (409, "CANCELLED_BEFORE_START") + code, body = call(port, "/scene/start", {"path": path, "request_id": "req-D-http0004", "client_id": "never-issued-session-id", "seq": 1}, token) + assert (code, body["error"]) == (409, "SESSION_EXPIRED") + code, body = call(port, "/scene/cancel", {"client_id": "never-issued-session-id", "request_id": "req-D-http0004", "seq": 1}, token) + assert (code, body["error"]) == (409, "SESSION_EXPIRED") + code, legacy = call(port, "/scene/start", {"path": path, "request_id": "req-L-http0005"}, token) + assert code == 200 + + +def test_http_session_limit_is_503(server): # noqa: F811 + port, token = server + codes = [call(port, "/scene/session", {}, token)[0] for _ in range(L.MAX_SESSIONS + 1)] + assert codes[:L.MAX_SESSIONS] == [200] * L.MAX_SESSIONS and codes[-1] == 503 + code, body = call(port, "/scene/session", {}, token) + assert (code, body["error"]) == (503, "SESSION_LIMIT") + + +def test_http_routes_reject_non_object_bodies_and_need_the_token(server): # noqa: F811 + port, token = server + for route in sj.ROUTES: + assert call(port, route, None, token)[0] == 400 + assert call(port, route, {}, None)[0] == 401 + + +# ================================================================== round: job-id ownership, post-admission failures + +def _unknown_body(jobs, call): + with pytest.raises(sj.JobError) as e: + call() + return (e.value.http, e.value.body()) + + +def test_h1_job_id_operations_are_session_owned_and_refused_exactly_like_unknown_ids(make, cube): + jobs = make() + a, b = sess(jobs), sess(jobs) + j = jobs.start(cube, "req-own-00001", a, 1) + wait_state(jobs, j["job_id"], "succeeded") + unknown = "0" * 32 + for call_for in (lambda jid, cid: jobs.status(jid, cid), lambda jid, cid: jobs.result(jid, None, cid), + lambda jid, cid: jobs.cancel(jid, cid)): + baseline = _unknown_body(jobs, lambda: call_for(unknown, b)) + assert baseline[0] == 404 and baseline[1]["error"] == "EXPIRED" + assert _unknown_body(jobs, lambda: call_for(j["job_id"], b)) == baseline # another session + assert _unknown_body(jobs, lambda: call_for(j["job_id"], None)) == baseline # no credential at all + assert _unknown_body(jobs, lambda: call_for(j["job_id"], "x" * 24)) == baseline # a never-issued id + assert jobs.status(j["job_id"], a)["state"] == "succeeded" # the owner still can + assert jobs.result(j["job_id"], None, a)[0] == 200 and jobs.cancel(j["job_id"], a)["state"] == "succeeded" + + +def test_h1_a_foreign_cancel_by_job_id_does_not_cancel_anything(make, cube): + gate, started = threading.Event(), threading.Event() + jobs = make(builder=fake_builder(gate, started=started)) + a, b = sess(jobs), sess(jobs) + j = jobs.start(cube, "req-own-00001", a, 1) + started.wait(10) + with pytest.raises(sj.JobError): + jobs.cancel(j["job_id"], b) + assert jobs.status(j["job_id"], a)["state"] == "running" + gate.set() + + +def test_h1_replaced_job_id_never_leaks_across_sessions(make, cube): + gate, started = threading.Event(), threading.Event() + jobs = make(builder=fake_builder(gate, honor_cancel=True, started=started)) + a, b = sess(jobs), sess(jobs) + ja = jobs.start(cube, "req-a-000001", a, 1) + started.wait(10) + jb = jobs.start(cube, "req-b-000001", b, 1) # another session replaces A's running job + assert jb["replaced_job_id"] is None # ...and learns nothing about it + assert jobs.status(ja["job_id"], a)["state"] == "cancelled" + jb2 = jobs.start(cube, "req-b-000002", b, 2) # the same session replacing its own job + assert jb2["replaced_job_id"] == jb["job_id"] + legacy = jobs.start(cube, "req-l-000001") # a legacy caller replacing a session job + assert legacy["replaced_job_id"] is None + legacy2 = jobs.start(cube, "req-l-000002") # legacy replacing legacy: as before + assert legacy2["replaced_job_id"] == legacy["job_id"] + sj_after_legacy = jobs.start(cube, "req-b-000003", b, 3) # a session replacing a legacy job + assert sj_after_legacy["replaced_job_id"] is None + gate.set() + + +def test_h1_legacy_jobs_keep_todays_behaviour_for_job_id_operations(make, cube): + jobs = make() + j = jobs.start(cube, "req-l-000001") + wait_state(jobs, j["job_id"], "succeeded") + assert jobs.status(j["job_id"])["state"] == "succeeded" + assert jobs.status(j["job_id"], sess(jobs))["state"] == "succeeded" # no owner: any caller, as today + assert jobs.result(j["job_id"])[0] == 200 and jobs.cancel(j["job_id"])["state"] == "succeeded" + + +def test_h1_http_routes_carry_the_credential(server, tmp_path): # noqa: F811 + port, token = server + path = str(fx.plain_cube_3mf(tmp_path / "h1.3mf")) + _, a = call(port, "/scene/session", {}, token) + _, b = call(port, "/scene/session", {}, token) + _, job = call(port, "/scene/start", {"path": path, "request_id": "req-h1-000001", "client_id": a["client_id"], "seq": 1}, token) + for route in ("/scene/status", "/scene/result", "/scene/cancel"): + missing = call(port, route, {"job_id": job["job_id"]}, token) + foreign = call(port, route, {"job_id": job["job_id"], "client_id": b["client_id"]}, token) + unknown = call(port, route, {"job_id": "0" * 32, "client_id": b["client_id"]}, token) + assert missing == foreign == unknown and missing[0] == 404 and missing[1]["error"] == "EXPIRED" + bad = call(port, route, {"job_id": job["job_id"], "client_id": None}, token) + assert bad[0] == 400 + code, ok = call(port, "/scene/status", {"job_id": job["job_id"], "client_id": a["client_id"]}, token) + assert code == 200 and ok["job_id"] == job["job_id"] + code, both = call(port, "/scene/cancel", {"job_id": job["job_id"], "client_id": a["client_id"], "request_id": "req-h1-000001"}, token) + assert code == 400 + + +def test_h1_job_id_cancel_and_result_touch_the_owner_session(make, cube): + for how in ("cancel_job", "result"): + now = [0.0] + jobs = make(clock=lambda: now[0], session_ttl=100.0, ttl=100.0) + s = sess(jobs) + j = jobs.start(cube, "req-A-000002", s, 2) + wait_state(jobs, j["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + now[0] += 90.0 + (jobs.cancel(j["job_id"], s) if how == "cancel_job" else jobs.result(j["job_id"], None, s)) + now[0] += 90.0 + jobs.open_session() + jobs.open_session() + assert s in jobs._sessions, how + + +def _wedge_then_recover(make, cube): + """A cancelled running job whose worker ignores the cancel, past the grace period: the engine is wedged.""" + now = [0.0] + gate, started = threading.Event(), threading.Event() + jobs = make(clock=lambda: now[0], wedge_grace=10.0, builder=fake_builder(gate, honor_cancel=False, started=started)) + s = sess(jobs) + late = HeldStart(jobs, cube, "req-late-0002", s, 2) # attempt 2 is delayed in flight + first = jobs.start(cube, "req-one-00001", s, 1) + assert started.wait(10) + jobs.cancel_request(s, "req-one-00001", 1) # seq 1 running AND cancelled... + now[0] += 100.0 + assert jobs.wedged() # ...and the worker is stuck past the grace + return jobs, s, gate, late, first + + +def test_h2_a_wedged_rejection_still_advances_the_watermark(make, cube): + jobs, s, gate, late, _ = _wedge_then_recover(make, cube) + refused(jobs, "WORKER_WEDGED", cube, "req-three-0003", s, 3, http=503) + gate.set() # the stuck call returns: the worker recovers + wait_for(lambda: not jobs.worker_alive()) + late.finish() # the delayed seq 2 finally arrives + assert late.error is not None and late.error.code == "STALE_START" + assert jobs.retained() == 1 # only the seq-1 job: seq 2 registered nothing + refused(jobs, "STALE_START", cube, "req-three-0003", s, 3) # an exact retry of the refused attempt is stale + ok = jobs.start(cube, "req-four-0004", s, 4) # the documented recovery: fresh request_id, higher seq + assert ok["state"] in ("queued", "running") + + +def test_h2_unsupported_format_and_missing_file_also_advance_the_watermark(make, cube, tmp_path): + jobs = make() + s = sess(jobs) + late = HeldStart(jobs, cube, "req-late-0002", s, 2) + text = tmp_path / "notes.txt" + text.write_text("x") + refused(jobs, "UNSUPPORTED_FORMAT", str(text), "req-three-0003", s, 3, http=422) + late.finish() + assert late.error.code == "STALE_START" + late2 = HeldStart(jobs, cube, "req-late-0004", s, 4) + refused(jobs, "INVALID_REQUEST", str(tmp_path / "missing.3mf"), "req-five-0005", s, 5, http=400) + late2.finish() + assert late2.error.code == "STALE_START" + refused(jobs, "STALE_START", str(text), "req-old-00001", s, 1) # staleness is reported first and moves nothing + assert jobs._sessions[s].watermark == 5 + assert jobs.retained() == 0 + + +# ------------------------------------------------------------------ Astra optionals + +def test_a_delayed_cancel_for_an_old_attempt_cannot_cancel_a_newer_job_that_reused_the_request_id(make, cube): + now = [0.0] + gate, started = threading.Event(), threading.Event() + calls = [] + + def builder(path, revision, ctl): + calls.append(1) + if len(calls) == 1: + return b"{}" + started.set() + while not gate.wait(0.005): + ctl.check() + return b"{}" + jobs = make(clock=lambda: now[0], ttl=10.0, builder=builder) + s = sess(jobs) + old = jobs.start(cube, "req-R-000001", s, 1) + wait_state(jobs, old["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + now[0] += 60.0 + with pytest.raises(sj.JobError): + st(jobs, old["job_id"]) # the old job was evicted + new = jobs.start(cube, "req-R-000001", s, 5) # the request id is reused at a higher seq + assert started.wait(10) and st(jobs, new["job_id"])["state"] == "running" + out = jobs.cancel_request(s, "req-R-000001", 1) # the delayed cancel for the OLD attempt + assert out["job_id"] is None + assert st(jobs, new["job_id"])["state"] == "running" # the newer job is untouched + assert jobs.cancel_request(s, "req-R-000001", 5)["job_id"] == new["job_id"] + gate.set() + + +def test_an_expired_session_whose_last_job_was_just_evicted_does_not_cause_session_limit(make, cube): + now = [0.0] + jobs = make(clock=lambda: now[0], max_sessions=1, session_ttl=50.0, ttl=50.0) + s = sess(jobs) + j = jobs.start(cube, "req-A-000001", s, 1) + wait_state(jobs, j["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + now[0] += 500.0 # the job AND the session expire together + fresh = jobs.open_session() # one call: evict the job, then prune the session + assert fresh["client_id"] != s and s not in jobs._sessions + + +# ================================================================== round: exact retries and exact-attempt cancels + +def test_b1_a_failed_precheck_on_an_exact_retry_keeps_the_original_job_reference(make, cube, tmp_path): + jobs = make() + s = sess(jobs) + b = jobs.start(cube, "req-B-000002", s, 2) + wait_state(jobs, b["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + hidden = cube + ".away" + os.replace(cube, hidden) # the file vanishes: the retry's precheck fails + refused(jobs, "INVALID_REQUEST", cube, "req-B-000002", s, 2, http=400) + assert jobs._sessions[s].current[3] == b["job_id"] # the retained job's reference was NOT cleared + os.replace(hidden, cube) + again = jobs.start(cube, "req-B-000002", s, 2) # a later exact retry still gets the job + assert again["job_id"] == b["job_id"] + + +def test_b1_concurrent_exact_retries_one_hashing_one_failing_its_precheck(make, cube): + jobs = make() + s = sess(jobs) + b = jobs.start(cube, "req-B-000002", s, 2) + status = wait_state(jobs, b["job_id"], "succeeded") + wait_for(lambda: not jobs.worker_alive()) + inside, release = threading.Event(), threading.Event() + real_hash = jobs._current_hash + + def slow(path): # retry R1 is mid-hash, outside the lock + inside.set() + assert release.wait(10) + return status["revision"] + jobs._current_hash = slow + out = {} + + def retry(): + try: + out["r"] = jobs.start(cube, "req-B-000002", s, 2) + except sj.JobError as exc: + out["e"] = exc + t = threading.Thread(target=retry, daemon=True) + t.start() + assert inside.wait(10) + hidden = cube + ".away" + os.replace(cube, hidden) + refused(jobs, "INVALID_REQUEST", cube, "req-B-000002", s, 2, http=400) # R2 fails its precheck meanwhile + os.replace(hidden, cube) + release.set() + t.join(10) + jobs._current_hash = real_hash + assert "e" not in out, out.get("e") and out["e"].code + assert out["r"]["job_id"] == b["job_id"] # R1 still gets the job that still exists + + +def test_b2_a_cancel_at_a_higher_seq_does_not_cancel_the_older_job_of_the_same_request(make, cube): + gate, started = threading.Event(), threading.Event() + jobs = make(builder=fake_builder(gate, started=started)) + s = sess(jobs) + five = jobs.start(cube, "req-R-000001", s, 5) + started.wait(10) + out = jobs.cancel_request(s, "req-R-000001", 6) # a cancel for seq 6, not for this attempt + assert out["job_id"] is None + assert st(jobs, five["job_id"])["state"] == "running" # the seq-5 job is untouched + assert jobs._sessions[s].dead_through == 6 # but seq 6 and everything below stays refused + refused(jobs, "CANCELLED_BEFORE_START", cube, "req-R-000002", s, 6) + gate.set() + + +def test_b2_only_the_exact_attempt_is_cancelled(make, cube): + gate, started = threading.Event(), threading.Event() + jobs = make(builder=fake_builder(gate, started=started)) + s = sess(jobs) + five = jobs.start(cube, "req-R-000001", s, 5) + started.wait(10) + assert jobs.cancel_request(s, "req-R-000001", 4)["job_id"] is None # an older attempt's cancel: no match + assert st(jobs, five["job_id"])["state"] == "running" + assert jobs._sessions[s].dead_through == 4 + out = jobs.cancel_request(s, "req-R-000001", 5) # the exact attempt + assert out["job_id"] == five["job_id"] and out["state"] == "cancelled" + assert jobs._sessions[s].dead_through == 5 + gate.set() diff --git a/backend/tests/test_scene_repair.py b/backend/tests/test_scene_repair.py new file mode 100644 index 00000000..f54bcb60 --- /dev/null +++ b/backend/tests/test_scene_repair.py @@ -0,0 +1,703 @@ +"""Repair round 1: each test here fails without the corresponding fix (finding id in the docstring).""" +import hashlib +import json +import re +import struct +import time +import tracemalloc + +import pytest + +from snapstudio_core import scene, scene_limits as L, units +from tests import scene_fixtures as fx +from tests.test_scene_build import build, codes, node +from tests.test_scene_contract import assert_valid, sub_validator + +REV = "ef" * 32 + + +def refuse(path, code): + with pytest.raises(scene.SceneError) as e: + scene.build_scene(str(path), REV) + assert e.value.code == code, (e.value.code, e.value.message) + return e.value + + +# ------------------------------------------------------------------ A1: build items with p:path + +def test_a1_build_item_p_path_uses_the_referenced_file_not_the_root(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(5, 5, 0), "/3D/Objects/ext.model")]) + ext = fx.sub_model_xml([fx.cube_object("1", 30)]) + sc = build(fx.three_mf(tmp_path / "p.3mf", root, {"3D/Objects/ext.model": ext})) + n = node(sc, "b0") + assert n["resource"] == {"part": "3D/Objects/ext.model", "object_id": "1"} + assert n["bounds_mm"] == {"min": [5.0, 5.0, 0.0], "max": [35.0, 35.0, 30.0]} # the 30 mm cube + assert [m["key"]["part"] for m in sc["meshes"]] == ["3D/Objects/ext.model"] # the root cube is not substituted + + +def test_a1_unresolvable_p_path_is_an_error_not_a_fallback_to_the_root(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", None, "/3D/Objects/missing.model")]) + refuse(fx.three_mf(tmp_path / "a.3mf", root), "UNRESOLVED_REFERENCE") + ext = fx.sub_model_xml([fx.cube_object("7", 30)]) # file exists, object 1 does not + root2 = fx.model_xml([fx.cube_object("1", 10)], [("1", None, "/3D/Objects/ext.model")]) + refuse(fx.three_mf(tmp_path / "b.3mf", root2, {"3D/Objects/ext.model": ext}), "UNRESOLVED_REFERENCE") + + +def test_a1_same_object_id_in_two_files_is_not_a_repeated_instance(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(5, 5, 0), "/3D/Objects/ext.model"), ("1", fx.tf(100, 5, 0))]) + ext = fx.sub_model_xml([fx.cube_object("1", 30)]) + sc = build(fx.three_mf(tmp_path / "r.3mf", root, {"3D/Objects/ext.model": ext})) + assert "REPEATED_INSTANCE_PLACEMENT_UNVERIFIED" not in codes(sc) + assert node(sc, "b0")["resource"]["part"] == "3D/Objects/ext.model" and node(sc, "b1")["resource"]["part"] == "3D/3dmodel.model" + twice = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(5, 5, 0), "/3D/Objects/ext.model"), ("1", fx.tf(100, 5, 0), "/3D/Objects/ext.model")]) + assert "REPEATED_INSTANCE_PLACEMENT_UNVERIFIED" in codes(build(fx.three_mf(tmp_path / "t.3mf", twice, {"3D/Objects/ext.model": ext}))) + + +# ------------------------------------------------------------------ A2: the unit header, read from the real root + +CORE = fx.CORE + + +def _prefixed(xml: str) -> str: + xml = re.sub(r"<(/?)(model|resources|object|mesh|vertices|vertex|triangles|triangle|components|component|build|item)\b", + r"<\1m:\2", xml) + return xml.replace(f'xmlns="{CORE}"', f'xmlns:m="{CORE}"') + + +UNIT_DOCS = { + "prefixed": lambda: _prefixed(fx.model_xml([fx.cube_object("1", 12)], [("1", None)], unit="inch")), + "long_comment": lambda: fx.model_xml([fx.cube_object("1", 12)], [("1", None)], unit="inch", + prolog=''), + "pi_and_comments": lambda: fx.model_xml([fx.cube_object("1", 12)], [("1", None)], unit="inch", + prolog=''), + "decoy_comment": lambda: fx.model_xml([fx.cube_object("1", 12)], [("1", None)], unit="millimeter", + prolog=''), + "absent": lambda: fx.model_xml([fx.cube_object("1", 12)], [("1", None)]).replace(' unit="millimeter"', ""), +} +EXPECT = {"prefixed": "inch", "long_comment": "inch", "pi_and_comments": "inch", "decoy_comment": "millimeter", "absent": "millimeter"} + + +@pytest.mark.parametrize("name", sorted(UNIT_DOCS)) +def test_a2_the_unit_comes_from_the_real_root_element(tmp_path, name): + xml = UNIT_DOCS[name]() + want = EXPECT[name] + assert units.read_unit(xml.encode()).name == want + assert units.mm_per_unit(xml.encode()) == units.MM_PER_UNIT[want] + sc = build(fx.three_mf(tmp_path / f"{name}.3mf", xml)) + assert sc["sources"][0]["unit"] == want # the scene parser and the shared helper agree + b = node(sc, "b0")["bounds_mm"] + assert b["max"][0] == pytest.approx(12 * units.MM_PER_UNIT[want], abs=1e-4) + + +def test_a2_legacy_readers_see_the_same_unit_through_a_long_preamble(tmp_path): + from snapstudio_core.intelligence import project_info + xml = UNIT_DOCS["long_comment"]() + path = str(fx.three_mf(tmp_path / "l.3mf", xml)) + assert project_info(path)["dimensions_mm"] == {"x": 304.8, "y": 304.8, "z": 304.8} + decoy = str(fx.three_mf(tmp_path / "d.3mf", UNIT_DOCS["decoy_comment"]())) + assert project_info(decoy)["dimensions_mm"] == {"x": 12.0, "y": 12.0, "z": 12.0} + + +def test_a2_an_unrecognised_unit_is_not_the_same_as_an_absent_one(): + absent = units.read_unit(b'') + yard = units.read_unit(b'') + assert absent.recognized and not absent.declared and absent.factor == 1.0 + assert not yard.recognized and yard.declared and yard.name == "yard" and yard.factor == 1.0 # legacy fallback, flagged + assert units.unit_of(b'') == ("yard", None) + + +# ------------------------------------------------------------------ A4 / D4: metadata present but unmatched is never a printable part + +def test_a4_prusa_attribute_order_does_not_matter(tmp_path): + vols = [(0, 11, "ModelPart"), (12, 23, "ParameterModifier")] + normal = build(fx.prusa_project(tmp_path / "a.3mf", volumes=vols)) + swapped = build(fx.prusa_project(tmp_path / "b.3mf", volumes=vols, last_first=True)) + assert swapped["meshes"][0]["volumes"] == normal["meshes"][0]["volumes"] + assert [v["role"] for v in swapped["meshes"][0]["volumes"]] == ["part", "modifier"] + assert node(swapped, "b0")["has_non_part_volumes"] is True + + +def test_a4_bambu_part_missing_from_the_settings_is_unknown_not_printable(tmp_path): + path = fx.bambu_project(tmp_path / "m.3mf", parts=2, items=[("100", fx.tf(10, 10, 0))], plates=[(1, [("100", 0)])], + origins=[(0, 0, 0), (500, 0, 0)], omit_parts=(2,)) + sc = build(path) + missing = node(sc, "b0.c1") + assert missing["role_context"] == "unknown" and missing["printable"] is None and missing["bounds_mm"] is None + lim = next(l for l in sc["limitations"] if l["code"] == "UNKNOWN_VOLUME_ROLE") + assert lim["target_ids"] == ["b0.c1"] and sc["status"] == "partial" + assert node(sc, "b0")["bounds_mm"] == {"min": [10.0, 10.0, 0.0], "max": [20.0, 20.0, 10.0]} # the unknown part does not count + assert node(sc, "b0.c1")["finding_ids"] == [] and sc["findings"] == [] + + +def test_a4_an_object_with_no_metadata_at_all_keeps_the_synthetic_part(tmp_path): + sc = build(fx.plain_cube_3mf(tmp_path / "p.3mf")) + assert sc["meshes"][0]["volumes"][0]["role"] == "part" and node(sc, "b0")["role_context"] is None + + +# ------------------------------------------------------------------ A5: roles follow the traversal context + +def _shared_project(tmp_path, roles): + sub = fx.sub_model_xml([fx.cube_object("1", 10)]) + comp = [("1", "/3D/Objects/o.model", None)] + root = fx.model_xml([fx.composite_object("100", comp), fx.composite_object("101", comp)], + [("100", fx.tf(10, 10, 0)), ("101", fx.tf(60, 10, 0))]) + settings = fx.model_settings_xml({"100": [("1", roles[0])], "101": [("1", roles[1])]}, [(1, [("100", 0), ("101", 0)])]) + return fx.three_mf(tmp_path / "s.3mf", root, {"3D/Objects/o.model": sub, "Metadata/model_settings.config": settings}) + + +def test_a5_a_shared_mesh_keeps_each_use_s_role(tmp_path): + sc = build(_shared_project(tmp_path, ("negative_part", "normal_part"))) + assert len(sc["meshes"]) == 1 # one mesh, two uses + neg, part = node(sc, "b0.c0"), node(sc, "b1.c0") + assert (neg["role_context"], neg["printable"], neg["bounds_mm"]) == ("negative", False, None) + assert (part["role_context"], part["printable"]) == ("part", True) + assert part["bounds_mm"]["min"] == [60.0, 10.0, 0.0] + flipped = build(_shared_project(tmp_path, ("normal_part", "modifier_part"))) + assert (node(flipped, "b0.c0")["role_context"], node(flipped, "b1.c0")["role_context"]) == ("part", "modifier") + + +def test_a5_a_negative_part_over_an_assembly_applies_to_everything_beneath(tmp_path): + sub = fx.sub_model_xml([fx.cube_object("1", 10), fx.composite_object("2", [("1", None, None), ("1", None, fx.tf(20, 0, 0))])]) + root = fx.model_xml([fx.composite_object("100", [("2", "/3D/Objects/o.model", None)])], [("100", fx.tf(10, 10, 0))]) + settings = fx.model_settings_xml({"100": [("2", "negative_part")]}, [(1, [("100", 0)])]) + sc = build(fx.three_mf(tmp_path / "a.3mf", root, {"3D/Objects/o.model": sub, "Metadata/model_settings.config": settings})) + assert [n["id"] for n in sc["nodes"]] == ["b0", "b0.c0", "b0.c0.c0", "b0.c0.c1"] + for nid in ("b0.c0", "b0.c0.c0", "b0.c0.c1"): + n = node(sc, nid) + assert n["role_context"] == "negative" and n["printable"] is False and n["bounds_mm"] is None and n["has_non_part_volumes"] + assert node(sc, "b0")["printable"] is False and node(sc, "b0")["bounds_mm"] is None + assert sc["findings"] == [] + + +# ------------------------------------------------------------------ A7: the response cap never allocates the whole body first + +def test_a7_part_names_longer_than_the_schema_are_refused_before_they_are_placed(tmp_path): + name = "3D/Objects/" + "n" * 300 + ".model" + sub = fx.sub_model_xml([fx.cube_object("1", 10)]) + root = fx.model_xml([fx.composite_object("9", [("1", "/" + name, fx.tf(i % 50, i // 50, 0)) for i in range(2000)])], [("9", None)]) + path = fx.three_mf(tmp_path / "long.3mf", root, {name: sub}) + tracemalloc.start() + err = refuse(path, "LIMIT_EXCEEDED") + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + assert "part name" in err.message and peak < 50 * 1024 * 1024 + + +def test_a7_part_names_at_the_limit_still_yield_a_schema_valid_scene(tmp_path): + name = "3D/Objects/" + "n" * (L.MAX_PART_NAME_LENGTH - len("3D/Objects/.model")) + ".model" + assert len(name) == L.MAX_PART_NAME_LENGTH + sub = fx.sub_model_xml([fx.cube_object("1", 10)]) + root = fx.model_xml([fx.composite_object("9", [("1", "/" + name, fx.tf(i % 50, i // 50, 0)) for i in range(600)])], [("9", None)]) + sc = build(fx.three_mf(tmp_path / "ok.3mf", root, {name: sub})) + assert sc["counts"]["nodes"] == 601 and len(scene.serialize(sc)) <= L.MAX_RESPONSE_BYTES + + +def test_a7_serializer_checks_the_cap_while_building_and_never_dumps_the_whole_body(tmp_path, monkeypatch): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(i % 20 * 12, i // 20 * 12, 0)) for i in range(300)]) + sc = scene.build_scene_dict(str(fx.three_mf(tmp_path / "many.3mf", root)), REV) + full = len(json.dumps(sc, separators=(",", ":"))) + assert full > 100_000 + monkeypatch.setattr(L, "MAX_RESPONSE_BYTES", 30_000) + seen = [] + real = json.dumps + + def spy(value, *a, **k): + text = real(value, *a, **k) + seen.append(len(text)) + return text + monkeypatch.setattr(scene.json, "dumps", spy) + with pytest.raises(scene.SceneError) as e: + scene.serialize(sc) + assert e.value.code == "LIMIT_EXCEEDED" + assert max(seen) < 30_000 and sum(seen) < 30_000 + 5_000 # stopped one element past the cap + monkeypatch.undo() + size = len(scene.serialize(sc)) + monkeypatch.setattr(L, "MAX_RESPONSE_BYTES", size) + assert len(scene.serialize(sc)) == size # the accounting is exact + monkeypatch.setattr(L, "MAX_RESPONSE_BYTES", size - 1) + with pytest.raises(scene.SceneError): + scene.serialize(sc) + + +# ------------------------------------------------------------------ A8 / D3: parse-time memory bounds cover every element + +def test_a8_hundreds_of_thousands_of_unused_objects_are_rejected_early(tmp_path): + objects = "".join(f'' for i in range(2, 300_002)) + root = fx.model_xml([fx.cube_object("1", 10)], [("1", None)]).replace("", objects + "") + t = time.monotonic() + err = refuse(fx.three_mf(tmp_path / "objs.3mf", root), "LIMIT_EXCEEDED") + assert "objects" in err.message and time.monotonic() - t < 10 + + +def test_d3_junk_elements_are_capped_and_memory_stays_bounded(tmp_path): + junk = "" * 2_000_000 # 8 MB of unknown elements + root = fx.model_xml([fx.cube_object("1", 10)], [("1", None)]).replace("", junk + "") + path = fx.three_mf(tmp_path / "junk.3mf", root) + tracemalloc.start() + err = refuse(path, "LIMIT_EXCEEDED") + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + assert "elements" in err.message and peak <= 300 * 1024 * 1024 + + +# ------------------------------------------------------------------ A9: STL is decoded incrementally + +def _stl_with_header(path, n): + path.write_bytes(b"\0" * 80 + struct.pack("', + f'', +]) +def test_a12_external_relationship_is_rejected_whatever_the_namespace(tmp_path, rels): + path = fx.three_mf(tmp_path / "e.3mf", fx.model_xml([fx.cube_object("1")], [("1", None)]), + {"3D/_rels/3dmodel.model.rels": '' + rels}) + err = refuse(path, "INVALID_ARCHIVE") + assert "external" in err.message + + +def test_d6_the_byte_prescan_rejects_a_doctype_in_a_config_read_without_an_xml_parser(tmp_path): + cfg = ']>' + path = fx.three_mf(tmp_path / "c.3mf", fx.model_xml([fx.cube_object("1")], [("1", None)]), {"Metadata/Slic3r_PE_model.config": cfg}) + arc = scene.Archive(str(path), scene.Control()) + try: + with pytest.raises(scene.SceneError) as e: + arc.read_small("Metadata/Slic3r_PE_model.config") + assert e.value.code == "INVALID_ARCHIVE" + finally: + arc.close() + + +# ------------------------------------------------------------------ A13: ids fit by construction + +def test_a13_ids_fit_128_characters_at_the_deepest_allowed_nesting(tmp_path): + chain = [fx.cube_object("0")] + [fx.composite_object(str(i), [(str(i - 1), None, None)]) for i in range(1, L.MAX_DEPTH)] + path = fx.three_mf(tmp_path / "deep.3mf", fx.model_xml(chain, [(str(L.MAX_DEPTH - 1), None)])) + sc = build(path) + ids = [n["id"] for n in sc["nodes"]] + assert len(ids) == L.MAX_DEPTH == len(set(ids)) and max(map(len, ids)) <= 128 + assert any("~" in i for i in ids) # long ancestries are digested, not truncated + assert [n["id"] for n in build(path)["nodes"]] == ids # deterministic + parents = {n["id"]: n["parent_id"] for n in sc["nodes"]} + assert all(p is None or p in parents for p in parents.values()) + assert node(sc, ids[-1])["instance_ref"]["component_path"] == [0] * (L.MAX_DEPTH - 1) + assert hashlib.sha256(ids[-1].encode()).hexdigest() # (ids are plain strings) + + +# ------------------------------------------------------------------ D5: schema closure and plate state + +def test_d5_error_response_is_closed_and_a_known_plate_needs_an_id(): + err = sub_validator("error_response") + assert not list(err.iter_errors({"error": "EXPIRED", "message": "x"})) + assert list(err.iter_errors({"error": "EXPIRED", "message": "x", "extra": 1})) + plate = sub_validator("plate_ref") + assert not list(plate.iter_errors({"state": "known", "plate_id": "p1", "source": "plate_config"})) + assert list(plate.iter_errors({"state": "known", "plate_id": None, "source": "plate_config"})) + assert not list(plate.iter_errors({"state": "unknown", "plate_id": None, "source": None})) + + +def test_d5_a_node_on_no_plate_of_a_project_with_plates_gets_no_fit_finding_and_is_partial(tmp_path): + path = fx.bambu_project(tmp_path / "np.3mf", parts=1, items=[("100", fx.tf(900, 900, 0))], plates=[(1, [])]) + sc = build(path) + assert node(sc, "b0")["plate"]["state"] == "unknown" + assert sc["findings"] == [] and sc["status"] == "partial" and "PLATE_MEMBERSHIP_UNKNOWN" in codes(sc) + on_plate = build(fx.bambu_project(tmp_path / "op.3mf", parts=1, items=[("100", fx.tf(900, 900, 0))], plates=[(1, [("100", 0)])])) + assert [f["kind"] for f in on_plate["findings"]] == ["placement"] # with membership proven, the finding returns + + +# ================================================================== repair round 2 + +import os + + +def _config(tmp_path, name, settings_xml, model=None, extra=None): + root = model or fx.model_xml([fx.cube_object("1", 10)], [("1", None)]) + return fx.three_mf(tmp_path / name, root, dict({"Metadata/model_settings.config": settings_xml}, **(extra or {}))) + + +# ------------------------------------------------------------------ B1: STL kind and bounds + +def test_b1_a_large_solid_payload_with_no_newlines_is_rejected_without_allocating_it(tmp_path): + path = tmp_path / "bomb.stl" + with open(path, "wb") as fh: + fh.write(b"solid ") + for _ in range(64): + fh.write(b"a" * (1024 * 1024)) # 64 MiB, not one newline + tracemalloc.start() + t = time.monotonic() + err = refuse(path, "INVALID_GEOMETRY") + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + assert time.monotonic() - t < 5 and peak < 20 * 1024 * 1024, (peak, err.message) + + +def test_b1_a_header_over_the_budget_with_trailing_bytes_is_rejected_before_any_decoding(tmp_path, monkeypatch): + from snapstudio_core import stl_io + n = L.MAX_PARSED_TRIANGLES + 1 + path = tmp_path / "trail.stl" + path.write_bytes(b"solid mislabelled binary".ljust(80, b" ") + struct.pack("" + JUNK + "") + elif which == "prusa": + path = fx.three_mf(tmp_path / "p.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", None)]), + {"Metadata/Slic3r_PE_model.config": "" + JUNK + ""}) + else: + path = fx.three_mf(tmp_path / "r.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", None)]), + rels=f'' + JUNK + "") + tracemalloc.start() + err = refuse(path, "LIMIT_EXCEEDED") + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + assert "elements" in err.message and peak <= 300 * 1024 * 1024 + + +def test_b2_unrecognised_elements_are_released_as_they_complete(tmp_path, monkeypatch): + calls = [] + real = scene._free + monkeypatch.setattr(scene, "_free", lambda e: (calls.append(e.tag), real(e))[1]) + junk = "" * 3000 + scene.build_scene_dict(str(_config(tmp_path, "j.3mf", "" + junk + "")), REV) + assert sum(1 for t in calls if t == "x") >= 3000 + calls.clear() + root = fx.model_xml([fx.cube_object("1", 10)], [("1", None)]).replace("", junk + "") + scene.build_scene_dict(str(fx.three_mf(tmp_path / "m.3mf", root)), REV) + assert sum(1 for t in calls if t.endswith("}x") or t == "x") >= 3000 + + +# ------------------------------------------------------------------ B3: p:path rules + +def test_b3_a_relative_build_item_path_is_refused(tmp_path): + ext = fx.sub_model_xml([fx.cube_object("1", 30)]) + root = fx.model_xml([], [("1", None, "3D/Objects/ext.model")]) + refuse(fx.three_mf(tmp_path / "a.3mf", root, {"3D/Objects/ext.model": ext}), "UNRESOLVED_REFERENCE") + ok = fx.model_xml([], [("1", None, "/3D/Objects/ext.model")]) + assert scene.build_scene_dict(str(fx.three_mf(tmp_path / "b.3mf", ok, {"3D/Objects/ext.model": ext})), REV)["counts"]["nodes"] == 1 + + +def test_b3_a_relative_component_path_is_refused(tmp_path): + ext = fx.sub_model_xml([fx.cube_object("1", 30)]) + root = fx.model_xml([fx.composite_object("9", [("1", "3D/Objects/ext.model", None)])], [("9", None)]) + refuse(fx.three_mf(tmp_path / "a.3mf", root, {"3D/Objects/ext.model": ext}), "UNRESOLVED_REFERENCE") + + +def test_b3_component_paths_are_honoured_only_from_the_root_model(tmp_path): + leaf = fx.sub_model_xml([fx.cube_object("1", 10)]) + mid = fx.sub_model_xml([fx.composite_object("2", [("1", "/3D/Objects/leaf.model", None)])]) + root = fx.model_xml([fx.composite_object("9", [("2", "/3D/Objects/mid.model", None)])], [("9", None)]) + err = refuse(fx.three_mf(tmp_path / "n.3mf", root, {"3D/Objects/mid.model": mid, "3D/Objects/leaf.model": leaf}), + "UNRESOLVED_REFERENCE") + assert "root model" in err.message + + +# ------------------------------------------------------------------ B4: duplicate records never silently overwrite + +def _two_part_settings(records): + return ('' + records + + '' + '') + + +def _bambu_one_part(tmp_path, name, settings): + sub = fx.sub_model_xml([fx.cube_object("1", 10)]) + root = fx.model_xml([fx.composite_object("100", [("1", "/3D/Objects/o.model", None)])], [("100", fx.tf(10, 10, 0))]) + return fx.three_mf(tmp_path / name, root, {"3D/Objects/o.model": sub, "Metadata/model_settings.config": settings}) + + +def test_b4_duplicate_records_that_disagree_make_the_role_unknown(tmp_path): + neg = '' + part = '' + both = '' + for name, records in (("objects", neg + part), ("parts", both)): + sc = build(_bambu_one_part(tmp_path, name + ".3mf", _two_part_settings(records))) + n = node(sc, "b0.c0") + assert n["role_context"] == "unknown" and n["printable"] is None + assert sc["status"] == "partial" and "UNKNOWN_VOLUME_ROLE" in codes(sc) and sc["findings"] == [] + same = build(_bambu_one_part(tmp_path, "same.3mf", _two_part_settings(neg + neg))) + assert node(same, "b0.c0")["role_context"] == "negative" # identical duplicates are fine + + +# ------------------------------------------------------------------ B5: build objects in another file fail closed + +def test_b5_a_negative_part_assembly_in_another_file_is_unknown_and_never_judged(tmp_path): + a = fx.sub_model_xml([fx.cube_object("1", 10), fx.composite_object("2", [("1", None, None)])]) + root = fx.model_xml([], [("2", fx.tf(900, 100, 0), "/3D/Objects/a.model")]) + settings = fx.model_settings_xml({"2": [("1", "negative_part")]}, [(1, [("2", 0)])]) + sc = build(fx.three_mf(tmp_path / "x.3mf", root, {"3D/Objects/a.model": a, "Metadata/model_settings.config": settings})) + assert [n["role_context"] for n in sc["nodes"]] == ["unknown", "unknown"] + assert all(n["printable"] is None for n in sc["nodes"]) + assert sc["status"] == "partial" and "UNKNOWN_VOLUME_ROLE" in codes(sc) + assert sc["findings"] == [] + + +def test_b5_without_any_settings_a_file_qualified_build_object_is_still_ordinary(tmp_path): + root = fx.model_xml([], [("1", fx.tf(5, 5, 0), "/3D/Objects/a.model")]) + a = fx.sub_model_xml([fx.cube_object("1", 10)]) + sc = build(fx.three_mf(tmp_path / "plain.3mf", root, {"3D/Objects/a.model": a})) + assert node(sc, "b0")["printable"] is True and node(sc, "b0")["role_context"] is None + + +# ------------------------------------------------------------------ B6: plate matching is file-qualified + +def test_b6_a_bare_object_id_shared_across_files_cannot_prove_plate_membership(tmp_path): + ext = fx.sub_model_xml([fx.cube_object("1", 10)]) + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(900, 10, 0)), ("1", fx.tf(5, 5, 0), "/3D/Objects/ext.model")]) + settings = fx.model_settings_xml({}, [(1, [("1", 0)])]) + sc = build(fx.three_mf(tmp_path / "s.3mf", root, {"3D/Objects/ext.model": ext, "Metadata/model_settings.config": settings})) + assert [node(sc, "b0")["plate"]["state"], node(sc, "b1")["plate"]["state"]] == ["ambiguous", "ambiguous"] + assert all(node(sc, i)["plate"]["plate_id"] is None for i in ("b0", "b1")) + lim = next(l for l in sc["limitations"] if l["code"] == "PLATE_MEMBERSHIP_UNKNOWN") + assert set(lim["target_ids"]) == {"b0", "b1"} + assert sc["findings"] == [] and sc["status"] == "partial" # the root cube at X=900 is NOT reported + + +# ------------------------------------------------------------------ B7: unsupported unit names its nodes + +def test_b7_unsupported_unit_lists_the_affected_nodes(tmp_path): + sub = fx.sub_model_xml([fx.cube_object("1", 10)], unit="yard") + root = fx.model_xml([fx.composite_object("9", [("1", "/3D/Objects/y.model", None)]), fx.cube_object("2", 10)], + [("9", None), ("2", fx.tf(50, 0, 0))]) + sc = build(fx.three_mf(tmp_path / "u.3mf", root, {"3D/Objects/y.model": sub})) + lim = next(l for l in sc["limitations"] if l["code"] == "UNSUPPORTED_UNIT") + assert lim["target_ids"] == ["b0.c0"] # only the nodes living in the yard part + assert {s["part"]: s["unit"] for s in sc["sources"]}["3D/Objects/y.model"] == "millimeter" # the documented fallback + description = scene.load_schema()["$defs"]["source"]["description"] + assert "UNSUPPORTED_UNIT" in description and "fallback" in description.lower() + + +# ------------------------------------------------------------------ B8: the failure wording is pinned + +def test_b8_a_failed_move_says_it_kept_no_copy(tmp_path, monkeypatch): + from snapstudio_core import plate_placement + path = str(fx.three_mf(tmp_path / "m.3mf", fx.model_xml([fx.cube_object("1", 25.4)], [("1", fx.tf(290.0, 3.0, 0.0))]))) + monkeypatch.setattr(plate_placement, "_rewrite_items", lambda raw, offset_for: (raw, 1)) + monkeypatch.setattr(plate_placement, "verify_only_placement_moved", lambda a, b: {"passed": True, "checks": []}) + res = plate_placement.prepare_placed_copy(path, out_dir=str(tmp_path / "out")) + assert res["ok"] is False + assert res["reason"] == ("Studio could not move the objects onto the plate, so it did not keep a copy. " + "Review the original in Snapmaker Orca before slicing.") + + +# ------------------------------------------------------------------ B9: id collisions are detected + +def test_b9_a_node_id_collision_is_an_internal_error(tmp_path, monkeypatch): + monkeypatch.setattr(scene.Traversal, "_node_id", staticmethod(lambda ctx, parent, path: "same")) + root = fx.model_xml([fx.cube_object("1", 10)], [("1", None), ("1", fx.tf(20, 0, 0))]) + refuse(fx.three_mf(tmp_path / "c.3mf", root), "INTERNAL") + + +# ------------------------------------------------------------------ B10: surviving mutants + +def test_b10_an_assembly_role_is_inherited_under_a_root_file_parent_and_the_outer_role_wins(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10), fx.composite_object("50", [("1", None, None)]), + fx.composite_object("100", [("50", None, None)])], [("100", fx.tf(10, 10, 0))]) + settings = fx.model_settings_xml({"100": [("50", "negative_part")], "50": [("1", "normal_part")]}, [(1, [("100", 0)])]) + sc = build(fx.three_mf(tmp_path / "r.3mf", root, {"Metadata/model_settings.config": settings})) + assert [node(sc, i)["role_context"] for i in ("b0.c0", "b0.c0.c0")] == ["negative", "negative"] + assert node(sc, "b0.c0.c0")["printable"] is False and sc["findings"] == [] + + +def test_b10_an_unreadable_prusa_range_makes_the_whole_mesh_unknown_and_partial(tmp_path): + cfg = ('' + '' + '') + sc = build(fx.three_mf(tmp_path / "p.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(900, 900, 0))]), + {"Metadata/Slic3r_PE_model.config": cfg})) + assert [v["role"] for v in sc["meshes"][0]["volumes"]] == ["unknown"] + assert node(sc, "b0")["printable"] is None and sc["status"] == "partial" and sc["findings"] == [] + + +def test_b10_binary_stl_is_decoded_in_bounded_batches_with_the_hook_between_them(tmp_path, monkeypatch): + from snapstudio_core import stl_io + path = _stl_with_header(tmp_path / "b.stl", 12_000) + sizes, checks = [], [] + real = stl_io._decode_binary_batch + monkeypatch.setattr(stl_io, "_decode_binary_batch", lambda raw, *a: (sizes.append(len(raw) // 50), real(raw, *a))[1]) + with open(path, "rb") as fh: + stl_io.decode_stl_stream(fh, os.path.getsize(path), max_triangles=250_000, max_vertices=300_000, + check=lambda: checks.append(1)) + assert sizes == [5000, 5000, 2000] and len(checks) >= 3 + + +# ================================================================== repair round 3 + +def _bambu_part_at_900(tmp_path, name, object_xml, plate_extra=""): + settings = ('' + object_xml + + '' + '' + plate_extra + '') + sub = fx.sub_model_xml([fx.cube_object("1", 10)]) + root = fx.model_xml([fx.composite_object("100", [("1", "/3D/Objects/o.model", None)])], [("100", fx.tf(900, 10, 0))]) + return fx.three_mf(tmp_path / name, root, {"3D/Objects/o.model": sub, "Metadata/model_settings.config": settings}) + + +@pytest.mark.parametrize("name,obj", [ + ("after", ''), + ("between", ''), + ("leading", ''), +]) +def test_r3_1_an_unknown_element_next_to_a_part_record_never_changes_its_role(tmp_path, name, obj): + sc = build(_bambu_part_at_900(tmp_path, f"{name}.3mf", obj)) + n = node(sc, "b0.c0") + assert n["role_context"] == "negative" and n["printable"] is False + assert sc["findings"] == [] # a negative part is never judged against the bed + + +def test_r3_1_an_unknown_element_after_a_model_instance_keeps_plate_membership(tmp_path): + obj = '' + sc = build(_bambu_part_at_900(tmp_path, "pl.3mf", obj, plate_extra="")) + assert node(sc, "b0")["plate"] == {"state": "known", "plate_id": "p1", "source": "plate_config"} + assert [f["kind"] for f in sc["findings"]] == ["placement"] # and the part at X=900 is judged, as before + + +@pytest.mark.parametrize("junk", ["", "", ""]) +def test_r3_1_an_unknown_element_after_a_prusa_volume_never_drops_it(tmp_path, junk): + verts, tris = [], [] + for k in range(2): + base = len(verts) + verts += fx.cube_vertices(10.0, (k * 20.0, 0.0, 0.0)) + tris += [(a + base, b + base, c + base) for a, b, c in fx.CUBE_TRIS] + obj = f'{fx.mesh_xml(verts, tris)}' + vol = lambda a, b, vt: f'' + cfg = ('' + vol(0, 11, "ModelPart") + junk + vol(12, 23, "NegativeVolume") + junk + "" + junk + "") + sc = build(fx.three_mf(tmp_path / "p.3mf", fx.model_xml([obj], [("1", fx.tf(900, 100, 0))]), + {"Metadata/Slic3r_PE_model.config": cfg})) + assert [v["role"] for v in sc["meshes"][0]["volumes"]] == ["part", "negative"] + assert node(sc, "b0")["bounds_mm"]["max"][0] == pytest.approx(910.0) # the negative cube (to 930) does not count + + +# ------------------------------------------------------------------ R3-2: every completed element is released + +def test_r3_2_relationships_and_namespaced_settings_elements_are_released(tmp_path, monkeypatch): + seen = [] + real = scene._free + monkeypatch.setattr(scene, "_free", lambda e: (seen.append(e.tag), real(e))[1]) + rels = f'' + '' * 50 + "" + cfg = '' + "" * 50 + "" + path = fx.three_mf(tmp_path / "r.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", None)]), + {"3D/_rels/x.rels": rels, "Metadata/model_settings.config": cfg}) + scene.build_scene_dict(str(path), REV) + assert seen.count(f"{{{fx.REL_NS}}}Relationship") >= 50 + assert seen.count("{urn:x}object") >= 50 + + +def test_r3_2_hundreds_of_thousands_of_small_records_stay_within_a_memory_gate(tmp_path): + rels = f'' + '' * 300_000 + "" + assert len(rels) < L.CONFIG_PART_BYTES + cfg = '' + "" * 880_000 + "" + assert len(cfg) < L.CONFIG_PART_BYTES + path = fx.three_mf(tmp_path / "big.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", None)]), + {"3D/_rels/x.rels": rels, "Metadata/model_settings.config": cfg}) + tracemalloc.start() + sc = scene.build_scene_dict(str(path), REV) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + assert sc["counts"]["nodes"] == 1 and peak <= 300 * 1024 * 1024 + + +# ------------------------------------------------------------------ R3-3: a present-but-empty p:path is an error + +def test_r3_3_an_empty_build_item_path_is_not_treated_as_absent(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", None, "")]) # object 1 EXISTS in this model + refuse(fx.three_mf(tmp_path / "i.3mf", root), "UNRESOLVED_REFERENCE") + + +def test_r3_3_an_empty_component_path_is_not_treated_as_absent(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10), fx.composite_object("9", [("1", "", None)])], [("9", None)]) + refuse(fx.three_mf(tmp_path / "c.3mf", root), "UNRESOLVED_REFERENCE") + + +# ------------------------------------------------------------------ R3-4: a part record without an id still means "has metadata" + +def test_r3_4_a_part_record_without_an_id_makes_the_object_have_metadata(tmp_path): + sc = build(_bambu_part_at_900(tmp_path, "noid.3mf", '')) + n = node(sc, "b0.c0") + assert n["role_context"] == "unknown" and n["printable"] is None + assert sc["status"] == "partial" and "UNKNOWN_VOLUME_ROLE" in codes(sc) and sc["findings"] == [] + plain = build(_bambu_part_at_900(tmp_path, "nometa.3mf", '')) + assert node(plain, "b0.c0")["role_context"] is None # an object with no part records is unchanged + + +# ------------------------------------------------------------------ R3-5: the root unit reaches external build items + +def test_r3_5_a_bad_root_unit_names_the_external_build_item_nodes(tmp_path): + ext = fx.sub_model_xml([fx.cube_object("1", 10)]) + root = fx.model_xml([], [("1", fx.tf(5, 5, 0), "/3D/Objects/e.model")], unit="yard") + sc = build(fx.three_mf(tmp_path / "y.3mf", root, {"3D/Objects/e.model": ext})) + lim = next(l for l in sc["limitations"] if l["code"] == "UNSUPPORTED_UNIT") + assert lim["target_ids"] == ["b0"] # its translation is in the unrecognized unit + assert sc["findings"] == [] + + +# ------------------------------------------------------------------ R3-6: a non-ASCII solid name is text + +def test_r3_6_an_ascii_stl_with_a_non_ascii_name_loads(tmp_path): + facet = "facet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n" + path = tmp_path / "wuerfel.stl" + path.write_bytes(("solid Würfel_größe\n" + facet * 3 + "endsolid Würfel_größe\n").encode("utf-8")) + assert scene.build_scene_dict(str(path), REV)["counts"]["triangles"] == 3 + binary = tmp_path / "bin.stl" # high bytes that are NOT UTF-8 stay binary + binary.write_bytes(bytes(range(128, 256)) * 3 + struct.pack(" assess), not from a hand-built bed_name + assert "Big Machine" in bed["source"] and "U1" not in bed["source"] + # and through the Bed-Fit API: identified by what it reports, judged against its recorded volume + from snapstudio_core import moonraker + monkeypatch.setattr(moonraker, "capabilities", lambda host, port: { + "bed_mm": {"x": 271.0, "y": 335.0, "z": 275.0}, "klipper_objects": [], "toolhead_count": 1}) + monkeypatch.setattr(printer_profiles, "identify", lambda facts, profiles=None: {"printer_id": "bigmachine"}) + result = service.bed_fit(path, host="big.invalid") + assert result["overall_level"] == "ok" and result["bed_mm"]["x"] == 1000.0 + assert result["bed_mm_source"] == "profile" and result["placement_checked"] is True + assert "bigmachine" not in result["bed_source"] def test_a_zero_tool_printer_does_not_divide_by_it(): diff --git a/backend/tests/test_setting_explanations.py b/backend/tests/test_setting_explanations.py index 8ecf5512..19cb8340 100644 --- a/backend/tests/test_setting_explanations.py +++ b/backend/tests/test_setting_explanations.py @@ -22,7 +22,9 @@ def _project(tmp_path, settings: dict): path = tmp_path / "foreign.3mf" - base = {"printer_model": "Bambu Lab X1 Carbon", "filament_colour": ["#FF0000"], "filament_type": ["PLA"]} + array_lengths = [len(value) for value in settings.values() if isinstance(value, list)] + count = max(array_lengths or [1]) + base = {"printer_model": "Bambu Lab X1 Carbon", "filament_colour": ["#FF0000"] * count, "filament_type": ["PLA"] * count} with zipfile.ZipFile(path, "w") as z: z.writestr("3D/3dmodel.model", '') z.writestr("Metadata/project_settings.config", json.dumps({**base, **settings})) @@ -82,6 +84,9 @@ def test_a_negative_raft_expansion_has_one_owner_per_run_and_one_explained_recor assert records[0]["explanation"] == orca_import.RAFT_EXPANSION_WHY # the reason is the engine's own, never empty, and never shown instead of the explanation assert records[0]["reason"] == orca_import.RAFT_EXPANSION_REASON + # whichever step wrote the surviving explanation also wrote its source and kind: never a mixed pair + assert records[0]["source"] == "Studio's import rule for this setting" + assert records[0]["kind"] == "orca" @pytest.mark.parametrize("mode", ["safe", "preserve", "u1", "optimize"]) @@ -109,10 +114,23 @@ def test_each_clamped_setting_reaches_the_summary_with_its_explanation(tmp_path) def test_an_ordinary_orca_import_change_keeps_its_own_explanation(tmp_path): summary, _ = _prepare(tmp_path, {"brim_type": "auto_brim"}) (brim,) = [c for c in summary["compat_changed"] if c["key"] == "brim_type"] - assert "Snapmaker Orca" in brim["explanation"] + assert brim["explanation"] == "Studio replaced the automatic brim choice with no brim. A brim you chose yourself is kept." + assert brim["kind"] == "orca" + assert brim["source"] == "Studio's import rule for this setting" + assert brim["kind"] == "orca" assert brim["reason"] and brim["reason"] != brim["explanation"] +@pytest.mark.parametrize("settings,key,phrase", [ + ({"adaptive_layer_height": "1", "support_type": "tree", "support_style": "organic"}, "support_style", "Studio replaced the tree support style with the hybrid style for this U1 copy."), +]) +def test_orca_verify_advice_names_only_studios_operation(settings, key, phrase, tmp_path): + summary, _ = _prepare(tmp_path, settings) + change = next(c for c in summary["compat_changed"] if c["key"] == key) + assert change["explanation"] == phrase + assert change["kind"] == "orca" + + def test_explanations_are_absent_or_non_empty_and_recommendations_carry_none(tmp_path): summary, _ = _prepare(tmp_path, {"layer_height": "0.16", "printer_settings_id": "Bambu Lab X1 Carbon 0.4 nozzle"}) assert isinstance(summary["kept_count"], int) @@ -121,3 +139,95 @@ def test_explanations_are_absent_or_non_empty_and_recommendations_carry_none(tmp # optional recommendations are a separate list and are not explained here for change in summary["recommended_changes"]: assert "explanation" not in change + + +@pytest.mark.parametrize("key", ["filament_flush_temp", "filament_adaptive_volumetric_speed"]) +@pytest.mark.parametrize("value,count,expected,reason", [ + ([220], 1, "Studio wrote each value in this list as text.", "wrote each value in the list as text"), + ([5, 5, 5, 5], 4, "Studio wrote each value in this list as text.", "wrote each value in the list as text"), + ([0, 0, 0, 0, 0], 5, "Studio wrote each value in this list as text.", "wrote each value in the list as text"), + ([" 5 ", 220, "", "7"], 4, "Studio filled an empty entry with the last non-empty value in this list. Studio trimmed whitespace around a string value. Studio wrote each value in this list as text.", "filled an empty entry with the last non-empty value; trimmed whitespace from a string; wrote each value in the list as text"), + (["", "5"], 2, "Studio filled an empty entry with the last non-empty value in this list.", "filled an empty entry with the last non-empty value"), + (["1", "2", "3", "4", "5", "6"], 4, "Studio resized this list to cover every filament slot.", "resized the list"), + ([" 1 ", "2", "3", "4", "5"], 4, "Studio resized this list to cover every filament slot. Studio trimmed whitespace around a string value.", "resized the list; trimmed whitespace from a string"), +]) +def test_filament_import_explanation_matches_each_operation(key, value, count, expected, reason): + from snapstudio_core.orca_import import _fix_filament_array_validity + changes = [] + cfg = {key: list(value)} + _fix_filament_array_validity(cfg, changes, count) + assert changes[0]["explanation"] == expected + assert changes[0]["kind"] == "engine" + assert cfg[key] != value + assert changes[0]["reason"] == reason + assert all(part in changes[0]["explanation"] for part in expected.split(". ") if part) + + +@pytest.mark.parametrize("mode", ["preserve", "recommended"]) +@pytest.mark.parametrize("key", ["filament_flush_temp", "filament_adaptive_volumetric_speed"]) +@pytest.mark.parametrize("value", [[220], [5, 5, 5, 5], [0, 0, 0, 0, 0], [" 5 ", 220, "", "7"]]) +def test_filament_array_conversion_passes_real_convert_preservation_invariant(tmp_path, mode, key, value): + """The real converter records every normalization; this cannot establish prose clarity, source choice, physical behavior, Orca behavior, dates, paraphrased overclaims, or screen-reader behavior.""" + summary, after = _prepare(tmp_path, {key: value}, mode=mode) + change = next(c for c in summary["compat_changed"] if c["key"] == key) + assert change["reason"] and change["explanation"] + assert all(isinstance(item, str) for item in after[key]) + assert after[key] != value + + +def test_action_reasons_replace_all_four_provenance_fields_as_one_record(): + from snapstudio_core.convert import _action_reasons + records, _, _, = _action_reasons({"normalizations": [ + {"key": "raft", "reason": "first", "explanation": "old", "source": "old source", "kind": "engine"}, + ], "orca_compatibility": [ + {"key": "raft", "reason": "second", "explanation": "new", "source": "new source", "kind": "engine"}, + ]}) + assert records["raft"] == {"reason": "second", "explanation": "new", "source": "new source", "kind": "engine"} + + +def test_action_reasons_clear_a_stale_explanation_only_with_an_explicit_clear(): + from snapstudio_core.convert import _action_reasons + records, _, _, = _action_reasons({"normalizations": [ + {"key": "raft", "reason": "first", "explanation": "old", "source": "old source", "kind": "engine"}, + {"key": "raft", "reason": ""}, + ]}) + assert "raft" not in records + + +def test_action_reasons_keep_prior_record_when_later_item_has_no_reason(): + from snapstudio_core.convert import _action_reasons + records, _, _, = _action_reasons({"normalizations": [ + {"key": "raft", "reason": "first", "explanation": "old", "source": "old source", "kind": "engine"}, + {"key": "raft"}, + ]}) + assert records["raft"] == {"reason": "first", "explanation": "old", "source": "old source", "kind": "engine"} + + +@pytest.mark.parametrize("key", ["filament_flush_temp", "filament_adaptive_volumetric_speed"]) +@pytest.mark.parametrize("value,expected,reason", [ + (["1", "2", "3", "4", " 5 "], "Studio resized this list to cover every filament slot.", "resized the list"), + (["1", "2", "3", "4", ""], "Studio resized this list to cover every filament slot.", "resized the list"), + (["1", "2", "3", "4", 5], "Studio resized this list to cover every filament slot.", "resized the list"), +]) +def test_entries_dropped_by_truncation_are_reported_only_as_a_resize(key, value, expected, reason): + """An entry beyond the slot count is dropped, so no trim, fill or text conversion may be claimed for it.""" + from snapstudio_core.orca_import import _fix_filament_array_validity + changes = [] + cfg = {key: list(value)} + _fix_filament_array_validity(cfg, changes, 4) + assert cfg[key] == ["1", "2", "3", "4"] + assert changes[0]["explanation"] == expected + assert changes[0]["reason"] == reason + + +@pytest.mark.parametrize("mode", ["preserve", "recommended"]) +@pytest.mark.parametrize("key", ["filament_flush_temp", "filament_adaptive_volumetric_speed"]) +@pytest.mark.parametrize("dropped", [" 5 ", "", 5]) +def test_truncation_through_the_real_convert_path_reports_only_a_resize(tmp_path, mode, key, dropped): + """Same rule end to end: the dropped entry never leaks a trim, fill or conversion claim into the summary.""" + four = ["#FF0000"] * 4 + summary, after = _prepare(tmp_path, {key: ["1", "2", "3", "4", dropped], "filament_colour": four, "filament_type": ["PLA"] * 4}, mode=mode) + change = next(c for c in summary["compat_changed"] if c["key"] == key) + assert after[key] == ["1", "2", "3", "4"] + assert change["explanation"] == "Studio resized this list to cover every filament slot." + assert change["reason"] == "resized the list" diff --git a/backend/tests/test_size_unknown_and_limits.py b/backend/tests/test_size_unknown_and_limits.py new file mode 100644 index 00000000..298dc05c --- /dev/null +++ b/backend/tests/test_size_unknown_and_limits.py @@ -0,0 +1,320 @@ +"""Issue 91 repair round: an unknown size stays unknown, every object is checked, size wording claims no +position, and measuring a project once is enough.""" +from __future__ import annotations + +from snapstudio_api import service +from snapstudio_core import bed_fit, geometry, placement, plate_placement as pp +from snapstudio_core.intelligence import project_info +from tests import scene_fixtures as fx +from tests.test_size_vs_placed_bounds import box_object, project, texts + +SETTINGS = "Metadata/model_settings.config" + + +def _renamed_root(tmp_path): + """Two small meshes authored 500 mm apart, in a project whose root model is NOT 3D/3dmodel.model.""" + root = fx.model_xml([box_object("1", 20, 20, 20), box_object("2", 20, 20, 20, origin=(500, 0, 0))], + [("1", fx.tf(50, 50, 0)), ("2", fx.tf(-400, 50, 0))]) + entries = {"_rels/.rels": fx.rels_xml(target="/3D/main.model"), "3D/main.model": root} + return str(fx.write_zip(tmp_path / "renamed.3mf", entries)) + + +# --- unknown stays unknown ---------------------------------------------------------------------------- + +def test_unmeasured_objects_do_not_fall_back_to_the_combined_extents(tmp_path): + path = _renamed_root(tmp_path) + info = project_info(path, placement_aware=True) + assert info["object_sizes_mm"] == [] + assert info["dimensions_mm"]["x"] == 520.0 # the legacy figure still exists ... + result = service.bed_fit(path) # ... and is not used for fit + assert result["available"] is False and result["reason"] == bed_fit.UNMEASURED_TEXT + assert "scale" not in result["reason"].lower() and "too big" not in result["reason"].lower() + check = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "warn" and check["detail"] == bed_fit.UNMEASURED_TEXT + + +def test_assess_objects_with_nothing_measured_is_unavailable_not_a_guess(): + out = bed_fit.assess_objects([]) + assert out["available"] is False and out["reason"] == bed_fit.UNMEASURED_TEXT + assert bed_fit.assess_objects(None)["available"] is False + + +def test_some_objects_unmeasured_never_reads_as_everything_fits(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], + [("1", fx.tf(100, 100, 0)), ("1", fx.tf(100, 100, 0), "/3D/Objects/missing.model")]) + path = str(fx.three_mf(tmp_path / "u.3mf", root)) + info = project_info(path, placement_aware=True) + assert info["objects_unmeasured"] == 1 and len(info["object_sizes_mm"]) == 1 + result = service.bed_fit(path) + assert result["overall_level"] == "warn" and result["objects_unmeasured"] == 1 + assert "could not be measured" in result["overall_text"] + check = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "warn" and "1 build item could not be measured" in check["detail"] + + +# --- every object is checked ---------------------------------------------------------------------------- + +def test_the_65th_object_is_checked_too(tmp_path): + objects = [box_object(str(i), 10, 10, 10) for i in range(1, 65)] + [box_object("65", 10, 10, 300)] + items = [(str(i), fx.tf(5 + (i % 20) * 12, 5 + (i // 20) * 12, 0)) for i in range(1, 66)] + path = project(tmp_path, objects, items) + result = service.bed_fit(path) + assert result["objects_checked"] == 65 and result["overall_level"] == "risk" + assert "By size, object 65 is taller than" in texts(result) + check = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "warn" and "object 65 (10.0 × 10.0 × 300.0 mm)" in check["detail"] + + +# --- size statements claim no position ----------------------------------------------------------------------- + +def test_the_prime_tower_finding_is_about_size_not_placement(): + out = bed_fit.assess({"x": 250, "y": 250, "z": 40}, multi_material=True) + tower = next(f for f in out["findings"] if "tower" in f["text"]) + assert tower["text"].startswith("By size,") and "Snapmaker Orca" in tower["text"] + assert "off the bed" not in tower["text"] and "push" not in tower["text"] + + +def test_a_multi_plate_project_is_not_told_its_objects_share_one_plate(tmp_path): + settings = fx.model_settings_xml({"1": [("1", "normal_part")], "2": [("2", "normal_part")]}, + [(1, [("1", 0)]), (2, [("2", 0)])]) + path = project(tmp_path, [fx.cube_object("1", 10), fx.cube_object("2", 10)], + [("1", fx.tf(50, 50, 0)), ("2", fx.tf(400, 50, 0))], settings=settings) + text = texts(service.bed_fit(path)) + assert "share the plate" not in text and "whole arrangement must fit" not in text + assert "Whatever sits on one plate must fit that plate" in text + + +def test_overall_wording_names_size_first(): + big = bed_fit.assess({"x": 320, "y": 100, "z": 50}) + assert big["overall_text"] == ("By size, this model won't fit as-is — this is the out-of-bounds error, " + "with the fix below.") + tight = bed_fit.assess({"x": 262, "y": 120, "z": 40}) + assert tight["overall_text"] == "By size, it fits, but the edges are tight — see below before slicing." + + +# --- a size is as modelled; a scaled item shows up as a placed bound ------------------------------------------ + +def test_object_size_ignores_scale_and_turn_on_the_first_item(tmp_path): + path = project(tmp_path, [box_object("1", 10, 10, 10)], + [("1", "2 0 0 0 2 0 0 0 2 10 10 0"), ("1", "0 1 0 -1 0 0 0 0 1 50 50 0")]) + [size] = geometry.object_sizes(path) + assert size["dimensions"] == {"x": 10.0, "y": 10.0, "z": 10.0} and size["instance_count"] == 2 + placed = geometry.build_item_dims(path) + assert placed[0]["dimensions"] == {"x": 20.0, "y": 20.0, "z": 20.0} + + +def test_a_scaled_instance_beyond_the_bed_is_caught_by_placement_not_size(tmp_path): + path = project(tmp_path, [box_object("1", 100, 100, 10)], [("1", "3 0 0 0 3 0 0 0 1 10 10 0")]) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + assert result["overall_text"].startswith("By size, this model is small enough") + assert any(f["text"].startswith("By placement") for f in result["findings"] if f["level"] == "risk") + + +# --- measure once ------------------------------------------------------------------------------------------- + +def test_a_project_is_measured_once_across_the_consumers(tmp_path, monkeypatch): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="once.3mf") + calls = {"measure": 0, "read": 0} + real_measure, real_read = geometry._measure_uncached, placement._read_objects + + def counting_measure(p): + calls["measure"] += 1 + return real_measure(p) + + def counting_read(p, *args, **kw): + calls["read"] += 1 + return real_read(p, *args, **kw) + + monkeypatch.setattr(geometry, "_measure_uncached", counting_measure) + monkeypatch.setattr(placement, "_read_objects", counting_read) + project_info(path, placement_aware=True) + service.bed_fit(path) + service.report(path) + assert calls == {"measure": 1, "read": 1} + + +def test_the_measurement_cache_follows_the_file(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="chg.3mf") + assert geometry.object_sizes(path)[0]["dimensions"]["x"] == 10.0 + project(tmp_path, [fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))], name="chg.3mf") + assert geometry.object_sizes(path)[0]["dimensions"]["x"] == 30.0 + + +def test_measuring_sizes_and_items_does_not_halve_the_vertex_limit(tmp_path, monkeypatch): + path = project(tmp_path, [fx.cube_object("1", 10), fx.cube_object("2", 10)], + [("1", fx.tf(10, 10, 0)), ("2", fx.tf(60, 10, 0))], name="lim.3mf") + monkeypatch.setattr(geometry, "_MAX_VERTS", 16) # exactly the 16 vertices the file holds + items, unresolved, sizes = geometry.measure(path) + assert len(items) == 2 and len(sizes) == 2 and unresolved == [] + + +def test_two_parts_with_the_same_object_id_have_two_sizes(tmp_path): + a, b = "3D/Objects/a.model", "3D/Objects/b.model" + extra = {a: fx.sub_model_xml([fx.cube_object("1", 10)]), b: fx.sub_model_xml([fx.cube_object("1", 50)])} + path = str(fx.three_mf(tmp_path / "two.3mf", fx.model_xml( + [], [("1", fx.tf(10, 10, 0), "/" + a), ("1", fx.tf(100, 10, 0), "/" + b)]), extra)) + sizes = {s["part"]: s["dimensions"]["x"] for s in geometry.object_sizes(path)} + assert sizes == {a: 10.0, b: 50.0} + assert pp.assess(path)["available"] + + +def test_project_info_keeps_its_old_cost_unless_asked_for_placement_data(tmp_path, monkeypatch): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="plain.3mf") + monkeypatch.setattr(geometry, "_measure_uncached", lambda p: (_ for _ in ()).throw(AssertionError("read"))) + info = project_info(path) + assert info["object_sizes_mm"] is None and info["placed"] is None and info["objects_unmeasured"] is None + assert info["dimensions_mm"] == {"x": 10.0, "y": 10.0, "z": 10.0} + + +# --- review round 2 -------------------------------------------------------------------------------------- + +def test_a_connected_printers_travel_extents_are_not_a_printable_rectangle(tmp_path, monkeypatch): + from snapstudio_core import moonraker + # a real U1 reports ~271 x 335 of axis TRAVEL; its printable area is 270 x 270 from about (0.5, 1) + monkeypatch.setattr(moonraker, "capabilities", lambda host, port: { + "bed_mm": {"x": 271, "y": 335, "z": 275}, "toolhead_count": 4, + "klipper_objects": ["print_task_config", "extruder"]}) # a Snapmaker: the U1 identifies itself + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 300, 0))], name="travel.3mf") + result = service.bed_fit(path, host="u1.invalid") + assert result["bed_mm"]["y"] <= 270 # the profile volume, not the travel + assert result["bed_mm_source"] == "profile" and result["bed_known"] is False + assert "connected" not in result["bed_source"] # recorded volume, not "your printer" + assert result["overall_level"] == "risk" + assert any(f["text"].startswith("By placement") and f["level"] == "risk" for f in result["findings"]) + assert "outside" in texts(result) + inside = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="inside.3mf") + assert service.bed_fit(inside, host="u1.invalid")["overall_level"] == "ok" + + +def test_every_failing_object_is_reported_with_its_own_fix(tmp_path): + path = project(tmp_path, [box_object("1", 280, 280, 10), box_object("2", 10, 10, 1000)], + [("1", fx.tf(0, 0, 0)), ("2", fx.tf(0, 0, 0))]) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + body = " ".join(f["text"] for f in result["findings"]) + assert "object 1 is too big" in body and "object 2 is taller than" in body + assert any(fx_.startswith("object 1: Scale to") for fx_ in result["fixes"]) + assert any(fx_.startswith("object 2: Scale to") for fx_ in result["fixes"]) + assert "2 objects won't fit as-is" in result["overall_text"] + by_object = {o["object_id"]: o for o in result["objects_by_size"]} + assert by_object["1"]["level"] == "risk" and by_object["1"]["fixes"] and by_object["1"]["findings"] + assert by_object["2"]["level"] == "risk" and by_object["2"]["fixes"] and by_object["2"]["findings"] + + +def test_a_same_size_replacement_that_keeps_its_timestamp_is_not_served_stale(tmp_path): + import os + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="swap.3mf") + before = os.stat(path) + assert geometry.object_sizes(path)[0]["dimensions"]["x"] == 10.0 + project(tmp_path, [fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))], name="swap.3mf") + assert os.stat(path).st_size == before.st_size + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) # same size, same timestamp + assert os.stat(path).st_mtime_ns == before.st_mtime_ns + assert geometry.object_sizes(path)[0]["dimensions"]["x"] == 30.0 + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0)])]) + fx.three_mf(tmp_path / "fp.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))]), {SETTINGS: settings}) + fp = str(tmp_path / "fp.3mf") + stat = os.stat(fp) + assert placement.read_objects(fp, keep_points=False)["objects"][0]["footprint"]["width"] == 10.0 + fx.three_mf(tmp_path / "fp.3mf", fx.model_xml([fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))]), {SETTINGS: settings}) + os.utime(fp, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + assert placement.read_objects(fp, keep_points=False)["objects"][0]["footprint"]["width"] == 30.0 + + +def test_placed_instances_number_uses_of_the_same_object(tmp_path): + a, b = "3D/Objects/a.model", "3D/Objects/b.model" + extra = {a: fx.sub_model_xml([fx.cube_object("1", 10)]), b: fx.sub_model_xml([fx.cube_object("1", 50)])} + path = str(fx.three_mf(tmp_path / "ids.3mf", fx.model_xml( + [], [("1", fx.tf(10, 10, 0), "/" + a), ("1", fx.tf(100, 10, 0), "/" + b)]), extra)) + instances = pp.placed_instances(path)["instances"] + assert [(i["instance_index"], i["instance_count"]) for i in instances] == [(0, 1), (0, 1)] + + +def test_a_printer_that_cannot_be_identified_gets_no_placement_verdict(tmp_path, monkeypatch): + from snapstudio_core import moonraker + monkeypatch.setattr(moonraker, "capabilities", lambda host, port: { + "bed_mm": {"x": 271, "y": 335, "z": 275}, "toolhead_count": 1, "klipper_objects": ["extruder"]}) + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 300, 0))], name="unknown.3mf") + result = service.bed_fit(path, host="x.invalid") + # neither the printable size nor the placement is known: unknown, never "fits" + assert result["available"] is False and "does not know this connected printer's printable area" in result["reason"] + long = project(tmp_path, [box_object("1", 10, 300, 10)], [("1", fx.tf(10, 10, 0))], name="long.3mf") + assert service.bed_fit(long, host="x.invalid")["available"] is False # 10 x 300 x 10 is not reported as fitting + + +def test_unjudged_instances_in_a_two_plate_project_are_a_warning_never_a_fit(tmp_path): + objects = {"1": [("1", "normal_part")]} + settings = fx.model_settings_xml(objects, [(1, [("1", None)]), (2, [("1", None)])]) # no instance_id + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(50, 50, 0)), ("1", fx.tf(400, 50, 0))], + settings=settings, name="unjudged.3mf") + result = service.bed_fit(path) + text = texts(result) + assert result["overall_level"] == "warn" + assert "each of the" not in text and "found nothing off the plate" not in text + assert any(f["level"] == "warn" and f["text"].startswith("By placement") for f in result["findings"]) + + +def test_the_caches_survive_many_threads(tmp_path): + import threading + paths = [project(tmp_path, [fx.cube_object("1", 10 + i)], [("1", fx.tf(100, 100, 0))], name=f"t{i}.3mf") + for i in range(8)] + errors = [] + + def work(i): + try: + for p in paths: + geometry.measure(p) + placement.read_objects(p, keep_points=False) + except Exception as exc: # noqa: BLE001 + errors.append(exc) + + threads = [threading.Thread(target=work, args=(i,)) for i in range(8)] + [t.start() for t in threads] + [t.join() for t in threads] + assert errors == [] + + +def test_a_cube_scaled_to_300_mm_tall_is_over_height_by_placement(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 0")], name="zscale.3mf") + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + assert any(f["text"].startswith("By placement") and "stand up to 300 mm" in f["text"] and f["level"] == "risk" + for f in result["findings"]) + assert any("tall instance" in fix for fix in result["fixes"]) + + +def test_a_tall_modifier_does_not_make_the_bed_fit_check_report_over_height(tmp_path): + sub = fx.sub_model_xml([fx.cube_object("1", 10), fx.cube_object("2", 300, origin=(40, 0, 0))]) + root = fx.model_xml([fx.composite_object("100", [("1", "/3D/Objects/o.model", None), ("2", "/3D/Objects/o.model", None)])], + [("100", fx.tf(20, 20, 0))]) + settings = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "modifier_part")]}, [(1, [("100", 0)])]) + path = str(fx.three_mf(tmp_path / "mod.3mf", root, {"3D/Objects/o.model": sub, SETTINGS: settings})) + assert not any("stand up to" in f["text"] for f in service.bed_fit(path)["findings"]) + + +def test_the_size_check_counts_only_the_parts_that_print(tmp_path): + # a 10 mm printable cube and a 300 mm modifier volume in the same object + sub = fx.sub_model_xml([fx.cube_object("1", 10), fx.cube_object("2", 300, origin=(40, 0, 0))]) + root = fx.model_xml([fx.composite_object("100", [("1", "/3D/Objects/o.model", None), ("2", "/3D/Objects/o.model", None)])], + [("100", fx.tf(20, 20, 0))]) + settings = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "modifier_part")]}, [(1, [("100", 0)])]) + path = str(fx.three_mf(tmp_path / "mod.3mf", root, {"3D/Objects/o.model": sub, SETTINGS: settings})) + [size] = geometry.object_sizes(path) + assert size["dimensions"] == {"x": 10.0, "y": 10.0, "z": 10.0} + result = service.bed_fit(path) + assert result["overall_level"] == "ok", texts(result) + assert result["dims_mm"] == {"x": 10.0, "y": 10.0, "z": 10.0} + report = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert report["status"] == "pass" and "10.0 × 10.0 × 10.0 mm" in report["detail"] + # a tall PRINTABLE part is still too tall + settings2 = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "normal_part")]}, [(1, [("100", 0)])]) + path2 = str(fx.three_mf(tmp_path / "tall.3mf", root, {"3D/Objects/o.model": sub, SETTINGS: settings2})) + assert service.bed_fit(path2)["overall_level"] == "risk" + + +def test_a_part_sunk_below_the_plane_is_a_point_to_check_in_the_bed_fit(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 -100")], name="sunk.3mf") + result = service.bed_fit(path) + assert result["overall_level"] != "ok" + assert any(f["text"].startswith("By placement") and "below the build plate" in f["text"] for f in result["findings"]) diff --git a/backend/tests/test_size_vs_placed_bounds.py b/backend/tests/test_size_vs_placed_bounds.py new file mode 100644 index 00000000..f9f4b9cf --- /dev/null +++ b/backend/tests/test_size_vs_placed_bounds.py @@ -0,0 +1,203 @@ +"""Issue 91: the size of an object is not where it sits. + +Size-only statements (how big is each object) and placed statements (where is each instance, on which +plate) are separate facts. Each consumer says which one it uses; a size never claims a position, and the +combined extents of separated objects or plates never become "scale or split" advice. +""" +from __future__ import annotations + +import pytest + +from snapstudio_api import service +from snapstudio_core import bed_fit, geometry, plate_placement as pp +from snapstudio_core.intelligence import project_info +from tests import scene_fixtures as fx + +SETTINGS = "Metadata/model_settings.config" + + +def box_object(oid, dx, dy, dz, origin=(0.0, 0.0, 0.0)): + ox, oy, oz = origin + verts = [(ox, oy, oz), (ox + dx, oy, oz), (ox + dx, oy + dy, oz), (ox, oy + dy, oz), + (ox, oy, oz + dz), (ox + dx, oy, oz + dz), (ox + dx, oy + dy, oz + dz), (ox, oy + dy, oz + dz)] + return f'{fx.mesh_xml(verts, fx.CUBE_TRIS)}' + + +def project(tmp_path, objects, items, name="p.3mf", settings=None, unit="millimeter"): + extra = {SETTINGS: settings} if settings else None + return str(fx.three_mf(tmp_path / name, fx.model_xml(objects, items, unit=unit), extra)) + + +def texts(result): + return " ".join(f["text"] for f in result["findings"]) + " " + result["overall_text"] + + +# --- size is per object and before any build item acts on it ---------------------------------------- + +def test_object_size_ignores_where_the_item_puts_the_object(tmp_path): + path = project(tmp_path, [box_object("1", 100, 20, 10)], + [("1", fx.tf(500, 40, 0)), ("1", "0 1 0 -1 0 0 0 0 1 50 50 0")]) # moved; and turned 90 degrees + sizes = geometry.object_sizes(path) + assert sizes == [{"object_id": "1", "part": "3D/3dmodel.model", "instance_count": 2, + "dimensions": {"x": 100.0, "y": 20.0, "z": 10.0}}] + placed = geometry.build_item_dims(path) + assert placed[0]["dimensions"] == {"x": 100.0, "y": 20.0, "z": 10.0} + assert placed[1]["dimensions"] == {"x": 20.0, "y": 100.0, "z": 10.0} # placed bounds follow the turn + + +def test_object_size_is_in_millimetres_for_an_inch_project(tmp_path): + path = project(tmp_path, [box_object("1", 12, 1, 1)], [("1", fx.tf(0, 0, 0))], unit="inch") + assert geometry.object_sizes(path)[0]["dimensions"] == {"x": 304.8, "y": 25.4, "z": 25.4} + info = project_info(path, placement_aware=True) + assert info["object_sizes_mm"][0]["dimensions_mm"]["x"] == 304.8 + + +def test_project_info_keeps_size_and_placed_apart(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + info = project_info(path, placement_aware=True) + assert info["dimensions_basis"] == "overall_extents_by_size" + assert info["object_sizes_mm"] == [{"object_id": "1", "part": "3D/3dmodel.model", "instance_count": 2, + "dimensions_mm": {"x": 10.0, "y": 10.0, "z": 10.0}}] + placed = info["placed"] + assert placed["available"] and placed["basis"] == "placed_bounds" and placed["plate_count"] == 1 + assert [i["bounds_mm"]["min"][0] for i in placed["instances"]] == [500.0, 100.0] + assert placed["combined_extent_mm"] == {"x": 410.0, "y": 10.0} + + +def test_stl_has_a_size_and_no_position(tmp_path): + path = str(fx.binary_stl(tmp_path / "c.stl", 20.0)) + info = project_info(path, placement_aware=True) + assert info["object_sizes_mm"][0]["dimensions_mm"] == {"x": 20.0, "y": 20.0, "z": 20.0} + assert info["placed"]["available"] is False and info["placed"]["instances"] == [] + + +# --- translated: a small object moved off the plate ----------------------------------------------------- + +def test_a_small_object_moved_off_the_plate_is_not_reported_as_fitting(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0))]) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + text = texts(result) + assert "By size" in text and "small enough" in result["overall_text"] + assert "By placement" in text and "outside" in text + assert "too big" not in text.lower() and not any("scale to" in f.lower() for f in result["fixes"]) + assert result["basis"] == "size_and_placement" and result["placement_checked"] is True + + +def test_the_size_check_alone_claims_no_position(tmp_path): + out = bed_fit.assess({"x": 10, "y": 10, "z": 10}) + assert out["basis"] == "size" and out["placement_checked"] is False + assert out["overall_text"].startswith("By size") + assert "not checked" in out["overall_text"] + assert all(f["text"].startswith("By size") for f in out["findings"]) + + +def test_a_small_object_inside_the_plate_is_ok_by_size_and_placement(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))]) + result = service.bed_fit(path) + assert result["overall_level"] == "ok" + assert "By placement, every placed instance sits inside the plate" in texts(result) + + +# --- separated: meshes far apart in their own coordinates, objects close together on the plate ---------- + +def _separated(tmp_path): + # Object 2's mesh is authored 500 mm from object 1's; its build item brings it back beside it. + return project(tmp_path, + [box_object("1", 20, 20, 20), box_object("2", 20, 20, 20, origin=(500, 0, 0))], + [("1", fx.tf(50, 50, 0)), ("2", fx.tf(-400, 50, 0))]) + + +def test_separated_meshes_are_not_one_big_object(tmp_path): + path = _separated(tmp_path) + info = project_info(path, placement_aware=True) + assert info["dimensions_mm"]["x"] == 520.0 # the overall extents, as before + assert [o["dimensions_mm"]["x"] for o in info["object_sizes_mm"]] == [20.0, 20.0] + # the legacy overall figure would say "too big, scale to 52%"; the per-object check does not + assert bed_fit.assess(info["dimensions_mm"])["overall_level"] == "risk" + result = service.bed_fit(path) + assert result["overall_level"] == "ok", texts(result) + assert result["objects_checked"] == 2 + assert "too big" not in texts(result).lower() and result["fixes"] == [] + assert pp.assess(path)["off_plate"] == [] + + +def test_separated_report_check_is_by_size_per_object(tmp_path): + check = next(c for c in service.report(_separated(tmp_path))["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "pass" and check["detail"].startswith("By size, the largest object (object ") + assert "20.0 × 20.0 × 20.0 mm" in check["detail"] and "largest object" in check["detail"] + + +def test_report_names_the_object_that_is_too_big_by_size(tmp_path): + path = project(tmp_path, [box_object("1", 20, 20, 20), box_object("2", 300, 20, 20)], + [("1", fx.tf(10, 10, 0)), ("2", fx.tf(10, 100, 0))]) + check = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "warn" + assert check["detail"].startswith("By size, object 2 (300.0 × 20.0 × 20.0 mm) is larger than the U1 bed") + result = service.bed_fit(path) + assert result["overall_level"] == "risk" and "By size, object 2 is too big" in texts(result) + + +def test_objects_far_apart_on_one_plate_are_a_placement_problem_not_a_size_problem(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 20), fx.cube_object("2", 20)], + [("1", fx.tf(10, 10, 0)), ("2", fx.tf(400, 10, 0))]) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + assert all(o["level"] == "ok" for o in result["objects_by_size"]) # each object is small enough + assert "By placement" in texts(result) and "By size, object" not in " ".join( + f["text"] for f in result["findings"] if f["level"] == "risk") + + +# --- multi-plate ------------------------------------------------------------------------------------------ + +def _two_plates(tmp_path, second_x=-130.0, second_size=20): + settings = fx.model_settings_xml({"1": [("1", "normal_part")], "2": [("2", "normal_part")]}, + [(1, [("1", 0)]), (2, [("2", 0)])]) + # plate 2 sits 400 mm along the shared grid: its object's mesh is authored there + return project(tmp_path, + [box_object("1", 20, 20, 20), box_object("2", second_size, 20, 20, origin=(400, 0, 0))], + [("1", fx.tf(50, 50, 0)), ("2", fx.tf(second_x, 50, 0))], settings=settings) + + +def test_a_multi_plate_project_has_no_combined_extent(tmp_path): + path = _two_plates(tmp_path) + placed = project_info(path, placement_aware=True)["placed"] + assert placed["plate_count"] == 2 and placed["combined_extent_mm"] is None + assert [(p["plate"], p["instances"]) for p in placed["plate_extents"]] == [(1, 1), (2, 1)] + assert [i["plate"] for i in placed["instances"]] == [1, 2] + + +def test_multi_plate_extents_do_not_become_scale_or_split_advice(tmp_path): + path = _two_plates(tmp_path) + info = project_info(path, placement_aware=True) + assert info["dimensions_mm"]["x"] > 400.0 # overall: 420 across both plates + result = service.bed_fit(path) + text = texts(result) + assert result["overall_level"] == "ok", text + assert "each of the 2 plates fits on its own" in text and "Positions across plates are not checked" in text + assert "scale" not in text.lower() and result["fixes"] == [] + + +def test_a_plate_whose_own_contents_do_not_fit_is_named(tmp_path): + path = _two_plates(tmp_path, second_size=300) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + assert any(f["text"].startswith("By placement, plate 2:") for f in result["findings"]) + assert any(f["text"].startswith("By size, object 2 is too big") for f in result["findings"]) + + +def test_report_check_for_multi_plate_is_still_by_size_per_object(tmp_path): + check = next(c for c in service.report(_two_plates(tmp_path))["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "pass" and check["detail"].startswith("By size, the largest object (object ") + + +# --- the legacy figure is still there for readers that only want a size --------------------------------- + +def test_dimensions_mm_is_unchanged_for_a_single_object(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 30)], [("1", fx.tf(50, 60, 0))]) + assert project_info(path, placement_aware=True)["dimensions_mm"] == {"x": 30.0, "y": 30.0, "z": 30.0} + + +@pytest.mark.parametrize("dims", [None, {"x": None, "y": 1, "z": 1}]) +def test_unmeasurable_size_stays_unavailable(dims): + assert bed_fit.assess_objects([], fallback_dims=dims)["available"] is False diff --git a/backend/tests/test_travel_and_move_writer.py b/backend/tests/test_travel_and_move_writer.py new file mode 100644 index 00000000..03670df7 --- /dev/null +++ b/backend/tests/test_travel_and_move_writer.py @@ -0,0 +1,133 @@ +"""Review round 3: travel extents are not a printable area; malformed p:path fails closed; the move writer +leaves every token but the X/Y translation alone.""" +from __future__ import annotations + +import zipfile + +from snapstudio_api import service +from snapstudio_core import geometry, placement, plate_placement as pp +from tests import scene_fixtures as fx + +A = "3D/Objects/a.model" + + +def _facts(identity): + return lambda host=None, port=7125: { + "reachable": True, "host": "h", "port": 7125, "toolhead_count": 4, "klipper_objects": [], + "bed_mm": {"x": 271, "y": 335, "z": 275}, "print_state": "standby", "identity": identity} + + +def _y300(tmp_path): + return str(fx.three_mf(tmp_path / "y.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 300, 0))]))) + + +def test_a_u1_is_judged_against_its_printable_area_not_its_travel(tmp_path, monkeypatch): + seen = {} + real = pp.assess + + def spy(path, bed=None, bed_name=None, height_mm=None): + seen["bed"] = bed + return real(path, bed=bed, bed_name=bed_name, height_mm=height_mm) + + monkeypatch.setattr(pp, "assess", spy) + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) + out = service.preflight(_y300(tmp_path), "h") + assert seen["bed"] is None # the profile polygon, not 271 x 335 + check = next(c for c in out["checks"] if c["id"] == "bed.fit") + assert check["result"] == "attention" and "335" not in check["evidence"] + + +def test_a_printer_that_cannot_be_identified_has_an_unknown_printable_area(tmp_path, monkeypatch): + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": None})) + out = service.preflight(_y300(tmp_path), "h") + check = next(c for c in out["checks"] if c["id"] == "bed.fit") + assert check["result"] == "unknown" and check["confidence"] != "confirmed" + assert "printable area" in check["evidence"] + + +def test_a_malformed_part_path_is_unresolved_never_the_root_object(tmp_path): + for bad in ("", "3D/Objects/a.model", "/../x.model", "http://x/a.model"): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0)), ("1", fx.tf(100, 100, 0), bad)]) + path = str(fx.three_mf(tmp_path / "bad.3mf", root)) + items, unresolved = geometry.measure_items(path) + assert [i["item_index"] for i in items] == [0], bad + assert [u["item_index"] for u in unresolved] == [1], bad + + +def test_a_malformed_component_path_is_unresolved(tmp_path): + for bad in ("", "3D/Objects/a.model"): + comp = fx.composite_object("5", [("1", bad, None)]) + extra = {A: fx.sub_model_xml([fx.cube_object("1", 10)])} + path = str(fx.three_mf(tmp_path / "c.3mf", fx.model_xml([fx.cube_object("1", 10), comp], [("5", fx.tf(10, 10, 0))]), extra)) + assert [u["item_index"] for u in geometry.measure_items(path)[1]] == [0], bad + assert placement.read_objects(path)["objects"][0]["resolved"] is False, bad + + +def test_the_move_writer_keeps_every_other_token_verbatim(): + out = pp._shift_transform("0.7071067812 0.7071067812 0 -0.7071067812 0.7071067812 0 0 0 1 100.123456789 200 5", 6, 0) + tokens = out.split() + assert tokens[:9] == ["0.7071067812", "0.7071067812", "0", "-0.7071067812", "0.7071067812", "0", "0", "0", "1"] + assert tokens[9] == "106.123457" and tokens[10] == "200" and tokens[11] == "5" + + +def test_a_rotated_project_is_moved_and_verified(tmp_path): + rot = "0.7071067812 0.7071067812 0 -0.7071067812 0.7071067812 0 0 0 1" + src = fx.three_mf(tmp_path / "rot.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", f"{rot} -5 100 0"), ("1", f"{rot} 100 100 0")])) + result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) + assert result["ok"] is True, result.get("reason") + assert result["verification"]["passed"] is True + with zipfile.ZipFile(result["output_path"]) as z: + assert z.read("3D/3dmodel.model").decode().count(rot) == 2 + + +def test_a_tall_object_is_not_confirmed_by_preflight_even_when_it_is_on_the_plate(tmp_path, monkeypatch): + path = str(fx.three_mf(tmp_path / "tall.3mf", fx.model_xml( + [fx.cube_object("1", 10).replace('z="10.0"', 'z="300.0"')], [("1", fx.tf(100, 100, 0))]))) + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) + check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") + assert check["result"] == "attention" and "height limit" in check["evidence"] + assert check["confidence"] == "confirmed" and "300" in check["evidence"] + + +def test_a_tall_modifier_does_not_make_a_short_part_over_height(tmp_path): + # a 10 mm printable part and a 300 mm modifier volume on the same object + sub = fx.sub_model_xml([fx.cube_object("1", 10), fx.cube_object("2", 300, origin=(40, 0, 0))]) + root = fx.model_xml([fx.composite_object("100", [("1", "/3D/Objects/o.model", None), ("2", "/3D/Objects/o.model", None)])], + [("100", fx.tf(20, 20, 0))]) + settings = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "modifier_part")]}, [(1, [("100", 0)])]) + path = str(fx.three_mf(tmp_path / "mod.3mf", root, {"3D/Objects/o.model": sub, "Metadata/model_settings.config": settings})) + report = pp.assess(path) + assert report["available"] and report["items"][0]["bounds_mm"]["max"][2] == 10.0 + assert pp.over_height(report) == [] + # and a tall PRINTABLE part is over height + settings2 = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "normal_part")]}, [(1, [("100", 0)])]) + path2 = str(fx.three_mf(tmp_path / "tall2.3mf", root, {"3D/Objects/o.model": sub, "Metadata/model_settings.config": settings2})) + assert len(pp.over_height(pp.assess(path2))) == 1 + + +def test_a_part_sunk_below_the_build_plane_is_not_confirmed_to_fit(tmp_path, monkeypatch): + # a 10 mm cube scaled 30x in Z and moved to Z=-100 spans -100..200: under 270, but below the plate + path = str(fx.three_mf(tmp_path / "sunk.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 -100")]))) + assert len(pp.below_plate(pp.assess(path))) == 1 and pp.over_height(pp.assess(path)) == [] + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) + check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") + assert check["result"] == "attention" and check["confidence"] == "likely" + assert "below the build plate" in check["evidence"] and "-100" in check["evidence"] + + +def test_a_part_resting_on_the_plate_is_not_below_it(tmp_path): + path = str(fx.three_mf(tmp_path / "flat.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))]))) + assert pp.below_plate(pp.assess(path)) == [] + + +def test_the_bed_check_source_names_the_printer_it_judged_against(): + from snapstudio_core import preflight as pf + placement = {"available": True, "off_plate": [], "bed_name": "the Voron 2.4 250's", "bed_height_mm": 250.0, + "bed": {"min_x": 0.0, "min_y": 0.0, "max_x": 250.0, "max_y": 250.0}, + "items": [{"object_id": "1", "bounds_mm": {"min": [10, 10, 0], "max": [20, 20, 30]}}]} + out = pf.evaluate({}, {"reachable": True, "toolhead_count": 1, "bed_mm": {"x": 250, "y": 250, "z": 250}, + "klipper_objects": [], "print_state": "standby"}, placement=placement) + source = next(c for c in out["checks"] if c["id"] == "bed.fit")["source"] + assert "Voron" in source and "U1" not in source diff --git a/backend/tests/test_units_consumers.py b/backend/tests/test_units_consumers.py new file mode 100644 index 00000000..393009c3 --- /dev/null +++ b/backend/tests/test_units_consumers.py @@ -0,0 +1,213 @@ +"""The 3MF `unit` header, applied once (snapstudio_core.units) and checked at every consumer. + +Before this fix several readers ignored the header, so a 12 inch part was reported as 12 mm and bed-fit +statements were wrong for any non-millimetre project. A part that is 12 inches must read 304.8 mm +EVERYWHERE; millimetre projects must read exactly as they always did. +""" +import pytest + +from snapstudio_core import units +from tests import scene_fixtures as fx + +UNITS = sorted(units.MM_PER_UNIT.items()) + + +def project(tmp_path, unit, size_mm=30.0, at_units=(0.0, 0.0, 0.0), name="u.3mf"): + """A cube whose true edge is `size_mm`, written in `unit`, placed `at_units` (in that unit).""" + f = units.MM_PER_UNIT[unit] + return str(fx.three_mf(tmp_path / name, fx.model_xml([fx.cube_object("1", size_mm / f)], [("1", fx.tf(*at_units))], unit=unit))) + + +def composite(tmp_path, unit, size_units, at_units, name="c.3mf"): + """The Bambu/Orca shape (one composite object over a mesh in 3D/Objects/): the only shape + placement.read_objects reads.""" + return str(fx.bambu_project(tmp_path / name, parts=1, items=[("100", fx.tf(*at_units))], plates=[(1, [("100", 0)])], + unit=unit, sizes=[size_units], origins=[(0.0, 0.0, 0.0)])) + + +def twelve_inch(tmp_path, at=(0.0, 0.0, 0.0)): + return str(fx.three_mf(tmp_path / "in.3mf", fx.model_xml([fx.cube_object("1", 12)], [("1", fx.tf(*at))], unit="inch"))) + + +# ------------------------------------------------------------------ the shared table + +def test_table_has_the_six_units_and_reads_the_header(): + assert units.MM_PER_UNIT == {"micron": 0.001, "millimeter": 1.0, "centimeter": 10.0, "inch": 25.4, + "foot": 304.8, "meter": 1000.0} + assert units.unit_of(b'') == ("inch", 25.4) + assert units.unit_of(b"") == ("foot", 304.8) + assert units.unit_of(b"") == ("millimeter", 1.0) # absent attribute = default + assert units.unit_of(b'') == ("yard", None) # unknown is surfaced, not guessed + assert units.mm_per_unit(b'') == 1.0 # legacy readers keep treating it as mm + info = units.read_unit(b'') # not a element + assert (info.recognized, info.declared, info.factor) == (False, False, 1.0) # unreadable is not "absent" + assert units.read_unit(b"this is not xml").recognized is False + assert units.read_unit(b"").recognized is False and units.read_unit(b"").recognized is True + assert units.scale_translation([1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 3, 4], 25.4)[9:] == [2 * 25.4, 3 * 25.4, 4 * 25.4] + same = [1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 3, 4] + assert units.scale_translation(same, 1.0) is same + + +# ------------------------------------------------------------------ every dimension source + +@pytest.mark.parametrize("unit,factor", UNITS) +def test_project_info_and_its_service_wrappers(tmp_path, unit, factor): + from snapstudio_api import service + from snapstudio_core.intelligence import project_info + path = project(tmp_path, unit) + want = {"x": 30.0, "y": 30.0, "z": 30.0} + assert project_info(path)["dimensions_mm"] == want + assert service.insights(path)["dimensions_mm"] == want # DesignHealth data path + assert service.strategy_recommend(path)["signals"]["dimensions_mm"] == want # strategy signals (~490/522/541) + + +@pytest.mark.parametrize("unit,factor", UNITS) +def test_canonical_and_layout_and_validation_report(tmp_path, unit, factor): + from snapstudio_api import service + from snapstudio_core import layout + path = project(tmp_path, unit) + assert service.canonical(path)["dimensions_mm"] == {"x": 30.0, "y": 30.0, "z": 30.0} # canonical.py:92 + rep = service.report(path) # validation_report.py:35 + fits = next(c for c in rep["checks"] if c["name"] == "Fits the print bed") + assert fits["status"] == "pass" and "30.0 × 30.0 × 30.0 mm" in fits["detail"] + lay = layout.assess_layout(path) # layout.py:55 + assert lay["status"] == "pass", lay + + +def test_twelve_inch_part_reads_304_8_mm_everywhere(tmp_path): + from snapstudio_api import service + from snapstudio_core import geometry, layout, plate_placement, placement, scale_doctor + from snapstudio_core.intelligence import project_info + path = twelve_inch(tmp_path) + full = {"x": 304.8, "y": 304.8, "z": 304.8} + assert project_info(path)["dimensions_mm"] == full + assert service.insights(path)["dimensions_mm"] == full + assert service.canonical(path)["dimensions_mm"] == full + assert service.strategy_recommend(path)["signals"]["dimensions_mm"] == full + # bed_fit (via its service caller): the model does NOT fit a 270 mm plate + bf = service.bed_fit(path) + assert bf["dims_mm"] == full and bf["overall_level"] == "risk" + assert any("305" in f["text"] for f in bf["findings"]) + # validation report + fits = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert fits["status"] == "warn" and "304.8" in fits["detail"] + # layout + assert layout.assess_layout(path)["status"] == "fail" + # geometry + item = geometry.build_item_dims(path)[0] + assert item["dimensions"] == {"x": 304.8, "y": 304.8, "z": 304.8} + mesh = geometry.load_mesh(path) + assert max(v[0] for v in mesh.verts) == pytest.approx(304.8) + # placement + obj = placement.read_objects(composite(tmp_path, "inch", 12, (0, 0, 0)))["objects"][0] + assert obj["footprint"]["width"] == pytest.approx(304.8) + assessed = plate_placement.assess(path) + assert assessed["items"][0]["dimensions"] == full and assessed["off_plate"] + # scale doctor + opts = scale_doctor.scale_options(path) + assert opts["current_parts"][0]["dimensions"] == full + prev = scale_doctor.preview(path, 100) + assert prev["original_dimensions"] == full and prev["fits_build_volume"] is False + + +@pytest.mark.parametrize("unit,factor", UNITS) +def test_build_item_translation_is_in_the_models_unit(tmp_path, unit, factor): + """A transform's translation is a length in the model's unit too (an inch offset is not 1 mm).""" + from snapstudio_core import geometry, placement + path = project(tmp_path, unit, size_mm=20.0, at_units=(10.0 / factor, 20.0 / factor, 0.0)) + item = geometry.build_item_dims(path)[0] + assert item["bounds"]["min"][0] == pytest.approx(10.0, abs=1e-6) + assert item["bounds"]["max"][1] == pytest.approx(40.0, abs=1e-6) + comp = composite(tmp_path, unit, 20.0 / factor, (10.0 / factor, 20.0 / factor, 0.0), name="comp.3mf") + fp = placement.read_objects(comp)["objects"][0]["footprint"] + assert (fp["min_x"], fp["max_y"]) == (pytest.approx(10.0, abs=1e-6), pytest.approx(40.0, abs=1e-6)) + + +def test_plate_placement_judges_an_inch_project_in_millimetres(tmp_path): + """3 inches in is 76.2 mm in: on the plate. 12 inches in is 304.8 mm in: off it.""" + from snapstudio_core import plate_placement + inside = project(tmp_path, "inch", size_mm=25.4, at_units=(3.0, 3.0, 0.0), name="a.3mf") + r = plate_placement.assess(inside) + assert not r["off_plate"] and r["items"][0]["position"] == {"x": 88.9, "y": 88.9} + outside = project(tmp_path, "inch", size_mm=25.4, at_units=(12.0, 3.0, 0.0), name="b.3mf") + assert plate_placement.assess(outside)["off_plate"] + + +def test_sub_model_units_are_per_part(tmp_path): + from snapstudio_core import geometry, placement + from snapstudio_core.intelligence import project_info + sub = fx.sub_model_xml([fx.cube_object("1", 2)], unit="centimeter") # a 20 mm cube + root = fx.model_xml([fx.composite_object("10", [("1", "/3D/Objects/a.model", None)])], [("10", fx.tf(5, 0, 0))]) + path = str(fx.three_mf(tmp_path / "m.3mf", root, {"3D/Objects/a.model": sub})) + assert project_info(path)["dimensions_mm"] == {"x": 20.0, "y": 20.0, "z": 20.0} + item = geometry.build_item_dims(path)[0] + assert item["bounds"]["min"][0] == pytest.approx(5.0) and item["bounds"]["max"][0] == pytest.approx(25.0) + assert placement.read_objects(path)["objects"][0]["footprint"]["width"] == pytest.approx(20.0) + + +# ------------------------------------------------------------------ millimetre behaviour is unchanged + +def test_millimetre_projects_read_exactly_as_before(tmp_path): + from snapstudio_core import geometry, placement + from snapstudio_core.intelligence import project_info + path = str(fx.three_mf(tmp_path / "mm.3mf", fx.model_xml([fx.cube_object("1", 12.3456)], [("1", fx.tf(1.25, 2.5, 0))]))) + assert project_info(path)["dimensions_mm"] == {"x": 12.3, "y": 12.3, "z": 12.3} + item = geometry.build_item_dims(path)[0] + assert item["bounds"] == {"min": (1.25, 2.5, 0.0), "max": (1.25 + 12.3456, 2.5 + 12.3456, 12.3456)} + fp = placement.read_objects(composite(tmp_path, "millimeter", 12.3456, (1.25, 2.5, 0)))["objects"][0]["footprint"] + assert (fp["min_x"], fp["min_y"]) == (1.25, 2.5) + # no unit attribute at all is millimetre + bare = fx.model_xml([fx.cube_object("1", 7)], [("1", None)]).replace(' unit="millimeter"', "") + assert project_info(str(fx.three_mf(tmp_path / "bare.3mf", bare)))["dimensions_mm"] == {"x": 7.0, "y": 7.0, "z": 7.0} + + +# ------------------------------------------------------------------ repair round 1: A3 / D1 / A15 + +def _root_translation(copy_path): + import re + import zipfile + with zipfile.ZipFile(copy_path) as z: + text = z.read("3D/3dmodel.model").decode() + return [float(x) for x in re.search(r"]*transform=\"([^\"]*)\"", text).group(1).split()] + + +def test_d1_the_placement_fix_moves_an_inch_model_by_the_right_amount(tmp_path): + from snapstudio_core import plate_placement + path = project(tmp_path, "inch", size_mm=25.4, at_units=(11.0, 3.0, 0.0)) + assert plate_placement.assess(path)["off_plate"] # 11 in + 1 in = 304.8 mm: off the 270 mm plate + res = plate_placement.prepare_placed_copy(path, out_dir=str(tmp_path / "out")) + assert res["ok"] is True, res.get("reason") + tx, ty, tz = _root_translation(res["output_path"])[9:12] + dx, dy = res["offset_mm"]["x"], res["offset_mm"]["y"] + assert tx == pytest.approx(11 + dx / 25.4, abs=1e-3) and ty == pytest.approx(3 + dy / 25.4, abs=1e-3) and tz == 0 + assert res["after"]["available"] and not res["after"]["off_plate"] # and it really lands on the plate + assert plate_placement.assess(res["output_path"])["off_plate"] == [] + + +def test_d1_a_move_that_did_not_work_leaves_no_copy_on_disk(tmp_path, monkeypatch): + from snapstudio_core import plate_placement + path = project(tmp_path, "inch", size_mm=25.4, at_units=(11.0, 3.0, 0.0)) + monkeypatch.setattr(plate_placement, "_rewrite_items", lambda raw, offset_for: (raw, 1)) # a move that moves nothing + monkeypatch.setattr(plate_placement, "verify_only_placement_moved", lambda a, b: {"passed": True, "checks": []}) + out = tmp_path / "out" + res = plate_placement.prepare_placed_copy(path, out_dir=str(out)) + assert res["ok"] is False and "output_path" not in res + assert not list(out.glob("*.3mf")) + + +def test_d1_millimetre_models_get_exactly_the_offset_they_always_got(tmp_path): + from snapstudio_core import plate_placement + path = str(fx.three_mf(tmp_path / "mm.3mf", fx.model_xml([fx.cube_object("1", 25.4)], [("1", fx.tf(290.0, 3.0, 0.0))]))) + res = plate_placement.prepare_placed_copy(path, out_dir=str(tmp_path / "out")) + assert res["ok"] is True + tx, ty, _ = _root_translation(res["output_path"])[9:12] + assert tx == pytest.approx(290.0 + res["offset_mm"]["x"], abs=1e-4) + + +def test_a15_placement_uses_the_shared_translation_scaler(): + from snapstudio_core import placement + assert not hasattr(placement, "_scale_translation") + rows = ((1, 0, 0), (0, 1, 0), (0, 0, 1), (1.0, 2.0, 3.0)) + assert units.scale_translation(rows, 25.4)[3] == pytest.approx((25.4, 50.8, 76.2)) + assert units.scale_translation(rows, 1.0) is rows + assert units.scale_translation(None, 25.4) is None diff --git a/backend/tools/scene_benchmark.py b/backend/tools/scene_benchmark.py new file mode 100644 index 00000000..fa66c50a --- /dev/null +++ b/backend/tools/scene_benchmark.py @@ -0,0 +1,303 @@ +"""Benchmark and budget-coverage report for scene/1. + + py -3.13 backend/tools/scene_benchmark.py --out docs/testing/scene-contract/results.json \ + [--real-world DIR] [--local-root DIR ...] [--local-sample 5] + +Measures, per corpus item: whether it fits the budgets, parse+encode time (cold and warm best-of-N), +peak extra memory (tracemalloc peak AND a sampled working-set delta), response size and counts. Then the +hard gates (100k-triangle fixture, cancel latency, 50-cycle leak check) and the coverage fraction. + +Privacy: items from outside the repository (``--local-root``) are recorded ONLY as anonymized ids +``P-01..P-05`` with byte size and triangle count. Names, paths and contents are never written. Nothing +here modifies any input; every file is only read. +""" +from __future__ import annotations + +import argparse +import json +import os +import platform +import statistics +import sys +import tempfile +import threading +import time +import tracemalloc +from pathlib import Path + +HERE = Path(__file__).resolve().parent +BACKEND = HERE.parent +REPO = BACKEND.parent +sys.path.insert(0, str(BACKEND)) + +from snapstudio_core import scene, scene_limits as L # noqa: E402 +from tests import scene_fixtures as fx # noqa: E402 + +MIB = 1024 * 1024 + + +def rss_bytes() -> int: + if sys.platform == "win32": + import ctypes + from ctypes import wintypes + + class PMC(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), + ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)] + k32 = ctypes.WinDLL("kernel32") + psapi = ctypes.WinDLL("psapi") + k32.GetCurrentProcess.restype = wintypes.HANDLE + psapi.GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(PMC), wintypes.DWORD] + counters = PMC() + counters.cb = ctypes.sizeof(PMC) + psapi.GetProcessMemoryInfo(k32.GetCurrentProcess(), ctypes.byref(counters), counters.cb) + return int(counters.WorkingSetSize) + with open("/proc/self/statm") as fh: + return int(fh.read().split()[1]) * os.sysconf("SC_PAGE_SIZE") + + +def machine() -> dict: + total = None + try: + if sys.platform == "win32": + import ctypes + + class MS(ctypes.Structure): + _fields_ = [("l", ctypes.c_ulong), ("m", ctypes.c_ulong), ("tp", ctypes.c_ulonglong), ("ap", ctypes.c_ulonglong), + ("tpf", ctypes.c_ulonglong), ("apf", ctypes.c_ulonglong), ("tv", ctypes.c_ulonglong), + ("av", ctypes.c_ulonglong), ("ae", ctypes.c_ulonglong)] + ms = MS() + ms.l = ctypes.sizeof(MS) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(ms)) + total = ms.tp + else: + total = os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE") + except Exception: # noqa: BLE001 + pass + return {"platform": platform.platform(), "python": platform.python_version(), "cpu_count": os.cpu_count(), + "processor": platform.processor() or None, "ram_gib": round(total / 1024 ** 3, 1) if total else None} + + +class Sampler(threading.Thread): + """Samples the working set while a measurement runs.""" + + def __init__(self) -> None: + super().__init__(daemon=True) + self.stop = threading.Event() + self.peak = 0 + + def run(self) -> None: + while not self.stop.is_set(): + self.peak = max(self.peak, rss_bytes()) + time.sleep(0.005) + + +LIFTED = {"MAX_PARSED_TRIANGLES": 2_000_000, "MAX_VERTICES": 2_000_000, "MAX_RENDERED_TRIANGLES": 2_000_000, + "MAX_RESPONSE_BYTES": 256 * MIB, "MODEL_XML_BYTES": 512 * MIB, "ARCHIVE_EXPANSION_BYTES": 1024 * MIB, + "MAX_NODES": 50_000} + + +def lifted(path: str) -> dict: + """Evidence for a budget decision: what the refused file would cost if the budgets were raised + (to 2M triangles / 256 MiB). Reports what it measures; never ships those numbers as limits.""" + saved = {k: getattr(L, k) for k in LIFTED} + try: + for k, v in LIFTED.items(): + setattr(L, k, v) + t = time.perf_counter() + try: + sc = scene.build_scene_dict(path, "0" * 64) + body = scene.serialize(sc) + except scene.SceneError as exc: + return {"still_refused": exc.code, "message": exc.message} + return {"triangles": sc["counts"]["triangles"], "rendered_triangles": sc["counts"]["rendered_triangles"], + "vertices": sc["counts"]["vertices"], "nodes": sc["counts"]["nodes"], "response_bytes": len(body), + "time_s": round(time.perf_counter() - t, 2)} + finally: + for k, v in saved.items(): + setattr(L, k, v) + + +def measure(path: str, warm_runs: int) -> dict: + size = os.path.getsize(path) + rec: dict = {"bytes": size} + ceiling = size > L.SNAPSHOT_MAX_BYTES + rec["job_gate"] = "SNAPSHOT_CEILING" if ceiling else "ok" + try: + t = time.perf_counter() + body = scene.build_scene(path, "0" * 64) + cold = time.perf_counter() - t + except scene.SceneError as exc: + rec.update(result="refused", code=exc.code, message=exc.message, fits_budgets=False) + rec["lifted"] = lifted(path) + return rec + sc = json.loads(body) + rec.update(result="ok", code=None, status=sc["status"], fits_budgets=not ceiling, + triangles=sc["counts"]["triangles"], rendered_triangles=sc["counts"]["rendered_triangles"], + vertices=sc["counts"]["vertices"], nodes=sc["counts"]["nodes"], meshes=sc["counts"]["meshes"], + response_bytes=len(body), limitations=[l["code"] for l in sc["limitations"]], + cold_s=round(cold, 3)) + times = [] + for _ in range(warm_runs): + t = time.perf_counter() + scene.build_scene(path, "0" * 64) + times.append(time.perf_counter() - t) + rec["warm_best_s"] = round(min(times), 3) + rec["warm_median_s"] = round(statistics.median(times), 3) + base = rss_bytes() + sampler = Sampler() + sampler.start() + tracemalloc.start() + scene.build_scene(path, "0" * 64) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + sampler.stop.set() + sampler.join() + rec["tracemalloc_peak_mib"] = round(peak / MIB, 1) + rec["rss_delta_mib"] = round(max(0, sampler.peak - base) / MIB, 1) + return rec + + +def corpus(tmp: Path, args) -> list[tuple[str, str, str]]: + """(id, class, path). Synthetic fixtures are authored here; nothing private is named.""" + items: list[tuple[str, str, str]] = [] + items.append(("S-cube", "synthetic", str(fx.plain_cube_3mf(tmp / "s-cube.3mf")))) + items.append(("S-bambu-3parts", "synthetic", str(fx.bambu_project( + tmp / "s-bambu.3mf", parts=3, items=[("100", fx.tf(10, 10, 0))], plates=[(1, [("100", 0)])], + roles=["normal_part", "modifier_part", "negative_part"])))) + items.append(("S-prusa-volumes", "synthetic", str(fx.prusa_project( + tmp / "s-prusa.3mf", volumes=[(0, 11, "ModelPart"), (12, 23, "ParameterModifier")])))) + items.append(("S-stl", "synthetic", str(fx.binary_stl(tmp / "s.stl")))) + for n in (10_000, 50_000, 100_000): + items.append((f"S-grid-{n // 1000}k", "synthetic", str(fx.big_grid_3mf(tmp / f"s-grid-{n}.3mf", n)))) + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(i * 12.0 % 250, (i // 20) * 12.0, 0)) for i in range(300)]) + items.append(("S-instances-300", "synthetic", str(fx.three_mf(tmp / "s-inst.3mf", root)))) + items.append(("S-over-budget", "synthetic-over-budget", str(fx.big_grid_3mf(tmp / "s-over.3mf", 280_000)))) + for p in sorted((REPO / "examples").iterdir()): + if p.suffix.lower() in (".3mf", ".stl") and ".orig." not in p.name: + items.append((f"E-{p.stem}", "repo-example", str(p))) + rw = Path(args.real_world) if args.real_world else BACKEND / "tests" / "fixtures" / "real-world" + if rw.is_dir(): + for p in sorted(rw.glob("*.3mf")): + if ".orig." not in p.name: + items.append((f"R-{p.stem}", "real-world", str(p))) + pool = sorted((p for d in args.local_root or [] for p in Path(d).rglob("*") + if p.suffix.lower() in (".3mf", ".stl") and p.is_file()), + key=lambda p: (p.stat().st_size, str(p))) + if pool: # evenly spaced by size across ALL local roots; only the anonymized id is ever recorded + picks = [pool[int(i * (len(pool) - 1) / max(1, args.local_sample - 1))] for i in range(args.local_sample)] + for n, p in enumerate(dict.fromkeys(picks), 1): + items.append((f"P-{n:02d}", "local-anonymized", str(p))) + return items + + +def survey(roots: list[str]) -> dict: + """Size-only survey of local projects (no names): how many are over the snapshot ceiling.""" + sizes = [] + for d in roots: + for p in Path(d).rglob("*"): + if p.suffix.lower() == ".3mf" and p.is_file(): + sizes.append(p.stat().st_size) + if not sizes: + return {} + over = sum(1 for s in sizes if s > L.SNAPSHOT_MAX_BYTES) + sizes.sort() + return {"files": len(sizes), "over_snapshot_ceiling": over, "fraction_within_ceiling": round(1 - over / len(sizes), 3), + "median_mib": round(statistics.median(sizes) / MIB, 1), "p90_mib": round(sizes[int(0.9 * (len(sizes) - 1))] / MIB, 1)} + + +def gates(tmp: Path) -> dict: + sys.path.insert(0, str(BACKEND)) + from snapstudio_api import scene_jobs as sj + + out: dict = {} + big = fx.big_grid_3mf(tmp / "gate-100k.3mf", 100_000) + m = measure(str(big), 3) + out["fixture_100k_triangles"] = {"parse_encode_warm_s": m.get("warm_best_s"), "cold_s": m.get("cold_s"), + "extra_peak_mib_tracemalloc": m.get("tracemalloc_peak_mib"), + "extra_peak_mib_rss": m.get("rss_delta_mib"), "response_bytes": m.get("response_bytes"), + "gate_time_le_10s": (m.get("warm_best_s") or 1e9) <= 10, + "gate_mem_le_500mib": max(m.get("tracemalloc_peak_mib") or 0, m.get("rss_delta_mib") or 0) <= 500, + "target_time_le_3s": (m.get("warm_best_s") or 1e9) <= 3, + "target_mem_le_300mib": max(m.get("tracemalloc_peak_mib") or 0, m.get("rss_delta_mib") or 0) <= 300, + "gate_response_le_8mib": (m.get("response_bytes") or 1e9) <= L.MAX_RESPONSE_BYTES} + jobs = sj.SceneJobs(snapshot_root=str(tmp / "snap")) + latencies = [] + for _ in range(5): + job = jobs.start(str(big), f"bench-cancel-{time.monotonic_ns()}") + end = time.monotonic() + 10 + while time.monotonic() < end and jobs.status(job["job_id"])["stage"] not in ("parsing", "encoding"): + time.sleep(0.005) + time.sleep(0.15) + t = time.perf_counter() + jobs.cancel(job["job_id"]) + while jobs.worker_alive(): + time.sleep(0.002) + latencies.append(time.perf_counter() - t) + out["cancel_latency_s"] = {"max": round(max(latencies), 3), "median": round(statistics.median(latencies), 3), + "gate_le_2s": max(latencies) <= 2.0} + small = str(fx.plain_cube_3mf(tmp / "leak-small.3mf")) + mid = str(fx.big_grid_3mf(tmp / "leak-mid.3mf", 20_000)) + base_threads = threading.active_count() + wedged = False + leak = sj.SceneJobs(snapshot_root=str(tmp / "snap2")) + for i in range(50): + job = leak.start(mid if i % 2 else small, f"bench-leak-{i:04d}-{time.monotonic_ns()}") + if i % 3 == 0: + time.sleep(0.01) + leak.cancel(job["job_id"]) + wedged = wedged or leak.wedged() + leak.close() + end = time.monotonic() + 10 + while leak.worker_alive() and time.monotonic() < end: + time.sleep(0.01) + snaps = os.listdir(leak.snapshot_dir()) if leak.snapshot_dir() and os.path.isdir(leak.snapshot_dir()) else [] + out["leak_50_cycles"] = {"retained_jobs": leak.retained(), "cap": L.MAX_TERMINAL_JOBS, "temp_snapshots": len(snaps), + "threads_before": base_threads, "threads_after": threading.active_count(), + "worker_wedged_seen": wedged, + "gate_pass": leak.retained() <= L.MAX_TERMINAL_JOBS and not snaps + and threading.active_count() == base_threads and not wedged} + return out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--out", required=True) + ap.add_argument("--real-world", help="folder holding the fetched real-world 3MF fixtures") + ap.add_argument("--local-root", action="append", help="folder of local projects (anonymized ids only)") + ap.add_argument("--local-sample", type=int, default=5) + ap.add_argument("--warm-runs", type=int, default=3) + args = ap.parse_args() + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + rows = [] + for ident, klass, path in corpus(tmp, args): + rec = measure(path, args.warm_runs) + rec.update(id=ident, **{"class": klass}) + rows.append(rec) + print(f"{ident:24s} {klass:22s} {rec.get('result'):8s} {rec.get('code') or '':16s} " + f"{rec.get('warm_best_s', '-')!s:>7} s {rec.get('response_bytes', '-')!s:>9} B", flush=True) + results = {"schema": "scene-benchmark/1", "machine": machine(), "budgets": dict(L.LIMITS_ECHO), + "snapshot_ceiling_bytes": L.SNAPSHOT_MAX_BYTES, "items": rows} + within = [r for r in rows if r["class"] in ("repo-example", "real-world", "local-anonymized", "synthetic")] + results["coverage"] = { + "by_class": {k: {"items": sum(1 for r in rows if r["class"] == k), + "fit": sum(1 for r in rows if r["class"] == k and r.get("fits_budgets"))} + for k in sorted({r["class"] for r in rows})}, + "fraction_fitting_excluding_deliberate_over_budget": round( + sum(1 for r in within if r.get("fits_budgets")) / max(1, len(within)), 3)} + if args.local_root: + results["local_survey_3mf_sizes_only"] = survey(args.local_root) + results["gates"] = gates(tmp) + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8") + print("wrote", out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 68c2dcb1..21918cd8 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "snapmaker-studio-desktop", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "snapmaker-studio-desktop", - "version": "1.5.0", + "version": "1.5.1", "dependencies": { "@radix-ui/react-slot": "^1", "@tanstack/react-query": "^5", diff --git a/desktop/package.json b/desktop/package.json index c156e6e9..4d1ca855 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "snapmaker-studio-desktop", "private": true, - "version": "1.5.0", + "version": "1.5.1", "description": "Snapmaker Studio — the local-first workflow platform for modern 3D printing. Understand any design, check if it will print, get it ready, and monitor your Snapmaker U1.", "type": "module", "scripts": { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 269709ca..911f1a25 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2787,7 +2787,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "snapmaker-studio-desktop" -version = "1.5.0" +version = "1.5.1" dependencies = [ "libc", "serde", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 007faea8..1ef36180 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "snapmaker-studio-desktop" -version = "1.5.0" +version = "1.5.1" description = "Snapmaker Studio desktop shell" edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 60a928e6..99b38148 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Snapmaker Studio", - "version": "1.5.0", + "version": "1.5.1", "identifier": "com.snapmakerstudio.desktop", "build": { "frontendDist": "../dist", diff --git a/desktop/src/api.ts b/desktop/src/api.ts index fb2c130f..a468318b 100644 --- a/desktop/src/api.ts +++ b/desktop/src/api.ts @@ -127,6 +127,9 @@ export interface SettingsChange { reason?: string; /** Plain-language explanation some steps add next to the reason. */ explanation?: string; + /** Evidence provenance for the explanation, replaced or cleared with it. */ + source?: string; + kind?: "file" | "engine" | "estimate" | "orca"; } export interface SettingsSummary { @@ -138,6 +141,10 @@ export interface SettingsSummary { warnings: string[]; recommendations_available: boolean; recommended_changes: SettingsChange[]; + /** Present only when the creator's supports will not be in effect in Orca (Recommended mode). */ + supports_note?: string; + /** Present only when the project's prime tower sits outside the printable area. */ + prime_tower_note?: string; /** Present only when Project Materials made a choice (or the engine was given the installed presets). */ project_materials?: ProjectMaterialsSummary; } @@ -357,7 +364,15 @@ export interface Insights { colors: number | null; painted: boolean; materials: { color: string; type: string | null }[]; + // Overall extents of all the mesh data together: a size-only figure, not one object's size and not a position. dimensions_mm: { x: number; y: number; z: number } | null; + dimensions_basis?: string; + // Per-object size (mm), before any build item moves, turns or scales it. + object_sizes_mm?: ObjectSize[]; + // Build items whose object could not be found, so they have no size above. + objects_unmeasured?: number; + // Where each instance sits and on which plate (placed bounds), kept apart from the sizes above. + placed?: PlacedInstances; triangles: number | null; complexity: string | null; issues: string[]; @@ -1315,8 +1330,37 @@ export async function ecosystemAdvice( // while passing every size check. These read where each object sits and, when a // single honest move fixes it, write a NEW copy — the original is never touched. +export interface ObjectSize { + object_id: string | null; + part?: string; + instance_count: number; + dimensions_mm: { x: number; y: number; z: number }; +} + +export interface PlacedInstances { + available: boolean; + basis: string; + reason?: string | null; + plate_count: number; + instances: { + item_index: number | null; + object_id: string; + instance_index: number; + instance_count: number; + plate: number | null; + bounds_mm: { min: number[]; max: number[] }; + dimensions_mm: { x: number; y: number; z: number }; + }[]; + plate_extents: { plate: number; instances: number; x: number; y: number }[]; + combined_extent_mm: { x: number; y: number } | null; +} + export interface PlacementItem { object_id: string; + // One object can be used by several build items; these tell the instances apart. + item_index?: number; + instance_index?: number; + instance_count?: number; dimensions: { x: number; y: number; z: number }; position: { x: number; y: number }; off_plate: boolean; @@ -1330,11 +1374,17 @@ export interface PlacementCheck { reason?: string; source_printer?: string | null; plate_count?: number; + /** False when positions across plates are not established by Studio (multi-plate projects). */ + placement_established?: boolean; + /** Prime tower judged against the printable area, from the project's own settings. */ + prime_tower?: { outside: { plate: number; x: number; y: number }[]; message: string | null; note?: string | null }; item_count?: number; items: PlacementItem[]; off_plate: PlacementItem[]; skipped_plates?: { plate: number; reason: string }[]; - unresolved_objects?: { object_id: string }[]; + unresolved_objects?: { object_id: string; item_index?: number; reason?: string }[]; + // Placed instances Studio could not judge (not found, or on no plate record). + not_judged?: number; fixable: boolean; summary?: string; } diff --git a/desktop/src/components/BusinessDoctors.tsx b/desktop/src/components/BusinessDoctors.tsx index 3380402c..a82f1093 100644 --- a/desktop/src/components/BusinessDoctors.tsx +++ b/desktop/src/components/BusinessDoctors.tsx @@ -120,7 +120,7 @@ export function BusinessDoctors({ filePath, host }: { filePath: string; host?: s
+ sub="estimated cost" /> diff --git a/desktop/src/components/DesignHealth.tsx b/desktop/src/components/DesignHealth.tsx index cbb38545..b7938d09 100644 --- a/desktop/src/components/DesignHealth.tsx +++ b/desktop/src/components/DesignHealth.tsx @@ -1,7 +1,8 @@ import { CheckCircle2, AlertTriangle, ShieldAlert, Droplets, Boxes, MoveVertical, LifeBuoy, Ruler } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { MeshReport } from "@/api"; +import type { MeshReport, ObjectSize } from "@/api"; +import { sizeRow } from "@/lib/sizeFit"; // U1 build volume (mm) — matches the engine's bed-fit check. const U1_BED = { x: 270, y: 270, z: 270 }; @@ -18,7 +19,8 @@ const STATUS_ICON: Record = { ok: CheckCircle2, warn: AlertTr function buildRows(mesh: MeshReport, dims?: { x: number; y: number; z: number } | null, bed?: { x: number; y: number; z: number } | null, - mode: "simple" | "advanced" = "simple"): Row[] { + mode: "simple" | "advanced" = "simple", objects?: ObjectSize[] | null, + unmeasured = 0): Row[] { const rows: Row[] = []; const integ = mesh.integrity; const fitBed = bed ?? U1_BED; @@ -67,30 +69,28 @@ function buildRows(mesh: MeshReport, dims?: { x: number; y: number; z: number } : "Few steep overhangs — this should print without supports." }); } - // 5. Bed fit (from dimensions vs the real connected printer bed, else the U1 default) - if (dims) { - const fits = dims.x <= fitBed.x && dims.y <= fitBed.y && dims.z <= fitBed.z; - const bedStr = `${fitBed.x} × ${fitBed.y} × ${fitBed.z} mm`; - rows.push({ key: "bedfit", label: "Bed fit", icon: Ruler, level: fits ? "ok" : "risk", - status: fits ? "Fits" : "Too big", - detail: fits - ? `${dims.x} × ${dims.y} × ${dims.z} mm fits ${bedSource} ${bedStr} bed.` - : `${dims.x} × ${dims.y} × ${dims.z} mm is larger than ${bedSource} ${bedStr} bed — scale it down or split it.` }); + // 5. Bed fit BY SIZE: each object against the real connected printer bed, else the U1 default. + // A size says nothing about position; placement is its own card. + const fit = sizeRow(objects, dims, fitBed, bedSource, unmeasured); + if (fit) { + rows.push({ key: "bedfit", label: "Bed fit (by size)", icon: Ruler, level: fit.level, + status: fit.status, detail: fit.detail }); } return rows; } /** Design Health — at-a-glance geometry verdicts with plain-language what/why/do. * `mode` "advanced" appends the raw metric footer. */ -export function DesignHealth({ mesh, dims, bed, mode = "simple" }: { +export function DesignHealth({ mesh, dims, objects, unmeasured, bed, mode = "simple" }: { mesh?: MeshReport; dims?: { x: number; y: number; z: number } | null; + objects?: ObjectSize[] | null; unmeasured?: number; bed?: { x: number; y: number; z: number } | null; mode?: "simple" | "advanced"; }) { if (!mesh) return null; if (!mesh.available) { return

Geometry analysis isn’t available for this file (it may be too large).

; } - const rows = buildRows(mesh, dims, bed, mode); + const rows = buildRows(mesh, dims, bed, mode, objects, unmeasured); if (!rows.length) return null; return ( diff --git a/desktop/src/components/FixPlan.tsx b/desktop/src/components/FixPlan.tsx index 4d10f477..afe3720c 100644 --- a/desktop/src/components/FixPlan.tsx +++ b/desktop/src/components/FixPlan.tsx @@ -71,8 +71,8 @@ export function buildFixPlan(p: FixPlanProps): FixAction[] { } if (!p.isStl && (p.doctor?.object_count ?? 0) > 1) { issues.push({ - title: "Arrange the plate and check spacing", - detail: "Studio can't verify object-to-object spacing for multi-part layouts. In Orca, use Arrange and watch for too-close / collision warnings.", + title: "Check object spacing", + detail: "Studio can't verify object-to-object spacing for multi-part layouts. Review the project in Snapmaker Orca and watch for too-close / collision warnings.", where: "Orca", level: "warn", }); diff --git a/desktop/src/components/PlacementCard.test.tsx b/desktop/src/components/PlacementCard.test.tsx new file mode 100644 index 00000000..307caa20 --- /dev/null +++ b/desktop/src/components/PlacementCard.test.tsx @@ -0,0 +1,38 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { PlacementCheck } from "@/api"; +import { PlacementView } from "./PlacementCard"; + +const NOTE = "Each plate fits on its own, but Studio cannot check where the plates sit. In our test with a two-plate project made for a larger-bed printer, Snapmaker Orca listed one object as Outside and would not slice until it was moved by hand; Arrange all plates did not fix that in our test. Review the project in Snapmaker Orca before slicing."; + +const check = (over: Partial = {}): PlacementCheck => ({ + schema_version: "placement/1", available: true, items: [], off_plate: [], fixable: false, + item_count: 2, plate_count: 1, ...over, +}); + +const render = (c: PlacementCheck) => + renderToStaticMarkup( {}} />); + +describe("PlacementCard", () => { + it("never says every object is inside for a multi-plate project", () => { + const html = render(check({ plate_count: 2, placement_established: false, summary: NOTE })); + expect(html).toContain("Each plate fits on its own, but Studio cannot check where the plates sit."); + expect(html).toContain("Arrange all plates did not fix that in our test"); + expect(html).not.toContain("use Arrange all plates"); + expect(html).not.toContain("Every object sits inside"); + expect(html).not.toContain("text-ready"); // no green check + }); + + it("still passes a single-plate project that is on the plate", () => { + const html = render(check()); + expect(html).toContain("Every object sits inside the U1's printable area."); + expect(html).toContain("text-ready"); + }); +}); + +describe("PlacementCard prime tower", () => { + it("shows the tower finding without turning the card green", () => { + const html = render(check({ prime_tower: { outside: [{ plate: 1, x: 236.9, y: 233.7 }], message: "The prime tower (plate 1) sits outside the U1's printable area." } })); + expect(html).toContain("The prime tower (plate 1) sits outside"); + }); +}); diff --git a/desktop/src/components/PlacementCard.tsx b/desktop/src/components/PlacementCard.tsx index a15d7a86..d6296994 100644 --- a/desktop/src/components/PlacementCard.tsx +++ b/desktop/src/components/PlacementCard.tsx @@ -5,7 +5,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { useToast } from "@/store/toast"; import { placementCheck, preparePlaced } from "@/api"; import type { PlacementCheck, PlacementFix } from "@/api"; -import { blockedReason, overhangText, placementVerdict } from "@/lib/placement"; +import { blockedReason, itemLabel, overhangText, placementVerdict } from "@/lib/placement"; /** * Where the objects actually sit. @@ -39,9 +39,6 @@ export function PlacementCard({ path }: { path: string }) { // route still have something to say. if (failed) return null; - const verdict = placementVerdict(check); - const blocked = blockedReason(check); - const runFix = async () => { setFixing(true); try { @@ -52,12 +49,26 @@ export function PlacementCard({ path }: { path: string }) { : "Studio did not move anything — see the reason on the card."); if (result.ok) setCheck(result.after ?? check); } catch { - showToast("Couldn't reposition this project — open it in Snapmaker Orca and use Arrange."); + showToast("Couldn't reposition this project — review it in Snapmaker Orca before slicing."); } finally { setFixing(false); } }; + return ; +} + +/** What the card shows for a given check. Separate from the fetching so it can be rendered on its own. */ +export function PlacementView({ check, fix, fixing, onFix }: { + check: PlacementCheck | null; + fix: PlacementFix | null; + fixing: boolean; + onFix: () => void; +}) { + const showToast = useToast((s) => s.show); + const verdict = placementVerdict(check); + const blocked = blockedReason(check); + return ( @@ -77,9 +88,9 @@ export function PlacementCard({ path }: { path: string }) { {check?.available && check.off_plate.length > 0 && (
    {check.off_plate.map((item) => ( -
  • +
  • - Object {item.object_id} · {item.dimensions.x} × {item.dimensions.y} ×{" "} + {itemLabel(item)} · {item.dimensions.x} × {item.dimensions.y} ×{" "} {item.dimensions.z} mm

    {overhangText(item)}

    @@ -88,15 +99,25 @@ export function PlacementCard({ path }: { path: string }) {
)} + {check?.available && check.prime_tower?.message && ( +

+ {check.prime_tower.message} +

+ )} + + {check?.available && !check.prime_tower?.message && check.prime_tower?.note && ( +

{check.prime_tower.note}

+ )} + {blocked && (

- {blocked} Open the project in Snapmaker Orca and use Arrange. + {blocked} Review the project in Snapmaker Orca before slicing.

)} {verdict.canFix && !fix?.ok && (
- diff --git a/desktop/src/components/PrepareModeChooser.tsx b/desktop/src/components/PrepareModeChooser.tsx index 7e64d9d2..b13be0ff 100644 --- a/desktop/src/components/PrepareModeChooser.tsx +++ b/desktop/src/components/PrepareModeChooser.tsx @@ -23,7 +23,7 @@ export function PrepareModeChooser({ mode, onModeChange, onCustom, previewing =
CustomReview settings before preparing. diff --git a/desktop/src/components/PrepareSettingsSummary.test.tsx b/desktop/src/components/PrepareSettingsSummary.test.tsx index d5cd8bc1..9e349ee2 100644 --- a/desktop/src/components/PrepareSettingsSummary.test.tsx +++ b/desktop/src/components/PrepareSettingsSummary.test.tsx @@ -112,17 +112,19 @@ describe("PrepareSettingsSummary explanations", () => { kept_count: 12, mapped_to_u1: [{ key: "nozzle_temperature", old: "[200]", new: "[200,200,200,200]", reason: "carried over to U1 toolheads (values preserved)" }], compat_changed: [ - { key: "prime_tower_brim_width", old: "-1", new: "5", reason: "U1 compatibility clamp: -1 → 5", explanation: "-1 is outside the valid range for this setting (0 or more), which the Compatibility check flags as an invalid value. Studio used 5, the U1 default." }, - { key: "brim_type", old: "auto_brim", new: "no_brim", reason: "the creator left the brim on automatic", explanation: "Automatic means the slicer chooses." }, + { key: "prime_tower_brim_width", old: "-1", new: "5", reason: "U1 compatibility clamp: -1 → 5", explanation: "-1 is outside the valid range for this setting (0 or more), which the Compatibility check flags as an invalid value. Studio used 5, the U1 default.", source: "Studio's valid range for this setting", kind: "engine" as const }, + { key: "brim_type", old: "auto_brim", new: "no_brim", reason: "the creator left the brim on automatic", explanation: "Automatic means the slicer chooses.", source: "Studio's rule for how Snapmaker Orca imports this setting", kind: "orca" as const }, { key: "some_setting", old: "1", new: "2", reason: "changed only for U1 compatibility" }, ], recommended_changes: [{ key: "speed", old: 20, new: 40, reason: "available with the recommended U1 profile" }], }; it("shows the engine's explanation under each compatibility change that has one", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup().replace(/'/g, "'"); expect(html).toContain("which the Compatibility check flags as an invalid value"); expect(html).toContain("Automatic means the slicer chooses."); + expect(html).toContain("Studio's check"); + expect(html).toContain("Studio's rule for how Snapmaker Orca imports this setting"); }); it("does not repeat a reason that only restates the change", () => { @@ -143,3 +145,40 @@ describe("PrepareSettingsSummary explanations", () => { expect(html).not.toContain("available with the recommended U1 profile"); }); }); + +describe("PrepareSettingsSummary in Recommended mode", () => { + const SUPPORTS = "Supports: the creator turned supports on. This mode does not keep them; Snapmaker Orca will open with supports off. Turn them on in Orca's Support tab or prepare with Preserve creator settings."; + + it("states that the creator's supports are not kept, before handoff", () => { + const html = renderToStaticMarkup().replace(/'/g, "'"); + expect(html).toContain(SUPPORTS); + }); + + it("does not describe the written settings as kept", () => { + const html = renderToStaticMarkup().replace(/'/g, "'"); + expect(html).not.toContain("501 creator settings kept"); + expect(html).not.toContain("kept from the original file"); + expect(html).toContain("501 creator settings are written in the file"); + expect(html).toContain("Studio's U1 starter settings replace the creator's print settings"); + }); + + it("keeps the Preserve wording and shows no supports line when there is no note", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("501 creator settings kept"); + expect(html).not.toContain("Supports"); + }); +}); + +describe("PrepareSettingsSummary prime tower", () => { + const TOWER = "The prime tower (plate 1) sits outside the U1's printable area: it starts at X 236.895, Y 233.729."; + it("states a computed prime tower finding in either mode", () => { + for (const mode of ["preserve", "recommended"] as const) { + const html = renderToStaticMarkup().replace(/'/g, "'"); + expect(html).toContain("Prime tower"); + expect(html).toContain(TOWER); + } + }); + it("shows nothing about the tower when there is no finding", () => { + expect(renderToStaticMarkup()).not.toContain("Prime tower"); + }); +}); diff --git a/desktop/src/components/PrepareSettingsSummary.tsx b/desktop/src/components/PrepareSettingsSummary.tsx index 8dd31bd5..b7802349 100644 --- a/desktop/src/components/PrepareSettingsSummary.tsx +++ b/desktop/src/components/PrepareSettingsSummary.tsx @@ -2,6 +2,7 @@ import type { SettingsChange, SettingsSummary } from "@/api"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { settingNote } from "@/lib/settingNotes"; +import { evidenceKindLabel } from "@/lib/evidenceKind"; function value(value: unknown) { const text = typeof value === "string" ? value : JSON.stringify(value); @@ -16,7 +17,7 @@ function Changes({ changes, explain = false }: { changes: SettingsChange[]; expl return (
  • {change.key}:{value(change.old)} → {value(change.new)}
    - {note &&

    {note}

    } + {note &&

    {note.kind && {evidenceKindLabel(note.kind)}}{note.kind && note.source ? ` — ${note.source}. ` : note.kind ? ". " : note.source ? `${note.source}. ` : ""}{note.note}

    }
  • ); })} @@ -62,6 +63,8 @@ function recommendationSummary(changes: SettingsChange[]) { return examples.size > 0 ? `${count}, including ${[...examples].join(" and ")}.` : `${count}.`; } +export const STARTER_SETTINGS_NOTE = "Studio's U1 starter settings replace the creator's print settings, and Snapmaker Orca uses the U1 preset for any value the file does not declare as changed. Not every setting listed here will be in effect."; + export const STARTER_NOTICE = "This STL does not include creator slicer settings. Studio will use a U1 starter profile unless you choose another Orca profile."; interface Props { @@ -85,8 +88,10 @@ export function PrepareSettingsSummary({ summary, mode, preview = false, onPrepa {preview &&

    Settings preview

    } - {(summary.kept_count > 0 || mappedToU1.length > 0) &&

    Kept from the original file

    {summary.kept_count} creator settings kept

    These settings are kept from the original file.

    {hasMappedTemperatures &&

    Creator temperature values were preserved and mapped to the U1 toolhead layout.

    }{mappedToU1.length > 0 && <>{!hasMappedTemperatures &&

    Creator values preserved and mapped to the U1 toolhead layout.

    }
    Technical detail
    }
    } - {summary.compat_changed.length > 0 &&

    Adjusted for U1 project compatibility

    These settings changed only for U1 compatibility.

      {compat.bullets.map((bullet) =>
    • {bullet}
    • )}
    {compat.unmatched.length > 0 &&
    }
    Technical detail
    } + {summary.supports_note &&

    Supports

    {summary.supports_note}

    } + {summary.prime_tower_note &&

    Prime tower

    {summary.prime_tower_note}

    } + {(summary.kept_count > 0 || mappedToU1.length > 0) &&

    {mode === "recommended" ? "Still written in the file" : "Kept from the original file"}

    {mode === "recommended" ? <>

    {summary.kept_count} creator settings are written in the file

    {STARTER_SETTINGS_NOTE}

    : <>

    {summary.kept_count} creator settings kept

    These settings are kept from the original file.

    }{hasMappedTemperatures &&

    Creator temperature values were preserved and mapped to the U1 toolhead layout.

    }{mappedToU1.length > 0 && <>{!hasMappedTemperatures &&

    Creator values preserved and mapped to the U1 toolhead layout.

    }
    Technical detail
    }
    } + {summary.compat_changed.length > 0 &&

    Adjusted for U1 project compatibility

    {mode === "recommended" ? "These settings were replaced by Studio's U1 starter settings or changed for U1 compatibility." : "These settings changed only for U1 compatibility."}

      {compat.bullets.map((bullet) =>
    • {bullet}
    • )}
    {compat.unmatched.length > 0 &&
    }
    Technical detail
    } {summary.could_not_carry.length > 0 &&

    Could not carry over

      {summary.could_not_carry.map((item) =>
    • {item.key}: {item.reason}
    • )}
    } {mode === "preserve" && summary.recommendations_available && summary.recommended_changes.length > 0 &&

    Optional recommendations (not applied)

    These optional recommendations are not applied unless you choose them.

    {recommendationSummary(summary.recommended_changes)}

    Technical detail
    {onPrepareRecommended && }
    } {preview &&
    {onPreparePreserve && }{onPrepareRecommended && }
    } diff --git a/desktop/src/components/prepare-copy-guard.test.ts b/desktop/src/components/prepare-copy-guard.test.ts index 0a494488..b3a6dfe4 100644 --- a/desktop/src/components/prepare-copy-guard.test.ts +++ b/desktop/src/components/prepare-copy-guard.test.ts @@ -6,6 +6,7 @@ const BANNED_PATTERNS = [/\bsafe\b/i, /print-ready/i, /guaranteed/i]; const sources = import.meta.glob([ "./PrepareModeChooser.tsx", "./PrepareSettingsSummary.tsx", + "../lib/evidenceKind.ts", "./FirstPrintCard.tsx", "../routes/Compatibility.tsx", "../routes/DesignInsights.tsx", diff --git a/desktop/src/components/ui/ToolTabs.test.tsx b/desktop/src/components/ui/ToolTabs.test.tsx new file mode 100644 index 00000000..80a1521f --- /dev/null +++ b/desktop/src/components/ui/ToolTabs.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { ToolTabs, type ToolTab } from "./ToolTabs"; + +afterEach(cleanup); + +const make = (ids: string[]): ToolTab[] => + ids.map((id) => ({ + id, label: `Tab ${id}`, + el:

    Heading {id}

    , + })); + +const tab = (name: string) => screen.getByRole("tab", { name }); +const press = (el: HTMLElement, key: string) => fireEvent.keyDown(el, { key }); + +describe("ToolTabs", () => { + it("names the tab row and wires tab and panel together with ids", () => { + render(); + expect(screen.getByRole("tablist", { name: "Example tools" })).toBeTruthy(); + const a = tab("Tab a"); + const panel = screen.getByRole("tabpanel"); + expect(a.id).toBeTruthy(); + expect(a.getAttribute("aria-controls")).toBe(panel.id); + expect(panel.getAttribute("aria-labelledby")).toBe(a.id); + expect(screen.getByRole("tabpanel", { name: "Tab a" })).toBe(panel); + }); + + it("only the selected tab has aria-controls, because inactive panels are not in the page", () => { + render(); + expect(tab("Tab a").getAttribute("aria-controls")).toBeTruthy(); + expect(tab("Tab b").hasAttribute("aria-controls")).toBe(false); + }); + + it("ignores navigation keys pressed with Alt, Ctrl or Meta", () => { + render(); + tab("Tab a").focus(); + for (const init of [ + { key: "ArrowLeft", altKey: true }, { key: "Home", ctrlKey: true }, { key: "ArrowRight", metaKey: true }, + { key: "Enter", ctrlKey: true }, + ]) { + const notPrevented = fireEvent.keyDown(tab("Tab a"), { ...init, cancelable: true }); + expect(notPrevented).toBe(true); // dispatchEvent returns false when preventDefault ran + expect(document.activeElement).toBe(tab("Tab a")); + expect(tab("Tab a").getAttribute("aria-selected")).toBe("true"); + } + }); + + it("keeps only the selected panel in the page", () => { + render(); + expect(screen.queryByText("Heading a")).not.toBeNull(); + expect(screen.queryByText("Heading b")).toBeNull(); + expect(screen.getAllByRole("tabpanel")).toHaveLength(1); + }); + + it("puts only the selected tab in the Tab order, and the panel is not a tab stop", () => { + render(); + expect(screen.getAllByRole("tab").map((t) => t.tabIndex)).toEqual([0, -1, -1]); + expect(screen.getByRole("tabpanel").tabIndex).toBe(-1); + }); + + it("moves focus with the arrow keys, wrapping at both ends, without switching the panel", () => { + render(); + tab("Tab a").focus(); + press(tab("Tab a"), "ArrowRight"); + expect(document.activeElement).toBe(tab("Tab b")); + press(tab("Tab b"), "ArrowRight"); + press(tab("Tab c"), "ArrowRight"); + expect(document.activeElement).toBe(tab("Tab a")); + press(tab("Tab a"), "ArrowLeft"); + expect(document.activeElement).toBe(tab("Tab c")); + expect(tab("Tab a").getAttribute("aria-selected")).toBe("true"); + expect(tab("Tab c").getAttribute("aria-selected")).toBe("false"); + expect(screen.queryByText("Heading a")).not.toBeNull(); + expect(screen.queryByText("Heading c")).toBeNull(); + }); + + it("moves the single tab stop with focus, leaving the selected tab as it was", () => { + render(); + tab("Tab a").focus(); + press(tab("Tab a"), "ArrowRight"); + expect(screen.getAllByRole("tab").map((t) => t.tabIndex)).toEqual([-1, 0, -1]); + expect(screen.getAllByRole("tab").map((t) => t.getAttribute("aria-selected"))).toEqual(["true", "false", "false"]); + }); + + it("after arrowing to another tab and leaving the row, focus comes back to the selected tab", () => { + render(); + tab("Tab a").focus(); + press(tab("Tab a"), "ArrowRight"); + expect(screen.getAllByRole("tab").map((t) => t.tabIndex)).toEqual([-1, 0, -1]); + act(() => { screen.getByRole("button", { name: "Action a" }).focus(); }); // focus leaves the row + expect(screen.getAllByRole("tab").map((t) => t.tabIndex)).toEqual([0, -1, -1]); + expect(tab("Tab a").getAttribute("aria-selected")).toBe("true"); + }); + + it("Home and End jump to the first and last tab, and Up and Down do nothing", () => { + render(); + tab("Tab a").focus(); + press(tab("Tab a"), "End"); + expect(document.activeElement).toBe(tab("Tab c")); + press(tab("Tab c"), "Home"); + expect(document.activeElement).toBe(tab("Tab a")); + press(tab("Tab a"), "ArrowDown"); + press(tab("Tab a"), "ArrowUp"); + expect(document.activeElement).toBe(tab("Tab a")); + }); + + it("Enter selects the focused tab and shows its panel", () => { + render(); + tab("Tab a").focus(); + press(tab("Tab a"), "ArrowRight"); + press(tab("Tab b"), "Enter"); + expect(tab("Tab b").getAttribute("aria-selected")).toBe("true"); + expect(screen.queryByText("Heading b")).not.toBeNull(); + expect(screen.queryByText("Heading a")).toBeNull(); + expect(screen.getByRole("tabpanel").getAttribute("aria-labelledby")).toBe(tab("Tab b").id); + }); + + it("Space selects the focused tab", () => { + render(); + tab("Tab b").focus(); + press(tab("Tab b"), " "); + expect(screen.queryByText("Heading b")).not.toBeNull(); + }); + + it("a click selects the tab and makes it the tab stop", () => { + render(); + fireEvent.click(tab("Tab b")); + expect(screen.queryByText("Heading b")).not.toBeNull(); + expect(screen.getAllByRole("tab").map((t) => t.tabIndex)).toEqual([-1, 0]); + }); + + it("honors the initial tab", () => { + render(); + expect(screen.queryByText("Heading b")).not.toBeNull(); + expect(screen.getAllByRole("tab").map((t) => t.tabIndex)).toEqual([-1, 0]); + }); + + it("renders nothing, without throwing, when there are no tabs", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("uses the shared focus ring tokens on each tab", () => { + render(); + for (const t of screen.getAllByRole("tab")) { + expect(t.className).toContain("focus-visible:ring-2"); + expect(t.className).toContain("focus-visible:ring-ring"); + expect(t.className).toContain("focus-visible:ring-offset-background"); + expect(t.className).toContain("focus-visible:outline-none"); + } + }); +}); + +function Removable({ ids }: { ids: string[] }) { + return ; +} + +function Dropper() { + const [ids, setIds] = useState(["a", "b"]); + return ( +
    + + +
    + ); +} + +describe("ToolTabs when a tab disappears", () => { + it("selects the first remaining tab when the selected tab is removed", () => { + const { rerender } = render(); + rerender(); + expect(tab("Tab b").getAttribute("aria-selected")).toBe("true"); + expect(screen.queryByText("Heading b")).not.toBeNull(); + expect(screen.getAllByRole("tab").map((t) => t.tabIndex)).toEqual([0, -1]); + }); + + it("moves the tab stop to the selected tab when the focused tab is removed", () => { + const { rerender } = render(); + tab("Tab a").focus(); + press(tab("Tab a"), "End"); + rerender(); + expect(tab("Tab a").getAttribute("aria-selected")).toBe("true"); + expect(screen.getAllByRole("tab").map((t) => t.tabIndex)).toEqual([0, -1]); + expect(document.activeElement).toBe(tab("Tab a")); + }); + + it("moves real focus to the new selected tab when the focused, selected tab is removed", () => { + const { rerender } = render(); + tab("Tab a").focus(); + rerender(); + expect(document.activeElement).toBe(tab("Tab b")); + }); + + it("moves focus to the selected tab when focus was inside a panel that is unmounted", () => { + render(); + const inside = screen.getByRole("button", { name: "Action a" }); + inside.focus(); + expect(document.activeElement).toBe(inside); + act(() => { fireEvent.click(screen.getByRole("button", { name: "drop a" })); }); + expect(document.activeElement).toBe(tab("Tab b")); + }); + + it("does not take focus back after the tabs went empty and came back", () => { + function Toggle({ ids }: { ids: string[] }) { + return ( +
    + + +
    + ); + } + const { rerender } = render(); + screen.getByRole("button", { name: "Action a" }).focus(); + rerender(); + const outside = screen.getByRole("button", { name: "outside" }); + outside.focus(); + rerender(); + expect(document.activeElement).toBe(outside); + }); + + it("does not take focus from elsewhere on the page when a tab is removed", () => { + render(); + const drop = screen.getByRole("button", { name: "drop a" }); + drop.focus(); + fireEvent.click(drop); + expect(document.activeElement).toBe(drop); + }); +}); diff --git a/desktop/src/components/ui/ToolTabs.tsx b/desktop/src/components/ui/ToolTabs.tsx index 0188b95b..05d6c6e6 100644 --- a/desktop/src/components/ui/ToolTabs.tsx +++ b/desktop/src/components/ui/ToolTabs.tsx @@ -1,10 +1,24 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { type LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -// Lightweight tab switcher used to merge two closely-related tools onto one page -// (one sidebar item, two panels) instead of two duplicate nav entries. Each panel -// is an existing route component rendered as-is. +// Tab switcher used to merge two closely-related tools onto one page (one sidebar +// item, two panels) instead of two duplicate nav entries. Each panel is an existing +// route component rendered as-is. +// +// Keyboard model (WAI-ARIA tabs pattern, manual activation): +// - Only one tab is in the Tab order (roving tabindex): the focused tab while focus is +// in the row, otherwise the selected tab. +// - Arrow Left/Right (with wrap), Home and End move focus only. They never switch the +// panel. Enter, Space or a click selects the focused tab. +// - Manual activation is deliberate. Switching unmounts one route and mounts the +// other, which re-runs that route's engine requests. Moving focus along the tab row +// must not trigger that. The pattern allows manual activation for panels that are +// expensive to show. +// - Focus entering the row always lands on the selected tab: the tab stop resets to it +// when focus leaves the row. +// - The selected tab and the tab that owns the tab stop are tracked separately +// (`active` and `focused`). export interface ToolTab { id: string; label: string; @@ -12,25 +26,113 @@ export interface ToolTab { el: ReactNode; } -export function ToolTabs({ tabs, initial }: { tabs: ToolTab[]; initial?: string }) { +export function ToolTabs({ tabs, initial, label }: { + tabs: ToolTab[]; + initial?: string; + /** Accessible name of the tab row (rendered as aria-label), for example "Print quality tools". */ + label: string; +}) { + const uid = useId(); const [active, setActive] = useState(initial ?? tabs[0]?.id); - const current = tabs.find((t) => t.id === active) ?? tabs[0]; + const [focused, setFocused] = useState(active); + const rootRef = useRef(null); + const tabRefs = useRef(new Map()); + // True while keyboard focus is somewhere inside this component (tab row or panel). + const focusInside = useRef(false); + + // A tab can disappear while selected or focused. Fall back to the first remaining tab. + const activeId = tabs.some((t) => t.id === active) ? active : tabs[0]?.id; + const focusedId = tabs.some((t) => t.id === focused) ? focused : activeId; + const idKey = tabs.map((t) => t.id).join("\u0000"); + + useEffect(() => { + // With no tabs the root is gone and no blur fired; forget any focus that was inside it. + if (!activeId) focusInside.current = false; + // Keep the stored state in step with what is shown, so a removed tab that + // returns later does not silently become selected again. + if (activeId !== active) setActive(activeId); + if (focusedId !== focused) setFocused(focusedId); + // If focus was on the removed tab, or inside the panel that was unmounted, the + // browser has dropped it to the page body. Put it on the selected tab. + const now = document.activeElement; + const lost = !now || now === document.body || !now.isConnected || !rootRef.current?.contains(now); + if (focusInside.current && lost && activeId) tabRefs.current.get(activeId)?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idKey]); + + if (tabs.length === 0 || !activeId) return null; + const current = tabs.find((t) => t.id === activeId) ?? tabs[0]; + const tabDomId = (id: string) => `${uid}-tab-${id}`; + const panelDomId = (id: string) => `${uid}-panel-${id}`; + + const moveFocus = (id: string) => { + setFocused(id); + tabRefs.current.get(id)?.focus(); + }; + const select = (id: string) => { + setFocused(id); + setActive(id); + }; + const onKeyDown = (e: KeyboardEvent, index: number) => { + // Browser and assistive-technology shortcuts (Alt+Left is Back, Ctrl+Home scrolls) are left alone. + if (e.altKey || e.ctrlKey || e.metaKey) return; + const last = tabs.length - 1; + let next: number | null = null; + if (e.key === "ArrowRight") next = index === last ? 0 : index + 1; + else if (e.key === "ArrowLeft") next = index === 0 ? last : index - 1; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = last; + if (next !== null) { + e.preventDefault(); + moveFocus(tabs[next].id); + } else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + select(tabs[index].id); + } + }; + return ( -
    -
    - {tabs.map((t) => ( - ))}
    -
    {current?.el}
    + {/* key: always unmount the previous panel, even when two panels render the same component type. */} +
    + {current.el} +
    ); } diff --git a/desktop/src/lib/evidenceKind.test.ts b/desktop/src/lib/evidenceKind.test.ts new file mode 100644 index 00000000..59b58a33 --- /dev/null +++ b/desktop/src/lib/evidenceKind.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { evidenceKindLabel } from "./evidenceKind"; + +describe("evidence kind labels", () => { + it.each([ + ["file", "Read from your file"], ["engine", "Studio's check"], + ["estimate", "Estimate"], ["orca", "Verify in Snapmaker Orca"], + ])("maps %s", (kind, label) => expect(evidenceKindLabel(kind)).toBe(label)); + it("does not invent a label for unknown evidence", () => expect(evidenceKindLabel("unknown")).toBeNull()); +}); diff --git a/desktop/src/lib/evidenceKind.ts b/desktop/src/lib/evidenceKind.ts new file mode 100644 index 00000000..7087fd88 --- /dev/null +++ b/desktop/src/lib/evidenceKind.ts @@ -0,0 +1,21 @@ +export type EvidenceKind = "file" | "engine" | "estimate" | "orca"; + +// `file` maps to preflight `confirmed` and guide certainty `confirmed`: Studio read +// the value directly from the file. `engine` maps to Studio's own preflight check; +// its guide certainty is `likely` when the check supports a conclusion. `estimate` +// maps to preflight derived output and guide certainty `informational`. `orca` is +// advice to verify in Snapmaker Orca; it makes no claim about Orca's behavior. +// The existing UI phrase "Manual check in Orca required" remains the manual-review +// wording; this label is only the provenance badge for the accompanying explanation. + +const LABELS: Record = { + file: "Read from your file", + engine: "Studio's check", + estimate: "Estimate", + orca: "Verify in Snapmaker Orca", +}; + +export function evidenceKindLabel(kind: EvidenceKind | string | undefined): string | null { + const hasOwn = (Object as unknown as { hasOwn: (value: object, key: PropertyKey) => boolean }).hasOwn; + return typeof kind === "string" && hasOwn(LABELS, kind) ? LABELS[kind as EvidenceKind] : null; +} diff --git a/desktop/src/lib/guideClaims.test.ts b/desktop/src/lib/guideClaims.test.ts index f7b9c892..581e9e2a 100644 --- a/desktop/src/lib/guideClaims.test.ts +++ b/desktop/src/lib/guideClaims.test.ts @@ -1,10 +1,33 @@ import { describe, it, expect } from "vitest"; +// @ts-expect-error The desktop type setup intentionally omits Node declarations; Vitest supplies these APIs. +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +// @ts-expect-error The desktop type setup intentionally omits Node declarations; Vitest supplies these APIs. +import { join, dirname } from "node:path"; +// @ts-expect-error The desktop type setup intentionally omits Node declarations; Vitest supplies these APIs. +import { tmpdir } from "node:os"; +// @ts-expect-error The desktop type setup intentionally omits Node declarations; Vitest supplies these APIs. +import { spawnSync } from "node:child_process"; +// @ts-expect-error The desktop type setup intentionally omits Node declarations; Vitest supplies these APIs. +import { fileURLToPath } from "node:url"; +declare const process: any; import { needsConfirm, confirmCopy, type ControlAction } from "./printerControl"; -import { FIDELITY_HEADINGS, fidelityHeadline, statusLabel } from "./fidelity"; +import { FIDELITY_HEADINGS, fidelityHeadline, statusLabel, targetNote } from "./fidelity"; import type { FidelityReport } from "@/api"; // Vite hands the guide's own JSON back as text; the guide sources live outside this app, so no copy is kept here. import tasksRaw from "../../../site/guide/content/tasks.json?raw"; import problemsRaw from "../../../site/guide/content/problems.json?raw"; +import pathRaw from "../../../site/guide/content/path.json?raw"; +import answersRaw from "../../../site/guide/content/answers.json?raw"; +import guideRaw from "../../../site/guide/content/guide.json?raw"; +import designInsightsRaw from "../routes/DesignInsights.tsx?raw"; +import placementCardRaw from "../components/PlacementCard.tsx?raw"; +import orcaHandoffRaw from "../components/OrcaHandoff.tsx?raw"; +import afterSlicingRaw from "../routes/AfterSlicing.tsx?raw"; +import printersRaw from "../routes/Printers.tsx?raw"; +import liveWorkspaceRaw from "../routes/LiveWorkspace.tsx?raw"; +import postSliceRaw from "./postSlice.ts?raw"; +import preflightRaw from "./preflight.ts?raw"; +import fidelityRaw from "./fidelity.ts?raw"; // Guard: statements in the user guide (site/guide) that depend on what this app does are checked // against the app. The expected facts come from the code imported above, not from the guide. @@ -14,7 +37,7 @@ import problemsRaw from "../../../site/guide/content/problems.json?raw"; // What it cannot establish: that the surrounding prose is well explained, or anything about a // physical printer or Snapmaker Orca. -const GUIDE: Record = { tasks: tasksRaw, problems: problemsRaw }; +const GUIDE: Record = { tasks: tasksRaw, problems: problemsRaw, path: pathRaw }; function guide(name: string): any { return JSON.parse(GUIDE[name]); } @@ -24,9 +47,41 @@ function strings(v: unknown): string[] { if (v && typeof v === "object") return Object.values(v).flatMap(strings); return []; } +export function checkNextActionLabels(answers: Array<{ nextAction: { label: string } }>, appSource: string): void { + const labels = new Set(); + for (const match of appSource.matchAll(/"([^"\n]+)"/g)) labels.add(match[1]); + for (const match of appSource.matchAll(/>\s*([^<>{}\n]+?)\s* guide("tasks").tasks.find((t: any) => t.id === id); const problem = (id: string) => guide("problems").find((p: any) => p.id === id); const text = (v: unknown) => strings(v).join(" "); +type GuideFact = { id: string; derive: string; tokens: string[] }; +const APP_FACTS: Record = { + "kept-orca": { id: "kept-reachability", derive: "app:desktop/src/lib/fidelity.ts#FIDELITY_HEADINGS", tokens: ["reached the copy", "read any note on the row"] }, +}; +// Real resolution: load the module under vitest and check it exports the name. Comments, strings and near-miss names do not count. +const appModules = import.meta.glob(["/src/**/*.{ts,tsx}", "!/src/**/*.test.*"]) as Record Promise>>; +export async function appExports(file: string, symbol: string): Promise { + const load = appModules["/" + file.replace(/^desktop\//, "")]; + if (!load) return false; + return Object.prototype.hasOwnProperty.call(await load(), symbol); +} +export async function checkAppAnswerFacts(answers: Array<{ id: string; requiredFacts: GuideFact[] }>, sourceRoot: string, pageText: string): Promise { + const appAnswers = answers.filter((a) => a.requiredFacts.some((f) => f.derive.startsWith("app:"))); + expect(new Set(Object.keys(APP_FACTS))).toEqual(new Set(appAnswers.map((a) => a.id))); + for (const answer of appAnswers) { + expect(answer.requiredFacts).toHaveLength(1); + const fact = answer.requiredFacts[0]; + expect(fact).toEqual(APP_FACTS[answer.id]); + const [, value] = fact.derive.split(":"); + const [file, fragment] = value.split("#"); + expect(readFileSync(join(sourceRoot, file), "utf8").length).toBeGreaterThan(0); + expect(await appExports(file, fragment)).toBe(true); + for (const token of fact.tokens) expect(pageText).toContain(token); + } +} describe("guide: printer controls match the app", () => { it("the grouping the guide relies on is still the app's policy", () => { @@ -93,3 +148,105 @@ describe("guide: fidelity groups match the app", () => { expect(page).not.toMatch(/not an error|nothing is wrong/i); }); }); + +describe("guide: golden answers have app-code assertions", () => { + it("covers the app-derived answers", () => { + /** This proves code-to-manifest coverage only; it cannot establish prose clarity, source fitness, honest dates, physical behavior, paraphrased overclaims, or screen-reader behavior. */ + const answers = JSON.parse(answersRaw).answers as Array<{ id: string; requiredFacts: Array<{ derive: string }> }>; + const expected = new Set(["setting-why", "size-or-placement", "kept-orca", "unknown-compatible", "sliced-for-u1", "original-unchanged", "print-success"]); + expect(new Set(answers.map((a) => a.id))).toEqual(expected); + const facts = new Set(answers.flatMap((a) => a.requiredFacts.map((f) => `${a.id}:${f.derive}`))); + expect(facts.size).toBe(7); + expect(facts.has("kept-orca:app:desktop/src/lib/fidelity.ts#FIDELITY_HEADINGS")).toBe(true); + expect(facts.has("print-success:backend:success_predict.predict")).toBe(true); + const app = [designInsightsRaw, placementCardRaw, orcaHandoffRaw, afterSlicingRaw, printersRaw, liveWorkspaceRaw].join("\n"); + for (const label of ["Print readiness", "Move onto the plate", "Open in Snapmaker Orca", "Check this job", "Printer Hub"]) expect(app).toContain(label); + expect(fidelityRaw).toContain("FIDELITY_HEADINGS"); + expect(postSliceRaw).toContain("export function unknownCount"); + expect(preflightRaw).toContain("resultLabel"); + expect(text(problem("looks-right"))).not.toContain("Print readiness"); + expect(text(guide("path"))).toContain("a signal may still have cost points"); + expect(designInsightsRaw).toMatch(/predict\.band !== "likely"[\s\S]*predict\.factors/); + expect(designInsightsRaw).toContain("Print readiness"); + }); + + it("resolves every app derive and checks the app-owned assertion table", async () => { + /** Resolution and table coverage cannot establish prose clarity, source fitness, honest dates, physical behavior, paraphrased overclaims, or screen-reader behavior. */ + const answers = JSON.parse(answersRaw).answers as Array<{ id: string; requiredFacts: GuideFact[] }>; + const appAnswers = answers.filter((a) => a.requiredFacts.some((f) => f.derive.startsWith("app:"))); + await checkAppAnswerFacts(answers, join(dirname(fileURLToPath(import.meta.url)), "../../.."), text(guide("path"))); + const appFacts = new Set(); + for (const answer of appAnswers) { + const fact = answer.requiredFacts[0]; + const expected = APP_FACTS[answer.id]; + expect({ id: fact.id, derive: fact.derive, tokens: fact.tokens }).toEqual(expected); + const [, value] = fact.derive.split(":"); + const [file, fragment] = value.split("#"); + expect(fragment).toBeTruthy(); + expect(await appExports(file, fragment)).toBe(true); + expect(FIDELITY_HEADINGS.kept).toBe("What stayed the same"); + expect(text(guide("path"))).toEqual(expect.stringContaining(fact.tokens[0])); + expect(text(guide("path"))).toEqual(expect.stringContaining(fact.tokens[1])); + appFacts.add(fact.id); + } + expect(appFacts).toEqual(new Set(Object.values(APP_FACTS).map((f) => f.id))); + expect(new Set(answers.map((a) => a.id))).toEqual(new Set(["setting-why", "size-or-placement", "kept-orca", "unknown-compatible", "sliced-for-u1", "original-unchanged", "print-success"])); + }); + + it("rejects a fresh manifest whose app derive was changed", async () => { + /** This mutation checks binding only; it cannot establish prose clarity, source fitness, dates, physical behavior, paraphrased overclaims, or screen-reader behavior. */ + const answers = JSON.parse(answersRaw).answers as Array<{ id: string; requiredFacts: GuideFact[] }>; + const mutated = JSON.parse(JSON.stringify(answers)) as Array<{ id: string; requiredFacts: GuideFact[] }>; + mutated.find((a) => a.id === "kept-orca")!.requiredFacts[0].derive = "app:desktop/src/lib/fidelity.ts#status"; + await expect(checkAppAnswerFacts(mutated, join(dirname(fileURLToPath(import.meta.url)), "../../.."), text(guide("path")))).rejects.toThrow(); + mutated.find((a) => a.id === "kept-orca")!.requiredFacts[0].derive = "app:desktop/src/lib/fidelity.ts#FIDELITY_HEADING"; + await expect(checkAppAnswerFacts(mutated, join(dirname(fileURLToPath(import.meta.url)), "../../.."), text(guide("path")))).rejects.toThrow(); + }); + + it("resolves app symbols by really importing the module", async () => { + /** Resolution checks that a name is exported; it cannot establish that the export is the right evidence for a guide claim. */ + expect(await appExports("desktop/src/lib/fidelity.ts", "FIDELITY_HEADINGS")).toBe(true); + expect(await appExports("desktop/src/lib/fidelity.ts", "FIDELITY_HEADING")).toBe(false); + expect(await appExports("desktop/src/lib/fidelity.ts", "toString")).toBe(false); + expect(await appExports("desktop/src/lib/no_such_file.ts", "FIDELITY_HEADINGS")).toBe(false); + }); + + it("checks exact next-action labels and built anchors", () => { + /** Literal-label and anchor checks cannot establish prose clarity, source fitness, honest dates, physical behavior, paraphrased overclaims, or screen-reader behavior. */ + const answers = JSON.parse(answersRaw).answers as Array<{ id: string; nextAction: { label: string; ref: string } }>; + const app = [designInsightsRaw, placementCardRaw, orcaHandoffRaw, afterSlicingRaw, printersRaw, liveWorkspaceRaw].join("\n"); + checkNextActionLabels(answers, app); + expect(answers.find((a) => a.id === "print-success")!.nextAction.label).toBe("Print readiness (estimate)"); + const html = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../..", "site/guide/public/index.html"), "utf8"); + for (const answer of answers) if (answer.nextAction.ref.startsWith("#")) expect(html).toContain(`id="${answer.nextAction.ref.slice(1)}"`); + }); + + it("rejects a shortened next-action label from a fresh valid manifest", () => { + /** This mutation check cannot establish prose clarity, source fitness, honest dates, physical behavior, paraphrased overclaims, or screen-reader behavior. */ + const answers = JSON.parse(answersRaw).answers as Array<{ nextAction: { label: string } }>; + const mutated = answers.map((answer) => ({ ...answer, nextAction: { ...answer.nextAction } })); + mutated.find((answer) => answer.nextAction.label === "Check this job")!.nextAction.label = "Check"; + expect(() => checkNextActionLabels(mutated, [designInsightsRaw, placementCardRaw, orcaHandoffRaw, afterSlicingRaw, printersRaw, liveWorkspaceRaw].join("\n"))).toThrow(); + }); + + it("checks both guide and desktop version branches without changing the built guide", () => { + const guideRoot = join(dirname(fileURLToPath(import.meta.url)), "../../..", "site/guide"); + const html = readFileSync(join(guideRoot, "public/index.html"), "utf8"); + const guideVersion = JSON.parse(guideRaw).site.version as string; + expect(html).toContain(`v${guideVersion}`); + const temp = mkdtempSync(join(tmpdir(), "guide-version-")); + try { + const config = join(temp, "tauri.conf.json"); + const run = (version: string) => { + writeFileSync(config, JSON.stringify({ version })); + return spawnSync("node", [join(guideRoot, "tools/check.mjs")], { cwd: guideRoot, env: { ...process.env, SNAPSTUDIO_TAURI_CONF: config }, encoding: "utf8" }); + }; + const match = run(guideVersion); + expect(match.status).toBe(0); + expect(match.stdout + match.stderr).not.toContain("WARNING guide and desktop versions differ"); + const mismatch = run("9.9.9"); + expect(mismatch.status).toBe(0); + expect(mismatch.stdout + mismatch.stderr).toContain("WARNING guide and desktop versions differ"); + } finally { rmSync(temp, { recursive: true, force: true }); } + }); +}); diff --git a/desktop/src/lib/placement.test.ts b/desktop/src/lib/placement.test.ts index 4197f15b..4887a3db 100644 --- a/desktop/src/lib/placement.test.ts +++ b/desktop/src/lib/placement.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { PlacementCheck, PlacementItem } from "@/api"; -import { blockedReason, overhangText, placementVerdict } from "./placement"; +import { blockedReason, itemLabel, overhangText, placementVerdict } from "./placement"; const item = (over: Partial = {}): PlacementItem => ({ object_id: "1", @@ -107,3 +107,56 @@ describe("blockedReason", () => { .toContain("will not guess"); }); }); + +describe("repeated instances", () => { + const second = { ...item({ right: 235 }), item_index: 0, instance_index: 0, instance_count: 2 }; + const first = { ...item(), off_plate: false, item_index: 1, instance_index: 1, instance_count: 2 }; + + it("labels each instance of one object separately", () => { + expect(itemLabel(second)).toBe("Object 1 · instance 1 of 2"); + expect(itemLabel(item())).toBe("Object 1"); + }); + + it("counts placed instances, so an in-bounds one cannot hide an off-plate one", () => { + const verdict = placementVerdict( + check({ items: [second, first], off_plate: [second], item_count: 2, fixable: false }), + ); + expect(verdict.headline).toBe("1 placed instance is outside the U1's printable area."); + expect(verdict.tone).toBe("blocked"); + }); + + it("says placed instances when all are inside", () => { + const verdict = placementVerdict(check({ items: [first, { ...first, item_index: 2 }], item_count: 2 })); + expect(verdict.headline).toBe("Every placed instance sits inside the U1's printable area."); + }); + + it("never shows the ok headline while an instance could not be judged", () => { + const verdict = placementVerdict( + check({ items: [first], item_count: 1, off_plate: [], not_judged: 2, unresolved_objects: [{ object_id: "1" }, { object_id: "1" }] }), + ); + expect(verdict.tone).toBe("blocked"); + expect(verdict.headline).toBe( + "2 placed instances could not be judged, so Studio cannot say whether everything is inside the U1's printable area.", + ); + expect(verdict.canFix).toBe(false); + }); + + it("explains unjudged instances even when nothing is off the plate", () => { + const reason = blockedReason(check({ off_plate: [], not_judged: 1, unresolved_objects: [{ object_id: "1" }] })); + expect(reason).toContain("could not be found or are not listed on any plate"); + }); +}); + +describe("placementVerdict for several plates", () => { + it("is unverified, not ok, and offers no fix when every plate fits on its own", () => { + const v = placementVerdict(check({ plate_count: 2, item_count: 2 })); + expect(v.tone).toBe("unverified"); + expect(v.canFix).toBe(false); + expect(v.headline).toContain("Studio cannot check where the plates sit"); + expect(v.headline).not.toMatch(/inside/i); + }); + + it("still reports an oversized plate as outside", () => { + expect(placementVerdict(check({ plate_count: 2, off_plate: [item()] })).tone).toBe("blocked"); + }); +}); diff --git a/desktop/src/lib/placement.ts b/desktop/src/lib/placement.ts index 9e349dce..cf6c6c26 100644 --- a/desktop/src/lib/placement.ts +++ b/desktop/src/lib/placement.ts @@ -6,7 +6,15 @@ import type { PlacementCheck, PlacementItem } from "@/api"; -export type PlacementTone = "ok" | "warn" | "blocked" | "unknown"; +export type PlacementTone = "ok" | "warn" | "blocked" | "unknown" | "unverified"; + +/** Same sentence the engine puts in the placement summary for a multi-plate project. */ +export const MULTI_PLATE_UNVERIFIED = + "Each plate fits on its own, but Studio cannot check where the plates sit. In our " + + "test with a two-plate project made for a larger-bed printer, Snapmaker Orca listed " + + "one object as Outside and would not slice until it was moved by hand; " + + "Arrange all plates did not fix that in our test. " + + "Review the project in Snapmaker Orca before slicing."; export interface PlacementVerdict { tone: PlacementTone; @@ -30,18 +38,42 @@ export function placementVerdict(check: PlacementCheck | null): PlacementVerdict canFix: false, }; } + const repeated = check.items.some((entry) => (entry.instance_count ?? 1) > 1); + const unjudged = unjudgedCount(check); + if (check.off_plate.length === 0 && unjudged > 0) { + // Not "inside": something could not be judged at all. + return { + tone: "blocked", + headline: `${unjudged} placed ${unjudged === 1 ? "instance" : "instances"} could not be judged, so Studio cannot say whether everything is inside the U1's printable area.`, + canFix: false, + }; + } + if (check.off_plate.length === 0 && (check.plate_count ?? 1) > 1) { + // Several plates: each may fit, but Studio does not know where the plates sit, + // so this is never a green "every object is inside". + return { tone: "unverified", headline: MULTI_PLATE_UNVERIFIED, canFix: false }; + } if (check.off_plate.length === 0) { const many = (check.item_count ?? 0) > 1; return { tone: "ok", - headline: many - ? "Every object sits inside the U1's printable area." - : "The object sits inside the U1's printable area.", + headline: repeated + ? "Every placed instance sits inside the U1's printable area." + : many + ? "Every object sits inside the U1's printable area." + : "The object sits inside the U1's printable area.", canFix: false, }; } const count = check.off_plate.length; - const noun = count === 1 ? "object is" : "objects are"; + // A repeated object is one object at several places: count the places. + const noun = repeated + ? count === 1 + ? "placed instance is" + : "placed instances are" + : count === 1 + ? "object is" + : "objects are"; return { tone: check.fixable ? "warn" : "blocked", headline: `${count} ${noun} outside the U1's printable area.`, @@ -49,6 +81,18 @@ export function placementVerdict(check: PlacementCheck | null): PlacementVerdict }; } +/** "Object 1", or "Object 1 · instance 2 of 3" when the same object is placed more than once. */ +export function itemLabel(item: PlacementItem): string { + const total = item.instance_count ?? 1; + if (total <= 1) return `Object ${item.object_id}`; + return `Object ${item.object_id} · instance ${(item.instance_index ?? 0) + 1} of ${total}`; +} + +/** Placed instances Studio could not judge. */ +export function unjudgedCount(check: PlacementCheck): number { + return check.not_judged ?? check.unresolved_objects?.length ?? 0; +} + /** How far off, and which way — as a sentence rather than four numbers. */ export function overhangText(item: PlacementItem): string { const parts = (["left", "right", "front", "back"] as const) @@ -61,10 +105,11 @@ export function overhangText(item: PlacementItem): string { /** Why Studio is refusing to move things, when it is. */ export function blockedReason(check: PlacementCheck | null): string | null { - if (!check?.available || check.fixable || check.off_plate.length === 0) return null; - if (check.unresolved_objects?.length) { - return "Some objects are not listed on any plate, so Studio cannot tell where they belong."; + if (!check?.available) return null; + if (unjudgedCount(check) > 0) { + return "Some placed instances could not be found or are not listed on any plate, so Studio cannot tell whether they are on the plate."; } + if (check.fixable || check.off_plate.length === 0) return null; if (check.skipped_plates?.length) { return "At least one plate will not fit a U1 plate. Studio moves every plate or none."; } diff --git a/desktop/src/lib/readiness.ts b/desktop/src/lib/readiness.ts index d4113c1e..d7a7aeb3 100644 --- a/desktop/src/lib/readiness.ts +++ b/desktop/src/lib/readiness.ts @@ -50,8 +50,8 @@ function deriveNextActions(atRisk: string[]): string[] { out.push("Plan colour swaps or remap to 4 colours in Snapmaker Orca."); if (blob.includes("support") || blob.includes("overhang")) out.push("Check / enable supports in Snapmaker Orca."); - if (blob.includes("plate") || blob.includes("layout") || blob.includes("arrange")) - out.push("Arrange all plates in Snapmaker Orca before slicing."); + if (blob.includes("plate") || blob.includes("layout")) + out.push("Review the plates and the prime tower in Snapmaker Orca before slicing."); return out; } diff --git a/desktop/src/lib/scaleOptions.ts b/desktop/src/lib/scaleOptions.ts index 25a60825..0cc668df 100644 --- a/desktop/src/lib/scaleOptions.ts +++ b/desktop/src/lib/scaleOptions.ts @@ -18,7 +18,7 @@ export function recommendBlurb(r: ScaleOptionsResult): string { return `Largest verified scale that stays on the plate: ${pct}%.`; } return `Largest fit by size only: ${pct}%. Snapmaker Orca can still reject a scale because ` - + `where the object sits on the plate also matters — verify in Orca and Arrange all plates after scaling.`; + + `where the object sits on the plate also matters — check it in Snapmaker Orca after scaling.`; } // The green "recommended" badge only appears when placement is actually verified — diff --git a/desktop/src/lib/settingNotes.test.ts b/desktop/src/lib/settingNotes.test.ts index f87e3b3e..1e28b198 100644 --- a/desktop/src/lib/settingNotes.test.ts +++ b/desktop/src/lib/settingNotes.test.ts @@ -5,11 +5,11 @@ const change = (extra: Record) => ({ key: "k", old: "a", new: " describe("settingNote", () => { it("prefers the engine's explanation", () => { - expect(settingNote(change({ reason: "short", explanation: "Because the importer says so." }))).toBe("Because the importer says so."); + expect(settingNote(change({ reason: "short", explanation: "Because the importer says so." }))).toEqual({ note: "Because the importer says so.", source: null, kind: null }); }); it("falls back to a specific reason", () => { - expect(settingNote(change({ reason: "the creator left the brim on automatic" }))).toBe("the creator left the brim on automatic"); + expect(settingNote(change({ reason: "the creator left the brim on automatic" }))).toEqual({ note: "the creator left the brim on automatic", source: null, kind: null }); }); it("says nothing when the reason only restates that the setting changed", () => { @@ -29,6 +29,12 @@ describe("settingNote", () => { }); it("keeps an honest 'not reported' reason, because that is itself a statement of what Studio does not know", () => { - expect(settingNote(change({ reason: "change was not reported by the repair pipeline" }))).toBe("change was not reported by the repair pipeline"); + expect(settingNote(change({ reason: "change was not reported by the repair pipeline" }))).toEqual({ note: "change was not reported by the repair pipeline", source: null, kind: null }); + }); + + it("returns source and kind with the same note record", () => { + expect(settingNote(change({ explanation: "It was outside the range.", source: "Studio's valid range for this setting", kind: "engine" }))).toEqual({ + note: "It was outside the range.", source: "Studio's valid range for this setting", kind: "engine", + }); }); }); diff --git a/desktop/src/lib/settingNotes.ts b/desktop/src/lib/settingNotes.ts index 6cce9247..107086f5 100644 --- a/desktop/src/lib/settingNotes.ts +++ b/desktop/src/lib/settingNotes.ts @@ -1,4 +1,5 @@ import type { SettingsChange } from "@/api"; +import { evidenceKindLabel, type EvidenceKind } from "./evidenceKind"; // What to tell a person about WHY one setting changed, using only what the engine already said. // @@ -16,12 +17,20 @@ const RESTATES_THE_CHANGE = new Set([ "U1 project identity normalized", ]); -export function settingNote(change: SettingsChange): string | null { +export interface SettingNote { + note: string; + source: string | null; + kind: EvidenceKind | null; +} + +export function settingNote(change: SettingsChange): SettingNote | null { const explanation = change.explanation?.trim(); - if (explanation) return explanation; + const source = change.source?.trim() || null; + const kind = change.kind && evidenceKindLabel(change.kind) ? change.kind : null; + if (explanation) return { note: explanation, source, kind }; const reason = change.reason?.trim(); if (!reason) return null; // "U1 compatibility clamp: -1 → 5" only repeats the old and new values already on the line above it. if (RESTATES_THE_CHANGE.has(reason) || reason.startsWith("U1 compatibility clamp:")) return null; - return reason; + return { note: reason, source, kind }; } diff --git a/desktop/src/lib/sizeFit.test.ts b/desktop/src/lib/sizeFit.test.ts new file mode 100644 index 00000000..1aa82e0e --- /dev/null +++ b/desktop/src/lib/sizeFit.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { ObjectSize } from "@/api"; +import { UNMEASURED, sizeRow } from "./sizeFit"; + +const BED = { x: 270, y: 270, z: 270 }; +const obj = (id: string, x: number, y = x, z = x): ObjectSize => ({ + object_id: id, + instance_count: 1, + dimensions_mm: { x, y, z }, +}); + +describe("sizeRow", () => { + it("says it is a size statement and that position is a separate check", () => { + const row = sizeRow([obj("1", 30)], { x: 30, y: 30, z: 30 }, BED, "the U1’s"); + expect(row?.level).toBe("ok"); + expect(row?.detail.startsWith("By size, ")).toBe(true); + expect(row?.detail).toContain("Size only: where it sits on the plate is a separate check."); + }); + + it("judges each object, not the combined extents of separated ones", () => { + // the overall extents (520 mm) are not an object's size + const row = sizeRow([obj("1", 20), obj("2", 20)], { x: 520, y: 20, z: 20 }, BED, "the U1’s"); + expect(row?.level).toBe("ok"); + expect(row?.detail).toContain("each of the 2 objects fits"); + expect(row?.detail).not.toMatch(/scale|split/); + }); + + it("names the object that is too big", () => { + const row = sizeRow([obj("1", 20), obj("2", 300, 20, 20)], null, BED, "the U1’s"); + expect(row?.level).toBe("risk"); + expect(row?.detail).toContain("By size, object 2 (300 × 20 × 20 mm) is larger than"); + }); + + it("keeps an unknown size unknown and never falls back to the combined extents", () => { + for (const overall of [{ x: 300, y: 10, z: 10 }, { x: 20, y: 20, z: 20 }]) { + const row = sizeRow([], overall, BED, "your connected U1"); + expect(row).toEqual({ level: "warn", status: "Unknown", detail: UNMEASURED }); + } + expect(sizeRow(undefined, { x: 520, y: 20, z: 20 }, BED, "x")?.detail).not.toMatch(/scale|split|fit /); + expect(sizeRow(undefined, null, BED, "x")).toBeNull(); + }); + + it("does not say everything fits while some build items could not be measured", () => { + const row = sizeRow([obj("1", 30)], null, BED, "the U1’s", 2); + expect(row?.level).toBe("warn"); + expect(row?.status).toBe("Check"); + expect(row?.detail).toContain("2 build items could not be measured"); + }); +}); diff --git a/desktop/src/lib/sizeFit.ts b/desktop/src/lib/sizeFit.ts new file mode 100644 index 00000000..f2d1d56b --- /dev/null +++ b/desktop/src/lib/sizeFit.ts @@ -0,0 +1,74 @@ +// The "Bed fit" row of Design Health, as plain data so its wording stays testable. +// +// This row is a SIZE statement: how big each object is against the bed. It says nothing about where +// anything sits, so it always begins "By size" and points at the placement check for position. The +// combined extents of several separated objects or plates are never read as one big object. + +import type { ObjectSize } from "@/api"; + +export type SizeLevel = "ok" | "warn" | "risk"; + +export interface Bed { + x: number; + y: number; + z: number; +} + +export interface SizeRow { + level: SizeLevel; + status: string; + detail: string; +} + +type Dims = { x: number; y: number; z: number }; + +const fitsBed = (d: Dims, bed: Bed) => d.x <= bed.x && d.y <= bed.y && d.z <= bed.z; +const mm = (d: Dims) => `${d.x} × ${d.y} × ${d.z} mm`; + +export const UNMEASURED = "By size, Studio could not measure each object, so it cannot say whether they fit."; +const SIZE_ONLY = "Size only: where it sits on the plate is a separate check."; + +/** + * `overall` is the combined extents of all the mesh data. It is only a signal that there IS geometry: + * it is never used to say an object fits or is too big, because for several separated objects or plates + * it is not any one object's size. No per-object sizes means "unknown". + */ +export function sizeRow( + objects: ObjectSize[] | null | undefined, + overall: Dims | null | undefined, + bed: Bed, + bedSource: string, + unmeasured = 0, +): SizeRow | null { + const bedStr = `${bed.x} × ${bed.y} × ${bed.z} mm`; + const sized = (objects ?? []).filter((o) => o.dimensions_mm); + if (sized.length === 0) { + if (!overall) return null; + return { level: "warn", status: "Unknown", detail: UNMEASURED }; + } + const missing = + unmeasured > 0 + ? ` ${unmeasured} build item${unmeasured === 1 ? "" : "s"} could not be measured, so Studio cannot say whether everything fits.` + : ""; + const tooBig = sized.filter((o) => !fitsBed(o.dimensions_mm, bed)); + const many = sized.length > 1; + if (tooBig.length > 0) { + const first = tooBig[0]; + const who = many && first.object_id != null ? `object ${first.object_id} (${mm(first.dimensions_mm)})` : mm(first.dimensions_mm); + const more = tooBig.length > 1 ? ` ${tooBig.length} objects are larger than the bed.` : ""; + return { + level: "risk", + status: "Too big", + detail: `By size, ${who} is larger than ${bedSource} ${bedStr} bed — scale it down or split it.${more}${missing}`, + }; + } + const largest = sized.reduce((a, b) => (b.dimensions_mm.x * b.dimensions_mm.y > a.dimensions_mm.x * a.dimensions_mm.y ? b : a)); + const subject = many + ? `each of the ${sized.length} objects fits ${bedSource} ${bedStr} bed (the largest is ${mm(largest.dimensions_mm)})` + : `${mm(largest.dimensions_mm)} fits ${bedSource} ${bedStr} bed`; + return { + level: missing ? "warn" : "ok", + status: missing ? "Check" : "Fits", + detail: `By size, ${subject}. ${SIZE_ONLY}${missing}`, + }; +} diff --git a/desktop/src/routes/Compatibility.tsx b/desktop/src/routes/Compatibility.tsx index 9286a6d6..1fc94584 100644 --- a/desktop/src/routes/Compatibility.tsx +++ b/desktop/src/routes/Compatibility.tsx @@ -159,8 +159,9 @@ export default function Compatibility() {

    - Layout is not verified — this checks the U1 profile copy, not object placement. Open in - Snapmaker Orca and use Arrange all plates before slicing; objects may sit outside a plate. + Layout is not verified — this checks the U1 profile copy, not object placement. Review the + project in Snapmaker Orca before slicing; for a project made for a larger printer an object + or the prime tower may sit outside a plate, and Arrange all plates did not fix that in our test.

    {prep.settings_summary && { if (path && !blocked) { setPrepareMode("recommended"); startPrepare(path, "recommended"); } }} />} diff --git a/desktop/src/routes/CompatibilityHub.tsx b/desktop/src/routes/CompatibilityHub.tsx index e03e6639..1174db5e 100644 --- a/desktop/src/routes/CompatibilityHub.tsx +++ b/desktop/src/routes/CompatibilityHub.tsx @@ -8,7 +8,7 @@ import SourceCompatibility from "@/routes/SourceCompatibility"; // as two tabs. /source still resolves on its own for any deep links. export default function CompatibilityHub() { return ( - }, { id: "source", label: "Source Check", icon: FileSearch, el: }, ]} /> diff --git a/desktop/src/routes/DesignInsights.tsx b/desktop/src/routes/DesignInsights.tsx index da63ece5..33115432 100644 --- a/desktop/src/routes/DesignInsights.tsx +++ b/desktop/src/routes/DesignInsights.tsx @@ -164,7 +164,7 @@ export default function DesignInsights() {

    We made a U1 copy of {file.name}. Your original file is not modified. {setupRisk - ? " Print-setup risks remain (see the checks) — open in Snapmaker Orca, arrange plates and review supports/colours before slicing." + ? " Print-setup risks remain (see the checks) — open in Snapmaker Orca and review the plates, supports and colours before slicing." : " This is an advisory check, not a guarantee — slice in Snapmaker Orca to confirm."}

    @@ -430,7 +430,7 @@ export default function DesignInsights() {
    Design Health
    - +
    )} diff --git a/desktop/src/routes/LiveWorkspace.tsx b/desktop/src/routes/LiveWorkspace.tsx index bf27f63a..3d8cbc89 100644 --- a/desktop/src/routes/LiveWorkspace.tsx +++ b/desktop/src/routes/LiveWorkspace.tsx @@ -126,7 +126,7 @@ export default function LiveWorkspace() {
    Design Health
    - +
    )} diff --git a/desktop/src/routes/PrintQualityHub.tsx b/desktop/src/routes/PrintQualityHub.tsx index 90ac5958..f0194489 100644 --- a/desktop/src/routes/PrintQualityHub.tsx +++ b/desktop/src/routes/PrintQualityHub.tsx @@ -8,7 +8,7 @@ import FirstLayer from "@/routes/FirstLayer"; // tabs. /first-layer still resolves on its own for any deep links. export default function PrintQualityHub() { return ( - }, { id: "first-layer", label: "First Layer", icon: Layers, el: }, ]} /> diff --git a/desktop/src/routes/ToolTabsHubs.test.tsx b/desktop/src/routes/ToolTabsHubs.test.tsx new file mode 100644 index 00000000..9ec36a8a --- /dev/null +++ b/desktop/src/routes/ToolTabsHubs.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The four panels call the engine on mount. Stubs keep this a test of the tab wiring only. +vi.mock("@/routes/Compatibility", () => ({ default: () =>

    Compatibility panel

    })); +vi.mock("@/routes/SourceCompatibility", () => ({ default: () =>

    Source Check panel

    })); +vi.mock("@/routes/PrintQuality", () => ({ default: () =>

    Print Quality panel

    })); +vi.mock("@/routes/FirstLayer", () => ({ default: () =>

    First Layer panel

    })); + +import CompatibilityHub from "./CompatibilityHub"; +import PrintQualityHub from "./PrintQualityHub"; + +afterEach(cleanup); + +describe("tool hubs", () => { + it("Compatibility has an accessible tab row with its two tabs", () => { + render(); + expect(screen.getByRole("tablist", { name: "Compatibility tools" })).toBeTruthy(); + expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Compatibility", "Source Check"]); + expect(screen.queryByText("Compatibility panel")).not.toBeNull(); + fireEvent.click(screen.getByRole("tab", { name: "Source Check" })); + expect(screen.queryByText("Source Check panel")).not.toBeNull(); + expect(screen.queryByText("Compatibility panel")).toBeNull(); + }); + + it("Print Quality has an accessible tab row with its two tabs", () => { + render(); + expect(screen.getByRole("tablist", { name: "Print quality tools" })).toBeTruthy(); + expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Print Quality", "First Layer"]); + fireEvent.click(screen.getByRole("tab", { name: "First Layer" })); + expect(screen.queryByText("First Layer panel")).not.toBeNull(); + }); +}); diff --git a/desktop/src/test-fixtures/compat-blocked.json b/desktop/src/test-fixtures/compat-blocked.json index 87dc5199..e0074d50 100644 --- a/desktop/src/test-fixtures/compat-blocked.json +++ b/desktop/src/test-fixtures/compat-blocked.json @@ -79,7 +79,7 @@ "title": "Object spacing / collisions are not verified by Studio yet", "explanation": "Studio does not yet check object-to-object spacing for multi-part 3MF layouts, so it cannot confirm objects aren't too close or colliding.", "setting_path": "Object layout (plate)", - "suggested_action": "Open in Snapmaker Orca and check for too-close / collision warnings; use Arrange or move objects before slicing.", + "suggested_action": "Review the project in Snapmaker Orca and look for too-close / collision warnings before slicing.", "evidence": "2 object(s) on the plate; spacing unchecked by Studio" } ], @@ -103,7 +103,7 @@ "title": "Object spacing / collisions are not verified by Studio yet", "explanation": "Studio does not yet check object-to-object spacing for multi-part 3MF layouts, so it cannot confirm objects aren't too close or colliding.", "setting_path": "Object layout (plate)", - "suggested_action": "Open in Snapmaker Orca and check for too-close / collision warnings; use Arrange or move objects before slicing.", + "suggested_action": "Review the project in Snapmaker Orca and look for too-close / collision warnings before slicing.", "evidence": "2 object(s) on the plate; spacing unchecked by Studio" } ], @@ -181,7 +181,7 @@ "title": "Object spacing / collisions are not verified by Studio yet", "explanation": "Studio does not yet check object-to-object spacing for multi-part 3MF layouts, so it cannot confirm objects aren't too close or colliding.", "setting_path": "Object layout (plate)", - "suggested_action": "Open in Snapmaker Orca and check for too-close / collision warnings; use Arrange or move objects before slicing.", + "suggested_action": "Review the project in Snapmaker Orca and look for too-close / collision warnings before slicing.", "evidence": "2 object(s) on the plate; spacing unchecked by Studio" } ], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f373737e..ac3110c6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,6 +65,27 @@ processes** on graceful close, crash, or force-kill. recommend a Prepare the engine would refuse. - **`repair`** — clamp rules -> profile swap -> conform filament arrays -> **`u1_identity`** normalize -> scrub foreign tokens -> blank slice_info version. +- **`regrid`** — prepare-time layout correction, both modes: a multi-plate project's + build items are translated onto the U1 plate grid (Orca's rule: stride = + `int(bed) * 1.2`, `cols = compute_colum_count`, origin `(col*sx, -row*sy)`) only + when every item's footprint lies in the cell its own plate record names; + otherwise nothing moves and it says why. Translation tokens only. Prime towers + are assessed by one rule (`prime_tower.assess`: `outside_proven` only when the start + corner is outside the printable area, otherwise `unknown`; first-entry fallback for + a plate with no value of its own) and never moved. Studio preserves the project's + `wipe_tower_x` and `wipe_tower_y` values in both modes (`profile.apply_swap`). + Recommended also declares these keys in `different_settings_to_system`. Tests cover + the written values and declarations. Whether Snapmaker Orca retains these coordinates + on import in Recommended mode has not been verified. Width is not an extent and no + depth, rectangle or clearance is estimated, because depth is decided by Orca at + slice time. +- **`support_intent`** — the one exception to Recommended's starter-settings reset. + Precedence: explicit disable (`enable_support` 0 in the project or on an object) + > positive intent (painted support *enforcers*, `support_enforcer` volumes, + `enable_support` 1 on an object) > starter reset (no data, or blocker-only data) + > the creator's values. Positive intent keeps and declares `enable_support`, + `support_type`, `support_filament`, `support_interface_filament`; Preserve never + reaches it. - **`u1_identity`** — the authoritative U1 identity layer: preset ids, version, filament identity (from the known-good reference), foreign Bambu/BBL/H2D scrub, newer-schema value normalization, and `is_u1_clean()` (the validation gate). diff --git a/docs/INNOVATION_FUND.md b/docs/INNOVATION_FUND.md index 46eefed2..6fe99e4e 100644 --- a/docs/INNOVATION_FUND.md +++ b/docs/INNOVATION_FUND.md @@ -170,7 +170,7 @@ guessing in the direction that looks better. to end and prints a 27-check pass/fail table, so the claims can be verified without reading the source. It runs in CI on every pull request. - **An acceptance harness that drives the installed application**, not a dev - server: 45 checks over the real window and the frozen engine, including that the + server: 50 checks over the real window and the frozen engine, including that the input file is byte-identical afterwards and that uninstalling leaves nothing behind. - **Regression tests against genuine slicer output** — real OrcaSlicer, @@ -208,19 +208,18 @@ simply the machine it is verified against. ## Evidence -Everything below was verified against the **v1.5.0 release-candidate build** (the same -application published on the release page; the installed-application run used an acceptance copy of its installer, and the real installer was exercised only on a disposable GitHub-hosted runner), not a development build. The backend and desktop test counts come from the +The table below holds results from an **earlier v1.5.1 release candidate** (a different build, never published; the installed-application run used an acceptance copy of its installer, and the real installer was exercised only on a disposable GitHub-hosted runner). The current v1.5.1 candidate has completed its build, upgrade-smoke and Linux clean-image checks only; its installed-application, Orca and real-printer checks have not been run, so these results are not acceptance of it. The backend and desktop test counts are the exception: they come from the current candidate's source. The backend and desktop test counts come from the source at the release commit, not from the installer. Commands, counts and full reports: [TRUST_STATUS.md](TRUST_STATUS.md). | What | Result | |---|---| -| Installed-application acceptance, through the real UI | 45/45 | -| Read-only verification against a real Snapmaker U1, from the Windows-installed app | 58/58 | +| Installed-application acceptance, through the real UI | 50/50 | +| Read-only verification against a real Snapmaker U1, from the Windows-installed app, with Spoolman and Bambuddy | 76/76 | | Regression tests against genuine Orca/Bambu/Prusa projects | 36 tests | | End-to-end pipeline self-check | 27/27 | -| Backend tests | 2804 passed, 13 skipped | -| Desktop tests | 725 passed | +| Backend tests | 3716 passed, 49 skipped | +| Desktop tests | 902 passed | | TypeScript, Rust, production build | clean | Demo: [`docs/media/snapmaker-studio-demo.mp4`](media/snapmaker-studio-demo.mp4) — diff --git a/docs/RELEASE_METADATA.md b/docs/RELEASE_METADATA.md index 60ef394f..37a67034 100644 --- a/docs/RELEASE_METADATA.md +++ b/docs/RELEASE_METADATA.md @@ -15,6 +15,27 @@ Every *other* document must link here rather than restate these values. ## Current release +| Field | Value | +|---|---| +| Version | v1.5.1 | +| Installer | `Snapmaker.Studio_1.5.1_x64-setup.exe` | +| Size (bytes) | 22,563,613 | +| SHA256 | `703eac4f394ab92bc30c1eaa67733f1a7b274c23e2900089980fc2a1b59e4082` | +| Linux installer | `snapmaker-studio_1.5.1_amd64_b83e236148dc.deb` | +| Linux size (bytes) | 26,119,758 | +| Linux SHA256 | `75d88a0d64a67c89ad7ded45865359b1b6642309ace14cb9945f5f55863766ff` | +| Build run | https://github.com/DVOpenLabs/snapmaker-studio/actions/runs/39055393052 (source commit `b83e236148dcdfa2cc0c72bc9658df70d9df97f9`, branch `integ/v1.5.1-candidate`) | +| Release URL | https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.1 (does not exist until the release is published) | +| Trust status | CANDIDATE, NOT ACCEPTED: build, upgrade smoke, Linux clean-image, installed-app acceptance and a partial Orca check are recorded; the real-printer check has not been run. See [docs/TRUST_STATUS.md](TRUST_STATUS.md) | + +This is a release candidate that has not been published. An earlier v1.5.1 release candidate (run 37947895421, commit abf20bf0f74d) was built from a different source tree, never published, and is superseded by this one; its acceptance results belong to that artifact only. A second candidate (run 38913728391, commit f0c0cd533627) was replaced after its acceptance harness fixture and a test helper were corrected, and a third (run 38929429440, commit e40a50ffa08c) was replaced by this one after Recommended mode was changed to declare the prime-tower shape it writes and the tower note was reworded; the results of those candidates are historical and are not reused. v1.5.1 is intended to be a stable release, not a prerelease, so GitHub's "latest release" would point at it once published. +Hashes for both files are also published as `SHA256SUMS` on the release. + +Note: verify with `Get-FileHash -Algorithm SHA256 ` (Windows) or +`sha256sum .deb` (Linux). + +## Previous release + | Field | Value | |---|---| | Version | v1.5.0 | @@ -24,17 +45,9 @@ Every *other* document must link here rather than restate these values. | Linux installer | `snapmaker-studio_1.5.0_amd64_5245cf9f9664.deb` | | Linux size (bytes) | 25,944,098 | | Linux SHA256 | `7fca28d9d4e2558f8a6bdc765b121e2290c54abd39e2a1b9b2efaeea6d923e5a` | -| Build run | https://github.com/DVOpenLabs/snapmaker-studio/actions/runs/37533259845 | | Release URL | https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.0 | -| Trust status | ACCEPTED — see [docs/TRUST_STATUS.md](TRUST_STATUS.md) | - -A stable release, not a prerelease, so GitHub's "latest release" points at it. -Hashes for both files are also published as `SHA256SUMS` on the release. - -Note: verify with `Get-FileHash -Algorithm SHA256 ` (Windows) or -`sha256sum .deb` (Linux). -## Previous release +## Superseded | Field | Value | |---|---| diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index b03b157b..2747d6fc 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,73 +1,100 @@ -# Snapmaker Studio v1.5.0 — Project Materials +# Snapmaker Studio v1.5.1 — clearer Prepare, honest plate and tower checks, easier spool choices, accessible controls > **Independent open-source project — not affiliated with or endorsed by Snapmaker.** > "Snapmaker" is a trademark of its respective owner. -Which real spool is in which colour slot, and which installed Snapmaker Orca preset goes with it, -is now part of preparing a project (#39). Studio shows what the model asks for, suggests spools from -your inventory, and writes your choices into a prepared copy. You pick every one of them. +A release on top of v1.5.0. More Bambu Studio projects now prepare, and when Studio cannot prepare one it says +why in plain words. Studio is more careful about what it claims for projects with several plates and for the +prime tower. Project Materials is easier to use with a real spool inventory, Prepare says why a setting changed, +and two screens work from the keyboard and with a screen reader. Your original files are still never modified, +Studio still does not slice, and it never starts a print without your confirmation. Studio is advisory: a copy +Studio prepares still needs a look in Snapmaker Orca before you slice, and none of this promises a successful print. -**Model → the materials and colours it needs → suggested spools from your inventory → the installed -Orca preset you confirm → review → Prepare.** +## Prepare: more projects, clearer answers + +- **More Bambu Studio projects prepare.** Studio now keeps six more per-object settings that Snapmaker Orca 2.4.0 + reads in the same words: infill pattern, skeleton and skin infill density, top and bottom shell layers, and + brim type. Every value is checked first, and anything else is still refused rather than guessed. +- **A part used more than once in an object no longer counts as corrupt.** A real mismatch between the parts and + the object is still refused. +- **When Studio refuses a file, it says why in plain language** — which settings or structure, on which object — + and what to do next. The technical wording is kept in a collapsed "Technical details" area. +- **Files Studio will not prepare are marked everywhere.** The Doctor, your library, the Dashboard and Projects + lists, Ready Now, the Compatibility check, the Validation Center and the command line all say "Review in Orca + first" instead of calling the file ready or sending it to Prepare. +- **Projects with blank values Snapmaker Orca 2.4.0 cannot read** are named in plain language instead of being + passed on. +- **No `.orig.3mf` file is left beside your original.** The original was never modified; the extra copy was only a + leftover. The command-line `repair` and `optimize` commands still keep their snapshot. +- **Prepare says why a setting changed**, and whether the statement is Studio's own check or advice to verify in + Snapmaker Orca. + +## Plates, objects and the prime tower: what Studio checks, and what it does not + +- **Several plates from a printer with a different plate size.** When every object's whole footprint lies inside + the plate area its project names for it, Studio lays each plate's objects out on the U1's plate grid (moves + only; rotation, scale and painting untouched), in both modes. Otherwise nothing is moved and the copy says why. +- **No false green for multi-plate projects.** Studio checks that each plate fits the U1 on its own. It does not + check where the plates sit, so the Object placement card, Ready Now and the Project Doctor say that Snapmaker + Orca can still list an object as Outside and to review the project there. +- **A prime tower outside the printable area is reported, never moved.** Both modes keep your project's own tower + position. Studio does not estimate how far a tower reaches; any other tower is "not verified". +- **Studio no longer recommends Arrange as a remedy.** Every message and guide page now says to review the project + in Snapmaker Orca before slicing. +- **Plate count comes from the plates the project declares**, so a project with several plates is no longer counted + as one, and a prepared copy is no longer reported as not validated because the count appeared to drop. +- **Bed fit and placement.** Each object's size and the instances' placement are reported separately, several small + separated objects are no longer summed into one "too big" object, and each placed copy of a repeated object is + judged on its own. +- **"Use Studio's U1 starter settings instead" keeps your support settings** when the model itself marks where it + needs support. When the model marks nothing, the Prepare summary tells you before you open the copy that + Snapmaker Orca will open with supports off. ## Project Materials -- **It appears on Prepare whenever the project has filament slots**, whether or not the - compatibility check found anything. Nothing on the page waits for a check result. -- **Top spool candidates, with the reasons.** For each slot Studio lists the best three spools - from your provider (Spoolman, SpoolEase or Bambuddy) and says why: material match, whether the - subtype could be compared, colour distance, and whether the filament left looks like enough - (always marked as an estimate Studio cannot fully trust). -- **Nothing is chosen for you.** No spool is selected automatically and no preset is confirmed - silently. Studio may suggest a generic preset; it never applies one on its own. -- **You choose the physical spool**, then the installed Orca preset for it. The spool's colour is - carried into the prepared copy. -- **Remember a mapping** for one spool or for similar spools. It is saved only after the prepared - copy was actually written, and it is checked again every time: a preset that was deleted, - renamed, replaced or no longer fits is not applied. -- **Review before Prepare.** A plain-language summary lists, per slot, what was mapped, what - changed, what was kept and what the preset controls. Spool details are labelled as what you - selected, not as read from the file. -- **Same-preset safety.** Snapmaker Orca copies a declared value to every slot that uses the same - preset. Studio removes only its own declarations from such a group, and stops (writing nothing, - and saying why) if the project's own vendor or type declarations would be copied onto a slot they - do not belong to. -- **Recommended mode no longer silently writes the legacy "Snapmaker PLA" preset.** A slot keeps - the project's own filament identity unless you confirmed an installed preset for it. - -## Snapmaker Orca's own presets - -Studio reads the filament presets installed with Snapmaker Orca and can check each one against the -U1 and your nozzle size. A preset Studio verifies that way is shown as **Proven**, and a confirmed -Orca preset is the verified path: Snapmaker Orca applies its temperature, flow and cooling. - -## Presets you made yourself — an important limit - -- Studio can find compatible filament presets you created in Snapmaker Orca, map a spool to one, - and remember that mapping. -- They are shown as **"Confirmed by you"** and **"Manual check in Orca required"**, never as Proven. -- In testing, when Snapmaker Orca opened a project that names a preset someone made, it kept the - project's own values and showed the preset as modified, rather than applying the preset's own - temperature, flow and cooling (in one profile it also showed a "Customized Preset" prompt). -- **Studio therefore does not say Orca will apply those values.** Check the filament in Snapmaker - Orca before slicing. - -## Not in this release - -- Creating custom Orca presets. -- Changing or decrementing anything in your spool provider. -- Choosing a spool for you. -- Writing print parameters of its own. -- Keeping a project's arbitrary print parameters underneath a different preset. - -Studio does not slice and does not start prints: Snapmaker Orca slices, and anything sent to your -printer is something you confirm. +- **Choose any spool from your inventory.** Each slot can open your provider's whole spool list, searchable by + vendor, material, colour or id, showing colour, vendor, material, spool id, weight status and the status of the + mapped Orca preset. +- **Studio says when a spool leaves the preset alone.** If you pick a spool but no Orca preset, the slot and the + review say the project's existing filament preset will remain. +- **Forget a saved mapping.** The confirmation says what is forgotten and what is not changed (your inventory, + your Orca presets and the project). +- **A saved-mappings file that is briefly locked is no longer treated as damaged.** Studio retries, and if it still + cannot read the file it changes nothing and asks you to try again. + +## Easier to use with a keyboard or screen reader + +- **Printer actions.** The confirmation for Start, Cancel print and Emergency stop is a proper dialog: it has a + name and description, starts on **Cancel**, and Escape only dismisses it. It names the printer (and the file for + Start) and is withdrawn if the printer changes or can no longer take the action. +- **Tool tabs.** The tab rows for Compatibility / Source Check and Print Quality / First Layer work with the arrow + keys, Home and End, show a clear focus outline, and wrap on narrow windows. + +## User guide + +- The in-app Help and Get Started pages link to the task-first user guide, which shows where each statement comes + from and which version of Studio it was checked against. Its screenshots were captured from v1.5.0 and have not + been retaken. + +## Known limits + +- Studio does not move objects between plates, does not move the prime tower, and does not rearrange a project's + plates beyond the move described above. For a project made for a printer with a larger bed that Studio cannot + re-grid, the objects keep the creator's positions, so Snapmaker Orca can list an object as Outside even when its + plate fits. In our test, Arrange all plates did not fix it; the object had to be moved by hand. +- Both modes keep your project's own prime tower position, which can lie outside the U1's printable area when the + project was made for a larger bed. Studio says so but does not move it. Whether Snapmaker Orca keeps that position + when it opens a copy prepared with the starter settings has not been verified. +- The starter-settings mode resets a multi-nozzle flush table to zeros. Check the tower in Orca's preview and open + Flushing volumes if you use them. +- Opening a copy in Snapmaker Orca is not a slice, and a slice is not a print. Studio does not slice and cannot + promise a successful print. ## Verify your download -- Windows: `Snapmaker.Studio_1.5.0_x64-setup.exe` — 21,944,474 bytes — SHA256 `d443f64f5167239f1422c719882cf245cff5d00d40c55fb67fc5a1e497b84e82` -- Linux: `snapmaker-studio_1.5.0_amd64_5245cf9f9664.deb` — 25,944,098 bytes — SHA256 `7fca28d9d4e2558f8a6bdc765b121e2290c54abd39e2a1b9b2efaeea6d923e5a` +- Windows: `Snapmaker.Studio_1.5.1_x64-setup.exe` — 22,563,613 bytes — SHA256 `703eac4f394ab92bc30c1eaa67733f1a7b274c23e2900089980fc2a1b59e4082` +- Linux: `snapmaker-studio_1.5.1_amd64_b83e236148dc.deb` — 26,119,758 bytes — SHA256 `75d88a0d64a67c89ad7ded45865359b1b6642309ace14cb9945f5f55863766ff` Neither installer is code-signed yet; verify the hash before you run it. Verification for this release is in -[TRUST_STATUS.md](https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.0/docs/TRUST_STATUS.md). +[TRUST_STATUS.md](https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/docs/TRUST_STATUS.md). diff --git a/docs/SUBMISSION_STATUS.md b/docs/SUBMISSION_STATUS.md index 98b2c8b8..9a62bb80 100644 --- a/docs/SUBMISSION_STATUS.md +++ b/docs/SUBMISSION_STATUS.md @@ -13,10 +13,10 @@ page is the status record only. | | | |---|---| -| Version | **v1.5.0** — the current stable release | +| Version | **v1.5.1** — release candidate, not yet published (latest published release: v1.5.0) | | Installer, size, SHA256 | [RELEASE_METADATA.md](RELEASE_METADATA.md) — canonical | -| Verification state | [TRUST_STATUS.md](TRUST_STATUS.md) — **ACCEPTED** | -| Release | | +| Verification state | [TRUST_STATUS.md](TRUST_STATUS.md) — **CANDIDATE, NOT ACCEPTED** | +| Release | | The installer is not code-signed — verify the SHA256 before installing. See [windows-install.md](windows-install.md) and diff --git a/docs/TRUST_STATUS.md b/docs/TRUST_STATUS.md index f0093a46..e0d61e0a 100644 --- a/docs/TRUST_STATUS.md +++ b/docs/TRUST_STATUS.md @@ -9,6 +9,95 @@ real Snapmaker U1 have all passed and are recorded here. > current form is `-InstallerPath` + `-AttestationPath` or `-RealInstaller` + `-ExpectedSha256` + `-SourceVersion`; > see `docs/internal/HARNESS_ISOLATION.md`. The entries are left as they were. +## v1.5.1 — CANDIDATE, NOT ACCEPTED + +**Status: built, not accepted.** This section records only what has been run against the candidate build named in +[RELEASE_METADATA.md](RELEASE_METADATA.md) (source commit `b83e236148dcdfa2cc0c72bc9658df70d9df97f9`, build +[run 39055393052](https://github.com/DVOpenLabs/snapmaker-studio/actions/runs/39055393052)). It becomes **ACCEPTED** +only when every gate below has passed and is recorded here. Earlier candidates (run 38913728391, commit `f0c0cd533627`; run 38929429440, commit `e40a50ffa08c`) +were replaced, the first after the acceptance harness's test file and a test helper were corrected, the second after Recommended mode +was changed to declare the tower shape it writes, and the section after +this one holds the results of an older release candidate built from different source; none of those results transfers to +this build. + +| Gate | Result for this candidate | +|---|---| +| Required pull-request CI on the exact source commit (9 checks: backend on Windows and Linux, frontend, cargo check, guide, Linux build, two Linux clean-environment jobs) | **pass** (9/9) — runs [39054656948](https://github.com/DVOpenLabs/snapmaker-studio/actions/runs/39054656948), [39054659490](https://github.com/DVOpenLabs/snapmaker-studio/actions/runs/39054659490), [39054656940](https://github.com/DVOpenLabs/snapmaker-studio/actions/runs/39054656940) | +| Backend suite on the candidate source (local, before the documentation commit) | **3716 passed / 49 skipped**; desktop **902 passed**, TypeScript clean | +| Release-candidate build (Windows installer + Linux `.deb`), checked against the manifest by SHA256 | **pass** | +| Windows default-path upgrade smoke (disposable GitHub-hosted runner, upgrading from the published v1.5.0 installer) | **pass** | +| Linux clean-environment validation, `ubuntu:22.04`, against this candidate's `.deb` | **49/49** | +| Linux clean-environment validation, `ubuntu:24.04`, against this candidate's `.deb` | **49/49** | +| Windows installed-application acceptance (acceptance-identity copy of this candidate's installer, clean local account, upgrade variant from the published v1.5.0 installer; committed harness unmodified; production-state tripwire passed, no orphan processes) | **50/50** on the first launch, with only the probe-port override listed below | +| Snapmaker Orca 2.4.1, one slice of this candidate's Recommended copy of the synthetic two-plate project, Plate 1 only | **sliced; Orca still reports "A G-code path goes beyond plate boundaries."** The tower is much smaller than for the previous candidate's copy, but one first-layer extent still leaves the area; see below. Not a pass, not a print | +| Real Snapmaker U1, read-only | **not run** — needs the maintainer's separate authorization for printer contact | +| Publish dry run | **not run** | + +### Environment overrides and findings in the candidate gates + +- **The committed harness ran unmodified.** The earlier candidate's run needed a patched copy because the harness's generated "painted project" test file lacked `[Content_Types].xml` and `_rels/.rels`, which Studio's readability rule requires. That file was corrected in the repository for this candidate, with a test that reads the fixture out of the harness and checks that a package without those parts is refused. +- Probe ports 9411–9413 were passed to the harness (`-ProbePort 9411 -RedirectPort 9412 -SpoolEasePort 9413`) because another program on the test machine held the default port 9401 and was left alone. +- **One launch-path refusal recurred and its cause is not established.** On the first run with this candidate the harness refused, fail-closed, to continue after launching the previous version ("the launched process image is not inside the harness install dir"); cleanup was clean and the production-state tripwire passed. The guard was not weakened. The refusal text now says whether the process image path was unreadable or fell outside the install directory; here the path had been read, so the containment or reparse-point check failed. A read-only observer in the wrapper later recorded the image path and every path component's attributes on a passing run and showed an ordinary path under the harness install directory with no reparse point. The refusal has now appeared on two launches in this environment and has not been reproduced under observation. +- Three earlier attempts failed before the harness started because the account launcher passed this account the wrong user profile variables; they ran nothing. +- The generated user guide, rebuilt from this candidate's source (static checks 50/50), is byte-identical to the committed one. + +### Snapmaker Orca slice of this candidate's Recommended copy (Orca 2.4.1, isolated profile, anonymous synthetic project) + +The copy was produced by the **bundled sidecar extracted from this candidate's installer** (sidecar SHA256 `e23f7f070c55dbb6680f297f30f3cf5f92e19e28684a7201e4d8badad11ea9ec`); the copy's SHA256 is `2fb023af5cf967fea2c1d460ca101c7c5c00632ebd71e4b4c0e0517d17bbbf8e` and the original was unchanged. It was opened in a fresh Orca instance with its own data directory and with `APPDATA`, `LOCALAPPDATA`, `TEMP` and `TMP` redirected, with first-run setup completed before the import. The real Orca profile signature was identical before and after. + +- **Settings Orca held before slicing, read from Orca:** U1, 0.4 mm nozzle, process "0.20mm Standard @Snapmaker U1 (0.4 nozzle)", layer 0.2 mm; tower width 30, prime volume 45, brim width 5; wall type Rectangle, purge-line spacing 100%, extra rib length 0, cone apex 30 (Orca's own unsaved-changes list showed all four as changed from its preset to the values Studio wrote, so the declaration took effect); brim type No-brim; supports off, Tree (auto) - the documented starter reset, which Prepare discloses. The slice's G-code header repeats these values. +- **Result:** one slice of Plate 1 (100 layers, 193 filament changes, 1 h 11 m). Orca showed the error "A G-code path goes beyond plate boundaries." +- **Own parse of the saved G-code** (endpoint-only parser; it ignores extrusion width, arcs and machine limits, so it is not a safety assessment): the object and all non-tower moves are inside the area. Prime-tower extrusion spans X 231.985 to 271.805, Y 228.819 to 269.139 against an area of X 0.5 to 270.5, Y 1 to 271. Only layer 0 leaves the area (6 segments, X over by up to 1.305 mm); layers 1 to 98 stay inside, at 30 mm wide. The previous candidate's Recommended copy produced a tower reaching X 284.9, Y 281.1, with extrusion outside the area on every tower layer. +- **What this does and does not show:** declaring the tower shape made the tower smaller, and a first-layer extent still crosses the plate edge for this project's stated tower position. Studio keeps that position and does not move it; Prepare says the tower's reach is only known when Orca slices. This is one project, one plate, one slice; it does not establish that any other project fits or fails. + +### Earlier Snapmaker Orca observations (previous candidate e40a50ffa08c, copies of anonymous synthetic projects; historical) + +The six outputs (three synthetic projects, Preserve and Recommended) were produced by the **bundled sidecar extracted from this candidate's installer** (sidecar SHA256 `1217c03d66c2b627008857fd36f7ab8ce1677e977ee761482cedc14d2ad5514b`, equal to the build manifest). Each was opened once in a fresh Orca instance with its own data directory and with `APPDATA`, `LOCALAPPDATA`, `TEMP` and `TMP` redirected (the launcher refuses to start otherwise); the originals were unchanged and the real Orca profile signature was identical before and after. + +| Output | Loaded | What was observed | +|---|---|---| +| Two-plate project from a larger bed, Preserve and Recommended | yes, both | printer U1, 0.4 mm nozzle; the object list shows Plate 2 and Outside group headers (not expanded) | +| Single-plate project, tower start outside the plate, Preserve and Recommended | yes, both | the object list shows an Outside group header (not expanded) | +| Single-plate project with a painted support enforcer, Preserve and Recommended | yes, both | the object list shows an Outside group header (not expanded) | + +Each load showed Orca's information notice that a configuration may come from a newer version and that `nozzle_volume_type` was replaced; only that notice was acknowledged. No "Customized Preset" prompt appeared. Closing each instance raised Orca's "unsaved changes" prompt, and only Discard was pressed. + +**Not observed:** the prime-tower X and Y values, which object is listed under which plate or under Outside, and the Support tab values; any slice; any print. Automated reading of those fields is not authorized, so a manual step in the same isolated setup has been prepared and its results are not yet recorded. Retention of the declared tower coordinates in Recommended mode is therefore **not verified**. + +### What is not verified for this candidate + +Plate 2 and Preserve-mode slices of this candidate, plate membership as Orca lists it, any print, and the real-printer checks. +One Recommended Plate 1 slice was run (above); it ended with Orca's plate-boundary error. +Multi-plate placement beyond the per-plate fit is not checked by Studio. The user guide's screenshots were +captured from v1.5.0. + +## v1.5.1 earlier release candidate — evidence for a different build + +The results below belong to an **earlier** v1.5.1 release candidate: [run 37947895421](https://github.com/DVOpenLabs/snapmaker-studio/actions/runs/37947895421), +commit `abf20bf0f74d44eb66b793c317fd303d518e5828`, which did not contain the Prepare, plate and prime-tower changes in +the current candidate and was never published. They are kept as a record of that artifact and are **not** acceptance of +the current candidate. Its immutable snapshot: [internal/evidence/1.5.1.json](internal/evidence/1.5.1.json). +| Gate | Result | +|---|---| +| Backend / desktop suites | backend **2927 passed / 49 skipped**, desktop **852**, Rust **49** | +| Release-candidate build (Windows installer + Linux `.deb`) | **pass** | +| Windows default-path upgrade smoke (disposable GitHub-hosted runner, upgrading from the published v1.5.0 installer) | **pass** | +| Windows installed-application acceptance (acceptance-identity copy of the installer, clean local account, upgrade variant from the published v1.5.0 installer; production-state tripwire 0 violations, no orphan processes) | **50/50** — [internal/acceptance-1.5.1.json](internal/acceptance-1.5.1.json) | +| Linux clean-environment validation, `ubuntu:22.04` | **49/49** | +| Linux clean-environment validation, `ubuntu:24.04` | **49/49** | +| Real Snapmaker U1, read-only, Windows-installed app, with both providers (Spoolman 0.27.0 and Bambuddy 1.2.5.7 as disposable local containers seeded with anonymous spools; six provider-on-hardware checks each, none skipped) | **76/76** — [internal/hardware-1.5.1.json](internal/hardware-1.5.1.json) | +| Update-check state stays in the lane's own data directory: turning the preference on in the installed release candidate (acceptance-identity copy, not the production identity) wrote `update_check.json` inside the lane's data directory and left the production path absent before and after | **pass** | +| Publish dry run | **pass** | +| `tsc`, `cargo test`, production build, `u1convert selfcheck` (27/27) | clean | + +### What is verified, and what is not + +The acceptance run is the acceptance-identity copy of the release candidate, not the production +installation; the real installer's upgrade was exercised on a disposable GitHub-hosted runner. The user +guide's screenshots were captured from v1.5.0 and were not retaken. The limit on presets a person made in +Snapmaker Orca is unchanged from v1.5.0 (below). No real SpoolEase device was tested, and nothing was +written to a spool provider. + ## v1.5.0 — ACCEPTED **Project Materials (#39).** The Windows installer and Linux `.deb` named in diff --git a/docs/innovation-fund/FINAL_SUBMISSION.md b/docs/innovation-fund/FINAL_SUBMISSION.md index 71329d92..c17ab92a 100644 --- a/docs/innovation-fund/FINAL_SUBMISSION.md +++ b/docs/innovation-fund/FINAL_SUBMISSION.md @@ -49,7 +49,7 @@ Long form: [../INNOVATION_FUND.md](../INNOVATION_FUND.md). ## 3. What is being submitted -**Release:** [v1.5.0](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.0) +**Release:** [v1.5.1](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.1) — the current stable build; v0.4.0 was the first verified against a real Snapmaker U1, and every release since has been. Installer name, size and SHA256: [../RELEASE_METADATA.md](../RELEASE_METADATA.md). Verification record: @@ -72,18 +72,18 @@ Practicality & Adaptability. The remaining 20% is a community vote. ## 5. Evidence, in one table -Verified against the v1.3.1 release-candidate build (the same application published on the release page; the installed-application run used an acceptance copy of its installer, and the real installer was exercised only on a disposable GitHub-hosted runner), not a development build. The backend and desktop test counts come from the +Verified against an earlier v1.5.1 release candidate (a different build, never published; the current candidate has not yet completed its installed-application, Orca or real-printer checks; the installed-application run used an acceptance copy of its installer, and the real installer was exercised only on a disposable GitHub-hosted runner), not a development build. The backend and desktop test counts come from the source at the release commit, not from the installer. | What | Result | |---|---| -| Installed-application acceptance, through the real UI | 45/45 | +| Installed-application acceptance, through the real UI | 50/50 | | Linux clean-image validation, Ubuntu 22.04 + 24.04 | 49/49 each | -| Read-only verification against a real Snapmaker U1, from the Windows-installed app | 58/58 | +| Read-only verification against a real Snapmaker U1, from the Windows-installed app, with Spoolman and Bambuddy | 76/76 | | Regression tests against genuine Orca/Bambu/Prusa projects | 36 tests | | End-to-end pipeline self-check | 27/27 | -| Backend tests | 2804 passed, 13 skipped | -| Desktop tests | 725 passed | +| Backend tests | 3716 passed, 49 skipped | +| Desktop tests | 902 passed | | TypeScript · production build · Rust | clean | Reproduce any of it: [JUDGE_WALKTHROUGH.md](JUDGE_WALKTHROUGH.md). diff --git a/docs/innovation-fund/JUDGE_OVERVIEW.md b/docs/innovation-fund/JUDGE_OVERVIEW.md index 348ed01a..f2657c3b 100644 --- a/docs/innovation-fund/JUDGE_OVERVIEW.md +++ b/docs/innovation-fund/JUDGE_OVERVIEW.md @@ -93,7 +93,7 @@ honest fix was to remove it, not to tune a number Studio cannot observe. ## What is verified, and how -Everything below ran against the **v1.5.0 release-candidate build** — an acceptance +Everything below ran against an **earlier v1.5.1 release candidate** (a different build, never published; the current candidate has not yet completed these checks) — an acceptance copy of its installer (same application files, separate install identity) was installed, launched, driven through the real window, then uninstalled; the real installer itself was exercised only on a disposable GitHub-hosted runner. Not a development @@ -102,18 +102,18 @@ source at the release commit, not from the installer. | What | Result | How to reproduce | |---|---|---| -| Installed-application acceptance (v1.3.0 release candidate, acceptance-identity copy of the installer) | **45/45** | Maintainer-run `tools/acceptance/run.ps1` (needs a rewrapped acceptance-identity installer; see [../internal/HARNESS_ISOLATION.md](../internal/HARNESS_ISOLATION.md)) | +| Installed-application acceptance (earlier v1.5.1 release candidate, acceptance-identity copy of the installer, upgrade variant) | **50/50** | Maintainer-run `tools/acceptance/run.ps1` (needs a rewrapped acceptance-identity installer; see [../internal/HARNESS_ISOLATION.md](../internal/HARNESS_ISOLATION.md)) | | Linux clean-image validation, Ubuntu 22.04 + 24.04 | **49/49** each | see [../linux-install.md](../linux-install.md) | -| Read-only verification against a real Snapmaker U1, from the Windows-installed app | **58/58** | see [../internal/hardware-1.5.0.json](../internal/hardware-1.5.0.json) | +| Read-only verification against a real Snapmaker U1, from the Windows-installed app, with Spoolman and Bambuddy | **76/76** | see [../internal/hardware-1.5.1.json](../internal/hardware-1.5.1.json) | | End-to-end pipeline self-check | **27/27** | `u1convert selfcheck` | | Genuine OrcaSlicer / BambuStudio / PrusaSlicer projects | **36 tests** | `pytest tests/test_real_world_3mf.py` | -| Backend | 2804 passed, 13 skipped | `pytest` | -| Desktop | 725 passed | `npm run test` | +| Backend | 3716 passed, 49 skipped | `pytest` | +| Desktop | 902 passed | `npm run test` | | TypeScript · production build · Rust | clean | `tsc --noEmit` · `npm run build` · `cargo check` | The installed-application acceptance count is higher than the previous -release's (41), because this release adds two SpoolEase checks and some -checks on the test lane's own bookkeeping — see +release's (45), because this run is the upgrade variant, started from the +previous release's installer, and adds five upgrade checks — see [../TRUST_STATUS.md](../TRUST_STATUS.md) for exactly which. Full records, including the raw evidence files: [../TRUST_STATUS.md](../TRUST_STATUS.md). Step-by-step: [JUDGE_WALKTHROUGH.md](JUDGE_WALKTHROUGH.md). @@ -214,4 +214,4 @@ SHA256 before running it. Why, and what is being done: | Technical depth | [TECHNICAL_DEPTH.md](TECHNICAL_DEPTH.md) | | Where Studio sits in the field | [COMPETITOR_MATRIX.md](COMPETITOR_MATRIX.md) · [DIFFERENTIATION_STRATEGY.md](DIFFERENTIATION_STRATEGY.md) | | The ecosystem story | [OPEN_ECOSYSTEM.md](OPEN_ECOSYSTEM.md) | -| Download | [Releases](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.0) · hash in [../RELEASE_METADATA.md](../RELEASE_METADATA.md) | +| Download | [Releases](https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.1) · hash in [../RELEASE_METADATA.md](../RELEASE_METADATA.md) | diff --git a/docs/innovation-fund/JUDGE_WALKTHROUGH.md b/docs/innovation-fund/JUDGE_WALKTHROUGH.md index aaa4587e..e2a4610d 100644 --- a/docs/innovation-fund/JUDGE_WALKTHROUGH.md +++ b/docs/innovation-fund/JUDGE_WALKTHROUGH.md @@ -71,7 +71,7 @@ u1convert placement ../examples/demo_offplate_foreign.3mf ``` ``` -1 object(s) fall outside the U1's plate, but the whole arrangement fits — +1 object falls outside the U1's plate, but the whole arrangement fits — moving it as one piece brings everything back on, keeping the creator's layout, rotation and scale. ``` @@ -141,9 +141,9 @@ builds real decompression bombs and asserts the reader refuses them. ## Run the tests ```bash -cd backend && pytest # 2804 passed, 13 skipped +cd backend && pytest # 3716 passed, 49 skipped cd backend && u1convert selfcheck # 27/27 over production code paths -cd desktop && npm run test # 725 passed +cd desktop && npm run test # 902 passed cd desktop && npm run build # tsc + vite ``` @@ -174,8 +174,8 @@ It installs the rewrapped build into an isolated directory with its own WebView2 profile and engine data directory, drives the real application window over the Chrome DevTools Protocol, and uninstalls. Its checks include that the input file is byte-identical afterwards and that uninstalling leaves nothing behind. It stops -only processes it started. For the v1.3.0 release candidate, this harness scored **45/45** — -[../internal/acceptance-1.3.1.json](../internal/acceptance-1.3.1.json). +only processes it started. For an earlier v1.5.1 release candidate (a different build; upgrade variant, started from the previous release's installer), this harness scored **50/50** — +[../internal/acceptance-1.5.1.json](../internal/acceptance-1.5.1.json). **A real printer.** With a Snapmaker U1 on the same network, the maintainer runs `tools/hardware/verify.ps1 -PrinterHost `. Like the acceptance harness, @@ -187,9 +187,9 @@ Read-only by construction: the allowed routes are asserted against a deny-list before the first request, so nothing is started, uploaded or queued and no temperature, motion, homing, pause, resume, cancel, emergency-stop or configuration call is made. The printer's address is replaced with a placeholder before anything -reaches the evidence file. This release's real-U1 check ran through the -Windows-installed application itself — last result: **58/58** — -[../internal/hardware-1.3.1.json](../internal/hardware-1.3.1.json). +reaches the evidence file. That earlier release candidate's real-U1 check ran through the +Windows-installed application itself — last result: **76/76**, with disposable Spoolman and Bambuddy containers seeded with anonymous spools — +[../internal/hardware-1.5.1.json](../internal/hardware-1.5.1.json). That run is worth reading rather than just counting. It proved the four toolheads' fitted nozzles are read live and shown as the printer's own reading, @@ -197,8 +197,7 @@ that an offline-saved conflicting nozzle note is shown as a conflict once the printer answers again with the printer's value used, and that a real "Remove my note" action restores the live row — with zero printer-control routes called throughout, confirmed by the harness's request listener, which -records every call. Not run this release: the Spoolman/Bambuddy-seeded -provider-on-hardware checks; this printer's four toolheads all carry the same +records every call. Not run this release: this printer's four toolheads all carry the same 0.4 mm nozzle, so a genuinely mixed-nozzle setup was exercised only in automated tests; and the Linux-installed app was not run against the real printer. No SpoolEase check was run against hardware either (its real-device diff --git a/docs/internal/HARNESS_ISOLATION.md b/docs/internal/HARNESS_ISOLATION.md index 976bef6d..df32b3f0 100644 --- a/docs/internal/HARNESS_ISOLATION.md +++ b/docs/internal/HARNESS_ISOLATION.md @@ -237,18 +237,24 @@ Menu and Desktop shortcut) and never the production keys or the shared publisher therefore still refuses to launch unless the production file is absent or `auto_check` is `false`, and the tripwire still detects a change. **These protections stay in place until override support is verified in the acceptance artifact; do not relax them on the strength of the source change alone.** - - *Product side (done in source, unreleased):* a build that includes the override reads and writes + - *Product side (in the v1.5.1 build and later):* a build that includes the override reads and writes `update_check.json` under `SNAPSTUDIO_DATA_DIR` when it is set. An explicit value that is empty, not valid text or not absolute is treated as unusable, never as "unset": the preference reads as off, saving fails, and no automatic check runs, so a bad value cannot select production state. Covered by temp-directory tests in `main.rs`. - *Harness side (already in place):* `Start-HarnessProcess` passes `SNAPSTUDIO_DATA_DIR = \engine-data` to the child only (`HarnessLauncher.psm1`, environment block next to `WEBVIEW2_USER_DATA_FOLDER`), so an override-aware build would write the file inside the lane with no further plumbing. - - *What remains:* (1) ship the override in a build and rewrap it as the acceptance artifact; (2) verify, on that - artifact, that `update_check.json` appears under the lane data dir and the production file is unchanged (extend the - tripwire to assert the lane file, and attest the build contains the override, for example by build identity); - (3) only then let the preflight skip the production check, and only for artifacts proven override-aware, keeping - it for older ones. Full harness integration is **not** claimed yet. + - *Verified on the v1.5.1 release candidate:* the override is in the v1.5.1 build. On the acceptance-identity copy + of that exact release candidate (installer SHA256 `8955097365b12e9eb1405a098c1f6ab345d56d397681a67f0cb08257d1471be3`, + clean local account), turning "Automatically check for updates" on in the installed Help screen wrote + `update_check.json` inside the lane data directory (`auto_check` true, then false when turned off), and the + production paths stayed absent before and after; the lane's production-state tripwire reported 0 violations. The + probe is a one-off script run against that artifact; it is not part of `tools/acceptance/run.ps1`. + - *What remains:* extend the tripwire to assert the lane file and attest that a build contains the override (for + example by build identity), and only then let the preflight skip the production check, and only for artifacts + proven override-aware, keeping it for older ones. The preflight and tripwire protections therefore **stay as they + are**: the upgrade lane first installs an older, non-override build, and full harness integration is **not** + claimed. - **Manual interference.** Launching the production exe by hand after the preflight, or using the auto-check UI during a harness session, is a detection-only case. - **Journal content** is validated on read, not on save, and the TEMP-misconfiguration case widens the override allowlist @@ -280,3 +286,28 @@ child only. The file also parse-checks the module and the four scripts, and asse that none contains a wildcard registration lookup, installer globbing, a session-environment assignment of an isolation variable, a name-based process stop, a raw `Start-Process`, a production uninstall-key export or import, or an absolute local path. + +## Environment overrides and known environment conflicts (recorded 2026-10-16) + +**Probe ports.** The acceptance lane starts two throwaway local servers (a "count" probe and a "redirect" probe) and a +SpoolEase stand-in. Their default ports are 9401, 9402 and 9403. If another program already listens on one of them the +probe servers cannot start and every provider check fails with "did not answer" or "Unauthorized", which reads like a +product defect and is not one. Before a run, check who owns the ports (`Get-NetTCPConnection -State Listen -LocalPort +9401,9402,9403`, then the owning process's name, start time and owner). Never stop a process the run did not start. Move +the lane instead: `-ProbePort 9411 -RedirectPort 9412 -SpoolEasePort 9413` (any free ports). The ports go only to the +lane's own child processes; nothing global changes. The probe and redirect ports are part of the run's record. + +**Ownership checks that stay in force.** Every process the lane starts is tracked by pid and start time, and only those +are stopped. The probe servers are stopped in the lane's `finally` block by their tracked pid. A probe that fails to start is +reported as a failed check; a listener the lane did not start is never touched. + +**Launch-path refusal.** After launching the app the lane refuses, fail-closed, unless the launched process image is inside +the harness install dir. A refusal stops the launched tree, uninstalls, runs the tripwire and reports `APP_LAUNCH_FAILED`. +This is the guard doing its job; do not relax it. One such refusal happened on 2026-10-16 right after a clean run and +did not repeat on an identical retry; the retained evidence did not record why the image path failed the check, so the +cause is not established. The refusal text now says whether the image path was unreadable at registration or resolved +outside the install dir, so the next occurrence can be diagnosed without weakening the guard. + +**Test file.** The painted project the acceptance lane builds at run time must be a valid 3MF package +(`[Content_Types].xml`, `_rels/.rels`, model and config parts). `backend/tests/test_acceptance_fixture.py` reads the +fixture out of `tools/acceptance/run.ps1` and fails if it stops being readable by the same rule Prepare uses. diff --git a/docs/internal/acceptance-1.5.1.json b/docs/internal/acceptance-1.5.1.json new file mode 100644 index 00000000..590f7cd7 --- /dev/null +++ b/docs/internal/acceptance-1.5.1.json @@ -0,0 +1,265 @@ +{ + "schema_version": "acceptance/3", + "lane": { + "schema": "harness-lane-evidence/3", + "lane": "upgrade between two rewrapped acceptance-identity installers (OLD -> NEW); not a production upgrade", + "laneKind": "acceptance", + "identity": "acceptance", + "status": "pass", + "installerSha256": "fb3d5b77b48a2ec24dc2bd352bc69650a60d1f6c89916860cc5eedd483310e46", + "sourceVersion": "1.5.1", + "templateSha256": "d30f5f9d4bc40902addbc895e4211498612c6f0c567a54738c47fe3403ce1028", + "rewrappedOnDemand": true, + "installDir": "install\\h3ad0b07139b54ea7a9fae0f424148a50", + "installArguments": "/S /NCRC /NS /D=", + "preflight": { + "updateCheck": "absent", + "webView2Runtime": "154.0.4258.62" + }, + "keptInstall": false, + "repair": null, + "journals": [ + { + "runId": "h3ad0b07139b54ea7a9fae0f424148a50", + "installExitCode": 0, + "finalized": true + }, + { + "runId": "h3ad0b07139b54ea7a9fae0f424148a50-u2", + "installExitCode": 0, + "finalized": true + } + ], + "orphanedInstall": null, + "uninstallOutcome": "Success", + "errorCount": 0, + "warningCount": 0, + "reasonCodes": [], + "warningCodes": [], + "tripwire": { + "violations": 0, + "added": 0, + "changed": 0, + "removed": 0, + "leveldbChanged": 0, + "structural": 0, + "completed": true, + "surfaces": { + "uninstallKey": 0, + "rememberedKey": 0, + "manufacturerKey": 0, + "runValue": 0, + "startMenuShortcut": 0, + "desktopShortcut": 0, + "installDir": 0 + } + }, + "shortcutFindings": 0, + "notProven": "Production registration, shortcut creation, default-path install/upgrade/uninstall of the real installer are NOT proven by this lane; they are exercised only on disposable GitHub-hosted CI runners." + }, + "checks": [ + { + "name": "Probe servers this run owns are up", + "ok": true + }, + { + "name": "Previous version installs", + "ok": true + }, + { + "name": "Previous version left state to migrate", + "ok": true + }, + { + "name": "Scripted install completes", + "ok": true + }, + { + "name": "Upgrade keeps the user's data", + "ok": true + }, + { + "name": "Upgrade leaves the acceptance registration in place", + "ok": true + }, + { + "name": "Upgrade registration reports the new version", + "ok": true + }, + { + "name": "Application installed", + "ok": true + }, + { + "name": "Frozen engine sidecar installed", + "ok": true + }, + { + "name": "Uninstaller installed", + "ok": true + }, + { + "name": "Application launches", + "ok": true + }, + { + "name": "Window title", + "ok": true + }, + { + "name": "Sidecar boots from the install directory", + "ok": true + }, + { + "name": "CDP reachable on the installed webview", + "ok": true + }, + { + "name": "Startup checks", + "ok": true + }, + { + "name": "Engine routes answer from the installed sidecar", + "ok": true + }, + { + "name": "Model passed on the command line is open", + "ok": true + }, + { + "name": "Project opens and its findings render", + "ok": true + }, + { + "name": "Prepare, fidelity, ledger and best-tool render", + "ok": true + }, + { + "name": "Colour plan renders on its own page", + "ok": true + }, + { + "name": "Post-Slice Doctor reads a sliced job in the installed app", + "ok": true + }, + { + "name": "One surface shows the whole job", + "ok": true + }, + { + "name": "Studio explains how sure it is about a job", + "ok": true + }, + { + "name": "First-evening mistakes are answered, not ignored", + "ok": true + }, + { + "name": "Spool notes and nozzle empty states render (W1/W6/W9)", + "ok": true + }, + { + "name": "Spool notes are created through the installed UI (W2)", + "ok": true + }, + { + "name": "Editing, clearing and validating a spool note behave as specified (W3/W5/W9)", + "ok": true + }, + { + "name": "Recording filament used shows an estimate, not a measurement (W4)", + "ok": true + }, + { + "name": "Nozzle sizes are confirmed through the installed UI (W6)", + "ok": true + }, + { + "name": "Sliced job is byte-identical afterwards", + "ok": true + }, + { + "name": "Original file is byte-identical afterwards", + "ok": true + }, + { + "name": "Painted colour is shown in the installed build", + "ok": true + }, + { + "name": "Spool notes and nozzle confirmations survive a relaunch (W7)", + "ok": true + }, + { + "name": "Removing nozzles, deleting a note and switching provider kind behave as specified (W8)", + "ok": true + }, + { + "name": "Probe servers survived the app restarts", + "ok": true + }, + { + "name": "Both provider wire shapes work in the installed build", + "ok": true + }, + { + "name": "No provider configured means no provider request", + "ok": true + }, + { + "name": "Provider addresses are validated in the installed build", + "ok": true + }, + { + "name": "A redirect off the local network is refused in the installed build", + "ok": true + }, + { + "name": "Closing the real window exits the app and its sidecar (not just a kill)", + "ok": true + }, + { + "name": "No orphan sidecar after close", + "ok": true + }, + { + "name": "Reopens cleanly", + "ok": true + }, + { + "name": "SpoolEase can be configured and read in the installed build", + "ok": true + }, + { + "name": "SpoolEase settings restore without the session key", + "ok": true + }, + { + "name": "Uninstall completes", + "ok": true + }, + { + "name": "Install directory removed", + "ok": true + }, + { + "name": "No sidecar survives uninstall", + "ok": true + }, + { + "name": "Harness journal finalised (nothing left to recover)", + "ok": true + }, + { + "name": "Production state unchanged (tripwire)", + "ok": true + }, + { + "name": "Harness cleanup reported no errors", + "ok": true + } + ], + "passed": 50, + "total": 50, + "evidence": "\\run\\\\evidence" +} diff --git a/docs/internal/evidence.json b/docs/internal/evidence.json index 833210f1..f7ecbfa2 100644 --- a/docs/internal/evidence.json +++ b/docs/internal/evidence.json @@ -1,30 +1,31 @@ { "acceptance": { - "passed": 45, - "report": "acceptance-1.5.0.json", - "total": 45 + "passed": 50, + "report": "acceptance-1.5.1.json", + "total": 50 }, "backend": { - "passed": 2804, - "skipped": 13 + "passed": 3716, + "skipped": 49 }, "demo": { "file": "docs/media/snapmaker-studio-demo.mp4", "seconds": 66 }, "desktop": { - "passed": 725 + "passed": 902 }, "hardware": { - "passed": 58, - "report": "hardware-1.5.0.json", - "total": 58 + "passed": 76, + "report": "hardware-1.5.1.json", + "total": 76 }, + "provenance": "Installer, backend, desktop and acceptance counts: current v1.5.1 candidate (source b83e236, run 39055393052; acceptance 50/50 from the committed harness, see TRUST_STATUS). Hardware and self-check counts: earlier v1.5.1 release candidate (run 37947895421, commit abf20bf); the real-printer check has not been run on the candidate.", "schema_version": "evidence/1", - "screenshots_dir": "docs/screenshots/v1.5.0", + "screenshots_dir": "docs/screenshots/v1.5.1", "selfcheck": { "passed": 27, "total": 27 }, - "version": "1.5.0" + "version": "1.5.1" } diff --git a/docs/internal/evidence/1.5.1.json b/docs/internal/evidence/1.5.1.json new file mode 100644 index 00000000..efbba697 --- /dev/null +++ b/docs/internal/evidence/1.5.1.json @@ -0,0 +1,38 @@ +{ + "acceptance": { + "passed": 50, + "report": "acceptance-1.5.1.json", + "total": 50 + }, + "backend": { + "passed": 3716, + "skipped": 49 + }, + "demo": { + "file": "docs/media/snapmaker-studio-demo.mp4", + "seconds": 66 + }, + "desktop": { + "passed": 902 + }, + "hardware": { + "passed": 76, + "report": "hardware-1.5.1.json", + "total": 76 + }, + "installer": { + "name": "Snapmaker.Studio_1.5.1_x64-setup.exe", + "sha256": "703eac4f394ab92bc30c1eaa67733f1a7b274c23e2900089980fc2a1b59e4082", + "size_bytes": 22563613, + "url": "https://github.com/DVOpenLabs/snapmaker-studio/releases/tag/v1.5.1" + }, + "provenance": "Installer, backend, desktop and acceptance counts: current v1.5.1 candidate (source b83e236, run 39055393052; acceptance 50/50 from the committed harness, see TRUST_STATUS). Hardware and self-check counts: earlier v1.5.1 release candidate (run 37947895421, commit abf20bf); the real-printer check has not been run on the candidate.", + "schema_version": "evidence-snapshot/1", + "screenshots_dir": "docs/screenshots/v1.5.1", + "selfcheck": { + "passed": 27, + "total": 27 + }, + "source": "candidate build, not yet published; replaced when this release is published", + "version": "1.5.1" +} diff --git a/docs/internal/hardware-1.5.1.json b/docs/internal/hardware-1.5.1.json new file mode 100644 index 00000000..f411ceed --- /dev/null +++ b/docs/internal/hardware-1.5.1.json @@ -0,0 +1,1745 @@ +{ + "schema_version": "hardware/1", + "printer": "", + "read_only_routes": [ + "/printer/status", + "/printer/capabilities", + "/printer/firmware", + "/preflight", + "/post_slice", + "/send_check", + "/material_plan", + "/nozzles/status", + "/nozzles/confirm" + ], + "checks": [ + { + "name": "Printer discovered and answering", + "ok": true, + "detail": "print state: standby" + }, + { + "name": "Firmware object list enumerated", + "ok": true, + "detail": "205 objects" + }, + { + "name": "Printer reports its own bed size", + "ok": true, + "detail": "271 × 335 × 281 mm" + }, + { + "name": "Printer reports its toolhead count", + "ok": true, + "detail": "4 toolheads" + }, + { + "name": "Preflight ran against the real machine", + "ok": true, + "detail": "" + }, + { + "name": "Loaded filament read from the real firmware", + "ok": true, + "detail": "#F8F81C PLA Matte, #6C5BB1 PLA Matte, #080A0D PLA SnapSpeed, #6F4C2F PLA Matte" + }, + { + "name": "Each loaded slot carries a material and a colour", + "ok": true, + "detail": "" + }, + { + "name": "Fitted nozzle matches the real firmware reading, never called unsupported", + "ok": true, + "detail": "ok: project expects 0.4 mm; the printer reports the same" + }, + { + "name": "A matched nozzle reading is credited to the printer, not asserted with no source", + "ok": true, + "detail": "project expects 0.4 mm; the printer reports the same" + }, + { + "name": "Nothing undetected is called unsupported", + "ok": true, + "detail": "" + }, + { + "name": "The printer's own bed size was used in the bed check", + "ok": true, + "detail": "1 object(s) fall outside this printer's 271.0 × 335.0 × 281.0 mm area" + }, + { + "name": "Project materials compared against the machine's toolheads", + "ok": true, + "detail": "project uses 6 filament slot(s); printer reports 4 toolheads" + }, + { + "name": "Project materials compared against what is actually loaded", + "ok": true, + "detail": "printer reports 4 loaded; project uses 6" + }, + { + "name": "Preflight recorded the printer as found", + "ok": true, + "detail": "" + }, + { + "name": "Sliced job joined to the real printer", + "ok": true, + "detail": "Everything Studio can check about this job against this printer looks right." + }, + { + "name": "The tool the job needs is confirmed to exist", + "ok": true, + "detail": "the job prints from slot 2; this printer reports 4 toolheads" + }, + { + "name": "The slot the job prints from is confirmed loaded", + "ok": true, + "detail": "1 slot(s) in use, all reporting a spool" + }, + { + "name": "Loaded material checked against the job's material", + "ok": true, + "detail": "1 slot(s) compared at material-family level" + }, + { + "name": "The job's bed is compared with the printer's own bed", + "ok": true, + "detail": "sliced for 270.0 × 270.0 mm; this printer reports 271.0 × 335.0 mm" + }, + { + "name": "Fitted nozzle after slicing matches the real firmware reading, never called unsupported", + "ok": true, + "detail": "the job was sliced for 0.4 mm; the printer reports the same" + }, + { + "name": "Nothing undetected is called unsupported after slicing", + "ok": true, + "detail": "" + }, + { + "name": "The send check fingerprints what it looked at", + "ok": true, + "detail": "token 813e891b…" + }, + { + "name": "The fingerprint carries the machine's real loadout", + "ok": true, + "detail": "4 slot(s) with a spool" + }, + { + "name": "Filament sufficiency stays unknown on a stock printer", + "ok": true, + "detail": "slot 2: unknown" + }, + { + "name": "Nothing on a stock printer is called short of filament", + "ok": true, + "detail": "" + }, + { + "name": "Community firmware is not claimed on a stock machine", + "ok": true, + "detail": "119 macros, many=true" + }, + { + "name": "Not finding a firmware marker is never called stock", + "ok": true, + "detail": "no community firmware answered for itself on the paths Studio knows about — which is not the same as this printer running stock firmware" + }, + { + "name": "The real machine is identified as a Snapmaker U1", + "ok": true, + "detail": "snapmaker_u1 (confirmed)" + }, + { + "name": "Identification is drawn from the printer's own vendor object", + "ok": true, + "detail": "the printer exposes the print_task_config object, which mainline Klipper does not have; it reports 4 toolheads, as this profile records" + }, + { + "name": "The U1 profile still reads as hardware verified", + "ok": true, + "detail": "Hardware verified" + }, + { + "name": "The live toolhead count is used, not the profile's", + "ok": true, + "detail": "4 from live" + }, + { + "name": "The live bed is used, not the profile's", + "ok": true, + "detail": "271 × 335 × 281 from live" + }, + { + "name": "Travel beyond the printable plate is not called a conflict", + "ok": true, + "detail": "0 conflict(s)" + }, + { + "name": "Loaded filament is recorded as the printer's own observation", + "ok": true, + "detail": "4 slot(s) from live" + }, + { + "name": "All four toolhead temperature channels still come back", + "ok": true, + "detail": "4 channel(s)" + }, + { + "name": "A U1-targeted job matches this identified U1", + "ok": true, + "detail": "the file says it was sliced for Snapmaker U1" + }, + { + "name": "The firmware summary names this machine, having identified it", + "ok": true, + "detail": "Your Snapmaker U1 reports 4 toolheads, 271.0×335.0×281.0 mm bed; 7 capabilities detected (119 custom macros on it)." + }, + { + "name": "No material provider ran, and none was contacted", + "ok": true, + "detail": "sources=undefined remaining_known=false" + }, + { + "name": "What the plan compares against came from the printer itself", + "ok": true, + "detail": "slot 1:printer, slot 2:printer, slot 3:printer, slot 4:printer" + }, + { + "name": "An address that answers nothing gets a generic hint", + "ok": true, + "detail": "Nothing answered Moonraker at that address. Check the printer is powered on and on this network, and that the address and port are the ones it shows. If it is a Snapmaker U1, its network interface only opens once Advanced Mode is turned on under Settings → Maintenance on the touchscreen." + }, + { + "name": "The machine's own reading survives a provider that agrees (spoolman)", + "ok": true, + "detail": "PLA Matte confirmed_by=printer" + }, + { + "name": "The provider supplies the weight the machine cannot know (spoolman)", + "ok": true, + "detail": "880 g tracked" + }, + { + "name": "Agreement is not reported as a disagreement (spoolman)", + "ok": true, + "detail": "0 conflict(s)" + }, + { + "name": "The printer stays authoritative when a provider disagrees (spoolman)", + "ok": true, + "detail": "PLA Matte, printer_confirmed=true" + }, + { + "name": "The disagreement is said out loud rather than resolved (spoolman)", + "ok": true, + "detail": "the printer reports PLA in this slot and your provider (Spoolman) says PETG — Studio is us" + }, + { + "name": "A disagreement does not throw the weight away (spoolman)", + "ok": true, + "detail": "920 g" + }, + { + "name": "The machine's own reading survives a provider that agrees (bambuddy)", + "ok": true, + "detail": "PLA Matte confirmed_by=printer" + }, + { + "name": "The provider supplies the weight the machine cannot know (bambuddy)", + "ok": true, + "detail": "880 g tracked" + }, + { + "name": "Agreement is not reported as a disagreement (bambuddy)", + "ok": true, + "detail": "0 conflict(s)" + }, + { + "name": "The printer stays authoritative when a provider disagrees (bambuddy)", + "ok": true, + "detail": "PLA Matte, printer_confirmed=true" + }, + { + "name": "The disagreement is said out loud rather than resolved (bambuddy)", + "ok": true, + "detail": "the printer reports PLA in this slot and your provider (Bambuddy) says PETG — Studio is us" + }, + { + "name": "A disagreement does not throw the weight away (bambuddy)", + "ok": true, + "detail": "920 g" + }, + { + "name": "Two providers agreeing with the machine decide the same", + "ok": true, + "detail": "{\"material\":\"PLA Matte\",\"state\":\"ready\",\"confirmed_by\":\"printer\",\"printer_confirmed\":true,\"remaining_g\":880,\"quality\":\"tracked\",\"conflicts\":[],\"verdict\":\"enough" + }, + { + "name": "Two providers disagreeing with the machine decide the same", + "ok": true, + "detail": "{\"material\":\"PLA Matte\",\"state\":\"ready\",\"confirmed_by\":\"printer\",\"printer_confirmed\":true,\"remaining_g\":920,\"quality\":\"tracked\",\"conflicts\":[\"the printer report" + }, + { + "name": "An unreachable provider leaves the machine's own facts intact", + "ok": true, + "detail": "PLA Matte confirmed_by=printer" + }, + { + "name": "An unreachable provider claims no weight rather than none", + "ok": true, + "detail": "remaining=null verdict=unknown" + }, + { + "name": "A real machine and a provider together reach a send decision", + "ok": true, + "detail": "unknown {\"blocker\":0,\"warning\":0,\"unknown\":1}" + }, + { + "name": "Nothing about this run claims the printer weighed anything", + "ok": true, + "detail": "no weighing claimed on the machine's behalf" + }, + { + "name": "The real printer's nozzle sizes are read live (R1)", + "ok": true, + "detail": "4 toolhead(s) reported live" + }, + { + "name": "Toolhead 1's own row: nozzle=live size, Source=Printer, Status=Reported live (R1/H7)", + "ok": true, + "detail": "Toolhead 1 0.4 mm Printer Reported live" + }, + { + "name": "Toolhead 2's own row: nozzle=live size, Source=Printer, Status=Reported live (R1/H7)", + "ok": true, + "detail": "Toolhead 2 0.4 mm Printer Reported live" + }, + { + "name": "Toolhead 3's own row: nozzle=live size, Source=Printer, Status=Reported live (R1/H7)", + "ok": true, + "detail": "Toolhead 3 0.4 mm Printer Reported live" + }, + { + "name": "Toolhead 4's own row: nozzle=live size, Source=Printer, Status=Reported live (R1/H7)", + "ok": true, + "detail": "Toolhead 4 0.4 mm Printer Reported live" + }, + { + "name": "A live-reported toolhead offers no way to type a size — the printer's evidence cannot be overridden (H6)", + "ok": true, + "detail": "0 select(s) found" + }, + { + "name": "Host text redacted before capture: v12-nozzle-live", + "ok": true, + "detail": "host_text_redacted: true" + }, + { + "name": "seed: note saved while offline (a direct, labelled write — not a UI action) is accepted", + "ok": true, + "detail": "HTTP 200" + }, + { + "name": "Toolhead 1's row shows the seeded note after the remount, before asserting the conflict (F1)", + "ok": true, + "detail": "Toolhead 1 — pick — 0.2 mm 0.25 mm 0.3 mm 0.4 mm 0.5 mm 0.6 mm 0.8 mm Not sure Printer You noted 0.6 mm · printer reports 0.4 mm — Studio uses the printer's readingUpdate my note to 0.4Remove my note" + }, + { + "name": "Toolhead 1's own row shows the conflict, naming both the printer's and the noted size (R2/H6)", + "ok": true, + "detail": "Toolhead 1 — pick — 0.2 mm 0.25 mm 0.3 mm 0.4 mm 0.5 mm 0.6 mm 0.8 mm Not sure Printer You noted 0.6 mm · printer reports 0.4 mm — Studio uses the printer's readingUpdate my note to 0.4Remove my note" + }, + { + "name": "A fresh probe:true status confirms the conflict, and that the printer still wins (R2)", + "ok": true, + "detail": "printer=0.4 noted=0.6" + }, + { + "name": "The real 'Remove my note' button is reachable on toolhead 1's conflict row", + "ok": true, + "detail": "" + }, + { + "name": "Removing the note through the real button returns toolhead 1's row to 'Reported live' (R2)", + "ok": true, + "detail": "Toolhead 1 0.4 mm Printer Reported live" + }, + { + "name": "A fresh probe:true status shows no stored note after removal (R2)", + "ok": true, + "detail": "confirmed=null conflict=false source=printer" + }, + { + "name": "A live-reported toolhead still offers no way to type a size, after removing the note (H6)", + "ok": true, + "detail": "0 select(s) found" + }, + { + "name": "The request listener actually observed allow-listed printer traffic (H5)", + "ok": true, + "detail": "4 allow-listed /printer/* POST(s) observed" + }, + { + "name": "No printer CONTROL route was ever called during this run (R2/A7-6)", + "ok": true, + "detail": "[]" + }, + { + "name": "Host text redacted before capture: hardware.png", + "ok": true, + "detail": "host_text_redacted: true" + } + ], + "passed": 76, + "total": 76, + "evidence": { + "status": { + "schema_version": "printer/1", + "host": "", + "port": 7125, + "print_state": "standby", + "filename": null, + "message": null, + "progress": 0, + "print_duration_s": 0, + "total_duration_s": 0, + "filament_used_mm": 0, + "current_layer": null, + "total_layer": null, + "speed_factor": 1, + "extrude_factor": 1, + "bed": { + "temperature": 24, + "target": 0 + }, + "toolheads": [ + { + "index": 0, + "temperature": 27, + "target": 0, + "active": false + }, + { + "index": 1, + "temperature": 27, + "target": 0, + "active": false + }, + { + "index": 2, + "temperature": 27, + "target": 0, + "active": false + }, + { + "index": 3, + "temperature": 27, + "target": 0, + "active": false + } + ] + }, + "capabilities": { + "schema_version": "printer/1", + "host": "", + "port": 7125, + "toolhead_count": 4, + "bed_mm": { + "x": 271, + "y": 335, + "z": 281 + }, + "klipper_objects": [ + "gcode", + "webhooks", + "exception_manager", + "configfile", + "mcu", + "mcu e0", + "mcu e1", + "mcu e2", + "mcu e3", + "gcode_move", + "print_stats", + "gcode_macro _PL_SAVE_VARIABLE", + "virtual_sdcard", + "pause_resume", + "display_status", + "gcode_macro M600", + "gcode_macro T4", + "gcode_macro T5", + "gcode_macro T6", + "gcode_macro T7", + "gcode_macro T8", + "gcode_macro T9", + "gcode_macro T10", + "gcode_macro T11", + "gcode_macro T12", + "gcode_macro T13", + "gcode_macro T14", + "gcode_macro T15", + "gcode_macro T16", + "gcode_macro T17", + "gcode_macro T18", + "gcode_macro T19", + "gcode_macro T20", + "gcode_macro T21", + "gcode_macro T22", + "gcode_macro T23", + "gcode_macro T24", + "gcode_macro T25", + "gcode_macro T26", + "gcode_macro T27", + "gcode_macro T28", + "gcode_macro T29", + "gcode_macro T30", + "gcode_macro T31", + "gcode_macro MOVE_TO_DISCARD_FILAMENT_POSITION", + "gcode_macro SHAPER_CALIBRATE", + "gcode_macro PRINT_START", + "gcode_macro PRINT_PRESTART_CHECK", + "gcode_macro PRINT_END", + "gcode_macro CANCEL_PRINT", + "gcode_macro INNER_PAUSE", + "gcode_macro INNER_RESUME", + "gcode_macro SET_PAUSE_NEXT_LAYER", + "gcode_macro SET_PAUSE_AT_LAYER", + "gcode_macro SET_PRINT_STATS_INFO", + "gcode_macro _CLIENT_EXTRUDE", + "gcode_macro _CLIENT_RETRACT", + "gcode_macro INNER_FILAMENT_UNLOAD", + "gcode_macro AUTO_FEEDING", + "gcode_macro AUTO_FEEDING_PREHEAT", + "gcode_macro MANUAL_FEEDING", + "gcode_macro FEEDING_RUNOUT_EVENT_HANDLE", + "gcode_macro ROUGHLY_CLEAN_NOZZLE", + "gcode_macro ROUGHLY_CLEAN_NOZZLE_WITH_DISCARD", + "gcode_macro FINELY_CLEAN_NOZZLE_STAGE_1", + "gcode_macro FINELY_CLEAN_NOZZLE_STAGE_2", + "gcode_macro SM_PRINT_CHECK_SWITCH_EXTRUDER", + "gcode_macro SM_PRINT_EXTRUDER_PREHEAT", + "gcode_macro SM_PRINT_AUTO_FEED", + "gcode_macro SM_PRINT_END_AUTO_UNLOAD_FILAMENT", + "gcode_macro SM_PRINT_START_LINE", + "gcode_macro SM_PRINT_START_LINE_EXTRUDER_0", + "gcode_macro SM_PRINT_START_LINE_EXTRUDER_1", + "gcode_macro SM_PRINT_START_LINE_EXTRUDER_2", + "gcode_macro SM_PRINT_START_LINE_EXTRUDER_3", + "gcode_macro INNER_ROUGHLY_CLEAN_NOZZLE_BASE_DISCARD", + "gcode_macro INNER_DISCARD_FILAMENT_BASE_DISCARD", + "gcode_macro INNER_CUTOFF_BASE_DISCARD", + "gcode_macro INNER_FLUSH_FILAMENT", + "gcode_macro INNER_MANUAL_FEED_STAGE_PREPARE", + "gcode_macro INNER_MANUAL_FEED_STAGE_EXTRUDE", + "gcode_macro INNER_MANUAL_FEED_STAGE_FLUSH", + "gcode_macro INNER_MANUAL_FEED_STAGE_FINISH", + "gcode_macro INNER_MANUAL_FEED_STAGE_CANCEL", + "gcode_macro INNER_FLOW_MEASURE_END_BASE_DISCARD", + "gcode_macro INNER_FLOW_CALIB_END_BASE_DISCARD", + "gcode_macro INNER_PREEXTRUDE_FILAMENT", + "gcode_macro INNER_DETECT_NOZZLE_STAGE_1", + "gcode_macro INNER_DETECT_NOZZLE_STAGE_2", + "gcode_macro CONTROL_EXTRUDE_ACTION", + "gcode_macro CONTROL_RETRACT_ACTION", + "gcode_macro FEED_HANG_NEUTRAL", + "gcode_macro AUTO_FEEDING_BATCH", + "exclude_object", + "print_task_config", + "mqtt", + "timelapse", + "filament_detect", + "resonance_tester", + "gcode_macro _EXTRUDER_OFFSET_ACTION_PRESTART", + "gcode_macro _EXTRUDER_OFFSET_ACTION_PREHOMING", + "gcode_macro _DETECT_PLATE", + "gcode_macro _EXTRUDER_OFFSET_ACTION_PREHEAT", + "gcode_macro _EXTRUDER_OFFSET_ACTION_HEAT_T0", + "gcode_macro _EXTRUDER_OFFSET_ACTION_HEAT_T1", + "gcode_macro _EXTRUDER_OFFSET_ACTION_HEAT_T2", + "gcode_macro _EXTRUDER_OFFSET_ACTION_HEAT_T3", + "gcode_macro _EXTRUDER_OFFSET_ACTION_AUTO_CLEAN_T0", + "gcode_macro _EXTRUDER_OFFSET_ACTION_AUTO_CLEAN_T1", + "gcode_macro _EXTRUDER_OFFSET_ACTION_AUTO_CLEAN_T2", + "gcode_macro _EXTRUDER_OFFSET_ACTION_AUTO_CLEAN_T3", + "gcode_macro _EXTRUDER_OFFSET_ACTION_MANUAL_CLEAN_T0", + "gcode_macro _EXTRUDER_OFFSET_ACTION_MANUAL_CLEAN_T1", + "gcode_macro _EXTRUDER_OFFSET_ACTION_MANUAL_CLEAN_T2", + "gcode_macro _EXTRUDER_OFFSET_ACTION_MANUAL_CLEAN_T3", + "gcode_macro _EXTRUDER_OFFSET_ACTION_WAIT_COOL_T0", + "gcode_macro _EXTRUDER_OFFSET_ACTION_WAIT_COOL_T1", + "gcode_macro _EXTRUDER_OFFSET_ACTION_WAIT_COOL_T2", + "gcode_macro _EXTRUDER_OFFSET_ACTION_WAIT_COOL_T3", + "gcode_macro EXTRUDER_OFFSET_ACTION_PROBE_CALIBRATE_ALL", + "gcode_macro _EXTRUDER_OFFSET_ACTION_PROBE_CALIBRATE_T0", + "gcode_macro _EXTRUDER_OFFSET_ACTION_PROBE_CALIBRATE_T1", + "gcode_macro _EXTRUDER_OFFSET_ACTION_PROBE_CALIBRATE_T2", + "gcode_macro _EXTRUDER_OFFSET_ACTION_PROBE_CALIBRATE_T3", + "gcode_macro _EXTRUDER_OFFSET_ACTION_EXIT", + "gcode_macro XYZ_OFFSET_CALIBRATE", + "gcode_macro XYZ_OFFSET_CALIBRATE_ALL", + "heaters", + "stepper_enable", + "tmc2240 stepper_x", + "tmc2240 stepper_y", + "tmc2209 stepper_z", + "heater_bed", + "temperature_sensor cavity", + "fan_generic cavity_fan", + "led cavity_led", + "fan_generic exhaust_fan", + "fan_generic circulation_fan", + "purifier", + "bed_mesh", + "homing_precise_corexy", + "heater_fan power_fan", + "adc_current_sensor I_AD", + "power_loss_check", + "auto_screws_tilt_adjust", + "gcode_macro AUTO_BED_MESH_CALIBRATE", + "gcode_macro SHAKE_Z", + "gcode_macro SENSORLESS_HOME_X", + "gcode_macro SENSORLESS_HOME_Y", + "gcode_macro SENSORLESS_HOME_Z", + "gcode_macro _HOMING_PRECISE_COREXY_ADVANCED", + "gcode_macro _CLIENT_VARIABLE", + "tmc2209 extruder", + "fan", + "heater_fan e0_nozzle_fan", + "output_pin e0_heat_sw", + "filament_motion_sensor e0_filament", + "filament_entangle_detect e0_filament", + "extruder_offset_calibration", + "probe", + "sensor_accelerometer_identify e0_accelerometer", + "power_loss_check e0", + "tmc2209 extruder1", + "fan_generic e1_fan", + "heater_fan e1_nozzle_fan", + "output_pin e1_heat_sw", + "filament_motion_sensor e1_filament", + "filament_entangle_detect e1_filament", + "power_loss_check e1", + "tmc2209 extruder2", + "fan_generic e2_fan", + "heater_fan e2_nozzle_fan", + "output_pin e2_heat_sw", + "filament_motion_sensor e2_filament", + "filament_entangle_detect e2_filament", + "power_loss_check e2", + "tmc2209 extruder3", + "fan_generic e3_fan", + "heater_fan e3_nozzle_fan", + "output_pin e3_heat_sw", + "filament_motion_sensor e3_filament", + "filament_entangle_detect e3_filament", + "power_loss_check e3", + "filament_feed left", + "filament_feed right", + "gcode_macro _FILAMENT_FEED_VARIABLE", + "idle_timeout", + "filament_parameters", + "defect_detection", + "gcode_macro PAUSE", + "gcode_macro RESUME", + "panda_breath", + "heater_generic panda_breath", + "gcode_macro M141", + "gcode_macro M191", + "motion_report", + "query_endstops", + "system_stats", + "manual_probe", + "machine_state_manager", + "toolhead", + "extruder", + "extruder1", + "extruder2", + "extruder3" + ] + }, + "preflight": { + "schema_version": "preflight/1", + "checks": [ + { + "id": "bed.fit", + "title": "Objects sit outside this printer's bed", + "result": "attention", + "evidence": "1 object(s) fall outside this printer's 271.0 × 335.0 × 281.0 mm area", + "confidence": "confirmed", + "consequence": "The slicer will refuse to slice, or the print will start off the plate.", + "action": "Use Studio's object-placement fix to move them on — it saves a copy and leaves your original alone.", + "source": "project geometry vs printer bed" + }, + { + "id": "materials.toolheads", + "title": "More materials than toolheads", + "result": "attention", + "evidence": "project uses 6 filament slot(s); printer reports 4 toolheads", + "confidence": "confirmed", + "consequence": "This project asks for 6 materials and your printer can hold 4 at once. Some will need to be combined, dropped, or swapped by hand during the print.", + "action": "Open Colours & Materials to see which colours could be handled as swaps.", + "source": "Klipper extruder objects" + }, + { + "id": "materials.loaded", + "title": "Fewer materials loaded than the project uses", + "result": "attention", + "evidence": "printer reports 4 loaded; project uses 6", + "confidence": "confirmed", + "consequence": "Some of the project's materials have nothing to print with.", + "action": "Load the missing materials before you start.", + "source": "printer filament state" + }, + { + "id": "printer.reachable", + "title": "Printer found", + "result": "ok", + "evidence": " answered on port 7125", + "confidence": "confirmed", + "consequence": "Studio can compare this project against your actual printer.", + "action": null, + "source": "Moonraker /server/info" + }, + { + "id": "nozzle.match", + "title": "Nozzle size", + "result": "ok", + "evidence": "project expects 0.4 mm; the printer reports the same", + "confidence": "confirmed", + "consequence": "The project was made for the nozzle the printer reports.", + "action": null, + "source": "printer firmware" + }, + { + "id": "printer.busy", + "title": "Printer is free", + "result": "ok", + "evidence": "the printer reports it is standby", + "confidence": "confirmed", + "consequence": "Nothing else is using the printer.", + "action": null, + "source": "Klipper print_stats" + }, + { + "id": "project.sliced", + "title": "Already sliced", + "result": "ok", + "evidence": "1 plate g-code part(s)", + "confidence": "confirmed", + "consequence": "This project already contains toolpaths from another machine. Studio removes them when it prepares a U1 copy so Snapmaker Orca slices fresh.", + "action": "Prepare a U1 copy, then slice it in Snapmaker Orca.", + "source": "project archive" + } + ], + "counts": { + "blocked": 0, + "attention": 3, + "unknown": 0, + "ok": 4 + }, + "needs_attention": [ + { + "id": "bed.fit", + "title": "Objects sit outside this printer's bed", + "result": "attention", + "evidence": "1 object(s) fall outside this printer's 271.0 × 335.0 × 281.0 mm area", + "confidence": "confirmed", + "consequence": "The slicer will refuse to slice, or the print will start off the plate.", + "action": "Use Studio's object-placement fix to move them on — it saves a copy and leaves your original alone.", + "source": "project geometry vs printer bed" + }, + { + "id": "materials.toolheads", + "title": "More materials than toolheads", + "result": "attention", + "evidence": "project uses 6 filament slot(s); printer reports 4 toolheads", + "confidence": "confirmed", + "consequence": "This project asks for 6 materials and your printer can hold 4 at once. Some will need to be combined, dropped, or swapped by hand during the print.", + "action": "Open Colours & Materials to see which colours could be handled as swaps.", + "source": "Klipper extruder objects" + }, + { + "id": "materials.loaded", + "title": "Fewer materials loaded than the project uses", + "result": "attention", + "evidence": "printer reports 4 loaded; project uses 6", + "confidence": "confirmed", + "consequence": "Some of the project's materials have nothing to print with.", + "action": "Load the missing materials before you start.", + "source": "printer filament state" + } + ], + "unknowns": [], + "printer_reachable": true, + "summary": "3 things to resolve before you slice.", + "disclaimer": "These are checks against what your printer reports right now. They are advisory — Studio cannot promise a print will succeed.", + "printer": { + "reachable": true, + "host": "", + "port": 7125, + "observed_at": 1791574602.5049605, + "toolhead_count": 4, + "bed_mm": { + "x": 271, + "y": 335, + "z": 281 + }, + "nozzle_diameters": [ + 0.4, + 0.4, + 0.4, + 0.4 + ], + "nozzle_confirmed_by": "printer", + "print_state": "standby", + "loaded_filaments": [ + { + "color": "#F8F81C", + "material": "PLA Matte", + "vendor": "Snapmaker", + "confirmed_by": "printer" + }, + { + "color": "#6C5BB1", + "material": "PLA Matte", + "vendor": "Snapmaker", + "confirmed_by": "printer" + }, + { + "color": "#080A0D", + "material": "PLA SnapSpeed", + "vendor": "Snapmaker", + "confirmed_by": "printer" + }, + { + "color": "#6F4C2F", + "material": "PLA Matte", + "vendor": "Snapmaker", + "confirmed_by": "printer" + } + ], + "nozzle_confirmed_at": null, + "nozzle_revision": 0, + "nozzle_conflicts": [], + "nozzle_storage_error": null, + "identity": { + "matched": true, + "printer_id": "snapmaker_u1", + "confidence": "confirmed", + "evidence": "the printer exposes the print_task_config object, which mainline Klipper does not have; it reports 4 toolheads, as this profile records", + "matched_on": [ + "the printer exposes the print_task_config object, which mainline Klipper does not have", + "it reports 4 toolheads, as this profile records" + ] + }, + "profile": { + "printer_id": "snapmaker_u1", + "display_name": "Snapmaker U1", + "manufacturer": "Snapmaker", + "model": "U1", + "verification_level": "hardware_verified", + "verification_label": "Hardware verified", + "verification_meaning": "A physical machine of this kind answered these questions, read-only, and the answers were recorded.", + "verification_note": "Read read-only from a physical Snapmaker U1 on a LAN during the beta.23 and v0.7.x acceptance work.", + "tool_count": 4, + "build_volume_mm": { + "x": 270, + "y": 270, + "z": 270.05 + }, + "known_unknowns": [ + "free storage on the printer — the firmware reports no sizes", + "how much filament is left on a loaded spool — the printer tracks which spool, not what remains" + ], + "not_verified": [], + "source_refs": [ + { + "kind": "hardware", + "detail": "read-only LAN session against a physical Snapmaker U1 on Moonraker port 7125", + "recorded": "docs/internal/evidence/0.7.2.json (hardware 26/26)" + }, + { + "kind": "repository", + "detail": "backend/snapstudio_core/data/profiles/snapmaker_u1.json — the known-good U1 machine preset extracted from a reference project" + } + ] + }, + "resolved": { + "schema_version": "printerprofile/1", + "printer_reachable": true, + "profile": { + "printer_id": "snapmaker_u1", + "display_name": "Snapmaker U1", + "manufacturer": "Snapmaker", + "model": "U1", + "verification_level": "hardware_verified", + "verification_label": "Hardware verified", + "verification_meaning": "A physical machine of this kind answered these questions, read-only, and the answers were recorded.", + "verification_note": "Read read-only from a physical Snapmaker U1 on a LAN during the beta.23 and v0.7.x acceptance work.", + "tool_count": 4, + "build_volume_mm": { + "x": 270, + "y": 270, + "z": 270.05 + }, + "known_unknowns": [ + "free storage on the printer — the firmware reports no sizes", + "how much filament is left on a loaded spool — the printer tracks which spool, not what remains" + ], + "not_verified": [], + "source_refs": [ + { + "kind": "hardware", + "detail": "read-only LAN session against a physical Snapmaker U1 on Moonraker port 7125", + "recorded": "docs/internal/evidence/0.7.2.json (hardware 26/26)" + }, + { + "kind": "repository", + "detail": "backend/snapstudio_core/data/profiles/snapmaker_u1.json — the known-good U1 machine preset extracted from a reference project" + } + ] + }, + "conflicts": [], + "sources": { + "tool_count": "live", + "build_volume_mm": "live" + }, + "tool_count": 4, + "build_volume_mm": { + "x": 271, + "y": 335, + "z": 281 + }, + "build_volume_note": "measured from the printer's own axis limits, which include travel beyond the printable plate", + "capabilities": { + "bed_mesh": { + "name": "bed_mesh", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists bed_mesh" + }, + "exclude_object": { + "name": "exclude_object", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists exclude_object" + }, + "input_shaper": { + "name": "input_shaper", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list input_shaper" + }, + "pause_resume": { + "name": "pause_resume", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists pause_resume" + }, + "eddy_probe": { + "name": "eddy_probe", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list probe_eddy_current" + }, + "filament_runout": { + "name": "filament_runout", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists filament_switch_sensor" + }, + "quad_gantry_level": { + "name": "quad_gantry_level", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list quad_gantry_level" + }, + "z_tilt": { + "name": "z_tilt", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list z_tilt" + }, + "virtual_sdcard": { + "name": "virtual_sdcard", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists virtual_sdcard" + } + }, + "material_state": { + "known": true, + "source": "live", + "slots": 4, + "evidence": "the printer reports which filaments are loaded" + } + } + } + }, + "post_slice": { + "schema_version": "post_slice/1", + "available": true, + "printer_reachable": true, + "job": { + "slicer": "Snapmaker Orca", + "slicer_version": "2.3.4", + "printer_model": "Snapmaker U1", + "layer_count": 12, + "layer_height_mm": 0.2, + "max_z_mm": 2.4, + "estimated_seconds": 250, + "tools_used": [ + 1 + ], + "total_g": 0.36, + "size_bytes": 726, + "purge": { + "separable": false, + "expected": false, + "detail": "single-tool job — no tool-change purge", + "confidence": "confirmed" + } + }, + "checks": [ + { + "id": "gcode.machine", + "title": "Sliced for this machine", + "result": "ok", + "evidence": "the file says it was sliced for Snapmaker U1", + "confidence": "confirmed", + "consequence": "The job's commands were generated for a Snapmaker U1.", + "action": null, + "source": "G-code configuration block" + }, + { + "id": "gcode.tools", + "title": "Every toolhead this job needs exists", + "result": "ok", + "evidence": "the job prints from slot 2; this printer reports 4 toolheads", + "confidence": "confirmed", + "consequence": "The printer can select every tool the job asks for.", + "action": null, + "source": "G-code vs Klipper extruder objects" + }, + { + "id": "gcode.loaded", + "title": "Every slot this job uses is loaded", + "result": "ok", + "evidence": "1 slot(s) in use, all reporting a spool", + "confidence": "confirmed", + "consequence": "Nothing will stop for a missing spool.", + "action": null, + "source": "G-code tool use vs printer filament state" + }, + { + "id": "gcode.material", + "title": "Loaded material matches the job", + "result": "ok", + "evidence": "1 slot(s) compared at material-family level", + "confidence": "likely", + "consequence": "The job's temperatures and speeds suit what is loaded.", + "action": null, + "source": "G-code filament types vs printer filament state — compared by family, so PLA Matte counts as PLA" + }, + { + "id": "gcode.bed", + "title": "Bed size matches", + "result": "ok", + "evidence": "sliced for 270.0 × 270.0 mm; this printer reports 271.0 × 335.0 mm", + "confidence": "confirmed", + "consequence": "The job was sliced for this printer's bed.", + "action": null, + "source": "G-code vs the printer's own reported bed" + }, + { + "id": "gcode.nozzle", + "title": "Nozzle size matches", + "result": "ok", + "evidence": "the job was sliced for 0.4 mm; the printer reports the same", + "confidence": "confirmed", + "consequence": "The job was sliced for the nozzle the printer reports.", + "action": null, + "source": "G-code configuration block; this printer's firmware" + }, + { + "id": "printer.busy", + "title": "Printer is free", + "result": "ok", + "evidence": "the printer reports it is standby", + "confidence": "confirmed", + "consequence": "Nothing else is using the printer.", + "action": null, + "source": "Klipper print_stats" + } + ], + "counts": { + "ok": 7 + }, + "needs_attention": [], + "unknowns": [], + "summary": "Everything Studio can check about this job against this printer looks right.", + "disclaimer": "This reads what the sliced file says it will do. It is not a simulation of the print, and Studio does not slice — Snapmaker Orca does.", + "printer": { + "reachable": true, + "host": "", + "port": 7125, + "observed_at": 1791574603.2581892, + "toolhead_count": 4, + "bed_mm": { + "x": 271, + "y": 335, + "z": 281 + }, + "nozzle_diameters": [ + 0.4, + 0.4, + 0.4, + 0.4 + ], + "nozzle_confirmed_by": "printer", + "print_state": "standby", + "loaded_filaments": [ + { + "color": "#F8F81C", + "material": "PLA Matte", + "vendor": "Snapmaker", + "confirmed_by": "printer" + }, + { + "color": "#6C5BB1", + "material": "PLA Matte", + "vendor": "Snapmaker", + "confirmed_by": "printer" + }, + { + "color": "#080A0D", + "material": "PLA SnapSpeed", + "vendor": "Snapmaker", + "confirmed_by": "printer" + }, + { + "color": "#6F4C2F", + "material": "PLA Matte", + "vendor": "Snapmaker", + "confirmed_by": "printer" + } + ], + "nozzle_confirmed_at": null, + "nozzle_revision": 0, + "nozzle_conflicts": [], + "nozzle_storage_error": null, + "identity": { + "matched": true, + "printer_id": "snapmaker_u1", + "confidence": "confirmed", + "evidence": "the printer exposes the print_task_config object, which mainline Klipper does not have; it reports 4 toolheads, as this profile records", + "matched_on": [ + "the printer exposes the print_task_config object, which mainline Klipper does not have", + "it reports 4 toolheads, as this profile records" + ] + }, + "profile": { + "printer_id": "snapmaker_u1", + "display_name": "Snapmaker U1", + "manufacturer": "Snapmaker", + "model": "U1", + "verification_level": "hardware_verified", + "verification_label": "Hardware verified", + "verification_meaning": "A physical machine of this kind answered these questions, read-only, and the answers were recorded.", + "verification_note": "Read read-only from a physical Snapmaker U1 on a LAN during the beta.23 and v0.7.x acceptance work.", + "tool_count": 4, + "build_volume_mm": { + "x": 270, + "y": 270, + "z": 270.05 + }, + "known_unknowns": [ + "free storage on the printer — the firmware reports no sizes", + "how much filament is left on a loaded spool — the printer tracks which spool, not what remains" + ], + "not_verified": [], + "source_refs": [ + { + "kind": "hardware", + "detail": "read-only LAN session against a physical Snapmaker U1 on Moonraker port 7125", + "recorded": "docs/internal/evidence/0.7.2.json (hardware 26/26)" + }, + { + "kind": "repository", + "detail": "backend/snapstudio_core/data/profiles/snapmaker_u1.json — the known-good U1 machine preset extracted from a reference project" + } + ] + }, + "resolved": { + "schema_version": "printerprofile/1", + "printer_reachable": true, + "profile": { + "printer_id": "snapmaker_u1", + "display_name": "Snapmaker U1", + "manufacturer": "Snapmaker", + "model": "U1", + "verification_level": "hardware_verified", + "verification_label": "Hardware verified", + "verification_meaning": "A physical machine of this kind answered these questions, read-only, and the answers were recorded.", + "verification_note": "Read read-only from a physical Snapmaker U1 on a LAN during the beta.23 and v0.7.x acceptance work.", + "tool_count": 4, + "build_volume_mm": { + "x": 270, + "y": 270, + "z": 270.05 + }, + "known_unknowns": [ + "free storage on the printer — the firmware reports no sizes", + "how much filament is left on a loaded spool — the printer tracks which spool, not what remains" + ], + "not_verified": [], + "source_refs": [ + { + "kind": "hardware", + "detail": "read-only LAN session against a physical Snapmaker U1 on Moonraker port 7125", + "recorded": "docs/internal/evidence/0.7.2.json (hardware 26/26)" + }, + { + "kind": "repository", + "detail": "backend/snapstudio_core/data/profiles/snapmaker_u1.json — the known-good U1 machine preset extracted from a reference project" + } + ] + }, + "conflicts": [], + "sources": { + "tool_count": "live", + "build_volume_mm": "live" + }, + "tool_count": 4, + "build_volume_mm": { + "x": 271, + "y": 335, + "z": 281 + }, + "build_volume_note": "measured from the printer's own axis limits, which include travel beyond the printable plate", + "capabilities": { + "bed_mesh": { + "name": "bed_mesh", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists bed_mesh" + }, + "exclude_object": { + "name": "exclude_object", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists exclude_object" + }, + "input_shaper": { + "name": "input_shaper", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list input_shaper" + }, + "pause_resume": { + "name": "pause_resume", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists pause_resume" + }, + "eddy_probe": { + "name": "eddy_probe", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list probe_eddy_current" + }, + "filament_runout": { + "name": "filament_runout", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists filament_switch_sensor" + }, + "quad_gantry_level": { + "name": "quad_gantry_level", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list quad_gantry_level" + }, + "z_tilt": { + "name": "z_tilt", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list z_tilt" + }, + "virtual_sdcard": { + "name": "virtual_sdcard", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists virtual_sdcard" + } + }, + "material_state": { + "known": true, + "source": "live", + "slots": 4, + "evidence": "the printer reports which filaments are loaded" + } + } + } + }, + "firmware": { + "schema_version": "firmwarecaps/1", + "available": true, + "toolhead_count": 4, + "bed_mm": { + "x": 271, + "y": 335, + "z": 281 + }, + "macro_count": 119, + "many_custom_macros": true, + "extended_firmware": false, + "extended_firmware_evidence": "no community firmware answered for itself on the paths Studio knows about — which is not the same as this printer running stock firmware", + "features": [ + { + "name": "Automatic bed mesh levelling", + "detail": "compensates for an uneven bed across the whole plate" + }, + { + "name": "Auto bed probing", + "detail": "automatic Z/level probing" + }, + { + "name": "Object exclusion", + "detail": "cancel one failed object mid-print without losing the rest" + }, + { + "name": "Pause / resume", + "detail": "safely pause and continue a print" + }, + { + "name": "Filament runout detection", + "detail": "stops the print if filament runs out or jams" + }, + { + "name": "Custom macros", + "detail": "119 gcode macros on this printer" + }, + { + "name": "4-toolhead multimaterial", + "detail": "prints up to 4 colours/materials in one job" + } + ], + "summary": "Your Snapmaker U1 reports 4 toolheads, 271.0×335.0×281.0 mm bed; 7 capabilities detected (119 custom macros on it).", + "identity": { + "matched": true, + "printer_id": "snapmaker_u1", + "confidence": "confirmed", + "evidence": "the printer exposes the print_task_config object, which mainline Klipper does not have; it reports 4 toolheads, as this profile records", + "matched_on": [ + "the printer exposes the print_task_config object, which mainline Klipper does not have", + "it reports 4 toolheads, as this profile records" + ] + } + }, + "identity": { + "matched": true, + "printer_id": "snapmaker_u1", + "confidence": "confirmed", + "evidence": "the printer exposes the print_task_config object, which mainline Klipper does not have; it reports 4 toolheads, as this profile records", + "matched_on": [ + "the printer exposes the print_task_config object, which mainline Klipper does not have", + "it reports 4 toolheads, as this profile records" + ] + }, + "resolved": { + "schema_version": "printerprofile/1", + "printer_reachable": true, + "profile": { + "printer_id": "snapmaker_u1", + "display_name": "Snapmaker U1", + "manufacturer": "Snapmaker", + "model": "U1", + "verification_level": "hardware_verified", + "verification_label": "Hardware verified", + "verification_meaning": "A physical machine of this kind answered these questions, read-only, and the answers were recorded.", + "verification_note": "Read read-only from a physical Snapmaker U1 on a LAN during the beta.23 and v0.7.x acceptance work.", + "tool_count": 4, + "build_volume_mm": { + "x": 270, + "y": 270, + "z": 270.05 + }, + "known_unknowns": [ + "free storage on the printer — the firmware reports no sizes", + "how much filament is left on a loaded spool — the printer tracks which spool, not what remains" + ], + "not_verified": [], + "source_refs": [ + { + "kind": "hardware", + "detail": "read-only LAN session against a physical Snapmaker U1 on Moonraker port 7125", + "recorded": "docs/internal/evidence/0.7.2.json (hardware 26/26)" + }, + { + "kind": "repository", + "detail": "backend/snapstudio_core/data/profiles/snapmaker_u1.json — the known-good U1 machine preset extracted from a reference project" + } + ] + }, + "conflicts": [], + "sources": { + "tool_count": "live", + "build_volume_mm": "live" + }, + "tool_count": 4, + "build_volume_mm": { + "x": 271, + "y": 335, + "z": 281 + }, + "build_volume_note": "measured from the printer's own axis limits, which include travel beyond the printable plate", + "capabilities": { + "bed_mesh": { + "name": "bed_mesh", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists bed_mesh" + }, + "exclude_object": { + "name": "exclude_object", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists exclude_object" + }, + "input_shaper": { + "name": "input_shaper", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list input_shaper" + }, + "pause_resume": { + "name": "pause_resume", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists pause_resume" + }, + "eddy_probe": { + "name": "eddy_probe", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list probe_eddy_current" + }, + "filament_runout": { + "name": "filament_runout", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists filament_switch_sensor" + }, + "quad_gantry_level": { + "name": "quad_gantry_level", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list quad_gantry_level" + }, + "z_tilt": { + "name": "z_tilt", + "state": "absent", + "source": "live", + "evidence": "the printer's firmware does not list z_tilt" + }, + "virtual_sdcard": { + "name": "virtual_sdcard", + "state": "present", + "source": "live", + "evidence": "the printer's firmware lists virtual_sdcard" + } + }, + "material_state": { + "known": true, + "source": "live", + "slots": 4, + "evidence": "the printer reports which filaments are loaded" + } + }, + "profile": { + "printer_id": "snapmaker_u1", + "display_name": "Snapmaker U1", + "manufacturer": "Snapmaker", + "model": "U1", + "verification_level": "hardware_verified", + "verification_label": "Hardware verified", + "verification_meaning": "A physical machine of this kind answered these questions, read-only, and the answers were recorded.", + "verification_note": "Read read-only from a physical Snapmaker U1 on a LAN during the beta.23 and v0.7.x acceptance work.", + "tool_count": 4, + "build_volume_mm": { + "x": 270, + "y": 270, + "z": 270.05 + }, + "known_unknowns": [ + "free storage on the printer — the firmware reports no sizes", + "how much filament is left on a loaded spool — the printer tracks which spool, not what remains" + ], + "not_verified": [], + "source_refs": [ + { + "kind": "hardware", + "detail": "read-only LAN session against a physical Snapmaker U1 on Moonraker port 7125", + "recorded": "docs/internal/evidence/0.7.2.json (hardware 26/26)" + }, + { + "kind": "repository", + "detail": "backend/snapstudio_core/data/profiles/snapmaker_u1.json — the known-good U1 machine preset extracted from a reference project" + } + ] + }, + "provider_on_hardware": { + "agreed": { + "spoolman": { + "tool": 1, + "label": "slot 2", + "needed": true, + "wants_material": "PLA", + "wants_colour": null, + "wants_name": null, + "grams": 0.36, + "has_material": "PLA Matte", + "has_colour": "#6C5BB1", + "action": null, + "state": "ready", + "detail": "PLA Matte is loaded and matches what the job expects. 880 g recorded, 0.36 g needed. Last updated just now.", + "colour_distance": null, + "confirmed_by": "printer", + "printer_confirmed": true, + "needs_grams": 0.36, + "remaining_g": 880, + "remaining_quality": "tracked", + "sufficiency": { + "verdict": "enough", + "detail": "880 g recorded, 0.36 g needed. Last updated just now.", + "source": "tracked spool weight", + "quality": "tracked", + "trusted": true, + "freshness": "fresh", + "age_s": 68 + }, + "conflicts": [], + "notes": [] + }, + "bambuddy": { + "tool": 1, + "label": "slot 2", + "needed": true, + "wants_material": "PLA", + "wants_colour": null, + "wants_name": null, + "grams": 0.36, + "has_material": "PLA Matte", + "has_colour": "#6C5BB1", + "action": null, + "state": "ready", + "detail": "PLA Matte is loaded and matches what the job expects. 880 g recorded, 0.36 g needed. Last updated just now.", + "colour_distance": null, + "confirmed_by": "printer", + "printer_confirmed": true, + "needs_grams": 0.36, + "remaining_g": 880, + "remaining_quality": "tracked", + "sufficiency": { + "verdict": "enough", + "detail": "880 g recorded, 0.36 g needed. Last updated just now.", + "source": "tracked spool weight", + "quality": "tracked", + "trusted": true, + "freshness": "fresh", + "age_s": 70 + }, + "conflicts": [], + "notes": [] + } + }, + "conflicted": { + "spoolman": { + "tool": 1, + "label": "slot 2", + "needed": true, + "wants_material": "PLA", + "wants_colour": null, + "wants_name": null, + "grams": 0.36, + "has_material": "PLA Matte", + "has_colour": "#6C5BB1", + "action": null, + "state": "ready", + "detail": "PLA Matte is loaded and matches what the job expects. 920 g recorded, 0.36 g needed. Last updated just now.", + "colour_distance": null, + "confirmed_by": "printer", + "printer_confirmed": true, + "needs_grams": 0.36, + "remaining_g": 920, + "remaining_quality": "tracked", + "sufficiency": { + "verdict": "enough", + "detail": "920 g recorded, 0.36 g needed. Last updated just now.", + "source": "tracked spool weight", + "quality": "tracked", + "trusted": true, + "freshness": "fresh", + "age_s": 69 + }, + "conflicts": [ + "the printer reports PLA in this slot and your provider (Spoolman) says PETG — Studio is using what the printer can see", + "the printer reports #6C5BB1 in this slot and your provider (Spoolman) says #1AA39B — Studio is using what the printer can see" + ], + "notes": [] + }, + "bambuddy": { + "tool": 1, + "label": "slot 2", + "needed": true, + "wants_material": "PLA", + "wants_colour": null, + "wants_name": null, + "grams": 0.36, + "has_material": "PLA Matte", + "has_colour": "#6C5BB1", + "action": null, + "state": "ready", + "detail": "PLA Matte is loaded and matches what the job expects. 920 g recorded, 0.36 g needed. Last updated just now.", + "colour_distance": null, + "confirmed_by": "printer", + "printer_confirmed": true, + "needs_grams": 0.36, + "remaining_g": 920, + "remaining_quality": "tracked", + "sufficiency": { + "verdict": "enough", + "detail": "920 g recorded, 0.36 g needed. Last updated just now.", + "source": "tracked spool weight", + "quality": "tracked", + "trusted": true, + "freshness": "fresh", + "age_s": 71 + }, + "conflicts": [ + "the printer reports PLA in this slot and your provider (Bambuddy) says PETG — Studio is using what the printer can see", + "the printer reports #6C5BB1 in this slot and your provider (Bambuddy) says #1AA39B — Studio is using what the printer can see" + ], + "notes": [] + } + } + }, + "nozzle_before": { + "host": "", + "port": 7125, + "reachable": true, + "live": [ + 0.4, + 0.4, + 0.4, + 0.4 + ], + "live_error": null, + "toolhead_count": 4, + "toolhead_count_source": "printer", + "revision": 0, + "observed_at": "2026-10-09T19:36:54Z", + "count_mismatch": false, + "storage_error": null, + "toolheads": [ + { + "toolhead": 0, + "diameter": 0.4, + "source": "printer", + "confirmed_at": null, + "confirmed": null, + "conflict": false, + "out_of_range": false + }, + { + "toolhead": 1, + "diameter": 0.4, + "source": "printer", + "confirmed_at": null, + "confirmed": null, + "conflict": false, + "out_of_range": false + }, + { + "toolhead": 2, + "diameter": 0.4, + "source": "printer", + "confirmed_at": null, + "confirmed": null, + "conflict": false, + "out_of_range": false + }, + { + "toolhead": 3, + "diameter": 0.4, + "source": "printer", + "confirmed_at": null, + "confirmed": null, + "conflict": false, + "out_of_range": false + } + ] + }, + "nozzle_conflict": { + "host": "", + "port": 7125, + "reachable": true, + "live": [ + 0.4, + 0.4, + 0.4, + 0.4 + ], + "live_error": null, + "toolhead_count": 4, + "toolhead_count_source": "printer", + "revision": 1, + "observed_at": "2026-10-09T19:36:57Z", + "count_mismatch": false, + "storage_error": null, + "toolheads": [ + { + "toolhead": 0, + "diameter": 0.4, + "source": "printer", + "confirmed_at": "2026-10-09T19:36:55Z", + "confirmed": 0.6, + "conflict": true, + "out_of_range": false + }, + { + "toolhead": 1, + "diameter": 0.4, + "source": "printer", + "confirmed_at": null, + "confirmed": null, + "conflict": false, + "out_of_range": false + }, + { + "toolhead": 2, + "diameter": 0.4, + "source": "printer", + "confirmed_at": null, + "confirmed": null, + "conflict": false, + "out_of_range": false + }, + { + "toolhead": 3, + "diameter": 0.4, + "source": "printer", + "confirmed_at": null, + "confirmed": null, + "conflict": false, + "out_of_range": false + } + ] + } + } +} \ No newline at end of file diff --git a/docs/screenshots/v1.5.1/painted.png b/docs/screenshots/v1.5.1/painted.png new file mode 100644 index 00000000..aa184b8d Binary files /dev/null and b/docs/screenshots/v1.5.1/painted.png differ diff --git a/docs/screenshots/v1.5.1/prepared.png b/docs/screenshots/v1.5.1/prepared.png new file mode 100644 index 00000000..9b37086d Binary files /dev/null and b/docs/screenshots/v1.5.1/prepared.png differ diff --git a/docs/screenshots/v1.5.1/problem.png b/docs/screenshots/v1.5.1/problem.png new file mode 100644 index 00000000..e207ed05 Binary files /dev/null and b/docs/screenshots/v1.5.1/problem.png differ diff --git a/docs/screenshots/v1.5.1/what-to-load.png b/docs/screenshots/v1.5.1/what-to-load.png new file mode 100644 index 00000000..6a5f90d1 Binary files /dev/null and b/docs/screenshots/v1.5.1/what-to-load.png differ diff --git a/docs/testing/scene-contract/README.md b/docs/testing/scene-contract/README.md new file mode 100644 index 00000000..69880fd8 --- /dev/null +++ b/docs/testing/scene-contract/README.md @@ -0,0 +1,341 @@ +# scene/1: the backend scene contract (PR 3a) + +The engine can now describe a project's geometry, placement and engine findings as one bounded, +machine-checked document (`scene/1`), built by a cancellable background job. This is the backend half +only. There is no viewer and no desktop change in this PR. + +* Contract: `backend/snapstudio_core/data/scene-1.schema.json` (JSON Schema 2020-12, frozen: every object + closed, every field required, unknown is `null`). +* Golden scene: `backend/tests/fixtures/scene/golden-scene-1.json`, written by hand and independently of the + builder by `make_golden.py` next to it. A test requires the real builder to reproduce it exactly. +* Builder: `backend/snapstudio_core/scene.py`. Every numeric budget lives in + `backend/snapstudio_core/scene_limits.py` and nowhere else. +* Jobs and routes: `backend/snapstudio_api/scene_jobs.py`; `POST /scene/start | status | result | cancel` + (existing `X-Auth-Token`; the result body is serialized once and sent by the new `_send_bytes`). +* Units fix: `backend/snapstudio_core/units.py`, applied to every dimension source (below). +* Measurements: `results.json` in this folder, produced by `backend/tools/scene_benchmark.py`. + +Not covered: any viewer, the Tauri window, WebKitGTK, a screen reader, a real printer, and Linux (see +"What was not run"). + +## What a scene is, and is not + +A scene is a **consistent snapshot of the file as it was copied for the job**. The job copies the source once +into a private file, hashes it (the `revision` is the SHA-256 of those bytes) and parses only the copy, so +replacing the original while a job runs cannot tear the scene. `SOURCE_CHANGED` applies only to (a) a file that +changed while it was being copied, (b) a reused `request_id`+path whose file now hashes differently from that +job's revision, and (c) an `expected_revision` that differs from the job's. It does **not** apply to edits made +after the snapshot was taken. The original file is only ever read (a test compares its bytes and mtime before and +after; so does the job test). + +Scenes say what the file proves and no more: + +* **Placement.** STL files, models with no build item and unresolved placement are `placement_state: "unknown"`. + Their `world_mm` is an identity used only so something can be drawn. No bed-fit or size statement of any kind is + attached to an unknown-placement node. +* **Plates.** Membership is per instance, from `model_settings.config` (`plate: known | ambiguous | unknown`). + Plate origins are never invented (`origin_mm` is always `null`). A multi-plate project gets + `MULTI_PLATE_PLACEMENT_UNCHECKED` and no placement findings, because the grid spacing is not in the file. +* **Volumes and roles.** Every mesh partitions *all* its triangles into volumes (`part`, `modifier`, `negative`, + `support_enforcer`, `support_blocker`, `unknown`). The synthetic whole-mesh `part` volume + (`source: "object"`) is used ONLY for an object that has no volume metadata at all. Prusa volume ranges are read + by attribute name (the order of `firstid`/`lastid` does not matter); an unmapped gap, an overlap or an unreadable + range is `unknown` (`UNKNOWN_VOLUME_ROLE`), never a guess. Bambu/Orca part records are **per use**, so the role + lives on the node (`role_context`), not on the shared mesh: a mesh can be a negative part under one object and an + ordinary part under another, and a role given to a component assembly applies to everything beneath it. Decision + (differs from `placement.read_objects`, which defaults to `normal_part`): an object that HAS part records but none + for one of its components gives that component `role_context: "unknown"`, `UNKNOWN_VOLUME_ROLE` and status + `partial`, and no fit finding. A node's bounds, `printable` and every fit statement use only role=part geometry; + non-part geometry is carried (`has_non_part_volumes`) and excluded from fit. +* **Production-extension build items.** A build item's `p:path` names the model FILE its object lives in. Node + identity, repeated-instance counting and lookup use the pair (part path, object id); an unresolvable `p:path` is + `UNRESOLVED_REFERENCE`, never a fall back to the root file. Per the Production Extension a `p:path` must be an + absolute package path (leading `/`), and component `p:path` references are honoured only from the root model + part; relative paths, and component paths in any other part, are `UNRESOLVED_REFERENCE`. +* **Fail closed across files and duplicates.** Where the file cannot prove an association the answer is + unknown, not a guess: (1) when the project has slicer settings, every part beneath a build object that lives in + another file gets `role_context: "unknown"` (the part records are matched against root-file objects), with + `UNKNOWN_VOLUME_ROLE`, status `partial` and no fit finding; (2) plate records name a bare object id, so when two + build resources in different files share an id, neither can be proven on a plate (`plate.state: "ambiguous"`, + `PLATE_MEMBERSHIP_UNKNOWN`, no finding); (3) two settings records for the same (object id, part id) that disagree + make the role unknown, identical duplicates are fine; (4) `UNSUPPORTED_UNIT` lists, in `target_ids`, the nodes + living in the affected part(s), and `sources[].unit`/`mm_per_unit` are then the fallback millimetre values. +* **Bounded parsing everywhere.** Model parts, the Bambu settings, the Prusa config and every `.rels` are parsed + with a counter on completed elements (1,000,000 each) and every completed element is released as soon as its data + is consumed (the element itself only, never its siblings: records a parent reads later, such as a negative + ``, are never lost to an unknown neighbour), so a part under its byte cap that is made of millions of tiny + elements is refused early. A part record without an `id` still counts as "this object has part metadata" (the + components it cannot cover are `unknown`). A present-but-empty `p:path` is `UNRESOLVED_REFERENCE`, not absent. + When the ROOT model's unit is unrecognized, the top node of every build item is listed in the `UNSUPPORTED_UNIT` + `target_ids`, since each item's translation is written in that unit. STL files: the kind is + decided from the header AND the size; a binary-looking header over the triangle budget is refused before any + decoding or large allocation whether or not the size matches; ASCII is read in 1 MiB chunks with a 4 KiB line cap, + split on LF, CRLF and a lone CR. +* **Bed.** The U1 rectangle comes from Studio's own template (`x 0.5..270.5, y 1..271`, 0.5 mm inward margin, + the same numbers `plate_placement` uses). If the template yields no usable rectangle, `bed.policy` is + `"fallback"`, `BED_TEMPLATE_UNAVAILABLE` is reported and **all fit findings are suppressed**. +* **Findings** carry `kind` (`placement | size | note`), `scope` (`project | plate | instance`), the engine + that produced them (`scene`), a JSON pointer into the scene and target node ids. Scene-produced + out-of-bounds and size statements are `placement` and `size`; the viewer copy rule (PR 3b) is that the word + "fits" is never derived from a scene. +* **Repeated instances.** Several build items of one object id get their own nodes and their own geometric + world bounds, but `REPEATED_INSTANCE_PLACEMENT_UNVERIFIED` is reported and no finding is attached, because the + engine's existing placement answer for repeated items is wrong on `main` (see "Known limitations"). +* **Non-millimetre sources.** Coordinates are converted to millimetres, and `sources[]` records each part's + unit. Because the contract was written before the conversion was shared, placement findings are still + disabled for any non-mm source (`NON_MM_SOURCE_UNIT`); size findings are not. A declared unit outside the six + defined ones is NOT treated like an absent attribute: the scene reports `UNSUPPORTED_UNIT`, falls back to + millimetres in `sources[]` (documented) and makes no fit statement. +* **Plates.** If a project has plates but an instance is on none, it is `PLATE_MEMBERSHIP_UNKNOWN`, status + `partial`, with no fit finding. + +## Units fix (a correction of wrong claims, not a new feature) + +Several readers ignored the 3MF `` header, so a 12 inch part was reported as 12 mm and +every size-based bed-fit statement for a non-millimetre project was wrong. One shared helper +(`snapstudio_core/units.py`: the six-unit `mm_per_unit` table, a header reader and a translation scaler) is now +applied to **every** dimension source. The header is read from the REAL root `model` element with the XML parser +(first start event; namespace prefixes, comments, processing instructions and a long preamble are irrelevant, and +a `` inside a comment is not seen), by the same `resolve_unit` function the scene parser calls. +Absent means millimetre; an unrecognized value is a distinct, flagged outcome (`UnitInfo.recognized`), with the +legacy readers keeping their old millimetre fallback: + +| Source | Change | +|---|---| +| `intelligence.project_info` (feeds DesignHealth, `service.insights`, strategy signals, `bed_fit`, `validation_report`, `canonical`, `layout`, `mm_doctor`, `toolhead_fit`) | vertex coordinates scaled per model part by that part's own unit | +| `geometry.build_item_dims` (feeds `layout`, `scale_doctor`, `plate_placement.assess`) | vertices scaled per part; component and build-item **translations** scaled too (a translation is a length in the model's unit) | +| `geometry.load_mesh` (feeds `scale_doctor.preview`, `mesh_diagnostics`) | vertices scaled per part | +| `placement.read_objects` | mesh points and transform translations scaled (through the shared helper); its repeated-instance behaviour is untouched | +| `plate_placement.prepare_placed_copy` (the "move onto the plate" fix) | the offset it writes is computed in millimetres and is now converted back to the ROOT model's unit before it is added to a build-item translation. A move that does not achieve its goal no longer leaves its copy on disk | + +Behaviour for millimetre files is unchanged: the scale factor is exactly 1.0 and the code skips the multiplication. +Evidence: outputs of `project_info`, `build_item_dims`, `load_mesh`, `read_objects`, `plate_placement.assess` and +`scale_doctor.scale_options` were hashed over all 50 fixtures and examples in the tree on `main` (b01f7d0) and on +this branch and compared: **identical** (sha256 `73a018e5...69ef` both sides). `tests/test_units_consumers.py` pins every consumer +named in the plan (`canonical`, `layout`, `validation_report`, `bed_fit` via `service.bed_fit`, strategy signals, +DesignHealth data path via `service.insights`, Scale Doctor, `plate_placement.assess`, `read_objects`) for all six +units, and asserts that a 12 inch part reads 304.8 mm everywhere. + +What this does **not** do (decision, plan v4 item 1): the size-based consumers still answer "does the model's +size fit the bed". They are not placement-aware. A small object translated off the plate, or separated instances +whose union is large, is still judged by size only. The scene's placement findings are the placement-aware +source. No consumer copy was edited here; qualifying those claims "by size" is PR 3c. + +## Jobs + +One worker thread, at most one queued job (newest wins; a running job is marked cancelled at once and the +replacement waits, queued, until the cancelled thread observes the flag and exits). States: +`queued | running | succeeded | failed | cancelled`. Terminal states are immutable and the first terminal wins +(a worker finishing after a cancel discards its result). 60 s deadline from the moment the job starts +running. A terminal job is kept 120 s or until more than 8 terminal jobs exist. An evicted or unknown id answers +404 `EXPIRED`; there is no stored "expired" state. + +| Route | Answers | +|---|---| +| `/scene/session {}` | 200 `{client_id, ttl_s}` (server-issued 24-character id); 503 `SESSION_LIMIT` | +| `/scene/start {path, request_id, client_id?, seq?}` | 200 job; 400 `INVALID_REQUEST` (also: only one of `client_id`/`seq`, null or malformed values, `seq` reused for another request, `request_id` reused at a higher `seq`); 422 `UNSUPPORTED_FORMAT`; 409 `INVALID_REQUEST` (request_id reused for another path), `SOURCE_CHANGED`, `STALE_START`, `CANCELLED_BEFORE_START` or `SESSION_EXPIRED`; 503 `WORKER_WEDGED` | +| `/scene/status {job_id, client_id?}` | 200 `{state, stage, completed, total, error, revision}`; 404 `EXPIRED`. A job started in a session needs that session's `client_id`; a missing or different one answers exactly like an unknown id (404 `EXPIRED`), so existence is never confirmed | +| `/scene/result {job_id, client_id?, expected_revision?}` | 200 scene; 422 `{error: }` for a failed job; 409 `CANCELLED` / `NOT_READY` / `SOURCE_CHANGED`; 404 `EXPIRED` | +| `/scene/cancel {job_id, client_id?}` or `{client_id, request_id, seq}` | 200 status (idempotent; a late cancel on a terminal job is a no-op; for a session job `client_id` is required, as for status and result). By request: 409 `SESSION_EXPIRED` for an unknown session; otherwise `dead_through` rises to `seq` and the job of that EXACT attempt (client_id, request_id and `seq` all equal) is cancelled, if any, so a delayed cancel for an old attempt cannot cancel a newer job that reused the request id, and a cancel for a higher `seq` does not cancel an older job; that `seq` and every lower one stay refused for the life of the session; with no such job the body is a `job_status` with `job_id: null`, `state: "cancelled"` | + +Error codes: `INVALID_REQUEST UNSUPPORTED_FORMAT INVALID_ARCHIVE INVALID_GEOMETRY UNRESOLVED_REFERENCE +LIMIT_EXCEEDED SOURCE_CHANGED CANCELLED TIMEOUT EXPIRED NOT_READY WORKER_WEDGED INTERNAL STALE_START +CANCELLED_BEFORE_START SESSION_EXPIRED SESSION_LIMIT` (`BUSY` is not an error code: a new start replaces the client's own earlier work). + +### Start ordering by session + +A client that aborts or times out a start cannot stop that request from still reaching the engine, possibly AFTER +the start it actually wants. The engine therefore orders a client's starts itself. The design is a **session** with +O(1) state, not a set of remembered requests. Four invariants, each pinned by tests named `test_i1_...` to +`test_i4_...`: + +* **I1: delayed starts cannot become valid through bookkeeping eviction.** A start is judged against the + session's watermark, not against whether its job is still retained, so an evicted job does not make an old + start admissible again. +* **I2: cancellation cannot be forgotten while the start remains admissible.** A cancel raises the session's + `dead_through` mark, so that start and every lower `seq` stay refused for as long as the session lives. +* **I3: idempotency is scoped to (session, request) and ownership is checked consistently on every path.** Jobs + are keyed by `(client_id, request_id)`; two sessions may use the same request_id and path and each gets, and + can cancel, its own job; cancel by request matches both ids exactly. +* **I4: capacity exhaustion or session expiry fails explicitly; nothing old is silently revived.** + +**Protocol.** `POST /scene/session {}` returns `{client_id, ttl_s}`; the engine chooses the id (24 characters of +`[A-Za-z0-9_-]`). Starts and by-request cancels carry that `client_id` and a `seq`, an integer from 1 to 2**53-1 that +the client treats as a strictly increasing generation counter for the session (the client only ever abandons lower +seqs when it moves to a higher one). Both keys or neither: null or half-present pairs are `400 INVALID_REQUEST`, +never a legacy start; ids must match in full (a trailing newline is rejected). With neither key the start behaves +exactly as before, in its own request_id-only namespace. + +**State per session** (nothing else): `watermark` (highest admitted seq), `dead_through` (highest cancelled seq), +`current` = (seq, request_id, path, job_id) of the entry at the watermark. + +**Rules, in order, under the registry lock that also registers jobs** (one admission function for every +successful start): unknown or expired `client_id` -> 409 `SESSION_EXPIRED` (a late start from an expired session +is refused, never re-created); `seq <= dead_through` -> 409 `CANCELLED_BEFORE_START`; `seq < watermark` -> 409 +`STALE_START`; `seq == watermark` with the same request_id and path -> the registered job (idempotent), or 409 +`STALE_START` if that job is no longer registered (an exact retry of an already-processed start is refused); equal +with anything else -> 400 `INVALID_REQUEST`; `seq > watermark` -> register (replacing the older job as before) and +set the watermark, except that a request_id already registered for the session is 400 `INVALID_REQUEST` +("request id reused": the old job is never handed back), and that attempt still raises the watermark so an older +attempt still in flight is refused. An idempotent repeat that was still hashing the file is re-judged by the same +rules BEFORE `SOURCE_CHANGED` is reported. After any refusal the client retries with a FRESH `request_id` and a +HIGHER `seq`. + +**Ownership (I3).** Every job records the session that started it. Status, result and cancel-by-job_id on a session +job need that session's `client_id` (404 `EXPIRED`, identical to an unknown id, otherwise), and `replaced_job_id` +in a start response names a replaced job only when it belonged to the same session (or both are legacy); in every +other case it is `null`. Legacy jobs keep today's behaviour. + +**An exact retry never loses its job.** A retry of the entry at the watermark (same `seq`, request_id and path) that is +rejected before registration (for example the file has since vanished) leaves the session's reference to that +entry's job untouched, so later and concurrent exact retries still get the job for as long as it is registered. + +**After an admitted start that is then rejected** (`WORKER_WEDGED`, an unsupported format, a missing file) the +session's watermark has already advanced to that `seq`, so a delayed older attempt cannot be accepted behind it. +The client retries with a fresh `request_id` and a higher `seq`; an exact retry is `STALE_START`. + +**Sessions.** At most 32 (`MAX_SESSIONS`), idle TTL 15 minutes (`SESSION_TTL_SECONDS`), touched by every start, +cancel (by request or by job_id), status and result. Sessions are pruned after expired jobs are evicted in the same pass. Only idle-expired sessions that own no registered job are pruned; a live session is never +evicted. At capacity with nothing prunable, opening a session is 503 `SESSION_LIMIT`. + +**Limits, stated plainly.** Ordering is provided PER SESSION. It is not provided across sessions: a start from +another session replaces the previous job exactly as before. An engine restart drops every session, so the first +start from an old `client_id` answers `SESSION_EXPIRED` and the client must open a new session. Session expiry +likewise ends that session's ordering (explicitly: `SESSION_EXPIRED`, never a silent revival). + +## Budgets (provisional) and the coverage decision + +| Budget | Value | +|---|---| +| Snapshot copy ceiling / archive expansion | 128 MiB / 128 MiB | +| Model XML (all `.model` parts) | 32 MiB | +| Rendered triangles (every repetition counted) / distinct parsed triangles | 250,000 / 250,000 | +| Distinct vertices | 300,000 | +| Nodes / depth | 2,000 / 64 | +| Whole serialized response | 8 MiB (exact bytes; geometry pre-check refuses before encoding) | +| Ids | 128 characters, by construction (a long ancestry is replaced by a deterministic digest) | +| Part names carried on nodes / `` definitions / XML elements | 256 characters / 20,000 / 1,000,000, all counted while parsing | +| Findings / limitations | 500 / 50 | + +Exceeding any of these is `LIMIT_EXCEEDED`, never a shortened scene. + +### Corpus and results + +Qualifications: the four real-world fixtures are upstream files fetched by `fetch_real_world.py` and are absent from +this worktree (the benchmark read them from the main checkout); all five local samples are STL files (89% of the 11,604 local .stl/.3mf files are STL), so they exercise the STL path and say little about 3MF project structure; they +were chosen by size quantile, not at random; the fixture +called "100k" is 99,458 triangles; timings are one machine, one run. + +Machine: Windows 11, Intel64 Family 6 Model 183 (32 logical CPUs), 63.8 GiB RAM, Python 3.13.14. Numbers are one +run on a quiet machine; warm = best of 3. Corpus: authored synthetic fixtures, the repo examples, the four +real-world fixtures (fetched by `fetch_real_world.py`, not committed) and five local projects chosen by size +quantile and recorded **only** as `P-01..P-05` (names and contents are never written). All 19 repo examples +fit and are summarized in `results.json`; four representative ones are shown here. + +| id | class | size | result | triangles (rendered) | nodes | response | parse+encode warm | tracemalloc peak | RSS delta | +|---|---|---|---|---|---|---|---|---|---| +| S-cube | synthetic | 1.5 KiB | ok (complete) | 12 (12) | 1 | 2.0 KiB | 0.001 s | 0.1 MiB | 0.1 MiB | +| S-bambu-3parts | synthetic | 4.7 KiB | ok (complete) | 36 (36) | 4 | 5.0 KiB | 0.001 s | 0.1 MiB | 0.0 MiB | +| S-prusa-volumes | synthetic | 2.6 KiB | ok (complete) | 24 (24) | 1 | 2.4 KiB | 0.001 s | 0.1 MiB | 0.0 MiB | +| S-stl | synthetic | 0.7 KiB | ok (partial) | 12 (12) | 1 | 2.0 KiB | 0.001 s | 0.1 MiB | 0.0 MiB | +| S-grid-10k | synthetic | 603.3 KiB | ok (complete) | 9,800 (9,800) | 1 | 233.6 KiB | 0.036 s | 1.3 MiB | 0.1 MiB | +| S-grid-50k | synthetic | 3.11 MiB | ok (complete) | 49,928 (49,928) | 1 | 1.15 MiB | 0.176 s | 5.6 MiB | 3.8 MiB | +| S-grid-100k | synthetic | 6.22 MiB | ok (complete) | 99,458 (99,458) | 1 | 2.28 MiB | 0.405 s | 10.9 MiB | 3.5 MiB | +| S-instances-300 | synthetic | 19.9 KiB | ok (partial) | 12 (3,600) | 300 | 187.3 KiB | 0.008 s | 1.8 MiB | 0.0 MiB | +| S-over-budget | synthetic (deliberately over) | 17.83 MiB | **refused `LIMIT_EXCEEDED`** | 279,752 | - | 6.42 MiB if budgets raised | 1.15 s if raised | - | - | +| E-demo_offplate_foreign | repo example | 9.7 KiB | ok (complete) | 12 (12) | 2 | 2.9 KiB | 0.001 s | 0.1 MiB | 0.0 MiB | +| E-demo_u1_showcase | repo example | 1.7 KiB | ok (partial) | 12 (12) | 1 | 2.0 KiB | 0.001 s | 0.1 MiB | 0.0 MiB | +| E-sample_cube | repo example | 0.7 KiB | ok (partial) | 12 (12) | 1 | 2.0 KiB | 0.001 s | 0.1 MiB | 0.0 MiB | +| E-sample_cube_SnapmakerU1 | repo example | 9.7 KiB | ok (complete) | 12 (12) | 2 | 2.7 KiB | 0.001 s | 0.1 MiB | 0.0 MiB | +| R-bambu-pa-pattern | real-world | 22.5 KiB | ok (complete) | 12 (12) | 2 | 2.8 KiB | 0.003 s | 0.1 MiB | 0.0 MiB | +| R-orca-badge | real-world | 797.9 KiB | ok (complete) | 72,586 (72,586) | 42 | 1.70 MiB | 0.274 s | 8.2 MiB | 3.0 MiB | +| R-orca-pa-line-dual | real-world | 29.0 KiB | ok (complete) | 24 (192) | 24 | 18.5 KiB | 0.002 s | 0.2 MiB | 0.0 MiB | +| R-prusa-seam-test | real-world | 2.40 MiB | ok (complete) | 225,154 (225,154) | 1 | 5.15 MiB | 0.87 s | 25.0 MiB | 30.2 MiB | +| P-01 | local | 0.5 KiB | ok (partial) | 8 (8) | 1 | 1.9 KiB | 0.001 s | 0.1 MiB | 0.0 MiB | +| P-02 | local | 963.6 KiB | ok (partial) | 19,733 (19,733) | 1 | 464.3 KiB | 0.02 s | 4.5 MiB | 3.2 MiB | +| P-03 | local | 3.79 MiB | ok (partial) | 79,518 (79,518) | 1 | 1.82 MiB | 0.097 s | 18.4 MiB | 24.9 MiB | +| P-04 | local | 15.60 MiB | **refused `LIMIT_EXCEEDED`** | 327,141 | - | 7.49 MiB if budgets raised | 0.43 s if raised | - | - | +| P-05 | local | 1454.44 MiB | **refused `LIMIT_EXCEEDED`** (over the 128 MiB snapshot ceiling) | - | - | - | - | - | - | + +Coverage (fraction of the corpus that fits, excluding the one deliberately over-budget fixture): **0.944** +(repo examples 19/19, synthetic 8/8, real-world **4/4**, local **3/5**). A size-only survey of the 1,230 local `.3mf` +files under the maintainer's model folders found 126 (**10.2%**) larger than the 128 MiB snapshot ceiling (median 31.2 +MiB, p90 130.1 MiB); no names or paths are recorded. Some projects also exceed the triangle budget: the local `P-04` +has 327,141 triangles and `P-05` is over the ceiling (30.5 million triangles). + +### Budget decision (accepted) + +The first run, at the originally specified 100,000 triangles, fit only 3 of the 4 real-world fixtures: +`R-prusa-seam-test` has 225,154 triangles. Evidence: raised runs took about 1.2 s, and the response is 5.15 MiB. +The 8 MiB whole-body wire cap is the real wall, not time or memory. Decision: **`MAX_RENDERED_TRIANGLES` and +`MAX_PARSED_TRIANGLES` are now 250,000** (`scene_limits.py`, the only place they live); the 300,000 decoded-vertex +budget already covered the fixture (112,569 vertices) and is unchanged, as are the 8 MiB cap and every other gate. +Models over the budget stay refused with a message that names the count and the limit, for example +"The model has 327,141 triangles; Studio draws at most 250,000." (for a 3MF the count is "at least N", reported the +moment the budget is passed). `P-04` is such a model; its scene would be about 7.5 MiB. Chunked or binary mesh +transfer, or decimation, for larger models is a documented follow-up and is not part of this PR. The 128 MiB source +ceiling is unchanged; 10.2% of the surveyed local `.3mf` files exceed it and are refused. + +### Gates + +| Gate | Result | +|---|---| +| Whole-body response cap enforced on the exact bytes | pass (tests; largest measured scene 5.15 MiB) | +| Node / triangle / vertex / depth budgets enforced during traversal | pass (tests follow the constants) | +| Cancel observed within 2 s | pass: max 0.005 s over 5 cancels on the 100k fixture (and a test on a 90k scene) | +| 50 start/cancel cycles: retained <= cap, temp snapshots 0, threads at baseline | pass: retained 8 of 8, snapshots 0, threads 1 to 1 | +| `WORKER_WEDGED` never occurs in the 50-cycle run | pass (its fail-closed path is tested separately) | +| Repo examples fit | pass (19/19) | +| Four real-world fixtures fit | pass (4/4) after the budget decision above | +| 100k-triangle fixture: parse+encode <= 10 s, extra peak memory <= 500 MB | pass: 0.358 s, 11.0 MiB (tracemalloc) / 6.1 MiB (RSS) | +| Targets (not gates): <= 3 s and <= 300 MB on the 100k fixture | met | + +## Reproduce + +```text +cd backend +py -3.13 -m pip install -e ".[test]" +py -3.13 -m pytest -q tests/test_scene_contract.py tests/test_scene_build.py tests/test_scene_hostile.py \ + tests/test_scene_jobs.py tests/test_scene_api.py tests/test_units_consumers.py +py -3.13 tools/scene_benchmark.py --out ../docs/testing/scene-contract/results.json \ + --real-world tests/fixtures/real-world [--local-root ...] +``` + +## Known limitations (carry into the PR description) + +1. **Size-only consumers say so (issue #91, addressed by PR #104).** DesignHealth, `bed_fit`, `validation_report` and + the service bed-fit path judge each object's SIZE on its own and say "By size"; where the instances sit is + reported separately ("By placement"). Several small, separated objects are no longer summed into one "too big" + object, and an object whose size cannot be measured stays unknown. The scene's placement findings remain the + viewer's placement source. +2. **Repeated build items of one object id (issue #93, addressed by PR #102).** `plate_placement.assess` now judges + every build item on its own, keyed by build item and part. The scene still reports + `REPEATED_INSTANCE_PLACEMENT_UNVERIFIED` and attaches no engine finding to those nodes; enabling them is a + separate change. +3. **Plates.** Non-Bambu projects (Prusa, plain 3MF, STL) have no plate records (`PLATES_UNAVAILABLE`). Grid origins + of multi-plate projects are unknown, so multi-plate projects get no placement findings. +4. **Stuck native calls** cannot be interrupted in-process (fail-closed `WORKER_WEDGED` backstop, no multiprocessing). +5. **STL** is decoded incrementally: a binary header count over the budget is refused before any triangle is + decoded, decoding runs in batches of 5,000 triangles with the cancel hook between them, and ASCII is line-streamed + with a running counter. +6. **Budgets are provisional.** Models over 250,000 triangles (or over the 128 MiB source ceiling) are refused with `LIMIT_EXCEEDED`; chunked/binary transfer or decimation is a follow-up. +7. `jsonschema` is a test-only extra (`pip install -e ".[test]"`); the engine never imports it. + +## Deviations from the written plan + +* **Node ids.** `b.c.c...` does not fit 128 characters at 64 levels, so beyond 128 the ancestry is + replaced by a deterministic digest (`b3.~<32 hex>`); the 128-character contract is kept, not widened. The digest + (128 bits of SHA-256) gives practical collision resistance, not mathematical uniqueness; the builder asserts + uniqueness while building and fails with `INTERNAL` if two node ids ever collide. +* **Freeze order.** The JSON Schema was frozen first, but the golden scene and the fixture matrix were authored + after the first draft of the traversal, not before it. The golden is written independently of the builder and + the builder is held to it. +* **Size findings** are only emitted for nodes whose placement is known (plan v2: no bed-relative statement for + unknown placement), single-plate, template bed, non-repeated instances. +* **No `request_validation.py` change was needed**; the route validation reuses its existing helpers from + `scene_jobs.py`. + +## What was not run + +Linux (the POSIX branch of the pid-liveness check is exercised only by monkeypatch; the Linux CI job), the frozen sidecar, the packaged app, any +viewer, a real printer and any network. diff --git a/docs/testing/scene-contract/results.json b/docs/testing/scene-contract/results.json new file mode 100644 index 00000000..9629d874 --- /dev/null +++ b/docs/testing/scene-contract/results.json @@ -0,0 +1,915 @@ +{ + "schema": "scene-benchmark/1", + "machine": { + "platform": "Windows-11-10.0.26200-SP0", + "python": "3.13.14", + "cpu_count": 32, + "processor": "Intel64 Family 6 Model 183 Stepping 1, GenuineIntel", + "ram_gib": 63.8 + }, + "budgets": { + "max_archive_bytes": 134217728, + "max_model_xml_bytes": 33554432, + "max_rendered_triangles": 250000, + "max_vertices": 300000, + "max_nodes": 2000, + "max_depth": 64, + "max_response_bytes": 8388608, + "max_findings": 500, + "max_limitations": 50, + "max_id_length": 128 + }, + "snapshot_ceiling_bytes": 134217728, + "items": [ + { + "bytes": 1553, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 1, + "meshes": 1, + "response_bytes": 2027, + "limitations": [ + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.005, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.1, + "id": "S-cube", + "class": "synthetic" + }, + { + "bytes": 4778, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 36, + "rendered_triangles": 36, + "vertices": 24, + "nodes": 4, + "meshes": 3, + "response_bytes": 5084, + "limitations": [], + "cold_s": 0.008, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "S-bambu-3parts", + "class": "synthetic" + }, + { + "bytes": 2690, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 24, + "rendered_triangles": 24, + "vertices": 16, + "nodes": 1, + "meshes": 1, + "response_bytes": 2454, + "limitations": [ + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "S-prusa-volumes", + "class": "synthetic" + }, + { + "bytes": 684, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "partial", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 1, + "meshes": 1, + "response_bytes": 2049, + "limitations": [ + "UNKNOWN_PLACEMENT", + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.008, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "S-stl", + "class": "synthetic" + }, + { + "bytes": 617746, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 9800, + "rendered_triangles": 9800, + "vertices": 5041, + "nodes": 1, + "meshes": 1, + "response_bytes": 239176, + "limitations": [ + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.046, + "warm_best_s": 0.036, + "warm_median_s": 0.037, + "tracemalloc_peak_mib": 1.3, + "rss_delta_mib": 0.1, + "id": "S-grid-10k", + "class": "synthetic" + }, + { + "bytes": 3256096, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 49928, + "rendered_triangles": 49928, + "vertices": 25281, + "nodes": 1, + "meshes": 1, + "response_bytes": 1205070, + "limitations": [ + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.215, + "warm_best_s": 0.176, + "warm_median_s": 0.178, + "tracemalloc_peak_mib": 5.6, + "rss_delta_mib": 3.8, + "id": "S-grid-50k", + "class": "synthetic" + }, + { + "bytes": 6523679, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 99458, + "rendered_triangles": 99458, + "vertices": 50176, + "nodes": 1, + "meshes": 1, + "response_bytes": 2395870, + "limitations": [ + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.59, + "warm_best_s": 0.405, + "warm_median_s": 0.411, + "tracemalloc_peak_mib": 10.9, + "rss_delta_mib": 3.5, + "id": "S-grid-100k", + "class": "synthetic" + }, + { + "bytes": 20350, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "partial", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 3600, + "vertices": 8, + "nodes": 300, + "meshes": 1, + "response_bytes": 191745, + "limitations": [ + "PLATES_UNAVAILABLE", + "REPEATED_INSTANCE_PLACEMENT_UNVERIFIED" + ], + "cold_s": 0.019, + "warm_best_s": 0.008, + "warm_median_s": 0.011, + "tracemalloc_peak_mib": 1.8, + "rss_delta_mib": 0.0, + "id": "S-instances-300", + "class": "synthetic" + }, + { + "bytes": 18696925, + "job_gate": "ok", + "result": "refused", + "code": "LIMIT_EXCEEDED", + "message": "The project has at least 250,001 triangles; Studio draws at most 250,000.", + "fits_budgets": false, + "lifted": { + "triangles": 279752, + "rendered_triangles": 279752, + "vertices": 140625, + "nodes": 1, + "response_bytes": 6727764, + "time_s": 1.15 + }, + "id": "S-over-budget", + "class": "synthetic-over-budget" + }, + { + "bytes": 9899, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2974, + "limitations": [], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.002, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-demo_offplate_foreign", + "class": "repo-example" + }, + { + "bytes": 1790, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "partial", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 1, + "meshes": 1, + "response_bytes": 2086, + "limitations": [ + "PLATE_MEMBERSHIP_UNKNOWN" + ], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.002, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-demo_u1_showcase", + "class": "repo-example" + }, + { + "bytes": 684, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "partial", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 1, + "meshes": 1, + "response_bytes": 2049, + "limitations": [ + "UNKNOWN_PLACEMENT", + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.001, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube", + "class": "repo-example" + }, + { + "bytes": 9667, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.002, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_foreign", + "class": "repo-example" + }, + { + "bytes": 10157, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.001, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_foreign_SnapmakerU1", + "class": "repo-example" + }, + { + "bytes": 9951, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_scaled_100_U1", + "class": "repo-example" + }, + { + "bytes": 9950, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_SnapmakerU1", + "class": "repo-example" + }, + { + "bytes": 10155, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_SnapmakerU1_SnapmakerU1", + "class": "repo-example" + }, + { + "bytes": 9951, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.001, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.001, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1_SnapmakerU1", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1_SnapmakerU1_10", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.002, + "warm_best_s": 0.001, + "warm_median_s": 0.002, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1_SnapmakerU1_2", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.003, + "warm_best_s": 0.003, + "warm_median_s": 0.004, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1_SnapmakerU1_3", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.003, + "warm_best_s": 0.003, + "warm_median_s": 0.003, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1_SnapmakerU1_4", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.003, + "warm_best_s": 0.003, + "warm_median_s": 0.003, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1_SnapmakerU1_5", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.003, + "warm_best_s": 0.003, + "warm_median_s": 0.003, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.1, + "id": "E-sample_cube_U1_SnapmakerU1_6", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.003, + "warm_best_s": 0.003, + "warm_median_s": 0.003, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1_SnapmakerU1_7", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.003, + "warm_best_s": 0.003, + "warm_median_s": 0.003, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "E-sample_cube_U1_SnapmakerU1_8", + "class": "repo-example" + }, + { + "bytes": 10156, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2740, + "limitations": [], + "cold_s": 0.004, + "warm_best_s": 0.003, + "warm_median_s": 0.003, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.1, + "id": "E-sample_cube_U1_SnapmakerU1_9", + "class": "repo-example" + }, + { + "bytes": 23059, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 12, + "rendered_triangles": 12, + "vertices": 8, + "nodes": 2, + "meshes": 1, + "response_bytes": 2875, + "limitations": [], + "cold_s": 0.003, + "warm_best_s": 0.003, + "warm_median_s": 0.003, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "R-bambu-pa-pattern", + "class": "real-world" + }, + { + "bytes": 817091, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 72586, + "rendered_triangles": 72586, + "vertices": 36353, + "nodes": 42, + "meshes": 39, + "response_bytes": 1786167, + "limitations": [], + "cold_s": 0.318, + "warm_best_s": 0.274, + "warm_median_s": 0.277, + "tracemalloc_peak_mib": 8.2, + "rss_delta_mib": 3.0, + "id": "R-orca-badge", + "class": "real-world" + }, + { + "bytes": 29649, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 24, + "rendered_triangles": 192, + "vertices": 16, + "nodes": 24, + "meshes": 2, + "response_bytes": 18971, + "limitations": [], + "cold_s": 0.003, + "warm_best_s": 0.002, + "warm_median_s": 0.003, + "tracemalloc_peak_mib": 0.2, + "rss_delta_mib": 0.0, + "id": "R-orca-pa-line-dual", + "class": "real-world" + }, + { + "bytes": 2514963, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "complete", + "fits_budgets": true, + "triangles": 225154, + "rendered_triangles": 225154, + "vertices": 112569, + "nodes": 1, + "meshes": 1, + "response_bytes": 5405324, + "limitations": [ + "PLATES_UNAVAILABLE" + ], + "cold_s": 1.037, + "warm_best_s": 0.87, + "warm_median_s": 0.875, + "tracemalloc_peak_mib": 25.0, + "rss_delta_mib": 30.2, + "id": "R-prusa-seam-test", + "class": "real-world" + }, + { + "bytes": 484, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "partial", + "fits_budgets": true, + "triangles": 8, + "rendered_triangles": 8, + "vertices": 6, + "nodes": 1, + "meshes": 1, + "response_bytes": 1988, + "limitations": [ + "UNKNOWN_PLACEMENT", + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.001, + "warm_best_s": 0.001, + "warm_median_s": 0.001, + "tracemalloc_peak_mib": 0.1, + "rss_delta_mib": 0.0, + "id": "P-01", + "class": "local-anonymized" + }, + { + "bytes": 986734, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "partial", + "fits_budgets": true, + "triangles": 19733, + "rendered_triangles": 19733, + "vertices": 9869, + "nodes": 1, + "meshes": 1, + "response_bytes": 475424, + "limitations": [ + "UNKNOWN_PLACEMENT", + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.021, + "warm_best_s": 0.02, + "warm_median_s": 0.021, + "tracemalloc_peak_mib": 4.5, + "rss_delta_mib": 3.2, + "id": "P-02", + "class": "local-anonymized" + }, + { + "bytes": 3975984, + "job_gate": "ok", + "result": "ok", + "code": null, + "status": "partial", + "fits_budgets": true, + "triangles": 79518, + "rendered_triangles": 79518, + "vertices": 39546, + "nodes": 1, + "meshes": 1, + "response_bytes": 1906815, + "limitations": [ + "UNKNOWN_PLACEMENT", + "PLATES_UNAVAILABLE" + ], + "cold_s": 0.097, + "warm_best_s": 0.097, + "warm_median_s": 0.097, + "tracemalloc_peak_mib": 18.4, + "rss_delta_mib": 24.9, + "id": "P-03", + "class": "local-anonymized" + }, + { + "bytes": 16357134, + "job_gate": "ok", + "result": "refused", + "code": "LIMIT_EXCEEDED", + "message": "The model has 327,141 triangles; Studio draws at most 250,000.", + "fits_budgets": false, + "lifted": { + "triangles": 327141, + "rendered_triangles": 327141, + "vertices": 163521, + "nodes": 1, + "response_bytes": 7852385, + "time_s": 0.43 + }, + "id": "P-04", + "class": "local-anonymized" + }, + { + "bytes": 1525086434, + "job_gate": "SNAPSHOT_CEILING", + "result": "refused", + "code": "LIMIT_EXCEEDED", + "message": "The model has 30,501,727 triangles; Studio draws at most 250,000.", + "fits_budgets": false, + "lifted": { + "still_refused": "LIMIT_EXCEEDED", + "message": "The model has 30,501,727 triangles; Studio draws at most 2,000,000." + }, + "id": "P-05", + "class": "local-anonymized" + } + ], + "coverage": { + "by_class": { + "local-anonymized": { + "items": 5, + "fit": 3 + }, + "real-world": { + "items": 4, + "fit": 4 + }, + "repo-example": { + "items": 19, + "fit": 19 + }, + "synthetic": { + "items": 8, + "fit": 8 + }, + "synthetic-over-budget": { + "items": 1, + "fit": 0 + } + }, + "fraction_fitting_excluding_deliberate_over_budget": 0.944 + }, + "local_survey_3mf_sizes_only": { + "files": 1230, + "over_snapshot_ceiling": 126, + "fraction_within_ceiling": 0.898, + "median_mib": 31.2, + "p90_mib": 130.1 + }, + "gates": { + "fixture_100k_triangles": { + "parse_encode_warm_s": 0.358, + "cold_s": 0.417, + "extra_peak_mib_tracemalloc": 11.0, + "extra_peak_mib_rss": 6.1, + "response_bytes": 2395870, + "gate_time_le_10s": true, + "gate_mem_le_500mib": true, + "target_time_le_3s": true, + "target_mem_le_300mib": true, + "gate_response_le_8mib": true + }, + "cancel_latency_s": { + "max": 0.005, + "median": 0.002, + "gate_le_2s": true + }, + "leak_50_cycles": { + "retained_jobs": 8, + "cap": 8, + "temp_snapshots": 0, + "threads_before": 1, + "threads_after": 1, + "worker_wedged_seen": false, + "gate_pass": true + } + } +} diff --git a/docs/testing/source-backed-guidance/README.md b/docs/testing/source-backed-guidance/README.md new file mode 100644 index 00000000..225a0155 --- /dev/null +++ b/docs/testing/source-backed-guidance/README.md @@ -0,0 +1,28 @@ +# Source-backed guidance: screenshots + +These screenshots show the "Adjusted for U1 project compatibility" notes in the Prepare summary after +this change. Each changed setting now carries a short plain-language reason and a label for where the +statement comes from: + +- **Studio's check** - Studio did or checked this itself (for example a value outside its valid range was + replaced with the U1 default). +- **Verify in Snapmaker Orca** - advice to check the setting in Snapmaker Orca. It makes no claim about how + Snapmaker Orca behaves. + +Files: `prepare-notes-top-*`, `prepare-notes-bottom-*` and `prepare-compat-section-full-*`, each in light and +dark themes. The project and settings are a synthetic local fixture; no real printer, address or model name +appears. + +## How they were captured + +Edge via Playwright against the local Vite dev server, with web fonts and off-machine hosts blocked. +The capture script reported no violations and no console errors (React SVG attribute warnings from the +sidebar are unrelated and ignored). + +## Not verified + +- The packaged Tauri window on Windows and Linux WebKitGTK; only Edge was used. +- Screen-reader output. +- Snapmaker Orca behavior. The labels never assert it. +- One older sentence outside this change (`preset_deviation.py`: "declared to Snapmaker Orca, which resets an + undeclared value...") still describes Orca behavior. It is unchanged here and left for a separate review. diff --git a/docs/testing/source-backed-guidance/prepare-compat-section-full-dark.png b/docs/testing/source-backed-guidance/prepare-compat-section-full-dark.png new file mode 100644 index 00000000..3cf629f5 Binary files /dev/null and b/docs/testing/source-backed-guidance/prepare-compat-section-full-dark.png differ diff --git a/docs/testing/source-backed-guidance/prepare-compat-section-full-light.png b/docs/testing/source-backed-guidance/prepare-compat-section-full-light.png new file mode 100644 index 00000000..bbdc9305 Binary files /dev/null and b/docs/testing/source-backed-guidance/prepare-compat-section-full-light.png differ diff --git a/docs/testing/source-backed-guidance/prepare-notes-bottom-dark.png b/docs/testing/source-backed-guidance/prepare-notes-bottom-dark.png new file mode 100644 index 00000000..5bbf613a Binary files /dev/null and b/docs/testing/source-backed-guidance/prepare-notes-bottom-dark.png differ diff --git a/docs/testing/source-backed-guidance/prepare-notes-bottom-light.png b/docs/testing/source-backed-guidance/prepare-notes-bottom-light.png new file mode 100644 index 00000000..1a0e29ea Binary files /dev/null and b/docs/testing/source-backed-guidance/prepare-notes-bottom-light.png differ diff --git a/docs/testing/source-backed-guidance/prepare-notes-top-dark.png b/docs/testing/source-backed-guidance/prepare-notes-top-dark.png new file mode 100644 index 00000000..fd06801f Binary files /dev/null and b/docs/testing/source-backed-guidance/prepare-notes-top-dark.png differ diff --git a/docs/testing/source-backed-guidance/prepare-notes-top-light.png b/docs/testing/source-backed-guidance/prepare-notes-top-light.png new file mode 100644 index 00000000..4656348c Binary files /dev/null and b/docs/testing/source-backed-guidance/prepare-notes-top-light.png differ diff --git a/docs/testing/tool-tabs/01-focus-ring-dark.png b/docs/testing/tool-tabs/01-focus-ring-dark.png new file mode 100644 index 00000000..46cf085b Binary files /dev/null and b/docs/testing/tool-tabs/01-focus-ring-dark.png differ diff --git a/docs/testing/tool-tabs/01-focus-ring-light.png b/docs/testing/tool-tabs/01-focus-ring-light.png new file mode 100644 index 00000000..328b5ed8 Binary files /dev/null and b/docs/testing/tool-tabs/01-focus-ring-light.png differ diff --git a/docs/testing/tool-tabs/02-compatibility-dark.png b/docs/testing/tool-tabs/02-compatibility-dark.png new file mode 100644 index 00000000..2b47418c Binary files /dev/null and b/docs/testing/tool-tabs/02-compatibility-dark.png differ diff --git a/docs/testing/tool-tabs/02-compatibility-light.png b/docs/testing/tool-tabs/02-compatibility-light.png new file mode 100644 index 00000000..a45fb87d Binary files /dev/null and b/docs/testing/tool-tabs/02-compatibility-light.png differ diff --git a/docs/testing/tool-tabs/03-compatibility-600px.png b/docs/testing/tool-tabs/03-compatibility-600px.png new file mode 100644 index 00000000..f384c291 Binary files /dev/null and b/docs/testing/tool-tabs/03-compatibility-600px.png differ diff --git a/docs/testing/tool-tabs/03-print-quality-600px.png b/docs/testing/tool-tabs/03-print-quality-600px.png new file mode 100644 index 00000000..b8b42a21 Binary files /dev/null and b/docs/testing/tool-tabs/03-print-quality-600px.png differ diff --git a/docs/testing/tool-tabs/README.md b/docs/testing/tool-tabs/README.md new file mode 100644 index 00000000..54b8ee3e --- /dev/null +++ b/docs/testing/tool-tabs/README.md @@ -0,0 +1,78 @@ +# Tool tabs: keyboard and focus verification + +Real-browser check of the two tab rows, **Compatibility / Source Check** and **Print Quality / First Layer**, operated +**only from the keyboard**, plus focus-ring, 600 px layout and network-isolation checks. Script: +`tools/acceptance/tool-tabs-keyboard.mjs` (Windows; Edge/Chromium; run with `--out `). + +**46/46 checks passed.** + +The run used the uncommitted change on top of the base commit. `results.json` records `baseCommit`, `worktreeDirty`, and a +`diffHash` (SHA-256 of `git diff HEAD` plus the untracked files, excluding this folder). + +## What is real and what is not + +- **Real:** the Studio engine and the web UI from this branch (served by the Vite dev server, not a production build), + running locally, in Microsoft Edge (Chromium, headless). +- **This is Edge.** It is not the Tauri window, not WebKitGTK (the Linux window), and not a screen reader. Focus order + and ring drawing can differ in those; how a screen reader announces the tab row was not tested. +- **No data:** no project is opened and no printer, provider or spool service is involved. The run uses the pages as + they first appear. +- **Browser network guard:** requests to Google Fonts are aborted and recorded but do not fail the run (see the next + item). Every other browser request or WebSocket to a host other than this machine is aborted and fails the run, as does + any engine request that names a printer or network discovery. Service workers are blocked. WebRTC and browser-internal + traffic cannot be intercepted from the script. +- **Not covered:** the spawned engine process makes its own network calls. This run neither constrains nor observes them, + so it does not show that the engine stays on this machine. +- **One known, blocked request:** the app's stylesheet asks Google Fonts for its Inter typeface (`desktop/src/index.css`, + line 1). That behavior is older than this change. The run blocks it, so the screenshots use the fallback typeface, and + `results.json` lists the hosts in `knownBlockedFontHosts`. It is not fixed here. +- **Ignored console messages:** `Failed to load resource` and `net::ERR` come from the blocked font request. React + `Invalid DOM property` warnings (`stroke-width`, `stroke-linecap`) come from the existing sidebar icons, not from the tab + row. `results.json` lists them in `ignoredConsoleMessages`. +- **Not run:** WebKitGTK (`tools/acceptance/linux`) and the packaged Windows window. + +## How the tabs behave + +- Only one tab is in the Tab order. Focus entering the row always lands on the selected tab: when focus leaves the row, + the tab stop resets to the selected tab, so a tab you only arrowed to is not remembered. +- Left and Right Arrow move focus, wrapping at the ends. Home and End jump to the first and last tab. Up and Down Arrow + are not handled by the tab row (the page scrolls as usual); covered by the unit tests, not by this run. +- Arrow keys, Home and End **only move focus**. Enter, Space or a click selects the focused tab and shows its panel. + Showing a panel reloads that tool, so moving focus along the row deliberately does not switch panels. +- Alt, Ctrl and Meta combinations (for example Alt+Left, Ctrl+Home) are left to the browser. Unit tests cover this. +- The tab row has an accessible name ("Compatibility tools", "Print quality tools"). The panel is labeled by its tab. + +## Per case (both pages unless noted) + +The table groups the 46 checks by behavior; `results.json` lists each one. + +| Result | Case | +|---|---| +| PASS | The tab row has its name | +| PASS | Exactly one tab is in the Tab order, and it is the selected one | +| PASS | Tab, pressed 120 times through the page, lands on the selected tab and never on the other | +| PASS | ArrowRight moves focus to the second tab without switching the panel | +| PASS | The tab stop moves with focus | +| PASS | After arrowing to the other tab and leaving the row, Shift+Tab from the first focusable control in the panel lands on the selected tab | +| PASS | ArrowRight and ArrowLeft wrap; Home and End jump to the ends | +| PASS | Home, End and the arrows never switched the panel | +| PASS | Enter switches the panel and its heading appears; focus stays on the tab | +| PASS | The panel is labeled by the selected tab | +| PASS | Space switches back and the first heading appears | +| PASS | Focus ring visible on the keyboard-focused tab, dark and light (Compatibility page only) | +| PASS | At 600 px wide: no horizontal page scroll, labels not truncated and inside the viewport (both pages) | +| PASS | No request reached another host (the Google Fonts request was blocked, see above), a printer, or discovery | +| PASS | No console errors other than the blocked-request failures (`Failed to load resource`, `net::ERR`) and React `Invalid DOM property` warnings, which the run ignores | + +The first focusable control inside each panel is named in `results.json`: "Open a 3MF project" (Compatibility) and +"Stringing / wisps" (Print Quality). + +Evidence: [results.json](results.json), [focus ring, dark](01-focus-ring-dark.png), +[focus ring, light](01-focus-ring-light.png), [Compatibility, dark](02-compatibility-dark.png), +[Compatibility, light](02-compatibility-light.png), [Compatibility at 600 px](03-compatibility-600px.png), +[Print Quality at 600 px](03-print-quality-600px.png). + +## Unit tests + +`desktop/src/components/ui/ToolTabs.test.tsx` and `desktop/src/routes/ToolTabsHubs.test.tsx` cover the same behavior in +jsdom, plus a tab that disappears while selected or focused, modifier keys, and an empty tab list. diff --git a/docs/testing/tool-tabs/results.json b/docs/testing/tool-tabs/results.json new file mode 100644 index 00000000..33617fc4 --- /dev/null +++ b/docs/testing/tool-tabs/results.json @@ -0,0 +1,250 @@ +{ + "browser": "Microsoft Edge (Chromium), headless", + "uiStartedByThisRun": true, + "uiUrl": "http://localhost:55063", + "baseCommit": "b01f7d049dc04c32df36489cf6ad5bd27d5c6b7e", + "worktreeDirty": true, + "diffHash": "7c46b2f8900f549c76b1b79eb04e2671c85cff9ddb7a94f6c63cff86164ef9ab", + "note": "Run on the uncommitted change on top of baseCommit; diffHash covers git diff HEAD plus untracked files (excluding this folder).", + "backendNetworkNotObserved": true, + "knownBlockedFontHosts": [ + "fonts.googleapis.com" + ], + "ignoredConsoleMessages": [ + "Failed to load resource: net::ERR_FAILED", + "Warning: Invalid DOM property `%s`. Did you mean `%s`?%s stroke-width strokeWidth \n at g\n at svg\n at div\n at div\n at aside\n at Sidebar (http://localhost:55063/src/components/shell/Si", + "Warning: Invalid DOM property `%s`. Did you mean `%s`?%s stroke-linecap strokeLinecap \n at g\n at svg\n at div\n at div\n at aside\n at Sidebar (http://localhost:55063/src/components/shel" + ], + "results": [ + { + "name": "compatibility: the tab row is named \"Compatibility tools\"", + "ok": true, + "detail": "" + }, + { + "name": "compatibility: exactly one tab is in the Tab order and it is the selected one", + "ok": true, + "detail": "[\"Compatibility\"]" + }, + { + "name": "compatibility: Tab, pressed 120 times through the page, lands on the selected tab and never on the other", + "ok": true, + "detail": "tabs reached: Compatibility" + }, + { + "name": "compatibility: ArrowRight moves focus to \"Source Check\"", + "ok": true, + "detail": "Source Check" + }, + { + "name": "compatibility: ArrowRight did not switch the panel", + "ok": true, + "detail": "" + }, + { + "name": "compatibility: the tab stop moved with focus", + "ok": true, + "detail": "[\"Source Check\"]" + }, + { + "name": "compatibility: the panel has a first focusable control", + "ok": true, + "detail": "Open a 3MF project" + }, + { + "name": "compatibility: after arrowing to \"Source Check\" and leaving the row, the tab stop is back on the selected tab \"Compatibility\"", + "ok": true, + "detail": "[\"Compatibility\"]" + }, + { + "name": "compatibility: Shift+Tab from the first focusable control in the panel (\"Open a 3MF project\") lands on the selected tab (\"Compatibility\"), not the one that was arrowed to", + "ok": true, + "detail": "Compatibility" + }, + { + "name": "compatibility: ArrowLeft wraps from the first tab to the last", + "ok": true, + "detail": "Source Check" + }, + { + "name": "compatibility: ArrowRight wraps from the last tab to the first", + "ok": true, + "detail": "Compatibility" + }, + { + "name": "compatibility: Home moves focus to the first tab", + "ok": true, + "detail": "Compatibility" + }, + { + "name": "compatibility: End moves focus to the last tab", + "ok": true, + "detail": "Source Check" + }, + { + "name": "compatibility: Home, End and the arrow keys never switched the panel", + "ok": true, + "detail": "" + }, + { + "name": "compatibility: Enter on \"Source Check\" switches the panel and its heading \"Source Check\" appears", + "ok": true, + "detail": "" + }, + { + "name": "compatibility: focus stays on the tab after Enter", + "ok": true, + "detail": "Source Check" + }, + { + "name": "compatibility: the panel is labeled by the selected tab", + "ok": true, + "detail": "" + }, + { + "name": "compatibility: Space on \"Compatibility\" switches back and its heading \"Compatibility Doctor\" appears", + "ok": true, + "detail": "" + }, + { + "name": "print-quality: the tab row is named \"Print quality tools\"", + "ok": true, + "detail": "" + }, + { + "name": "print-quality: exactly one tab is in the Tab order and it is the selected one", + "ok": true, + "detail": "[\"Print Quality\"]" + }, + { + "name": "print-quality: Tab, pressed 120 times through the page, lands on the selected tab and never on the other", + "ok": true, + "detail": "tabs reached: Print Quality" + }, + { + "name": "print-quality: ArrowRight moves focus to \"First Layer\"", + "ok": true, + "detail": "First Layer" + }, + { + "name": "print-quality: ArrowRight did not switch the panel", + "ok": true, + "detail": "" + }, + { + "name": "print-quality: the tab stop moved with focus", + "ok": true, + "detail": "[\"First Layer\"]" + }, + { + "name": "print-quality: the panel has a first focusable control", + "ok": true, + "detail": "Stringing / wisps" + }, + { + "name": "print-quality: after arrowing to \"First Layer\" and leaving the row, the tab stop is back on the selected tab \"Print Quality\"", + "ok": true, + "detail": "[\"Print Quality\"]" + }, + { + "name": "print-quality: Shift+Tab from the first focusable control in the panel (\"Stringing / wisps\") lands on the selected tab (\"Print Quality\"), not the one that was arrowed to", + "ok": true, + "detail": "Print Quality" + }, + { + "name": "print-quality: ArrowLeft wraps from the first tab to the last", + "ok": true, + "detail": "First Layer" + }, + { + "name": "print-quality: ArrowRight wraps from the last tab to the first", + "ok": true, + "detail": "Print Quality" + }, + { + "name": "print-quality: Home moves focus to the first tab", + "ok": true, + "detail": "Print Quality" + }, + { + "name": "print-quality: End moves focus to the last tab", + "ok": true, + "detail": "First Layer" + }, + { + "name": "print-quality: Home, End and the arrow keys never switched the panel", + "ok": true, + "detail": "" + }, + { + "name": "print-quality: Enter on \"First Layer\" switches the panel and its heading \"First Layer Doctor\" appears", + "ok": true, + "detail": "" + }, + { + "name": "print-quality: focus stays on the tab after Enter", + "ok": true, + "detail": "First Layer" + }, + { + "name": "print-quality: the panel is labeled by the selected tab", + "ok": true, + "detail": "" + }, + { + "name": "print-quality: Space on \"Print Quality\" switches back and its heading \"Print Quality Doctor\" appears", + "ok": true, + "detail": "" + }, + { + "name": "focus ring (dark): the keyboard-focused tab shows a visible ring", + "ok": true, + "detail": "{\"box\":\"rgb(15, 17, 26) 0px 0px 0px 2px, rgb(0, 225, 255) 0px 0px 0px 4px, rgba(0, 0, 0, 0) 0px 0px 0px 0px\",\"outline\":\"solid\",\"focusVisible\":true}" + }, + { + "name": "focus ring (light): the keyboard-focused tab shows a visible ring", + "ok": true, + "detail": "{\"box\":\"rgb(255, 255, 255) 0px 0px 0px 2px, rgb(0, 149, 230) 0px 0px 0px 4px, rgba(0, 0, 0, 0) 0px 0px 0px 0px\",\"outline\":\"solid\",\"focusVisible\":true}" + }, + { + "name": "compatibility at 600 px: no horizontal page scroll", + "ok": true, + "detail": "{\"page\":0,\"body\":0}" + }, + { + "name": "compatibility at 600 px: tab labels are not truncated and sit inside the viewport", + "ok": true, + "detail": "{\"pageOverflow\":0,\"bodyOverflow\":0,\"truncated\":[],\"outside\":[],\"heights\":[38,38],\"barRight\":568,\"width\":600}" + }, + { + "name": "compatibility at 600 px: both labels are present in full", + "ok": true, + "detail": "Compatibility | Source Check" + }, + { + "name": "print-quality at 600 px: no horizontal page scroll", + "ok": true, + "detail": "{\"page\":0,\"body\":0}" + }, + { + "name": "print-quality at 600 px: tab labels are not truncated and sit inside the viewport", + "ok": true, + "detail": "{\"pageOverflow\":0,\"bodyOverflow\":0,\"truncated\":[],\"outside\":[],\"heights\":[38,38],\"barRight\":568,\"width\":600}" + }, + { + "name": "print-quality at 600 px: both labels are present in full", + "ok": true, + "detail": "Print Quality | First Layer" + }, + { + "name": "no request reached another host, a printer, or discovery", + "ok": true, + "detail": "" + }, + { + "name": "no unexpected console errors", + "ok": true, + "detail": "" + } + ] +} \ No newline at end of file diff --git a/site/guide/EVIDENCE.md b/site/guide/EVIDENCE.md index 3b3eddca..77e6995c 100644 --- a/site/guide/EVIDENCE.md +++ b/site/guide/EVIDENCE.md @@ -1,7 +1,9 @@ # Evidence record — what each page rests on -The guide describes **Snapmaker Studio v1.5.0** (tag `v1.5.0`; the interface in `main` at the time of writing is the -same, apart from the Help / Get Started links added with this guide). Every button label in the lessons was read from the +The `orca` evidence kind is advice to verify; it makes no claim about Snapmaker Orca behavior. + +The guide describes **Snapmaker Studio v1.5.1** (tag `v1.5.1`). Its screenshots were captured from the installed v1.5.0 +release and have not been recaptured; the interface changed in v1.5.1 where the release notes say so. Every button label in the lessons was read from the running interface or from the source that renders it. "UI" below means the label or behaviour was observed in the running app during capture (`tools/capture.mjs`, a real Studio engine with disposable data and example files); "Source" means it was read from the named file because it cannot be reached without a printer or the installed shell. @@ -123,7 +125,7 @@ All English text was reviewed in two independent passes, then reread after the c 3. Unresolved plural placeholders: "1 object(s) moved…", "Found 5 invalid-value issue(s) and 2 warning(s).", "1 thing(s) are worth settling…". 4. "Moving the objects as one piece would not bring them all on…" lacks a destination ("onto the plate"). 5. "Upload sliced gcode" should read "G-code" like the rest of the app. -6. Cost Doctor says "true cost to make" for an estimate built on user assumptions. +6. Fixed in the app (`BusinessDoctors.tsx`): Cost Doctor now says "estimated cost". The committed screenshot still shows the old wording until it is recaptured. 7. "Not proven separable — reserve a toolhead each" is jargon for beginners. 8. Capitalization varies for one feature ("This print"/"This Print", "Batch prepare"/"Batch Prepare", "After slicing"/"After Slicing"). 9. The page text for a missing file starts in lowercase and has no final period ("that file does not exist"). @@ -144,3 +146,15 @@ Before publishing the corrected copy, Astra (`gpt-6-astra`) checked the guide's Two further notes from that review were also applied: the watch-folder wording now says it looks only while **After Slicing** or **This print** is open (the poll has no focus or visibility gate, `desktop/src/components/OrcaRoundTrip.tsx`), and the exercise record above now states that the printer name in the header is the confirmed fact while the mismatch finding is *likely*. New regression checks: `backend/tests/test_guide_claims.py` (engine-derived placement and printer-name facts), `desktop/src/lib/guideClaims.test.ts` (printer-action policy, prompt text and fidelity labels from app code), and a wording tripwire in `tools/build.mjs`. They establish that the guide agrees with current engine and app code on those points. They do not establish prose quality, physical printer behavior, Snapmaker Orca behavior, or that a paraphrase of a past overclaim cannot return. They were checked to fail against the earlier wording. + +## Source-backed guidance review (2026-10-08) + +The guidance manifest records seven questions, the code-derived facts behind each answer, and forbidden overclaims. +Page sources are checked offline against repository paths or the guide's allowlisted links. +The build checks only the shape and file existence of each fact's `derive` reference; the real symbol resolution happens in the tests. +The reviewed field is metadata for maintainers; the build and renderer do not treat it as proof of current behavior. +Automated checks cannot judge prose clarity or whether a cited source is the right one. They cannot verify review dates, physical printer behavior, Snapmaker Orca behavior, paraphrased overclaims, or screen-reader experience. + +The **Print readiness (estimate)** percentage is treated as risk-signal output, not a probability of print success. +The percentage's mathematical validity remains unproven and needs a maintainer decision. +The app's verdict line "Likely to print (N%)" (`success_predict.py`) is an open maintainer decision: issue #92. diff --git a/site/guide/README.md b/site/guide/README.md index 78c28100..32e9a0b0 100644 --- a/site/guide/README.md +++ b/site/guide/README.md @@ -1,6 +1,6 @@ # Snapmaker Studio — user guide -A static, task-oriented guide for **Snapmaker Studio v1.5.0**. It is organized around the decisions a person makes before a +A static, task-oriented guide for **Snapmaker Studio v1.5.1**. It is organized around the decisions a person makes before a print, not around the order of the app's menus: 1. **Learn with an example project** — six stages (Open → Check → Prepare → Review → Slice in Orca → Check the sliced job), @@ -42,6 +42,7 @@ site/guide/ examples.json ← the interactive examples and their feedback shots.json ← each screenshot's alt text, caption and control explanations geometry.json ← GENERATED: where each control sits, as percentages of the picture + answers.json ← frozen seven-question manifest for code-backed golden answers tools/ ← build, preview, capture and verification scripts (Node, no runtime dependencies) deploy/ ← sample deployment files (nothing here runs automatically) evidence/redesign/ ← screenshots of the finished redesign (desktop and phone, both themes) @@ -55,6 +56,11 @@ land on the right page). With JavaScript the guide shows one page at a time and Requires Node 18 or newer. No packages are needed to build or preview. +Stages, tasks and warnings may include a checked version and repository-backed sources. The build validates source paths, +link keys and the shape of the seven golden answers; it checks only the shape and file existence of each `derive` +reference, and the real symbol resolution happens in the tests. Each source appears in a collapsed **Where this comes from** block. The offline +check compares the guide version with the desktop configuration and warns on drift without failing. + ```bash cd site/guide node tools/build.mjs # validate content and regenerate public/index.html (fails on any problem) @@ -133,4 +139,7 @@ screenshot they replace any local path with ``. Each run rewrites `conte captured, so hotspots stay aligned. Review new screenshots for private data before committing (this guide's captures use only synthetic example files and made-up spools). +Evidence kinds use `file` for values read from a file, `engine` for a Studio check, `estimate` for a derived estimate, and +`orca` only for advice to verify in Snapmaker Orca. The last kind makes no claim about Orca behavior. + Studio's own window is captured in its dark theme. The guide itself has both themes. Screenshots of the finished guide are in `evidence/redesign/`. diff --git a/site/guide/content/answers.json b/site/guide/content/answers.json new file mode 100644 index 00000000..6e45bd6f --- /dev/null +++ b/site/guide/content/answers.json @@ -0,0 +1,12 @@ +{ + "schema": "answers/1", + "answers": [ + {"id":"setting-why","question":"Why did Studio change this setting?","page":"path-review","requiredFacts":[{"id":"engine-reason","derive":"backend:rules.apply_clamps","tokens":["valid range","U1 default"]}],"forbidden":["improves the print","guarantees compatibility"],"nextAction":{"label":"Open in Snapmaker Orca","ref":"#path-orca"}}, + {"id":"size-or-placement","question":"Too big, or just off the plate?","page":"task-move-onto-plate","requiredFacts":[{"id":"placement-move","derive":"backend:plate_placement.EDGE_MARGIN_MM","tokens":["one move cannot fix the layout","multi-plate"]}],"forbidden":["Studio scales the object","will fit after moving"],"nextAction":{"label":"Move onto the plate (saves a copy)","ref":"#task-move-onto-plate"}}, + {"id":"kept-orca","question":"Does “Kept” mean Snapmaker Orca uses it?","page":"path-review","requiredFacts":[{"id":"kept-reachability","derive":"app:desktop/src/lib/fidelity.ts#FIDELITY_HEADINGS","tokens":["reached the copy","read any note on the row"]}],"forbidden":["Kept means Orca uses it","Kept needs no action"],"nextAction":{"label":"Open in Snapmaker Orca","ref":"#path-orca"}}, + {"id":"unknown-compatible","question":"Is “Studio can’t tell” the same as incompatible?","page":"problem-studio-cant-tell","requiredFacts":[{"id":"unknown-gap","derive":"backend:post_slice._machine_match","tokens":["does not guess","no printer is connected"]}],"forbidden":["Studio can't tell means incompatible","unknown means failed"],"nextAction":{"label":"Printer Hub","ref":"#task-connect-printer"}}, + {"id":"sliced-for-u1","question":"Was this job sliced for my U1?","page":"path-job","requiredFacts":[{"id":"sliced-header","derive":"backend:post_slice._machine_match","tokens":["Studio reads the header","not simulated"]}],"forbidden":["Studio simulated the commands","guaranteed to print"],"nextAction":{"label":"Check this job","ref":"#task-check-sliced-job"}}, + {"id":"original-unchanged","question":"Did Studio change my original?","page":"task-prepare-u1-copy","requiredFacts":[{"id":"copy-only","derive":"test:backend/tests/test_guide_claims.py::test_original_unchanged_answer_uses_real_convert_path","tokens":["SnapmakerU1","original file is not modified"]}],"forbidden":["Studio overwrites the original","original is replaced"],"nextAction":{"label":"Prepare U1 copy","ref":"#task-prepare-u1-copy"}}, + {"id":"print-success","question":"Will this print succeed?","page":"path-check","requiredFacts":[{"id":"readiness-estimate","derive":"backend:success_predict.predict","tokens":["takes points off for each risk signal","not a probability of print success","at least 75"]}],"forbidden":["likelihood of success","will print","guaranteed"],"nextAction":{"label":"Print readiness (estimate)","ref":"#path-check"}} + ] +} diff --git a/site/guide/content/examples.json b/site/guide/content/examples.json index 1e94684d..1c2b66ba 100644 --- a/site/guide/content/examples.json +++ b/site/guide/content/examples.json @@ -26,7 +26,7 @@ "shot": "placement-oversized", "facts": [ "**Object 1 · 320 × 20 × 20 mm** — *Hangs 1.0 mm past the left edge and 50.0 mm past the right edge.*", - "Studio says: *Moving the objects as one piece would not bring them all on, so Studio will not guess. Open the project in Snapmaker Orca and use Arrange.*", + "Studio says: *Moving the objects as one piece would not bring them all on, so Studio will not guess. Review the project in Snapmaker Orca before slicing.*", "There is *no* Move button." ], "question": "Why is there no Move button, and what do you do?", @@ -47,7 +47,7 @@ ], "question": "Each cube is tiny. What is the real problem?", "options": [ - { "label": "The objects are too far apart: together they are wider than the plate. One shift cannot bring both onto the plate, so rearrange them in Orca with Arrange.", "verdict": "best", "feedback": "Right. Neither cube is big; the layout is. Moving them as one piece would push one off the other side, so Studio stops. Arrange in Snapmaker Orca can bring the objects closer together." }, + { "label": "The objects are too far apart: together they are wider than the plate. One shift cannot bring both onto the plate, so review the layout in Snapmaker Orca.", "verdict": "best", "feedback": "Right. Neither cube is big; the layout is. Moving them as one piece would push one off the other side, so Studio stops. Studio cannot place them for you; review the layout in Snapmaker Orca." }, { "label": "The cube is too big for the U1.", "verdict": "wrong", "feedback": "The cube is 10 mm. The problem is the distance between the two objects, not the size of either one." }, { "label": "Press Move onto the plate anyway.", "verdict": "wrong", "feedback": "The button is not offered here, on purpose: moving the whole layout could not bring both cubes onto the plate." } ] diff --git a/site/guide/content/guide.json b/site/guide/content/guide.json index 28b444fd..e8ae8307 100644 --- a/site/guide/content/guide.json +++ b/site/guide/content/guide.json @@ -1,10 +1,11 @@ { "site": { "title": "Snapmaker Studio guide — decide what to do with your project", - "description": "A task-first guide to Snapmaker Studio v1.5.0: follow one example from opening a project to checking a sliced job, find help for what you are doing now, or look up a warning.", - "version": "1.5.0", + "description": "A task-first guide to Snapmaker Studio v1.5.1: follow one example from opening a project to checking a sliced job, find help for what you are doing now, or look up a warning.", + "version": "1.5.1", + "reviewed": "2026-10-08", "disclaimer": "Snapmaker Studio is an **independent open-source project — not affiliated with or endorsed by Snapmaker**. “Snapmaker” is a trademark of its owner.", - "footer": "This guide describes **Snapmaker Studio v1.5.0**, the current stable release. Studio is advisory: it checks and prepares files, Snapmaker Orca slices, and nothing here promises that a print will succeed. Source, release notes and verification: [github.com/DVOpenLabs/snapmaker-studio](@repo)." + "footer": "This guide describes **Snapmaker Studio v1.5.1**, the current stable release. Studio is advisory: it checks and prepares files, Snapmaker Orca slices, and nothing here promises that a print will succeed. Source, release notes and verification: [github.com/DVOpenLabs/snapmaker-studio](@repo)." }, "links": { "repo": "https://github.com/DVOpenLabs/snapmaker-studio", diff --git a/site/guide/content/path.json b/site/guide/content/path.json index 1bce2d53..23b03180 100644 --- a/site/guide/content/path.json +++ b/site/guide/content/path.json @@ -40,7 +40,7 @@ "next": { "page": "path-check", "why": "Next question: what needs attention before I slice?" } }, { - "id": "check", "page": "path-check", "n": 2, "lane": "studio", "label": "Check", + "id": "check", "page": "path-check", "n": 2, "lane": "studio", "label": "Check", "checked": "1.5.1", "sources": [{"label":"Readiness estimate","ref":"https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/backend/snapstudio_core/success_predict.py","kind":"estimate"},{"label":"Readiness card","ref":"https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/desktop/src/routes/DesignInsights.tsx","kind":"engine"}], "title": "Read the checks", "question": "What needs attention before slicing?", "lead": "Studio compares the project with the U1 and with Snapmaker Orca's expectations. You do not need to read every line. What matters is telling a confirmed problem from a heads-up, and both from something Studio cannot know.", @@ -54,7 +54,8 @@ "means": [ { "seen": "**1 object is outside the U1's printable area.**", "meaning": "An object hangs off the plate. Studio names the object, the edge and the distance.", "certainty": "confirmed", "action": "Decide whether it is misplaced or too big (next stage)." }, { "seen": "**NEEDS ATTENTION** — “Invalid value for …”", "meaning": "Likely to cause trouble in Orca. For a downloaded 3MF it usually means settings from another printer; the text says the model itself may be fine.", "certainty": "check", "action": "Read the **Do this:** line. Preparing a U1 copy is designed to handle this." }, - { "seen": "**HEADS UP**", "meaning": "Worth knowing, often something Studio cannot verify, such as spacing between objects.", "certainty": "check", "action": "Check it in Orca (**Arrange**, collision warnings)." }, + { "seen": "**Print readiness (estimate)**", "meaning": "Studio starts at 100 and takes points off for each risk signal it found. The percentage is not a probability of print success. **Few risks** means the score is at least 75, so a signal may still have cost points. The line under it shows the percentage; the bullet list of signals appears only below 75.", "certainty": "info", "action": "Review any listed signals before printing." }, + { "seen": "**HEADS UP**", "meaning": "Worth knowing, often something Studio cannot verify, such as spacing between objects.", "certainty": "check", "action": "Check it in Orca (collision warnings)." }, { "seen": "**STUDIO CAN’T TELL** / **STUDIO CAN’T CHECK THIS**", "meaning": "Studio lacks information it needs for this check. It is not a problem found; it is a gap.", "certainty": "unknown", "action": "Connect the printer, or check it yourself. See the unknown-versus-confirmed example in stage 6." }, { "seen": "**LOOKS RIGHT**", "meaning": "Studio checked and found nothing wrong.", "certainty": "ok", "action": "Read any action listed on the card. It is advice, not a guarantee." } ], @@ -83,7 +84,7 @@ "next": { "page": "path-prepare", "why": "Next question: what can Studio fix, and how should I prepare my copy?" } }, { - "id": "prepare", "page": "path-prepare", "n": 3, "lane": "studio", "label": "Prepare", + "id": "prepare", "page": "path-prepare", "n": 3, "lane": "studio", "label": "Prepare", "checked": "1.5.1", "title": "Fix and prepare", "question": "What can Studio fix, and how should I prepare my copy?", "lead": "Studio can write a repositioned copy and a U1 copy. It fixes only what one safe action can fix, and it tells you when it will not. Both actions write new files; your original stays as it is.", @@ -96,7 +97,7 @@ }, "means": [ { "seen": "**Move onto the plate (saves a copy)** is offered", "meaning": "One shift of the whole layout brings every object onto the plate. Studio can do it and verify the result.", "certainty": "ok", "action": "Press it, then prepare the moved copy." }, - { "seen": "No Move button: “Moving the objects as one piece would not bring them all on, so Studio will not guess.”", "meaning": "A single shift cannot fix it: the layout is wider than the plate in its current orientation.", "certainty": "confirmed", "action": "Open the project in Orca and use **Arrange**; if an object still does not fit, rotate, resize or split it." }, + { "seen": "No Move button: “Moving the objects as one piece would not bring them all on, so Studio will not guess.”", "meaning": "A single shift cannot fix it: the layout is wider than the plate in its current orientation.", "certainty": "confirmed", "action": "Review the project in Snapmaker Orca before slicing; if an object does not fit, rotate, resize or split it there." }, { "seen": "**Preserve creator settings** (Recommended)", "meaning": "Keeps the creator's print settings and changes only the minimum for Orca to open the file.", "certainty": "info", "action": "The right choice for a downloaded model." }, { "seen": "**Use Studio's U1 starter settings instead**", "meaning": "Replaces the creator's settings with Studio's U1 defaults: speed, temperature, retraction, supports and cooling change.", "certainty": "check", "action": "Use this when the file came from a very different printer." }, { "seen": "“Print-setup risks remain (see the checks)”", "meaning": "Studio prepared the file for the U1, but the checks still list things for you.", "certainty": "info", "action": "Handle them in Orca (stage 5)." } @@ -128,7 +129,7 @@ "next": { "page": "path-review", "why": "Next question: what changed in the prepared copy?" } }, { - "id": "review", "page": "path-review", "n": 4, "lane": "studio", "label": "Review", + "id": "review", "page": "path-review", "n": 4, "lane": "studio", "label": "Review", "checked": "1.5.1", "sources": [{"label":"Fidelity report","ref":"https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/desktop/src/lib/fidelity.ts","kind":"engine"}], "title": "Review what changed", "question": "What changed in the prepared copy?", "lead": "Studio does not ask you to trust the copy. It lists what it kept, what it changed, what it could not carry over and what it could not check, and you can always return to your original.", @@ -137,8 +138,8 @@ "note": "These cards appear on the Workspace right after preparing, and on **This print** under **2. Prepared**." }, "means": [ - { "seen": "**What survived preparing this copy** — 14 kept · 2 changed", "meaning": "A comparison of what Studio can identify in both files.", "certainty": "info", "action": "Expand **What Studio changed** and look at each line." }, - { "seen": "**Adjusted for U1 project compatibility**", "meaning": "Settings changed only so the project suits the U1, for example the printer identity.", "certainty": "info", "action": "Skim **Before and after**; flag anything that surprises you." }, + { "seen": "**What survived preparing this copy** — 14 kept · 2 changed", "meaning": "A comparison of what Studio can identify in both files. Kept means the value reached the copy, not that Orca uses it: read any note on the row.", "certainty": "info", "action": "Expand **What Studio changed** and look at each line." }, + { "seen": "**Adjusted for U1 project compatibility**", "meaning": "Settings changed only so the project suits the U1, for example the printer identity. Where a setting was outside its valid range, Studio used the U1 default.", "certainty": "info", "action": "Skim **Before and after**; flag anything that surprises you." }, { "seen": "**Optional recommendations (not applied)**", "meaning": "Suggestions Studio did not apply.", "certainty": "info", "action": "Nothing changes unless you choose **Prepare another copy with recommended settings**." }, { "seen": "**What Studio could not carry over**", "meaning": "Something in the original was left behind, or Studio could not establish what happened to it. Read each row's reason.", "certainty": "check", "action": "Check that setting in Orca." }, { "seen": "**What Studio could not check**", "meaning": "Studio could not verify this item: it may be an unexplained change, or something it could not compare.", "certainty": "unknown", "action": "Compare that setting with your original in Orca before slicing, and report it." } @@ -178,13 +179,13 @@ "means": [ { "seen": "**Open in Snapmaker Orca**", "meaning": "A one-way handoff. Studio opens the file in Orca and does nothing else with it.", "certainty": "info", "action": "Press it, or use **Copy path** and open the file in Orca yourself." }, { "seen": "**Install Snapmaker Orca**", "meaning": "Studio could not find Orca on this computer.", "certainty": "info", "action": "Install it from its [releases page](@orca), then use **Copy path** and open the prepared file from inside Orca." }, - { "seen": "**Do this in Orca** (fix plan)", "meaning": "A step Studio cannot do: for example turning on supports, or using Arrange and checking spacing.", "certainty": "check", "action": "Do it before slicing." }, + { "seen": "**Do this in Orca** (fix plan)", "meaning": "A step Studio cannot do: for example turning on supports, or checking spacing.", "certainty": "check", "action": "Do it before slicing." }, { "seen": "**Manual check in Orca required**", "meaning": "A preset you made yourself is named in the copy, but Studio cannot confirm Orca applies its temperature, flow and cooling.", "certainty": "unknown", "action": "Check the filament in Orca before slicing." }, { "seen": "Orca shows a “Customized Preset” notice", "meaning": "Orca treats the project's settings as a customization, which can happen when the creator's tuned settings are kept.", "certainty": "info", "action": "Review the settings in Orca before slicing." } ], "steps": [ "On the prepared result, press **Open in Snapmaker Orca** (or **Copy path**).", - "In Orca, check: the printer is **Snapmaker U1**; the filaments and colors match what you will load; the objects are on the plate (use **Arrange** if unsure); supports are set the way you want.", + "In Orca, check: the printer is **Snapmaker U1**; the filaments and colors match what you will load; the objects are on the plate (check the layout if unsure); supports are set the way you want.", "Slice in Orca (**Slice plate**).", "Export the G-code (the file of printer instructions) with Orca's own export command and note where it is saved. Menu wording can differ between Orca versions.", "Keep the exported `.gcode`. Save the Orca project too if you want your Orca settings kept." @@ -204,7 +205,7 @@ ], "problems": ["orca-not-found", "customized-preset", "manual-check-in-orca"], "tasks": ["open-in-orca", "review-in-orca"], - "keywords": ["orca", "slice", "slicer", "handoff", "export", "gcode", "arrange", "supports", "customized preset", "install orca"], + "keywords": ["orca", "slice", "slicer", "handoff", "export", "gcode", "supports", "customized preset", "install orca"], "next": { "page": "path-job", "why": "Next question: does the sliced job match my printer and loaded materials?" } }, { diff --git a/site/guide/content/problems.json b/site/guide/content/problems.json index 3a7cfcdf..f78d075f 100644 --- a/site/guide/content/problems.json +++ b/site/guide/content/problems.json @@ -11,12 +11,12 @@ "id": "heads-up", "label": "HEADS UP", "certainty": "check", "seen": ["check"], "means": "Worth knowing, often something Studio cannot fully assess, such as spacing between objects or whether the profile is for a U1.", "why": "Studio does not yet check spacing between objects in a multi-part layout, and says so rather than guessing.", - "do": ["Open the project in Snapmaker Orca and use **Arrange**; watch for collision warnings.", "If it says **Profile may not be a Snapmaker U1 profile**, make sure the active printer profile is Snapmaker U1, or prepare a U1 copy."], + "do": ["Review the project in Snapmaker Orca and watch for collision warnings.", "If it says **Profile may not be a Snapmaker U1 profile**, make sure the active printer profile is Snapmaker U1, or prepare a U1 copy."], "dont": "A heads-up is not a failure. It is something to look at, and often something Studio cannot check for you.", "shot": "compat-findings", "tasks": ["review-in-orca"], "keywords": ["spacing", "collision", "profile", "not a u1 profile", "warning"] }, { - "id": "studio-cant-tell", "label": "STUDIO CAN’T TELL", "certainty": "unknown", "seen": ["check", "job"], + "id": "studio-cant-tell", "label": "STUDIO CAN’T TELL", "certainty": "unknown", "seen": ["check", "job"], "checked": "1.5.1", "sources": [{"label":"Unknown result","ref":"https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/backend/snapstudio_core/post_slice.py","kind":"engine"}], "means": "Studio lacks information it needs for this check, often because no printer is connected, so it does not guess.", "why": "Most often no printer is connected. The app's own line says: *“Studio can’t tell” means Studio has no way to read that from your printer — not that your printer can’t do it.* Studio can also say it when the file itself leaves a fact out.", "do": ["Connect your printer in Printer Hub so Studio can compare it with the project.", "Or check the item yourself at the printer; the card says what to check."], @@ -32,7 +32,7 @@ "shot": "after-result", "tasks": ["connect-printer", "confirm-nozzles"], "keywords": ["after slicing", "toolheads", "slots loaded", "bed", "nozzle", "unknown"] }, { - "id": "looks-right", "label": "LOOKS RIGHT", "certainty": "ok", "seen": ["check", "job"], + "id": "looks-right", "label": "LOOKS RIGHT", "certainty": "ok", "seen": ["check", "job"], "checked": "1.5.1", "sources": [{"label":"Check results (sliced job)","ref":"https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/desktop/src/lib/postSlice.ts","kind":"engine"},{"label":"Check results (before slicing)","ref":"https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/desktop/src/lib/preflight.ts","kind":"engine"}], "means": "Studio checked this and found nothing wrong.", "why": "For example, when a sliced job names a U1, Studio shows **Sliced for the machine Studio prepares for**.", "do": ["Read any action listed on the card; a result that looks right can still have a next step."], @@ -59,9 +59,9 @@ "id": "cannot-move-as-one-piece", "label": "Moving the objects as one piece would not bring them all on, so Studio will not guess.", "certainty": "confirmed", "seen": ["prepare"], "means": "One shift of the whole layout cannot bring every object onto the plate: the layout is wider than the plate in its current orientation.", "why": "Studio moves everything as one piece and verifies the copy, or does nothing. It will not half-fix a project. There is no **Move onto the plate (saves a copy)** button in this case.", - "do": ["Open the project in Snapmaker Orca and use **Arrange**, as Studio says.", "If one object is longer than the plate, resize, rotate or split it in Orca; Studio does not edit your model."], + "do": ["Review the project in Snapmaker Orca before slicing, as Studio says.", "If one object is longer than the plate, resize, rotate or split it in Orca; Studio does not edit your model."], "dont": "Do not hunt for a hidden Move button.", - "shot": "placement-oversized", "tasks": ["move-onto-plate", "review-in-orca"], "keywords": ["oversized", "too big", "too large", "will not guess", "arrange", "no move button", "wider than the plate"] + "shot": "placement-oversized", "tasks": ["move-onto-plate", "review-in-orca"], "keywords": ["oversized", "too big", "too large", "will not guess", "no move button", "wider than the plate"] }, { "id": "could-not-open-3mf", "label": "Studio could not open this file as a 3MF project.", "certainty": "confirmed", "seen": ["open"], diff --git a/site/guide/content/shots.json b/site/guide/content/shots.json index c2458141..e0228bab 100644 --- a/site/guide/content/shots.json +++ b/site/guide/content/shots.json @@ -718,7 +718,7 @@ "placement-oversized": { "file": "placement-oversized.png", "title": "Placement: an object longer than the plate", - "alt": "Studio's Object placement card for a project with one long bar. It says one object is outside the U1's printable area: Object 1, 320 by 20 by 20 millimeters, hangs 1.0 millimeters past the left edge and 50.0 millimeters past the right edge. A note says moving the objects as one piece would not bring them all on, so Studio will not guess, and to use Arrange in Snapmaker Orca. There is no Move button.", + "alt": "Studio's Object placement card for a project with one long bar. It says one object is outside the U1's printable area: Object 1, 320 by 20 by 20 millimeters, hangs 1.0 millimeters past the left edge and 50.0 millimeters past the right edge. A note says moving the objects as one piece would not bring them all on, so Studio will not guess, and points to Snapmaker Orca. There is no Move button.", "caption": "`example_oversized.3mf`: one 320 mm bar on a plate about 270 mm wide.", "hotspots": [ { @@ -747,7 +747,7 @@ { "key": "refusal", "title": "Studio will not guess", - "text": "Rearranging separate objects is a job for **Arrange** in Snapmaker Orca." + "text": "Moving separate objects relative to each other is a job for Snapmaker Orca." } ] }, diff --git a/site/guide/content/tasks.json b/site/guide/content/tasks.json index e8379974..3e44fc24 100644 --- a/site/guide/content/tasks.json +++ b/site/guide/content/tasks.json @@ -20,11 +20,11 @@ "**Windows:** run the installer. It is not code-signed yet, so SmartScreen may say **Unknown publisher**. Once the hash matches, choose **More info → Run anyway**, then start **Snapmaker Studio** from the Start menu. More: [Windows install guide](@windows_install).", "**Linux:** `sudo apt install ./`, then start **Snapmaker Studio** from your application menu or run `snapmaker-studio-desktop`. More: [Linux install guide](@linux_install)." ], - "success": ["Studio opens on **Home**, with the version **v1.5.0** in the status bar and **Local-only · nothing leaves your network** in the footer.", "There is nothing to sign in to."], + "success": ["Studio opens on **Home**, with the version **v1.5.1** in the status bar and **Local-only · nothing leaves your network** in the footer.", "There is nothing to sign in to."], "differs": [ { "if": "Windows says “Windows protected your PC”.", "then": "This is expected for an installer that is not code-signed yet. Continue only if the SHA256 matches and you downloaded the file from Studio's Releases page." }, { "if": "The hash does not match.", "then": "Do not run the installer. Delete it and download it again from Studio's Releases page." }, - { "if": "Studio looks different from this guide.", "then": "This guide describes **v1.5.0**. Check the version at the bottom of the Studio window; the [release notes](@releases) say what changed." } + { "if": "Studio looks different from this guide.", "then": "This guide describes **v1.5.1**. Check the version at the bottom of the Studio window; the [release notes](@releases) say what changed." } ], "notes": [ { "kind": "limit", "text": "Linux support is verified by automated installs on clean Ubuntu 22.04 and 24.04 systems. Other distributions and installs on a physical desktop have not been verified yet; see the [Linux install guide](@linux_install)." }, @@ -100,7 +100,7 @@ "problems": ["studio-cant-tell"], "keywords": ["colours", "colors", "toolheads", "four", "painted", "swap", "multi-colour", "filament", "reserve", "color"] }, { - "id": "move-onto-plate", "page": "task-move-onto-plate", "group": "prepare", "title": "Move an object that hangs off the plate", + "id": "move-onto-plate", "page": "task-move-onto-plate", "group": "prepare", "title": "Move an object that hangs off the plate", "checked": "1.5.1", "sources": [{"label":"Placement check","ref":"https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/backend/snapstudio_core/plate_placement.py","kind":"engine"}], "when": "**Object placement** says an object is outside the U1's printable area.", "prereq": ["The project open in Studio."], "steps": [ @@ -110,13 +110,13 @@ "Open that copy (**Open a model**) and prepare it (**Prepare U1 copy**)." ], "success": ["A new `…_placed_U1.3mf` beside your original, and the card says **The object sits inside the U1's printable area.**"], - "differs": [{ "if": "There is no Move button.", "then": "Either every object already fits, or one move cannot fix the layout (Studio also never moves multi-plate projects). Open the project in Orca and use **Arrange**; see the placement example." }], + "differs": [{ "if": "There is no Move button.", "then": "Either every object already fits, or one move cannot fix the layout (Studio also never moves multi-plate projects). Review the project in Snapmaker Orca before slicing; see the placement example." }], "notes": [{ "kind": "safe", "text": "The moved copy shifts the whole layout as one piece; the spacing between objects, their rotation, scale and height stay as the creator set them." }], "shot": "move-result", - "problems": ["object-outside-area", "cannot-move-as-one-piece"], "keywords": ["move", "plate", "off the plate", "out of bounds", "placement", "hangs", "bed", "arrange"] + "problems": ["object-outside-area", "cannot-move-as-one-piece"], "keywords": ["move", "plate", "off the plate", "out of bounds", "placement", "hangs", "bed"] }, { - "id": "prepare-u1-copy", "page": "task-prepare-u1-copy", "group": "prepare", "title": "Prepare a U1 copy and choose a mode", + "id": "prepare-u1-copy", "page": "task-prepare-u1-copy", "group": "prepare", "title": "Prepare a U1 copy and choose a mode", "checked": "1.5.1", "sources": [{"label":"Copy writer","ref":"https://github.com/DVOpenLabs/snapmaker-studio/blob/v1.5.1/backend/snapstudio_core/convert.py","kind":"engine"}], "when": "You want a copy Orca can open with U1-compatible settings.", "prereq": ["The project open in Studio."], "steps": [ @@ -221,7 +221,7 @@ "steps": [ "Check that the printer is **Snapmaker U1**.", "Check that the filaments and colors match what you will load, toolhead by toolhead.", - "Check that every object is on the plate. Use **Arrange** if unsure; Studio does not verify spacing between objects.", + "Check that every object is on the plate. Review the layout if unsure; Studio does not verify spacing between objects.", "Check that supports are set the way you want. Studio's fix plan marks these as **Do this in Orca**.", "If a preset you made is named in the copy, check its values in Orca (**Manual check in Orca required**).", "Slice (**Slice plate**), then export the G-code with Orca's own export command." @@ -229,7 +229,7 @@ "success": ["You have an exported `.gcode` file and know what you checked."], "differs": [{ "if": "Orca shows a “Customized Preset” notice.", "then": "That can happen when the creator's tuned settings are kept. Review the settings before slicing." }], "notes": [{ "kind": "limit", "text": "Orca's own screens are not shown in this guide, and its menu wording can differ between versions." }], - "shot": null, "problems": ["customized-preset", "manual-check-in-orca"], "keywords": ["orca", "review", "arrange", "supports", "slice", "export", "checklist", "gcode"] + "shot": null, "problems": ["customized-preset", "manual-check-in-orca"], "keywords": ["orca", "review", "supports", "slice", "export", "checklist", "gcode"] }, { "id": "check-sliced-job", "page": "task-check-sliced-job", "group": "job", "title": "Check a sliced job", @@ -360,7 +360,7 @@ "when": "You want to know whether a newer Studio exists.", "prereq": [], "steps": [ - "Your version is at the bottom of the window (**v1.5.0** for this guide).", + "Your version is at the bottom of the window (**v1.5.1** for this guide).", "Open **Help → Check for a newer version** and press **Check GitHub now**. The update check does not download or install anything; it only shows a version number and a link.", "To update, download the new installer from Studio's Releases page, verify its hash, and run it over your install. Your data is kept." ], diff --git a/site/guide/package.json b/site/guide/package.json index a175480d..1dcfac44 100644 --- a/site/guide/package.json +++ b/site/guide/package.json @@ -1,7 +1,7 @@ { "name": "snapmaker-studio-guide", "private": true, - "version": "1.5.0", + "version": "1.5.1", "type": "module", "description": "Interactive user guide for Snapmaker Studio (static site; no runtime dependencies).", "scripts": { diff --git a/site/guide/public/assets/guide.css b/site/guide/public/assets/guide.css index 18493ba5..9a3c1def 100644 --- a/site/guide/public/assets/guide.css +++ b/site/guide/public/assets/guide.css @@ -200,6 +200,11 @@ a.chip:hover, button.chip:hover { border-color: var(--accent); color: var(--text .differs summary { cursor: pointer; padding: 11px 16px; font-weight: 700; } .differs details > div { padding: 0 16px 12px; color: var(--muted); } .differs details > div p { margin: 0; } +.sources { margin-top: 18px; border: 1px solid var(--border); border-radius: 12px; background: var(--panel); } +.sources summary { cursor: pointer; padding: 11px 16px; font-weight: 700; } +.sources > div { padding: 0 16px 12px; color: var(--muted); } +.sources ul { margin: 8px 0 0; padding-left: 22px; } +.sources li { margin: 6px 0; } .stage-nav { display: flex; justify-content: space-between; gap: 12px; margin: 38px 0 0; padding-top: 22px; border-top: 1px solid var(--border); flex-wrap: wrap; } /* interactive examples */ diff --git a/site/guide/public/assets/search.js b/site/guide/public/assets/search.js index 6d2733c3..faeafc8d 100644 --- a/site/guide/public/assets/search.js +++ b/site/guide/public/assets/search.js @@ -94,7 +94,7 @@ export function renderSearch(container, q) { if (!results.length) { const box = el("div", { class: "empty", role: "status" }); box.innerHTML = `

    Nothing matched “${escapeHtml(query)}”

    -

    This guide describes Studio v1.5.0. Try fewer or different words, or browse:

    +

    This guide describes Studio v1.5.1. Try fewer or different words, or browse:

    Something missing from the guide? Tell us on GitHub (opens in a new tab).

    `; container.appendChild(box); diff --git a/site/guide/public/index.html b/site/guide/public/index.html index 5f456593..6c35d494 100644 --- a/site/guide/public/index.html +++ b/site/guide/public/index.html @@ -4,7 +4,7 @@ Snapmaker Studio guide — decide what to do with your project - + @@ -17,7 +17,7 @@ - Snapmaker StudioGuide · v1.5.0 + Snapmaker StudioGuide · v1.5.1