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..6cd03d61 100644 --- a/api/routers/export.py +++ b/api/routers/export.py @@ -1,15 +1,180 @@ +import base64 +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"]) 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 + +# ...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. + + ``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) + + +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 + 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 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: + 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") + + 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.) + # 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): + 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/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 new file mode 100644 index 00000000..0972e836 --- /dev/null +++ b/api/tests/test_export_router.py @@ -0,0 +1,208 @@ +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 + from services import imported_sources + + 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) 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)) + + 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_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") + 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 + + +@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 005f1f78..3c027340 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -595,6 +595,26 @@ 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. + // + // 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' } + } + 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..87c7a18d 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,21 @@ 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) { + // 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.') + } + } + function getOptimizePath(url: string): string { if (url.startsWith('/workspace/')) { return url.slice('/workspace/'.length) @@ -971,6 +1009,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..514618eb --- /dev/null +++ b/src/areas/generate/orcaSlicerLink.test.ts @@ -0,0 +1,82 @@ +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 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) + // 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', () => { + 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..d5a76c9b --- /dev/null +++ b/src/areas/generate/orcaSlicerLink.ts @@ -0,0 +1,79 @@ +// 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' + +/** 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) + let binary = '' + for (const b of bytes) binary += String.fromCharCode(b) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * 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 { + 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 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 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)}` +} 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 }> }