diff --git a/AGENTS.md b/AGENTS.md
index ce9272c..81fa5a3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,7 +33,7 @@ Read on session start, in order:
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **BitmapForge** (1798 symbols, 4337 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **BitmapForge** (1755 symbols, 4499 relationships, 144 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/CLAUDE.md b/CLAUDE.md
index 28fd1bc..478d94f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,10 +9,19 @@ BitmapForge is a client-side web app that converts 3D models (STL, OBJ, GLTF/GLB
## Commands
- `npm run dev` — Start Vite dev server
-- `npm run build` — Production build to `dist/`
+- `npm run build` — Production build to `dist/` (also builds embed SDK first via `prebuild`)
+- `npm run build:embed` — Build embed SDK → copies to `public/embed/`
- `npm run preview` — Preview production build
+- `npm test` — Run all tests (Vitest)
+- `npm run test:watch` — Run tests in watch mode
+- `npm run test:e2e` — Run Playwright E2E tests
+- `npm run coverage` — Generate coverage report
+- `npm run lint` — Check for ESLint errors
+- `npm run lint:fix` — Auto-fix ESLint errors
+- `npm run format` — Prettier format all files
+- `npm run format:check` — Check Prettier formatting
-No test framework or linter is configured.
+**Tooling:** ESLint + Prettier for code style, Vitest for unit/integration tests, Playwright for E2E. Pre-commit hooks (husky + lint-staged) run lint + format on staged files automatically.
## Architecture
@@ -58,7 +67,7 @@ React 19, Vite 7, Tailwind CSS 4, Zustand (state), Three.js (3D), Radix UI (prim
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **BitmapForge** (1798 symbols, 4337 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **BitmapForge** (1755 symbols, 4499 relationships, 144 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/public/embed/bitmap-forge.es.js b/public/embed/bitmap-forge.es.js
index a3775bd..971ae77 100644
--- a/public/embed/bitmap-forge.es.js
+++ b/public/embed/bitmap-forge.es.js
@@ -3083,7 +3083,6 @@ class $ {
applyReset(e, t) {
return !1;
}
- // eslint-disable-line no-unused-vars
/** Cancel any in-progress reset. */
clearReset() {
}
@@ -4073,11 +4072,6 @@ const tt = {
helvetikerBold: () => import("./helvetiker_bold.typeface-Dpdv0pBL.js"),
optimer: () => import("./optimer_regular.typeface-BwmB6JhU.js"),
optimerBold: () => import("./optimer_bold.typeface-DdbaUQRb.js"),
- gentilis: () => import("./gentilis_regular.typeface-D0DjsepO.js"),
- gentilisBold: () => import("./gentilis_bold.typeface-6e94SVPf.js"),
- droidSans: () => import("./droid_sans_regular.typeface-Bs81qZjP.js"),
- droidSansBold: () => import("./droid_sans_bold.typeface-CSkX9gkA.js"),
- droidSerif: () => import("./droid_serif_regular.typeface-fxu4RfN8.js"),
droidMono: () => import("./droid_sans_mono_regular.typeface-Bk29f_ZW.js")
}, Pn = new Ln(), Se = /* @__PURE__ */ new Map();
async function Nn(u) {
diff --git a/src/app/components/AnimationControls/AnimationControls.jsx b/src/app/components/AnimationControls/AnimationControls.jsx
index 6c303b8..3422f08 100644
--- a/src/app/components/AnimationControls/AnimationControls.jsx
+++ b/src/app/components/AnimationControls/AnimationControls.jsx
@@ -21,8 +21,10 @@ function AnimationControls() {
const animationSpeed = useProjectStore((state) => state.animationSpeed)
const showPhaseDuration = useProjectStore((state) => state.showPhaseDuration)
const animationDuration = useProjectStore((state) => state.animationDuration)
+ const fadeMode = useProjectStore((state) => state.fadeMode)
const setUseFadeInOut = useProjectStore((state) => state.setUseFadeInOut)
const setFadeVariant = useProjectStore((state) => state.setFadeVariant)
+ const setFadeMode = useProjectStore((state) => state.setFadeMode)
const setAnimationEffect = useProjectStore((state) => state.setAnimationEffect)
const setAnimationSpeed = useProjectStore((state) => state.setAnimationSpeed)
const setShowPhaseDuration = useProjectStore((state) => state.setShowPhaseDuration)
@@ -39,6 +41,25 @@ function AnimationControls() {
{useFadeInOut && (
+
Direction
+
+ {[
+ { key: 'both', label: 'Both' },
+ { key: 'in', label: 'Fade in only' },
+ { key: 'out', label: 'Fade out only' }
+ ].map(({ key, label }) => (
+
+ ))}
+
Style
{FADE_VARIANT_KEYS.map((key) => (
diff --git a/src/app/store/selectors.js b/src/app/store/selectors.js
index b5ba230..77342da 100644
--- a/src/app/store/selectors.js
+++ b/src/app/store/selectors.js
@@ -31,6 +31,7 @@ export const selectEffectOptions = (state) => ({
export const selectAnimationOptions = (state) => ({
useFadeInOut: state.useFadeInOut,
+ fadeMode: state.fadeMode,
animationEffects: state.animationEffects,
animationSpeed: state.animationSpeed,
showPhaseDuration: state.showPhaseDuration,
diff --git a/src/app/store/selectors.test.js b/src/app/store/selectors.test.js
index fcabc15..05267c9 100644
--- a/src/app/store/selectors.test.js
+++ b/src/app/store/selectors.test.js
@@ -32,6 +32,7 @@ function makeState() {
colorShiftSaturation: 0,
// animation
useFadeInOut: true,
+ fadeMode: 'both',
animationEffects: { spinY: true },
animationSpeed: 1.0,
showPhaseDuration: 3000,
@@ -39,7 +40,7 @@ function makeState() {
animationPreset: 'spinY',
rotateOnShow: false,
showPreset: 'spinY',
- fadeVariant: 'bloom',
+ fadeVariant: 'cascade',
// input source
inputType: 'model',
model: null,
@@ -103,10 +104,11 @@ describe('selectEffectOptions', () => {
})
describe('selectAnimationOptions', () => {
- it('returns object with all 8 animation fields', () => {
+ it('returns object with all 9 animation fields', () => {
const result = selectAnimationOptions(makeState())
const keys = [
'useFadeInOut',
+ 'fadeMode',
'animationEffects',
'animationSpeed',
'showPhaseDuration',
diff --git a/src/app/store/slices/animationSlice.js b/src/app/store/slices/animationSlice.js
index 708c745..d0ccafb 100644
--- a/src/app/store/slices/animationSlice.js
+++ b/src/app/store/slices/animationSlice.js
@@ -2,7 +2,8 @@ import { DEFAULT_ANIMATION_EFFECTS } from '../../../engine/animation/effectTypes
export const ANIMATION_DEFAULTS = {
useFadeInOut: true,
- fadeVariant: 'bloom',
+ fadeVariant: 'glitch',
+ fadeMode: 'both',
animationEffects: { ...DEFAULT_ANIMATION_EFFECTS },
animationSpeed: 1.0,
showPhaseDuration: 3000,
@@ -17,6 +18,7 @@ export const createAnimationSlice = (set, _get) => ({
setUseFadeInOut: (useFadeInOut) => set({ useFadeInOut }),
setFadeVariant: (fadeVariant) => set({ fadeVariant }),
+ setFadeMode: (fadeMode) => set({ fadeMode }),
setAnimationEffect: (key, value) =>
set((state) => ({
animationEffects: { ...state.animationEffects, [key]: Boolean(value) }
diff --git a/src/app/utils/codeExport.test.js b/src/app/utils/codeExport.test.js
index b28a5c7..31f9800 100644
--- a/src/app/utils/codeExport.test.js
+++ b/src/app/utils/codeExport.test.js
@@ -103,7 +103,7 @@ describe('buildCodeZip — engine sources', () => {
it('ZIP contains 35 engine source files', async () => {
const zip = await getCodeZip()
const engineFiles = Object.keys(zip.files).filter((p) => p.includes('/engine/') && !p.endsWith('/'))
- expect(engineFiles).toHaveLength(40)
+ expect(engineFiles).toHaveLength(41)
})
it('ZIP contains engine/SceneManager.js', async () => {
diff --git a/src/app/utils/engineSources.js b/src/app/utils/engineSources.js
index ed760e8..c9aa88a 100644
--- a/src/app/utils/engineSources.js
+++ b/src/app/utils/engineSources.js
@@ -6,10 +6,11 @@ import BaseEffectSrc from '../../engine/effects/BaseEffect.js?raw'
import BitmapEffectSrc from '../../engine/effects/BitmapEffect.js?raw'
import ditherStrategiesSrc from '../../engine/effects/ditherStrategies.js?raw'
import BaseFadeVariantSrc from '../../engine/effects/fadeVariants/BaseFadeVariant.js?raw'
-import BloomVariantSrc from '../../engine/effects/fadeVariants/BloomVariant.js?raw'
import CascadeVariantSrc from '../../engine/effects/fadeVariants/CascadeVariant.js?raw'
import StaticVariantSrc from '../../engine/effects/fadeVariants/StaticVariant.js?raw'
import GlitchVariantSrc from '../../engine/effects/fadeVariants/GlitchVariant.js?raw'
+import DriftVariantSrc from '../../engine/effects/fadeVariants/DriftVariant.js?raw'
+import ScatterVariantSrc from '../../engine/effects/fadeVariants/ScatterVariant.js?raw'
import fadeVariantsIndexSrc from '../../engine/effects/fadeVariants/index.js?raw'
import BaseRendererSrc from '../../engine/renderers/BaseRenderer.js?raw'
import BitmapRendererSrc from '../../engine/renderers/BitmapRenderer.js?raw'
@@ -57,10 +58,11 @@ const ENGINE_SOURCES = [
{ path: 'engine/effects/BitmapEffect.js', content: BitmapEffectSrc },
{ path: 'engine/effects/ditherStrategies.js', content: ditherStrategiesSrc },
{ path: 'engine/effects/fadeVariants/BaseFadeVariant.js', content: BaseFadeVariantSrc },
- { path: 'engine/effects/fadeVariants/BloomVariant.js', content: BloomVariantSrc },
{ path: 'engine/effects/fadeVariants/CascadeVariant.js', content: CascadeVariantSrc },
{ path: 'engine/effects/fadeVariants/StaticVariant.js', content: StaticVariantSrc },
{ path: 'engine/effects/fadeVariants/GlitchVariant.js', content: GlitchVariantSrc },
+ { path: 'engine/effects/fadeVariants/DriftVariant.js', content: DriftVariantSrc },
+ { path: 'engine/effects/fadeVariants/ScatterVariant.js', content: ScatterVariantSrc },
{ path: 'engine/effects/fadeVariants/index.js', content: fadeVariantsIndexSrc },
{ path: 'engine/renderers/BaseRenderer.js', content: BaseRendererSrc },
{ path: 'engine/renderers/BitmapRenderer.js', content: BitmapRendererSrc },
diff --git a/src/app/utils/engineSources.test.js b/src/app/utils/engineSources.test.js
index 7606d18..fd6c7e5 100644
--- a/src/app/utils/engineSources.test.js
+++ b/src/app/utils/engineSources.test.js
@@ -9,10 +9,11 @@ const REQUIRED_PATHS = [
'engine/effects/BitmapEffect.js',
'engine/effects/ditherStrategies.js',
'engine/effects/fadeVariants/BaseFadeVariant.js',
- 'engine/effects/fadeVariants/BloomVariant.js',
'engine/effects/fadeVariants/CascadeVariant.js',
'engine/effects/fadeVariants/StaticVariant.js',
'engine/effects/fadeVariants/GlitchVariant.js',
+ 'engine/effects/fadeVariants/DriftVariant.js',
+ 'engine/effects/fadeVariants/ScatterVariant.js',
'engine/effects/fadeVariants/index.js',
'engine/renderers/BaseRenderer.js',
'engine/renderers/BitmapRenderer.js',
@@ -99,10 +100,11 @@ describe('ENGINE_SOURCES', () => {
it('includes all fadeVariant files', () => {
const fadeFiles = [
'engine/effects/fadeVariants/BaseFadeVariant.js',
- 'engine/effects/fadeVariants/BloomVariant.js',
'engine/effects/fadeVariants/CascadeVariant.js',
'engine/effects/fadeVariants/StaticVariant.js',
'engine/effects/fadeVariants/GlitchVariant.js',
+ 'engine/effects/fadeVariants/DriftVariant.js',
+ 'engine/effects/fadeVariants/ScatterVariant.js',
'engine/effects/fadeVariants/index.js'
]
const paths = new Set(ENGINE_SOURCES.map((e) => e.path))
diff --git a/src/app/utils/exportConfig.test.js b/src/app/utils/exportConfig.test.js
index f9844ac..9accf59 100644
--- a/src/app/utils/exportConfig.test.js
+++ b/src/app/utils/exportConfig.test.js
@@ -10,7 +10,7 @@ function makeState(overrides = {}) {
minBrightness: 0.05,
backgroundColor: '#0a0a0a',
animationDuration: 2500,
- fadeVariant: 'bloom',
+ fadeVariant: 'cascade',
renderMode: 'bitmap',
model: null,
useFadeInOut: true,
diff --git a/src/app/utils/projectFile.js b/src/app/utils/projectFile.js
index cc0dd61..c315825 100644
--- a/src/app/utils/projectFile.js
+++ b/src/app/utils/projectFile.js
@@ -86,7 +86,7 @@ function migrateV1toV2(project) {
settings: {
...project.settings,
useFadeInOut: project.settings.useFadeInOut ?? true,
- fadeVariant: project.settings.fadeVariant ?? 'bloom',
+ fadeVariant: project.settings.fadeVariant ?? 'cascade',
animationEffects: project.settings.animationEffects ?? { ...DEFAULT_ANIMATION_EFFECTS },
baseRotation: project.settings.baseRotation ?? { x: 0, y: 0, z: 0 },
seed: null // preserve old deterministic-hash behavior
diff --git a/src/app/utils/reactComponentExport.test.js b/src/app/utils/reactComponentExport.test.js
index 6f904bf..4f1a7dc 100644
--- a/src/app/utils/reactComponentExport.test.js
+++ b/src/app/utils/reactComponentExport.test.js
@@ -10,7 +10,7 @@ const BASE_STATE = {
minBrightness: 0.05,
backgroundColor: '#0a0a0a',
animationDuration: 2500,
- fadeVariant: 'bloom',
+ fadeVariant: 'cascade',
useFadeInOut: true,
animationEffects: { spinX: false, spinY: true, spinZ: false, float: false },
animationSpeed: 1.0,
@@ -63,7 +63,7 @@ describe('buildReactComponent — ZIP structure', () => {
it('ZIP contains 35 engine source files', async () => {
const zip = await getZip()
const engineFiles = Object.keys(zip.files).filter((p) => p.includes('/engine/') && !p.endsWith('/'))
- expect(engineFiles).toHaveLength(40)
+ expect(engineFiles).toHaveLength(41)
})
it('uses custom component name for folder', async () => {
diff --git a/src/app/utils/webComponentExport.test.js b/src/app/utils/webComponentExport.test.js
index 46ad9ca..0281c89 100644
--- a/src/app/utils/webComponentExport.test.js
+++ b/src/app/utils/webComponentExport.test.js
@@ -10,7 +10,7 @@ const BASE_STATE = {
minBrightness: 0.05,
backgroundColor: '#0a0a0a',
animationDuration: 2500,
- fadeVariant: 'bloom',
+ fadeVariant: 'cascade',
useFadeInOut: true,
animationEffects: { spinX: false, spinY: true, spinZ: false, float: false },
animationSpeed: 1.0,
@@ -58,7 +58,7 @@ describe('buildWebComponent — ZIP structure', () => {
it('ZIP contains 35 engine source files', async () => {
const zip = await getZip()
const engineFiles = Object.keys(zip.files).filter((p) => p.includes('/engine/') && !p.endsWith('/'))
- expect(engineFiles).toHaveLength(40)
+ expect(engineFiles).toHaveLength(41)
})
it('uses custom element name for folder and file', async () => {
diff --git a/src/engine/SceneManager.js b/src/engine/SceneManager.js
index 8b46975..26960b3 100644
--- a/src/engine/SceneManager.js
+++ b/src/engine/SceneManager.js
@@ -64,6 +64,12 @@ class SceneManager {
this.animationEngine = new AnimationEngine()
+ // When the user switches fade variant mid-loop, reset the model rotation so the
+ // new fadeIn always captures the front-facing pose.
+ this.effect.onVariantChange = () => {
+ this.animationEngine.onExternalFadeRestart(this.animGroup)
+ }
+
this.objectGroup = null
this.currentObjectUrl = null
this.lastFrameTime = performance.now()
diff --git a/src/engine/animation/AnimationEngine.js b/src/engine/animation/AnimationEngine.js
index 20bd097..4bcb668 100644
--- a/src/engine/animation/AnimationEngine.js
+++ b/src/engine/animation/AnimationEngine.js
@@ -13,6 +13,7 @@ const EFFECT_ORDER = ['spinX', 'spinY', 'spinZ', 'float', 'bounce', 'pulse', 'sh
class AnimationEngine {
constructor() {
this.useFadeInOut = true
+ this.fadeMode = 'both'
this.animationEffects = { ...DEFAULT_ANIMATION_EFFECTS }
this.speed = ANIMATION_PRESETS.spinY.defaultSpeed
this.showPhaseDuration = 20000
@@ -43,6 +44,11 @@ class AnimationEngine {
// Snapshot of previous effects — populated on the first update() call so
// startup never triggers a spurious reset.
this._previousEffects = null
+
+ // Spin-aligned show duration computed once when entering the show phase.
+ // Rounds up to the nearest full rotation so fadeOut always fires at a
+ // complete spin boundary (360°, 720°, …).
+ this._effectiveShowDuration = this.showPhaseDuration
}
// Build a proxy object that maps the old _resetTransitions shape to effect internals.
@@ -99,8 +105,39 @@ class AnimationEngine {
this._effectMap.orbit._restorePending = val
}
+ // Compute the show phase duration rounded up to the nearest complete spin
+ // so fadeOut always fires at a 360°/720°/… boundary.
+ // Falls back to raw showPhaseDuration when no spin effect is active.
+ _computeSpinAlignedShowDuration() {
+ const hasSpin = this.animationEffects.spinX || this.animationEffects.spinY || this.animationEffects.spinZ
+ if (!hasSpin) return this.showPhaseDuration
+ const spinPeriodMs = ((2 * Math.PI) / this.speed) * 1000
+ const minSpins = Math.max(1, Math.ceil(this.showPhaseDuration / spinPeriodMs))
+ return minSpins * spinPeriodMs
+ }
+
+ // Called by SceneManager when an external event (e.g. fade variant change)
+ // triggers a new fadeIn mid-loop so the 3D model rotation is reset before
+ // particles are captured.
+ onExternalFadeRestart(modelGroup) {
+ this._resetSpinRotations(modelGroup)
+ }
+
+ // Reset accumulated rotation to 0 for every active spin axis.
+ // Called at cycle boundaries so each fadeIn always starts from the front.
+ _resetSpinRotations(modelGroup) {
+ if (!modelGroup) return
+ const e = this.animationEffects
+ if (e.spinX) modelGroup.rotation.x = 0
+ if (e.spinY) modelGroup.rotation.y = 0
+ if (e.spinZ) modelGroup.rotation.z = 0
+ }
+
setFadeOptions(options = {}) {
if (typeof options.useFadeInOut === 'boolean') this.useFadeInOut = options.useFadeInOut
+ if (options.fadeMode === 'both' || options.fadeMode === 'in' || options.fadeMode === 'out') {
+ this.fadeMode = options.fadeMode
+ }
if (options.animationEffects && typeof options.animationEffects === 'object') {
this.animationEffects = { ...this.animationEffects, ...options.animationEffects }
}
@@ -212,26 +249,77 @@ class AnimationEngine {
const now = performance.now()
const currentPhase = effect.getAnimationPhase()
- if (currentPhase === 'fadeIn' && effect.isAnimationComplete()) {
- effect.startAnimation('show')
- this.phaseStartTime = now
- // Do NOT call applyEffects here. The transition frame must render at the same
- // rotation the particles were computed from — any increment would cause a visible
- // tilt as the static render snaps to a different pose than the particle positions.
- // Rotation begins on the next frame when the show branch runs normally.
- } else if (currentPhase === 'show') {
- this.applyEffects(modelGroup, deltaSeconds, camera)
- this._applyResetTransitions(modelGroup, deltaSeconds)
- if (now - this.phaseStartTime >= this.showPhaseDuration) {
- effect.startAnimation('fadeOut')
- }
- } else if (currentPhase === 'fadeOut') {
- // Continue rotating during fade-out — particles are 2D snapshots that fade
- // independently of the 3D model, so rotation here is visually seamless.
- this.applyEffects(modelGroup, deltaSeconds, camera)
- this._applyResetTransitions(modelGroup, deltaSeconds)
- if (effect.isAnimationComplete()) {
+
+ if (this.fadeMode === 'in') {
+ // Sequence: fadeIn → show → fadeIn → … (no fadeOut)
+ if (currentPhase === 'fadeOut') {
+ // Landed here from a mode switch — jump back to fadeIn.
+ this._resetSpinRotations(modelGroup)
effect.startAnimation('fadeIn')
+ } else if (currentPhase === 'fadeIn' && effect.isAnimationComplete()) {
+ this._effectiveShowDuration = this._computeSpinAlignedShowDuration()
+ effect.startAnimation('show')
+ this.phaseStartTime = now
+ } else if (currentPhase === 'show') {
+ this.applyEffects(modelGroup, deltaSeconds, camera)
+ this._applyResetTransitions(modelGroup, deltaSeconds)
+ if (now - this.phaseStartTime >= this._effectiveShowDuration) {
+ // Reset spin to front so the next fadeIn always assembles facing forward.
+ this._resetSpinRotations(modelGroup)
+ effect.startAnimation('fadeIn')
+ }
+ }
+ } else if (this.fadeMode === 'out') {
+ // Sequence: show → fadeOut → show → … (no fadeIn)
+ if (currentPhase === 'fadeIn') {
+ // Initial state or mode switch — skip directly to show.
+ this._effectiveShowDuration = this._computeSpinAlignedShowDuration()
+ effect.startAnimation('show')
+ this.phaseStartTime = now
+ } else if (currentPhase === 'show') {
+ this.applyEffects(modelGroup, deltaSeconds, camera)
+ this._applyResetTransitions(modelGroup, deltaSeconds)
+ if (now - this.phaseStartTime >= this._effectiveShowDuration) {
+ effect.startAnimation('fadeOut')
+ }
+ } else if (currentPhase === 'fadeOut') {
+ this.applyEffects(modelGroup, deltaSeconds, camera)
+ this._applyResetTransitions(modelGroup, deltaSeconds)
+ if (effect.isAnimationComplete()) {
+ // Reset spin to front before the next show phase so the object always
+ // starts facing forward after fading back in.
+ this._resetSpinRotations(modelGroup)
+ this._effectiveShowDuration = this._computeSpinAlignedShowDuration()
+ effect.startAnimation('show')
+ this.phaseStartTime = now
+ }
+ }
+ } else {
+ // 'both' — fadeIn → show → fadeOut → …
+ if (currentPhase === 'fadeIn' && effect.isAnimationComplete()) {
+ this._effectiveShowDuration = this._computeSpinAlignedShowDuration()
+ effect.startAnimation('show')
+ this.phaseStartTime = now
+ // Do NOT call applyEffects here. The transition frame must render at the same
+ // rotation the particles were computed from — any increment would cause a visible
+ // tilt as the static render snaps to a different pose than the particle positions.
+ // Rotation begins on the next frame when the show branch runs normally.
+ } else if (currentPhase === 'show') {
+ this.applyEffects(modelGroup, deltaSeconds, camera)
+ this._applyResetTransitions(modelGroup, deltaSeconds)
+ if (now - this.phaseStartTime >= this._effectiveShowDuration) {
+ effect.startAnimation('fadeOut')
+ }
+ } else if (currentPhase === 'fadeOut') {
+ // Continue rotating during fade-out — particles are 2D snapshots that fade
+ // independently of the 3D model, so rotation here is visually seamless.
+ this.applyEffects(modelGroup, deltaSeconds, camera)
+ this._applyResetTransitions(modelGroup, deltaSeconds)
+ if (effect.isAnimationComplete()) {
+ // Reset spin to front so the next fadeIn always assembles facing forward.
+ this._resetSpinRotations(modelGroup)
+ effect.startAnimation('fadeIn')
+ }
}
}
}
@@ -240,7 +328,11 @@ class AnimationEngine {
if (!this.useFadeInOut) {
return Math.round(((2 * Math.PI) / this.speed) * 1000)
}
- return this.animationDuration * 2 + this.showPhaseDuration
+ const effectiveShow = this._computeSpinAlignedShowDuration()
+ if (this.fadeMode === 'in' || this.fadeMode === 'out') {
+ return this.animationDuration + effectiveShow
+ }
+ return this.animationDuration * 2 + effectiveShow
}
resetToStart() {
@@ -273,17 +365,28 @@ class AnimationEngine {
const e = this.animationEffects
// Mirror update(): rotation only accumulates during the 'show' phase.
- // During fadeIn and fadeOut the model is stationary — this keeps particle
- // landing positions consistent with the rotation shown in the show phase.
+ // During fadeIn the model is stationary — this keeps particle landing
+ // positions consistent with the rotation shown in the show phase.
let showTs = ts // seconds elapsed within the show phase
if (this.useFadeInOut) {
const fadeDurS = this.animationDuration / 1000
- if (ts < fadeDurS) {
- showTs = 0 // fade-in: model stationary at rotation 0
+ if (this.fadeMode === 'in') {
+ // Loop: [fadeIn(dur), show(showDur)]
+ if (ts < fadeDurS) {
+ showTs = 0 // fadeIn: model stationary
+ } else {
+ showTs = ts - fadeDurS
+ }
+ } else if (this.fadeMode === 'out') {
+ // Loop: [show(showDur), fadeOut(dur)] — no static phase, rotation runs from start.
+ showTs = ts
} else {
- // Both show and fade-out: rotation runs continuously from show start.
- // fade-out keeps the model spinning (matches live animation behaviour).
- showTs = ts - fadeDurS
+ // 'both': [fadeIn(dur), show(showDur), fadeOut(dur)]
+ if (ts < fadeDurS) {
+ showTs = 0 // fadeIn: model stationary
+ } else {
+ showTs = ts - fadeDurS
+ }
}
}
@@ -314,14 +417,31 @@ class AnimationEngine {
if (effect) {
if (this.useFadeInOut) {
const dur = this.animationDuration
- const show = this.showPhaseDuration
+ const show = this._computeSpinAlignedShowDuration()
const t = absoluteTimeMs
- if (t < dur) {
- effect.setPhaseProgress('fadeIn', t / dur)
- } else if (t < dur + show) {
- effect.setPhaseProgress('show', 1)
+ if (this.fadeMode === 'in') {
+ // Loop: [fadeIn(dur), show(showDur)]
+ if (t < dur) {
+ effect.setPhaseProgress('fadeIn', t / dur)
+ } else {
+ effect.setPhaseProgress('show', 1)
+ }
+ } else if (this.fadeMode === 'out') {
+ // Loop: [show(showDur), fadeOut(dur)]
+ if (t < show) {
+ effect.setPhaseProgress('show', 1)
+ } else {
+ effect.setPhaseProgress('fadeOut', (t - show) / dur)
+ }
} else {
- effect.setPhaseProgress('fadeOut', (t - dur - show) / dur)
+ // 'both': [fadeIn(dur), show(showDur), fadeOut(dur)]
+ if (t < dur) {
+ effect.setPhaseProgress('fadeIn', t / dur)
+ } else if (t < dur + show) {
+ effect.setPhaseProgress('show', 1)
+ } else {
+ effect.setPhaseProgress('fadeOut', (t - dur - show) / dur)
+ }
}
} else {
effect.setPhaseProgress('show', 1)
diff --git a/src/engine/effects/BitmapEffect.js b/src/engine/effects/BitmapEffect.js
index 3f589d5..94986de 100644
--- a/src/engine/effects/BitmapEffect.js
+++ b/src/engine/effects/BitmapEffect.js
@@ -48,9 +48,19 @@ class BitmapEffect extends BaseEffect {
this._postChain.addEffect('colorShift', new ColorShiftEffect())
// Active fade variant — recreated whenever options.fadeVariant changes.
- const initialVariant = this.options.fadeVariant ?? 'bloom'
+ const initialVariant = this.options.fadeVariant ?? 'glitch'
this.fadeVariant = createFadeVariant(initialVariant)
this._currentFadeVariantKey = initialVariant
+
+ // Optional callback: fired when a variant change triggers a new fadeIn mid-loop.
+ // SceneManager uses this to reset the 3D model rotation so the rebuilt particles
+ // always capture the front-facing pose.
+ this.onVariantChange = null
+
+ // Held between render() and renderBitmap() so renderBitmap() can issue a
+ // corrective re-render after a variant-change rotation reset.
+ this._scene = null
+ this._camera = null
}
/** The visible output canvas from the active renderer. */
@@ -107,6 +117,8 @@ class BitmapEffect extends BaseEffect {
}
render(scene, camera) {
+ this._scene = scene
+ this._camera = camera
this.renderer.render(scene, camera)
this.tickAnimation()
this.renderBitmap()
@@ -122,10 +134,23 @@ class BitmapEffect extends BaseEffect {
// Swap variant instance when the store option changes and restart the fade
// so the new style plays immediately from the beginning.
- const wantedVariant = this.options.fadeVariant ?? 'bloom'
+ const wantedVariant = this.options.fadeVariant ?? 'glitch'
if (wantedVariant !== this._currentFadeVariantKey) {
this._currentFadeVariantKey = wantedVariant
this.fadeVariant = createFadeVariant(wantedVariant)
+ // Order matters:
+ // 1. Reset the 3D model rotation to 0 (via SceneManager callback) so the
+ // front face will be captured when particles initialise.
+ // 2. Re-render the 3D scene with the reset rotation so the WebGL canvas
+ // is up-to-date before initializeParticles() samples it below.
+ // (Without this, the canvas still holds the frame rendered at the top
+ // of this render() call — before the rotation was reset.)
+ // 3. Start the fadeIn animation, which resets particles to uninitialised;
+ // they will be initialised from the freshly-rendered canvas on this frame.
+ this.onVariantChange?.()
+ if (this._scene && this._camera) {
+ this.renderer.render(this._scene, this._camera)
+ }
this.startAnimation('fadeIn')
}
@@ -186,6 +211,8 @@ class BitmapEffect extends BaseEffect {
}
dispose() {
+ this._scene = null
+ this._camera = null
this._postChain = null
this._activeRenderer?.dispose()
this._activeRenderer = null
diff --git a/src/engine/effects/fadeVariants/BloomVariant.js b/src/engine/effects/fadeVariants/BloomVariant.js
deleted file mode 100644
index b1aab0b..0000000
--- a/src/engine/effects/fadeVariants/BloomVariant.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { BaseFadeVariant } from './BaseFadeVariant.js'
-
-/**
- * Bloom — pixels reveal by brightness tier (highlights ignite first, shadows fill in last).
- * On fadeIn: bright pixels bloom first; dark pixels trail behind.
- * On fadeOut: dark pixels extinguish first; bright pixels linger, then fade.
- *
- * Each pixel's alpha is a smooth ramp based on where its brightness sits relative
- * to the current progress front, creating a glowing wash-in/wash-out effect.
- */
-class BloomVariant extends BaseFadeVariant {
- initVariantMetadata(particles) {
- // Normalise brightness into [0,1] per-particle threshold.
- // A small offset so even the darkest pixels eventually appear.
- for (const p of particles) {
- // threshold: bright pixels → low threshold (appear early in fadeIn)
- p.bloomThreshold = 1 - p.brightness
- }
- }
-
- getVisiblePixels(particles, progress, phase) {
- // Softness controls how wide the alpha gradient band is (0 = hard cut, higher = softer).
- const SOFTNESS = 0.18
- const result = []
-
- for (const p of particles) {
- let alpha
- if (phase === 'fadeIn') {
- // Pixel lights up when progress crosses its threshold.
- alpha = Math.min(1, Math.max(0, (progress - p.bloomThreshold + SOFTNESS) / SOFTNESS))
- } else if (phase === 'fadeOut') {
- // Pixel extinguishes when progress climbs past its threshold.
- alpha = Math.min(1, Math.max(0, (p.bloomThreshold - progress + SOFTNESS) / SOFTNESS))
- } else {
- continue
- }
-
- if (alpha <= 0) continue
- result.push({ x: p.finalX, y: p.finalY, brightness: p.brightness, color: p.color, alpha })
- }
- return result
- }
-}
-
-export { BloomVariant }
diff --git a/src/engine/effects/fadeVariants/DriftVariant.js b/src/engine/effects/fadeVariants/DriftVariant.js
new file mode 100644
index 0000000..323721c
--- /dev/null
+++ b/src/engine/effects/fadeVariants/DriftVariant.js
@@ -0,0 +1,53 @@
+import { BaseFadeVariant } from './BaseFadeVariant.js'
+
+/**
+ * Drift — pixels fly from their scatter positions (off-canvas) to their final positions,
+ * creating a "reverse explosion" assembly effect with maximum visible movement.
+ *
+ * On fadeIn: pixels stream in from all directions simultaneously, staggered by distance
+ * from the image centre so edge pixels arrive slightly later.
+ * On fadeOut: pixels stream back out to the same scatter positions in reverse.
+ *
+ * Uses the existing p.startX/Y (scatter) and p.finalX/Y (grid) plus the distance-based
+ * p.delay (0–0.4) that BaseEffect pre-computes for every particle.
+ */
+class DriftVariant extends BaseFadeVariant {
+ // No extra metadata needed — startX/Y, finalX/Y, and delay are already on every particle.
+ // eslint-disable-next-line no-unused-vars
+ initVariantMetadata(particles, canvasWidth, canvasHeight, pixelSize, gridWidth, gridHeight) {}
+
+ getVisiblePixels(particles, progress, phase, easeInOutCubic) {
+ const result = []
+
+ for (const p of particles) {
+ const remaining = 1 - p.delay
+ const localP = remaining > 0 ? Math.max(0, Math.min(1, (progress - p.delay) / remaining)) : 1
+ const t = easeInOutCubic(localP)
+
+ let x, y, alpha
+
+ if (phase === 'fadeIn') {
+ if (localP <= 0) continue
+ x = Math.round(p.startX + (p.finalX - p.startX) * t)
+ y = Math.round(p.startY + (p.finalY - p.startY) * t)
+ // Quick alpha ramp so pixels are visible while travelling, not just at arrival.
+ alpha = Math.min(1, localP * 4)
+ } else if (phase === 'fadeOut') {
+ if (localP >= 1) continue
+ x = Math.round(p.finalX + (p.startX - p.finalX) * t)
+ y = Math.round(p.finalY + (p.startY - p.finalY) * t)
+ // Stay fully visible until the last quarter of the journey, then fade out.
+ alpha = localP < 0.75 ? 1 : Math.max(0, (1 - localP) * 4)
+ } else {
+ continue
+ }
+
+ if (alpha <= 0) continue
+ result.push({ x, y, brightness: p.brightness, color: p.color, alpha })
+ }
+
+ return result
+ }
+}
+
+export { DriftVariant }
diff --git a/src/engine/effects/fadeVariants/ScatterVariant.js b/src/engine/effects/fadeVariants/ScatterVariant.js
new file mode 100644
index 0000000..3354968
--- /dev/null
+++ b/src/engine/effects/fadeVariants/ScatterVariant.js
@@ -0,0 +1,69 @@
+import { BaseFadeVariant } from './BaseFadeVariant.js'
+
+/**
+ * Scatter — pixels start at random positions anywhere on the canvas and converge
+ * to their final grid positions (or disperse back out on fadeOut).
+ *
+ * Unlike Drift (which flies particles in from off-canvas edges), Scatter seeds start
+ * positions across the entire canvas area. This gives the impression of pixels
+ * reshuffling in place — chaotic but self-contained — rather than arriving from outside.
+ *
+ * The per-pixel stagger is also purely random (not distance-based), so there is no
+ * visible spatial order: some edge pixels settle early, some centre pixels settle late.
+ *
+ * On fadeIn: pixels migrate from their random canvas positions to the grid.
+ * On fadeOut: pixels disperse back to the same random positions.
+ */
+class ScatterVariant extends BaseFadeVariant {
+ initVariantMetadata(particles, canvasWidth, canvasHeight) {
+ const seed = this.options.seed ?? 7
+
+ for (const p of particles) {
+ const h1 = Math.sin(p.idx * 23.9898 + seed * 91.233) * 43758.5453
+ const h2 = Math.sin(p.idx * 67.1234 + seed * 43.123) * 43758.5453
+ const h3 = Math.sin(p.idx * 11.5678 + seed * 77.456) * 43758.5453
+
+ // Random start position anywhere on the visible canvas.
+ p.scatterX = (h1 - Math.floor(h1)) * canvasWidth
+ p.scatterY = (h2 - Math.floor(h2)) * canvasHeight
+
+ // Purely random stagger — no spatial bias.
+ p.scatterDelay = (h3 - Math.floor(h3)) * 0.5
+ }
+ }
+
+ getVisiblePixels(particles, progress, phase, easeInOutCubic) {
+ const result = []
+
+ for (const p of particles) {
+ const remaining = 1 - p.scatterDelay
+ const localP = remaining > 0 ? Math.max(0, Math.min(1, (progress - p.scatterDelay) / remaining)) : 1
+ const t = easeInOutCubic(localP)
+
+ let x, y, alpha
+
+ if (phase === 'fadeIn') {
+ if (localP <= 0) continue
+ x = Math.round(p.scatterX + (p.finalX - p.scatterX) * t)
+ y = Math.round(p.scatterY + (p.finalY - p.scatterY) * t)
+ // Pixels are visible while travelling — ramp up quickly so movement is visible.
+ alpha = Math.min(1, localP * 3)
+ } else if (phase === 'fadeOut') {
+ if (localP >= 1) continue
+ x = Math.round(p.finalX + (p.scatterX - p.finalX) * t)
+ y = Math.round(p.finalY + (p.scatterY - p.finalY) * t)
+ // Stay fully visible for most of the journey, fade at the end.
+ alpha = localP < 0.75 ? 1 : Math.max(0, (1 - localP) * 4)
+ } else {
+ continue
+ }
+
+ if (alpha <= 0) continue
+ result.push({ x, y, brightness: p.brightness, color: p.color, alpha })
+ }
+
+ return result
+ }
+}
+
+export { ScatterVariant }
diff --git a/src/engine/effects/fadeVariants/StaticVariant.js b/src/engine/effects/fadeVariants/StaticVariant.js
index 7423476..7bc9c97 100644
--- a/src/engine/effects/fadeVariants/StaticVariant.js
+++ b/src/engine/effects/fadeVariants/StaticVariant.js
@@ -38,8 +38,9 @@ class StaticVariant extends BaseFadeVariant {
// Signal clears from top (threshold≈0) downward
alpha = Math.min(1, Math.max(0, (progress - p.staticThreshold + SOFTNESS) / SOFTNESS))
} else if (phase === 'fadeOut') {
- // Signal cuts out from bottom (threshold≈1) upward
- alpha = Math.min(1, Math.max(0, (p.staticThreshold - (1 - progress) + SOFTNESS) / SOFTNESS))
+ // Signal cuts out from bottom (threshold≈1) upward — start fully visible, end invisible.
+ // Complement of the fadeIn formula with inverted threshold so bottom rows disappear first.
+ alpha = 1 - Math.min(1, Math.max(0, (progress + p.staticThreshold - 1 + SOFTNESS) / SOFTNESS))
} else {
continue
}
diff --git a/src/engine/effects/fadeVariants/index.js b/src/engine/effects/fadeVariants/index.js
index da90df5..d8d6a6f 100644
--- a/src/engine/effects/fadeVariants/index.js
+++ b/src/engine/effects/fadeVariants/index.js
@@ -1,28 +1,32 @@
-import { BloomVariant } from './BloomVariant.js'
import { CascadeVariant } from './CascadeVariant.js'
import { StaticVariant } from './StaticVariant.js'
import { GlitchVariant } from './GlitchVariant.js'
+import { DriftVariant } from './DriftVariant.js'
+import { ScatterVariant } from './ScatterVariant.js'
const FADE_VARIANT_LABELS = {
- bloom: 'Bloom',
cascade: 'Cascade',
static: 'Static',
- glitch: 'Glitch'
+ glitch: 'Glitch',
+ drift: 'Drift',
+ scatter: 'Scatter'
}
const FADE_VARIANT_KEYS = Object.keys(FADE_VARIANT_LABELS)
function createFadeVariant(name, options = {}) {
switch (name) {
- case 'cascade':
- return new CascadeVariant(options)
case 'static':
return new StaticVariant(options)
case 'glitch':
return new GlitchVariant(options)
- case 'bloom':
+ case 'drift':
+ return new DriftVariant(options)
+ case 'scatter':
+ return new ScatterVariant(options)
+ case 'cascade':
default:
- return new BloomVariant(options)
+ return new CascadeVariant(options)
}
}
diff --git a/test/integration/exportConformance.test.js b/test/integration/exportConformance.test.js
index 99bd878..e3f3600 100644
--- a/test/integration/exportConformance.test.js
+++ b/test/integration/exportConformance.test.js
@@ -176,7 +176,7 @@ describe('React component ZIP conformance', () => {
it('ZIP contains 35 engine source files', async () => {
const zip = await getZip()
const engineFiles = Object.keys(zip.files).filter((p) => p.includes('/engine/') && !p.endsWith('/'))
- expect(engineFiles).toHaveLength(40)
+ expect(engineFiles).toHaveLength(41)
})
})
@@ -212,7 +212,7 @@ describe('Web Component ZIP conformance', () => {
it('ZIP contains 35 engine source files', async () => {
const zip = await getZip()
const engineFiles = Object.keys(zip.files).filter((p) => p.includes('/engine/') && !p.endsWith('/'))
- expect(engineFiles).toHaveLength(40)
+ expect(engineFiles).toHaveLength(41)
})
})
@@ -282,7 +282,7 @@ describe('Code ZIP conformance', () => {
it('ZIP contains 35 engine source files', async () => {
const zip = await getZip()
const engineFiles = Object.keys(zip.files).filter((p) => p.includes('/engine/') && !p.endsWith('/'))
- expect(engineFiles).toHaveLength(40)
+ expect(engineFiles).toHaveLength(41)
})
})