From ae097624da884a556e9d7466fcd79a05429e09b1 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 21 Aug 2026 10:39:23 +0200 Subject: [PATCH 1/6] docs: add a security policy with a private reporting route Modly had no SECURITY.md and no private channel for vulnerability reports, which left email as the only route for researchers. Private vulnerability reporting is now enabled on the repository; this points people at it and sets expectations around it. The policy leads with a threat model and lets the scope follow from it, so that an excluded report comes with the reason it was excluded. Two assumptions are deliberate: workflow files are untrusted input because sharing them is normal, and any web page the user has open is an untrusted caller of the loopback API. The second is why the network-exposure exclusion is narrowed to deliberate exposure only -- a page in the user's own browser needs none. Every claim was checked against the code. The policy does not call the installer signed (no platform signs it), says nothing about PyTorch (we do not ship it), and does not excuse social engineering on the strength of UI warnings that do not exist. --- .github/SECURITY.md | 101 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/SECURITY.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..b41e3fc6 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,101 @@ +# Security Policy + +## Scope + +Modly is designed to run locally. It is an Electron desktop application that +spawns a Python backend bound to `127.0.0.1`, and it runs AI models on the +user's own machine. Our threat model assumes: + +- The user installed Modly through a supported channel: the installer published + on the project's GitHub releases page, or a manual install following the + README. +- The user has not installed untrusted extensions. Extensions are arbitrary + Python code and are trusted as much as any other software the user chooses to + install. +- The user may open and run workflow files authored by someone else. Sharing + workflows is a normal thing to do, so a workflow is untrusted input. +- **Any web page the user has open in a browser is an untrusted caller of the + local API.** The backend listens on loopback, but a page in the user's browser + is on the same machine — it must not be able to read, write, or trigger + anything through Modly. +- Model weights are downloaded from the repositories Modly ships or from + repositories the user explicitly chooses. +- Python dependencies are at the versions Modly installs during first-run setup. + +A report is in scope only if it affects a user operating within this threat +model. + +## What We Consider a Vulnerability + +We want to hear about issues where a reasonable user — someone who does not +install untrusted extensions — can be harmed by Modly itself. + +The clearest examples: + +- A **workflow file** that such a user might plausibly open and run, using only + built-in nodes and installed extensions, that leads to code execution, + file access outside the expected directories, or data exfiltration. +- A **web page** that, simply by being open while Modly is running, can reach + the local API to read files, write files, or start work on the user's machine. +- An **extension manifest** that escapes its own directory, or that causes code + outside the extension to be loaded into a privileged context. +- A flaw in the **auto-update** mechanism on the platforms where it is enabled + (Windows and Linux; macOS updates manually): unverified or improperly verified + update payloads, or signature checks that fail open. +- Reaching **Node or main-process privileges** from renderer content, or + otherwise defeating the `contextIsolation` boundary between the renderer and + the preload bridge. + +When submitting a report, please include a clear description of why this is a +problem for a typical local Modly user. Reports without this context are +difficult to act on. + +## What We Do Not Consider a Security Vulnerability + +Please report the following through regular GitHub issues instead. Filing them +as security reports will likely cause them to be deprioritized or closed. + +- **Issues that require the user to deliberately expose the backend to the + network.** Modly binds to `127.0.0.1` and offers no option to do otherwise. If + you put a reverse proxy or a port forward in front of it, you have chosen to + expose it and are responsible for securing that deployment. Note that this + exclusion does *not* cover attacks from a web page on the user's own machine — + those need no exposure and are in scope, as described above. +- **Issues that require a specific third-party extension to be installed.** + Extensions are third-party code. Report those to the maintainer of the + extension. +- **Malicious content inside model weights the user chooses to download.** + Modly fetches weights from the repository named by an extension or by the + user. Report those to the repository host; if an extension points at a + malicious repository, report it to that extension's maintainer. +- **Vulnerabilities that depend on dependency versions we neither ship nor + recommend.** +- **Crashes, hangs, or memory exhaustion** from a heavy mesh, a large image, or + a runaway workflow. Annoying, but not a security issue in our model. File a + regular bug. +- Automated scanner output submitted without a working reproduction. + +## Supported Versions + +Modly is pre-1.0 (currently 0.x). Security fixes ship in the most recent +release only. Please confirm the issue on the latest version before reporting. + +## Reporting + +If you believe you have found an issue that falls within the scope above, please +report it privately via GitHub's +[Report a vulnerability](https://github.com/lightningpixel/modly/security/advisories/new) +feature rather than opening a public issue, discussion, or Discord message. + +Please include: + +- A description of the vulnerability and the affected component. +- Reproduction steps, ideally with a minimal workflow file or proof of concept. +- The Modly version, install method, and operating system. +- An explanation of how this affects a typical local user as described in the + threat model. + +We aim to acknowledge valid reports within 3 business days, and we will +coordinate a fix and a disclosure timeline with you. Reporters are credited in +the resulting advisory and in the release notes unless they prefer to remain +anonymous. From 3c88d71465dc4128690efd25f556d42ddf15ac64 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 28 Aug 2026 18:04:53 +0200 Subject: [PATCH 2/6] dump version 0.4.2 --- api/main.py | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/main.py b/api/main.py index 7f77a02f..382ed67e 100644 --- a/api/main.py +++ b/api/main.py @@ -34,7 +34,7 @@ def filter(self, record): app = FastAPI( title="Modly API", - version="0.4.1", + version="0.4.2", lifespan=lifespan, ) diff --git a/package.json b/package.json index 0d166b46..df135498 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modly", - "version": "0.4.1", + "version": "0.4.2", "description": "Local AI-powered 3D mesh generation from images", "main": "./out/main/index.js", "author": "Modly", From 650dfd955a1b1b1237cd1689b20258bf35cb7089 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 4 Sep 2026 22:29:25 +0200 Subject: [PATCH 3/6] docs: add CONTRIBUTING.md and /assign command bot Lets external contributors claim an issue without repo write access. Commenting /assign self-assigns via a github-script Action (GITHUB_TOKEN has the write permission the commenter doesn't); /unassign releases it. CONTRIBUTING.md documents the full flow: claim -> fork -> PR with `Closes #N` -> board moves through In progress / Ready to review / Ready to test / Done. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SDMd7LzfFJ7TXav5etBjRi --- .github/workflows/assign-command.yml | 65 ++++++++++++++++++++++++++++ CONTRIBUTING.md | 48 ++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 .github/workflows/assign-command.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/assign-command.yml b/.github/workflows/assign-command.yml new file mode 100644 index 00000000..205e7a53 --- /dev/null +++ b/.github/workflows/assign-command.yml @@ -0,0 +1,65 @@ +name: Assign command + +on: + issue_comment: + types: [created] + +permissions: + issues: write + +jobs: + assign: + if: ${{ !github.event.issue.pull_request && (github.event.comment.body == '/assign' || github.event.comment.body == '/unassign') }} + runs-on: ubuntu-latest + steps: + - name: Handle /assign + if: github.event.comment.body == '/assign' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const commenter = context.payload.comment.user.login; + + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number }); + + if (issue.assignees.length > 0) { + const names = issue.assignees.map(a => `@${a.login}`).join(', '); + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `⚠️ This issue is already assigned to ${names}. Ask them to comment \`/unassign\` first if they're no longer working on it.`, + }); + return; + } + + await github.rest.issues.addAssignees({ owner, repo, issue_number, assignees: [commenter] }); + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `✅ Assigned to @${commenter}. Comment \`/unassign\` if you can no longer work on this. Open a PR that includes \`Closes #${issue_number}\` in its description when you're ready for review.`, + }); + + - name: Handle /unassign + if: github.event.comment.body == '/unassign' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const commenter = context.payload.comment.user.login; + + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number }); + const isAssigned = issue.assignees.some(a => a.login === commenter); + + if (!isAssigned) { + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `⚠️ @${commenter}, you're not currently assigned to this issue.`, + }); + return; + } + + await github.rest.issues.removeAssignees({ owner, repo, issue_number, assignees: [commenter] }); + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `Unassigned @${commenter}. This issue is open again — comment \`/assign\` to pick it up.`, + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..20f6137f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to Modly + +Thanks for wanting to help out! You don't need write access to the repository to +pick up a ticket, work on it, and ship a fix — here's how the flow works. + +## Finding something to work on + +- Browse [open issues](https://github.com/lightningpixel/modly/issues) or the + [project board](https://github.com/users/lightningpixel/projects/1). +- Issues labeled `good first issue` are a good place to start if you're new to + the codebase. See [`CLAUDE.md`](./CLAUDE.md) for an architecture overview. + +## Claiming a ticket + +Comment **`/assign`** on the issue you want to work on. A bot will assign it to +you automatically — no repo permissions required. + +- Only one person can be assigned to an issue at a time. If it's already + assigned, ask the assignee first or wait for them to release it. +- No longer working on it? Comment **`/unassign`** to free it up for someone + else. + +This keeps the [project board](https://github.com/users/lightningpixel/projects/1) +honest: an assigned issue moves to **In progress** automatically, so anyone +looking at the board can see what's actively being worked on. + +## Submitting your work + +1. **Fork** the repository and create a branch for your change. +2. Make your change. Keep it focused — one issue, one PR. +3. Run the checks locally before opening a PR: + ```bash + npm run lint + npm run test + ``` +4. Open a **pull request** against `dev`. Include `Closes #` in + the PR description so it's linked to the ticket and closes it automatically + on merge. + +Opening a PR from your fork moves the linked issue to **Ready to review** on +the board. Once a maintainer approves the review, it moves to **Ready to +test**; once merged, it moves to **Done**. + +## Getting help + +If something in an issue is unclear, ask in a comment on the issue itself +before starting — it's cheaper to clarify scope up front than to redo work +later. From e0473a00573b02dd91bf41d72514204af6be2d68 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sat, 5 Sep 2026 18:00:40 +0200 Subject: [PATCH 4/6] feat: sync project board status with /assign and linked PRs The /assign bot moved GitHub assignees but never touched the Project v2 board itself, so the "In progress" column stayed empty. Same for PRs: opening one with `Closes #N` closed the issue on merge but never moved the card to "Ready to review". - assign-command.yml: on /assign, move the linked board item to "In progress"; on /unassign, move it back to "Backlog". Uses PROJECT_TOKEN since the default GITHUB_TOKEN has no Projects v2 scope. - pr-board-sync.yml (new): on PR opened/edited/ready_for_review, parse closing keywords (Closes/Fixes/Resolves #N) from the description and move each linked issue's card to "Ready to review". Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YTLv6fJFA5MotMqWgStGrf --- .github/workflows/assign-command.yml | 92 ++++++++++++++++++++++++++++ .github/workflows/pr-board-sync.yml | 76 +++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 .github/workflows/pr-board-sync.yml diff --git a/.github/workflows/assign-command.yml b/.github/workflows/assign-command.yml index 205e7a53..96c868f6 100644 --- a/.github/workflows/assign-command.yml +++ b/.github/workflows/assign-command.yml @@ -7,12 +7,19 @@ on: permissions: issues: write +env: + PROJECT_ID: PVT_kwHOA8O2Dc4BX1OY + STATUS_FIELD_ID: PVTSSF_lAHOA8O2Dc4BX1OYzhS_ZbU + STATUS_IN_PROGRESS: "98236657" + STATUS_BACKLOG: "f75ad846" + jobs: assign: if: ${{ !github.event.issue.pull_request && (github.event.comment.body == '/assign' || github.event.comment.body == '/unassign') }} runs-on: ubuntu-latest steps: - name: Handle /assign + id: do_assign if: github.event.comment.body == '/assign' uses: actions/github-script@v7 with: @@ -29,6 +36,7 @@ jobs: owner, repo, issue_number, body: `⚠️ This issue is already assigned to ${names}. Ask them to comment \`/unassign\` first if they're no longer working on it.`, }); + core.setOutput('assigned', 'false'); return; } @@ -37,8 +45,50 @@ jobs: owner, repo, issue_number, body: `✅ Assigned to @${commenter}. Comment \`/unassign\` if you can no longer work on this. Open a PR that includes \`Closes #${issue_number}\` in its description when you're ready for review.`, }); + core.setOutput('assigned', 'true'); + + - name: Move to In progress + if: github.event.comment.body == '/assign' && steps.do_assign.outputs.assigned == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + }); + + const { node } = await github.graphql( + `query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { projectItems(first: 10) { nodes { id project { id } } } } + } + }`, + { issueId: issue.node_id } + ); + + const item = node.projectItems.nodes.find(n => n.project.id === process.env.PROJECT_ID); + if (!item) { + console.log('Issue is not on the project board; skipping status update.'); + return; + } + + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + }`, + { + projectId: process.env.PROJECT_ID, + itemId: item.id, + fieldId: process.env.STATUS_FIELD_ID, + optionId: process.env.STATUS_IN_PROGRESS, + } + ); - name: Handle /unassign + id: do_unassign if: github.event.comment.body == '/unassign' uses: actions/github-script@v7 with: @@ -55,6 +105,7 @@ jobs: owner, repo, issue_number, body: `⚠️ @${commenter}, you're not currently assigned to this issue.`, }); + core.setOutput('unassigned', 'false'); return; } @@ -63,3 +114,44 @@ jobs: owner, repo, issue_number, body: `Unassigned @${commenter}. This issue is open again — comment \`/assign\` to pick it up.`, }); + core.setOutput('unassigned', 'true'); + + - name: Move to Backlog + if: github.event.comment.body == '/unassign' && steps.do_unassign.outputs.unassigned == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + }); + + const { node } = await github.graphql( + `query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { projectItems(first: 10) { nodes { id project { id } } } } + } + }`, + { issueId: issue.node_id } + ); + + const item = node.projectItems.nodes.find(n => n.project.id === process.env.PROJECT_ID); + if (!item) { + console.log('Issue is not on the project board; skipping status update.'); + return; + } + + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + }`, + { + projectId: process.env.PROJECT_ID, + itemId: item.id, + fieldId: process.env.STATUS_FIELD_ID, + optionId: process.env.STATUS_BACKLOG, + } + ); diff --git a/.github/workflows/pr-board-sync.yml b/.github/workflows/pr-board-sync.yml new file mode 100644 index 00000000..4f47529c --- /dev/null +++ b/.github/workflows/pr-board-sync.yml @@ -0,0 +1,76 @@ +name: PR board sync + +on: + pull_request: + types: [opened, edited, ready_for_review] + +permissions: + contents: read + +env: + PROJECT_ID: PVT_kwHOA8O2Dc4BX1OY + STATUS_FIELD_ID: PVTSSF_lAHOA8O2Dc4BX1OYzhS_ZbU + STATUS_READY_TO_REVIEW: c6aa22db + +jobs: + move-linked-issues: + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - name: Move linked issues to Ready to review + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const body = context.payload.pull_request.body || ''; + const keywords = '(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)'; + const re = new RegExp(`${keywords}\\s+#(\\d+)`, 'gi'); + const issueNumbers = [...new Set([...body.matchAll(re)].map(m => Number(m[1])))]; + + if (issueNumbers.length === 0) { + console.log('No closing keyword found in the PR description; nothing to move.'); + return; + } + + for (const issue_number of issueNumbers) { + let issue; + try { + ({ data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, repo: context.repo.repo, issue_number, + })); + } catch (err) { + console.log(`Issue #${issue_number} not found in this repo, skipping.`); + continue; + } + + const { node } = await github.graphql( + `query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { projectItems(first: 10) { nodes { id project { id } } } } + } + }`, + { issueId: issue.node_id } + ); + + const item = node.projectItems.nodes.find(n => n.project.id === process.env.PROJECT_ID); + if (!item) { + console.log(`Issue #${issue_number} is not on the project board, skipping.`); + continue; + } + + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + }`, + { + projectId: process.env.PROJECT_ID, + itemId: item.id, + fieldId: process.env.STATUS_FIELD_ID, + optionId: process.env.STATUS_READY_TO_REVIEW, + } + ); + console.log(`Moved issue #${issue_number} to Ready to review.`); + } From aca9a79b319777ea3ab8b599526f5f5b9e2257d6 Mon Sep 17 00:00:00 2001 From: weng haishi <74546450+wenghaishi@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:54:01 +0800 Subject: [PATCH 5/6] feat: add "Open in OrcaSlicer" export action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-click hand-off from a generated model to OrcaSlicer via its orcaslicer://open?file= deeplink, in the Export dropdown. - Backend: GET /export/slicer/{fmt}/{token}/model.{fmt} converts the workspace GLB to STL on the fly — bakes scene-graph transforms, reorients Y-up->Z-up, normalizes print size. Path-only URL ending in the filename (no query string), since OrcaSlicer derives the import format from the URL's final segment; ancestry-based path containment. - Electron: slicer:open IPC opens the deeplink and reports failure so the UI can fall back when OrcaSlicer isn't installed. - Frontend: "Open in OrcaSlicer" item in the Export dropdown, shown for sliceable workspace meshes; pure deeplink builder with unit tests. Tests: api/tests/test_export_router.py and orcaSlicerLink.test.ts. Co-Authored-By: Claude Opus 4.8 --- api/routers/export.py | 107 ++++++++++++++++++ api/tests/test_export_router.py | 130 ++++++++++++++++++++++ electron/main/ipc-handlers.ts | 15 +++ electron/preload/electron-api.ts | 6 + package.json | 2 +- src/areas/generate/GeneratePage.tsx | 38 +++++++ src/areas/generate/orcaSlicerLink.test.ts | 51 +++++++++ src/areas/generate/orcaSlicerLink.ts | 44 ++++++++ src/shared/types/electron.d.ts | 3 + 9 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 api/tests/test_export_router.py create mode 100644 src/areas/generate/orcaSlicerLink.test.ts create mode 100644 src/areas/generate/orcaSlicerLink.ts diff --git a/api/routers/export.py b/api/routers/export.py index 2a2f2bf3..f40a9045 100644 --- a/api/routers/export.py +++ b/api/routers/export.py @@ -1,4 +1,7 @@ +import base64 +import binascii import io +import math import trimesh from fastapi import APIRouter, HTTPException @@ -10,6 +13,110 @@ SUPPORTED = {"glb", "stl", "obj", "ply"} +# Formats OrcaSlicer's importer accepts (see the orcaslicer://open contract). +# GLB is deliberately excluded — OrcaSlicer cannot import glTF/GLB, so a .glb +# deeplink downloads but silently fails to slice. +SLICER_FORMATS = {"stl", "obj"} +SLICER_MEDIA_TYPES = {"stl": "model/stl", "obj": "text/plain"} + +# Image-to-3D output has no inherent physical scale (a single photo carries no +# real-world size), and AI generators emit roughly unit-sized meshes — which +# import into a slicer as an invisible ~1 mm speck. Normalise the longest +# bounding-box edge to a sane, obviously-printable default; the user rescales +# in OrcaSlicer as needed. +DEFAULT_PRINT_LONGEST_MM = 50.0 + + +def _to_single_mesh(loaded: object) -> "trimesh.Trimesh": + """Flatten a loaded GLB into one Trimesh, baking scene-graph node transforms. + + ``trimesh.util.concatenate(scene.geometry.values())`` would DROP the node + transforms and misassemble a multi-node scene, so flatten at the scene level + where the graph transforms are applied. + """ + if isinstance(loaded, trimesh.Trimesh): + return loaded + if isinstance(loaded, trimesh.Scene): + if len(loaded.geometry) == 0: + raise HTTPException(422, "Mesh contains no geometry") + # Bake the scene-graph node transforms into a single mesh. The spelling + # varies across trimesh versions — to_mesh()/to_geometry() are the modern + # APIs (4.6+); dump(concatenate=True) is the pre-removal fallback for 4.5. + for flatten in (lambda s: s.to_mesh(), lambda s: s.to_geometry(), lambda s: s.dump(concatenate=True)): + try: + result = flatten(loaded) + except (AttributeError, TypeError): + continue + if isinstance(result, trimesh.Trimesh): + return result + if isinstance(result, (list, tuple)) and result: + return trimesh.util.concatenate(result) + # Fallback: concatenate the geometry as-is (may ignore node transforms). + return trimesh.util.concatenate(list(loaded.geometry.values())) + raise HTTPException(422, "Unsupported mesh contents") + + +def _scale_to_print_size(mesh: "trimesh.Trimesh", longest_mm: float = DEFAULT_PRINT_LONGEST_MM) -> None: + """Uniformly scale ``mesh`` in place so its longest bbox edge is ``longest_mm``.""" + extents = mesh.extents + longest = float(max(extents)) if extents is not None and len(extents) else 0.0 + if longest > 1e-9 and math.isfinite(longest): + mesh.apply_scale(longest_mm / longest) + + +@router.get("/slicer/{fmt}/{token}/{filename}") +def export_for_slicer(fmt: str, token: str, filename: str): + """Serve a generated GLB converted to a slicer-importable mesh, at a URL + shaped for OrcaSlicer's ``orcaslicer://open?file=`` deeplink. + + The URL is intentionally path-only and ends in the real filename+extension + (e.g. ``/export/slicer/stl//model.stl``). OrcaSlicer + downloads the URL and derives the import filename — and therefore the mesh + format — from the URL's FINAL path segment, so a query string (``?path=...``) + would corrupt the parsed extension and the model would silently fail to + import. ``token`` is the url-safe-base64 of the workspace-relative source + path; ``filename`` (e.g. ``model.stl``) is what OrcaSlicer names the download. + """ + fmt = fmt.lower() + if fmt not in SLICER_FORMATS: + raise HTTPException(400, f"Unsupported slicer format: {fmt}. Supported: {', '.join(sorted(SLICER_FORMATS))}") + if not filename.lower().endswith(f".{fmt}"): + raise HTTPException(400, "Filename must end with the requested format extension") + + try: + padded = token + "=" * (-len(token) % 4) + rel_path = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError): + raise HTTPException(400, "Malformed source token") + + # Containment check via ancestry, not string prefix: `startswith` would let a + # sibling like `-other/...` slip through, and `..` escapes resolve + # outside the workspace and fail this check. + workspace = WORKSPACE_DIR.resolve() + full_path = (workspace / rel_path).resolve() + if full_path != workspace and workspace not in full_path.parents: + raise HTTPException(400, "Invalid path") + if not full_path.is_file(): + raise HTTPException(404, f"File not found: {rel_path}") + + mesh = _to_single_mesh(trimesh.load(str(full_path))) + # glTF/GLB is Y-up; OrcaSlicer's world is Z-up. Rotate +90° about X so the + # model imports standing upright instead of on its side. (Modly's own viewer + # rests generated meshes on the Y=0 plane, confirming Y is the up axis.) + mesh.apply_transform(trimesh.transformations.rotation_matrix(math.pi / 2, [1, 0, 0])) + _scale_to_print_size(mesh) + + data = mesh.export(file_type=fmt) + if isinstance(data, str): + data = data.encode("utf-8") + return Response( + content=data, + media_type=SLICER_MEDIA_TYPES.get(fmt, "application/octet-stream"), + # Fixed name (not the client-supplied segment) — keeps arbitrary input out + # of the response header. OrcaSlicer names the file from the URL anyway. + headers={"Content-Disposition": f'attachment; filename="model.{fmt}"'}, + ) + @router.get("/{fmt}") def export_mesh(fmt: str, path: str): diff --git a/api/tests/test_export_router.py b/api/tests/test_export_router.py new file mode 100644 index 00000000..bc07188f --- /dev/null +++ b/api/tests/test_export_router.py @@ -0,0 +1,130 @@ +import base64 +import io +import tempfile +import unittest +from pathlib import Path + +from fastapi import HTTPException + +# The export router imports trimesh at module load; skip the whole suite (rather +# than breaking `unittest discover`) in minimal environments without it. +try: + import numpy as np + import trimesh + + import routers.export as export_router + + HAVE_TRIMESH = True +except Exception: # noqa: BLE001 + HAVE_TRIMESH = False + + +def _token(rel_path: str) -> str: + return base64.urlsafe_b64encode(rel_path.encode("utf-8")).decode("ascii").rstrip("=") + + +def _load_stl(resp) -> "trimesh.Trimesh": + return trimesh.load(io.BytesIO(resp.body), file_type="stl") + + +@unittest.skipUnless(HAVE_TRIMESH, "trimesh not installed") +class ExportForSlicerTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.workspace = Path(self._tmp.name).resolve() + self._orig_workspace = export_router.WORKSPACE_DIR + export_router.WORKSPACE_DIR = self.workspace + # A box that is tallest along Y (glTF up-axis). Exported to GLB, it + # reloads as a Scene so the flatten path is exercised too. + box = trimesh.creation.box(extents=[10.0, 30.0, 10.0]) + self.rel = "Workflows/hero.glb" + (self.workspace / "Workflows").mkdir(parents=True, exist_ok=True) + box.export(str(self.workspace / self.rel)) + + def tearDown(self) -> None: + export_router.WORKSPACE_DIR = self._orig_workspace + self._tmp.cleanup() + + def test_converts_glb_to_stl_with_download_filename(self) -> None: + resp = export_router.export_for_slicer("stl", _token(self.rel), "model.stl") + self.assertEqual(resp.media_type, "model/stl") + self.assertIn('filename="model.stl"', resp.headers["content-disposition"]) + mesh = _load_stl(resp) + self.assertGreater(len(mesh.faces), 0) + + def test_reorients_y_up_to_z_up(self) -> None: + # The box is tallest in Y; after the Y->Z rotation it must be tallest in + # Z so it imports standing upright on the slicer bed. + resp = export_router.export_for_slicer("stl", _token(self.rel), "model.stl") + ex = _load_stl(resp).extents + self.assertEqual(int(np.argmax(ex)), 2, f"expected Z to be the tallest axis, got extents {ex}") + + def test_normalizes_longest_edge_to_default_print_size(self) -> None: + resp = export_router.export_for_slicer("stl", _token(self.rel), "model.stl") + longest = float(max(_load_stl(resp).extents)) + self.assertAlmostEqual(longest, export_router.DEFAULT_PRINT_LONGEST_MM, places=3) + + def test_rejects_unsupported_format(self) -> None: + with self.assertRaises(HTTPException) as ctx: + export_router.export_for_slicer("glb", _token(self.rel), "model.glb") + self.assertEqual(ctx.exception.status_code, 400) + + def test_rejects_filename_extension_mismatch(self) -> None: + with self.assertRaises(HTTPException) as ctx: + export_router.export_for_slicer("stl", _token(self.rel), "model.obj") + self.assertEqual(ctx.exception.status_code, 400) + + def test_rejects_malformed_token(self) -> None: + with self.assertRaises(HTTPException) as ctx: + export_router.export_for_slicer("stl", "!!!not-base64!!!", "model.stl") + self.assertEqual(ctx.exception.status_code, 400) + + def test_rejects_path_traversal(self) -> None: + with self.assertRaises(HTTPException) as ctx: + export_router.export_for_slicer("stl", _token("../escape.glb"), "model.stl") + self.assertEqual(ctx.exception.status_code, 400) + + def test_rejects_sibling_prefix_escape(self) -> None: + # A sibling dir whose name starts with the workspace dir name must not be + # reachable — the old str.startswith containment guard would allow it. + sibling = self.workspace.parent / (self.workspace.name + "-secret") + sibling.mkdir(parents=True, exist_ok=True) + (sibling / "x.glb").write_bytes(b"nope") + rel = f"../{self.workspace.name}-secret/x.glb" + with self.assertRaises(HTTPException) as ctx: + export_router.export_for_slicer("stl", _token(rel), "model.stl") + self.assertEqual(ctx.exception.status_code, 400) + + def test_missing_file_is_404(self) -> None: + with self.assertRaises(HTTPException) as ctx: + export_router.export_for_slicer("stl", _token("Workflows/nope.glb"), "model.stl") + self.assertEqual(ctx.exception.status_code, 404) + + +@unittest.skipUnless(HAVE_TRIMESH, "trimesh not installed") +class FlattenAndScaleHelperTests(unittest.TestCase): + def test_flatten_bakes_scene_node_transforms(self) -> None: + # Two boxes placed at different positions via scene-graph transforms. + # util.concatenate(geometry.values()) would ignore the transforms; the + # scene-level flatten must reflect them in the combined bounds. + scene = trimesh.Scene() + scene.add_geometry(trimesh.creation.box(extents=[2, 2, 2]), transform=trimesh.transformations.translation_matrix([0, 0, 0])) + scene.add_geometry(trimesh.creation.box(extents=[2, 2, 2]), transform=trimesh.transformations.translation_matrix([100, 0, 0])) + mesh = export_router._to_single_mesh(scene) + self.assertIsInstance(mesh, trimesh.Trimesh) + # Combined X extent spans both boxes: ~101 (from -1 to 101). + self.assertGreater(mesh.extents[0], 100.0) + + def test_scale_to_print_size(self) -> None: + mesh = trimesh.creation.box(extents=[1.0, 2.0, 4.0]) + export_router._scale_to_print_size(mesh, longest_mm=80.0) + self.assertAlmostEqual(float(max(mesh.extents)), 80.0, places=3) + + def test_scale_ignores_degenerate_mesh(self) -> None: + # A single point cloud has zero extent; scaling must not divide by zero. + mesh = trimesh.Trimesh(vertices=[[0, 0, 0]], faces=[]) + export_router._scale_to_print_size(mesh) # must not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 005f1f78..f097a559 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -595,6 +595,21 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Shell ipcMain.handle('shell:openExternal', (_, url: string) => shell.openExternal(url)) + // Open a model in OrcaSlicer via its orcaslicer://open?file= deeplink. + // Returns success/error so the renderer can surface a fallback (e.g. when + // OrcaSlicer is not installed and no app is registered for the scheme). + ipcMain.handle('slicer:open', async (_, url: string): Promise<{ success: boolean; error?: string }> => { + if (typeof url !== 'string' || !url.startsWith('orcaslicer://')) { + return { success: false, error: 'slicer:open requires an orcaslicer:// URL' } + } + try { + await shell.openExternal(url) + return { success: true } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + }) + // App info // System memory (used/available/total bytes). // On macOS, matches Activity Monitor's "Memory Used": diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index 89fa69ec..5d482f80 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -43,6 +43,12 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra // Shell utilities shell: { openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url) }, + // Slicer integration — open a model in OrcaSlicer via its deeplink + slicer: { + open: (url: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('slicer:open', url) as Promise<{ success: boolean; error?: string }>, + }, + // System info system: { memory: (): Promise<{ total: number; used: number; available: number }> => diff --git a/package.json b/package.json index df135498..fdedfaad 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index a6dbe4f3..f970993c 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -8,6 +8,7 @@ import GenerationHUD from './components/GenerationHUD' import Viewer3D from './components/Viewer3D' import WorkflowPanel from './components/WorkflowPanel' import { getDefaultAssetLibraryService } from './assetLibraryService' +import { buildOrcaSlicerDeepLink, canOpenInOrcaSlicer } from './orcaSlicerLink' import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' import { ASSET_LIBRARY_SORT_OPTIONS, @@ -41,9 +42,13 @@ const EXPORT_FORMATS = [ function ExportDropdown({ onExport, onClose, + onOpenInSlicer, + canOpenInSlicer, }: { onExport: (f: 'glb' | 'obj' | 'stl' | 'ply') => void onClose: () => void + onOpenInSlicer: () => void + canOpenInSlicer: boolean }) { return (
@@ -57,6 +62,23 @@ function ExportDropdown({ {desc} ))} + {canOpenInSlicer && ( + <> +
+ + + )}
) } @@ -611,6 +633,7 @@ export default function GeneratePage(): JSX.Element { }, [undoMesh, redoMesh]) const hasModel = currentJob?.status === 'done' && !!currentJob.outputUrl + const showOpenInSlicer = hasModel && canOpenInOrcaSlicer(currentJob?.outputUrl) // Drop the active transform tool when the mesh is deselected, so it doesn't // silently re-activate on the next selection. @@ -660,6 +683,19 @@ export default function GeneratePage(): JSX.Element { link.click() } + async function handleOpenInOrcaSlicer() { + if (!currentJob?.outputUrl) return + try { + const link = buildOrcaSlicerDeepLink(apiUrl, currentJob.outputUrl) + const result = await window.electron.slicer.open(link) + if (!result.success) { + showError(result.error ?? 'Could not open OrcaSlicer. Make sure it is installed.') + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Could not open OrcaSlicer.') + } + } + function getOptimizePath(url: string): string { if (url.startsWith('/workspace/')) { return url.slice('/workspace/'.length) @@ -971,6 +1007,8 @@ export default function GeneratePage(): JSX.Element { void} onClose={() => setOpenPanel(null)} + onOpenInSlicer={() => { void handleOpenInOrcaSlicer() }} + canOpenInSlicer={showOpenInSlicer} /> )}
diff --git a/src/areas/generate/orcaSlicerLink.test.ts b/src/areas/generate/orcaSlicerLink.test.ts new file mode 100644 index 00000000..2f7e47d3 --- /dev/null +++ b/src/areas/generate/orcaSlicerLink.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + SLICER_FORMAT, + buildOrcaSlicerDeepLink, + canOpenInOrcaSlicer, + encodeWorkspacePathToken, +} from './orcaSlicerLink.ts' + +test('builds an orcaslicer://open deeplink whose file= is a percent-encoded, query-less URL ending in model.stl', () => { + const link = buildOrcaSlicerDeepLink('http://localhost:8765', '/workspace/Workflows/checkpoints/hero.glb') + assert.ok(link.startsWith('orcaslicer://open?file=')) + const modelUrl = decodeURIComponent(link.slice('orcaslicer://open?file='.length)) + // OrcaSlicer derives the import format from the URL's final path segment, so + // it must end in the real extension and carry no query string. + assert.ok(!modelUrl.includes('?'), 'model URL must not contain a query string') + assert.ok(modelUrl.endsWith('/model.stl'), 'model URL must end in model.stl') + assert.equal( + modelUrl, + `http://localhost:8765/export/slicer/stl/${encodeWorkspacePathToken('Workflows/checkpoints/hero.glb')}/model.stl`, + ) +}) + +test('token round-trips a workspace path through url-safe base64 (matches the API decode)', () => { + const path = 'Workflows/checkpoints/hero model (v2).glb' + const token = encodeWorkspacePathToken(path) + assert.ok(!/[+/=]/.test(token), 'token must be url-safe with no padding') + // Decode the way the Python API does: restore padding, then urlsafe-decode. + const padded = token + '='.repeat((4 - (token.length % 4)) % 4) + const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8') + assert.equal(decoded, path) +}) + +test('strips a trailing slash from the api origin', () => { + const link = buildOrcaSlicerDeepLink('http://localhost:8765/', '/workspace/a.glb') + const modelUrl = decodeURIComponent(link.slice('orcaslicer://open?file='.length)) + assert.equal(modelUrl, `http://localhost:8765/export/slicer/stl/${encodeWorkspacePathToken('a.glb')}/model.stl`) +}) + +test('canOpenInOrcaSlicer accepts workspace meshes and rejects splats, imports, and empty', () => { + assert.equal(canOpenInOrcaSlicer('/workspace/Foo/hero.glb'), true) + assert.equal(canOpenInOrcaSlicer('/workspace/Foo/scan.ply'), false) + assert.equal(canOpenInOrcaSlicer('/workspace/Foo/scan.splat'), false) + assert.equal(canOpenInOrcaSlicer('/optimize/serve-file?path=/tmp/x.glb'), false) + assert.equal(canOpenInOrcaSlicer(undefined), false) +}) + +test('SLICER_FORMAT is a format OrcaSlicer can import', () => { + assert.equal(SLICER_FORMAT, 'stl') +}) diff --git a/src/areas/generate/orcaSlicerLink.ts b/src/areas/generate/orcaSlicerLink.ts new file mode 100644 index 00000000..2bade12e --- /dev/null +++ b/src/areas/generate/orcaSlicerLink.ts @@ -0,0 +1,44 @@ +// Builds the OrcaSlicer deeplink for a generated mesh. +// +// OrcaSlicer registers the `orcaslicer://open?file=` scheme; its handler +// downloads the http(s) URL in `file=` and imports it, deriving the filename — +// and therefore the mesh format — from the URL's FINAL path segment. That means +// the served URL must be path-only and end in a real `model.` with NO +// query string, and the whole thing must be percent-encoded. OrcaSlicer cannot +// import GLB, so we point at the backend's slicer-export route which converts to +// STL on the fly. + +/** Format handed to OrcaSlicer. STL is universal and OrcaSlicer auto-repairs it. */ +export const SLICER_FORMAT = 'stl' + +/** URL-safe base64 (no padding) of a UTF-8 string — matches the API's token decode. */ +export function encodeWorkspacePathToken(workspacePath: string): string { + const bytes = new TextEncoder().encode(workspacePath) + let binary = '' + for (const b of bytes) binary += String.fromCharCode(b) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Whether a generation output can be opened in OrcaSlicer: it must be a mesh + * served from the workspace (Gaussian splats and non-workspace imports are not + * sliceable through this route). + */ +export function canOpenInOrcaSlicer(outputUrl: string | undefined): boolean { + if (!outputUrl) return false + return outputUrl.startsWith('/workspace/') && !/\.(ply|splat)$/i.test(outputUrl) +} + +/** + * Build the `orcaslicer://open?file=...` deeplink for a generated mesh. + * + * @param apiUrl Modly backend origin, e.g. `http://localhost:8765` + * @param outputUrl workspace URL of the mesh, e.g. `/workspace/Foo/hero.glb` + */ +export function buildOrcaSlicerDeepLink(apiUrl: string, outputUrl: string): string { + const workspacePath = outputUrl.replace(/^\/workspace\//, '') + const token = encodeWorkspacePathToken(workspacePath) + const base = apiUrl.replace(/\/+$/, '') + const modelUrl = `${base}/export/slicer/${SLICER_FORMAT}/${token}/model.${SLICER_FORMAT}` + return `orcaslicer://open?file=${encodeURIComponent(modelUrl)}` +} diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 1a4d6fde..b674ec00 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -154,6 +154,9 @@ declare global { shell: { openExternal: (url: string) => Promise } + slicer: { + open: (url: string) => Promise<{ success: boolean; error?: string }> + } system: { memory: () => Promise<{ total: number; used: number; available: number }> } From a0ade5329732275c08adb09a0164132c1bf3ea5a Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sat, 19 Sep 2026 17:27:27 +0200 Subject: [PATCH 6/6] fix(slicer): slice imported meshes, and stop rotating and rescaling blindly The slicer route assumed every source was a unit-sized, Y-up glTF straight from a generator, but the Export action is reachable for any mesh in the viewer. - Imported meshes (served through /optimize/serve-file) were excluded outright, so the most direct "I have a model, slice it" path offered no action at all and gave no hint why. They are now sliceable via an exact-membership registry of the files the user picked themselves this session, which keeps the route closed to arbitrary absolute paths rather than widening its path guard. - The Y->Z rotation now applies only to glTF sources. STL/OBJ/PLY are already Z-up, and import converts them to GLB without touching the axes, so the original extension decides -- not the container's. - Normalising to 50 mm now happens only for unit-sized meshes. A mesh that already carries a real-world size is the user's own, and silently shrinking a 180 mm part would waste a print. - slicer:open no longer claims it can detect a missing OrcaSlicer: on Windows an unregistered scheme still makes ShellExecuteEx succeed, so that error branch could never run. test_normalizes_longest_edge_to_default_print_size encoded the unconditional rescale, so its fixture becomes a unit-sized mesh, matching real generator output. --- api/routers/export.py | 96 ++++++++++++++++++----- api/routers/optimize.py | 6 ++ api/services/imported_sources.py | 53 +++++++++++++ api/tests/test_export_router.py | 84 +++++++++++++++++++- electron/main/ipc-handlers.ts | 9 ++- src/areas/generate/GeneratePage.tsx | 4 +- src/areas/generate/orcaSlicerLink.test.ts | 35 ++++++++- src/areas/generate/orcaSlicerLink.ts | 51 ++++++++++-- 8 files changed, 303 insertions(+), 35 deletions(-) create mode 100644 api/services/imported_sources.py diff --git a/api/routers/export.py b/api/routers/export.py index f40a9045..6cd03d61 100644 --- a/api/routers/export.py +++ b/api/routers/export.py @@ -2,11 +2,13 @@ import binascii import io import math +from pathlib import Path import trimesh from fastapi import APIRouter, HTTPException from fastapi.responses import Response, FileResponse +from services import imported_sources from services.generator_registry import WORKSPACE_DIR router = APIRouter(tags=["export"]) @@ -26,6 +28,15 @@ # in OrcaSlicer as needed. DEFAULT_PRINT_LONGEST_MM = 50.0 +# ...but this route also serves meshes the user authored or imported, which DO +# carry a real-world size. Silently resizing a 180 mm part down to 50 mm wastes +# a print, so only rescale what is small enough to be unit-sized AI output. +UNIT_SCALE_MAX = 5.0 + +# Source formats whose up-axis is Y (the glTF convention). Everything else this +# route accepts — STL, OBJ, PLY — is conventionally Z-up already. +GLTF_SUFFIXES = {".glb", ".gltf"} + def _to_single_mesh(loaded: object) -> "trimesh.Trimesh": """Flatten a loaded GLB into one Trimesh, baking scene-graph node transforms. @@ -64,6 +75,62 @@ def _scale_to_print_size(mesh: "trimesh.Trimesh", longest_mm: float = DEFAULT_PR mesh.apply_scale(longest_mm / longest) +def _normalize_print_scale(mesh: "trimesh.Trimesh") -> bool: + """Rescale ``mesh`` only if it looks unit-sized; return whether it was rescaled. + + A mesh whose longest edge already exceeds ``UNIT_SCALE_MAX`` is assumed to + carry a real-world size the user chose, and is left untouched. + """ + extents = mesh.extents + longest = float(max(extents)) if extents is not None and len(extents) else 0.0 + if not math.isfinite(longest) or not (1e-9 < longest <= UNIT_SCALE_MAX): + return False + _scale_to_print_size(mesh) + return True + + +def _resolve_slicer_source(token: str) -> tuple[Path, str]: + """Decode ``token`` into an existing source file and its ORIGINAL suffix. + + Two kinds of source are accepted: + + * a workspace-relative path, confined to the workspace by ancestry; + * an absolute path, but ONLY when the user imported that exact file this + session (see ``services.imported_sources``). Membership there is an + equality test on the resolved path, so this grants no traversal and does + not widen the route to arbitrary disk paths. + + The returned suffix is the format the user actually supplied — for an import + that is the pre-conversion extension, which is what decides the up-axis. + """ + try: + padded = token + "=" * (-len(token) % 4) + decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError): + raise HTTPException(400, "Malformed source token") + + candidate = Path(decoded) + if candidate.is_absolute(): + original_suffix = imported_sources.source_suffix(candidate) + if original_suffix is None: + raise HTTPException(400, "Invalid path") + full_path = candidate.resolve() + if not full_path.is_file(): + raise HTTPException(404, f"File not found: {decoded}") + return full_path, original_suffix + + # Containment check via ancestry, not string prefix: `startswith` would let a + # sibling like `-other/...` slip through, and `..` escapes resolve + # outside the workspace and fail this check. + workspace = WORKSPACE_DIR.resolve() + full_path = (workspace / decoded).resolve() + if full_path != workspace and workspace not in full_path.parents: + raise HTTPException(400, "Invalid path") + if not full_path.is_file(): + raise HTTPException(404, f"File not found: {decoded}") + return full_path, full_path.suffix.lower() + + @router.get("/slicer/{fmt}/{token}/{filename}") def export_for_slicer(fmt: str, token: str, filename: str): """Serve a generated GLB converted to a slicer-importable mesh, at a URL @@ -74,8 +141,10 @@ def export_for_slicer(fmt: str, token: str, filename: str): downloads the URL and derives the import filename — and therefore the mesh format — from the URL's FINAL path segment, so a query string (``?path=...``) would corrupt the parsed extension and the model would silently fail to - import. ``token`` is the url-safe-base64 of the workspace-relative source - path; ``filename`` (e.g. ``model.stl``) is what OrcaSlicer names the download. + import. ``token`` is the url-safe-base64 of the source path — workspace- + relative, or absolute for a file the user imported this session (see + ``_resolve_slicer_source``); ``filename`` (e.g. ``model.stl``) is what + OrcaSlicer names the download. """ fmt = fmt.lower() if fmt not in SLICER_FORMATS: @@ -83,28 +152,17 @@ def export_for_slicer(fmt: str, token: str, filename: str): if not filename.lower().endswith(f".{fmt}"): raise HTTPException(400, "Filename must end with the requested format extension") - try: - padded = token + "=" * (-len(token) % 4) - rel_path = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") - except (binascii.Error, UnicodeDecodeError, ValueError): - raise HTTPException(400, "Malformed source token") - - # Containment check via ancestry, not string prefix: `startswith` would let a - # sibling like `-other/...` slip through, and `..` escapes resolve - # outside the workspace and fail this check. - workspace = WORKSPACE_DIR.resolve() - full_path = (workspace / rel_path).resolve() - if full_path != workspace and workspace not in full_path.parents: - raise HTTPException(400, "Invalid path") - if not full_path.is_file(): - raise HTTPException(404, f"File not found: {rel_path}") + full_path, source_suffix = _resolve_slicer_source(token) mesh = _to_single_mesh(trimesh.load(str(full_path))) # glTF/GLB is Y-up; OrcaSlicer's world is Z-up. Rotate +90° about X so the # model imports standing upright instead of on its side. (Modly's own viewer # rests generated meshes on the Y=0 plane, confirming Y is the up axis.) - mesh.apply_transform(trimesh.transformations.rotation_matrix(math.pi / 2, [1, 0, 0])) - _scale_to_print_size(mesh) + # STL/OBJ/PLY sources are already Z-up, so rotating them would do the very + # thing this corrects — lay an upright model on its side. + if source_suffix in GLTF_SUFFIXES: + mesh.apply_transform(trimesh.transformations.rotation_matrix(math.pi / 2, [1, 0, 0])) + _normalize_print_scale(mesh) data = mesh.export(file_type=fmt) if isinstance(data, str): diff --git a/api/routers/optimize.py b/api/routers/optimize.py index 6081c704..de1afa77 100644 --- a/api/routers/optimize.py +++ b/api/routers/optimize.py @@ -21,6 +21,7 @@ from urllib.parse import quote from pydantic import BaseModel +from services import imported_sources from services.generator_registry import WORKSPACE_DIR router = APIRouter(tags=["optimize"]) @@ -420,6 +421,7 @@ async def import_mesh_by_path(body: ImportByPathRequest): if ext == "glb": # Serve the original file directly — no copy + imported_sources.register(file_path, file_path) return {"url": f"/optimize/serve-file?path={quote(str(file_path))}"} # Mesh ply / obj / stl: convert to GLB in a temp directory (not the workspace) @@ -427,6 +429,10 @@ async def import_mesh_by_path(body: ImportByPathRequest): output_path = os.path.join(tmp_dir, "mesh.glb") loaded = trimesh.load(str(file_path)) loaded.export(output_path) + # Remember the ORIGINAL extension: this conversion changes the container but + # not the axes, so a .stl imported here still holds Z-up data despite the + # .glb suffix, and must not be rotated as if it were glTF. + imported_sources.register(output_path, file_path) return {"url": f"/optimize/serve-file?path={quote(output_path)}"} diff --git a/api/services/imported_sources.py b/api/services/imported_sources.py new file mode 100644 index 00000000..a21dded5 --- /dev/null +++ b/api/services/imported_sources.py @@ -0,0 +1,53 @@ +"""Registry of meshes the user explicitly imported from outside the workspace. + +`/optimize/import-by-path` serves files the user picked through the OS file +dialog, either in place or converted into a temp dir — never from the workspace. +The slicer export route confines itself to the workspace by design, so those +imports would be unsliceable without widening that guard to arbitrary absolute +paths, which would be a real regression. + +This registry is the narrow alternative: a route may accept an absolute path +only if it is an EXACT member here, i.e. a file the user chose themselves in +this session. Membership is an equality test, never a prefix test, so it grants +no traversal. + +It also remembers each file's ORIGINAL suffix. An imported `.stl` is converted +to GLB on import without any axis change, so the container extension alone would +mislead a consumer into applying the glTF Y-up -> Z-up rotation to data that is +already Z-up. + +Session-scoped and in-memory: it is emptied when the backend restarts, so a +deeplink replayed after a restart is rejected rather than silently served. Links +are consumed within a second of the click, so this is not worth persisting. +""" + +from pathlib import Path + +# resolved absolute path (str) -> original suffix, lowercase, with the dot +_SOURCES: dict[str, str] = {} + + +def register(served_path: str | Path, original_path: str | Path) -> str: + """Record a user-imported file as sliceable and return its resolved path. + + ``served_path`` is what gets served (possibly a converted temp GLB); + ``original_path`` is the file the user actually picked, whose suffix decides + the source format. + """ + resolved = str(Path(served_path).resolve()) + _SOURCES[resolved] = Path(original_path).suffix.lower() + return resolved + + +def source_suffix(path: str | Path) -> str | None: + """Original suffix of a registered import, or None if it is not registered.""" + return _SOURCES.get(str(Path(path).resolve())) + + +def is_registered(path: str | Path) -> bool: + return source_suffix(path) is not None + + +def clear() -> None: + """Drop every entry — for tests.""" + _SOURCES.clear() diff --git a/api/tests/test_export_router.py b/api/tests/test_export_router.py index bc07188f..0972e836 100644 --- a/api/tests/test_export_router.py +++ b/api/tests/test_export_router.py @@ -13,6 +13,7 @@ import trimesh import routers.export as export_router + from services import imported_sources HAVE_TRIMESH = True except Exception: # noqa: BLE001 @@ -34,9 +35,10 @@ def setUp(self) -> None: self.workspace = Path(self._tmp.name).resolve() self._orig_workspace = export_router.WORKSPACE_DIR export_router.WORKSPACE_DIR = self.workspace - # A box that is tallest along Y (glTF up-axis). Exported to GLB, it - # reloads as a Scene so the flatten path is exercised too. - box = trimesh.creation.box(extents=[10.0, 30.0, 10.0]) + # A box that is tallest along Y (glTF up-axis) and unit-sized, matching + # what image-to-3D generators emit. Exported to GLB, it reloads as a + # Scene so the flatten path is exercised too. + box = trimesh.creation.box(extents=[0.3, 1.0, 0.3]) self.rel = "Workflows/hero.glb" (self.workspace / "Workflows").mkdir(parents=True, exist_ok=True) box.export(str(self.workspace / self.rel)) @@ -64,6 +66,24 @@ def test_normalizes_longest_edge_to_default_print_size(self) -> None: longest = float(max(_load_stl(resp).extents)) self.assertAlmostEqual(longest, export_router.DEFAULT_PRINT_LONGEST_MM, places=3) + def test_leaves_a_real_world_sized_mesh_alone(self) -> None: + # A mesh that already carries a physical size is the user's own: silently + # shrinking a 180 mm part to 50 mm would waste a print. + big = trimesh.creation.box(extents=[40.0, 180.0, 40.0]) + rel = "Workflows/part.glb" + big.export(str(self.workspace / rel)) + resp = export_router.export_for_slicer("stl", _token(rel), "model.stl") + self.assertAlmostEqual(float(max(_load_stl(resp).extents)), 180.0, places=2) + + def test_does_not_rotate_a_z_up_stl_source(self) -> None: + # STL is conventionally Z-up already; the glTF Y->Z rotation would lay an + # upright model on its side. + rel = "Workflows/upright.stl" + trimesh.creation.box(extents=[10.0, 10.0, 30.0]).export(str(self.workspace / rel)) + resp = export_router.export_for_slicer("stl", _token(rel), "model.stl") + ex = _load_stl(resp).extents + self.assertEqual(int(np.argmax(ex)), 2, f"expected Z to stay the tallest axis, got extents {ex}") + def test_rejects_unsupported_format(self) -> None: with self.assertRaises(HTTPException) as ctx: export_router.export_for_slicer("glb", _token(self.rel), "model.glb") @@ -126,5 +146,63 @@ def test_scale_ignores_degenerate_mesh(self) -> None: export_router._scale_to_print_size(mesh) # must not raise +@unittest.skipUnless(HAVE_TRIMESH, "trimesh not installed") +class ImportedSourceSlicerTests(unittest.TestCase): + """Meshes imported from outside the workspace are sliceable — but only the + exact files the user picked, and never with the wrong up-axis.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.outside = Path(self._tmp.name).resolve() + self._orig_workspace = export_router.WORKSPACE_DIR + # A workspace elsewhere, so nothing here is reachable as a relative path. + self._ws_tmp = tempfile.TemporaryDirectory() + export_router.WORKSPACE_DIR = Path(self._ws_tmp.name).resolve() + imported_sources.clear() + # Unit-sized and tallest along Y, as a glTF export would be. + self.mesh_path = self.outside / "imported.glb" + trimesh.creation.box(extents=[0.3, 1.0, 0.3]).export(str(self.mesh_path)) + + def tearDown(self) -> None: + export_router.WORKSPACE_DIR = self._orig_workspace + imported_sources.clear() + self._tmp.cleanup() + self._ws_tmp.cleanup() + + def test_rejects_an_absolute_path_that_was_never_imported(self) -> None: + # The whole point of the registry: an absolute path alone buys nothing. + with self.assertRaises(HTTPException) as ctx: + export_router.export_for_slicer("stl", _token(str(self.mesh_path)), "model.stl") + self.assertEqual(ctx.exception.status_code, 400) + + def test_serves_a_registered_import(self) -> None: + imported_sources.register(self.mesh_path, self.mesh_path) + resp = export_router.export_for_slicer("stl", _token(str(self.mesh_path)), "model.stl") + self.assertEqual(resp.media_type, "model/stl") + self.assertGreater(len(_load_stl(resp).faces), 0) + + def test_rotates_a_registered_gltf_import(self) -> None: + imported_sources.register(self.mesh_path, self.mesh_path) + resp = export_router.export_for_slicer("stl", _token(str(self.mesh_path)), "model.stl") + ex = _load_stl(resp).extents + self.assertEqual(int(np.argmax(ex)), 2, f"expected Z to be the tallest axis, got extents {ex}") + + def test_does_not_rotate_an_import_that_was_stl_before_conversion(self) -> None: + # `import-by-path` converts STL/OBJ/PLY to GLB without touching the axes, + # so the .glb container here still holds Z-up data. Rotating it would be + # exactly the bug the rotation exists to prevent. + imported_sources.register(self.mesh_path, self.outside / "original.stl") + resp = export_router.export_for_slicer("stl", _token(str(self.mesh_path)), "model.stl") + ex = _load_stl(resp).extents + self.assertEqual(int(np.argmax(ex)), 1, f"expected Y to stay the tallest axis, got extents {ex}") + + def test_registered_but_deleted_file_is_404(self) -> None: + imported_sources.register(self.mesh_path, self.mesh_path) + self.mesh_path.unlink() + with self.assertRaises(HTTPException) as ctx: + export_router.export_for_slicer("stl", _token(str(self.mesh_path)), "model.stl") + self.assertEqual(ctx.exception.status_code, 404) + + if __name__ == "__main__": unittest.main() diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index f097a559..3c027340 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -596,8 +596,13 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe ipcMain.handle('shell:openExternal', (_, url: string) => shell.openExternal(url)) // Open a model in OrcaSlicer via its orcaslicer://open?file= deeplink. - // Returns success/error so the renderer can surface a fallback (e.g. when - // OrcaSlicer is not installed and no app is registered for the scheme). + // + // The returned error only covers the shell refusing the call outright. It is + // NOT an install check: on Windows an unregistered scheme still makes + // ShellExecuteEx succeed — the OS shows its own "You'll need a new app to open + // this orcaslicer link" dialog and this resolves with success. Detecting a + // missing OrcaSlicer would take a per-platform handler probe (registry on + // Windows), so the renderer must not promise the user that it knows. ipcMain.handle('slicer:open', async (_, url: string): Promise<{ success: boolean; error?: string }> => { if (typeof url !== 'string' || !url.startsWith('orcaslicer://')) { return { success: false, error: 'slicer:open requires an orcaslicer:// URL' } diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index f970993c..87c7a18d 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -689,7 +689,9 @@ export default function GeneratePage(): JSX.Element { const link = buildOrcaSlicerDeepLink(apiUrl, currentJob.outputUrl) const result = await window.electron.slicer.open(link) if (!result.success) { - showError(result.error ?? 'Could not open OrcaSlicer. Make sure it is installed.') + // Deliberately not "make sure it is installed": the main process cannot + // tell a missing OrcaSlicer from a working one (see slicer:open). + showError(result.error ?? 'Could not open OrcaSlicer.') } } catch (err) { showError(err instanceof Error ? err.message : 'Could not open OrcaSlicer.') diff --git a/src/areas/generate/orcaSlicerLink.test.ts b/src/areas/generate/orcaSlicerLink.test.ts index 2f7e47d3..514618eb 100644 --- a/src/areas/generate/orcaSlicerLink.test.ts +++ b/src/areas/generate/orcaSlicerLink.test.ts @@ -38,12 +38,43 @@ test('strips a trailing slash from the api origin', () => { assert.equal(modelUrl, `http://localhost:8765/export/slicer/stl/${encodeWorkspacePathToken('a.glb')}/model.stl`) }) -test('canOpenInOrcaSlicer accepts workspace meshes and rejects splats, imports, and empty', () => { +test('canOpenInOrcaSlicer accepts sliceable workspace meshes and rejects splats and empty', () => { assert.equal(canOpenInOrcaSlicer('/workspace/Foo/hero.glb'), true) + assert.equal(canOpenInOrcaSlicer('/workspace/Foo/part.stl'), true) + assert.equal(canOpenInOrcaSlicer('/workspace/Foo/part.obj'), true) + // Gaussian splats are point clouds, not printable meshes. assert.equal(canOpenInOrcaSlicer('/workspace/Foo/scan.ply'), false) assert.equal(canOpenInOrcaSlicer('/workspace/Foo/scan.splat'), false) - assert.equal(canOpenInOrcaSlicer('/optimize/serve-file?path=/tmp/x.glb'), false) + // A format the slicer route cannot convert. + assert.equal(canOpenInOrcaSlicer('/workspace/Foo/rig.fbx'), false) assert.equal(canOpenInOrcaSlicer(undefined), false) + assert.equal(canOpenInOrcaSlicer(''), false) +}) + +test('canOpenInOrcaSlicer accepts an imported mesh served from outside the workspace', () => { + // `Import` serves user-picked files through /optimize/serve-file, never from + // the workspace — excluding those made the action invisible for the most + // direct "I have a model, slice it" path. + assert.equal(canOpenInOrcaSlicer('/optimize/serve-file?path=%2Ftmp%2Fx.glb'), true) + assert.equal(canOpenInOrcaSlicer('/optimize/serve-file?path=C%3A%5CUsers%5CMe%5Cmage.glb'), true) + assert.equal(canOpenInOrcaSlicer('/optimize/serve-file?path=%2Ftmp%2Fscan.splat'), false) +}) + +test('deeplink for an imported mesh tokenises the decoded absolute path', () => { + const absolute = String.raw`C:\Users\Me\Downloads\mage_v2.glb` + const outputUrl = `/optimize/serve-file?path=${encodeURIComponent(absolute)}` + const modelUrl = decodeURIComponent( + buildOrcaSlicerDeepLink('http://localhost:8765', outputUrl).slice('orcaslicer://open?file='.length), + ) + assert.equal( + modelUrl, + `http://localhost:8765/export/slicer/stl/${encodeWorkspacePathToken(absolute)}/model.stl`, + ) + assert.ok(!modelUrl.includes('?'), 'model URL must not contain a query string') +}) + +test('buildOrcaSlicerDeepLink refuses an output it cannot slice', () => { + assert.throws(() => buildOrcaSlicerDeepLink('http://localhost:8765', '/workspace/Foo/scan.splat')) }) test('SLICER_FORMAT is a format OrcaSlicer can import', () => { diff --git a/src/areas/generate/orcaSlicerLink.ts b/src/areas/generate/orcaSlicerLink.ts index 2bade12e..d5a76c9b 100644 --- a/src/areas/generate/orcaSlicerLink.ts +++ b/src/areas/generate/orcaSlicerLink.ts @@ -11,6 +11,15 @@ /** Format handed to OrcaSlicer. STL is universal and OrcaSlicer auto-repairs it. */ export const SLICER_FORMAT = 'stl' +/** Prefix of an imported mesh served from outside the workspace. */ +const SERVE_FILE_PREFIX = '/optimize/serve-file?path=' + +/** + * Source formats the slicer route can convert. Gaussian splats (`.splat`, and + * the `.ply` they are delivered in) are point clouds, not printable meshes. + */ +const SLICEABLE_SOURCE = /\.(glb|gltf|obj|stl)$/i + /** URL-safe base64 (no padding) of a UTF-8 string — matches the API's token decode. */ export function encodeWorkspacePathToken(workspacePath: string): string { const bytes = new TextEncoder().encode(workspacePath) @@ -20,24 +29,50 @@ export function encodeWorkspacePathToken(workspacePath: string): string { } /** - * Whether a generation output can be opened in OrcaSlicer: it must be a mesh - * served from the workspace (Gaussian splats and non-workspace imports are not - * sliceable through this route). + * The source path the slicer route should convert, or `undefined` when the + * output cannot be sliced. + * + * Two output shapes reach the viewer: a workspace URL (generated or + * workflow-produced meshes) and a `serve-file` URL (meshes the user imported + * from elsewhere on disk). Both are sliceable — the API accepts the absolute + * path of an import because it recorded that the user picked it themselves. + */ +function sliceableSourcePath(outputUrl: string | undefined): string | undefined { + if (!outputUrl) return undefined + + if (outputUrl.startsWith('/workspace/')) { + const workspacePath = outputUrl.slice('/workspace/'.length) + return SLICEABLE_SOURCE.test(workspacePath) ? workspacePath : undefined + } + + if (outputUrl.startsWith(SERVE_FILE_PREFIX)) { + const absolutePath = decodeURIComponent(outputUrl.slice(SERVE_FILE_PREFIX.length)) + return SLICEABLE_SOURCE.test(absolutePath) ? absolutePath : undefined + } + + return undefined +} + +/** + * Whether a generation output can be opened in OrcaSlicer: it must be a mesh in + * a format the slicer route converts, served either from the workspace or as a + * user-selected import. */ export function canOpenInOrcaSlicer(outputUrl: string | undefined): boolean { - if (!outputUrl) return false - return outputUrl.startsWith('/workspace/') && !/\.(ply|splat)$/i.test(outputUrl) + return sliceableSourcePath(outputUrl) !== undefined } /** * Build the `orcaslicer://open?file=...` deeplink for a generated mesh. * * @param apiUrl Modly backend origin, e.g. `http://localhost:8765` - * @param outputUrl workspace URL of the mesh, e.g. `/workspace/Foo/hero.glb` + * @param outputUrl workspace or serve-file URL of the mesh + * @throws if `outputUrl` is not sliceable — guard with {@link canOpenInOrcaSlicer} */ export function buildOrcaSlicerDeepLink(apiUrl: string, outputUrl: string): string { - const workspacePath = outputUrl.replace(/^\/workspace\//, '') - const token = encodeWorkspacePathToken(workspacePath) + const sourcePath = sliceableSourcePath(outputUrl) + if (!sourcePath) throw new Error(`Not sliceable: ${outputUrl}`) + const token = encodeWorkspacePathToken(sourcePath) const base = apiUrl.replace(/\/+$/, '') const modelUrl = `${base}/export/slicer/${SLICER_FORMAT}/${token}/model.${SLICER_FORMAT}` return `orcaslicer://open?file=${encodeURIComponent(modelUrl)}`