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