From ae097624da884a556e9d7466fcd79a05429e09b1 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 21 Aug 2026 10:39:23 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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 }> }