---
name: atomm-3d-preview
description: Use when building, reviewing or debugging a three.js 3D preview for an Atomm generator app — covers the single-geometry-source architecture, camera and wheel interaction, debounced rebuilds and resource disposal, IBL/tone-mapping/material baselines, and a checklist to self-check against before submitting.
---

# Atomm 3D preview

Technical requirements for a three.js 3D preview in a creative tool / generator on the Atomm platform: architecture, interaction, render quality, performance and stability, plus the acceptance checklist used at review.

**Applies to**: every generator app that ships a 3D preview view.

| 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. |

## 1. 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 the 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) — 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.

## 2. Architecture

### 2.1 One geometry source

Follow a single data flow:

```
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.

### 2.2 Coordinate systems

These conventions **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.

### 2.3 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.

## 3. Interaction

### 3.1 Camera controls

With `OrbitControls`, 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.

### 3.2 Wheel events

If you take over zoom yourself, 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 `.passive` opt-out), so zooming inside the 3D area scrolls the whole page with it. The wheel handling built into `OrbitControls` already satisfies this.

### 3.3 Ambient motion

- 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.
- 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 4.1), so motion stays continuous while parameters change.
- Every animation **must** honour `prefers-reduced-motion` and switch off entirely when it is set.

### 3.4 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).
- 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.

### 3.5 Text selection

The 3D stage container **must** set `user-select: none`, so drag-to-rotate does not select page text.

## 4. Rebuild on change

### 4.1 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.

### 4.2 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 5.3)
- `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.**

### 4.3 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.
- A component can mount at 0×0 — inside a hidden tab or a collapsed panel. When ResizeObserver reports 0×0, 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.

## 5. Render quality

### 5.1 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 — 4.2 disposes it on unmount
  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.

### 5.2 Tone mapping

ACES tone mapping **must** be enabled:

```js
renderer.toneMapping = THREE.ACESFilmicToneMapping
```

It makes a visible difference to how MeshStandardMaterial / MeshPhysicalMaterial read.

### 5.3 Materials

- 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; 4.2 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.
- 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.

### 5.4 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.

## 6. Dependencies

- Pin the 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, check the browser console. **No deprecation warnings in the console** is an acceptance criterion.

## 7. Common parts

| 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 |

## 8. Acceptance checklist

Work through this before submitting. Everything has to pass.

Items marked **(run)** cannot be settled by reading the code — they need the app running in a browser. If you have not run it, report them as unverified rather than ticking them off.

**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) **(run)**
- [ ] 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 **(run)**
- [ ] 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 **(run)**

**Performance and stability**

- [ ] `setPixelRatio` is clamped (DPR ≤ 2)
- [ ] Five minutes of continuous parameter changes with flat GPU and JS memory (everything is disposed) **(run)**
- [ ] Hide the tab and come back: the view is intact, not blank, nothing lost **(run)**
- [ ] Mounting at 0×0 still initialises correctly once a real size arrives **(run)**
- [ ] 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 **(run)**
