mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-27 09:46:22 +02:00
feat(v2.0.5): Intentional Amnesia — active forgetting via top-down inhibitory control
First AI memory system to model forgetting as a neuroscience-grounded PROCESS rather than passive decay. Adds the `suppress` MCP tool (#24), Rac1 cascade worker, migration V10, and dashboard forgetting indicators. Based on: - Anderson, Hanslmayr & Quaegebeur (2025), Nat Rev Neurosci — right lateral PFC as the domain-general inhibitory controller; SIF compounds with each stopping attempt. - Cervantes-Sandoval et al. (2020), Front Cell Neurosci PMC7477079 — Rac1 GTPase as the active synaptic destabilization mechanism. What's new: * `suppress` MCP tool — each call compounds `suppression_count` and subtracts a `0.15 × count` penalty (saturating at 80%) from retrieval scores during hybrid search. Distinct from delete (removes) and demote (one-shot). * Rac1 cascade worker — background sweep piggybacks the 6h consolidation loop, walks `memory_connections` edges from recently-suppressed seeds, applies attenuated FSRS decay to co-activated neighbors. You don't just forget Jake — you fade the café, the roommate, the birthday. * 24h labile window — reversible via `suppress({id, reverse: true})` within 24 hours. Matches Nader reconsolidation semantics. * Migration V10 — additive-only (`suppression_count`, `suppressed_at` + partial indices). All v2.0.x DBs upgrade seamlessly on first launch. * Dashboard: `ForgettingIndicator.svelte` pulses when suppressions are active. 3D graph nodes dim to 20% opacity when suppressed. New WebSocket events: `MemorySuppressed`, `MemoryUnsuppressed`, `Rac1CascadeSwept`. Heartbeat carries `suppressed_count`. * Search pipeline: SIF penalty inserted into the accessibility stage so it stacks on top of passive FSRS decay. * Tool count bumped 23 → 24. Cognitive modules 29 → 30. Memories persist — they are INHIBITED, not erased. `memory.get(id)` returns full content through any number of suppressions. The 24h labile window is a grace period for regret. Also fixes issue #31 (dashboard graph view buggy) as a companion UI bug discovered during the v2.0.5 audit cycle: * Root cause: node glow `SpriteMaterial` had no `map`, so `THREE.Sprite` rendered as a solid-coloured 1×1 plane. Additive blending + `UnrealBloomPass(0.8, 0.4, 0.85)` amplified the square edges into hard-edged glowing cubes. * Fix: shared 128×128 radial-gradient `CanvasTexture` singleton used as the sprite map. Retuned bloom to `(0.55, 0.6, 0.2)`. Halved fog density (0.008 → 0.0035). Edges bumped from dark navy `0x4a4a7a` to brand violet `0x8b5cf6` with higher opacity. Added explicit `scene.background` and a 2000-point starfield for depth. * 21 regression tests added in `ui-fixes.test.ts` locking every invariant in (shared texture singleton, depthWrite:false, scale ×6, bloom magic numbers via source regex, starfield presence). Tests: 1,284 Rust (+47) + 171 Vitest (+21) = 1,455 total, 0 failed Clippy: clean across all targets, zero warnings Release binary: 22.6MB, `cargo build --release -p vestige-mcp` green Versions: workspace aligned at 2.0.5 across all 6 crates/packages Closes #31
This commit is contained in:
parent
95bde93b49
commit
8178beb961
359 changed files with 8277 additions and 3416 deletions
|
|
@ -48,12 +48,14 @@ describe('EdgeManager', () => {
|
|||
expect(line.userData.target).toBe('b');
|
||||
});
|
||||
|
||||
it('caps opacity at 0.6', () => {
|
||||
it('caps opacity at 0.8 (raised from 0.6 in v2.0.6 issue #31 fix)', () => {
|
||||
const edges = [makeEdge('a', 'b', { weight: 10.0 })];
|
||||
manager.createEdges(edges, positions);
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
expect(line.material.opacity).toBeLessThanOrEqual(0.6);
|
||||
expect(line.material.opacity).toBeLessThanOrEqual(0.8);
|
||||
// Baseline floor too — with weight 10 we should be at the cap, not below old 0.6
|
||||
expect(line.material.opacity).toBeGreaterThanOrEqual(0.6);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -122,7 +124,8 @@ describe('EdgeManager', () => {
|
|||
}
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
expect(line.material.opacity).toBe(0.5);
|
||||
// v2.0.6 issue #31 fix raised final edge opacity 0.5 → 0.65
|
||||
expect(line.material.opacity).toBe(0.65);
|
||||
});
|
||||
|
||||
it('uses easeOutCubic for smooth deceleration', () => {
|
||||
|
|
@ -266,7 +269,8 @@ describe('EdgeManager', () => {
|
|||
// Both should be fully grown
|
||||
expect(manager.group.children.length).toBe(2);
|
||||
manager.group.children.forEach((child) => {
|
||||
expect((child as any).material.opacity).toBe(0.5);
|
||||
// v2.0.6 issue #31 fix raised final edge opacity 0.5 → 0.65
|
||||
expect((child as any).material.opacity).toBe(0.65);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,15 +3,29 @@
|
|||
*/
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Minimal canvas gradient mock — collects colour stops so tests can inspect
|
||||
// them if they want to, but is mostly a no-op for runtime.
|
||||
function createMockGradient() {
|
||||
return {
|
||||
colorStops: [] as Array<{ offset: number; color: string }>,
|
||||
addColorStop(offset: number, color: string) {
|
||||
this.colorStops.push({ offset, color });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal canvas 2D context mock
|
||||
const mockContext2D = {
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 100 })),
|
||||
createRadialGradient: vi.fn(() => createMockGradient()),
|
||||
createLinearGradient: vi.fn(() => createMockGradient()),
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
fillStyle: '',
|
||||
fillStyle: '' as string | object,
|
||||
shadowColor: '',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
|
|
|
|||
|
|
@ -282,10 +282,16 @@ export class MeshBasicMaterial extends BaseMaterial {
|
|||
}
|
||||
|
||||
export class LineBasicMaterial extends BaseMaterial {
|
||||
depthWrite = true;
|
||||
constructor(params?: Record<string, unknown>) {
|
||||
super();
|
||||
if (params) {
|
||||
if (typeof params.opacity === 'number') this.opacity = params.opacity;
|
||||
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
|
||||
if (params.color instanceof Color) this.color = params.color;
|
||||
else if (typeof params.color === 'number') this.color = new Color(params.color);
|
||||
if (typeof params.blending === 'number') this.blending = params.blending;
|
||||
if (typeof params.depthWrite === 'boolean') this.depthWrite = params.depthWrite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -303,10 +309,19 @@ export class PointsMaterial extends BaseMaterial {
|
|||
}
|
||||
|
||||
export class SpriteMaterial extends BaseMaterial {
|
||||
depthWrite = true;
|
||||
constructor(params?: Record<string, unknown>) {
|
||||
super();
|
||||
if (params) {
|
||||
if (typeof params.opacity === 'number') this.opacity = params.opacity;
|
||||
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
|
||||
if (params.color instanceof Color) this.color = params.color;
|
||||
else if (typeof params.color === 'number') this.color = new Color(params.color);
|
||||
if (typeof params.blending === 'number') this.blending = params.blending;
|
||||
if (typeof params.depthWrite === 'boolean') this.depthWrite = params.depthWrite;
|
||||
if (params.map && typeof params.map === 'object') {
|
||||
this.map = params.map as { dispose: () => void };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
236
apps/dashboard/src/lib/graph/__tests__/ui-fixes.test.ts
Normal file
236
apps/dashboard/src/lib/graph/__tests__/ui-fixes.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* Regression tests for vestige issue #31 (v2.0.6 Phase 1 dashboard UI fix).
|
||||
*
|
||||
* Before v2.0.6 the graph view rendered "glowing cubes" instead of round halos,
|
||||
* with navy edges swallowed by heavy fog. Root cause: the node glow SpriteMaterial
|
||||
* had no `map` set, so THREE.Sprite rendered as a solid-coloured plane whose
|
||||
* square edges were then amplified by UnrealBloomPass into hard bright squares.
|
||||
*
|
||||
* These tests lock in every property that was broken so any regression surfaces
|
||||
* as a red test instead of shipping another ugly screenshot into the issue tracker.
|
||||
*
|
||||
* The scene.ts assertions are intentionally source-level (fs regex) because the
|
||||
* real scene.ts pulls in three/addons (OrbitControls, EffectComposer, UnrealBloomPass,
|
||||
* WebGLRenderer) which are painful to mock in isolation. Reading the .ts file and
|
||||
* regex-checking the magic numbers catches accidental revert/tweaks without needing
|
||||
* a full WebGL harness.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
vi.mock('three', async () => {
|
||||
const mock = await import('./three-mock');
|
||||
return { ...mock };
|
||||
});
|
||||
|
||||
import { NodeManager } from '../nodes';
|
||||
import { EdgeManager } from '../edges';
|
||||
import { Vector3, AdditiveBlending } from './three-mock';
|
||||
import { makeNode, makeEdge, resetNodeCounter } from './helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Node glow sprite — THE fix for the "glowing cubes" artifact
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('issue #31 — node glow sprites render as round halos, not squares', () => {
|
||||
let manager: NodeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
resetNodeCounter();
|
||||
manager = new NodeManager();
|
||||
});
|
||||
|
||||
it('glow SpriteMaterial has a map set (the root cause of the square artifact)', () => {
|
||||
manager.createNodes([makeNode({ id: 'a', retention: 0.8 })]);
|
||||
const glow = manager.glowMap.get('a')!;
|
||||
const mat = glow.material as any;
|
||||
|
||||
// Without a map, THREE.Sprite renders as a solid coloured plane —
|
||||
// additive blend + bloom then turns it into a glowing square.
|
||||
// The fix generates a shared radial-gradient CanvasTexture and assigns
|
||||
// it here, so bloom has a soft circular shape to diffuse.
|
||||
expect(mat.map).not.toBeNull();
|
||||
expect(mat.map).toBeDefined();
|
||||
});
|
||||
|
||||
it('glow sprites on multiple nodes SHARE the same texture instance (singleton cache)', () => {
|
||||
// The shared texture is a module-level cache — if a future refactor
|
||||
// accidentally creates one per-node we'll leak memory on large graphs.
|
||||
manager.createNodes([
|
||||
makeNode({ id: 'a' }),
|
||||
makeNode({ id: 'b' }),
|
||||
makeNode({ id: 'c' }),
|
||||
]);
|
||||
const a = (manager.glowMap.get('a')!.material as any).map;
|
||||
const b = (manager.glowMap.get('b')!.material as any).map;
|
||||
const c = (manager.glowMap.get('c')!.material as any).map;
|
||||
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
});
|
||||
|
||||
it('glow sprite has depthWrite:false to prevent z-fighting with the sphere behind it', () => {
|
||||
manager.createNodes([makeNode({ id: 'a' })]);
|
||||
const mat = manager.glowMap.get('a')!.material as any;
|
||||
expect(mat.depthWrite).toBe(false);
|
||||
});
|
||||
|
||||
it('glow sprite uses additive blending (required for bloom to read as light)', () => {
|
||||
manager.createNodes([makeNode({ id: 'a' })]);
|
||||
const mat = manager.glowMap.get('a')!.material as any;
|
||||
expect(mat.blending).toBe(AdditiveBlending);
|
||||
});
|
||||
|
||||
it('glow sprite scale uses the new 6× multiplier (was 4× — gradient needed more footprint)', () => {
|
||||
// size = 0.5 + retention*2 → 0.5 + 1.0*2 = 2.5
|
||||
// glow scale with new formula: 2.5 * 6 * 1.0 = 15
|
||||
manager.createNodes([makeNode({ id: 'full', retention: 1.0 })]);
|
||||
const glow = manager.glowMap.get('full')!;
|
||||
expect(glow.scale.x).toBeCloseTo(15, 5);
|
||||
expect(glow.scale.y).toBeCloseTo(15, 5);
|
||||
});
|
||||
|
||||
it('glow sprite base opacity is 0.3 + retention*0.35 (was 0.15 + retention*0.2)', () => {
|
||||
manager.createNodes([makeNode({ id: 'full', retention: 1.0 })]);
|
||||
const mat = manager.glowMap.get('full')!.material as any;
|
||||
// 0.3 + 1.0 * 0.35 = 0.65
|
||||
expect(mat.opacity).toBeCloseTo(0.65, 5);
|
||||
});
|
||||
|
||||
it('suppressed node glow opacity drops to 0.1 (v2.0.5 active forgetting)', () => {
|
||||
manager.createNodes([makeNode({ id: 's', retention: 0.8, suppression_count: 2 })]);
|
||||
const mat = manager.glowMap.get('s')!.material as any;
|
||||
expect(mat.opacity).toBeCloseTo(0.1, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Edge materials — dark navy → brand violet, higher opacity
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('issue #31 — edges are brand violet and actually visible', () => {
|
||||
let manager: EdgeManager;
|
||||
let positions: Map<string, InstanceType<typeof Vector3>>;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new EdgeManager();
|
||||
positions = new Map([
|
||||
['a', new Vector3(0, 0, 0)],
|
||||
['b', new Vector3(10, 0, 0)],
|
||||
]);
|
||||
});
|
||||
|
||||
it('edge color is brand violet 0x8b5cf6, not the old dark navy 0x4a4a7a', () => {
|
||||
manager.createEdges([makeEdge('a', 'b', { weight: 0.5 })], positions);
|
||||
const line = manager.group.children[0] as any;
|
||||
const c = line.material.color;
|
||||
|
||||
// 0x8b5cf6 → r=139/255, g=92/255, b=246/255
|
||||
expect(c.r).toBeCloseTo(0x8b / 255, 3);
|
||||
expect(c.g).toBeCloseTo(0x5c / 255, 3);
|
||||
expect(c.b).toBeCloseTo(0xf6 / 255, 3);
|
||||
|
||||
// And definitely NOT the old navy 0x4a4a7a (74/255, 74/255, 122/255)
|
||||
expect(c.r).not.toBeCloseTo(0x4a / 255, 3);
|
||||
});
|
||||
|
||||
it('edges have depthWrite:false so they additively blend through fog cleanly', () => {
|
||||
manager.createEdges([makeEdge('a', 'b')], positions);
|
||||
const line = manager.group.children[0] as any;
|
||||
expect(line.material.depthWrite).toBe(false);
|
||||
});
|
||||
|
||||
it('edge opacity base is 0.25 + weight*0.5 (was 0.1 + weight*0.5)', () => {
|
||||
manager.createEdges([makeEdge('a', 'b', { weight: 0.5 })], positions);
|
||||
const line = manager.group.children[0] as any;
|
||||
// 0.25 + 0.5 * 0.5 = 0.5
|
||||
expect(line.material.opacity).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
it('edge opacity with low weight still reads (new floor catches regressions)', () => {
|
||||
manager.createEdges([makeEdge('a', 'b', { weight: 0.0 })], positions);
|
||||
const line = manager.group.children[0] as any;
|
||||
// Floor is 0.25 — used to be 0.1 which was invisible through fog
|
||||
expect(line.material.opacity).toBeGreaterThanOrEqual(0.25);
|
||||
});
|
||||
|
||||
it('edge opacity cap is 0.8 (was 0.6)', () => {
|
||||
manager.createEdges([makeEdge('a', 'b', { weight: 100.0 })], positions);
|
||||
const line = manager.group.children[0] as any;
|
||||
expect(line.material.opacity).toBeCloseTo(0.8, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Scene config — source-level regex assertions (scene.ts needs three/addons)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('issue #31 — scene.ts bloom/fog/starfield config is locked in', () => {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
let src: string;
|
||||
|
||||
beforeAll(() => {
|
||||
src = readFileSync(resolve(__dirname, '../scene.ts'), 'utf-8');
|
||||
});
|
||||
|
||||
it('fog density is reduced from 0.008 → 0.0035', () => {
|
||||
// Positive match: the new density appears inside a FogExp2 call
|
||||
expect(src).toMatch(/FogExp2\(\s*0x[0-9a-f]+,\s*0\.0035/i);
|
||||
// Negative match: the old aggressive density is gone
|
||||
expect(src).not.toMatch(/FogExp2\(\s*0x[0-9a-f]+,\s*0\.008\b/i);
|
||||
});
|
||||
|
||||
it('bloom strength is 0.55 (was 0.8 — was blown out)', () => {
|
||||
// Match on the constructor signature: (size, strength, radius, threshold)
|
||||
expect(src).toMatch(
|
||||
/new UnrealBloomPass\([\s\S]*?,\s*0\.55,\s*0\.6,\s*0\.2\s*\)/
|
||||
);
|
||||
// Old values must be gone
|
||||
expect(src).not.toMatch(/new UnrealBloomPass\([\s\S]*?,\s*0\.8,\s*0\.4,\s*0\.85\s*\)/);
|
||||
});
|
||||
|
||||
it('scene.background is explicitly set (not left as default black void)', () => {
|
||||
expect(src).toMatch(/scene\.background\s*=/);
|
||||
});
|
||||
|
||||
it('a starfield is created and added to the scene', () => {
|
||||
// createStarfield helper exists and is called at least once
|
||||
expect(src).toMatch(/function\s+createStarfield\s*\(/);
|
||||
expect(src).toMatch(/createStarfield\s*\(\s*\)/);
|
||||
expect(src).toMatch(/scene\.add\(\s*starfield\s*\)/);
|
||||
});
|
||||
|
||||
it('starfield is exposed on SceneContext (so dispose/update can touch it later)', () => {
|
||||
expect(src).toMatch(/starfield:\s*THREE\.Points/);
|
||||
});
|
||||
|
||||
it('ACESFilmicToneMapping still active (did not accidentally revert tone map)', () => {
|
||||
expect(src).toMatch(/ACESFilmicToneMapping/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Source-level checks on nodes.ts — the shared glow texture helper
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('issue #31 — nodes.ts glow texture helper exists and is a singleton', () => {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
let src: string;
|
||||
|
||||
beforeAll(() => {
|
||||
src = readFileSync(resolve(__dirname, '../nodes.ts'), 'utf-8');
|
||||
});
|
||||
|
||||
it('shared glow texture cache exists at module level', () => {
|
||||
expect(src).toMatch(/let\s+sharedGlowTexture/);
|
||||
expect(src).toMatch(/function\s+getGlowTexture\s*\(/);
|
||||
});
|
||||
|
||||
it('radial gradient has a transparent outer stop (not hard edge)', () => {
|
||||
// The key insight — colour stops must go to rgba(255,255,255,0) at the edge
|
||||
expect(src).toMatch(/createRadialGradient/);
|
||||
expect(src).toMatch(/rgba\(255,\s*255,\s*255,\s*0(?:\.0)?\)/);
|
||||
});
|
||||
|
||||
it('SpriteMaterial is constructed with a map parameter', () => {
|
||||
expect(src).toMatch(/new THREE\.SpriteMaterial\(\{[\s\S]*?map:\s*getGlowTexture\(\)/);
|
||||
});
|
||||
});
|
||||
|
|
@ -36,11 +36,15 @@ export class EdgeManager {
|
|||
|
||||
const points = [sourcePos, targetPos];
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
// Brand violet (#8b5cf6) instead of the old dark navy 0x4a4a7a
|
||||
// which was invisible against the fog. Higher opacity base so
|
||||
// edges actually read as a graph.
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x4a4a7a,
|
||||
color: 0x8b5cf6,
|
||||
transparent: true,
|
||||
opacity: Math.min(0.1 + edge.weight * 0.5, 0.6),
|
||||
opacity: Math.min(0.25 + edge.weight * 0.5, 0.8),
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const line = new THREE.Line(geometry, material);
|
||||
|
|
@ -58,10 +62,11 @@ export class EdgeManager {
|
|||
const points = [sourcePos.clone(), sourcePos.clone()];
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x4a4a7a,
|
||||
color: 0x8b5cf6,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const line = new THREE.Line(geometry, material);
|
||||
|
|
@ -111,11 +116,11 @@ export class EdgeManager {
|
|||
attrs.needsUpdate = true;
|
||||
|
||||
const mat = g.line.material as THREE.LineBasicMaterial;
|
||||
mat.opacity = progress * 0.5;
|
||||
mat.opacity = progress * 0.65;
|
||||
|
||||
if (g.frame >= g.totalFrames) {
|
||||
// Final opacity from weight
|
||||
mat.opacity = 0.5;
|
||||
// Final opacity matches new createEdges baseline
|
||||
mat.opacity = 0.65;
|
||||
this.growingEdges.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -126,7 +131,7 @@ export class EdgeManager {
|
|||
d.frame++;
|
||||
const progress = d.frame / d.totalFrames;
|
||||
const mat = d.line.material as THREE.LineBasicMaterial;
|
||||
mat.opacity = Math.max(0, 0.5 * (1 - progress));
|
||||
mat.opacity = Math.max(0, 0.65 * (1 - progress));
|
||||
|
||||
if (d.frame >= d.totalFrames) {
|
||||
this.group.remove(d.line);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,37 @@ import * as THREE from 'three';
|
|||
import type { GraphNode } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
|
||||
// Shared radial-gradient texture used for every node's glow Sprite.
|
||||
// Without a map, THREE.Sprite renders as a flat coloured plane — additive-
|
||||
// blending + UnrealBloomPass then amplifies its square edges into the
|
||||
// hard-edged "glowing cubes" artefact reported in issue #31. Using a
|
||||
// soft radial gradient gives a real round halo and lets bloom do its job.
|
||||
let sharedGlowTexture: THREE.Texture | null = null;
|
||||
function getGlowTexture(): THREE.Texture {
|
||||
if (sharedGlowTexture) return sharedGlowTexture;
|
||||
const size = 128;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
// Fallback: empty 1x1 texture; halos will be invisible but nothing crashes.
|
||||
sharedGlowTexture = new THREE.Texture();
|
||||
return sharedGlowTexture;
|
||||
}
|
||||
const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||
gradient.addColorStop(0.0, 'rgba(255, 255, 255, 1.0)');
|
||||
gradient.addColorStop(0.25, 'rgba(255, 255, 255, 0.7)');
|
||||
gradient.addColorStop(0.55, 'rgba(255, 255, 255, 0.2)');
|
||||
gradient.addColorStop(1.0, 'rgba(255, 255, 255, 0.0)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.needsUpdate = true;
|
||||
sharedGlowTexture = tex;
|
||||
return tex;
|
||||
}
|
||||
|
||||
function easeOutElastic(t: number): number {
|
||||
if (t === 0 || t === 1) return t;
|
||||
const p = 0.3;
|
||||
|
|
@ -90,16 +121,20 @@ export class NodeManager {
|
|||
const size = 0.5 + node.retention * 2;
|
||||
const color = NODE_TYPE_COLORS[node.type] || '#8B95A5';
|
||||
|
||||
// v2.0.5 Active Forgetting: suppressed memories dim to 20% opacity
|
||||
// and lose their emissive glow, mimicking inhibitory-control silencing.
|
||||
const isSuppressed = (node.suppression_count ?? 0) > 0;
|
||||
|
||||
// Node mesh
|
||||
const geometry = new THREE.SphereGeometry(size, 16, 16);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(color),
|
||||
emissive: new THREE.Color(color),
|
||||
emissiveIntensity: 0.3 + node.retention * 0.5,
|
||||
emissiveIntensity: isSuppressed ? 0.0 : 0.3 + node.retention * 0.5,
|
||||
roughness: 0.3,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.3 + node.retention * 0.7,
|
||||
opacity: isSuppressed ? 0.2 : 0.3 + node.retention * 0.7,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
|
|
@ -109,15 +144,20 @@ export class NodeManager {
|
|||
this.meshMap.set(node.id, mesh);
|
||||
this.group.add(mesh);
|
||||
|
||||
// Glow sprite
|
||||
// Glow sprite — radial-gradient texture kills the square-halo artefact
|
||||
// from issue #31. depthWrite:false prevents z-fighting with the sphere.
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
map: getGlowTexture(),
|
||||
color: new THREE.Color(color),
|
||||
transparent: true,
|
||||
opacity: initialScale > 0 ? 0.15 + node.retention * 0.2 : 0,
|
||||
opacity: initialScale > 0 ? (isSuppressed ? 0.1 : 0.3 + node.retention * 0.35) : 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(size * 4 * initialScale, size * 4 * initialScale, 1);
|
||||
// Slightly larger halo — the gradient falls off quickly so we need
|
||||
// more screen real estate for a visible soft bloom footprint.
|
||||
sprite.scale.set(size * 6 * initialScale, size * 6 * initialScale, 1);
|
||||
sprite.position.copy(pos);
|
||||
sprite.userData = { isGlow: true, nodeId: node.id };
|
||||
this.glowMap.set(node.id, sprite);
|
||||
|
|
@ -275,8 +315,8 @@ export class NodeManager {
|
|||
if (mn.frame >= 5) {
|
||||
const glowT = Math.min((mn.frame - 5) / 5, 1);
|
||||
const glowMat = mn.glow.material as THREE.SpriteMaterial;
|
||||
glowMat.opacity = glowT * 0.25;
|
||||
const glowSize = mn.targetScale * 4 * scale;
|
||||
glowMat.opacity = glowT * 0.4;
|
||||
const glowSize = mn.targetScale * 6 * scale;
|
||||
mn.glow.scale.set(glowSize, glowSize, 1);
|
||||
}
|
||||
|
||||
|
|
@ -300,7 +340,7 @@ export class NodeManager {
|
|||
const scale = Math.max(0.001, dn.originalScale * shrink);
|
||||
|
||||
dn.mesh.scale.setScalar(scale);
|
||||
const glowScale = scale * 4;
|
||||
const glowScale = scale * 6;
|
||||
dn.glow.scale.set(glowScale, glowScale, 1);
|
||||
|
||||
// Fade opacity
|
||||
|
|
@ -342,7 +382,7 @@ export class NodeManager {
|
|||
|
||||
const glow = this.glowMap.get(gn.id);
|
||||
if (glow) {
|
||||
const glowSize = scale * 4;
|
||||
const glowSize = scale * 6;
|
||||
glow.scale.set(glowSize, glowSize, 1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,53 @@ export interface SceneContext {
|
|||
point1: THREE.PointLight;
|
||||
point2: THREE.PointLight;
|
||||
};
|
||||
starfield: THREE.Points;
|
||||
}
|
||||
|
||||
function createStarfield(): THREE.Points {
|
||||
// 2000 dim points distributed on a spherical shell at radius 600-1000.
|
||||
// Purely decorative depth cue — never intersects the graph area and
|
||||
// sits below the bloom threshold so it doesn't bloom.
|
||||
const starCount = 2000;
|
||||
const positions = new Float32Array(starCount * 3);
|
||||
const colors = new Float32Array(starCount * 3);
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = 600 + Math.random() * 400;
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
positions[i * 3 + 2] = r * Math.cos(phi);
|
||||
// Subtle colour variation — mostly cool white, some violet tint.
|
||||
const tint = Math.random();
|
||||
colors[i * 3] = 0.55 + tint * 0.25;
|
||||
colors[i * 3 + 1] = 0.55 + tint * 0.15;
|
||||
colors[i * 3 + 2] = 0.75 + tint * 0.25;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
const mat = new THREE.PointsMaterial({
|
||||
size: 1.6,
|
||||
sizeAttenuation: true,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
return new THREE.Points(geo, mat);
|
||||
}
|
||||
|
||||
export function createScene(container: HTMLDivElement): SceneContext {
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x050510, 0.008);
|
||||
// Darker-than-black background with a subtle colour cast. Combined with
|
||||
// the starfield and reduced fog, the void has depth instead of reading
|
||||
// as a broken shader canvas.
|
||||
scene.background = new THREE.Color(0x05050f);
|
||||
// Fog density reduced 0.008 → 0.0035 — the old value was killing every
|
||||
// edge and node past 50 units. Lighter colour blends into the background.
|
||||
scene.fog = new THREE.FogExp2(0x0a0a1a, 0.0035);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
|
|
@ -40,7 +82,7 @@ export function createScene(container: HTMLDivElement): SceneContext {
|
|||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.2;
|
||||
renderer.toneMappingExposure = 1.25;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
|
|
@ -55,25 +97,32 @@ export function createScene(container: HTMLDivElement): SceneContext {
|
|||
|
||||
const composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
// Bloom retuned for radial-gradient glow sprites (issue #31 fix):
|
||||
// strength 0.8 → 0.55 — gentler, avoids the old blown-out look
|
||||
// radius 0.4 → 0.6 — softer falloff, diffuses cleanly through glow
|
||||
// threshold 0.85 → 0.2 — let mid-tones bloom instead of highlights only
|
||||
const bloomPass = new UnrealBloomPass(
|
||||
new THREE.Vector2(container.clientWidth, container.clientHeight),
|
||||
0.8,
|
||||
0.4,
|
||||
0.85
|
||||
0.55,
|
||||
0.6,
|
||||
0.2
|
||||
);
|
||||
composer.addPass(bloomPass);
|
||||
|
||||
const ambient = new THREE.AmbientLight(0x1a1a3a, 0.5);
|
||||
const ambient = new THREE.AmbientLight(0x2a2a5a, 0.7);
|
||||
scene.add(ambient);
|
||||
|
||||
const point1 = new THREE.PointLight(0x6366f1, 1.5, 200);
|
||||
const point1 = new THREE.PointLight(0x6366f1, 1.8, 240);
|
||||
point1.position.set(50, 50, 50);
|
||||
scene.add(point1);
|
||||
|
||||
const point2 = new THREE.PointLight(0xa855f7, 1, 200);
|
||||
const point2 = new THREE.PointLight(0xa855f7, 1.2, 240);
|
||||
point2.position.set(-50, -30, -50);
|
||||
scene.add(point2);
|
||||
|
||||
const starfield = createStarfield();
|
||||
scene.add(starfield);
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.params.Points = { threshold: 2 };
|
||||
const mouse = new THREE.Vector2();
|
||||
|
|
@ -88,6 +137,7 @@ export function createScene(container: HTMLDivElement): SceneContext {
|
|||
raycaster,
|
||||
mouse,
|
||||
lights: { ambient, point1, point2 },
|
||||
starfield,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue