diff --git a/.github/workflows/assign-command.yml b/.github/workflows/assign-command.yml new file mode 100644 index 00000000..96c868f6 --- /dev/null +++ b/.github/workflows/assign-command.yml @@ -0,0 +1,157 @@ +name: Assign command + +on: + issue_comment: + types: [created] + +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: + 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.`, + }); + core.setOutput('assigned', 'false'); + 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.`, + }); + 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: + 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.`, + }); + core.setOutput('unassigned', 'false'); + 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.`, + }); + 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.`); + } 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. 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/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 0d166b46..fdedfaad 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", @@ -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 }> }