From d229f2c99445272ee3c1f582460ba0e370c142dc Mon Sep 17 00:00:00 2001 From: santiagocetran Date: Fri, 10 Apr 2026 13:02:04 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20rework=20fade=20variants=20?= =?UTF-8?q?=E2=80=94=20remove=20Bloom,=20add=20Drift/Sweep/Vortex,=20add?= =?UTF-8?q?=20fade=20direction=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove BloomVariant; Cascade is now the default fade style - Add fadeMode ('both' | 'in' | 'out') so users can run fade-in only or fade-out only loops — useful for website embeds that should appear once and stay visible - Add three movement-heavy variants: Drift (pixels fly from scatter positions), Sweep (left-to-right wave with per-pixel slide), and Vortex (curved arcing paths producing a swirl assembly) - Wire fadeMode through store slice, selector, AnimationEngine (update, getLoopDurationMs, seekTo) for correct export frame timing - Update all affected tests and engine source manifests Co-Authored-By: Claude Sonnet 4.6 --- .../AnimationControls/AnimationControls.jsx | 21 +++ src/app/store/selectors.js | 1 + src/app/store/selectors.test.js | 6 +- src/app/store/slices/animationSlice.js | 4 +- src/app/utils/codeExport.test.js | 2 +- src/app/utils/engineSources.js | 8 +- src/app/utils/engineSources.test.js | 8 +- src/app/utils/exportConfig.test.js | 2 +- src/app/utils/projectFile.js | 2 +- src/app/utils/reactComponentExport.test.js | 4 +- src/app/utils/webComponentExport.test.js | 4 +- src/engine/animation/AnimationEngine.js | 136 ++++++++++++++---- src/engine/effects/BitmapEffect.js | 4 +- .../effects/fadeVariants/BloomVariant.js | 45 ------ .../effects/fadeVariants/DriftVariant.js | 53 +++++++ .../effects/fadeVariants/SweepVariant.js | 65 +++++++++ .../effects/fadeVariants/VortexVariant.js | 74 ++++++++++ src/engine/effects/fadeVariants/index.js | 22 ++- test/integration/exportConformance.test.js | 6 +- 19 files changed, 365 insertions(+), 102 deletions(-) delete mode 100644 src/engine/effects/fadeVariants/BloomVariant.js create mode 100644 src/engine/effects/fadeVariants/DriftVariant.js create mode 100644 src/engine/effects/fadeVariants/SweepVariant.js create mode 100644 src/engine/effects/fadeVariants/VortexVariant.js 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..2d9713b 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: 'cascade', + 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..25b788c 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(42) }) it('ZIP contains engine/SceneManager.js', async () => { diff --git a/src/app/utils/engineSources.js b/src/app/utils/engineSources.js index ed760e8..f83b9cc 100644 --- a/src/app/utils/engineSources.js +++ b/src/app/utils/engineSources.js @@ -6,10 +6,12 @@ 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 SweepVariantSrc from '../../engine/effects/fadeVariants/SweepVariant.js?raw' +import VortexVariantSrc from '../../engine/effects/fadeVariants/VortexVariant.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 +59,12 @@ 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/SweepVariant.js', content: SweepVariantSrc }, + { path: 'engine/effects/fadeVariants/VortexVariant.js', content: VortexVariantSrc }, { 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..a6d2efe 100644 --- a/src/app/utils/engineSources.test.js +++ b/src/app/utils/engineSources.test.js @@ -9,10 +9,12 @@ 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/SweepVariant.js', + 'engine/effects/fadeVariants/VortexVariant.js', 'engine/effects/fadeVariants/index.js', 'engine/renderers/BaseRenderer.js', 'engine/renderers/BitmapRenderer.js', @@ -99,10 +101,12 @@ 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/SweepVariant.js', + 'engine/effects/fadeVariants/VortexVariant.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..34a8ec9 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(42) }) 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..02aace2 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(42) }) it('uses custom element name for folder and file', async () => { diff --git a/src/engine/animation/AnimationEngine.js b/src/engine/animation/AnimationEngine.js index 20bd097..216b286 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 @@ -101,6 +102,9 @@ class AnimationEngine { 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 +216,65 @@ 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. effect.startAnimation('fadeIn') + } else if (currentPhase === 'fadeIn' && effect.isAnimationComplete()) { + effect.startAnimation('show') + this.phaseStartTime = now + } else if (currentPhase === 'show') { + this.applyEffects(modelGroup, deltaSeconds, camera) + this._applyResetTransitions(modelGroup, deltaSeconds) + if (now - this.phaseStartTime >= this.showPhaseDuration) { + 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. + effect.startAnimation('show') + this.phaseStartTime = now + } 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') { + this.applyEffects(modelGroup, deltaSeconds, camera) + this._applyResetTransitions(modelGroup, deltaSeconds) + if (effect.isAnimationComplete()) { + effect.startAnimation('show') + this.phaseStartTime = now + } + } + } else { + // 'both' — original behaviour: fadeIn → show → fadeOut → … + 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()) { + effect.startAnimation('fadeIn') + } } } } @@ -240,6 +283,9 @@ class AnimationEngine { if (!this.useFadeInOut) { return Math.round(((2 * Math.PI) / this.speed) * 1000) } + if (this.fadeMode === 'in' || this.fadeMode === 'out') { + return this.animationDuration + this.showPhaseDuration + } return this.animationDuration * 2 + this.showPhaseDuration } @@ -273,17 +319,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 + } } } @@ -316,12 +373,29 @@ class AnimationEngine { const dur = this.animationDuration const show = this.showPhaseDuration 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..e385eda 100644 --- a/src/engine/effects/BitmapEffect.js +++ b/src/engine/effects/BitmapEffect.js @@ -48,7 +48,7 @@ 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 ?? 'cascade' this.fadeVariant = createFadeVariant(initialVariant) this._currentFadeVariantKey = initialVariant } @@ -122,7 +122,7 @@ 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 ?? 'cascade' if (wantedVariant !== this._currentFadeVariantKey) { this._currentFadeVariantKey = wantedVariant this.fadeVariant = createFadeVariant(wantedVariant) 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/SweepVariant.js b/src/engine/effects/fadeVariants/SweepVariant.js new file mode 100644 index 0000000..7562e6b --- /dev/null +++ b/src/engine/effects/fadeVariants/SweepVariant.js @@ -0,0 +1,65 @@ +import { BaseFadeVariant } from './BaseFadeVariant.js' + +/** + * Sweep — a wave front travels left-to-right across the canvas. As the wave reaches each + * pixel's column, that pixel slides a short distance into its final position from the left. + * + * On fadeIn: wave moves left→right; pixels slide rightward into place as the wave passes. + * On fadeOut: wave moves right→left; pixels slide further rightward and disappear. + * + * The BAND constant controls how wide the active sweep zone is (relative to total progress 0–1). + * A narrower band creates a crisper edge; wider feels more gradual. + */ +class SweepVariant extends BaseFadeVariant { + // eslint-disable-next-line no-unused-vars + initVariantMetadata(particles, canvasWidth, canvasHeight, pixelSize, gridWidth, gridHeight) { + const maxX = canvasWidth - pixelSize || 1 + for (const p of particles) { + // Normalised X position: 0 = leftmost column, 1 = rightmost column. + p.sweepThreshold = p.finalX / maxX + } + } + + getVisiblePixels(particles, progress, phase, easeInOutCubic) { + // Width of the wave's active band in progress-space (0–1). + const BAND = 0.15 + // How far each pixel slides before settling at its final position (in canvas pixels). + const SLIDE = 32 + const result = [] + + for (const p of particles) { + let x, y, alpha + + if (phase === 'fadeIn') { + // Wave sweeps left→right. Pixel activates when the front reaches its column. + const startAt = p.sweepThreshold * (1 - BAND) + const localP = Math.max(0, Math.min(1, (progress - startAt) / BAND)) + if (localP <= 0) continue + const t = easeInOutCubic(localP) + // Slide from slightly left of final position into place. + x = Math.round(p.finalX - SLIDE + SLIDE * t) + y = p.finalY + alpha = Math.min(1, localP * 3) + } else if (phase === 'fadeOut') { + // Wave sweeps right→left. Pixels on the right exit first. + const startAt = (1 - p.sweepThreshold) * (1 - BAND) + const localP = Math.max(0, Math.min(1, (progress - startAt) / BAND)) + if (localP >= 1) continue + const t = easeInOutCubic(localP) + // Slide further right as the wave sweeps through. + x = Math.round(p.finalX + SLIDE * t) + y = p.finalY + alpha = Math.max(0, 1 - localP * 3) + } else { + continue + } + + if (alpha <= 0) continue + result.push({ x, y, brightness: p.brightness, color: p.color, alpha }) + } + + return result + } +} + +export { SweepVariant } diff --git a/src/engine/effects/fadeVariants/VortexVariant.js b/src/engine/effects/fadeVariants/VortexVariant.js new file mode 100644 index 0000000..373f199 --- /dev/null +++ b/src/engine/effects/fadeVariants/VortexVariant.js @@ -0,0 +1,74 @@ +import { BaseFadeVariant } from './BaseFadeVariant.js' + +/** + * Vortex — pixels travel along curved arcing paths from their scatter positions to their + * final positions, creating a swirling assembly effect. + * + * The arc is produced by adding a perpendicular offset to the straight travel path. + * The offset follows a bell-curve envelope (peaks at the midpoint of the journey and + * returns to zero at both ends), so pixels start and land exactly where they should. + * All arcs curve in the same rotational direction (clockwise), producing the vortex feel. + * + * On fadeIn: pixels spiral inward from scatter positions to the grid. + * On fadeOut: pixels spiral outward from the grid back to scatter positions. + */ +class VortexVariant extends BaseFadeVariant { + // eslint-disable-next-line no-unused-vars + initVariantMetadata(particles, canvasWidth, canvasHeight, pixelSize, gridWidth, gridHeight) { + for (const p of particles) { + // Reuse the distance-based stagger that BaseEffect already computed. + p.vortexDelay = p.delay + + const dx = p.finalX - p.startX + const dy = p.finalY - p.startY + const len = Math.sqrt(dx * dx + dy * dy) || 1 + + // Perpendicular unit vector — 90° clockwise rotation of the travel direction. + // (-dy, dx) rotates the vector (dx, dy) by +90° in canvas space. + p.perpX = -dy / len + p.perpY = dx / len + + // Arc amplitude: 30% of travel distance, capped to avoid excessive off-screen swing. + p.vortexAmp = Math.min(len * 0.3, 120) + } + } + + getVisiblePixels(particles, progress, phase, easeInOutCubic) { + const result = [] + + for (const p of particles) { + const remaining = 1 - p.vortexDelay + const localP = remaining > 0 ? Math.max(0, Math.min(1, (progress - p.vortexDelay) / remaining)) : 1 + + let x, y, alpha + + if (phase === 'fadeIn') { + if (localP <= 0) continue + const t = easeInOutCubic(localP) + // Bell-curve arc: zero at start and end, peaks at midpoint. + const arc = 4 * t * (1 - t) * p.vortexAmp + x = Math.round(p.startX + (p.finalX - p.startX) * t + p.perpX * arc) + y = Math.round(p.startY + (p.finalY - p.startY) * t + p.perpY * arc) + alpha = Math.min(1, localP * 4) + } else if (phase === 'fadeOut') { + if (localP >= 1) continue + const t = easeInOutCubic(localP) + // Reverse path: final→start, arc curves in same direction. + const arc = 4 * t * (1 - t) * p.vortexAmp + x = Math.round(p.finalX + (p.startX - p.finalX) * t + p.perpX * arc) + y = Math.round(p.finalY + (p.startY - p.finalY) * t + p.perpY * arc) + // Stay 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 { VortexVariant } diff --git a/src/engine/effects/fadeVariants/index.js b/src/engine/effects/fadeVariants/index.js index da90df5..57ee832 100644 --- a/src/engine/effects/fadeVariants/index.js +++ b/src/engine/effects/fadeVariants/index.js @@ -1,28 +1,36 @@ -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 { SweepVariant } from './SweepVariant.js' +import { VortexVariant } from './VortexVariant.js' const FADE_VARIANT_LABELS = { - bloom: 'Bloom', cascade: 'Cascade', static: 'Static', - glitch: 'Glitch' + glitch: 'Glitch', + drift: 'Drift', + sweep: 'Sweep', + vortex: 'Vortex' } 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 'sweep': + return new SweepVariant(options) + case 'vortex': + return new VortexVariant(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..e01eb10 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(42) }) }) @@ -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(42) }) }) @@ -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(42) }) }) From c888edb35f8dc8e4f28875d95816ed4f3de48017 Mon Sep 17 00:00:00 2001 From: santiagocetran Date: Tue, 14 Apr 2026 18:20:09 -0300 Subject: [PATCH 2/3] feat: fix animation phase alignment and rework fade variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animation loop fixes: - Default fade variant changed from 'cascade' to 'glitch' - StaticVariant fadeOut formula was inverted (object appeared instead of disappearing); fixed to complement of fadeIn formula - AnimationEngine: show phase now rounds up to nearest full spin boundary (360°/720°/…) so fadeOut never fires mid-rotation - AnimationEngine: spin rotation resets to 0 at every cycle boundary so each fadeIn always assembles the front-facing model pose - BitmapEffect: on variant change, reset rotation and re-render the 3D scene before sampling particle positions, fixing mid-spin pose capture Fade variant changes: - Remove Vortex (too similar to Drift) - Replace Sweep with Scatter: pixels start at random canvas positions and converge to the grid with a random per-pixel stagger — chaotic assembly effect distinct from Drift's off-canvas fly-in Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 58 +++++++-------- CLAUDE.md | 71 ++++++++++-------- public/embed/bitmap-forge.es.js | 6 -- src/app/store/slices/animationSlice.js | 2 +- src/app/utils/codeExport.test.js | 2 +- src/app/utils/engineSources.js | 6 +- src/app/utils/engineSources.test.js | 6 +- src/app/utils/reactComponentExport.test.js | 2 +- src/app/utils/webComponentExport.test.js | 2 +- src/engine/SceneManager.js | 6 ++ src/engine/animation/AnimationEngine.js | 60 +++++++++++++-- src/engine/effects/BitmapEffect.js | 31 +++++++- .../effects/fadeVariants/ScatterVariant.js | 69 +++++++++++++++++ .../effects/fadeVariants/StaticVariant.js | 5 +- .../effects/fadeVariants/SweepVariant.js | 65 ---------------- .../effects/fadeVariants/VortexVariant.js | 74 ------------------- src/engine/effects/fadeVariants/index.js | 12 +-- test/integration/exportConformance.test.js | 6 +- 18 files changed, 242 insertions(+), 241 deletions(-) create mode 100644 src/engine/effects/fadeVariants/ScatterVariant.js delete mode 100644 src/engine/effects/fadeVariants/SweepVariant.js delete mode 100644 src/engine/effects/fadeVariants/VortexVariant.js diff --git a/AGENTS.md b/AGENTS.md index ce9272c..fadcae8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,10 +30,9 @@ Read on session start, in order: - Time: UTC, `YYYY-MM-DD HH:MM UTC`. IDs: `TASK-`, `DEC-`, `HND-`, `CNF-`. - # 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** (1456 symbols, 4146 relationships, 117 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. @@ -67,36 +66,35 @@ This project is indexed by GitNexus as **BitmapForge** (1798 symbols, 4337 relat ## Tools Quick Reference -| Tool | When to use | Command | -| ---------------- | ----------------------------- | ----------------------------------------------------------------------- | -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | ## Impact Risk Levels -| Depth | Meaning | Action | -| ----- | ------------------------------------- | --------------------- | -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | ## Resources -| Resource | Use for | -| -------------------------------------------- | ---------------------------------------- | -| `gitnexus://repo/BitmapForge/context` | Codebase overview, check index freshness | -| `gitnexus://repo/BitmapForge/clusters` | All functional areas | -| `gitnexus://repo/BitmapForge/processes` | All execution flows | -| `gitnexus://repo/BitmapForge/process/{name}` | Step-by-step execution trace | +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/BitmapForge/context` | Codebase overview, check index freshness | +| `gitnexus://repo/BitmapForge/clusters` | All functional areas | +| `gitnexus://repo/BitmapForge/processes` | All execution flows | +| `gitnexus://repo/BitmapForge/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing Before completing any code modification task, verify: - 1. `gitnexus_impact` was run for all modified symbols 2. No HIGH/CRITICAL risk warnings were ignored 3. `gitnexus_detect_changes()` confirms changes match expected scope @@ -122,13 +120,13 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. ## CLI -| Task | Read this skill file | -| -------------------------------------------- | ----------------------------------------------------------- | -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | diff --git a/CLAUDE.md b/CLAUDE.md index 28fd1bc..36e0feb 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 @@ -55,10 +64,9 @@ React 19, Vite 7, Tailwind CSS 4, Zustand (state), Three.js (3D), Radix UI (prim - The engine and app layers are intentionally separated so the engine could be published as a standalone npm package. - # 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** (1456 symbols, 4146 relationships, 117 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. @@ -92,36 +100,35 @@ This project is indexed by GitNexus as **BitmapForge** (1798 symbols, 4337 relat ## Tools Quick Reference -| Tool | When to use | Command | -| ---------------- | ----------------------------- | ----------------------------------------------------------------------- | -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | ## Impact Risk Levels -| Depth | Meaning | Action | -| ----- | ------------------------------------- | --------------------- | -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | ## Resources -| Resource | Use for | -| -------------------------------------------- | ---------------------------------------- | -| `gitnexus://repo/BitmapForge/context` | Codebase overview, check index freshness | -| `gitnexus://repo/BitmapForge/clusters` | All functional areas | -| `gitnexus://repo/BitmapForge/processes` | All execution flows | -| `gitnexus://repo/BitmapForge/process/{name}` | Step-by-step execution trace | +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/BitmapForge/context` | Codebase overview, check index freshness | +| `gitnexus://repo/BitmapForge/clusters` | All functional areas | +| `gitnexus://repo/BitmapForge/processes` | All execution flows | +| `gitnexus://repo/BitmapForge/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing Before completing any code modification task, verify: - 1. `gitnexus_impact` was run for all modified symbols 2. No HIGH/CRITICAL risk warnings were ignored 3. `gitnexus_detect_changes()` confirms changes match expected scope @@ -147,13 +154,13 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. ## CLI -| Task | Read this skill file | -| -------------------------------------------- | ----------------------------------------------------------- | -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | 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/store/slices/animationSlice.js b/src/app/store/slices/animationSlice.js index 2d9713b..d0ccafb 100644 --- a/src/app/store/slices/animationSlice.js +++ b/src/app/store/slices/animationSlice.js @@ -2,7 +2,7 @@ import { DEFAULT_ANIMATION_EFFECTS } from '../../../engine/animation/effectTypes export const ANIMATION_DEFAULTS = { useFadeInOut: true, - fadeVariant: 'cascade', + fadeVariant: 'glitch', fadeMode: 'both', animationEffects: { ...DEFAULT_ANIMATION_EFFECTS }, animationSpeed: 1.0, diff --git a/src/app/utils/codeExport.test.js b/src/app/utils/codeExport.test.js index 25b788c..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(42) + 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 f83b9cc..c9aa88a 100644 --- a/src/app/utils/engineSources.js +++ b/src/app/utils/engineSources.js @@ -10,8 +10,7 @@ import CascadeVariantSrc from '../../engine/effects/fadeVariants/CascadeVariant. 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 SweepVariantSrc from '../../engine/effects/fadeVariants/SweepVariant.js?raw' -import VortexVariantSrc from '../../engine/effects/fadeVariants/VortexVariant.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' @@ -63,8 +62,7 @@ const ENGINE_SOURCES = [ { 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/SweepVariant.js', content: SweepVariantSrc }, - { path: 'engine/effects/fadeVariants/VortexVariant.js', content: VortexVariantSrc }, + { 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 a6d2efe..fd6c7e5 100644 --- a/src/app/utils/engineSources.test.js +++ b/src/app/utils/engineSources.test.js @@ -13,8 +13,7 @@ const REQUIRED_PATHS = [ 'engine/effects/fadeVariants/StaticVariant.js', 'engine/effects/fadeVariants/GlitchVariant.js', 'engine/effects/fadeVariants/DriftVariant.js', - 'engine/effects/fadeVariants/SweepVariant.js', - 'engine/effects/fadeVariants/VortexVariant.js', + 'engine/effects/fadeVariants/ScatterVariant.js', 'engine/effects/fadeVariants/index.js', 'engine/renderers/BaseRenderer.js', 'engine/renderers/BitmapRenderer.js', @@ -105,8 +104,7 @@ describe('ENGINE_SOURCES', () => { 'engine/effects/fadeVariants/StaticVariant.js', 'engine/effects/fadeVariants/GlitchVariant.js', 'engine/effects/fadeVariants/DriftVariant.js', - 'engine/effects/fadeVariants/SweepVariant.js', - 'engine/effects/fadeVariants/VortexVariant.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/reactComponentExport.test.js b/src/app/utils/reactComponentExport.test.js index 34a8ec9..4f1a7dc 100644 --- a/src/app/utils/reactComponentExport.test.js +++ b/src/app/utils/reactComponentExport.test.js @@ -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(42) + 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 02aace2..0281c89 100644 --- a/src/app/utils/webComponentExport.test.js +++ b/src/app/utils/webComponentExport.test.js @@ -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(42) + 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 216b286..57f1307 100644 --- a/src/engine/animation/AnimationEngine.js +++ b/src/engine/animation/AnimationEngine.js @@ -44,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. @@ -100,6 +105,34 @@ 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') { @@ -221,14 +254,18 @@ class AnimationEngine { // 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.showPhaseDuration) { + if (now - this.phaseStartTime >= this._effectiveShowDuration) { + // Reset spin to front so the next fadeIn always assembles facing forward. + this._resetSpinRotations(modelGroup) effect.startAnimation('fadeIn') } } @@ -236,25 +273,31 @@ class AnimationEngine { // 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.showPhaseDuration) { + 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' — original behaviour: fadeIn → show → fadeOut → … + // '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 @@ -264,7 +307,7 @@ class AnimationEngine { } else if (currentPhase === 'show') { this.applyEffects(modelGroup, deltaSeconds, camera) this._applyResetTransitions(modelGroup, deltaSeconds) - if (now - this.phaseStartTime >= this.showPhaseDuration) { + if (now - this.phaseStartTime >= this._effectiveShowDuration) { effect.startAnimation('fadeOut') } } else if (currentPhase === 'fadeOut') { @@ -273,6 +316,8 @@ class AnimationEngine { 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') } } @@ -283,10 +328,11 @@ class AnimationEngine { if (!this.useFadeInOut) { return Math.round(((2 * Math.PI) / this.speed) * 1000) } + const effectiveShow = this._computeSpinAlignedShowDuration() if (this.fadeMode === 'in' || this.fadeMode === 'out') { - return this.animationDuration + this.showPhaseDuration + return this.animationDuration + effectiveShow } - return this.animationDuration * 2 + this.showPhaseDuration + return this.animationDuration * 2 + effectiveShow } resetToStart() { @@ -371,7 +417,7 @@ class AnimationEngine { if (effect) { if (this.useFadeInOut) { const dur = this.animationDuration - const show = this.showPhaseDuration + const show = this._computeSpinAlignedShowDuration() const t = absoluteTimeMs if (this.fadeMode === 'in') { // Loop: [fadeIn(dur), show(showDur)] diff --git a/src/engine/effects/BitmapEffect.js b/src/engine/effects/BitmapEffect.js index e385eda..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 ?? 'cascade' + 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 ?? 'cascade' + 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/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/SweepVariant.js b/src/engine/effects/fadeVariants/SweepVariant.js deleted file mode 100644 index 7562e6b..0000000 --- a/src/engine/effects/fadeVariants/SweepVariant.js +++ /dev/null @@ -1,65 +0,0 @@ -import { BaseFadeVariant } from './BaseFadeVariant.js' - -/** - * Sweep — a wave front travels left-to-right across the canvas. As the wave reaches each - * pixel's column, that pixel slides a short distance into its final position from the left. - * - * On fadeIn: wave moves left→right; pixels slide rightward into place as the wave passes. - * On fadeOut: wave moves right→left; pixels slide further rightward and disappear. - * - * The BAND constant controls how wide the active sweep zone is (relative to total progress 0–1). - * A narrower band creates a crisper edge; wider feels more gradual. - */ -class SweepVariant extends BaseFadeVariant { - // eslint-disable-next-line no-unused-vars - initVariantMetadata(particles, canvasWidth, canvasHeight, pixelSize, gridWidth, gridHeight) { - const maxX = canvasWidth - pixelSize || 1 - for (const p of particles) { - // Normalised X position: 0 = leftmost column, 1 = rightmost column. - p.sweepThreshold = p.finalX / maxX - } - } - - getVisiblePixels(particles, progress, phase, easeInOutCubic) { - // Width of the wave's active band in progress-space (0–1). - const BAND = 0.15 - // How far each pixel slides before settling at its final position (in canvas pixels). - const SLIDE = 32 - const result = [] - - for (const p of particles) { - let x, y, alpha - - if (phase === 'fadeIn') { - // Wave sweeps left→right. Pixel activates when the front reaches its column. - const startAt = p.sweepThreshold * (1 - BAND) - const localP = Math.max(0, Math.min(1, (progress - startAt) / BAND)) - if (localP <= 0) continue - const t = easeInOutCubic(localP) - // Slide from slightly left of final position into place. - x = Math.round(p.finalX - SLIDE + SLIDE * t) - y = p.finalY - alpha = Math.min(1, localP * 3) - } else if (phase === 'fadeOut') { - // Wave sweeps right→left. Pixels on the right exit first. - const startAt = (1 - p.sweepThreshold) * (1 - BAND) - const localP = Math.max(0, Math.min(1, (progress - startAt) / BAND)) - if (localP >= 1) continue - const t = easeInOutCubic(localP) - // Slide further right as the wave sweeps through. - x = Math.round(p.finalX + SLIDE * t) - y = p.finalY - alpha = Math.max(0, 1 - localP * 3) - } else { - continue - } - - if (alpha <= 0) continue - result.push({ x, y, brightness: p.brightness, color: p.color, alpha }) - } - - return result - } -} - -export { SweepVariant } diff --git a/src/engine/effects/fadeVariants/VortexVariant.js b/src/engine/effects/fadeVariants/VortexVariant.js deleted file mode 100644 index 373f199..0000000 --- a/src/engine/effects/fadeVariants/VortexVariant.js +++ /dev/null @@ -1,74 +0,0 @@ -import { BaseFadeVariant } from './BaseFadeVariant.js' - -/** - * Vortex — pixels travel along curved arcing paths from their scatter positions to their - * final positions, creating a swirling assembly effect. - * - * The arc is produced by adding a perpendicular offset to the straight travel path. - * The offset follows a bell-curve envelope (peaks at the midpoint of the journey and - * returns to zero at both ends), so pixels start and land exactly where they should. - * All arcs curve in the same rotational direction (clockwise), producing the vortex feel. - * - * On fadeIn: pixels spiral inward from scatter positions to the grid. - * On fadeOut: pixels spiral outward from the grid back to scatter positions. - */ -class VortexVariant extends BaseFadeVariant { - // eslint-disable-next-line no-unused-vars - initVariantMetadata(particles, canvasWidth, canvasHeight, pixelSize, gridWidth, gridHeight) { - for (const p of particles) { - // Reuse the distance-based stagger that BaseEffect already computed. - p.vortexDelay = p.delay - - const dx = p.finalX - p.startX - const dy = p.finalY - p.startY - const len = Math.sqrt(dx * dx + dy * dy) || 1 - - // Perpendicular unit vector — 90° clockwise rotation of the travel direction. - // (-dy, dx) rotates the vector (dx, dy) by +90° in canvas space. - p.perpX = -dy / len - p.perpY = dx / len - - // Arc amplitude: 30% of travel distance, capped to avoid excessive off-screen swing. - p.vortexAmp = Math.min(len * 0.3, 120) - } - } - - getVisiblePixels(particles, progress, phase, easeInOutCubic) { - const result = [] - - for (const p of particles) { - const remaining = 1 - p.vortexDelay - const localP = remaining > 0 ? Math.max(0, Math.min(1, (progress - p.vortexDelay) / remaining)) : 1 - - let x, y, alpha - - if (phase === 'fadeIn') { - if (localP <= 0) continue - const t = easeInOutCubic(localP) - // Bell-curve arc: zero at start and end, peaks at midpoint. - const arc = 4 * t * (1 - t) * p.vortexAmp - x = Math.round(p.startX + (p.finalX - p.startX) * t + p.perpX * arc) - y = Math.round(p.startY + (p.finalY - p.startY) * t + p.perpY * arc) - alpha = Math.min(1, localP * 4) - } else if (phase === 'fadeOut') { - if (localP >= 1) continue - const t = easeInOutCubic(localP) - // Reverse path: final→start, arc curves in same direction. - const arc = 4 * t * (1 - t) * p.vortexAmp - x = Math.round(p.finalX + (p.startX - p.finalX) * t + p.perpX * arc) - y = Math.round(p.finalY + (p.startY - p.finalY) * t + p.perpY * arc) - // Stay 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 { VortexVariant } diff --git a/src/engine/effects/fadeVariants/index.js b/src/engine/effects/fadeVariants/index.js index 57ee832..d8d6a6f 100644 --- a/src/engine/effects/fadeVariants/index.js +++ b/src/engine/effects/fadeVariants/index.js @@ -2,16 +2,14 @@ import { CascadeVariant } from './CascadeVariant.js' import { StaticVariant } from './StaticVariant.js' import { GlitchVariant } from './GlitchVariant.js' import { DriftVariant } from './DriftVariant.js' -import { SweepVariant } from './SweepVariant.js' -import { VortexVariant } from './VortexVariant.js' +import { ScatterVariant } from './ScatterVariant.js' const FADE_VARIANT_LABELS = { cascade: 'Cascade', static: 'Static', glitch: 'Glitch', drift: 'Drift', - sweep: 'Sweep', - vortex: 'Vortex' + scatter: 'Scatter' } const FADE_VARIANT_KEYS = Object.keys(FADE_VARIANT_LABELS) @@ -24,10 +22,8 @@ function createFadeVariant(name, options = {}) { return new GlitchVariant(options) case 'drift': return new DriftVariant(options) - case 'sweep': - return new SweepVariant(options) - case 'vortex': - return new VortexVariant(options) + case 'scatter': + return new ScatterVariant(options) case 'cascade': default: return new CascadeVariant(options) diff --git a/test/integration/exportConformance.test.js b/test/integration/exportConformance.test.js index e01eb10..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(42) + 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(42) + 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(42) + expect(engineFiles).toHaveLength(41) }) }) From 4fa9e0d3c197f071060a90c2100408cab59f3467 Mon Sep 17 00:00:00 2001 From: santiagocetran Date: Tue, 14 Apr 2026 18:27:46 -0300 Subject: [PATCH 3/3] chore: fix Prettier formatting on AnimationEngine and docs Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 58 +++++++++++++------------ CLAUDE.md | 58 +++++++++++++------------ src/engine/animation/AnimationEngine.js | 2 +- 3 files changed, 61 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fadcae8..81fa5a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,9 +30,10 @@ Read on session start, in order: - Time: UTC, `YYYY-MM-DD HH:MM UTC`. IDs: `TASK-`, `DEC-`, `HND-`, `CNF-`. + # GitNexus — Code Intelligence -This project is indexed by GitNexus as **BitmapForge** (1456 symbols, 4146 relationships, 117 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. @@ -66,35 +67,36 @@ This project is indexed by GitNexus as **BitmapForge** (1456 symbols, 4146 relat ## Tools Quick Reference -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| Tool | When to use | Command | +| ---------------- | ----------------------------- | ----------------------------------------------------------------------- | +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | ## Impact Risk Levels -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | +| Depth | Meaning | Action | +| ----- | ------------------------------------- | --------------------- | +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | ## Resources -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/BitmapForge/context` | Codebase overview, check index freshness | -| `gitnexus://repo/BitmapForge/clusters` | All functional areas | -| `gitnexus://repo/BitmapForge/processes` | All execution flows | -| `gitnexus://repo/BitmapForge/process/{name}` | Step-by-step execution trace | +| Resource | Use for | +| -------------------------------------------- | ---------------------------------------- | +| `gitnexus://repo/BitmapForge/context` | Codebase overview, check index freshness | +| `gitnexus://repo/BitmapForge/clusters` | All functional areas | +| `gitnexus://repo/BitmapForge/processes` | All execution flows | +| `gitnexus://repo/BitmapForge/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing Before completing any code modification task, verify: + 1. `gitnexus_impact` was run for all modified symbols 2. No HIGH/CRITICAL risk warnings were ignored 3. `gitnexus_detect_changes()` confirms changes match expected scope @@ -120,13 +122,13 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. ## CLI -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Read this skill file | +| -------------------------------------------- | ----------------------------------------------------------- | +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | diff --git a/CLAUDE.md b/CLAUDE.md index 36e0feb..478d94f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,9 +64,10 @@ React 19, Vite 7, Tailwind CSS 4, Zustand (state), Three.js (3D), Radix UI (prim - The engine and app layers are intentionally separated so the engine could be published as a standalone npm package. + # GitNexus — Code Intelligence -This project is indexed by GitNexus as **BitmapForge** (1456 symbols, 4146 relationships, 117 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. @@ -100,35 +101,36 @@ This project is indexed by GitNexus as **BitmapForge** (1456 symbols, 4146 relat ## Tools Quick Reference -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| Tool | When to use | Command | +| ---------------- | ----------------------------- | ----------------------------------------------------------------------- | +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | ## Impact Risk Levels -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | +| Depth | Meaning | Action | +| ----- | ------------------------------------- | --------------------- | +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | ## Resources -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/BitmapForge/context` | Codebase overview, check index freshness | -| `gitnexus://repo/BitmapForge/clusters` | All functional areas | -| `gitnexus://repo/BitmapForge/processes` | All execution flows | -| `gitnexus://repo/BitmapForge/process/{name}` | Step-by-step execution trace | +| Resource | Use for | +| -------------------------------------------- | ---------------------------------------- | +| `gitnexus://repo/BitmapForge/context` | Codebase overview, check index freshness | +| `gitnexus://repo/BitmapForge/clusters` | All functional areas | +| `gitnexus://repo/BitmapForge/processes` | All execution flows | +| `gitnexus://repo/BitmapForge/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing Before completing any code modification task, verify: + 1. `gitnexus_impact` was run for all modified symbols 2. No HIGH/CRITICAL risk warnings were ignored 3. `gitnexus_detect_changes()` confirms changes match expected scope @@ -154,13 +156,13 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. ## CLI -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Read this skill file | +| -------------------------------------------- | ----------------------------------------------------------- | +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | diff --git a/src/engine/animation/AnimationEngine.js b/src/engine/animation/AnimationEngine.js index 57f1307..4bcb668 100644 --- a/src/engine/animation/AnimationEngine.js +++ b/src/engine/animation/AnimationEngine.js @@ -111,7 +111,7 @@ class AnimationEngine { _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 spinPeriodMs = ((2 * Math.PI) / this.speed) * 1000 const minSpins = Math.max(1, Math.ceil(this.showPhaseDuration / spinPeriodMs)) return minSpins * spinPeriodMs }