# atomm Developer Platform > Full documentation for building generator apps on the atomm platform. > The design system is published separately as a DESIGN.md-format file at /design.md. --- # Quickstart Integrate a generator built with any tech stack into Atomm: **create a generator → add one line of SDK and integrate capabilities → preview and debug locally in real time → submit for review and publish**. No command-line tools required. > **Any stack that outputs static web files works** > > Plain HTML, Vite, Vue, React — any tech stack works. Atomm communicates with your app through a browser-side SDK and doesn't constrain how you build it. ## End-to-end flow **1. Create a generator in the Developer Console** Start by picking a name to serve as its public identifier (**must start with a lowercase letter and contain only lowercase letters, digits, and hyphens -; it cannot be changed after creation**, as it becomes part of the runtime subdomain and access path). After creation you'll land on the app detail page to continue setup. This name is used again in local preview and final publishing. **2. Add the SDK to your app and integrate capabilities** Add one script tag to your page to get the global `atomm` object: ```html ``` Then integrate platform capabilities as needed. Taking export as an example, this takes two steps — place an export button placeholder element (the SDK renders it in place as an Export button), and register an `export` hook to supply the resulting file: ```html
``` ```js // The platform calls back into your app for the result file when the user clicks export atomm.lifecycle.on('export', async () => ({ filename: 'design.glb', blob })) ``` See the [atomm object](/docs/atomm) and [export capability](/docs/export) for details. **3. Start your local server, then preview and verify in real time in the browser** Start your local server (e.g. `http://localhost:5173`), build the preview URL using the generator name from step 1, and put your local address into `?local=` (only loopback addresses such as localhost / 127.0.0.1 are supported): ```text https://www.atomm.com/creativetools/community/generator/?local=http://localhost:5173/ ``` DevTool runs your local app inside the same environment it will run in live, and previews it there. The integration assistant panel on the right shows whether the SDK is loaded and lists the platform capabilities available to integrate, so you can verify in the preview that export and other capabilities are being invoked correctly. You can also open this directly from the "Local debugging" card on the app detail page by entering your local address. See [Local debugging](/docs/devtool) for details. **4. Upload the artifact, submit for review, and publish** On the app detail page's configuration card, add your listing information, upload the packaged artifact, and submit for review. Once approved, it's automatically published to `https://www.atomm.com/creativetools/community/generator/<生成器名称>`, and users can open and use it directly from the generator gallery — no installation required. See [Submitting for review and publishing](/docs/publish) for details. --- # Local Debugging No command-line tools need to be installed. Local debugging takes only two steps: **integrate the SDK**, then open the online DevTool in your browser to load your local service. ## 1. Integrate the Platform SDK Include this in your app page: ```html ``` Once loaded, the SDK handshakes with the platform and injects a global `atomm` object (see [atomm object](/docs/atomm)). ## 2. Open the Online DevTool Preview After starting your service locally (any port), open the URL below in your browser and fill your local address into `?local=` (only loopback addresses such as localhost / 127.0.0.1 are supported): ```text https://www.atomm.com/creativetools/community/generator/?local=http://localhost:5173/ ``` - Left: a live preview of your generator (your local service runs in the same environment it will run in live, with the same capability limits) - Right: two tabs - **Simulator**: currently offers "Preview language" — switching it changes what `atomm.app.getLocale()` returns and reloads your generator, so you can verify your localization (see [The atomm object · Language](/docs/atomm#language-atommapp)). - **Integration assistant**: - **SDK integration hint**: shows an "integration incomplete" notice until `platform-sdk.js` is loaded, along with copyable integration code and a documentation link; it disappears automatically once the SDK handshake succeeds. - **Platform capability list**: lists the capabilities the platform provides (export / download, the `atomm` object, etc.); each item lets you "view docs" or "copy the integration prompt" to hand off to an AI for integration. Dev preview mode is fixed to the `dev` environment, incurs no charges, has all platform capabilities enabled by default, and does not depend on any backend data. > **You can also open it directly from the console** > > On the app detail page in the Developer Console, fill in your local address in the "Local Debugging" card to open the preview above directly. > **About HTTP and mixed content** > > `www.atomm.com` runs over HTTPS. Browsers grant a mixed-content exemption for `http` addresses on `localhost`, so a local http service loads normally; **non-localhost http addresses will be blocked by the browser** — use `localhost` or enable HTTPS for your local service. --- # atomm Object `atomm` is a global object injected into the page once the platform SDK (`platform-sdk.js`) loads. It exposes platform capabilities and UI utilities, and can be used directly in your generator code. ## Lifecycle — atomm.lifecycle Register platform lifecycle hooks via `lifecycle.on`: ```js atomm.lifecycle.on('export', async () => { ... }) ``` ## Toasts — atomm.ui Shows a platform-wide toast notification. Takes an object as its argument: - `type`: optional, one of `success` / `warning` / `error` / `info`, defaults to `success` - `message`: the notification text - `duration`: optional, how long the toast stays visible, **in seconds**; `0` means it won't auto-close; defaults to **3 seconds** if omitted `toast()` returns the **id** of that toast; call `closeToast(id)` to close it manually. A persistent toast with `duration: 0` must be closed this way, or it will stay on screen indefinitely. ```js atomm.ui.toast({ type: 'success', message: '已完成' }) // Show for 5 seconds atomm.ui.toast({ type: 'info', message: '正在处理…', duration: 5 }) // duration: 0 keeps it on screen; close it with closeToast once the task finishes const id = await atomm.ui.toast({ type: 'info', message: '导出中…', duration: 0 }) // …task finishes… await atomm.ui.closeToast(id) ``` > **toast requires an object** > > Passing a string (e.g. `toast('已完成')`) won't work — the platform only reads `message` and `type` off an object, so a string is treated as an empty message. The `ui` namespace currently provides `toast` (which returns the toast's id) and `closeToast`. ## Language — atomm.app The platform's current UI language, so your generator can localize along with it. ```js // Current locale code — always one of the 17 supported languages const locale = await atomm.app.getLocale() // All supported locales (code + display name), for building a language selector consistent with the platform const locales = await atomm.app.getSupportedLocales() // → [{ code: 'zh', name: '简体中文' }, { code: 'en', name: 'English' }, …] ``` Calling `getLocale()` once at startup to pick the initial language is enough. **It is a one-time read, not a subscription** — changing the platform language reloads the whole page, so your app restarts and reads the new value on its own. You never need to listen for changes. `getLocale()` always returns one of the 17 codes below, but **your app may not have translated all of them**. When you get a language you have no copy for, fall back to English or your own default.
Code Display name Language
zh 简体中文 Simplified Chinese
en English English
zh-hant 繁體中文 Traditional Chinese
de Deutsch German
es Español Spanish
fr Français French
it Italiano Italian
ja 日本語 Japanese
ko 한국어 Korean
ru Русский Russian
uk Українська Ukrainian
sl Slovenščina Slovenian
th ไทย Thai
pl Polski Polish
cs Čeština Czech
id Bahasa Indonesia Indonesian
vi Tiếng Việt Vietnamese
The table is in the order `getSupportedLocales()` returns, and "Display name" is exactly the `name` it gives you — use it directly in a language picker. > **Testing languages locally** > > The developer toolkit's Simulator has a "Preview language" dropdown. Switching it changes what `getLocale()` returns and reloads your generator, so you don't have to change the language on the real platform. See [Developer toolkit](/docs/devtool). ## User — atomm.user ```js // Whether the user is currently logged in (returns only a boolean, no token or profile data) const loggedIn = await atomm.user.isLoggedIn() // If not logged in, opens the login dialog and waits for the user to finish; returns whether they ended up logged in if (!loggedIn) { const ok = await atomm.user.login() if (!ok) return // User canceled or login failed } // Reaching here means the user is logged in — continue with your logic ``` > **Call login from within a user gesture** > > `login()` opens a login window, so call it from within a user gesture handler (e.g. a click callback) to avoid having it blocked by the browser's popup blocker. Logging in is a long-running interaction, so the platform already extends its timeout for it (about 5 minutes) — you don't need to add your own. ## Safe invocation If you're not sure whether your code is running inside the platform, use optional chaining to avoid errors: ```js window.atomm?.ui?.toast?.({ type: 'info', message: 'hi' }) ``` --- # Overview This section covers the technical requirements for building a three.js 3D preview into a creative tool / generator on the Atomm platform — architecture, interaction, render quality, and performance and stability — and closes with a pre-submission acceptance checklist. **Applies to**: every generator app that ships a 3D preview view. The same requirements are also written as a single-file skill for coding agents (**Download 3D Preview Skill**, top right). Hand it straight to yours. The demo below is what an agent built after reading that skill: drag to orbit, scroll to zoom, and every parameter on the right feeds one geometry IR that the 2D preview, the 3D view and the exported SVG all read from. ## How to read the requirement levels
Term Meaning
Must Mandatory. Checked item by item at review.
Should The default. Deviate only with a good reason.
Optional Implement it if your product needs it.
## Do you need a 3D preview at all? A 3D preview is optional. Build one only when **thickness, stacked depth or how parts fit together** is core to what your tool has to show. If the output is essentially flat, use a 2D SVG with a procedural material texture instead (a `feTurbulence` wood-grain filter, for example) — you get the material read at a fraction of the cost. Once you decide to build one, it has to meet every acceptance criterion below. A 3D preview that falls short of the interaction and rendering baseline makes the product feel worse than no 3D at all, and will not pass review. --- # Architecture ## One geometry source You **must** follow a single data flow: ```text Config (parameter object) → pure render → geometry IR → 2D canvas / 3D view / exported file ``` The 3D view **must** consume the same geometry IR as the 2D canvas and the exported file. Do **not** write a second geometry pipeline on the 3D side — the preview and the export will drift apart. ## Coordinate systems Each rendering space uses the conventions below, and they **must** be documented in one place rather than rediscovered per file:
Space Unit / origin / axes
IR (canonical space) mm, centred at (0,0), Y down (2D — no Z)
3D (three.js) mm, Y up, Z is thickness
Outlines are built in the XY plane and extruded along +Z, so the part faces the camera. Flip IR to Y-up by mirroring, not rotating — set `group.scale.y = -1` once, on the content group; rotating it flat moves thickness onto another axis. Two ways the flip goes wrong: - **One part is upside-down and mirrored while everything around it is correct** — that mesh set `scale.y` itself. It already inherits the group's flip, so the second one cancels it. - **Every face renders inside-out** — the flip was applied to the geometry (`geometry.scale(1, -1, 1)`). three.js compensates the winding order from `matrixWorld` at the object level only; there is no compensation at the geometry level. ## Path conversion SVG paths for text and complex shapes **must** go through `new SVGLoader().parse()` and `SVGLoader.createShapes()` to become a `THREE.Shape` — that path applies the fill rule correctly, so holes and letter counters come out right — and then through `ExtrudeGeometry`. Do **not** write your own SVG path parser. --- # Interaction ## Camera controls When you use `OrbitControls`, you **must** configure all of the following: - **Damping on**: `controls.enableDamping = true`, with `controls.update()` called every frame. - **Clamped zoom**: set `minDistance` and `maxDistance`. 1.2×R to 8×R (R being the model's bounding radius) is the recommended range — it keeps the camera from entering the model or drifting out to nothing. - **Clamped pitch**: set `maxPolarAngle` slightly under π (0.95π works), so the camera never tips under the model. - **Opening shot**: about 3.2×R out, tilted slightly down — the whole model visible at the default angle, and reading as a solid. ## Wheel events If you take over zoom yourself, you **must** attach a native non-passive listener: ```js element.addEventListener('wheel', handler, { passive: false }) ``` and call `preventDefault()` in the handler. Framework-level wheel bindings are usually passive and cannot stop the page's default scroll (React's `onWheel`, Vue's `@wheel` without a passive opt-out), so zooming inside the 3D area scrolls the whole page with it. The wheel handling built into `OrbitControls` already satisfies this. ## Ambient motion - You **should** add an idle float: a Lissajous sway on frequencies that are not integer multiples of each other, so there is no perceptible loop point. Keep the amplitude slight. - You **should** drive drag through an under-damped spring rather than mapping pointer travel 1:1 onto rotation. Aim for the behaviour, not a number: releasing produces **one visible overshoot and settles in roughly 0.3s**. (Stiffness 70 / damping 5.5 lands there in one particular per-frame integrator — the values only mean something alongside the formula you use, so tune to the behaviour.) - Ambient-motion transforms **must** live on a persistent parent node (the rig) that geometry rebuilds never touch (see [Rebuild on change](/docs/3d-preview/rebuild)), so motion stays continuous while parameters change. - Every animation **must** honour `prefers-reduced-motion` and switch off entirely when it is set. ## The frame loop - Per-frame updates (motion, springs, `controls.update()`) **must** run in a rAF loop that mutates `Object3D` transforms directly. The UI framework re-renders only when Config changes; do **not** trigger framework state updates from the frame loop (React `setState`, Vue reactive assignment, Svelte store writes — all of them re-render 60 times a second and drop frames). - Note that rAF pauses while the tab is hidden (`document.visibilityState === 'hidden'`), so every animated value freezes at its last frame. Anything that reads those values — an automated check, a debugging session — has to bring the tab to the foreground first, or it reads stale numbers and concludes the motion is broken. ## Text selection The 3D stage container **must** set `user-select: none`, so drag-to-rotate does not select page text. --- # Rebuild on change ## Rebuild strategy - Geometry rebuilds triggered by a parameter change **must** be debounced (100–200ms), so dragging a slider does not rebuild per frame and drop the frame rate. - A rebuild **must** be scoped to the content group's subtree. Camera position, OrbitControls state and the ambient-motion rig **must not** reset because a parameter changed. ## Disposing resources Two different lifetimes — do **not** collapse them into one dispose function. **On every rebuild**, release what *this build* created: - every `geometry.dispose()` - every `material.dispose()`, plus any texture created for this build Skip this and GPU memory climbs the whole time someone is adjusting parameters, until the page locks up. **On unmount only**, release what outlives a rebuild: - the PMREM render target and the environment map - textures shared across rebuilds — the procedural noise texture, a `CanvasTexture` shared with the 2D canvas (see [Render quality](/docs/3d-preview/rendering)) - `controls.dispose()` and `renderer.dispose()` Neither list tolerates items from the other. Regenerating the environment map every rebuild costs a multi-pass PMREM render per debounce tick — more expensive than the leak you were avoiding. Disposing a shared texture every rebuild is worse: the next frame renders against a dead texture and the material goes blank. Note that walking the content subtree and disposing every texture you find there hits both traps at once — a material *references* shared textures without owning them. **Dispose what you created, not what you referenced.** ## Sizing, DPR and zero-size mount - `renderer.setPixelRatio()` **must** be clamped — `Math.min(window.devicePixelRatio, 2)`. Rendering at DPR 3 on a phone or a 5K display costs 2.25× the fragments of DPR 2 for no visible gain, and is the most common reason a preview that runs fine on the developer's machine drops frames on the user's. - Reacting to a container resize means all three of `camera.aspect`, `camera.updateProjectionMatrix()` and `renderer.setSize()`. Miss `updateProjectionMatrix()` and the image stretches; miss `setSize()` and it renders at the old resolution. - Your component can mount at 0×0 — inside a hidden tab or a collapsed panel. When ResizeObserver reports 0×0 you **must** ignore it and keep the current view state, then initialise or refit once a non-zero size arrives. That is what keeps the view intact across a tab switch. --- # Render quality ## Lighting - Image-based lighting **must** be configured; `AmbientLight` alone is **not** acceptable. The recommended setup is the built-in procedural interior — no asset files, no network request, nothing for CSP to block:```js const pmrem = new THREE.PMREMGenerator(renderer) const envRT = pmrem.fromScene(new RoomEnvironment()) // keep envRT — it is disposed on unmount, see Rebuild on change scene.environment = envRT.texture ``` - Keep the environment dim (`scene.environmentIntensity ≈ 0.4` as a reference; a lower `envMapIntensity` on dark parts) to hold contrast, and add one soft directional light so the lighting has a direction. ## Tone mapping ACES tone mapping **must** be enabled: ```js renderer.toneMapping = THREE.ACESFilmicToneMapping ``` It makes a visible difference to how MeshStandardMaterial / MeshPhysicalMaterial read. ## Materials - You **should** generate a procedural noise texture on a canvas and use it as both `map` and `bumpMap` — a cheap way to get a physical material read. Build it **once** and reuse it across rebuilds; [Rebuild on change](/docs/3d-preview/rebuild) covers when to dispose it. - `ExtrudeGeometry` UVs are world coordinates in mm, so `texture.repeat` **must** be derived from physical size: pick how many mm one tile of the texture should span, and set `repeat = 1 / tile_mm` (a 45mm wood grain gives `1/45`). Hardcode a repeat count instead and the grain changes size whenever the part does. - The procedural material SVG used by the 2D canvas can be rendered to a canvas and reused in 3D as a `CanvasTexture`, keeping 2D and 3D materials consistent. For the top face — assuming the model is centred on the origin, so world coordinates run from −R to +R — `repeat = 1/(2R)` and `offset = 0.5` map that span onto 0–1. - You **should** split materials per face group, `[faceMat, sideMat]` (group 0 top and bottom, group 1 sides), with the sides a shade darker to read as solid. ## HDRI environment (optional) For a more convincing environment, ship a small CC0-licensed 1k `.hdr` (around 1.5MB): run it through PMREM for `scene.environment`, use the raw equirect texture as `scene.background` with a moderate `backgroundBlurriness`, and fall back to RoomEnvironment as an instant placeholder while it loads. Two things to watch for: - A downward-tilted camera looks at the panorama's zenith or ground — the flattest part of it — and the background collapses into one blurred colour. Rotate the horizon behind the subject with `backgroundRotation` and `environmentRotation`, and start the camera at a shallower angle. - Keep the background clearly darker than the subject, so the model's silhouette and its holes stay readable. --- # Dependencies - Pin your three.js version. Its API deprecates things continuously — known examples: - the `RoomEnvironment` constructor dropped its `renderer` argument around r150; - r180 deprecated `RGBELoader` in favour of `HDRLoader` (the old class still loads, but warns); - r185 deprecated `PCFSoftShadowMap`, which now silently falls back to `PCFShadowMap`; - colour-space and tone-mapping defaults have changed more than once. - After a three.js upgrade you **must** check the browser console. **No deprecation warnings in the console** is an acceptance criterion. --- # Common parts Recipes to reach for when the shape calls for them:
Part How to build it
Raised marks / lettering Reuse the cut part's outline path → SVGLoader ExtrudeGeometry , and place it at z = panel thickness . Add it to the content group like everything else — it inherits the group's Y flip, so do not flip it again
Thin rings / outlined parts Generate inner and outer outlines at radius ± stroke/2 and extrude the thin ring; sample circles smoothly, flatten polygons
Fold-up flaps Treat every open cut arc as one flap, with the hinge axis running between the arc's two endpoints (its chord). Extrude the flap and rotate it about that axis, in whichever direction lifts the flap's centroid toward +z. The base panel gets the matching hole punched out, while the uncut core stays solid. Cycle the fold angle through a small set of values so the result looks natural
--- # Acceptance checklist Work through this before you submit. Everything has to pass. ## Interaction - [ ] OrbitControls damping is on; zoom distance and pitch are both clamped - [ ] Opening shot is around 3.2×R, tilted slightly down, whole model on one screen - [ ] The wheel inside the 3D area never scrolls or zooms the page - [ ] Frame rate holds while a slider is dragged continuously (geometry rebuilds are debounced) - [ ] Changing any parameter leaves the camera where it was and does not interrupt motion - [ ] Drag springs back on release; idle has a slight float; `prefers-reduced-motion` disables all of it - [ ] The 3D stage sets `user-select: none` — dragging never selects page text ## Correctness - [ ] 3D geometry, the 2D canvas and the exported file all come from one IR (change any parameter and all three follow) - [ ] The Y-down → Y-up flip happens once on the content group — not per mesh, not on the geometry. No mirrored text, no holes on the wrong side, no inside-out faces - [ ] Every panel parameter has a visible effect in the 3D view, or is hidden for that mode with an explanation — a parameter that is visible but does nothing is not acceptable ## Performance and stability - [ ] `setPixelRatio` is clamped (DPR ≤ 2) - [ ] Five minutes of continuous parameter changes with flat GPU and JS memory (everything is disposed) - [ ] Hide the tab and come back: the view is intact, not blank, nothing lost - [ ] Mounting at 0×0 still initialises correctly once a real size arrives - [ ] A container resize updates `camera.aspect`, `updateProjectionMatrix()` and `setSize()` together — the view refits without stretching ## Render quality - [ ] Environment lighting is configured (RoomEnvironment or HDRI) with a directional light on top — not bare AmbientLight - [ ] ACES tone mapping is on - [ ] The environment map and any shared texture are built once and disposed on unmount only — not regenerated or disposed per rebuild - [ ] Top and side materials are separate groups, sides a shade darker - [ ] No three.js deprecation warnings in the browser console --- # Export Register the `export` lifecycle hook, and the platform will invoke your generator to retrieve the resulting file whenever the user clicks "Export" or "Open in Studio." The return value is always `{ filename, blob }`, and **any file format is supported** (images, SVG, 3D models, PDF, etc.). ## Placing the Export Button You **place the export button yourself, inside your app**—add an element with the `data-atomm-export-button` attribute anywhere on the page, and the SDK will render it in place as a platform-controlled Export button (Download / Open in Studio + credit indicator). Clicks are always routed through the platform: ```html
``` > **Two items, two different paths** > > Clicking it opens a dropdown with two items that behave differently. **Download** hands the files straight to the user with no dialog at all. **Open in Studio** opens an **export settings dialog** first: the user picks Download or Open in Studio, chooses machine / processing mode / material, and assigns a processing type to each SVG colour group (see [SVG Export Color Spec](/docs/export/svg-color-spec)). Your `export` hook still receives `intent`, fired once per tab, so returning different artefacts per action keeps working. - Position and outer layout are entirely up to you (e.g., `position: fixed` to pin it to a corner); you can place multiple instances on one page, each hydrating independently. - The button renders inside a Shadow DOM for style isolation; theming is done exclusively through the documented `--atomm-export-*` CSS variables (set them on the element itself or any ancestor—they're automatically inherited by the button): ```css [data-atomm-export-button] { --atomm-export-bg: #d32f2f; /* Button background color */ --atomm-export-radius: 0; /* Border radius */ --atomm-export-width: 200px; /* Width: px, or 100% to fill the container */ --atomm-export-height: 40px; /* Height */ } ```
Variable Default Controls
--atomm-export-bg / -bg-hover / -bg-active #070b10 / #252c36 / #1c2129 Button background for the default / hover / active states
--atomm-export-color #fff Button text color
--atomm-export-width auto (fits content) Button width ( px , or 100% to fill the container; min 24px)
--atomm-export-height 32px Button height ( px or 100% ; min 24px)
--atomm-export-radius 8px Border radius
--atomm-export-font / -font-size Inter, system-ui, sans-serif / 14px Font family / font size
--atomm-export-menu-bg / -menu-color / -menu-hover-bg #fff / #111 / #f4f4f5 Dropdown menu colors
--atomm-export-menu-radius / -menu-shadow 8px / 0 4px 16px rgba(16,24,40,.12) Menu border radius / shadow
--atomm-export-z 1000 Dropdown overlay z-index
> Only the `--atomm-export-*` variables listed above are supported customization points; internal button class names may change at any time, so don't rely on them. > **The export button only works inside the atomm environment (not a bug)** > > The export button—and its Download / Open in Studio / credit indicator—is driven by the atomm platform, and only works when your generator runs inside the atomm environment: the live platform, or the dev tool's local preview (`?local=`). > > If you open your generator standalone, outside atomm (e.g. opening the HTML directly), the button may still render, but clicking it won't produce a file and no billing status will show—this is by design, not a bug. Always verify export via local preview or the live platform. ### Free-Use Count / Credit Indicator on the Button The button automatically displays the current billing status (pushed by the platform—no action needed on your part):
State Display Meaning
Free window 30s countdown A short grace period granted after a successful export; exports within this window are free
Free count Free 3/3 Remaining / total free exports
Credit cost Credit icon + number Credits consumed per export once free uses are exhausted
> In local debugging (`?local=`), **clicking export never actually deducts credits**; refreshing the page resets it. ## Registering the export Hook ```js atomm.lifecycle.on('export', async ({ intent }) => { // intent tells you whether the user clicked Download or Open in Studio; the handler reads the current generation result itself and produces a Blob const blob = await exportCurrentResultAsBlob() if (!blob) { throw new Error('请先生成作品后再下载') } return { filename: 'my-generator.glb', // Must include an extension; this determines the downloaded filename and the asset type used by Open in Studio blob, // A Blob of any format; MIME type comes from blob.type } }) ``` ### The intent Argument: Telling Download from Open in Studio Download and Open in Studio share this one hook. `intent` tells you which dropdown item the user picked, so you can return different output per action. Each item triggers your hook once; return the same output for both if the distinction does not apply.
intent When it fires What you typically return
'download' The Download tab Every file the user should get on disk (the platform zips multiple files)
'openInStudio' The Open in Studio tab Only what Studio should open, typically a single editable vector file
> **Standalone bitmaps are wrapped into SVG for Studio** > > A processing type can only be written onto an SVG element as an attribute, and a standalone bitmap file has nowhere to carry one. So when the user picks **Open in Studio**, the platform wraps any standalone bitmap in your output (PNG / JPEG / WebP / GIF / BMP) in an SVG `` — that is what lets it show up under the "Bitmap" group in the dialog, receive a processing type, and reach Studio with it. > > Physical size is decided like this: if the image carries a resolution (PNG `pHYs`, JPEG EXIF / JFIF, BMP pixels-per-metre) it is converted to millimetres from that; otherwise the SVG/CSS 96 PPI default is used. **If the size matters, write resolution metadata into the image you export.** > > **Download always delivers your bytes untouched** — no wrapping, no attributes. If you told the user you export a PNG, a PNG is what they get. ```js atomm.lifecycle.on('export', async ({ intent }) => { // Open in Studio: the file will be edited further in Studio, so send one vector file if (intent === 'openInStudio') { return { filename: 'design.svg', blob: await buildSvg() } } // Download: send every file; the platform bundles them into a single zip return [ { filename: 'cut.svg', blob: cutBlob }, { filename: 'engrave.svg', blob: engraveBlob }, { filename: 'score.svg', blob: scoreBlob }, { filename: 'readme.txt', blob: readmeBlob }, ] }) ``` > If the distinction doesn't apply to you, return the same thing for both; ignoring `intent` entirely (`async () => { ... }`) keeps working. ## Return Value Fields
Field Type Description
filename string The downloaded filename; must include an extension (e.g., design.glb ) and must not contain path separators / \
blob Blob File content of any format; each file must be ≤ 100MB ; MIME type comes from blob.type , falling back to the filename extension when empty
## Multi-File Export (Optional) The handler can also return an **array of files**, and the platform will bundle them into **a single zip download**: ```js atomm.lifecycle.on('export', async () => [ { filename: 'model.glb', blob: glbBlob }, { filename: 'preview.png', blob: pngBlob }, ]) ```
Rule Description
zip filename Derived from the base name of the first file in the array ( model.glb model.zip )
Duplicate names Automatically deduplicated to name (1).ext
Size limit The combined size of all files must be ≤ 100MB ; exceeding this fails the entire export
Array of length 1 Downloads the file directly, without wrapping it in a zip
Open in Studio Supports multiple files —all returned files are handed to Studio to open, with no format restriction; formats Studio doesn't support are flagged by Studio itself. Multi-file import requires xTool Studio 1.8+ ; single-file export remains compatible with older versions
> Single files still use `{ filename, blob }`; use an array for multiple files. If any item in the returned array is invalid (not a Blob, filename missing an extension, etc.), the entire result is considered invalid. ## Getting a Blob Almost any export source can be converted to a Blob in a single line:
What you have Convert to Blob
Canvas (2D drawing) canvas.toBlob(cb, 'image/png')
SVG string / text / JSON new Blob([str], { type: 'image/svg+xml' })
ArrayBuffer / 3D mesh (glb/stl, etc.) new Blob([buffer], { type: 'model/gltf-binary' })
Existing data URL / remote URL await (await fetch(x)).blob()
File from Use it directly (a File is already a Blob)
> **The return value is always { filename, blob }** > > Export data is always carried as a **Blob**, so any format is supported — a Blob preserves the binary content of any file type. > > `filename` must include an extension and must not contain path separators; if a file exceeds 100MB or the return value isn't a valid `{ filename, blob }`, the platform treats it as invalid, the export fails, and the user is notified. > **Open in Studio's format support is determined by Studio** > > Downloads support saving any format to disk. "Open in Studio" hands the file off to xTool Studio to open—the platform makes no assumptions about format, and Studio itself will flag any format it doesn't support. > **Not registering the hook will cause review rejection** > > The export button is available to every generator (it appears as soon as you add `data-atomm-export-button` to your app). If a user clicks export and you haven't registered the `export` hook, the platform receives "the app provides no download method"—and your submission will be rejected in review as a result. Any generator that supports export must register this hook; you can test it locally by clicking "Export" in the preview to confirm a file is produced correctly. --- # SVG Export Color Spec The platform reads processing intent from colour: **stroke cuts in #FE0002, stroke engravings in #2366FF, fill engravings in #2366FF.** Every other colour still exports — the user just assigns its processing type by hand. Your colours are **read, never rewritten**: colour is only used to identify, and the only thing written into the file is the processing type, `data-processing-type`. ## Two colours, five groups
Group How to draw it
Red line stroke=
Blue line stroke=
Fill vector fill=
Bitmap elements, colour-independent
Other vector every other visible vector, split into one row per colour
However many elements a group holds, it takes one row and the user configures it once. This is the dialog they get after picking Open in Studio from the dropdown (Download saves straight to disk with no dialog). Machine, processing mode and material sit at the top; together they decide which processing types this machine supports. The dropdown on the right of each row is that group's processing type. ## A complete example Your `export` hook returns an SVG like this: ```xml ``` It only uses the first three groups, so the dialog shows three rows — Red line, Blue line and Fill vector, one element each. The user picks a processing type for each, and on confirm your file becomes: ```xml ``` The attribute goes on each element that is actually processed, never on a `` to be inherited. The file carries the processing type and nothing else — no power, speed or similar parameters. Studio applies the recommended parameters for whichever material the user picked in the dialog. That is everything you need to make export work. The three sections below are detail — reach for them when an export does not come out the way you expected. ## The colour was not recognised First check the notation is one the platform reads. Each element resolves in the order **inline style → ``` `