diff --git a/.serena/project.yml b/.serena/project.yml index acca853..45f6836 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,43 +1,6 @@ -# the name by which the project can be referenced within Serena +# the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "Arena3Dweb" - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor powershell python -# python_jedi python_ty r rego ruby -# ruby_solargraph rust scala scss solidity -# svelte swift systemverilog terraform toml -# typescript typescript_vts vue yaml zig -# (This list may be outdated. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) -# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) -# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) -# - For Free Pascal/Lazarus, use pascal -# Special requirements: -# Some languages require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -# R dropped: legacy code being deleted through the v3 migration, and its LSP -# (R `languageserver` package) isn't installed — its failure aborted the whole -# language-server manager. Active dev is TS (frontend) + Python (backend). -languages: -- typescript -- python - # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" @@ -59,23 +22,19 @@ ignore_all_files_in_gitignore: true # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). -# Paths can be absolute or relative to the project root. -# Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. -# Example: -# additional_workspace_folders: -# - ../sibling-package -# - ../shared-lib -additional_workspace_folders: [] - # list of additional paths to ignore in this project. # Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" # Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] @@ -135,3 +94,75 @@ read_only_memory_patterns: [] # Extends the list from the global configuration, merging the two lists. # Example: ["_archive/.*", "_episodes/.*"] ignored_memory_patterns: [] + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown +# matlab msl nextflow nix ocaml +# pascal perl php php_phpactor php_phpantom +# powershell python python_basedpyright python_jedi python_pyrefly +# python_ty qml r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- typescript +- python + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- . + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5ac2b..c240456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,14 @@ - Edge weight can now be shown as **edge thickness**, not only opacity. The Edge Actions panel replaces the "Edge Opacity By Weight" checkbox with a "Show Edge Weight As" radio — Nothing / Opacity / Width / Both — plus intra- and inter-layer width sliders for whichever property weight isn't driving. Sessions carry the choice as the independent `edgeOpacityByWeight` and `edgeWidthByWeight` booleans; files written before this default to opacity, so they render unchanged. Thickness needed `Line2` (instanced quads) because WebGL renders every line primitive at exactly 1px regardless of `linewidth`. +### Changed + +- Channel curvature sliders reach much further — intra-layer 10–60 (was 10–20), inter-layer 1–30 (was 1–10) — so the channels of a multi-channel edge can be pulled well apart instead of running nearly parallel. Defaults are unchanged, so existing sessions render exactly as before. + ### Fixed +- Edges no longer glow. Thick edges cover a large share of the screen, and bloom on all of them was blinding against dark backgrounds. Bloom is now selective (three's off-screen bloom-composer pattern) and reads from node spheres alone, so nodes still glow, edges and layer planes stay crisp, and the extra pass costs a few spheres rather than a second full scene. +- Curved channel edges rendered as dotted lines with beads at the curve points once thickness landed. `LineMaterial`'s `worldUnits` mode assumes a perspective camera — its fragment shader traces a view ray from the camera origin and discards anything farther than half a width from the segment, which under this app's orthographic camera discards along the whole segment. Widths are now screen-space, sized against a shared resolution uniform kept on the frustum size (so the numbers still mean world units), retargeted on resize and for the PNG export. - Node colors rendered washed out/dark in the 3D scene compared to the 2D navigator. The bloom composer was blitting linear color straight to the sRGB canvas (missing `OutputPass`), and the ambient light was left at the pre-r155 intensity that physical lighting divides by PI. ## [3.0.0] - 2026-07-27 diff --git a/frontend/e2e/edge-width.spec.ts b/frontend/e2e/edge-width.spec.ts index 46f3485..176e1d4 100644 --- a/frontend/e2e/edge-width.spec.ts +++ b/frontend/e2e/edge-width.spec.ts @@ -8,9 +8,11 @@ import { expect, test } from '@playwright/test' // background, mid-grey layer planes, and bright near-grey edges. Only the // bright band counts — the planes' own pixel count *falls* as thicker edges // cover them, so any metric including them cancels the effect out. Nodes are -// the saturated pixels and are excluded by the hue test. Retries because the -// render loop is FPS-limited and the headless GPU context can drop and -// restore, leaving most frames' buffers empty. +// the saturated pixels and are excluded by the hue test. Requires bloom off +// (loadExample): glow lifts the whole plane into the bright band, which swamps +// the edges' own pixels. Retries because the render loop is FPS-limited and +// the headless GPU context can drop and restore, leaving most frames' buffers +// empty. async function edgeCoverage(page: import('@playwright/test').Page) { for (let i = 0; i < 60; i++) { const sample = await page.evaluate(() => { @@ -34,7 +36,8 @@ async function edgeCoverage(page: import('@playwright/test').Page) { const b = data[p + 2] const hi = Math.max(r, gr, b) const lo = Math.min(r, gr, b) - if (hi - lo >= 30) node++ // saturated: node spheres + if (hi - lo >= 30) + node++ // saturated: node spheres else if (hi >= 128) edge++ // bright near-grey: edges } resolve({ edge, node }) @@ -55,6 +58,14 @@ async function loadExample(page: import('@playwright/test').Page) { await page.getByRole('tab', { name: 'File' }).click() await page.getByRole('button', { name: 'Load Example' }).click() await expect(page.locator('#file_status')).toContainText('Loaded network') + // Bloom off so pixel counts measure geometry, not glow. Driven on the input + // itself rather than through its tab: uncheck() waits for visibility, and on + // a slow runner the Scene Actions pane isn't shown by the time we get here. + await page.locator('#toggleBloom').evaluate((el) => { + const box = el as HTMLInputElement + box.checked = false + box.dispatchEvent(new Event('change')) + }) await page.getByRole('tab', { name: 'Edge Actions' }).click() } diff --git a/frontend/e2e/network-flow.spec.ts b/frontend/e2e/network-flow.spec.ts index a5492aa..1e0dba8 100644 --- a/frontend/e2e/network-flow.spec.ts +++ b/frontend/e2e/network-flow.spec.ts @@ -13,7 +13,7 @@ async function selectOption( value: string ) { await page.locator(selector).evaluate((el, v) => { - ;(el as HTMLSelectElement).value = v as string + ;(el as HTMLSelectElement).value = v el.dispatchEvent(new Event('change', { bubbles: true })) }, value) } @@ -21,7 +21,8 @@ async function selectOption( type NodeSnapshot = { pos: [number, number, number]; cluster: string } function readNodes() { - return async (): Promise => { + // Runs inside page.evaluate, which accepts a sync function just as happily. + return (): NodeSnapshot[] => { const ctx = ( window as unknown as { __arena: { ctx: { nodeObjects: unknown[] } } } ).__arena.ctx @@ -113,7 +114,9 @@ test('load example → layout → clustered layout → export', async ({ page }) // --- Export session ------------------------------------------------------ await page.getByRole('tab', { name: 'File' }).dispatchEvent('click') const downloadPromise = page.waitForEvent('download') - await page.getByRole('button', { name: 'Save Session' }).dispatchEvent('click') + await page + .getByRole('button', { name: 'Save Session' }) + .dispatchEvent('click') const download = await downloadPromise expect(download.suggestedFilename()).toMatch(/\.json$/) }) diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 0000000..bce0526 --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + // The specs live outside the app's tsconfig (which covers src/ only) and run + // in Node, not the browser bundle — hence node types and their own project. + // typescript-eslint resolves each file against its nearest tsconfig.json, so + // this file is what makes typed linting work in e2e/. + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["."] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 71a493e..3d77d3e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@playwright/test": "^1.61.1", "@types/bootstrap": "^5.2.11", + "@types/node": "^26.2.0", "@types/three": "^0.185.0", "eslint": "^10.6.0", "prettier": "^3.9.4", @@ -623,6 +624,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -2343,6 +2354,13 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b8c4a62..b63ec7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,8 +6,8 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "lint": "eslint src", - "format": "prettier --write src", + "lint": "eslint src e2e", + "format": "prettier --write src e2e", "test": "vitest", "test:e2e": "playwright test" }, @@ -18,6 +18,7 @@ "devDependencies": { "@playwright/test": "^1.61.1", "@types/bootstrap": "^5.2.11", + "@types/node": "^26.2.0", "@types/three": "^0.185.0", "eslint": "^10.6.0", "prettier": "^3.9.4", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 68bf373..79ab670 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ testDir: './e2e', fullyParallel: false, retries: process.env.CI ? 2 : 0, + // These drive a WebGL scene, and CI has no GPU — every test renders through + // software GL and lands in the 15-30s range, so the 30s default was passing + // on retries alone. + timeout: 90_000, reporter: [['list']], use: { baseURL: 'http://localhost:5173', diff --git a/frontend/src/actions/screen.ts b/frontend/src/actions/screen.ts index 4412f4e..3ae40fd 100644 --- a/frontend/src/actions/screen.ts +++ b/frontend/src/actions/screen.ts @@ -2,7 +2,7 @@ // Port of v2 www/js/object_actions/screen.js. Shiny sync calls dropped. import * as THREE from 'three' -import { ctx } from '../three' +import { ctx, edgeResolution } from '../three' import { onBackgroundColor, renderFrame, @@ -40,6 +40,9 @@ export function setWindowBounds(): void { ctx.yBoundMax = window.innerHeight / 2 ctx.zBoundMin = -window.innerHeight / 2.5 ctx.zBoundMax = window.innerHeight / 2.5 + // Every edge LineMaterial shares this uniform; keep it on the frustum size + // so screen-space linewidths keep reading as world units. + edgeResolution.set(window.innerWidth, window.innerHeight) } export function setCamera(): void { @@ -127,7 +130,12 @@ export function exportSceneImage(): boolean { }) renderer.setSize(Math.round(w * scale), Math.round(h * scale), false) renderer.setClearColor(ctx.renderer.getClearColor(new THREE.Color()), 1) + // Edge linewidths are screen-space against the shared frustum-sized + // resolution; point it at the export frustum for this render so widths + // scale with the PNG, then hand it back to the live view. + edgeResolution.set(w, h) renderer.render(ctx.scene.THREE_Object, camera) + edgeResolution.set(2 * ctx.xBoundMax, 2 * ctx.yBoundMax) // Composite onto a 2D canvas so the DOM-overlay labels can be drawn on top. const out = document.createElement('canvas') diff --git a/frontend/src/three/Edge.ts b/frontend/src/three/Edge.ts index e8b2613..336da9e 100644 --- a/frontend/src/three/Edge.ts +++ b/frontend/src/three/Edge.ts @@ -8,7 +8,7 @@ import { EDGE_WIDTH_MIN, SELECTED_DEFAULT_COLOR, } from './constants' -import { ctx, disposeObject3D } from './runtime' +import { ctx, disposeObject3D, edgeResolution } from './runtime' export interface EdgeOptions { id?: number @@ -120,9 +120,11 @@ export class Edge { // Thick lines: WebGL renders every line primitive at exactly 1px, so real // widths need Line2, which expands each segment into an instanced quad. - // worldUnits keeps the width in view space — with this app's window-sized - // orthographic frustum that reads as constant on-screen thickness, and it - // scales correctly into the higher-resolution PNG export. + // Widths are screen-space (LineMaterial's worldUnits shader assumes a + // perspective view ray and breaks curved lines into dots under this app's + // orthographic camera) — with the frustum-sized resolution uniform below, + // linewidth is effectively world units, constant on screen and scaled + // correctly into the higher-resolution PNG export. // ponytail: one material per line, same count as the LineBasicMaterial it // replaces. Quantise into a shared cache if material churn ever shows up. createLine( @@ -137,8 +139,12 @@ export class Edge { transparent: true, opacity: opacity, linewidth: width, - worldUnits: true, }) + // Screen-space widths, sized against the shared frustum-tracking + // resolution (see runtime.edgeResolution) so linewidth stays in world + // units. Assign the uniform's value directly: the `resolution` setter + // copies, and we need every edge to share the one mutable Vector2. + material.uniforms.resolution.value = edgeResolution return new Line2(geometry, material) } diff --git a/frontend/src/three/Node.ts b/frontend/src/three/Node.ts index 72bc7ba..db34098 100644 --- a/frontend/src/three/Node.ts +++ b/frontend/src/three/Node.ts @@ -7,6 +7,7 @@ import { DIAMOND_RADIUS, CONE_RADIUS, CONE_HEIGHT, + BLOOM_LAYER, } from './constants' import { ctx } from './runtime' @@ -83,6 +84,9 @@ export class Node { transparent: true, }) this.sphere = new THREE.Mesh(geometry, material) + // Nodes are the only thing that glows. enable(), not set(): the sphere + // stays on layer 0 so the default-masked raycaster still picks it. + this.sphere.layers.enable(BLOOM_LAYER) } initTranslate(x: number, y: number, z: number): void { diff --git a/frontend/src/three/constants.ts b/frontend/src/three/constants.ts index d4075c9..6f33aa3 100644 --- a/frontend/src/three/constants.ts +++ b/frontend/src/three/constants.ts @@ -26,6 +26,12 @@ export const EDGE_WIDTH_MAX = 6 // rather than rasterised as invisible quads (v2 relied on alphaTest: 0.05). export const EDGE_MIN_VISIBLE_OPACITY = 0.05 +// Render layer for the things that glow — node spheres, and nothing else. +// postprocessing.ts renders the bloom source with the camera masked to this +// layer alone, so edges stay crisp (thick lines glowing all over wash out dark +// backgrounds) and that second pass costs a few spheres instead of the scene. +export const BLOOM_LAYER = 1 + // Colors export const LAYER_DEFAULT_COLOR = '#777777' export const SELECTED_LAYER_DEFAULT_COLOR = '#f7f43e' diff --git a/frontend/src/three/postprocessing.ts b/frontend/src/three/postprocessing.ts index 183fe1d..8f28ef5 100644 --- a/frontend/src/three/postprocessing.ts +++ b/frontend/src/three/postprocessing.ts @@ -8,12 +8,19 @@ import * as THREE from 'three' import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js' import { OutputPass } from 'three/addons/postprocessing/OutputPass.js' import { RenderPass } from 'three/addons/postprocessing/RenderPass.js' +import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js' import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js' +import { BLOOM_LAYER } from './constants' import { ctx } from './runtime' +// Selective bloom (three's webgl_postprocessing_unreal_bloom_selective +// pattern): bloomComposer renders the glow off-screen from BLOOM_LAYER alone, +// composer draws the full scene and adds that glow on top. let composer: EffectComposer | null = null +let bloomComposer: EffectComposer | null = null let renderPass: RenderPass | null = null let bloomPass: UnrealBloomPass | null = null +let mixPass: ShaderPass | null = null const isMobile = typeof navigator !== 'undefined' && @@ -44,13 +51,43 @@ export function bloomActive(): boolean { function ensureComposer(): void { if (composer || !ctx.renderer) return const size = ctx.renderer.getSize(new THREE.Vector2()) - composer = new EffectComposer(ctx.renderer) renderPass = new RenderPass(new THREE.Scene(), new THREE.Camera()) - composer.addPass(renderPass) - // Subtle accent glow: high threshold so only bright saturated colors - // (nodes, colored edges) bloom; labels are DOM overlays and unaffected. + + // Glow only, rendered off-screen. Subtle accent: high threshold so only + // bright saturated colors (nodes) bloom; labels are DOM overlays and + // unaffected, edges are masked off by layer in renderFrame. + bloomComposer = new EffectComposer(ctx.renderer) + bloomComposer.renderToScreen = false + bloomComposer.addPass(renderPass) bloomPass = new UnrealBloomPass(size, 0.35, 0.3, 0.8) - composer.addPass(bloomPass) + bloomComposer.addPass(bloomPass) + + composer = new EffectComposer(ctx.renderer) + composer.addPass(renderPass) + mixPass = new ShaderPass( + new THREE.ShaderMaterial({ + uniforms: { + baseTexture: { value: null }, + bloomTexture: { value: bloomComposer.renderTarget2.texture }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + }`, + fragmentShader: ` + uniform sampler2D baseTexture; + uniform sampler2D bloomTexture; + varying vec2 vUv; + void main() { + gl_FragColor = texture2D( baseTexture, vUv ) + texture2D( bloomTexture, vUv ); + }`, + }), + 'baseTexture' + ) + mixPass.needsSwap = true + composer.addPass(mixPass) // Composer buffers are linear; without this the last pass would blit linear // values straight to an sRGB canvas and the whole scene renders dark. Must // stay last. @@ -59,6 +96,7 @@ function ensureComposer(): void { export function resizePostprocessing(width: number, height: number): void { composer?.setSize(width, height) + bloomComposer?.setSize(width, height) bloomPass?.resolution.set(width, height) } @@ -68,10 +106,18 @@ export function renderFrame(): void { if (!ctx.renderer || !ctx.scene || !ctx.camera) return if (userEnabled && backgroundDark) { ensureComposer() - if (composer && renderPass) { + if (composer && bloomComposer && renderPass) { // Scene object is replaced on every network load; re-point per frame. renderPass.scene = ctx.scene.THREE_Object renderPass.camera = ctx.camera + // Glow source: node spheres only. Masking the camera to BLOOM_LAYER + // keeps edges and planes out of it — the point of the exercise — and + // makes this second pass a handful of spheres rather than the scene, + // which matters where there's no GPU (CI runs on software GL). + const mask = ctx.camera.layers.mask + ctx.camera.layers.set(BLOOM_LAYER) + bloomComposer.render() + ctx.camera.layers.mask = mask composer.render() return } diff --git a/frontend/src/three/runtime.ts b/frontend/src/three/runtime.ts index 69d977c..8d33118 100644 --- a/frontend/src/three/runtime.ts +++ b/frontend/src/three/runtime.ts @@ -12,6 +12,7 @@ import type { Scene } from './Scene' import type * as THREE from 'three' +import { Vector2 } from 'three' import type { Layer } from './Layer' import type { Node } from './Node' import type { Edge } from './Edge' @@ -97,6 +98,14 @@ export interface RuntimeContext { const winW = typeof window !== 'undefined' ? window.innerWidth : 800 const winH = typeof window !== 'undefined' ? window.innerHeight : 800 +// Shared resolution uniform for every edge LineMaterial. Screen-space fat +// lines divide linewidth by this, so it must track the camera frustum size in +// world units: every material references this single Vector2, so resize +// (screen.ts resetScreen) and PNG export retarget all edges by mutating it — +// no scene traversal. worldUnits is not an option: its shader assumes a +// perspective view ray and shreds lines under this app's orthographic camera. +export const edgeResolution = new Vector2(winW, winH) + export const ctx: RuntimeContext = { renderer: null, camera: null, diff --git a/frontend/src/three/three.test.ts b/frontend/src/three/three.test.ts index dcd385b..edf6bfc 100644 --- a/frontend/src/three/three.test.ts +++ b/frontend/src/three/three.test.ts @@ -1,7 +1,15 @@ import { beforeEach, describe, expect, it } from 'vitest' import * as THREE from 'three' import { Line2 } from 'three/addons/lines/Line2.js' -import { Node, Layer, Scene, Edge, ctx, resetContext } from './index' +import { + Node, + Layer, + Scene, + Edge, + ctx, + edgeResolution, + resetContext, +} from './index' import { EDGE_WIDTH_MAX, EDGE_WIDTH_MIN } from './constants' beforeEach(() => { @@ -193,7 +201,10 @@ describe('Edge', () => { const e = new Edge({ source: 'A::L', target: 'B::L', weights: [1] }) const material = (e.THREE_Object as Line2).material expect(material.linewidth).toBe(4) - expect(material.worldUnits).toBe(true) + // Screen-space widths (worldUnits breaks under the orthographic camera), + // sized against the shared frustum-tracking resolution uniform. + expect(material.worldUnits).toBe(false) + expect(material.uniforms.resolution.value).toBe(edgeResolution) }) it('skips edges too faint to see instead of building invisible lines', () => { diff --git a/frontend/src/ui/edge.ts b/frontend/src/ui/edge.ts index 0ab1a5b..4c40cf2 100644 --- a/frontend/src/ui/edge.ts +++ b/frontend/src/ui/edge.ts @@ -76,11 +76,11 @@ const EDGE_HTML = `
- +
- +
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 9bb92fc..7db3ec6 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -10,7 +10,10 @@ "sourceMap": true, "noEmit": true, "skipLibCheck": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"] + "lib": ["ES2022", "DOM", "DOM.Iterable"], + // playwright.config.ts reads process.env; @types/node is not picked up + // automatically here, so name it. + "types": ["node"] }, - "include": ["src"] + "include": ["src", "playwright.config.ts"] }