feat(extension): add normalized download for MakerWorld

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-30 19:33:31 +02:00
1 parent 3678ed18aa
commit 2380c566e5
19 files changed
+1789 -17

No files matched your search

+1 -1
View File
@@ -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
+9 -1
View File
@@ -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.
+9 -3
View File
@@ -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
View File
@@ -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
+17 -3
View File
@@ -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 -1
View File
@@ -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",
+31 -1
View File
@@ -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",
};
+6
View File
@@ -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
+19
View File
@@ -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" });
+10
View File
@@ -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",
};
+4
View File
@@ -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),
)
+5 -1
View File
@@ -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;
}
+1
View File
@@ -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/") },
+3 -1
View File
@@ -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,