mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 22:52:25 -05:00
feat(extension): add normalized download for MakerWorld
This commit is contained in:
1 parent
3678ed18aa
commit
2380c566e5
19 files changed
+1789
-17
No files matched your search
@@ -84,7 +84,7 @@ Instead of having your collection scattered across different websites and your f
|
||||
Thingport ships two small companion tools, each downloadable from the in-app Download page or GitHub Releases:
|
||||
|
||||
- **[Thingport Bridge](bridge/README.md)** — a lightweight desktop helper that makes "Open in {Slicer}" work for slicers (Bambu Studio, PrusaSlicer, Cura, Anycubic Slicer Next) whose own URL-protocol handlers won't accept a link from a self-hosted domain.
|
||||
- **[Thingport Grab](extension/README.md)** — a browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, and Printables models straight from their own pages, without leaving the site (see the screenshots above). Get it from [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/thingport-grab/) or [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kahfidpmojfocohinlmglnfoaimocbol).
|
||||
- **[Thingport Grab](extension/README.md)** — a browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, and Printables models straight from their own pages, without leaving the site (see the screenshots above). Even without a Thingport instance, it adds a "Download normalized" button to MakerWorld model pages that converts Bambu Studio projects into 3MFs other slicers open with their colours and settings. Get it from [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/thingport-grab/) or [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kahfidpmojfocohinlmglnfoaimocbol).
|
||||
|
||||
## Provider Setup
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@ src/
|
||||
background/ background script -- config/auth, every request to the Thingport instance,
|
||||
imports, the guided MakerWorld collection job, recent imports, toolbar icon
|
||||
content/ content script -- the floating icon, its panel flows (panels/), the setup dialog,
|
||||
overlays, MakerWorld page-data and download-URL resolution (makerworld/)
|
||||
overlays, MakerWorld page-data and download-URL resolution, and the injected
|
||||
Download normalized button (makerworld/)
|
||||
styles/ its SCSS, compiled into the bundle and injected into its shadow root
|
||||
normalizer/ the Bambu 3MF normalizer and the worker that runs it
|
||||
popup/ toolbar popup (popup.html, its script and styles/)
|
||||
styles/ SCSS design tokens and mixins shared by the popup and the content UI
|
||||
assets/ the Thingport icon SVG (inlined into the bundles)
|
||||
@@ -46,6 +48,12 @@ The content script and the popup never call the Thingport instance themselves: t
|
||||
message to the background script, which owns the credentials and the instance's host permission.
|
||||
Every message and its reply is typed in `src/shared/messages.ts`.
|
||||
|
||||
`src/normalizer/threeMfNormalizer.ts` is a copy of the backend's
|
||||
`backend/src/services/threeMfNormalizer.ts` (the Mozilla source zip can only hold this folder), and
|
||||
`src/shared/slicers.ts` mirrors the web app's list of slicers that need it. Change them together.
|
||||
The normalizer runs in a worker, bundled into the content script as a string through the build's
|
||||
`?worker` imports; where a page's CSP blocks that worker, it runs on the page's main thread instead.
|
||||
|
||||
Colors, spacing and type live in `src/styles/_tokens.scss`. Colors are CSS custom properties with a
|
||||
light and a dark palette: the popup follows the browser's color scheme, while the in-page UI always
|
||||
uses the light one.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Thingport Grab privacy policy
|
||||
|
||||
_Effective 29 September 2026._
|
||||
_Effective 30 September 2026._
|
||||
|
||||
Thingport Grab is a browser extension that imports 3D-printing models from MakerWorld, Printables
|
||||
and Thingiverse into **your own self-hosted Thingport instance**. It has no servers of its own: the
|
||||
and Thingiverse into **your own self-hosted Thingport instance**, and converts MakerWorld files for
|
||||
other slicers in your browser. It has no servers of its own: the
|
||||
extension's developer never receives any of your data, and nothing is shared with anyone else.
|
||||
|
||||
## What the extension stores
|
||||
@@ -35,6 +36,8 @@ the **provider site you're on**.
|
||||
knows whether to show its button. On a MakerWorld collection page, the same check runs for each
|
||||
model in the collection when you open the extension's panel there.
|
||||
- The address of whatever you import, and your choice of collection, when you import it.
|
||||
- A request for the slicer picked in your Thingport profile, when a MakerWorld model page opens, so
|
||||
the extension knows whether to show its **Download normalized** button.
|
||||
- If you're logged into MakerWorld in this browser: your MakerWorld session cookie, when you import
|
||||
from MakerWorld. MakerWorld requires it to download files. The extension also saves it to your
|
||||
Thingport account's MakerWorld setting, so imports made from Thingport's own web app work too.
|
||||
@@ -46,6 +49,9 @@ the **provider site you're on**.
|
||||
It may also click the page's own Download button and read the link from the download that
|
||||
starts; that download is cancelled and removed from your download history straight away, so no
|
||||
file is saved.
|
||||
- When you click **Download normalized** on a MakerWorld model page, the extension downloads that
|
||||
model's file from MakerWorld, as the page's own Download button would, converts it inside your
|
||||
browser and saves the result to your downloads. The file isn't sent anywhere else.
|
||||
- On a MakerWorld collection import, the extension moves your tab from one model page to the next,
|
||||
just as if you were clicking through them.
|
||||
|
||||
@@ -67,7 +73,7 @@ model, and the model links listed on collection pages. It doesn't read any other
|
||||
| Storage | Keeps the settings and data listed under "What the extension stores". |
|
||||
| Cookies | Reads MakerWorld's session cookie, only on makerworld.com, for MakerWorld imports. |
|
||||
| Downloads | Reads the link from the MakerWorld download it starts, then cancels and removes that download. |
|
||||
| Access to MakerWorld, Printables and Thingiverse | Shows the import button on their pages. |
|
||||
| Access to MakerWorld, Printables and Thingiverse | Shows the import button, and MakerWorld's Download normalized button, on their pages. |
|
||||
| Access to your Thingport instance | Requested for that one address when you set the extension up, so it can talk to your instance. |
|
||||
|
||||
## Your control over your data
|
||||
|
||||
+25
-1
@@ -6,12 +6,16 @@ Visiting a model, collection, or Thingiverse Likes page shows a floating Thingpo
|
||||
opens a small panel to pick what to import (and, for a single model, an optional destination
|
||||
collection), then imports it the same way Thingport's own "+ Add > Import" does.
|
||||
|
||||
It's useful without Thingport too: on MakerWorld model pages it adds a **Download normalized**
|
||||
button that turns the Bambu Studio project into a 3MF that PrusaSlicer, Cura and other slicers open
|
||||
with its colors and print settings intact. See [Download normalized](#download-normalized-no-thingport-needed).
|
||||
|
||||
**Get it from [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/thingport-grab/) or
|
||||
[Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kahfidpmojfocohinlmglnfoaimocbol).**
|
||||
The Chrome build is on the [releases page](#install) until its store listing is live.
|
||||
|
||||
It talks directly to your Thingport instance's API from the extension's background script -- no
|
||||
separate server, no data sent anywhere else. See the [privacy policy](PRIVACY.md) for exactly what
|
||||
separate server, no data sent anywhere else -- and converts files in your browser. See the [privacy policy](PRIVACY.md) for exactly what
|
||||
it stores and sends.
|
||||
|
||||
## Screenshots
|
||||
@@ -99,6 +103,26 @@ As a developer-mode "unpacked" extension, until the Chrome Web Store listing is
|
||||
4. Click the new Thingport icon in your toolbar, enter your instance's URL and your Thingport
|
||||
login, and save.
|
||||
|
||||
## Download normalized (no Thingport needed)
|
||||
|
||||
MakerWorld's files are Bambu Studio projects, which PrusaSlicer, Cura, Anycubic Slicer Next,
|
||||
Creality Print, Elegoo Slicer and Snapmaker Orca often open without their colors or print settings.
|
||||
On a MakerWorld model page, the extension adds a **Download normalized** button next to MakerWorld's
|
||||
own Download button. Clicking it downloads the selected print profile's file, converts it, and saves
|
||||
a `<name>-normalized.3mf` that keeps painted colors, filament colors, plates and the designer's
|
||||
settings (walls, layer height, infill, supports). Multi-part objects may be merged into one,
|
||||
per-object overrides and layer color changes are dropped, and you pick your own printer profile.
|
||||
Hover the bulb on the button for the same summary.
|
||||
|
||||
- **Who sees it:** everyone who hasn't connected the extension to a Thingport instance, and people
|
||||
who have, when their Thingport profile's slicer is one of those above (with Bambu Studio or
|
||||
OrcaSlicer picked, the original file is the right one, so the button stays hidden).
|
||||
- **Where it runs:** entirely in your browser, the same conversion Thingport's "Open normalized"
|
||||
does on the server. The file goes nowhere but your downloads folder.
|
||||
- **Limits:** you need to be signed in to MakerWorld, as for its own Download button, and files over
|
||||
50 MB are too big to convert in the browser -- the button says so. It's found by its label, so it
|
||||
only appears while MakerWorld is in English.
|
||||
|
||||
## Setup
|
||||
|
||||
The popup asks for your instance URL (e.g. `https://thingport.example.com`) and your Thingport
|
||||
|
||||
Generated
+17
-3
@@ -16,6 +16,7 @@
|
||||
"@types/node": "^20.19.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"fflate": "^0.8.3",
|
||||
"jszip": "^3.10.2",
|
||||
"oxlint": "^1.85.0",
|
||||
"playwright": "^1.63.0",
|
||||
"sass": "^1.105.0",
|
||||
@@ -5661,9 +5662,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
|
||||
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
@@ -11000,6 +11001,19 @@
|
||||
"npm": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-ext/node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/web-worker": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "thingport-grab",
|
||||
"private": true,
|
||||
"version": "1.1.2",
|
||||
"description": "One-click import of MakerWorld, Thingiverse, and Printables models into your self-hosted Thingport instance.",
|
||||
"description": "Import MakerWorld, Thingiverse and Printables models into Thingport, and convert MakerWorld 3MFs for PrusaSlicer, Cura and more.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -32,6 +32,7 @@
|
||||
"@types/node": "^20.19.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"fflate": "^0.8.3",
|
||||
"jszip": "^3.10.2",
|
||||
"oxlint": "^1.85.0",
|
||||
"playwright": "^1.63.0",
|
||||
"sass": "^1.105.0",
|
||||
|
||||
@@ -45,6 +45,36 @@ const scssPlugin: esbuild.Plugin = {
|
||||
},
|
||||
};
|
||||
|
||||
/** `?worker` imports compile to that file's bundled source as a string, for a blob: URL worker (a
|
||||
* content script can't start one from an extension URL). */
|
||||
const workerPlugin: esbuild.Plugin = {
|
||||
name: "worker",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /\?worker$/ }, (args) => {
|
||||
const file = path.resolve(args.resolveDir, `${args.path.replace(/\?worker$/, "")}.ts`);
|
||||
return { path: path.relative(ROOT, file).split(path.sep).join("/"), namespace: "worker" };
|
||||
});
|
||||
build.onLoad({ filter: /.*/, namespace: "worker" }, async (args) => {
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [path.join(ROOT, args.path)],
|
||||
absWorkingDir: ROOT,
|
||||
bundle: true,
|
||||
write: false,
|
||||
metafile: true,
|
||||
format: "iife",
|
||||
target: build.initialOptions.target,
|
||||
minify: false,
|
||||
legalComments: "none",
|
||||
});
|
||||
return {
|
||||
contents: result.outputFiles[0].text,
|
||||
loader: "text",
|
||||
watchFiles: Object.keys(result.metafile.inputs).map((input) => path.join(ROOT, input)),
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function staticFilesPlugin(
|
||||
target: Target,
|
||||
outdir: string,
|
||||
@@ -88,7 +118,7 @@ async function buildTarget(target: Target, watch: boolean): Promise<void> {
|
||||
sourcemap: watch ? "inline" : false,
|
||||
legalComments: "none",
|
||||
loader: { ".svg": "text" },
|
||||
plugins: [scssPlugin, staticFilesPlugin(target, outdir, pkg)],
|
||||
plugins: [scssPlugin, workerPlugin, staticFilesPlugin(target, outdir, pkg)],
|
||||
logLevel: "info",
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CONFIG_CHANGE_KEYS } from "../shared/storage";
|
||||
import { classifyUrl } from "../shared/urls";
|
||||
import { setContext, type LibraryState } from "./context";
|
||||
import { resolveMakerworldDownloadUrl } from "./makerworld/downloadResolver";
|
||||
import { offerNormalizedDownload, unmountNormalizedDownload } from "./makerworld/normalizedDownload";
|
||||
import { mountJobOverlay } from "./overlays";
|
||||
import { loadPanel } from "./panels";
|
||||
import { errorHtml } from "./panels/results";
|
||||
@@ -29,6 +30,7 @@ function reportTabIconState(active: boolean): void {
|
||||
|
||||
function unmount(): void {
|
||||
closeSetupModal();
|
||||
unmountNormalizedDownload();
|
||||
unmountHost();
|
||||
setContext(null);
|
||||
}
|
||||
@@ -60,6 +62,10 @@ async function init(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (classification.provider === "makerworld" && classification.kind === "single") {
|
||||
void offerNormalizedDownload(url, stateRes.data.configured, isStale);
|
||||
}
|
||||
|
||||
// Not set up: still show a grayed-out icon on importable pages so the extension is discoverable.
|
||||
if (!stateRes.data.configured) {
|
||||
const root = mountHost();
|
||||
|
||||
@@ -42,11 +42,14 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
|
||||
}
|
||||
|
||||
/** Matched by text (class names are build hashes), visible elements only. */
|
||||
function findDownloadButton(): HTMLElement | null {
|
||||
export function isDownloadButton(el: Element): el is HTMLElement {
|
||||
if (!/^download$/i.test((el.textContent || "").trim())) return false;
|
||||
return (el as HTMLElement).offsetParent !== null; // hidden (display:none or detached)
|
||||
}
|
||||
|
||||
export function findDownloadButton(): HTMLElement | null {
|
||||
for (const el of document.querySelectorAll<HTMLElement>("button, a")) {
|
||||
if (!/^download$/i.test((el.textContent || "").trim())) continue;
|
||||
if (el.offsetParent === null) continue; // hidden (display:none or detached)
|
||||
return el;
|
||||
if (isDownloadButton(el)) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Runs the 3MF normalizer in a worker so a big model doesn't freeze the tab. Where the page's CSP
|
||||
// won't allow a blob: worker, it runs here instead.
|
||||
|
||||
import { normalize3mf } from "../../normalizer/threeMfNormalizer";
|
||||
import type { WorkerReply } from "../../normalizer/worker";
|
||||
import workerSource from "../../normalizer/worker?worker";
|
||||
|
||||
class WorkerUnavailable extends Error {}
|
||||
|
||||
function normalizeInWorker(input: ArrayBuffer): Promise<Uint8Array> {
|
||||
const url = URL.createObjectURL(new Blob([workerSource], { type: "text/javascript" }));
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(url);
|
||||
} catch {
|
||||
reject(new WorkerUnavailable());
|
||||
return;
|
||||
}
|
||||
let started = false;
|
||||
worker.addEventListener("message", (event: MessageEvent<WorkerReply>) => {
|
||||
const message = event.data;
|
||||
if (message.type === "ready") {
|
||||
started = true;
|
||||
// Copied rather than transferred, so the fallback still has it if this fails.
|
||||
worker.postMessage(input);
|
||||
return;
|
||||
}
|
||||
worker.terminate();
|
||||
if (message.type === "done") resolve(message.bytes);
|
||||
else reject(new Error(message.error));
|
||||
});
|
||||
worker.addEventListener("error", (event) => {
|
||||
worker.terminate();
|
||||
reject(started ? new Error(event.message || "Normalizing failed") : new WorkerUnavailable());
|
||||
});
|
||||
}).finally(() => URL.revokeObjectURL(url));
|
||||
}
|
||||
|
||||
export async function normalizeBambu3mf(input: ArrayBuffer): Promise<Uint8Array> {
|
||||
try {
|
||||
return await normalizeInWorker(input);
|
||||
} catch (err) {
|
||||
if (!(err instanceof WorkerUnavailable)) throw err;
|
||||
}
|
||||
// Let the "Normalizing…" state paint before the main thread is busy.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return (await normalize3mf(new Uint8Array(input))).bytes;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// "Download normalized" next to MakerWorld's own Download button, for people whose slicer mis-reads
|
||||
// Bambu Studio projects: not linked to a Thingport instance at all, or linked with such a slicer
|
||||
// picked. The file is downloaded and converted in this tab; it never goes anywhere else.
|
||||
|
||||
import { NORMALIZE_3MF_SLICERS } from "../../shared/slicers";
|
||||
import { api } from "../runtime";
|
||||
import css from "../styles/normalized.scss?inline";
|
||||
import { findDownloadButton, isDownloadButton, resolveMakerworldDownloadUrl } from "./downloadResolver";
|
||||
import { normalizeBambu3mf } from "./normalize";
|
||||
|
||||
// Converting holds the file, its unzipped model XML and the result in memory at once.
|
||||
const MAX_BYTES = 50 * 1024 * 1024;
|
||||
const MB = 1024 * 1024;
|
||||
const HOST_ID = "thingport-grab-normalized";
|
||||
const LABEL = "Download normalized";
|
||||
const DONE_RESET_MS = 3000;
|
||||
const ERROR_SHOW_MS = 10000;
|
||||
|
||||
// Material Design "Lightbulb" (Apache 2.0), the web app's icon for the same option.
|
||||
const BULB_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7"/></svg>';
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(tag: K, className: string): HTMLElementTagNameMap[K] {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
return el;
|
||||
}
|
||||
|
||||
function bulbIcon(): Element {
|
||||
return document.importNode(new DOMParser().parseFromString(BULB_SVG, "image/svg+xml").documentElement, true);
|
||||
}
|
||||
|
||||
let host: HTMLElement | null = null;
|
||||
let observer: MutationObserver | null = null;
|
||||
let bubbleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function explanation(slicerLabel: string | null): string {
|
||||
const slicers = slicerLabel ? `${slicerLabel} often opens` : "PrusaSlicer, Cura and other slicers often open";
|
||||
return (
|
||||
`MakerWorld files are Bambu Studio projects, which ${slicers} without their colors or print ` +
|
||||
"settings. This downloads a converted copy that keeps painted colors, filament colors, plates and the " +
|
||||
"designer's settings (walls, layer height, infill, supports). Heads up: multi-part objects may be merged into " +
|
||||
"one, per-object overrides and layer color changes are dropped, and you pick your own printer profile. " +
|
||||
"Converted in your browser by Thingport Grab."
|
||||
);
|
||||
}
|
||||
|
||||
function tooBig(bytes: number): Error {
|
||||
return new Error(
|
||||
`This file is ${Math.round(bytes / MB)} MB, too big to normalize in the browser (the limit is ${MAX_BYTES / MB} MB). ` +
|
||||
"Use MakerWorld's regular Download instead.",
|
||||
);
|
||||
}
|
||||
|
||||
async function readBody(
|
||||
res: Response,
|
||||
declared: number | null,
|
||||
onProgress: (text: string) => void,
|
||||
): Promise<ArrayBuffer> {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return res.arrayBuffer();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > MAX_BYTES) {
|
||||
void reader.cancel();
|
||||
throw tooBig(declared ?? received);
|
||||
}
|
||||
chunks.push(value);
|
||||
const got = (received / MB).toFixed(1);
|
||||
onProgress(declared ? `Downloading ${got} of ${(declared / MB).toFixed(1)} MB…` : `Downloading ${got} MB…`);
|
||||
}
|
||||
return new Blob(chunks as BlobPart[]).arrayBuffer();
|
||||
}
|
||||
|
||||
/** From Content-Disposition, else the URL's last path segment. */
|
||||
function sourceFilename(res: Response, url: string): string {
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
const encoded = disposition.match(/filename\*\s*=\s*[^']*'[^']*'([^;]+)/i)?.[1];
|
||||
const plain = disposition.match(/filename\s*=\s*"?([^";]+)"?/i)?.[1];
|
||||
let name = "";
|
||||
try {
|
||||
name = decodeURIComponent(encoded ?? plain ?? new URL(url).pathname.split("/").pop() ?? "");
|
||||
} catch {
|
||||
name = plain ?? "";
|
||||
}
|
||||
return name.replace(/[\\/:*?"<>|]+/g, "_").trim() || "model.3mf";
|
||||
}
|
||||
|
||||
function normalizedFilename(source: string): string {
|
||||
return `${source.replace(/\.3mf$/i, "")}-normalized.3mf`;
|
||||
}
|
||||
|
||||
function saveFile(bytes: Uint8Array, filename: string): void {
|
||||
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "model/3mf" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
|
||||
async function downloadNormalized(pageUrl: string, onProgress: (text: string) => void): Promise<void> {
|
||||
onProgress("Getting file…");
|
||||
const resolved = await resolveMakerworldDownloadUrl(pageUrl);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
"Couldn't get this model's file from MakerWorld. Make sure you're signed in to MakerWorld, then try again.",
|
||||
);
|
||||
}
|
||||
const res = await fetch(resolved.downloadUrl);
|
||||
if (!res.ok) throw new Error(`MakerWorld's file server refused the download (${res.status}). Try again in a moment.`);
|
||||
const declared = Number(res.headers.get("Content-Length")) || null;
|
||||
if (declared && declared > MAX_BYTES) {
|
||||
void res.body?.cancel();
|
||||
throw tooBig(declared);
|
||||
}
|
||||
const input = await readBody(res, declared, onProgress);
|
||||
onProgress("Normalizing…");
|
||||
let output: Uint8Array;
|
||||
try {
|
||||
output = await normalizeBambu3mf(input);
|
||||
} catch (err) {
|
||||
throw new Error("Couldn't normalize this file: it doesn't look like a 3MF project MakerWorld's Download gives.", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
saveFile(output, normalizedFilename(sourceFilename(res, resolved.downloadUrl)));
|
||||
}
|
||||
|
||||
function buildHost(pageUrl: string, slicerLabel: string | null): HTMLElement {
|
||||
const el = document.createElement("span");
|
||||
el.id = HOST_ID;
|
||||
const root = el.attachShadow({ mode: "open" });
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
const button = element("button", "tg-nd");
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-describedby", "tg-nd-info");
|
||||
const icon = element("span", "tg-nd__info");
|
||||
icon.append(bulbIcon());
|
||||
const label = element("span", "tg-nd__label");
|
||||
label.textContent = LABEL;
|
||||
button.append(icon, label);
|
||||
const bubble = element("div", "tg-nd-bubble");
|
||||
bubble.id = "tg-nd-info";
|
||||
bubble.setAttribute("role", "tooltip");
|
||||
bubble.hidden = true;
|
||||
root.append(style, button, bubble);
|
||||
|
||||
const showBubble = (text: string, error = false) => {
|
||||
clearTimeout(bubbleTimer);
|
||||
bubble.textContent = text;
|
||||
bubble.classList.toggle("tg-nd-bubble--error", error);
|
||||
bubble.hidden = false;
|
||||
// Fixed, so an ancestor's overflow can't clip it.
|
||||
const rect = button.getBoundingClientRect();
|
||||
const left = Math.min(Math.max(8, rect.left), window.innerWidth - bubble.offsetWidth - 8);
|
||||
bubble.style.left = `${left}px`;
|
||||
bubble.style.top = `${rect.bottom + 8}px`;
|
||||
if (error) bubbleTimer = setTimeout(hideBubble, ERROR_SHOW_MS);
|
||||
};
|
||||
const hideBubble = () => {
|
||||
clearTimeout(bubbleTimer);
|
||||
bubble.hidden = true;
|
||||
};
|
||||
const showInfo = () => {
|
||||
if (bubble.hidden) showBubble(explanation(slicerLabel));
|
||||
};
|
||||
icon.addEventListener("mouseenter", showInfo);
|
||||
icon.addEventListener("mouseleave", () => !bubble.classList.contains("tg-nd-bubble--error") && hideBubble());
|
||||
button.addEventListener("focus", () => button.matches(":focus-visible") && showInfo());
|
||||
button.addEventListener("blur", () => !bubble.classList.contains("tg-nd-bubble--error") && hideBubble());
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
hideBubble();
|
||||
button.disabled = true;
|
||||
icon.replaceChildren(element("span", "tg-nd__spinner"));
|
||||
try {
|
||||
await downloadNormalized(pageUrl, (text) => (label.textContent = text));
|
||||
label.textContent = "Downloaded";
|
||||
await new Promise((resolve) => setTimeout(resolve, DONE_RESET_MS));
|
||||
} catch (err) {
|
||||
showBubble(err instanceof Error ? err.message : String(err), true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
icon.replaceChildren(bulbIcon());
|
||||
label.textContent = LABEL;
|
||||
}
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Keeps the button right after MakerWorld's, which can render late or be re-rendered away. */
|
||||
function place(): void {
|
||||
if (!host) return;
|
||||
const anchor = host.previousElementSibling;
|
||||
if (host.isConnected && anchor && isDownloadButton(anchor)) return;
|
||||
const button = findDownloadButton();
|
||||
if (!button) {
|
||||
host.remove();
|
||||
return;
|
||||
}
|
||||
button.after(host);
|
||||
// A flex/grid gap already spaces it; the margin is for plain inline layouts.
|
||||
const gap = getComputedStyle(button.parentElement!).columnGap;
|
||||
host.style.marginLeft = gap && gap !== "normal" && gap !== "0px" ? "0" : "";
|
||||
}
|
||||
|
||||
function mount(pageUrl: string, slicerLabel: string | null): void {
|
||||
unmountNormalizedDownload();
|
||||
host = buildHost(pageUrl, slicerLabel);
|
||||
let scheduled = false;
|
||||
observer = new MutationObserver(() => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
place();
|
||||
});
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
place();
|
||||
}
|
||||
|
||||
export function unmountNormalizedDownload(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
host?.remove();
|
||||
host = null;
|
||||
clearTimeout(bubbleTimer);
|
||||
}
|
||||
|
||||
/** Offered when the user isn't linked to Thingport, or is but picked a slicer that needs it. */
|
||||
export async function offerNormalizedDownload(
|
||||
pageUrl: string,
|
||||
configured: boolean,
|
||||
isStale: () => boolean,
|
||||
): Promise<void> {
|
||||
let slicerLabel: string | null = null;
|
||||
if (configured) {
|
||||
try {
|
||||
const { slicer } = await api<{ slicer: string | null }>("GET", "/settings/slicer");
|
||||
slicerLabel = slicer ? (NORMALIZE_3MF_SLICERS[slicer] ?? null) : null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!slicerLabel) return;
|
||||
}
|
||||
if (!isStale()) mount(pageUrl, slicerLabel);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// The "Download normalized" button next to MakerWorld's own Download button (see
|
||||
// makerworld/normalizedDownload.ts). Its own shadow root, so it sits in the page's layout without
|
||||
// sharing styles either way. Warning-colored like the web app's "Open normalized" button.
|
||||
@use "../../styles/tokens" as t;
|
||||
@use "../../styles/mixins" as m;
|
||||
|
||||
:host {
|
||||
@include t.theme-vars(t.$palette-light);
|
||||
all: initial;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-left: t.$space-2;
|
||||
}
|
||||
|
||||
.tg-nd {
|
||||
@include m.isolated(inline-flex);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
padding: 6px 14px 6px 10px;
|
||||
border: 1.5px solid t.c("warning");
|
||||
border-radius: t.$radius-md;
|
||||
background: t.c("surface");
|
||||
color: t.c("warning");
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
@include m.focus-ring;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: t.c("warning-soft");
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 2px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: tg-nd-spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.tg-nd-bubble {
|
||||
@include m.isolated;
|
||||
@include m.card(t.$radius-md);
|
||||
position: fixed;
|
||||
z-index: t.$z-top;
|
||||
max-width: 320px;
|
||||
padding: 10px 12px;
|
||||
font-size: t.$font-size-sm;
|
||||
line-height: 1.45;
|
||||
|
||||
&--error {
|
||||
color: t.c("danger");
|
||||
}
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tg-nd-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
// Worker entry for normalizing off the page's main thread (see content/makerworld/normalize.ts).
|
||||
// Says "ready" first, so the page can tell a worker that never started from a failed conversion.
|
||||
|
||||
import { normalize3mf } from "./threeMfNormalizer";
|
||||
|
||||
export type WorkerReply = { type: "ready" } | { type: "done"; bytes: Uint8Array } | { type: "error"; error: string };
|
||||
|
||||
const reply = (message: WorkerReply, transfer: Transferable[] = []) => postMessage(message, { transfer });
|
||||
|
||||
self.addEventListener("message", async (event: MessageEvent<ArrayBuffer>) => {
|
||||
try {
|
||||
const { bytes } = await normalize3mf(new Uint8Array(event.data));
|
||||
reply({ type: "done", bytes }, [bytes.buffer]);
|
||||
} catch (err) {
|
||||
reply({ type: "error", error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
reply({ type: "ready" });
|
||||
@@ -0,0 +1,10 @@
|
||||
// Slicers that mis-import Bambu Studio projects, mirroring the web app's NORMALIZE_3MF_SLICER_IDS
|
||||
// (frontend/src/constants/settingsOptions.ts) with their labels. Keep in sync by hand.
|
||||
export const NORMALIZE_3MF_SLICERS: Readonly<Record<string, string>> = {
|
||||
prusaslicer: "PrusaSlicer",
|
||||
cura: "Cura",
|
||||
anycubicslicernext: "Anycubic Slicer Next",
|
||||
crealityprintlink: "Creality Print",
|
||||
elegooslicer: "Elegoo Slicer",
|
||||
"snapmaker-orca": "Snapmaker Orca",
|
||||
};
|
||||
@@ -41,6 +41,8 @@ $palette-light: (
|
||||
"hover": rgba(0, 0, 0, 0.06),
|
||||
"danger": #d32f2f,
|
||||
"danger-soft": rgba(211, 47, 47, 0.08),
|
||||
"warning": #ed6c02,
|
||||
"warning-soft": rgba(237, 108, 2, 0.08),
|
||||
"switch-off": #c4c4c4,
|
||||
"backdrop": rgba(0, 0, 0, 0.5),
|
||||
"backdrop-strong": rgba(0, 0, 0, 0.75),
|
||||
@@ -62,6 +64,8 @@ $palette-dark: map.merge(
|
||||
"hover": rgba(255, 255, 255, 0.08),
|
||||
"danger": #ef6b6b,
|
||||
"danger-soft": rgba(239, 107, 107, 0.12),
|
||||
"warning": #ffa726,
|
||||
"warning-soft": rgba(255, 167, 38, 0.12),
|
||||
"switch-off": #555555,
|
||||
"shadow": rgba(0, 0, 0, 0.6),
|
||||
)
|
||||
|
||||
Vendored
+5
-1
@@ -1,5 +1,5 @@
|
||||
// esbuild loaders (see scripts/build.ts): .svg imports are markup strings, `?inline` .scss imports
|
||||
// are compiled CSS strings.
|
||||
// are compiled CSS strings, `?worker` imports are a bundled worker's source.
|
||||
declare module "*.svg" {
|
||||
const markup: string;
|
||||
export default markup;
|
||||
@@ -9,3 +9,7 @@ declare module "*.scss?inline" {
|
||||
export default css;
|
||||
}
|
||||
declare module "*.scss";
|
||||
declare module "*?worker" {
|
||||
const source: string;
|
||||
export default source;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const SECTIONS = [
|
||||
"Pick a Thingport collection to file the model into, then jump straight to it with Open in Thingport.",
|
||||
"Uses your MakerWorld login from the browser, so there's no session cookie to paste.",
|
||||
"Hides itself on models you already have, and keeps your last few imports one click away in its toolbar popup.",
|
||||
"Useful on its own too: a Download normalized button on MakerWorld converts Bambu Studio projects in your browser, so PrusaSlicer, Cura and other slicers open them with their colours and settings. No Thingport instance needed.",
|
||||
"Talks only to your own Thingport instance. Nothing goes anywhere else.",
|
||||
],
|
||||
link: { label: "Thingport Grab", href: url("docs/grab/") },
|
||||
|
||||
@@ -181,7 +181,9 @@ const jsonLd = [
|
||||
<h2>Save a model without leaving the page you found it on</h2>
|
||||
<p>
|
||||
<strong>Thingport Grab</strong> is a browser extension for Chrome, Edge and Firefox. It adds an import button to
|
||||
MakerWorld, Printables and Thingiverse model pages, and picks up your MakerWorld login for you.
|
||||
MakerWorld, Printables and Thingiverse model pages, and picks up your MakerWorld login for you. Even without Thingport,
|
||||
it adds a “Download normalized” button to MakerWorld that converts Bambu Studio projects for PrusaSlicer, Cura and
|
||||
other slicers, right in your browser.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Thingport Bridge</strong> is a small desktop helper that lets “Open in slicer” launch Bambu Studio, PrusaSlicer,
|
||||
|
||||
Reference in new issue
Block a user