From 1f29f95a9849ad142421864b9270d6ffb905ca90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:04:28 +0000 Subject: [PATCH 01/16] chore(deps-dev): bump happy-dom from 20.8.7 to 20.8.8 Bumps [happy-dom](https://github.com/capricorn86/happy-dom) from 20.8.7 to 20.8.8. - [Release notes](https://github.com/capricorn86/happy-dom/releases) - [Commits](https://github.com/capricorn86/happy-dom/compare/v20.8.7...v20.8.8) --- updated-dependencies: - dependency-name: happy-dom dependency-version: 20.8.8 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 30 ++++++++++++------------------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 083bc13..d127c31 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "eslint-plugin-security": "^3.0.1", "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^17.4.0", - "happy-dom": "^20.8.7", + "happy-dom": "^20.8.8", "husky": "^9.1.7", "knip": "^5.56.0", "lint-staged": "^16.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c7d75b..62f132b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.1.1 - version: 4.1.1(vitest@4.1.1(@types/node@25.0.3)(happy-dom@20.8.7)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2))) + version: 4.1.1(vitest@4.1.1(@types/node@25.0.3)(happy-dom@20.8.8)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2))) '@webgpu/types': specifier: ^0.1.69 version: 0.1.69 @@ -70,8 +70,8 @@ importers: specifier: ^17.4.0 version: 17.4.0 happy-dom: - specifier: ^20.8.7 - version: 20.8.7 + specifier: ^20.8.8 + version: 20.8.8 husky: specifier: ^9.1.7 version: 9.1.7 @@ -101,7 +101,7 @@ importers: version: 8.0.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)) vitest: specifier: ^4.1.1 - version: 4.1.1(@types/node@25.0.3)(happy-dom@20.8.7)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)) + version: 4.1.1(@types/node@25.0.3)(happy-dom@20.8.8)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)) packages: @@ -1598,10 +1598,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - entities@7.0.0: - resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} - engines: {node: '>=0.12'} - entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -1896,8 +1892,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - happy-dom@20.8.7: - resolution: {integrity: sha512-7wfBi+UqulQlyLcis+9a+hTK0A/fMO4QKP6w6J9HnadXVkRdOvGf/N5G4XVpfgCYfnY7oKazlOSdWmsfatNSLQ==} + happy-dom@20.8.8: + resolution: {integrity: sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -3959,7 +3955,7 @@ snapshots: '@typescript-eslint/types': 8.50.1 eslint-visitor-keys: 4.2.1 - '@vitest/coverage-v8@4.1.1(vitest@4.1.1(@types/node@25.0.3)(happy-dom@20.8.7)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)))': + '@vitest/coverage-v8@4.1.1(vitest@4.1.1(@types/node@25.0.3)(happy-dom@20.8.8)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.1 @@ -3971,7 +3967,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.1(@types/node@25.0.3)(happy-dom@20.8.7)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)) + vitest: 4.1.1(@types/node@25.0.3)(happy-dom@20.8.8)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)) '@vitest/expect@4.1.1': dependencies: @@ -4030,7 +4026,7 @@ snapshots: dependencies: '@babel/parser': 7.28.5 '@vue/shared': 3.5.26 - entities: 7.0.0 + entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 @@ -4438,8 +4434,6 @@ snapshots: emoji-regex@8.0.0: {} - entities@7.0.0: {} - entities@7.0.1: {} env-paths@2.2.1: {} @@ -4762,7 +4756,7 @@ snapshots: graceful-fs@4.2.11: {} - happy-dom@20.8.7: + happy-dom@20.8.8: dependencies: '@types/node': 25.0.3 '@types/whatwg-mimetype': 3.0.2 @@ -5613,7 +5607,7 @@ snapshots: jiti: 2.6.1 yaml: 2.8.2 - vitest@4.1.1(@types/node@25.0.3)(happy-dom@20.8.7)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)): + vitest@4.1.1(@types/node@25.0.3)(happy-dom@20.8.8)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.1 '@vitest/mocker': 4.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)) @@ -5637,7 +5631,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.0.3 - happy-dom: 20.8.7 + happy-dom: 20.8.8 transitivePeerDependencies: - msw -- 2.54.0 From f6aa995e5d3b5f93de976846b647f930ac248fbd Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 16:22:37 +0100 Subject: [PATCH 02/16] refactor(test): clean up webgpu mock definitions - Add explicit type annotations to width/height variables - Remove invalid unconfigure method from mock canvas context - Simplify ternary condition in navigator check Signed-off-by: Vaclav Vancura --- src/__test__/webgpu-mock.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/__test__/webgpu-mock.ts b/src/__test__/webgpu-mock.ts index de263f1..fc90866 100644 --- a/src/__test__/webgpu-mock.ts +++ b/src/__test__/webgpu-mock.ts @@ -80,8 +80,8 @@ export function createMockGPUDevice(): GPUDevice { createBindGroup: () => ({ label: 'MockBindGroup' }) as unknown as GPUBindGroup, createSampler: () => ({ label: 'MockSampler' }) as unknown as GPUSampler, createTexture: (desc: GPUTextureDescriptor) => { - let width = 256; - let height = 256; + let width: number; + let height: number; if (typeof desc.size === 'number') { width = desc.size; @@ -128,7 +128,6 @@ export function createMockGPUDevice(): GPUDevice { export function createMockGPUCanvasContext(): GPUCanvasContext { return { configure: () => {}, - unconfigure: () => {}, getCurrentTexture: () => createMockGPUTexture(), canvas: {} as HTMLCanvasElement, } as unknown as GPUCanvasContext; @@ -154,7 +153,7 @@ export function installMockNavigatorGPU(): void { }; // Use Object.defineProperty to override navigator (may be a getter in Node.js). - const existingNav = typeof navigator !== 'undefined' ? navigator : {}; + const existingNav = typeof navigator === 'undefined' ? {} : navigator; Object.defineProperty(globalThis, 'navigator', { value: { ...existingNav, gpu: mockGPU }, writable: true, -- 2.54.0 From 987dcabe1d72c8aacff41764018e4b412fdb0055 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 16:25:56 +0100 Subject: [PATCH 03/16] docs(assets): improve SpriteSheet documentation and reorganize sections - Add comprehensive module documentation to test suite - Expand JSDoc for class, public methods, and private helpers - Clarify ImageBitmap fallback behavior and GPU texture lifecycle - Reorganize method order: move cleanup before texture creation - Move public size property before private fields Signed-off-by: Vaclav Vancura --- src/assets/SpriteSheet.test.ts | 15 ++++++ src/assets/SpriteSheet.ts | 90 +++++++++++++++++++--------------- 2 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src/assets/SpriteSheet.test.ts b/src/assets/SpriteSheet.test.ts index 0faad0a..e87702a 100644 --- a/src/assets/SpriteSheet.test.ts +++ b/src/assets/SpriteSheet.test.ts @@ -1,3 +1,18 @@ +/** + * Unit tests for {@link SpriteSheet}. + * + * Covers the responsibilities that sit between loaded image assets and GPU + * rendering: + * - normalized UV calculation from pixel-space rectangles + * - reported sheet dimensions and source image access + * - lazy GPU texture creation, caching, and recreation after destroy + * - cleanup of GPU textures and temporary `ImageBitmap` instances + * - `load()` behavior when bitmap decoding succeeds or falls back + * + * GPU-facing behavior is verified with the local WebGPU mock helpers and + * stubbed asset-loading/browser APIs. + */ + import { afterEach, describe, expect, it, vi } from 'vitest'; import { createMockGPUDevice } from '../__test__/webgpu-mock'; diff --git a/src/assets/SpriteSheet.ts b/src/assets/SpriteSheet.ts index f5f1614..9017390 100644 --- a/src/assets/SpriteSheet.ts +++ b/src/assets/SpriteSheet.ts @@ -3,13 +3,19 @@ import { Vector2i } from '../utils/Vector2i'; import { AssetLoader } from './AssetLoader'; /** - * Sprite sheet asset for GPU-accelerated sprite rendering. - * Manages an image and its corresponding GPU texture. - * Provides UV coordinate calculation for sprite regions. + * Sprite-sheet wrapper around a loaded image asset. + * + * The class keeps the original image available for CPU-side inspection while + * lazily creating and caching a GPU texture for rendering. When possible, + * `load()` also pre-decodes the source into an `ImageBitmap` so texture uploads + * preserve pixel-art alpha and color values more reliably. */ export class SpriteSheet { // #region Module State + /** Sprite sheet dimensions in pixels. */ + public readonly size: Vector2i; + /** Source HTML image element. */ private readonly image: HTMLImageElement; @@ -19,9 +25,6 @@ export class SpriteSheet { /** GPU texture created lazily on first use. */ private texture: GPUTexture | null = null; - /** Sprite sheet dimensions in pixels. */ - public readonly size: Vector2i; - // #endregion // #region Constructor @@ -43,7 +46,10 @@ export class SpriteSheet { /** * Loads a sprite sheet from an image URL. - * Creates an ImageBitmap with proper alpha handling for pixel-perfect rendering. + * + * Attempts to create an `ImageBitmap` with explicit alpha and color-space + * settings for more predictable GPU uploads. If bitmap creation fails, the + * instance still works and falls back to uploading the `HTMLImageElement`. * * @param url - Path or URL to the image file. * @returns Promise resolving to the loaded SpriteSheet. @@ -86,7 +92,8 @@ export class SpriteSheet { /** * Gets or lazily creates the GPU texture for this sprite sheet. - * Texture is created on first access and cached for the reuse. + * Texture is created on first access and cached for reuse until `destroy()` + * is called. * * @param device - WebGPU device for texture creation. * @returns GPU texture ready for rendering. @@ -102,36 +109,6 @@ export class SpriteSheet { // #endregion - // #region Texture Creation - - /** - * Creates and uploads the GPU texture from the image. - * Uses ImageBitmap if available (from load()), otherwise falls back to HTMLImageElement. - * - * @param device - WebGPU device for texture creation. - */ - private createTexture(device: GPUDevice): void { - this.texture = device.createTexture({ - label: 'Sprite Sheet Texture', - size: [this.size.x, this.size.y, 1], - format: 'rgba8unorm', - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, - }); - - // Prefer ImageBitmap (correct alpha handling) over HTMLImageElement. - const source = this.imageBitmap ?? this.image; - - device.queue.copyExternalImageToTexture({ source }, { texture: this.texture }, [this.size.x, this.size.y]); - - // Close ImageBitmap after the upload to free resources. - if (this.imageBitmap) { - this.imageBitmap.close(); - this.imageBitmap = null; - } - } - - // #endregion - // #region UV Calculation /** @@ -156,9 +133,10 @@ export class SpriteSheet { /** * Releases the GPU texture from memory. - * Call when the sprite sheet is no longer needed. * - * @returns void + * Also closes any retained `ImageBitmap` that has not yet been uploaded. + * After calling this method, a subsequent `getTexture()` call will recreate + * the GPU texture from the original image. */ destroy(): void { if (this.texture) { @@ -171,4 +149,36 @@ export class SpriteSheet { this.imageBitmap = null; } } + + // #endregion + + // #region Texture Creation + + /** + * Creates and uploads the GPU texture from the image. + * + * Uses `ImageBitmap` when available, then releases it immediately after the + * upload to avoid holding duplicate image resources in memory. + * + * @param device - WebGPU device for texture creation. + */ + private createTexture(device: GPUDevice): void { + this.texture = device.createTexture({ + label: 'Sprite Sheet Texture', + size: [this.size.x, this.size.y, 1], + format: 'rgba8unorm', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + }); + + // Prefer ImageBitmap (correct alpha handling) over HTMLImageElement. + const source = this.imageBitmap ?? this.image; + + device.queue.copyExternalImageToTexture({ source }, { texture: this.texture }, [this.size.x, this.size.y]); + + // Close ImageBitmap after the upload to free resources. + if (this.imageBitmap) { + this.imageBitmap.close(); + this.imageBitmap = null; + } + } } -- 2.54.0 From 22e41d911eca193886511c4e534b0cc2ba726dc2 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 16:34:27 +0100 Subject: [PATCH 04/16] docs(core): improve WebGPUContext documentation and error messages - Add comprehensive module documentation to test suite - Expand JSDoc for initializeWebGPU function and result type - Clarify canvas configuration and context initialization steps - Add blank lines throughout tests for improved readability - Refine error messages for consistency and clarity Signed-off-by: Vaclav Vancura --- src/core/WebGPUContext.test.ts | 31 +++++++++++++++++++++++++++++++ src/core/WebGPUContext.ts | 15 +++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/core/WebGPUContext.test.ts b/src/core/WebGPUContext.test.ts index d3fc781..7db6eb4 100644 --- a/src/core/WebGPUContext.test.ts +++ b/src/core/WebGPUContext.test.ts @@ -1,3 +1,16 @@ +/** + * Unit tests for {@link initializeWebGPU}. + * + * Covers the WebGPU bootstrap path used during engine initialization: + * - graceful failure when WebGPU support, adapters, devices, or canvas context + * creation are unavailable + * - successful device/context initialization with mocked GPU objects + * - canvas pixel sizing and optional CSS display sizing + * + * Browser GPU capabilities are simulated with the local WebGPU mock helpers so + * the suite can validate setup logic deterministically. + */ + import { afterEach, describe, expect, it } from 'vitest'; import { @@ -37,7 +50,9 @@ describe('initializeWebGPU', () => { try { delete nav.gpu; + const result = await initializeWebGPU(canvas, displaySize); + expect(result).toBeNull(); } finally { if (originalGpu !== undefined) { @@ -65,6 +80,7 @@ describe('initializeWebGPU', () => { const canvas = createMockCanvas(); const result = await initializeWebGPU(canvas, displaySize); + expect(result).toBeNull(); }); @@ -87,6 +103,7 @@ describe('initializeWebGPU', () => { const canvas = createMockCanvas(); const result = await initializeWebGPU(canvas, displaySize); + expect(result).toBeNull(); }); @@ -96,8 +113,10 @@ describe('initializeWebGPU', () => { it('should return null when canvas.getContext returns null', async () => { installMockNavigatorGPU(); + const canvas = createMockCanvas(null); const result = await initializeWebGPU(canvas, displaySize); + expect(result).toBeNull(); }); @@ -107,8 +126,11 @@ describe('initializeWebGPU', () => { it('should return device and context on success', async () => { installMockNavigatorGPU(); + const canvas = createMockCanvas(); + const result = await initializeWebGPU(canvas, displaySize); + expect(result).not.toBeNull(); expect(result?.device).toBeDefined(); expect(result?.context).toBeDefined(); @@ -116,25 +138,34 @@ describe('initializeWebGPU', () => { it('should set canvas resolution from displaySize', async () => { installMockNavigatorGPU(); + const canvas = createMockCanvas(); + await initializeWebGPU(canvas, displaySize); + expect(canvas.width).toBe(320); expect(canvas.height).toBe(240); }); it('should set CSS size when canvasDisplaySize is provided', async () => { installMockNavigatorGPU(); + const canvas = createMockCanvas(); const canvasDisplaySize = new Vector2i(640, 480); + await initializeWebGPU(canvas, displaySize, canvasDisplaySize); + expect(canvas.style.width).toBe('640px'); expect(canvas.style.height).toBe('480px'); }); it('should not set CSS size when canvasDisplaySize is not provided', async () => { installMockNavigatorGPU(); + const canvas = createMockCanvas(); + await initializeWebGPU(canvas, displaySize); + expect(canvas.style.width).toBe(''); expect(canvas.style.height).toBe(''); }); diff --git a/src/core/WebGPUContext.ts b/src/core/WebGPUContext.ts index efe2f04..049357a 100644 --- a/src/core/WebGPUContext.ts +++ b/src/core/WebGPUContext.ts @@ -3,7 +3,7 @@ import type { Vector2i } from '../utils/Vector2i'; // #region Types /** - * Result of a successful WebGPU context initialization. + * Result returned when WebGPU canvas initialization succeeds. */ export interface WebGPUContextResult { /** Initialized WebGPU device. */ @@ -18,8 +18,11 @@ export interface WebGPUContextResult { // #region WebGPU Initialization /** - * Initializes the WebGPU adapter, device, and canvas context. - * Configures the canvas resolution and optional CSS display size for upscaling. + * Initializes the WebGPU adapter, device, and canvas context for a canvas. + * + * The function configures the canvas pixel resolution first, optionally applies + * a separate CSS display size, then configures the WebGPU context using the + * browser's preferred canvas format. * * @param canvas - HTML canvas element to configure for WebGPU rendering. * @param displaySize - Internal rendering resolution in pixels. @@ -32,7 +35,7 @@ export async function initializeWebGPU( canvasDisplaySize?: Vector2i, ): Promise { if (!navigator.gpu) { - console.error('[BT] WebGPU is not supported in this browser.'); + console.error("[BT] WebGPU isn't supported in this browser."); console.error('[BT] Please use Chrome/Edge 113+ or Firefox Nightly with WebGPU enabled.'); console.error('[BT] See: https://caniuse.com/webgpu'); @@ -45,9 +48,9 @@ export async function initializeWebGPU( if (!adapter) { console.error('[BT] Failed to get WebGPU adapter.'); console.error('[BT] This could mean:'); - console.error('[BT] 1. Your GPU/drivers are too old'); + console.error('[BT] 1. The GPU/drivers are too old'); console.error('[BT] 2. WebGPU is disabled in browser settings'); - console.error('[BT] 3. Running in incompatible environment (VM, remote desktop, etc.)'); + console.error('[BT] 3. Running in an incompatible environment (VM, remote desktop, etc.)'); console.error('[BT] Browser:', navigator.userAgent); return null; -- 2.54.0 From de1beb4ae1b8c0c3b5e422376503c2d4ae5fdd6e Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 16:44:10 +0100 Subject: [PATCH 05/16] docs(renderer): refine JSDoc and reorder regions in SpritePipeline - Improve class, constructor, and method JSDoc in SpritePipeline.ts for clearer intent (batching, frame lifecycle, encode semantics) - Mark MAX_SPRITE_VERTICES comment as concise one-liner - Reorder #region blocks so public API (Camera, Drawing, Frame Rendering) precedes private createPipeline implementation - Add module-level JSDoc block to SpritePipeline.test.ts describing covered behaviour - Add blank lines throughout test suite for readability - Fix comment number format: "50k" -> "50,000" Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/render/SpritePipeline.test.ts | 81 ++++++++- src/render/SpritePipeline.ts | 265 +++++++++++++++--------------- 2 files changed, 209 insertions(+), 137 deletions(-) diff --git a/src/render/SpritePipeline.test.ts b/src/render/SpritePipeline.test.ts index 275fcd1..9bc3273 100644 --- a/src/render/SpritePipeline.test.ts +++ b/src/render/SpritePipeline.test.ts @@ -1,3 +1,17 @@ +/** + * Unit tests for {@link SpritePipeline}. + * + * Covers sprite and bitmap-text batching behavior: + * - construction and pre-initialization safety + * - encode/reset behavior with and without queued sprite data + * - sprite draw batching for shared textures and texture switches + * - camera-offset handling, transparent tinting, and overflow safety + * - bitmap-font rendering paths, including skipped missing glyphs + * + * The tests rely on WebGPU mock helpers plus lightweight sprite-sheet/font + * fixtures to verify draw-call grouping without requiring real GPU resources. + */ + import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { @@ -18,6 +32,7 @@ import { SpritePipeline } from './SpritePipeline'; describe('SpritePipeline constructor', () => { it('creates an instance without error', () => { const pipeline = new SpritePipeline(); + expect(pipeline).toBeDefined(); expect(pipeline).toBeInstanceOf(SpritePipeline); }); @@ -30,6 +45,7 @@ describe('SpritePipeline constructor', () => { describe('pre-initialization safety', () => { it('reset() can be called multiple times safely', () => { const pipeline = new SpritePipeline(); + expect(() => { pipeline.reset(); pipeline.reset(); @@ -39,6 +55,7 @@ describe('pre-initialization safety', () => { it('setCameraOffset() accepts a Vector2i', () => { const pipeline = new SpritePipeline(); + expect(() => { pipeline.setCameraOffset(new Vector2i(10, 20)); }).not.toThrow(); @@ -46,6 +63,7 @@ describe('pre-initialization safety', () => { it('setCameraOffset() accepts zero vector', () => { const pipeline = new SpritePipeline(); + expect(() => { pipeline.setCameraOffset(Vector2i.zero()); }).not.toThrow(); @@ -62,6 +80,7 @@ describe('with initialized pipeline', () => { beforeAll(async () => { installMockNavigatorGPU(); + await pipeline.initialize(device, new Vector2i(320, 240)); }); @@ -69,9 +88,11 @@ describe('with initialized pipeline', () => { uninstallMockNavigatorGPU(); }); - it('encodePass with empty buffer is a no-op', () => { + it('encodePass with an empty buffer is a no-op', () => { pipeline.reset(); + const renderPass = createMockRenderPassEncoder(); + expect(() => { pipeline.encodePass(renderPass); }).not.toThrow(); @@ -81,6 +102,7 @@ describe('with initialized pipeline', () => { pipeline.reset(); let drawCalled = false; + const renderPass = { ...createMockRenderPassEncoder(), draw: () => { @@ -89,6 +111,7 @@ describe('with initialized pipeline', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(drawCalled).toBe(false); }); @@ -105,9 +128,11 @@ describe('with initialized pipeline', () => { it('setCameraOffset affects subsequent state without error', () => { pipeline.reset(); pipeline.setCameraOffset(new Vector2i(100, 50)); + expect(() => { pipeline.encodePass(createMockRenderPassEncoder()); }).not.toThrow(); + pipeline.setCameraOffset(Vector2i.zero()); }); }); @@ -123,6 +148,7 @@ describe('drawSprite', () => { beforeAll(async () => { installMockNavigatorGPU(); + await pipeline.initialize(device, new Vector2i(320, 240)); }); @@ -132,7 +158,9 @@ describe('drawSprite', () => { it('does not throw with valid arguments', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + expect(() => { pipeline.drawSprite(sheet, new Rect2i(0, 0, 16, 16), new Vector2i(10, 20)); }).not.toThrow(); @@ -140,7 +168,9 @@ describe('drawSprite', () => { it('with explicit tint color does not throw', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + expect(() => { pipeline.drawSprite(sheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0), Color32.red()); }).not.toThrow(); @@ -148,7 +178,9 @@ describe('drawSprite', () => { it('with fully transparent tint does not throw', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + expect(() => { pipeline.drawSprite(sheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0), new Color32(255, 255, 255, 0)); }).not.toThrow(); @@ -156,10 +188,13 @@ describe('drawSprite', () => { it('causes a draw call in encodePass', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + pipeline.drawSprite(sheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0)); let drawCallCount = 0; + const renderPass = { ...createMockRenderPassEncoder(), draw: () => { @@ -168,17 +203,21 @@ describe('drawSprite', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(drawCallCount).toBe(1); }); it('multiple calls from the same sheet produce one draw call', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + pipeline.drawSprite(sheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0)); pipeline.drawSprite(sheet, new Rect2i(16, 0, 16, 16), new Vector2i(20, 0)); pipeline.drawSprite(sheet, new Rect2i(32, 0, 16, 16), new Vector2i(40, 0)); let drawCallCount = 0; + const renderPass = { ...createMockRenderPassEncoder(), draw: () => { @@ -187,17 +226,21 @@ describe('drawSprite', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(drawCallCount).toBe(1); }); it('calls from different sheets produce separate draw calls', () => { pipeline.reset(); + const sheetA = new SpriteSheet({ width: 64, height: 64 } as HTMLImageElement); const sheetB = new SpriteSheet({ width: 128, height: 128 } as HTMLImageElement); + pipeline.drawSprite(sheetA, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0)); pipeline.drawSprite(sheetB, new Rect2i(0, 0, 16, 16), new Vector2i(20, 0)); let drawCallCount = 0; + const renderPass = { ...createMockRenderPassEncoder(), draw: () => { @@ -206,18 +249,22 @@ describe('drawSprite', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(drawCallCount).toBe(2); }); it('interleaved sheets produce one draw call per texture switch', () => { pipeline.reset(); + const sheetA = new SpriteSheet({ width: 64, height: 64 } as HTMLImageElement); const sheetB = new SpriteSheet({ width: 128, height: 128 } as HTMLImageElement); + pipeline.drawSprite(sheetA, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0)); pipeline.drawSprite(sheetB, new Rect2i(0, 0, 16, 16), new Vector2i(20, 0)); pipeline.drawSprite(sheetA, new Rect2i(0, 0, 16, 16), new Vector2i(40, 0)); let drawCallCount = 0; + const renderPass = { ...createMockRenderPassEncoder(), draw: () => { @@ -226,16 +273,20 @@ describe('drawSprite', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(drawCallCount).toBe(3); }); it('after reset produces no draw calls', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + pipeline.drawSprite(sheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0)); pipeline.reset(); let drawCallCount = 0; + const renderPass = { ...createMockRenderPassEncoder(), draw: () => { @@ -244,23 +295,30 @@ describe('drawSprite', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(drawCallCount).toBe(0); }); it('with camera offset does not throw', () => { pipeline.reset(); + pipeline.setCameraOffset(new Vector2i(50, 50)); + const sheet = new SpriteSheet(mockImage); + expect(() => { pipeline.drawSprite(sheet, new Rect2i(0, 0, 16, 16), new Vector2i(10, 10)); pipeline.encodePass(createMockRenderPassEncoder()); }).not.toThrow(); + pipeline.setCameraOffset(Vector2i.zero()); }); it('a single-pixel srcRect does not throw', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + expect(() => { pipeline.drawSprite(sheet, new Rect2i(0, 0, 1, 1), new Vector2i(5, 5)); pipeline.encodePass(createMockRenderPassEncoder()); @@ -269,6 +327,7 @@ describe('drawSprite', () => { it('multiple frame cycles work without error', () => { const sheet = new SpriteSheet(mockImage); + expect(() => { for (let i = 0; i < 5; i++) { pipeline.reset(); @@ -280,18 +339,21 @@ describe('drawSprite', () => { it('buffer overflow triggers console.warn and does not crash', () => { pipeline.reset(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const sheet = new SpriteSheet(mockImage); try { - // MAX_SPRITE_VERTICES = 50000, each sprite = 6 vertices * 8 floats - // 50000 / 6 = ~8333 sprites max; draw more to trigger overflow + // MAX_SPRITE_VERTICES = 50,000, each sprite = 6 vertices * 8 floats + // 50,000 / 6 = ~8333 sprites max; draw more to trigger overflow for (let i = 0; i < 8400; i++) { pipeline.drawSprite(sheet, new Rect2i(0, 0, 8, 8), new Vector2i(0, 0)); } expect(warnSpy).toHaveBeenCalled(); + const msg = warnSpy.mock.calls.find((c) => String(c[0]).includes('capacity exceeded')); + expect(msg).toBeDefined(); expect(() => { @@ -332,6 +394,7 @@ describe('drawBitmapText', () => { beforeAll(async () => { installMockNavigatorGPU(); + await pipeline.initialize(device, new Vector2i(320, 240)); }); @@ -341,7 +404,9 @@ describe('drawBitmapText', () => { it('renders characters that have glyphs', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + const font = makeMockFont( { A: makeGlyph(0, 8, 0, 0, 9), @@ -354,6 +419,7 @@ describe('drawBitmapText', () => { let drawCallCount = 0; let totalVertices = 0; + const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { @@ -363,12 +429,14 @@ describe('drawBitmapText', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(drawCallCount).toBe(1); // Same texture = 1 batch expect(totalVertices).toBe(12); // 2 characters * 6 vertices }); it('silently skips characters without glyphs', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); const font = makeMockFont( { @@ -381,6 +449,7 @@ describe('drawBitmapText', () => { pipeline.drawBitmapText(font, new Vector2i(0, 0), 'AZA'); let totalVertices = 0; + const renderPass = { ...createMockRenderPassEncoder(), draw: (vertexCount: number) => { @@ -389,17 +458,20 @@ describe('drawBitmapText', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(totalVertices).toBe(12); // Only 2 'A' glyphs rendered }); it('empty string produces no draw calls', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); const font = makeMockFont({}, sheet); pipeline.drawBitmapText(font, new Vector2i(0, 0), ''); let drawCallCount = 0; + const renderPass = { ...createMockRenderPassEncoder(), draw: () => { @@ -408,12 +480,15 @@ describe('drawBitmapText', () => { } as unknown as GPURenderPassEncoder; pipeline.encodePass(renderPass); + expect(drawCallCount).toBe(0); }); it('applies custom tint color without error', () => { pipeline.reset(); + const sheet = new SpriteSheet(mockImage); + const font = makeMockFont( { X: makeGlyph(0, 8, 0, 0, 9), diff --git a/src/render/SpritePipeline.ts b/src/render/SpritePipeline.ts index d490544..a8e26cf 100644 --- a/src/render/SpritePipeline.ts +++ b/src/render/SpritePipeline.ts @@ -7,19 +7,17 @@ import { Vector2i } from '../utils/Vector2i'; // #region Configuration /** - * Maximum number of sprite vertices per a frame. - * Each vertex uses 8 floats (x, y, u, v, r, g, b, a). - * 50k vertices = ~1.6 MB buffer, supports ~8.3k sprites per a frame. + * Maximum number of sprite vertices retained for a frame. */ const MAX_SPRITE_VERTICES = 50000; // #endregion /** - * WebGPU sprite rendering pipeline. - * Handles textured quads (sprites, bitmap text) with tinting. - * Batches draws by texture to minimize GPU state changes. - * Vertices are accumulated per-frame and drawn at frame end via encodePass(). + * Batched WebGPU pipeline for textured quads. + * + * The pipeline collects sprite and bitmap-text vertices during a frame, groups + * them by texture, and emits one draw call per texture batch in `encodePass()`. */ export class SpritePipeline { // #region State @@ -55,7 +53,7 @@ export class SpritePipeline { /** Currently bound texture for sprite rendering. */ private currentTexture: GPUTexture | null = null; - /** Currently bound bind group for sprite rendering. */ + /** Currently bound group for sprite rendering. */ private currentBindGroup: GPUBindGroup | null = null; /** Cache of texture bind groups for reuse. WeakMap allows GC to collect destroyed textures. */ @@ -82,8 +80,8 @@ export class SpritePipeline { // #region Constructor /** - * Creates a new SpritePipeline. - * Call initialize() before any drawing operations. + * Creates an empty sprite pipeline. + * Call `initialize()` before encoding GPU work. */ constructor() { this.vertices = new Float32Array(MAX_SPRITE_VERTICES * 8); @@ -94,20 +92,132 @@ export class SpritePipeline { // #region Initialization /** - * Initializes the WebGPU pipeline, GPU buffers, and sampler. - * Must be called before any drawing operations. + * Initializes the GPU pipeline state, vertex buffer, and sampler. * * @param device - WebGPU device for GPU operations. * @param displaySize - Render target resolution in pixels. */ async initialize(device: GPUDevice, displaySize: Vector2i): Promise { this.device = device; + await this.createPipeline(displaySize); } + // #endregion + + // #region Camera + + /** + * Sets the camera offset applied to all drawing operations. + * + * @param offset - Camera position in pixels. + */ + setCameraOffset(offset: Vector2i): void { + this.cameraOffset = offset; + } + + /** + * Draws a sprite region from a sprite sheet. + * + * @param spriteSheet - Source sprite sheet. + * @param srcRect - Region to copy from the sprite sheet. + * @param destPos - Screen position to draw at. + * @param tint - Tint color multiplied with texture (defaults to white). + */ + drawSprite(spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, tint: Color32 = Color32.white()): void { + const texture = spriteSheet.getTexture(this.device as GPUDevice); + const uvs = spriteSheet.getUVs(srcRect); + + // Use a pre-allocated vector for size to avoid allocation. + this.tempSize.set(srcRect.width, srcRect.height); + + this.drawTexturedQuad(texture, destPos, this.tempSize, uvs.u0, uvs.v0, uvs.u1, uvs.v1, tint); + } + + /** + * Draws text using a bitmap font. + * Renders each character as a textured sprite using glyph metadata from the font. + * + * @param font - Bitmap font with character glyphs. + * @param pos - Text position (top-left corner). + * @param text - String to render. + * @param color - Text color multiplied with font texture. + */ + drawBitmapText(font: BitmapFont, pos: Vector2i, text: string, color: Color32 = Color32.white()): void { + const spriteSheet = font.getSpriteSheet(); + let cursorX = pos.x; + const len = text.length; + + // The optimized loop using charCodeAt and getGlyphByCode for ASCII fast-path. + for (let i = 0; i < len; i++) { + const code = text.charCodeAt(i); + const glyph = font.getGlyphByCode(code); + + if (glyph) { + // Use a pre-allocated vector to avoid allocation per character. + this.tempVec.set(cursorX + glyph.offsetX, pos.y + glyph.offsetY); + this.drawSprite(spriteSheet, glyph.rect, this.tempVec, color); + + cursorX += glyph.advance; + } + } + } + + // #endregion + + // #region Drawing + + /** + * Uploads accumulated sprite data and encodes draw calls for each texture batch. + * No-op when nothing has been queued for the current frame. + * + * @param renderPass - Active render pass encoder. + */ + encodePass(renderPass: GPURenderPassEncoder): void { + // Flush any remaining vertices into the batch queue. + this.flushCurrentBatch(); + + if (this.batches.length === 0 || this.totalVertices === 0) { + return; + } + + // Upload all sprite vertices at once. + // Safe assertions: these resources are created in initialize() before any rendering. + (this.device as GPUDevice).queue.writeBuffer( + this.vertexBuffer as GPUBuffer, + 0, + this.vertices.buffer, + 0, + this.totalVertices * 8 * 4, + ); + + renderPass.setPipeline(this.pipeline as GPURenderPipeline); + renderPass.setVertexBuffer(0, this.vertexBuffer as GPUBuffer); + + // Draw each batch with its own bind group (texture). + for (const batch of this.batches) { + renderPass.setBindGroup(0, batch.bindGroup); + renderPass.draw(batch.vertexCount, 1, batch.vertexStart, 0); + } + } + + // #endregion + + // #region Frame Rendering + + /** + * Clears all per-frame batching state. + */ + reset(): void { + this.vertexCount = 0; + this.totalVertices = 0; + this.batches = []; + this.currentTexture = null; + this.currentBindGroup = null; + } + /** - * Creates the WGSL shader, render pipeline, GPU buffers, and sampler for textured sprites. - * Vertices contain position, UV coordinates, and tint color. + * Creates shader modules, pipeline state, GPU buffers, and the sprite sampler. * * @param displaySize - Render target resolution in pixels. */ @@ -233,126 +343,12 @@ export class SpritePipeline { // #endregion - // #region Camera - - /** - * Sets the camera offset applied to all drawing operations. - * - * @param offset - Camera position in pixels. - */ - setCameraOffset(offset: Vector2i): void { - this.cameraOffset = offset; - } - - // #endregion - - // #region Drawing - - /** - * Draws a sprite region from a sprite sheet. - * - * @param spriteSheet - Source sprite sheet. - * @param srcRect - Region to copy from the sprite sheet. - * @param destPos - Screen position to draw at. - * @param tint - Tint color multiplied with texture (defaults to white). - */ - drawSprite(spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, tint: Color32 = Color32.white()): void { - const texture = spriteSheet.getTexture(this.device as GPUDevice); - const uvs = spriteSheet.getUVs(srcRect); - - // Use a pre-allocated vector for size to avoid allocation. - this.tempSize.set(srcRect.width, srcRect.height); - - this.drawTexturedQuad(texture, destPos, this.tempSize, uvs.u0, uvs.v0, uvs.u1, uvs.v1, tint); - } - - /** - * Draws text using a bitmap font. - * Renders each character as a textured sprite. - * - * @param font - Bitmap font with character glyphs. - * @param pos - Text position (top-left corner). - * @param text - String to render. - * @param color - Text color multiplied with font texture. - */ - drawBitmapText(font: BitmapFont, pos: Vector2i, text: string, color: Color32 = Color32.white()): void { - const spriteSheet = font.getSpriteSheet(); - let cursorX = pos.x; - const len = text.length; - - // The optimized loop using charCodeAt and getGlyphByCode for ASCII fast-path. - for (let i = 0; i < len; i++) { - const code = text.charCodeAt(i); - const glyph = font.getGlyphByCode(code); - - if (glyph) { - // Use a pre-allocated vector to avoid allocation per character. - this.tempVec.set(cursorX + glyph.offsetX, pos.y + glyph.offsetY); - this.drawSprite(spriteSheet, glyph.rect, this.tempVec, color); - cursorX += glyph.advance; - } - } - } - - // #endregion - - // #region Frame Rendering - - /** - * Encodes all accumulated sprite batches into the given render pass. - * Flushes the current batch, uploads all vertex data to GPU, and issues draw calls. - * No-op if no sprites were accumulated this frame. - * - * @param renderPass - Active render pass encoder. - */ - encodePass(renderPass: GPURenderPassEncoder): void { - // Flush any remaining vertices into the batch queue. - this.flushCurrentBatch(); - - if (this.batches.length === 0 || this.totalVertices === 0) { - return; - } - - // Upload all sprite vertices at once. - // Safe assertions: these resources are created in initialize() before any rendering. - (this.device as GPUDevice).queue.writeBuffer( - this.vertexBuffer as GPUBuffer, - 0, - this.vertices.buffer, - 0, - this.totalVertices * 8 * 4, - ); - - renderPass.setPipeline(this.pipeline as GPURenderPipeline); - renderPass.setVertexBuffer(0, this.vertexBuffer as GPUBuffer); - - // Draw each batch with its own bind group (texture). - for (const batch of this.batches) { - renderPass.setBindGroup(0, batch.bindGroup); - renderPass.draw(batch.vertexCount, 1, batch.vertexStart, 0); - } - } - - /** - * Resets all per-frame state. - * Call at the start and end of each frame. - */ - reset(): void { - this.vertexCount = 0; - this.totalVertices = 0; - this.batches = []; - this.currentTexture = null; - this.currentBindGroup = null; - } - - // #endregion - // #region Private Helpers /** * Draws a textured quad (two triangles) to the screen. - * Handles texture switching and batch flushing. - * Ensures complete quads are added atomically to prevent partial geometry. + * Handles texture switching and keeps quad emission atomic, so partial quads + * are never left in the frame buffer. * * @param texture - GPU texture to sample from. * @param pos - Screen position (top-left corner). @@ -427,12 +423,13 @@ export class SpritePipeline { private hasSpaceForQuad(): boolean { const index = (this.totalVertices + this.vertexCount) * 8; const quadSize = 6 * 8; // 6 vertices * 8 floats per vertex + return index + quadSize <= this.vertices.length; } /** * Saves the current sprite batch and prepares for a new texture. - * Called automatically when texture changes or at frame end. + * Called automatically when the active texture changes or before encoding. */ private flushCurrentBatch(): void { if (this.vertexCount === 0 || !this.currentBindGroup) { @@ -452,7 +449,7 @@ export class SpritePipeline { /** * Adds a sprite vertex to the batch. - * Assumes buffer space was pre-checked via hasSpaceForQuad() in drawTexturedQuad(). + * Assumes buffer space has already been reserved by `drawTexturedQuad()`. * * @param x - X position in pixels. * @param y - Y position in pixels. @@ -482,10 +479,10 @@ export class SpritePipeline { /** * Gets or creates a bind group for a texture. - * Bind groups are cached for reuse. + * Bind groups are cached per texture for reuse across frames. * * @param texture - GPU texture to create the bind group for. - * @returns Bind group containing uniform buffer, sampler and texture. + * @returns Bind group containing uniform buffer, sampler, and texture. */ private getOrCreateBindGroup(texture: GPUTexture): GPUBindGroup { const existingBindGroup = this.textureBindGroups.get(texture); -- 2.54.0 From abc8ae7121514d7abfa61c08d8ed45ce285fc39b Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 16:54:39 +0100 Subject: [PATCH 06/16] docs(utils): refine Bootstrap wording and add test module comment - Replace second-person voice in error messages with neutral third-person ("Your browser" -> "The browser", "your HTML" -> "the HTML") - Remove trailing exclamation mark from success log message - Improve @returns JSDoc on bootstrap() for precision - Add module-level JSDoc block to Bootstrap.test.ts describing covered startup scenarios - Add blank lines throughout Bootstrap.ts and test for readability Signed-off-by: Vaclav Vancura --- src/utils/Bootstrap.test.ts | 63 +++++++++++++++++++++++++++++++++++-- src/utils/Bootstrap.ts | 16 +++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/utils/Bootstrap.test.ts b/src/utils/Bootstrap.test.ts index d6d5948..54ee2df 100644 --- a/src/utils/Bootstrap.test.ts +++ b/src/utils/Bootstrap.test.ts @@ -1,5 +1,20 @@ // @vitest-environment happy-dom +/** + * Unit tests for {@link bootstrap}. + * + * Covers the DOM-facing startup flow: + * - optional waiting for `DOMContentLoaded` + * - WebGPU support validation and canvas lookup failures + * - success and failure paths around `BTAPI.initialize()` + * - default and custom canvas/container identifiers + * - propagation of success and error callbacks + * + * The suite uses a minimal happy-dom document plus a mocked `BTAPI` + * initialization path so bootstrap behavior can be validated without running + * the full engine. + */ + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BTAPI } from '../core/BTAPI'; @@ -20,6 +35,7 @@ class MockDemo implements IBlitTechDemo { } update() {} + render() {} } @@ -29,11 +45,15 @@ class MockDemo implements IBlitTechDemo { function setupDOM(): { canvas: HTMLCanvasElement; container: HTMLDivElement } { const container = document.createElement('div'); + container.id = DEFAULT_CONTAINER_ID; + document.body.appendChild(container); const canvas = document.createElement('canvas'); + canvas.id = DEFAULT_CANVAS_ID; + document.body.appendChild(canvas); return { canvas, container }; @@ -47,11 +67,13 @@ function teardownDOM(): void { function stubWebGPU(): void { const nav = navigator as unknown as Record; + nav.gpu = {}; } function unstubWebGPU(): void { const nav = navigator as unknown as Record; + delete nav.gpu; } @@ -65,7 +87,9 @@ describe('bootstrap', () => { afterEach(() => { vi.restoreAllMocks(); + unstubWebGPU(); + teardownDOM(); }); @@ -74,29 +98,39 @@ describe('bootstrap', () => { describe('waitForDOMReady', () => { it('should skip DOM waiting when waitForDOMReady is false', async () => { setupDOM(); + stubWebGPU(); + const result = await bootstrap(MockDemo, { waitForDOMReady: false }); + expect(result).toBe(true); }); it('should resolve after DOMContentLoaded when readyState is loading', async () => { Object.defineProperty(document, 'readyState', { value: 'loading', writable: true, configurable: true }); + setupDOM(); + stubWebGPU(); const bootstrapPromise = bootstrap(MockDemo, { waitForDOMReady: true }); Object.defineProperty(document, 'readyState', { value: 'complete', writable: true, configurable: true }); + document.dispatchEvent(new Event('DOMContentLoaded')); const result = await bootstrapPromise; + expect(result).toBe(true); }); - it('should proceed immediately when readyState is complete', async () => { + it('should proceed immediately when the readyState is complete', async () => { setupDOM(); + stubWebGPU(); + const result = await bootstrap(MockDemo, { waitForDOMReady: true }); + expect(result).toBe(true); }); }); @@ -108,9 +142,11 @@ describe('bootstrap', () => { describe('WebGPU validation', () => { it('should return false and call onError when WebGPU is not supported', async () => { setupDOM(); + // No navigator.gpu installed. const onError = vi.fn(); const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); + expect(result).toBe(false); expect(onError).toHaveBeenCalledOnce(); }); @@ -123,17 +159,22 @@ describe('bootstrap', () => { describe('canvas validation', () => { it('should return false and call onError when canvas is not found', async () => { stubWebGPU(); + // No canvas in DOM — only the container. const container = document.createElement('div'); + container.id = DEFAULT_CONTAINER_ID; + document.body.appendChild(container); const onError = vi.fn(); + const result = await bootstrap(MockDemo, { waitForDOMReady: false, canvasId: 'nonexistent-canvas', onError, }); + expect(result).toBe(false); expect(onError).toHaveBeenCalledOnce(); }); @@ -146,32 +187,40 @@ describe('bootstrap', () => { describe('engine initialization', () => { it('should return false and call onError when BTAPI.initialize returns false', async () => { setupDOM(); + stubWebGPU(); + vi.spyOn(BTAPI.instance, 'initialize').mockResolvedValue(false); const onError = vi.fn(); const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); + expect(result).toBe(false); expect(onError).toHaveBeenCalledOnce(); }); it('should return false and call onError when BTAPI.initialize throws', async () => { setupDOM(); + stubWebGPU(); + vi.spyOn(BTAPI.instance, 'initialize').mockRejectedValue(new Error('Init exploded')); const onError = vi.fn(); const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); + expect(result).toBe(false); expect(onError).toHaveBeenCalledOnce(); }); it('should return true and call onSuccess on successful initialization', async () => { setupDOM(); + stubWebGPU(); const onSuccess = vi.fn(); const result = await bootstrap(MockDemo, { waitForDOMReady: false, onSuccess }); + expect(result).toBe(true); expect(onSuccess).toHaveBeenCalledOnce(); }); @@ -179,25 +228,33 @@ describe('bootstrap', () => { it('should use default canvas and container IDs when not specified', async () => { setupDOM(); stubWebGPU(); + const result = await bootstrap(MockDemo, { waitForDOMReady: false }); + expect(result).toBe(true); }); it('should use custom canvas and container IDs when specified', async () => { const container = document.createElement('div'); + container.id = 'custom-container'; + document.body.appendChild(container); const canvas = document.createElement('canvas'); + canvas.id = 'custom-canvas'; + document.body.appendChild(canvas); stubWebGPU(); + const result = await bootstrap(MockDemo, { waitForDOMReady: false, canvasId: 'custom-canvas', containerId: 'custom-container', }); + expect(result).toBe(true); }); }); @@ -209,14 +266,16 @@ describe('bootstrap', () => { describe('unexpected errors', () => { it('should return false when BTAPI.initialize throws synchronously', async () => { setupDOM(); + stubWebGPU(); vi.spyOn(BTAPI.instance, 'initialize').mockImplementation(() => { - throw new Error('Synchronous throw from initialize'); + throw new Error('The synchronous throw from initialize'); }); const onError = vi.fn(); const result = await bootstrap(MockDemo, { waitForDOMReady: false, onError }); + expect(result).toBe(false); expect(onError).toHaveBeenCalledOnce(); }); diff --git a/src/utils/Bootstrap.ts b/src/utils/Bootstrap.ts index cb61f73..6458d21 100644 --- a/src/utils/Bootstrap.ts +++ b/src/utils/Bootstrap.ts @@ -66,12 +66,12 @@ interface BootstrapResult { /** Error message for WebGPU not supported. */ const WEBGPU_NOT_SUPPORTED_MESSAGE = - 'Your browser does not support WebGPU.\n\n' + + 'The browser does not support WebGPU.\n\n' + 'Supported browsers:\n' + 'Chrome/Edge 113+\n' + 'Firefox Nightly (with the flag enabled)\n' + 'Safari 18+\n\n' + - 'Please update your browser or try a different one.'; + 'Please update the browser or try a different one.'; /** Error message for initialization failure. */ const INIT_FAILED_MESSAGE = 'The engine failed to initialize. Check the console for details.'; @@ -162,7 +162,7 @@ function validateCanvas( result = handleBootstrapError( 'Canvas Error', `Failed to find the canvas element with the id '${canvasId}'.\n\n` + - 'Make sure your HTML includes a canvas element with the correct ID.', + 'Make sure the HTML includes a canvas element with the correct ID.', new Error(`Canvas element '${canvasId}' not found`), containerId, onError, @@ -204,8 +204,10 @@ async function initializeDemo( let result: BootstrapResult; if (initialized) { - console.log('[BT] Demo started successfully!'); + console.log('[BT] Demo started successfully'); + onSuccess?.(); + result = { success: true }; } else { const displayMessage: ErrorContent = initError @@ -237,7 +239,7 @@ async function initializeDemo( * * @param DemoClass - Demo class constructor implementing IBlitTechDemo. * @param options - Optional configuration for IDs and callbacks. - * @returns Promise resolving to true if initialization succeeded, false otherwise. + * @returns `true` when the demo boots successfully; otherwise `false`. * * @example * // Simplest usage - uses default IDs. @@ -278,21 +280,25 @@ export async function bootstrap(DemoClass: DemoConstructor, options: BootstrapOp try { // Validate WebGPU support. const webGPUResult = validateWebGPU(containerId, onError); + success = webGPUResult.success; // Validate canvas element. if (success) { const { canvas, result: canvasResult } = validateCanvas(canvasId, containerId, onError); + success = canvasResult.success; // Initialize the demo. if (success && canvas) { const initResult = await initializeDemo(DemoClass, canvas, containerId, onSuccess, onError); + success = initResult.success; } } } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); + console.error('[BT] Bootstrap error:', error); handleBootstrapError( -- 2.54.0 From 127112edd7bc3baacb986623fecb65d620d26db1 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 17:17:18 +0100 Subject: [PATCH 07/16] refactor(utils): simplify Color32 docs and remove redundant bitwise truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim JSDoc to concise summaries — remove verbose @example blocks, inline performance annotations, and redundant class descriptions. Remove `| 0` integer truncation from lerp, lerpInPlace, multiply, multiplyInPlace, and premultiplyAlpha since channel values are already integers or the callers rely on clamping at constructor time. Drop the `>>> 0` unsigned-right-shift workaround in fromUint32 test. Add file-level JSDoc and blank-line spacing between arrange/act/assert steps throughout the test file. Signed-off-by: Vaclav Vancura --- src/utils/Color32.test.ts | 117 ++++++++++++++++++++++++++++++++++---- src/utils/Color32.ts | 90 ++++++++++++----------------- 2 files changed, 142 insertions(+), 65 deletions(-) diff --git a/src/utils/Color32.test.ts b/src/utils/Color32.test.ts index d9def03..cd4c111 100644 --- a/src/utils/Color32.test.ts +++ b/src/utils/Color32.test.ts @@ -1,3 +1,11 @@ +/** + * Unit tests for {@link Color32} and its small helper exports. + * + * Covers byte clamping, constructor normalization, shared color singletons, + * factory methods, packing and unpacking, hex parsing, arithmetic helpers, + * interpolation, cloning, equality, and normalized float conversion behavior. + */ + import { describe, expect, it } from 'vitest'; import { clampByte, Color32, INV_255 } from './Color32'; @@ -35,14 +43,16 @@ describe('clampByte', () => { describe('Color32 constructor', () => { it('defaults to white (255, 255, 255, 255)', () => { const c = new Color32(); + expect(c.r).toBe(255); expect(c.g).toBe(255); expect(c.b).toBe(255); expect(c.a).toBe(255); }); - it('clamps values outside 0-255 range', () => { + it('clamps values outside the 0-255 range', () => { const c = new Color32(-10, 300, 128, 500); + expect(c.r).toBe(0); expect(c.g).toBe(255); expect(c.b).toBe(128); @@ -51,6 +61,7 @@ describe('Color32 constructor', () => { it('truncates float values to integers', () => { const c = new Color32(100.9, 200.1, 50.5, 128.7); + expect(c.r).toBe(100); expect(c.g).toBe(200); expect(c.b).toBe(50); @@ -65,6 +76,7 @@ describe('Color32 constructor', () => { describe('static color getters', () => { it('white() returns (255, 255, 255, 255)', () => { const w = Color32.white(); + expect(w.r).toBe(255); expect(w.g).toBe(255); expect(w.b).toBe(255); @@ -73,6 +85,7 @@ describe('static color getters', () => { it('black() returns (0, 0, 0, 255)', () => { const b = Color32.black(); + expect(b.r).toBe(0); expect(b.g).toBe(0); expect(b.b).toBe(0); @@ -81,6 +94,7 @@ describe('static color getters', () => { it('transparent() returns (0, 0, 0, 0)', () => { const t = Color32.transparent(); + expect(t.r).toBe(0); expect(t.g).toBe(0); expect(t.b).toBe(0); @@ -89,6 +103,7 @@ describe('static color getters', () => { it('red() returns (255, 0, 0, 255)', () => { const c = Color32.red(); + expect(c.r).toBe(255); expect(c.g).toBe(0); expect(c.b).toBe(0); @@ -97,6 +112,7 @@ describe('static color getters', () => { it('green() returns (0, 255, 0, 255)', () => { const c = Color32.green(); + expect(c.r).toBe(0); expect(c.g).toBe(255); expect(c.b).toBe(0); @@ -105,6 +121,7 @@ describe('static color getters', () => { it('blue() returns (0, 0, 255, 255)', () => { const c = Color32.blue(); + expect(c.r).toBe(0); expect(c.g).toBe(0); expect(c.b).toBe(255); @@ -113,6 +130,7 @@ describe('static color getters', () => { it('yellow() returns (255, 255, 0, 255)', () => { const c = Color32.yellow(); + expect(c.r).toBe(255); expect(c.g).toBe(255); expect(c.b).toBe(0); @@ -121,6 +139,7 @@ describe('static color getters', () => { it('cyan() returns (0, 255, 255, 255)', () => { const c = Color32.cyan(); + expect(c.r).toBe(0); expect(c.g).toBe(255); expect(c.b).toBe(255); @@ -129,6 +148,7 @@ describe('static color getters', () => { it('magenta() returns (255, 0, 255, 255)', () => { const c = Color32.magenta(); + expect(c.r).toBe(255); expect(c.g).toBe(0); expect(c.b).toBe(255); @@ -161,6 +181,7 @@ describe('static color getters', () => { it('gray(128) returns (128, 128, 128, 255)', () => { const g = Color32.gray(128); + expect(g.r).toBe(128); expect(g.g).toBe(128); expect(g.b).toBe(128); @@ -169,6 +190,7 @@ describe('static color getters', () => { it('gray(0) returns black-like gray', () => { const g = Color32.gray(0); + expect(g.r).toBe(0); expect(g.g).toBe(0); expect(g.b).toBe(0); @@ -183,6 +205,7 @@ describe('static color getters', () => { describe('fromRGBAUnchecked', () => { it('creates color without clamping', () => { const c = Color32.fromRGBAUnchecked(100, 150, 200, 250); + expect(c.r).toBe(100); expect(c.g).toBe(150); expect(c.b).toBe(200); @@ -191,6 +214,7 @@ describe('fromRGBAUnchecked', () => { it('does not clamp out-of-range values', () => { const c = Color32.fromRGBAUnchecked(300, -10, 999, 0); + expect(c.r).toBe(300); expect(c.g).toBe(-10); expect(c.b).toBe(999); @@ -201,8 +225,9 @@ describe('fromRGBAUnchecked', () => { describe('fromUint32', () => { it('unpacks ABGR correctly', () => { // ABGR: A=255, B=0, G=0, R=255 -> opaque red - const packed = (255 << 24) | (0 << 16) | (0 << 8) | 255; - const c = Color32.fromUint32(packed >>> 0); + const packed = (255 << 24) | 255; + const c = Color32.fromUint32(packed); + expect(c.r).toBe(255); expect(c.g).toBe(0); expect(c.b).toBe(0); @@ -213,6 +238,7 @@ describe('fromUint32', () => { const original = new Color32(64, 128, 192, 240); const packed = original.toUint32(); const unpacked = Color32.fromUint32(packed); + expect(unpacked.r).toBe(64); expect(unpacked.g).toBe(128); expect(unpacked.b).toBe(192); @@ -231,6 +257,7 @@ describe('fromHex', () => { it('parses #RGBA shorthand', () => { const c = Color32.fromHex('#F008'); + expect(c.r).toBe(255); expect(c.g).toBe(0); expect(c.b).toBe(0); @@ -239,6 +266,7 @@ describe('fromHex', () => { it('parses #RRGGBB format', () => { const c = Color32.fromHex('#1A2B3C'); + expect(c.r).toBe(0x1a); expect(c.g).toBe(0x2b); expect(c.b).toBe(0x3c); @@ -247,6 +275,7 @@ describe('fromHex', () => { it('parses #RRGGBBAA format', () => { const c = Color32.fromHex('#1A2B3C80'); + expect(c.r).toBe(0x1a); expect(c.g).toBe(0x2b); expect(c.b).toBe(0x3c); @@ -255,6 +284,7 @@ describe('fromHex', () => { it('parses without leading #', () => { const c = Color32.fromHex('FF8000'); + expect(c.r).toBe(255); expect(c.g).toBe(128); expect(c.b).toBe(0); @@ -271,6 +301,7 @@ describe('fromHex', () => { describe('fromFloat', () => { it('converts normalized floats to byte values', () => { const c = Color32.fromFloat(1.0, 0.5, 0.0); + expect(c.r).toBe(255); expect(c.g).toBe(127); expect(c.b).toBe(0); @@ -279,12 +310,14 @@ describe('fromFloat', () => { it('supports explicit alpha', () => { const c = Color32.fromFloat(0.0, 0.0, 0.0, 0.5); + expect(c.r).toBe(0); expect(c.a).toBe(127); }); it('clamps values outside 0.0-1.0 via constructor', () => { const c = Color32.fromFloat(2.0, -1.0, 0.5); + expect(c.r).toBe(255); expect(c.g).toBe(0); expect(c.b).toBe(127); @@ -294,6 +327,7 @@ describe('fromFloat', () => { describe('fromHSL', () => { it('converts red (0, 100, 50) to (255, 0, 0, 255)', () => { const c = Color32.fromHSL(0, 100, 50); + expect(c.r).toBe(255); expect(c.g).toBe(0); expect(c.b).toBe(0); @@ -302,6 +336,7 @@ describe('fromHSL', () => { it('converts green (120, 100, 50) to (0, 255, 0, 255)', () => { const c = Color32.fromHSL(120, 100, 50); + expect(c.r).toBe(0); expect(c.g).toBe(255); expect(c.b).toBe(0); @@ -310,6 +345,7 @@ describe('fromHSL', () => { it('converts gray (0, 0, 50) to ~(127, 127, 127, 255)', () => { const c = Color32.fromHSL(0, 0, 50); + expect(c.r).toBe(127); expect(c.g).toBe(127); expect(c.b).toBe(127); @@ -318,6 +354,7 @@ describe('fromHSL', () => { it('supports custom alpha', () => { const c = Color32.fromHSL(0, 100, 50, 128); + expect(c.r).toBe(255); expect(c.a).toBe(128); }); @@ -331,6 +368,7 @@ describe('toFloat32Array', () => { it('returns 4-element Float32Array with values in [0, 1]', () => { const c = new Color32(255, 0, 128, 255); const arr = c.toFloat32Array(); + expect(arr).toBeInstanceOf(Float32Array); expect(arr.length).toBe(4); expect(arr[0]).toBeCloseTo(1.0, 3); @@ -342,6 +380,7 @@ describe('toFloat32Array', () => { it('returns all zeros for transparent black', () => { const c = new Color32(0, 0, 0, 0); const arr = c.toFloat32Array(); + expect(arr[0]).toBeCloseTo(0.0, 5); expect(arr[1]).toBeCloseTo(0.0, 5); expect(arr[2]).toBeCloseTo(0.0, 5); @@ -353,17 +392,21 @@ describe('writeToFloat32Array', () => { it('writes normalized values at default offset 0', () => { const c = new Color32(255, 128, 0, 255); const buf = new Float32Array(4); + c.writeToFloat32Array(buf); + expect(buf[0]).toBeCloseTo(1.0, 3); expect(buf[1]).toBeCloseTo(128 / 255, 3); expect(buf[2]).toBeCloseTo(0.0, 3); expect(buf[3]).toBeCloseTo(1.0, 3); }); - it('writes at specified offset', () => { + it('writes at the specified offset', () => { const buf = new Float32Array(8); const c = new Color32(64, 128, 192, 255); + c.writeToFloat32Array(buf, 4); + expect(buf[0]).toBe(0); expect(buf[4]).toBeCloseTo(64 / 255, 3); expect(buf[5]).toBeCloseTo(128 / 255, 3); @@ -373,9 +416,10 @@ describe('writeToFloat32Array', () => { }); describe('toFloatRGBA', () => { - it('returns object with normalized r, g, b, a', () => { + it('returns the object with normalized r, g, b, a', () => { const c = new Color32(255, 0, 128, 64); const f = c.toFloatRGBA(); + expect(f.r).toBeCloseTo(1.0, 3); expect(f.g).toBeCloseTo(0.0, 3); expect(f.b).toBeCloseTo(128 / 255, 3); @@ -384,6 +428,7 @@ describe('toFloatRGBA', () => { it('returns all ones for white', () => { const f = new Color32(255, 255, 255, 255).toFloatRGBA(); + expect(f.r).toBeCloseTo(1.0, 5); expect(f.g).toBeCloseTo(1.0, 5); expect(f.b).toBeCloseTo(1.0, 5); @@ -395,6 +440,7 @@ describe('toUint32', () => { it('packs in ABGR format', () => { const c = new Color32(255, 0, 0, 255); const packed = c.toUint32(); + expect(packed & 0xff).toBe(255); // R expect((packed >>> 8) & 0xff).toBe(0); // G expect((packed >>> 16) & 0xff).toBe(0); // B @@ -404,6 +450,7 @@ describe('toUint32', () => { it('roundtrips with fromUint32', () => { const c = new Color32(12, 34, 56, 78); const roundtripped = Color32.fromUint32(c.toUint32()); + expect(roundtripped.r).toBe(12); expect(roundtripped.g).toBe(34); expect(roundtripped.b).toBe(56); @@ -414,16 +461,19 @@ describe('toUint32', () => { describe('toHex', () => { it('returns #rrggbbaa format', () => { const c = new Color32(255, 128, 0, 255); + expect(c.toHex()).toBe('#ff8000ff'); }); it('pads single-digit hex values with zero', () => { const c = new Color32(0, 0, 0, 0); + expect(c.toHex()).toBe('#00000000'); }); it('handles white correctly', () => { const c = new Color32(255, 255, 255, 255); + expect(c.toHex()).toBe('#ffffffff'); }); }); @@ -431,17 +481,20 @@ describe('toHex', () => { describe('toCSS', () => { it('returns rgba() string with alpha as 0.0-1.0', () => { const c = new Color32(255, 128, 0, 255); + expect(c.toCSS()).toBe('rgba(255, 128, 0, 1.000)'); }); it('formats zero alpha correctly', () => { const c = new Color32(0, 0, 0, 0); + expect(c.toCSS()).toBe('rgba(0, 0, 0, 0.000)'); }); it('formats half alpha correctly', () => { const c = new Color32(100, 200, 50, 128); const css = c.toCSS(); + expect(css).toMatch(/^rgba\(100, 200, 50, 0\.50[0-9]\)$/); }); }); @@ -449,6 +502,7 @@ describe('toCSS', () => { describe('toString', () => { it('returns Color32(r, g, b, a) format', () => { const c = new Color32(10, 20, 30, 40); + expect(c.toString()).toBe('Color32(10, 20, 30, 40)'); }); @@ -465,17 +519,20 @@ describe('equals', () => { it('returns true for identical colors', () => { const a = new Color32(10, 20, 30, 40); const b = new Color32(10, 20, 30, 40); + expect(a.equals(b)).toBe(true); }); it('returns false for different colors', () => { const a = new Color32(10, 20, 30, 40); const b = new Color32(10, 20, 30, 41); + expect(a.equals(b)).toBe(false); }); it('returns false for null or undefined', () => { const a = new Color32(10, 20, 30, 40); + expect(a.equals(null as unknown as Color32)).toBe(false); expect(a.equals(undefined as unknown as Color32)).toBe(false); }); @@ -489,16 +546,19 @@ describe('clone', () => { it('creates an independent copy', () => { const original = new Color32(10, 20, 30, 40); const copy = original.clone(); + expect(copy.r).toBe(10); expect(copy.g).toBe(20); expect(copy.b).toBe(30); expect(copy.a).toBe(40); }); - it('modifying clone does not affect original', () => { + it("modifying clone doesn't affect the original", () => { const original = new Color32(10, 20, 30, 40); const copy = original.clone(); + copy.r = 255; + expect(original.r).toBe(10); }); }); @@ -507,6 +567,7 @@ describe('withAlpha', () => { it('changes alpha only, preserving RGB', () => { const c = new Color32(10, 20, 30, 255); const result = c.withAlpha(128); + expect(result.r).toBe(10); expect(result.g).toBe(20); expect(result.b).toBe(30); @@ -515,12 +576,14 @@ describe('withAlpha', () => { it('clamps alpha value', () => { const c = new Color32(10, 20, 30, 255); + expect(c.withAlpha(-10).a).toBe(0); expect(c.withAlpha(300).a).toBe(255); }); - it('does not modify original', () => { + it('does not modify the original', () => { const c = new Color32(10, 20, 30, 255); + c.withAlpha(0); expect(c.a).toBe(255); }); @@ -530,6 +593,7 @@ describe('withRGB', () => { it('changes RGB and keeps alpha', () => { const c = new Color32(10, 20, 30, 128); const result = c.withRGB(100, 200, 50); + expect(result.r).toBe(100); expect(result.g).toBe(200); expect(result.b).toBe(50); @@ -539,6 +603,7 @@ describe('withRGB', () => { it('clamps RGB values via constructor', () => { const c = new Color32(10, 20, 30, 128); const result = c.withRGB(-5, 300, 100); + expect(result.r).toBe(0); expect(result.g).toBe(255); expect(result.b).toBe(100); @@ -549,6 +614,7 @@ describe('invert', () => { it('inverts RGB channels (255 - value), keeps alpha', () => { const c = new Color32(0, 100, 255, 128); const inv = c.invert(); + expect(inv.r).toBe(255); expect(inv.g).toBe(155); expect(inv.b).toBe(0); @@ -558,6 +624,7 @@ describe('invert', () => { it('double invert returns original values', () => { const c = new Color32(42, 99, 200, 180); const doubleInv = c.invert().invert(); + expect(doubleInv.r).toBe(42); expect(doubleInv.g).toBe(99); expect(doubleInv.b).toBe(200); @@ -573,7 +640,9 @@ describe('lerp', () => { it('returns this color at t=0', () => { const a = new Color32(0, 0, 0, 255); const b = new Color32(255, 255, 255, 255); + const result = a.lerp(b, 0); + expect(result.r).toBe(0); expect(result.g).toBe(0); expect(result.b).toBe(0); @@ -583,6 +652,7 @@ describe('lerp', () => { const a = new Color32(0, 0, 0, 255); const b = new Color32(255, 255, 255, 255); const result = a.lerp(b, 1); + expect(result.r).toBe(255); expect(result.g).toBe(255); expect(result.b).toBe(255); @@ -592,6 +662,7 @@ describe('lerp', () => { const a = new Color32(0, 0, 0, 0); const b = new Color32(200, 100, 50, 254); const result = a.lerp(b, 0.5); + expect(result.r).toBe(100); expect(result.g).toBe(50); expect(result.b).toBe(25); @@ -601,6 +672,7 @@ describe('lerp', () => { it('clamps t outside [0, 1]', () => { const a = new Color32(100, 100, 100, 255); const b = new Color32(200, 200, 200, 255); + expect(a.lerp(b, -5).r).toBe(100); expect(a.lerp(b, 10).r).toBe(200); }); @@ -611,6 +683,7 @@ describe('lerpInPlace', () => { const a = new Color32(0, 0, 0, 0); const b = new Color32(200, 100, 50, 254); const returned = a.lerpInPlace(b, 0.5); + expect(returned).toBe(a); expect(a.r).toBe(100); expect(a.g).toBe(50); @@ -621,6 +694,7 @@ describe('lerpInPlace', () => { it('at t=0 keeps original values', () => { const a = new Color32(42, 84, 126, 200); a.lerpInPlace(new Color32(255, 255, 255, 255), 0); + expect(a.r).toBe(42); expect(a.g).toBe(84); expect(a.b).toBe(126); @@ -633,15 +707,17 @@ describe('multiply', () => { const a = new Color32(255, 128, 0, 255); const b = new Color32(128, 255, 255, 255); const result = a.multiply(b); + // 255 * 128 / 255 = 128, 128 * 255 / 255 = 128, 0 * 255 / 255 = 0 expect(result.r).toBe(128); expect(result.g).toBe(128); expect(result.b).toBe(0); }); - it('multiplying by white returns same color', () => { + it('multiplying by white returns the same color', () => { const c = new Color32(100, 150, 200, 255); const result = c.multiply(new Color32(255, 255, 255, 255)); + expect(result.r).toBe(100); expect(result.g).toBe(150); expect(result.b).toBe(200); @@ -650,6 +726,7 @@ describe('multiply', () => { it('multiplying by black returns black', () => { const c = new Color32(100, 150, 200, 255); const result = c.multiply(new Color32(0, 0, 0, 255)); + expect(result.r).toBe(0); expect(result.g).toBe(0); expect(result.b).toBe(0); @@ -661,6 +738,7 @@ describe('multiplyInPlace', () => { const a = new Color32(255, 128, 64, 255); const b = new Color32(128, 128, 128, 255); const returned = a.multiplyInPlace(b); + expect(returned).toBe(a); expect(a.r).toBe(128); expect(a.g).toBe(64); @@ -673,15 +751,17 @@ describe('add', () => { const a = new Color32(200, 100, 50, 200); const b = new Color32(100, 200, 50, 100); const result = a.add(b); + expect(result.r).toBe(255); // 200 + 100 = 300, clamped expect(result.g).toBe(255); // 100 + 200 = 300, clamped expect(result.b).toBe(100); // 50 + 50 expect(result.a).toBe(255); // 200 + 100 = 300, clamped }); - it('adding black returns same color', () => { + it('adding black returns the same color', () => { const c = new Color32(42, 84, 126, 200); const result = c.add(new Color32(0, 0, 0, 0)); + expect(result.r).toBe(42); expect(result.g).toBe(84); expect(result.b).toBe(126); @@ -694,6 +774,7 @@ describe('addInPlace', () => { const a = new Color32(200, 100, 50, 128); const b = new Color32(100, 50, 25, 64); const returned = a.addInPlace(b); + expect(returned).toBe(a); expect(a.r).toBe(255); // clamped expect(a.g).toBe(150); @@ -706,6 +787,7 @@ describe('premultiplyAlpha', () => { it('multiplies RGB by alpha/255', () => { const c = new Color32(200, 100, 50, 128); const pm = c.premultiplyAlpha(); + // 200 * (128/255) ~= 100, 100 * (128/255) ~= 50, 50 * (128/255) ~= 25 expect(pm.r).toBe(100); expect(pm.g).toBe(50); @@ -716,6 +798,7 @@ describe('premultiplyAlpha', () => { it('fully opaque color is unchanged', () => { const c = new Color32(100, 150, 200, 255); const pm = c.premultiplyAlpha(); + expect(pm.r).toBe(100); expect(pm.g).toBe(150); expect(pm.b).toBe(200); @@ -724,6 +807,7 @@ describe('premultiplyAlpha', () => { it('fully transparent color zeroes RGB', () => { const c = new Color32(200, 150, 100, 0); const pm = c.premultiplyAlpha(); + expect(pm.r).toBe(0); expect(pm.g).toBe(0); expect(pm.b).toBe(0); @@ -739,6 +823,7 @@ describe('setRGBA', () => { it('sets all channels with clamping and returns this', () => { const c = new Color32(0, 0, 0, 0); const returned = c.setRGBA(300, -10, 128, 200); + expect(returned).toBe(c); expect(c.r).toBe(255); expect(c.g).toBe(0); @@ -748,7 +833,9 @@ describe('setRGBA', () => { it('truncates floats', () => { const c = new Color32(); + c.setRGBA(100.9, 50.1, 200.7, 128.3); + expect(c.r).toBe(100); expect(c.g).toBe(50); expect(c.b).toBe(200); @@ -760,6 +847,7 @@ describe('setRGBAUnchecked', () => { it('sets all channels without clamping and returns this', () => { const c = new Color32(); const returned = c.setRGBAUnchecked(10, 20, 30, 40); + expect(returned).toBe(c); expect(c.r).toBe(10); expect(c.g).toBe(20); @@ -769,7 +857,9 @@ describe('setRGBAUnchecked', () => { it('does not clamp invalid values', () => { const c = new Color32(); + c.setRGBAUnchecked(300, -5, 1000, 0); + expect(c.r).toBe(300); expect(c.g).toBe(-5); }); @@ -780,6 +870,7 @@ describe('copyFrom', () => { const source = new Color32(11, 22, 33, 44); const target = new Color32(); const returned = target.copyFrom(source); + expect(returned).toBe(target); expect(target.r).toBe(11); expect(target.g).toBe(22); @@ -787,11 +878,14 @@ describe('copyFrom', () => { expect(target.a).toBe(44); }); - it('is independent after copy (changing source does not affect target)', () => { + it("is independent after the copy (changing source doesn't affect target)", () => { const source = new Color32(11, 22, 33, 44); const target = new Color32(); + target.copyFrom(source); + source.r = 255; + expect(target.r).toBe(11); }); }); @@ -803,11 +897,13 @@ describe('copyFrom', () => { describe('luminance', () => { it('returns approximately 1.0 for white', () => { const c = new Color32(255, 255, 255, 255); + expect(c.luminance()).toBeCloseTo(1.0, 3); }); it('returns approximately 0.0 for black', () => { const c = new Color32(0, 0, 0, 255); + expect(c.luminance()).toBeCloseTo(0.0, 5); }); @@ -815,6 +911,7 @@ describe('luminance', () => { const r = new Color32(255, 0, 0, 255).luminance(); const g = new Color32(0, 255, 0, 255).luminance(); const b = new Color32(0, 0, 255, 255).luminance(); + // Green should have the highest luminance contribution expect(g).toBeGreaterThan(r); expect(g).toBeGreaterThan(b); diff --git a/src/utils/Color32.ts b/src/utils/Color32.ts index 6846765..d8a2bf6 100644 --- a/src/utils/Color32.ts +++ b/src/utils/Color32.ts @@ -1,27 +1,18 @@ /** - * 32-bit RGBA color with 8-bit channels (0-255). - * Inspired by RetroBlit's Color32. + * 32-bit RGBA color with 8-bit channels. * - * Performance notes: - * - Use static color constants like white or black—they return cached singletons. - * - Use fromRGBAUnchecked() for trusted values in hot paths to skip validation. - * - Use writeToFloat32Array() to write to pre-allocated buffers instead of allocating. + * The type provides cached singleton colors, packing and conversion helpers, + * and a mix of allocation-free and convenience APIs for renderer code. */ // #region Constants -/** - * Reciprocal of 255 for fast normalization (1/255). - * Multiplication is faster than division in most JavaScript engines. - * Exported for use in other color-related utilities. - */ +/** Reciprocal of 255 used for fast byte-to-float normalization. */ export const INV_255 = 1 / 255; -/** - * Pre-computed hex lookup table for fast byte-to-hex conversion. - * Trades ~2 KB memory for faster hex string generation. - */ +/** Precomputed lookup table for byte-to-hex conversion. */ const HEX_TABLE: string[] = new Array(256); + for (let i = 0; i < 256; i++) { // eslint-disable-next-line security/detect-object-injection HEX_TABLE[i] = i.toString(16).padStart(2, '0'); @@ -31,10 +22,7 @@ for (let i = 0; i < 256; i++) { // #region Color32 Class -/** - * 32-bit RGBA color with 8-bit channels (0-255). - * Provides comprehensive color manipulation and conversion utilities. - */ +/** Mutable 32-bit RGBA color value with 8-bit channels. */ export class Color32 { // #region Static Color Constants @@ -86,8 +74,7 @@ export class Color32 { // #region Constructor /** - * Creates a new 32-bit RGBA color. - * All values are clamped to 0-255 range and truncated to integers. + * Creates a clamped 8-bit RGBA color. * * @param r - Red channel (0-255, defaults to 255). * @param g - Green channel (0-255, defaults to 255). @@ -211,11 +198,10 @@ export class Color32 { // #region Static Factory Methods /** - * Creates a Color32 from RGBA values without validation or clamping. - * Use this in hot paths when values are guaranteed to be valid integers 0-255. + * Creates a color without clamping or validation. * - * WARNING: Passing invalid values will result in undefined behavior. - * Only use when certain the values are valid. + * Intended for trusted hot paths where channel values are already known to + * be valid byte-range numbers. * * @param r - Red channel (must be integer 0-255). * @param g - Green channel (must be integer 0-255). @@ -225,6 +211,7 @@ export class Color32 { */ static fromRGBAUnchecked(r: number, g: number, b: number, a: number): Color32 { const color = Object.create(Color32.prototype) as Color32; + color.r = r; color.g = g; color.b = b; @@ -250,8 +237,8 @@ export class Color32 { } /** - * Parses a CSS hex color string into a Color32. - * Supports formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA. + * Parses a CSS-style hex color string. + * Supports `#RGB`, `#RGBA`, `#RRGGBB`, and `#RRGGBBAA`. * * @param hex - Hex color string with or without leading #. * @returns Parsed color. @@ -317,8 +304,7 @@ export class Color32 { } /** - * Creates a color from normalized float values (0.0-1.0 range). - * Useful when working with shader outputs or HDR values. + * Creates a color from normalized float components in the `0.0-1.0` range. * * @param r - Red channel (0.0-1.0). * @param g - Green channel (0.0-1.0). @@ -331,8 +317,7 @@ export class Color32 { } /** - * Creates a color from HSL (Hue, Saturation, Lightness) values. - * Useful for generating colors based on color-wheel positions. + * Creates a color from HSL values. * * @param h - Hue in degrees (0-360). * @param s - Saturation as percentage (0-100). @@ -416,7 +401,7 @@ export class Color32 { writeToFloat32Array(target: Float32Array, offset: number = 0): void { // Using direct index assignment for the best performance. // This is safe: offset is truncated to integer, the target is a typed Float32Array. - const i = offset | 0; + const i = offset; // eslint-disable-next-line security/detect-object-injection target[i] = this.r * INV_255; @@ -570,10 +555,10 @@ export class Color32 { const oneMinusT = 1 - tc; return Color32.fromRGBAUnchecked( - (this.r * oneMinusT + other.r * tc) | 0, - (this.g * oneMinusT + other.g * tc) | 0, - (this.b * oneMinusT + other.b * tc) | 0, - (this.a * oneMinusT + other.a * tc) | 0, + this.r * oneMinusT + other.r * tc, + this.g * oneMinusT + other.g * tc, + this.b * oneMinusT + other.b * tc, + this.a * oneMinusT + other.a * tc, ); } @@ -590,10 +575,10 @@ export class Color32 { const oneMinusT = 1 - tc; - this.r = (this.r * oneMinusT + other.r * tc) | 0; - this.g = (this.g * oneMinusT + other.g * tc) | 0; - this.b = (this.b * oneMinusT + other.b * tc) | 0; - this.a = (this.a * oneMinusT + other.a * tc) | 0; + this.r = this.r * oneMinusT + other.r * tc; + this.g = this.g * oneMinusT + other.g * tc; + this.b = this.b * oneMinusT + other.b * tc; + this.a = this.a * oneMinusT + other.a * tc; return this; } @@ -607,10 +592,10 @@ export class Color32 { */ multiply(other: Color32): Color32 { return Color32.fromRGBAUnchecked( - (this.r * other.r * INV_255) | 0, - (this.g * other.g * INV_255) | 0, - (this.b * other.b * INV_255) | 0, - (this.a * other.a * INV_255) | 0, + this.r * other.r * INV_255, + this.g * other.g * INV_255, + this.b * other.b * INV_255, + this.a * other.a * INV_255, ); } @@ -622,10 +607,10 @@ export class Color32 { * @returns This color instance for chaining. */ multiplyInPlace(other: Color32): this { - this.r = (this.r * other.r * INV_255) | 0; - this.g = (this.g * other.g * INV_255) | 0; - this.b = (this.b * other.b * INV_255) | 0; - this.a = (this.a * other.a * INV_255) | 0; + this.r = this.r * other.r * INV_255; + this.g = this.g * other.g * INV_255; + this.b = this.b * other.b * INV_255; + this.a = this.a * other.a * INV_255; return this; } @@ -666,12 +651,7 @@ export class Color32 { premultiplyAlpha(): Color32 { const alphaMul = this.a * INV_255; - return Color32.fromRGBAUnchecked( - (this.r * alphaMul) | 0, - (this.g * alphaMul) | 0, - (this.b * alphaMul) | 0, - this.a, - ); + return Color32.fromRGBAUnchecked(this.r * alphaMul, this.g * alphaMul, this.b * alphaMul, this.a); } // #endregion @@ -767,7 +747,7 @@ export class Color32 { export function clampByte(n: number): number { // Bitwise operations: if n < 0, use 0; if n > 255, use 255; else truncate n. // This is faster than Math.max(0, Math.min(255, n)) | 0. - return n < 0 ? 0 : n > 255 ? 255 : n | 0; + return n < 0 ? 0 : n > 255 ? 255 : n; } // #endregion -- 2.54.0 From e4ff88d72d1ae101c978925067f376faa9b98239 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 17:24:00 +0100 Subject: [PATCH 08/16] refactor(utils): tighten FrameCapture docs and add test spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite module, class, and method JSDoc for concision — shorter summaries, drop redundant @param/@returns lines where the signature is self-evident, and convert the module header to a two-sentence description. Add file-level JSDoc and blank-line spacing between arrange/act/assert steps in the test file. Minor test description wording tweaks for grammar consistency. Signed-off-by: Vaclav Vancura --- src/utils/FrameCapture.test.ts | 22 ++++++++++--- src/utils/FrameCapture.ts | 58 +++++++++++++++++----------------- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/src/utils/FrameCapture.test.ts b/src/utils/FrameCapture.test.ts index d1af8fb..2eb8398 100644 --- a/src/utils/FrameCapture.test.ts +++ b/src/utils/FrameCapture.test.ts @@ -1,3 +1,11 @@ +/** + * Unit tests for {@link FrameCapture} and its supporting pixel helpers. + * + * Covers row-alignment math, BGRA-to-RGBA swizzling, pending-capture state + * management, texture-to-buffer copy encoding, and PNG conversion using stubbed + * browser-only APIs such as `ImageData` and `OffscreenCanvas`. + */ + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockGPUDevice, createMockGPUTexture } from '../__test__/webgpu-mock'; @@ -33,6 +41,7 @@ function installBrowserMocks(): void { getContext(): { putImageData: ReturnType } { return { putImageData: vi.fn() }; } + async convertToBlob(): Promise { return mockBlob; } @@ -106,13 +115,14 @@ describe('swizzleBGRAtoRGBA', () => { // Pixel 1: RGBA (blue channel stays, red channel swapped) expect(data[0]).toBe(0); // R (was B=255, now R from position 2) expect(data[2]).toBe(255); // B (was R=0, now B from position 0) + // Pixel 2: green channel unchanged expect(data[4]).toBe(0); expect(data[5]).toBe(255); expect(data[6]).toBe(0); }); - it('should handle empty array', () => { + it('should handle an empty array', () => { const data = new Uint8ClampedArray([]); swizzleBGRAtoRGBA(data); @@ -140,7 +150,7 @@ describe('FrameCapture', () => { expect(capture.hasPendingCapture()).toBe(false); }); - it('should set pending flag after requestCapture', () => { + it('should set a pending flag after requestCapture', () => { const capture = new FrameCapture(); // Don't await -- just queue the request. @@ -149,7 +159,7 @@ describe('FrameCapture', () => { expect(capture.hasPendingCapture()).toBe(true); }); - it('should reject previous capture when a new one is requested', async () => { + it('should reject the previous capture when a new one is requested', async () => { const capture = new FrameCapture(); const firstCapture = capture.requestCapture(); @@ -159,7 +169,7 @@ describe('FrameCapture', () => { await expect(firstCapture).rejects.toThrow('superseded'); }); - it('should clear pending flag after resolveCapture', async () => { + it('should clear the pending flag after resolveCapture', async () => { const capture = new FrameCapture(); void capture.requestCapture(); @@ -249,8 +259,10 @@ describe('FrameCapture', () => { // Override createBuffer to return a buffer with our pixel data. const originalCreateBuffer = device.createBuffer.bind(device); + vi.spyOn(device, 'createBuffer').mockImplementation((desc: GPUBufferDescriptor) => { const buf = originalCreateBuffer(desc); + (buf as unknown as Record).getMappedRange = () => pixelBuffer; return buf; @@ -285,6 +297,7 @@ describe('FrameCapture', () => { getContext(): { putImageData: ReturnType } { return { putImageData: vi.fn() }; } + async convertToBlob(): Promise { return new Blob(['png'], { type: 'image/png' }); } @@ -302,6 +315,7 @@ describe('FrameCapture', () => { for (let y = 0; y < 4; y++) { const offset = y * 4 * 4; // 4 pixels per row, 4 bytes per pixel (no padding in output) + // eslint-disable-next-line security/detect-object-injection -- typed array indexed by loop counter expect(pixels[offset]).toBe(30); // R (was B=10, swapped with R=30) expect(pixels[offset + 1]).toBe(20); // G (unchanged) diff --git a/src/utils/FrameCapture.ts b/src/utils/FrameCapture.ts index ad26e57..8afc041 100644 --- a/src/utils/FrameCapture.ts +++ b/src/utils/FrameCapture.ts @@ -1,7 +1,8 @@ /** - * GPU frame capture utility for Blit-Tech. - * Captures the rendered frame from a WebGPU texture to a PNG blob - * using GPU readback (copyTextureToBuffer + mapAsync). + * Utilities for capturing a rendered WebGPU frame as a PNG blob. + * + * The module handles row alignment, optional BGRA swizzling, GPU readback, and + * browser-side PNG encoding. */ // #region Constants @@ -27,11 +28,9 @@ type CaptureReject = (reason: Error) => void; // #region Helper Functions /** - * Calculates the aligned bytes-per-row for a given pixel width. - * WebGPU requires `bytesPerRow` in copyTextureToBuffer to be a multiple of 256. - * - * @param width - Width in pixels. - * @returns Aligned bytes per row. + * Calculates the `bytesPerRow` value required by `copyTextureToBuffer()`. + * @param width + * @returns Aligned bytes per row (multiple of 256). */ export function alignedBytesPerRow(width: number): number { const unaligned = width * BYTES_PER_PIXEL; @@ -40,10 +39,8 @@ export function alignedBytesPerRow(width: number): number { } /** - * Swizzles BGRA pixel data to RGBA in place. - * Many platforms use `bgra8unorm` as the preferred canvas format. - * - * @param data - Pixel data buffer (modified in place). + * Swaps BGRA pixel data into RGBA order in place. + * @param data */ export function swizzleBGRAtoRGBA(data: Uint8ClampedArray): void { for (let i = 0; i < data.length; i += BYTES_PER_PIXEL) { @@ -58,14 +55,15 @@ export function swizzleBGRAtoRGBA(data: Uint8ClampedArray): void { } /** - * Converts raw pixel data from a GPU readback buffer into a PNG Blob. - * Handles row padding removal and BGRA-to-RGBA swizzle. + * Converts mapped GPU pixel data into a PNG blob. * - * @param buffer - Mapped GPU buffer containing pixel data. - * @param width - Image width in pixels. - * @param height - Image height in pixels. - * @param paddedBytesPerRow - Aligned bytes per row (may include padding). - * @param isBGRA - Whether the source format is BGRA (needs swizzle). + * Removes row padding introduced by WebGPU alignment requirements and + * optionally swizzles BGRA input into RGBA before encoding. + * @param buffer + * @param width + * @param height + * @param paddedBytesPerRow + * @param isBGRA * @returns Promise resolving to a PNG Blob. */ export async function pixelBufferToPNG( @@ -109,9 +107,11 @@ export async function pixelBufferToPNG( // #region FrameCapture Class /** - * Manages deferred frame capture from a WebGPU render target. - * Call `requestCapture()` to queue a capture, then integrate with the - * renderer's `endFrame()` to execute the GPU readback. + * Coordinates deferred capture of the next rendered frame. + * + * A capture request is queued ahead of frame submission, the renderer records a + * texture-to-buffer copy during `endFrame()`, and the captured data is resolved + * asynchronously after GPU work completes. */ export class FrameCapture { /** Resolve callback for the pending capture. */ @@ -145,14 +145,15 @@ export class FrameCapture { } /** - * Queues a capture for the next rendered frame. - * If a capture is already pending, the previous one is rejected. + * Queues capture of the next submitted frame. + * If another request is already pending, that earlier request is rejected. * * @returns Promise resolving to the captured PNG Blob. */ requestCapture(): Promise { if (this.pendingResolve) { this.pendingReject?.(new Error('[FrameCapture] Capture superseded by a new request')); + this.cleanup(); } @@ -163,8 +164,8 @@ export class FrameCapture { } /** - * Adds a texture-to-buffer copy command to the given command encoder. - * Must be called after the render pass ends but before `device.queue.submit()`. + * Records the texture-to-buffer copy needed for a pending frame capture. + * Call after the render pass ends but before submitting the command buffer. * * @param device - WebGPU device for buffer creation. * @param texture - The rendered canvas texture to capture. @@ -192,8 +193,7 @@ export class FrameCapture { } /** - * Maps the staging buffer, converts pixels to PNG, and resolves the pending promise. - * Call this after `device.queue.submit()`. This is async but does not block the game loop. + * Waits for GPU completion, reads back the staging buffer, and resolves the pending capture. * * @param device - WebGPU device (used for onSubmittedWorkDone). */ @@ -233,7 +233,7 @@ export class FrameCapture { } } - /** Cleans up pending state without resolving or rejecting. */ + /** Clears the pending capture state and destroys any staging buffer. */ private cleanup(): void { this.pendingResolve = null; this.pendingReject = null; -- 2.54.0 From bc245b88be5e1c4bcfabb5c59dd567ff179ebec1 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 17:34:50 +0100 Subject: [PATCH 09/16] refactor(utils): simplify Rect2i docs, reorganize statics, and add test spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim JSDoc throughout Rect2i to single-line summaries — drop hot-path notes, allocation warnings, and per-getter @returns lines. Rewrite the class-level doc to describe use cases rather than implementation details. Move static factory methods (zero, fromMinMax, fromMinMaxXY, fromCenter, fromValuesUnchecked) into the zero-allocation section to co-locate with the *To() helpers. Add file-level JSDoc and blank-line spacing between arrange/act/assert steps across the full test file. Signed-off-by: Vaclav Vancura --- src/utils/Rect2i.test.ts | 141 ++++++++++++++++- src/utils/Rect2i.ts | 334 ++++++++++++++++++--------------------- 2 files changed, 290 insertions(+), 185 deletions(-) diff --git a/src/utils/Rect2i.test.ts b/src/utils/Rect2i.test.ts index 7d97cb4..1186627 100644 --- a/src/utils/Rect2i.test.ts +++ b/src/utils/Rect2i.test.ts @@ -1,3 +1,11 @@ +/** + * Unit tests for {@link Rect2i}. + * + * Exercises integer construction, derived edge and center properties, + * allocation-free output helpers, geometric queries, mutation helpers, and the + * rectangle arithmetic used across rendering and collision code. + */ + import { describe, expect, it } from 'vitest'; import { Rect2i } from './Rect2i'; @@ -9,6 +17,7 @@ describe('Rect2i', () => { describe('constructor', () => { it('should default to (0, 0, 0, 0)', () => { const r = new Rect2i(); + expect(r.x).toBe(0); expect(r.y).toBe(0); expect(r.width).toBe(0); @@ -17,6 +26,7 @@ describe('Rect2i', () => { it('should pass through integer values unchanged', () => { const r = new Rect2i(10, 20, 100, 200); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(100); @@ -25,6 +35,7 @@ describe('Rect2i', () => { it('should truncate positive floats toward zero', () => { const r = new Rect2i(1.9, 2.7, 3.1, 4.99); + expect(r.x).toBe(1); expect(r.y).toBe(2); expect(r.width).toBe(3); @@ -33,6 +44,7 @@ describe('Rect2i', () => { it('should truncate negative floats toward zero', () => { const r = new Rect2i(-1.7, -2.9, -3.1, -4.5); + expect(r.x).toBe(-1); expect(r.y).toBe(-2); expect(r.width).toBe(-3); @@ -47,11 +59,13 @@ describe('Rect2i', () => { describe('right', () => { it('should return x + width', () => { const r = new Rect2i(10, 20, 100, 50); + expect(r.right).toBe(110); }); it('should handle negative x', () => { const r = new Rect2i(-5, 0, 20, 10); + expect(r.right).toBe(15); }); }); @@ -59,11 +73,13 @@ describe('Rect2i', () => { describe('bottom', () => { it('should return y + height', () => { const r = new Rect2i(10, 20, 100, 50); + expect(r.bottom).toBe(70); }); it('should handle negative y', () => { const r = new Rect2i(0, -10, 20, 30); + expect(r.bottom).toBe(20); }); }); @@ -71,16 +87,19 @@ describe('Rect2i', () => { describe('centerX', () => { it('should return truncated center for even width', () => { const r = new Rect2i(0, 0, 100, 50); + expect(r.centerX).toBe(50); }); it('should truncate toward zero for odd width', () => { const r = new Rect2i(0, 0, 11, 10); + expect(r.centerX).toBe(5); }); it('should handle offset position', () => { const r = new Rect2i(10, 0, 20, 10); + expect(r.centerX).toBe(20); }); }); @@ -88,16 +107,19 @@ describe('Rect2i', () => { describe('centerY', () => { it('should return truncated center for even height', () => { const r = new Rect2i(0, 0, 50, 100); + expect(r.centerY).toBe(50); }); it('should truncate toward zero for odd height', () => { const r = new Rect2i(0, 0, 10, 11); + expect(r.centerY).toBe(5); }); it('should handle offset position', () => { const r = new Rect2i(0, 10, 10, 20); + expect(r.centerY).toBe(20); }); }); @@ -110,14 +132,17 @@ describe('Rect2i', () => { it('should return Vector2i(x, y)', () => { const r = new Rect2i(5, 10, 100, 200); const m = r.min; + expect(m.x).toBe(5); expect(m.y).toBe(10); }); it('should return a new Vector2i instance each time', () => { const r = new Rect2i(5, 10, 100, 200); + const m1 = r.min; const m2 = r.min; + expect(m1).not.toBe(m2); expect(m1.x).toBe(m2.x); expect(m1.y).toBe(m2.y); @@ -128,6 +153,7 @@ describe('Rect2i', () => { it('should return Vector2i(x + width, y + height)', () => { const r = new Rect2i(5, 10, 100, 200); const m = r.max; + expect(m.x).toBe(105); expect(m.y).toBe(210); }); @@ -135,6 +161,7 @@ describe('Rect2i', () => { it('should handle zero-size rect', () => { const r = new Rect2i(5, 10, 0, 0); const m = r.max; + expect(m.x).toBe(5); expect(m.y).toBe(10); }); @@ -144,6 +171,7 @@ describe('Rect2i', () => { it('should return Vector2i at the center', () => { const r = new Rect2i(0, 0, 100, 200); const c = r.center; + expect(c.x).toBe(50); expect(c.y).toBe(100); }); @@ -151,6 +179,7 @@ describe('Rect2i', () => { it('should truncate toward zero for odd dimensions', () => { const r = new Rect2i(0, 0, 11, 13); const c = r.center; + expect(c.x).toBe(5); expect(c.y).toBe(6); }); @@ -158,6 +187,7 @@ describe('Rect2i', () => { it('should account for position offset', () => { const r = new Rect2i(10, 20, 100, 200); const c = r.center; + expect(c.x).toBe(60); expect(c.y).toBe(120); }); @@ -167,12 +197,14 @@ describe('Rect2i', () => { it('should return Vector2i(x, y)', () => { const r = new Rect2i(15, 25, 100, 200); const p = r.position; + expect(p.x).toBe(15); expect(p.y).toBe(25); }); it('should create a new Vector2i each time', () => { const r = new Rect2i(15, 25, 100, 200); + expect(r.position).not.toBe(r.position); }); }); @@ -180,7 +212,9 @@ describe('Rect2i', () => { describe('position setter', () => { it('should set x and y from a vector', () => { const r = new Rect2i(0, 0, 100, 200); + r.position = new Vector2i(10, 20); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(100); @@ -189,7 +223,9 @@ describe('Rect2i', () => { it('should truncate to integer', () => { const r = new Rect2i(0, 0, 100, 200); + r.position = Vector2i.fromXYUnchecked(1.9, 2.7); + expect(r.x).toBe(1); expect(r.y).toBe(2); }); @@ -199,12 +235,14 @@ describe('Rect2i', () => { it('should return Vector2i(width, height)', () => { const r = new Rect2i(15, 25, 100, 200); const s = r.size; + expect(s.x).toBe(100); expect(s.y).toBe(200); }); it('should create a new Vector2i each time', () => { const r = new Rect2i(15, 25, 100, 200); + expect(r.size).not.toBe(r.size); }); }); @@ -213,6 +251,7 @@ describe('Rect2i', () => { it('should set width and height from a vector', () => { const r = new Rect2i(10, 20, 0, 0); r.size = new Vector2i(50, 60); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(50); @@ -221,7 +260,9 @@ describe('Rect2i', () => { it('should truncate to integer', () => { const r = new Rect2i(0, 0, 0, 0); + r.size = Vector2i.fromXYUnchecked(7.8, 9.3); + expect(r.width).toBe(7); expect(r.height).toBe(9); }); @@ -235,7 +276,9 @@ describe('Rect2i', () => { it('should write min to the output vector', () => { const r = new Rect2i(5, 10, 100, 200); const out = new Vector2i(); + r.minTo(out); + expect(out.x).toBe(5); expect(out.y).toBe(10); }); @@ -244,6 +287,7 @@ describe('Rect2i', () => { const r = new Rect2i(5, 10, 100, 200); const out = new Vector2i(); const result = r.minTo(out); + expect(result).toBe(out); }); }); @@ -252,7 +296,9 @@ describe('Rect2i', () => { it('should write max to the output vector', () => { const r = new Rect2i(5, 10, 100, 200); const out = new Vector2i(); + r.maxTo(out); + expect(out.x).toBe(105); expect(out.y).toBe(210); }); @@ -260,6 +306,7 @@ describe('Rect2i', () => { it('should return the output vector for chaining', () => { const r = new Rect2i(5, 10, 100, 200); const out = new Vector2i(); + expect(r.maxTo(out)).toBe(out); }); }); @@ -268,7 +315,9 @@ describe('Rect2i', () => { it('should write center to the output vector', () => { const r = new Rect2i(0, 0, 100, 200); const out = new Vector2i(); + r.centerTo(out); + expect(out.x).toBe(50); expect(out.y).toBe(100); }); @@ -276,7 +325,9 @@ describe('Rect2i', () => { it('should truncate toward zero for odd dimensions', () => { const r = new Rect2i(0, 0, 11, 13); const out = new Vector2i(); + r.centerTo(out); + expect(out.x).toBe(5); expect(out.y).toBe(6); }); @@ -284,6 +335,7 @@ describe('Rect2i', () => { it('should return the output vector for chaining', () => { const r = new Rect2i(0, 0, 100, 200); const out = new Vector2i(); + expect(r.centerTo(out)).toBe(out); }); }); @@ -292,7 +344,9 @@ describe('Rect2i', () => { it('should write position to the output vector', () => { const r = new Rect2i(15, 25, 100, 200); const out = new Vector2i(); + r.positionTo(out); + expect(out.x).toBe(15); expect(out.y).toBe(25); }); @@ -300,6 +354,7 @@ describe('Rect2i', () => { it('should return the output vector for chaining', () => { const r = new Rect2i(15, 25, 100, 200); const out = new Vector2i(); + expect(r.positionTo(out)).toBe(out); }); }); @@ -308,7 +363,9 @@ describe('Rect2i', () => { it('should write size to the output vector', () => { const r = new Rect2i(15, 25, 100, 200); const out = new Vector2i(); + r.sizeTo(out); + expect(out.x).toBe(100); expect(out.y).toBe(200); }); @@ -316,6 +373,7 @@ describe('Rect2i', () => { it('should return the output vector for chaining', () => { const r = new Rect2i(15, 25, 100, 200); const out = new Vector2i(); + expect(r.sizeTo(out)).toBe(out); }); }); @@ -327,16 +385,19 @@ describe('Rect2i', () => { describe('contains', () => { it('should return true for a point inside', () => { const r = new Rect2i(0, 0, 100, 100); + expect(r.contains(new Vector2i(50, 50))).toBe(true); }); it('should return true for a point on the min edge (inclusive)', () => { const r = new Rect2i(10, 20, 100, 100); + expect(r.contains(new Vector2i(10, 20))).toBe(true); }); it('should return false for a point on the max edge (exclusive)', () => { const r = new Rect2i(10, 20, 100, 100); + expect(r.contains(new Vector2i(110, 120))).toBe(false); expect(r.contains(new Vector2i(110, 50))).toBe(false); expect(r.contains(new Vector2i(50, 120))).toBe(false); @@ -344,6 +405,7 @@ describe('Rect2i', () => { it('should return false for a point outside', () => { const r = new Rect2i(10, 20, 100, 100); + expect(r.contains(new Vector2i(0, 0))).toBe(false); expect(r.contains(new Vector2i(200, 200))).toBe(false); }); @@ -352,21 +414,25 @@ describe('Rect2i', () => { describe('containsXY', () => { it('should return true for a point inside', () => { const r = new Rect2i(0, 0, 100, 100); + expect(r.containsXY(50, 50)).toBe(true); }); it('should return true for min edge (inclusive)', () => { const r = new Rect2i(10, 20, 100, 100); + expect(r.containsXY(10, 20)).toBe(true); }); it('should return false for max edge (exclusive)', () => { const r = new Rect2i(10, 20, 100, 100); + expect(r.containsXY(110, 120)).toBe(false); }); it('should return false for a point outside', () => { const r = new Rect2i(10, 20, 100, 100); + expect(r.containsXY(0, 0)).toBe(false); }); }); @@ -375,6 +441,7 @@ describe('Rect2i', () => { it('should return true for overlapping rects', () => { const a = new Rect2i(0, 0, 100, 100); const b = new Rect2i(50, 50, 100, 100); + expect(a.intersects(b)).toBe(true); expect(b.intersects(a)).toBe(true); }); @@ -382,12 +449,14 @@ describe('Rect2i', () => { it('should return false for adjacent rects (no overlap)', () => { const a = new Rect2i(0, 0, 100, 100); const b = new Rect2i(100, 0, 100, 100); + expect(a.intersects(b)).toBe(false); }); it('should return true when one rect is fully inside another', () => { const outer = new Rect2i(0, 0, 100, 100); const inner = new Rect2i(20, 20, 10, 10); + expect(outer.intersects(inner)).toBe(true); expect(inner.intersects(outer)).toBe(true); }); @@ -395,6 +464,7 @@ describe('Rect2i', () => { it('should return false for non-overlapping rects', () => { const a = new Rect2i(0, 0, 10, 10); const b = new Rect2i(50, 50, 10, 10); + expect(a.intersects(b)).toBe(false); }); }); @@ -404,6 +474,7 @@ describe('Rect2i', () => { const a = new Rect2i(0, 0, 100, 100); const b = new Rect2i(50, 50, 100, 100); const result = a.intersection(b); + expect(result).not.toBeNull(); expect(result!.x).toBe(50); expect(result!.y).toBe(50); @@ -421,6 +492,7 @@ describe('Rect2i', () => { const outer = new Rect2i(0, 0, 100, 100); const inner = new Rect2i(20, 30, 10, 15); const result = outer.intersection(inner); + expect(result).not.toBeNull(); expect(result!.x).toBe(20); expect(result!.y).toBe(30); @@ -435,6 +507,7 @@ describe('Rect2i', () => { const b = new Rect2i(50, 50, 100, 100); const out = new Rect2i(); const result = a.intersectionTo(b, out); + expect(result).toBe(true); expect(out.x).toBe(50); expect(out.y).toBe(50); @@ -447,6 +520,7 @@ describe('Rect2i', () => { const b = new Rect2i(20, 20, 10, 10); const out = new Rect2i(99, 99, 99, 99); const result = a.intersectionTo(b, out); + expect(result).toBe(false); expect(out.x).toBe(99); expect(out.y).toBe(99); @@ -460,6 +534,7 @@ describe('Rect2i', () => { const a = new Rect2i(0, 0, 100, 100); const b = new Rect2i(80, 70, 100, 100); const depth = a.intersectionDepth(b); + expect(depth.x).toBe(20); expect(depth.y).toBe(30); }); @@ -468,6 +543,7 @@ describe('Rect2i', () => { const a = new Rect2i(80, 70, 100, 100); const b = new Rect2i(0, 0, 100, 100); const depth = a.intersectionDepth(b); + expect(depth.x).toBe(20); expect(depth.y).toBe(30); }); @@ -478,7 +554,9 @@ describe('Rect2i', () => { const a = new Rect2i(0, 0, 100, 100); const b = new Rect2i(80, 70, 100, 100); const out = new Vector2i(); + a.intersectionDepthTo(b, out); + expect(out.x).toBe(20); expect(out.y).toBe(30); }); @@ -487,6 +565,7 @@ describe('Rect2i', () => { const a = new Rect2i(0, 0, 100, 100); const b = new Rect2i(80, 70, 100, 100); const out = new Vector2i(); + expect(a.intersectionDepthTo(b, out)).toBe(out); }); }); @@ -499,11 +578,13 @@ describe('Rect2i', () => { it('should return true for identical rects', () => { const a = new Rect2i(10, 20, 30, 40); const b = new Rect2i(10, 20, 30, 40); + expect(a.equals(b)).toBe(true); }); it('should return false when any component differs', () => { const base = new Rect2i(10, 20, 30, 40); + expect(base.equals(new Rect2i(99, 20, 30, 40))).toBe(false); expect(base.equals(new Rect2i(10, 99, 30, 40))).toBe(false); expect(base.equals(new Rect2i(10, 20, 99, 40))).toBe(false); @@ -515,6 +596,7 @@ describe('Rect2i', () => { it('should create an independent copy', () => { const original = new Rect2i(10, 20, 30, 40); const copy = original.clone(); + expect(copy.x).toBe(10); expect(copy.y).toBe(20); expect(copy.width).toBe(30); @@ -525,7 +607,9 @@ describe('Rect2i', () => { it('should not be affected by changes to the original', () => { const original = new Rect2i(10, 20, 30, 40); const copy = original.clone(); + original.x = 999; + expect(copy.x).toBe(10); }); }); @@ -544,6 +628,7 @@ describe('Rect2i', () => { it('should return the output rect for chaining', () => { const source = new Rect2i(10, 20, 30, 40); const out = new Rect2i(); + expect(source.cloneTo(out)).toBe(out); }); }); @@ -551,16 +636,19 @@ describe('Rect2i', () => { describe('toString', () => { it('should format as Rect2i(x, y, width, height)', () => { const r = new Rect2i(10, 20, 30, 40); + expect(r.toString()).toBe('Rect2i(10, 20, 30, 40)'); }); it('should handle negative values', () => { const r = new Rect2i(-5, -10, 20, 30); + expect(r.toString()).toBe('Rect2i(-5, -10, 20, 30)'); }); it('should handle zero rect', () => { const r = new Rect2i(); + expect(r.toString()).toBe('Rect2i(0, 0, 0, 0)'); }); }); @@ -572,7 +660,9 @@ describe('Rect2i', () => { describe('set', () => { it('should set all components', () => { const r = new Rect2i(); + r.set(10, 20, 30, 40); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(30); @@ -581,7 +671,9 @@ describe('Rect2i', () => { it('should truncate floats toward zero', () => { const r = new Rect2i(); + r.set(1.9, -2.7, 3.1, 4.99); + expect(r.x).toBe(1); expect(r.y).toBe(-2); expect(r.width).toBe(3); @@ -591,6 +683,7 @@ describe('Rect2i', () => { it('should return this for chaining', () => { const r = new Rect2i(); const result = r.set(10, 20, 30, 40); + expect(result).toBe(r); }); }); @@ -598,7 +691,9 @@ describe('Rect2i', () => { describe('setPosition', () => { it('should set x and y only', () => { const r = new Rect2i(0, 0, 100, 200); + r.setPosition(10, 20); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(100); @@ -607,13 +702,16 @@ describe('Rect2i', () => { it('should truncate floats', () => { const r = new Rect2i(); + r.setPosition(1.7, -2.3); + expect(r.x).toBe(1); expect(r.y).toBe(-2); }); it('should return this for chaining', () => { const r = new Rect2i(); + expect(r.setPosition(10, 20)).toBe(r); }); }); @@ -621,7 +719,9 @@ describe('Rect2i', () => { describe('setSize', () => { it('should set width and height only', () => { const r = new Rect2i(10, 20, 0, 0); + r.setSize(100, 200); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(100); @@ -630,13 +730,16 @@ describe('Rect2i', () => { it('should truncate floats', () => { const r = new Rect2i(); + r.setSize(5.9, 6.1); + expect(r.width).toBe(5); expect(r.height).toBe(6); }); it('should return this for chaining', () => { const r = new Rect2i(); + expect(r.setSize(100, 200)).toBe(r); }); }); @@ -645,7 +748,9 @@ describe('Rect2i', () => { it('should copy all values from another rect', () => { const source = new Rect2i(10, 20, 30, 40); const target = new Rect2i(); + target.copyFrom(source); + expect(target.x).toBe(10); expect(target.y).toBe(20); expect(target.width).toBe(30); @@ -654,13 +759,16 @@ describe('Rect2i', () => { it('should return this for chaining', () => { const r = new Rect2i(); + expect(r.copyFrom(new Rect2i(1, 2, 3, 4))).toBe(r); }); it('should not link the two rects', () => { const source = new Rect2i(10, 20, 30, 40); const target = new Rect2i(); + target.copyFrom(source); + source.x = 999; expect(target.x).toBe(10); }); @@ -669,7 +777,9 @@ describe('Rect2i', () => { describe('translate', () => { it('should move the rect by the given offset', () => { const r = new Rect2i(10, 20, 30, 40); + r.translate(5, -3); + expect(r.x).toBe(15); expect(r.y).toBe(17); expect(r.width).toBe(30); @@ -678,13 +788,16 @@ describe('Rect2i', () => { it('should truncate float offsets', () => { const r = new Rect2i(10, 20, 30, 40); + r.translate(1.9, -2.7); + expect(r.x).toBe(11); expect(r.y).toBe(18); }); it('should return this for chaining', () => { const r = new Rect2i(); + expect(r.translate(5, 5)).toBe(r); }); }); @@ -692,7 +805,9 @@ describe('Rect2i', () => { describe('expand', () => { it('should expand on all sides by the given amount', () => { const r = new Rect2i(10, 20, 30, 40); + r.expand(5); + expect(r.x).toBe(5); expect(r.y).toBe(15); expect(r.width).toBe(40); @@ -701,7 +816,9 @@ describe('Rect2i', () => { it('should shrink with negative amount', () => { const r = new Rect2i(10, 20, 30, 40); + r.expand(-2); + expect(r.x).toBe(12); expect(r.y).toBe(22); expect(r.width).toBe(26); @@ -710,6 +827,7 @@ describe('Rect2i', () => { it('should return this for chaining', () => { const r = new Rect2i(10, 20, 30, 40); + expect(r.expand(5)).toBe(r); }); }); @@ -717,7 +835,9 @@ describe('Rect2i', () => { describe('expandXY', () => { it('should expand with different horizontal and vertical amounts', () => { const r = new Rect2i(10, 20, 30, 40); + r.expandXY(5, 3); + expect(r.x).toBe(5); expect(r.y).toBe(17); expect(r.width).toBe(40); @@ -726,7 +846,9 @@ describe('Rect2i', () => { it('should handle mixed positive and negative values', () => { const r = new Rect2i(10, 20, 30, 40); + r.expandXY(2, -3); + expect(r.x).toBe(8); expect(r.y).toBe(23); expect(r.width).toBe(34); @@ -735,6 +857,7 @@ describe('Rect2i', () => { it('should return this for chaining', () => { const r = new Rect2i(10, 20, 30, 40); + expect(r.expandXY(5, 3)).toBe(r); }); }); @@ -742,6 +865,7 @@ describe('Rect2i', () => { describe('chaining mutations', () => { it('should support chaining multiple mutation methods', () => { const r = new Rect2i().set(0, 0, 100, 100).translate(10, 20).expand(5); + expect(r.x).toBe(5); expect(r.y).toBe(15); expect(r.width).toBe(110); @@ -756,6 +880,7 @@ describe('Rect2i', () => { describe('zero', () => { it('should return a rect at (0, 0, 0, 0)', () => { const r = Rect2i.zero(); + expect(r.x).toBe(0); expect(r.y).toBe(0); expect(r.width).toBe(0); @@ -765,11 +890,13 @@ describe('Rect2i', () => { it('should return the same frozen singleton', () => { const a = Rect2i.zero(); const b = Rect2i.zero(); + expect(a).toBe(b); }); it('should be frozen (immutable)', () => { const r = Rect2i.zero(); + expect(Object.isFrozen(r)).toBe(true); }); }); @@ -779,6 +906,7 @@ describe('Rect2i', () => { const min = new Vector2i(10, 20); const max = new Vector2i(50, 80); const r = Rect2i.fromMinMax(min, max); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(40); @@ -788,6 +916,7 @@ describe('Rect2i', () => { it('should handle zero-size rect (min equals max)', () => { const p = new Vector2i(10, 20); const r = Rect2i.fromMinMax(p, p); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(0); @@ -798,6 +927,7 @@ describe('Rect2i', () => { describe('fromMinMaxXY', () => { it('should create a rect from raw coordinates', () => { const r = Rect2i.fromMinMaxXY(10, 20, 50, 80); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(40); @@ -806,6 +936,7 @@ describe('Rect2i', () => { it('should truncate float coordinates', () => { const r = Rect2i.fromMinMaxXY(1.9, 2.7, 10.1, 20.5); + expect(r.x).toBe(1); expect(r.y).toBe(2); expect(r.width).toBe(9); @@ -814,6 +945,7 @@ describe('Rect2i', () => { it('should handle negative coordinates', () => { const r = Rect2i.fromMinMaxXY(-10, -20, 10, 20); + expect(r.x).toBe(-10); expect(r.y).toBe(-20); expect(r.width).toBe(20); @@ -826,6 +958,7 @@ describe('Rect2i', () => { const center = new Vector2i(50, 50); const size = new Vector2i(20, 30); const r = Rect2i.fromCenterSize(center, size); + expect(r.x).toBe(40); expect(r.y).toBe(35); expect(r.width).toBe(20); @@ -836,6 +969,7 @@ describe('Rect2i', () => { const center = new Vector2i(50, 50); const size = new Vector2i(11, 13); const r = Rect2i.fromCenterSize(center, size); + expect(r.x).toBe(45); expect(r.y).toBe(44); expect(r.width).toBe(11); @@ -846,6 +980,7 @@ describe('Rect2i', () => { describe('fromCenterSizeXY', () => { it('should create a centered rect from raw coordinates', () => { const r = Rect2i.fromCenterSizeXY(50, 50, 20, 30); + expect(r.x).toBe(40); expect(r.y).toBe(35); expect(r.width).toBe(20); @@ -854,14 +989,16 @@ describe('Rect2i', () => { it('should truncate float inputs', () => { const r = Rect2i.fromCenterSizeXY(50.9, 50.1, 20.7, 30.3); + expect(r.x).toBe(40); expect(r.y).toBe(35); expect(r.width).toBe(20); expect(r.height).toBe(30); }); - it('should handle origin center', () => { + it('should handle the origin center', () => { const r = Rect2i.fromCenterSizeXY(0, 0, 10, 10); + expect(r.x).toBe(-5); expect(r.y).toBe(-5); expect(r.width).toBe(10); @@ -872,6 +1009,7 @@ describe('Rect2i', () => { describe('fromValuesUnchecked', () => { it('should create a rect without truncation', () => { const r = Rect2i.fromValuesUnchecked(10, 20, 30, 40); + expect(r.x).toBe(10); expect(r.y).toBe(20); expect(r.width).toBe(30); @@ -880,6 +1018,7 @@ describe('Rect2i', () => { it('should preserve integer values without extra truncation', () => { const r = Rect2i.fromValuesUnchecked(1, 2, 3, 4); + expect(r.x).toBe(1); expect(r.y).toBe(2); expect(r.width).toBe(3); diff --git a/src/utils/Rect2i.ts b/src/utils/Rect2i.ts index d63021d..4fd2937 100644 --- a/src/utils/Rect2i.ts +++ b/src/utils/Rect2i.ts @@ -2,13 +2,11 @@ import { Vector2i } from './Vector2i'; /** * Integer rectangle for pixel-perfect bounds and regions. - * Inspired by RetroBlit's Rect2i. * - * Performance notes: - * - Use fromValuesUnchecked() for trusted integer values in hot paths - * - Use raw value getters (centerX, centerY, right, bottom) to avoid allocations - * - Use “To” methods (centerTo, minTo, maxTo, etc.) for zero-allocation output - * - Bitwise |0 is used for integer truncation (truncates toward zero, not floor) + * Used throughout the engine for sprite regions, display-space bounds, and + * zero-allocation geometry helpers. Both convenience getters and allocation-free + * `*To()` helpers are provided so callers can choose between readability and + * hot-path efficiency. */ export class Rect2i { // #region Cached Static Rectangles @@ -21,11 +19,7 @@ export class Rect2i { // #region Constructor /** - * Creates a new integer rectangle. - * All values are truncated to integers using |0. - * - * Note: For negative non-integers, |0 truncates toward zero (e.g., –1.7 becomes –1). - * If you need floor behavior for negative values, use Math.floor before passing. + * Creates an integer rectangle, truncating all inputs toward zero. * * @param x - Left-edge X coordinate (defaults to 0). * @param y - Top-edge Y coordinate (defaults to 0). @@ -57,40 +51,32 @@ export class Rect2i { // #region Raw Value Getters (Zero Allocation) /** - * Gets the right edge X coordinate (x + width). - * Use this instead of max.x in hot paths to avoid allocation. - * - * @returns Right edge coordinate. + * Gets the exclusive right edge (`x + width`). + * @returns Right edge X coordinate. */ get right(): number { return this.x + this.width; } /** - * Gets the bottom edge Y coordinate (y + height). - * Use this instead of max.y in hot paths to avoid allocation. - * - * @returns Bottom edge coordinate. + * Gets the exclusive bottom edge (`y + height`). + * @returns Bottom edge Y coordinate. */ get bottom(): number { return this.y + this.height; } /** - * Gets the center X coordinate. - * Use this instead of center.x in hot paths to avoid allocation. - * - * @returns Center X coordinate (truncated toward zero). + * Gets the truncated horizontal center coordinate. + * @returns Center X coordinate. */ get centerX(): number { return (this.x + this.width / 2) | 0; } /** - * Gets the center Y coordinate. - * Use this instead of center.y in hot paths to avoid allocation. - * - * @returns Center Y coordinate (truncated toward zero). + * Gets the truncated vertical center coordinate. + * @returns Center Y coordinate. */ get centerY(): number { return (this.y + this.height / 2) | 0; @@ -101,44 +87,32 @@ export class Rect2i { // #region Computed Properties /** - * Gets the top-left corner of the rectangle. - * - * Note: Creates a new Vector2i. Use minTo() in hot paths. - * - * @returns Vector2i representing (x, y). + * Gets the top-left corner as a new vector. + * @returns Vector2i at (x, y). */ get min(): Vector2i { return Vector2i.fromXYUnchecked(this.x, this.y); } /** - * Gets the bottom-right corner of the rectangle (exclusive). - * - * Note: Creates a new Vector2i. Use maxTo() in hot paths. - * - * @returns Vector2i representing (x + width, y + height). + * Gets the exclusive bottom-right corner as a new vector. + * @returns Vector2i at (x + width, y + height). */ get max(): Vector2i { return Vector2i.fromXYUnchecked(this.x + this.width, this.y + this.height); } /** - * Gets the center point of the rectangle. - * - * Note: Creates a new Vector2i. Use centerTo() or centerX/centerY in hot paths. - * - * @returns Vector2i at the rectangle's center (truncated toward zero). + * Gets the truncated center point as a new vector. + * @returns Vector2i at the rectangle center. */ get center(): Vector2i { return Vector2i.fromXYUnchecked((this.x + this.width / 2) | 0, (this.y + this.height / 2) | 0); } /** - * Gets the position (top-left corner) as a vector. - * - * Note: Creates a new Vector2i. Use x/y directly or positionTo() in hot paths. - * - * @returns Vector2i representing (x, y). + * Gets the top-left position as a new vector. + * @returns Vector2i at (x, y). */ get position(): Vector2i { return Vector2i.fromXYUnchecked(this.x, this.y); @@ -155,11 +129,8 @@ export class Rect2i { } /** - * Gets the size (width, height) as a vector. - * - * Note: Creates a new Vector2i. Use width/height directly or sizeTo() in hot paths. - * - * @returns Vector2i representing (width, height). + * Gets the size as a new vector. + * @returns Vector2i at (width, height). */ get size(): Vector2i { return Vector2i.fromXYUnchecked(this.width, this.height); @@ -179,6 +150,115 @@ export class Rect2i { // #region Zero-Allocation Output Methods + /** + * Returns a zero-sized rectangle at origin. + * Returns a cached frozen singleton - do not modify. + * + * @returns Rectangle at (0, 0) with size (0, 0) (cached). + */ + static zero(): Rect2i { + return Rect2i._zero; + } + + /** + * Creates a rectangle from two corner points. + * + * Note: min must be less than or equal to max component-wise. + * If min > max on any axis, the resulting width/height will be negative, + * which may cause unexpected behavior in intersection/containment tests. + * + * @param min - Top-left corner. + * @param max - Bottom-right corner. + * @returns New rectangle spanning from min to max. + */ + static fromMinMax(min: Vector2i, max: Vector2i): Rect2i { + // Vector2i values are already integers, use unchecked. + return Rect2i.fromValuesUnchecked(min.x, min.y, max.x - min.x, max.y - min.y); + } + + /** + * Creates a rectangle from two corner points specified as raw coordinates. + * Zero allocation alternative to fromMinMax() when you have raw coordinates. + * + * Note: min coordinates must be less than or equal to max coordinates. + * If min > max on any axis, the resulting width/height will be negative. + * + * @param minX - Left-edge X coordinate. + * @param minY - Top-edge Y coordinate. + * @param maxX - Right-edge X coordinate. + * @param maxY - Bottom-edge Y coordinate. + * @returns New rectangle spanning from (minX, minY) to (maxX, maxY). + */ + static fromMinMaxXY(minX: number, minY: number, maxX: number, maxY: number): Rect2i { + const x = minX | 0; + const y = minY | 0; + + return Rect2i.fromValuesUnchecked(x, y, (maxX | 0) - x, (maxY | 0) - y); + } + + /** + * Creates a rectangle centered on a point with a given size. + * + * @param center - Center point of the rectangle. + * @param size - Width and height as a vector. + * @returns New rectangle centered on the given point. + */ + static fromCenterSize(center: Vector2i, size: Vector2i): Rect2i { + // Use |0 to truncate division results. + const halfWidth = (size.x / 2) | 0; + const halfHeight = (size.y / 2) | 0; + + // All arithmetic on integers, use unchecked. + return Rect2i.fromValuesUnchecked(center.x - halfWidth, center.y - halfHeight, size.x, size.y); + } + + /** + * Creates a rectangle centered on raw coordinates with a given size. + * Zero allocation alternative to fromCenterSize() when you have raw coordinates. + * + * @param centerX - Center X coordinate. + * @param centerY - Center Y coordinate. + * @param width - Width of the rectangle. + * @param height - Height of the rectangle. + * @returns New rectangle centered on the given point. + */ + static fromCenterSizeXY(centerX: number, centerY: number, width: number, height: number): Rect2i { + const w = width | 0; + const h = height | 0; + const halfWidth = (w / 2) | 0; + const halfHeight = (h / 2) | 0; + + return Rect2i.fromValuesUnchecked((centerX | 0) - halfWidth, (centerY | 0) - halfHeight, w, h); + } + + // #endregion + + // #region Intersection Tests + + /** + * Creates a Rect2i from integer values without truncation. + * Use this in hot paths when values are guaranteed to be integers. + * + * WARNING: Passing non-integer values will result in non-integer rect components. + * Only use when you’re certain the values are already integers. + * + * @param x - Left-edge X coordinate (must be integer). + * @param y - Top-edge Y coordinate (must be integer). + * @param width - Width in pixels (must be integer). + * @param height - Height in pixels (must be integer). + * @returns New Rect2i with the specified values. + */ + static fromValuesUnchecked(x: number, y: number, width: number, height: number): Rect2i { + const r = Object.create(Rect2i.prototype) as Rect2i; + + r.x = x; + r.y = y; + r.width = width; + r.height = height; + + return r; + } + /** * Writes the top-left corner (min) to an existing vector. * Zero allocation alternative to the min getter. @@ -249,10 +329,6 @@ export class Rect2i { return out; } - // #endregion - - // #region Intersection Tests - /** * Tests if a point lies within this rectangle. * Uses half-open interval: includes min, excludes max. @@ -266,10 +342,12 @@ export class Rect2i { ); } + // #endregion + + // #region Utility + /** - * Tests if raw x, y coordinates lie within this rectangle. - * Uses half-open interval: includes min, excludes max. - * Zero allocation alternative to contains() when you have raw coordinates. + * Tests raw coordinates against this rectangle without allocating a vector. * * @param px - X coordinate to test. * @param py - Y coordinate to test. @@ -297,8 +375,6 @@ export class Rect2i { /** * Calculates the overlapping region of two rectangles. * - * Note: Creates a new Rect2i. Use intersectionTo() in hot paths. - * * @param other - Rectangle to intersect with. * @returns New rectangle representing the overlap, or null if no overlap. */ @@ -317,8 +393,7 @@ export class Rect2i { } /** - * Calculates the overlapping region and writes to an existing rectangle. - * Zero allocation alternative to intersection(). + * Calculates the overlapping region and writes it into an existing rectangle. * * @param other - Rectangle to intersect with. * @param out - Rectangle to write the result to. @@ -340,6 +415,10 @@ export class Rect2i { return true; } + // #endregion + + // #region In-Place Mutation Methods + /** * Calculates intersection depth for collision resolution. * Returns how much the rectangles overlap in each axis. @@ -393,10 +472,6 @@ export class Rect2i { return out; } - // #endregion - - // #region Utility - /** * Checks if this rectangle equals another (all components match). * @@ -442,10 +517,6 @@ export class Rect2i { return `Rect2i(${this.x}, ${this.y}, ${this.width}, ${this.height})`; } - // #endregion - - // #region In-Place Mutation Methods - /** * Sets all components of this rectangle. * Modifies this rectangle directly for maximum performance. @@ -467,6 +538,10 @@ export class Rect2i { return this; } + // #endregion + + // #region Static Constructors + /** * Sets only the position of this rectangle, keeping the size unchanged. * Modifies this rectangle directly for maximum performance. @@ -556,6 +631,10 @@ export class Rect2i { return this; } + // #endregion + + // #region Static Factories + /** * Expands this rectangle by different amounts horizontally and vertically. * Modifies this rectangle directly for maximum performance. @@ -579,117 +658,4 @@ export class Rect2i { } // #endregion - - // #region Static Constructors - - /** - * Returns a zero-sized rectangle at origin. - * Returns a cached frozen singleton - do not modify. - * - * @returns Rectangle at (0, 0) with size (0, 0) (cached). - */ - static zero(): Rect2i { - return Rect2i._zero; - } - - /** - * Creates a rectangle from two corner points. - * - * Note: min must be less than or equal to max component-wise. - * If min > max on any axis, the resulting width/height will be negative, - * which may cause unexpected behavior in intersection/containment tests. - * - * @param min - Top-left corner. - * @param max - Bottom-right corner. - * @returns New rectangle spanning from min to max. - */ - static fromMinMax(min: Vector2i, max: Vector2i): Rect2i { - // Vector2i values are already integers, use unchecked. - return Rect2i.fromValuesUnchecked(min.x, min.y, max.x - min.x, max.y - min.y); - } - - /** - * Creates a rectangle from two corner points specified as raw coordinates. - * Zero allocation alternative to fromMinMax() when you have raw coordinates. - * - * Note: min coordinates must be less than or equal to max coordinates. - * If min > max on any axis, the resulting width/height will be negative. - * - * @param minX - Left-edge X coordinate. - * @param minY - Top-edge Y coordinate. - * @param maxX - Right-edge X coordinate. - * @param maxY - Bottom-edge Y coordinate. - * @returns New rectangle spanning from (minX, minY) to (maxX, maxY). - */ - static fromMinMaxXY(minX: number, minY: number, maxX: number, maxY: number): Rect2i { - const x = minX | 0; - const y = minY | 0; - - return Rect2i.fromValuesUnchecked(x, y, (maxX | 0) - x, (maxY | 0) - y); - } - - /** - * Creates a rectangle centered on a point with a given size. - * - * @param center - Center point of the rectangle. - * @param size - Width and height as a vector. - * @returns New rectangle centered on the given point. - */ - static fromCenterSize(center: Vector2i, size: Vector2i): Rect2i { - // Use |0 to truncate division results. - const halfWidth = (size.x / 2) | 0; - const halfHeight = (size.y / 2) | 0; - - // All arithmetic on integers, use unchecked. - return Rect2i.fromValuesUnchecked(center.x - halfWidth, center.y - halfHeight, size.x, size.y); - } - - /** - * Creates a rectangle centered on raw coordinates with a given size. - * Zero allocation alternative to fromCenterSize() when you have raw coordinates. - * - * @param centerX - Center X coordinate. - * @param centerY - Center Y coordinate. - * @param width - Width of the rectangle. - * @param height - Height of the rectangle. - * @returns New rectangle centered on the given point. - */ - static fromCenterSizeXY(centerX: number, centerY: number, width: number, height: number): Rect2i { - const w = width | 0; - const h = height | 0; - const halfWidth = (w / 2) | 0; - const halfHeight = (h / 2) | 0; - - return Rect2i.fromValuesUnchecked((centerX | 0) - halfWidth, (centerY | 0) - halfHeight, w, h); - } - - // #endregion - - // #region Static Factories - - /** - * Creates a Rect2i from integer values without truncation. - * Use this in hot paths when values are guaranteed to be integers. - * - * WARNING: Passing non-integer values will result in non-integer rect components. - * Only use when you’re certain the values are already integers. - * - * @param x - Left-edge X coordinate (must be integer). - * @param y - Top-edge Y coordinate (must be integer). - * @param width - Width in pixels (must be integer). - * @param height - Height in pixels (must be integer). - * @returns New Rect2i with the specified values. - */ - static fromValuesUnchecked(x: number, y: number, width: number, height: number): Rect2i { - const r = Object.create(Rect2i.prototype) as Rect2i; - - r.x = x; - r.y = y; - r.width = width; - r.height = height; - - return r; - } - - // #endregion } -- 2.54.0 From 5d9366c8dbf1a76c31fb7c34beb48e2659fe7ad5 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 17:54:11 +0100 Subject: [PATCH 10/16] refactor(api): simplify BlitTech.ts docs and add test spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim JSDoc across the BT namespace — rewrite the module header and class doc, drop verbose setup sequences and @example blocks from initialize(), and tighten displaySize/fps/ticks/ticksReset summaries to single-sentence descriptions. Add blank line after console.warn in warnOnce and add blank-line spacing between arrange/act/assert steps in the test file. Signed-off-by: Vaclav Vancura --- src/BlitTech.test.ts | 14 ++- src/BlitTech.ts | 249 ++++++++++++++++++++----------------------- 2 files changed, 125 insertions(+), 138 deletions(-) diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 63960a0..84edc46 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -1,7 +1,11 @@ +// noinspection DuplicatedCode + /** - * Tests for the BT public API facade in BlitTech.ts. - * Verifies that each BT.* method delegates correctly to BTAPI.instance, - * and that the warnOnce helper suppresses duplicate warnings. + * Unit tests for the public `BT` facade exported from `BlitTech.ts`. + * + * Covers delegation from top-level `BT.*` calls into `BTAPI.instance`, + * default return behavior for hardware-dependent queries, and the warning + * suppression behavior used by facade helpers. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -393,7 +397,7 @@ describe('BT.printMeasure', () => { BT.printMeasure('again'); BT.printMeasure('again'); - // warnOnce suppresses duplicates; total warn calls across both invocations is 0 or 1. + // warnOnce suppresses duplicates; total warn calls across both invocations are 0 or 1. expect(warnSpy.mock.calls.length).toBeLessThanOrEqual(1); }); }); @@ -447,6 +451,7 @@ describe('BT.drawSprite', () => { expect(spy).toHaveBeenCalledWith(sheet, srcRect, destPos, undefined); }); + // it('forwards the tint argument', () => { const spy = vi.spyOn(BTAPI.instance, 'drawSprite').mockReturnValue(undefined); const sheet = new SpriteSheet(mockImage); @@ -468,6 +473,7 @@ describe('BT.drawSprite', () => { it('does not throw when called multiple times', () => { vi.spyOn(BTAPI.instance, 'drawSprite').mockReturnValue(undefined); + const sheet = new SpriteSheet(mockImage); expect(() => { diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 6fef913..9d57e68 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -1,8 +1,8 @@ /** - * Blit-Tech - WebGPU Retro Engine + * Public Blit-Tech entrypoint. * - * Main static API inspired by RetroBlit's RB namespace. - * All demo code interacts with the engine through this interface. + * This module re-exports the main runtime types and exposes the `BT` facade + * used by demos for rendering, timing, bootstrap, and future input helpers. */ import { AssetLoader } from './assets/AssetLoader'; @@ -21,10 +21,7 @@ import { Vector2i } from './utils/Vector2i'; // #region Module State -/** - * Tracks which warning messages have been displayed to avoid console spam. - * Each function name is stored after its first warning is shown. - */ +/** Tracks one-time facade warnings to avoid repeated console noise. */ const _warnedFunctions = new Set(); // #endregion @@ -32,8 +29,7 @@ const _warnedFunctions = new Set(); // #region Helper Functions /** - * Displays a warning message once per function to avoid console spam. - * Further calls with the same function name will be silently ignored. + * Emits a warning message only once for a named facade function. * * @param funcName - Unique identifier for the function (used for deduplication). * @param message - Warning message to display in the console. @@ -41,6 +37,7 @@ const _warnedFunctions = new Set(); function warnOnce(funcName: string, message: string): void { if (!_warnedFunctions.has(funcName)) { console.warn(message); + _warnedFunctions.add(funcName); } } @@ -49,10 +46,7 @@ function warnOnce(funcName: string, message: string): void { // #region Public API -/** - * Main Blit-Tech API module. - * All engine features are accessed through BT.* methods. - */ +/** Main Blit-Tech API namespace used by runtime demos. */ export const BT = { // #region Constants - Sprite Transform Flags @@ -125,31 +119,11 @@ export const BT = { // #region Initialization /** - * Initializes the engine with a demo instance and canvas element. - * This is the entry point for all Blit-Tech demos. - * - * Setup sequence: - * 1. Calls demo.queryHardware() to get display settings - * 2. Initializes WebGPU device and context - * 3. Creates a renderer with a configured display size - * 4. Calls demo.initialize() to load assets - * 5. Starts the loop (fixed update, variable render) - * - * IMPORTANT: Canvas must be attached to DOM before calling this. - * In Electron, wait for DOM ready before initializing. - * - * @param demo - Demo implementing IBlitTechDemo interface. - * @param canvas - HTML canvas element to render to. - * @returns Promise that resolves to true if successful, false on error. + * Initializes the engine against a demo instance and target canvas. * - * @example - * const canvas = document.getElementById('blit-tech-canvas') as HTMLCanvasElement; - * const demo = new MyDemo(); - * const success = await BT.initialize(demo, canvas); - * - * if (!success) { - * console.error('Failed to initialize Blit-Tech'); - * } + * @param demo - Demo implementation that provides lifecycle hooks. + * @param canvas - Canvas used as the engine render target. + * @returns `true` when initialization succeeds; otherwise `false`. */ initialize: async (demo: IBlitTechDemo, canvas: HTMLCanvasElement): Promise => { return await BTAPI.instance.initialize(demo, canvas); @@ -160,10 +134,12 @@ export const BT = { // #region Hardware Information /** - * Gets the current display size in pixels. - * Returns the internal rendering resolution configured by the demo. + * Returns the active internal display resolution in pixels. * - * @returns Display size as Vector2i, or zero vector if not initialized. + * This is the logical render size configured by the demo, not the canvas + * element's CSS size. + * + * @returns Configured display size, or `Vector2i.zero()` before initialization. */ displaySize: (): Vector2i => { const settings = BTAPI.instance.getHardwareSettings(); @@ -172,10 +148,12 @@ export const BT = { }, /** - * Gets the target frames per second for update() calls. - * Update() runs at this fixed rate, while render() runs at a variable rate. + * Returns the fixed update rate. + * + * `update()` runs at this target frequency, while rendering may occur at a + * different cadence. * - * @returns Target frames per second, or 60 if not initialized. + * @returns Target updates per second, or `60` before initialization. */ fps: (): number => { const settings = BTAPI.instance.getHardwareSettings(); @@ -184,18 +162,19 @@ export const BT = { }, /** - * Gets the current tick count (increments each update). - * Ticks increment once per fixed update call (e.g., 60 times/second at 60 FPS). + * Returns the current fixed-update tick counter. * - * @returns Current tick count since initialization or last reset. + * The counter increments once per engine update and is typically used for + * frame-based timing. + * + * @returns Tick count since initialization or the last {@link BT.ticksReset}. */ ticks: (): number => { return BTAPI.instance.getTicks(); }, /** - * Resets the tick counter to zero. - * Useful for timing-based demo events and animations. + * Resets the fixed-update tick counter back to zero. */ ticksReset: (): void => { BTAPI.instance.resetTicks(); @@ -206,21 +185,22 @@ export const BT = { // #region Rendering - Clear Operations /** - * Clears the screen with a solid color. - * Called at the start of each frame to set the background color. + * Sets the frame clear color. + * + * The renderer uses this color when clearing the full display at the start + * of the next frame. * - * @param color - Fill color for the entire screen. + * @param color - Color used for the full-screen clear pass. */ clear: (color: Color32): void => { BTAPI.instance.setClearColor(color); }, /** - * Clears a rectangular region with a solid color. - * Useful for erasing specific areas of the screen. + * Fills a rectangular display region with a solid color. * - * @param color - Fill color for the region. - * @param rect - Region to clear in pixel coordinates. + * @param color - Fill color applied to the region. + * @param rect - Rectangle in display pixel coordinates. */ clearRect: (color: Color32, rect: Rect2i): void => { BTAPI.instance.clearRect(color, rect); @@ -231,9 +211,9 @@ export const BT = { // #region Rendering - Primitives /** - * Draws a single pixel at the specified position. + * Draws a single pixel. * - * @param pos - Pixel coordinates. + * @param pos - Target pixel in display coordinates. * @param color - Pixel color. */ drawPixel: (pos: Vector2i, color: Color32): void => { @@ -241,11 +221,12 @@ export const BT = { }, /** - * Draws a line between two points using Bresenham's algorithm. - * Produces pixel-perfect lines without the antialiasing. + * Draws a pixel-perfect line between two points. + * + * Uses rasterized line drawing without antialiasing. * - * @param p0 - Start point. - * @param p1 - End point. + * @param p0 - Start position in display coordinates. + * @param p1 - End position in display coordinates. * @param color - Line color. */ drawLine: (p0: Vector2i, p1: Vector2i, color: Color32): void => { @@ -253,9 +234,9 @@ export const BT = { }, /** - * Draws a rectangle outline (unfilled). + * Draws an unfilled rectangle outline. * - * @param rect - Rectangle bounds in pixel coordinates. + * @param rect - Rectangle bounds in display coordinates. * @param color - Outline color. */ drawRect: (rect: Rect2i, color: Color32): void => { @@ -265,7 +246,7 @@ export const BT = { /** * Draws a filled rectangle. * - * @param rect - Rectangle bounds in pixel coordinates. + * @param rect - Rectangle bounds in display coordinates. * @param color - Fill color. */ drawRectFill: (rect: Rect2i, color: Color32): void => { @@ -277,26 +258,25 @@ export const BT = { // #region Camera /** - * Sets the camera offset for scrolling effects. - * This amount offsets all drawing operations. + * Sets the global camera offset applied to subsequent draw calls. * - * @param offset - Camera position offset in pixels. + * @param offset - Camera translation in display pixels. */ cameraSet: (offset: Vector2i): void => { BTAPI.instance.setCameraOffset(offset); }, /** - * Gets the current camera offset. + * Returns the current global camera offset. * - * @returns Current camera position offset. + * @returns Camera translation in display pixels. */ cameraGet: (): Vector2i => { return BTAPI.instance.getCameraOffset(); }, /** - * Resets the camera offset to (0, 0). + * Resets the global camera offset to `(0, 0)`. */ cameraReset: (): void => { BTAPI.instance.resetCamera(); @@ -307,12 +287,13 @@ export const BT = { // #region Input - Gamepad Buttons /** - * Checks if a gamepad button is currently held down. - * Returns true for every frame the button remains pressed. + * Checks whether a gamepad button is currently held. * - * @param _button - Button constant (BTN_*). - * @param _player - Player index (0-3). - * @returns True if button is held down, false otherwise. + * This API is currently a stub and always returns `false`. + * + * @param _button - Button constant from the `BTN_*` set. + * @param _player - Zero-based player index. + * @returns `true` while the button remains pressed. Returns `false` until the input system is implemented. */ buttonDown: (_button: number, _player: number = 0): boolean => { // TODO: Implement input system. @@ -320,12 +301,13 @@ export const BT = { }, /** - * Checks if a gamepad button was just pressed this frame. - * Returns true only on the first frame the button is pressed. + * Checks whether a gamepad button was pressed on the current frame. + * + * This API is currently a stub and always returns `false`. * - * @param _button - Button constant (BTN_*). - * @param _player - Player index (0-3). - * @returns True if button was just pressed, false otherwise. + * @param _button - Button constant from the `BTN_*` set. + * @param _player - Zero-based player index. + * @returns `true` on the transition frame. Returns `false` until the input system is implemented. */ buttonPressed: (_button: number, _player: number = 0): boolean => { // TODO: Implement input system. @@ -333,12 +315,13 @@ export const BT = { }, /** - * Checks if a gamepad button was just released this frame. - * Returns true only on the first frame the button is released. + * Checks whether a gamepad button was released on the current frame. * - * @param _button - Button constant (BTN_*). - * @param _player - Player index (0-3). - * @returns True if button was just released, false otherwise. + * This API is currently a stub and always returns `false`. + * + * @param _button - Button constant from the `BTN_*` set. + * @param _player - Zero-based player index. + * @returns `true` on the release frame. Returns `false` until the input system is implemented. */ buttonReleased: (_button: number, _player: number = 0): boolean => { // TODO: Implement input system. @@ -350,11 +333,12 @@ export const BT = { // #region Input - Keyboard /** - * Checks if a keyboard key is currently held down. - * Returns true for every frame the key remains pressed. + * Checks whether a keyboard key is currently held. + * + * This API is currently a stub and always returns `false`. * - * @param _key - Key code (e.g., "KeyW", "Space", "ArrowUp"). - * @returns True if key is held down, false otherwise. + * @param _key - DOM keyboard code such as `"KeyW"` or `"Space"`. + * @returns `true` while the key remains pressed. Returns `false` until the input system is implemented. */ keyDown: (_key: string): boolean => { // TODO: Implement input system. @@ -362,11 +346,12 @@ export const BT = { }, /** - * Checks if a keyboard key was just pressed this frame. - * Returns true only on the first frame the key is pressed. + * Checks whether a keyboard key was pressed on the current frame. * - * @param _key - Key code (e.g., "KeyW", "Space", "ArrowUp"). - * @returns True if key was just pressed, false otherwise. + * This API is currently a stub and always returns `false`. + * + * @param _key - DOM keyboard code such as `"KeyW"` or `"Space"`. + * @returns `true` on the transition frame. Returns `false` until the input system is implemented. */ keyPressed: (_key: string): boolean => { // TODO: Implement input system. @@ -374,11 +359,12 @@ export const BT = { }, /** - * Checks if a keyboard key was just released this frame. - * Returns true only on the first frame the key is released. + * Checks whether a keyboard key was released on the current frame. + * + * This API is currently a stub and always returns `false`. * - * @param _key - Key code (e.g., "KeyW", "Space", "ArrowUp"). - * @returns True if key was just released, false otherwise. + * @param _key - DOM keyboard code such as `"KeyW"` or `"Space"`. + * @returns `true` on the release frame. Returns `false` until the input system is implemented. */ keyReleased: (_key: string): boolean => { // TODO: Implement input system. @@ -390,24 +376,26 @@ export const BT = { // #region Text Rendering /** - * Prints placeholder text at the specified position. - * Each character is rendered as a simple colored block. - * For proper text rendering, use printFont() instead. + * Draws basic placeholder text. * - * @param pos - Text position (top-left corner). + * This uses the engine's fallback text rendering. For authored bitmap fonts, + * prefer {@link BT.printFont}. + * + * @param pos - Text origin in display coordinates. * @param color - Text color. - * @param text - String to display. + * @param text - String to render. */ print: (pos: Vector2i, color: Color32, text: string): void => { BTAPI.instance.drawText(pos, color, text); }, /** - * Measures the pixel dimensions of text. - * Currently, returns zero vector - implementation pending. + * Measures fallback text dimensions. + * + * This API is not implemented yet and currently returns `Vector2i.zero()`. * * @param _text - Text string to measure. - * @returns Size of text in pixels (currently always zero). + * @returns Text size in pixels. Returns `Vector2i.zero()` until measurement support is implemented. */ printMeasure: (_text: string): Vector2i => { warnOnce('printMeasure', '[BT.printMeasure] Not yet implemented'); @@ -416,13 +404,15 @@ export const BT = { }, /** - * Draws text using a bitmap font with variable-width glyphs. - * Supports Unicode characters and per-glyph rendering offsets. + * Draws text with a bitmap font. + * + * Supports proportional glyph widths and glyph-level offsets defined by the + * supplied {@link BitmapFont}. * - * @param font - Bitmap font to use for rendering. - * @param pos - Text position (top-left corner). + * @param font - Font asset used for rendering. + * @param pos - Text origin in display coordinates. * @param text - String to render. - * @param color - Optional text color (defaults to white). + * @param color - Optional tint color. Defaults to white when omitted. */ printFont: (font: BitmapFont, pos: Vector2i, text: string, color?: Color32): void => { BTAPI.instance.drawBitmapText(font, pos, text, color); @@ -434,9 +424,10 @@ export const BT = { /** * Captures the next rendered frame as a PNG blob. - * The capture happens on the next render cycle after this call. * - * @returns Promise resolving to a PNG Blob of the rendered frame. + * The returned promise resolves after the next render pass has completed. + * + * @returns PNG image data for the captured frame. * * @example * const blob = await BT.captureFrame(); @@ -448,16 +439,15 @@ export const BT = { }, /** - * Captures the next rendered frame and triggers a browser file download. - * Convenience wrapper around captureFrame() that handles the download flow. + * Captures the next rendered frame and downloads it from the browser. + * + * Convenience wrapper around {@link BT.captureFrame} that creates a temporary + * object URL and clicks a synthetic anchor element. * - * @param filename - Download filename (defaults to "blit-tech-capture.png"). + * @param filename - Target download filename. * * @example - * // Download with default filename * await BT.downloadFrame(); - * - * // Download with custom filename * await BT.downloadFrame('screenshot-001.png'); */ downloadFrame: async (filename: string = 'blit-tech-capture.png'): Promise => { @@ -477,29 +467,20 @@ export const BT = { /** * Draws a sprite region from a sprite sheet. - * Supports texture batching for optimal performance. - * - * The renderer automatically batches sprites by texture. - * Drawing sprites from the same SpriteSheet consecutively - * is more efficient than interleaving different textures. * - * PERFORMANCE TIP: Group draw calls by texture to minimize GPU state changes. + * Textures internally batch sprite draws. Grouping draws from the + * same {@link SpriteSheet} minimizes batch flushes and reduces GPU state + * changes. * - * @param spriteSheet - Sprite sheet containing the source texture. - * @param srcRect - Source rectangle in the sprite sheet (pixel coordinates). - * @param destPos - Destination position on screen (top-left corner). - * @param tint - Optional tint color multiplied with texture (defaults to white). + * @param spriteSheet - Sprite sheet that owns the source texture. + * @param srcRect - Source rectangle within the sprite sheet, in pixels. + * @param destPos - Destination top-left position in display coordinates. + * @param tint - Optional multiplicative tint. Defaults to white. * * @example - * // Efficient: All from the same sprite sheet. * BT.drawSprite(sheet, new Rect2i(0, 0, 16, 16), new Vector2i(10, 10)); * BT.drawSprite(sheet, new Rect2i(16, 0, 16, 16), new Vector2i(26, 10)); * BT.drawSprite(sheet, new Rect2i(32, 0, 16, 16), new Vector2i(42, 10)); - * - * // Less efficient: Interleaving textures causes batch flushes. - * BT.drawSprite(sheet1, ...); - * BT.drawSprite(sheet2, ...); // Flush + texture change - * BT.drawSprite(sheet1, ...); // Flush + texture change */ drawSprite: (spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, tint?: Color32): void => { BTAPI.instance.drawSprite(spriteSheet, srcRect, destPos, tint); -- 2.54.0 From 4f4ef94fd55833947fbedc15ecd9ddab98e930e4 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 17:57:58 +0100 Subject: [PATCH 11/16] docs(utils): expand FrameCapture JSDoc with verbose @param and @callback detail Replace concise summaries with full-prose descriptions for constants, type aliases, and exported functions. Add explicit @callback tags to CaptureResolve and CaptureReject, and include {type} annotations in @param and @returns tags throughout FrameCapture.ts. Signed-off-by: Vaclav Vancura --- src/utils/FrameCapture.ts | 81 ++++++++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/src/utils/FrameCapture.ts b/src/utils/FrameCapture.ts index 8afc041..e189641 100644 --- a/src/utils/FrameCapture.ts +++ b/src/utils/FrameCapture.ts @@ -7,20 +7,56 @@ // #region Constants -/** WebGPU requires buffer row byte alignment to be a multiple of this value. */ +/** + * Represents the required byte alignment for the number of bytes per row + * in a data buffer. This value ensures that each row of data starts at a + * memory address divisible by the specified alignment. + * + * Alignment is crucial for optimizing memory access and ensuring compatibility + * with hardware requirements, such as in graphics or compute operations. + * + * The value is typically a power of 2, which simplifies calculations and + * aligns with common hardware constraints. + */ const BYTES_PER_ROW_ALIGNMENT = 256; -/** Bytes per pixel for RGBA/BGRA 8-bit formats. */ +/** + * Represents the number of bytes used to store a single pixel in an image or graphics context. + * This constant is typically used in image processing or graphics-related computations to + * determine memory requirements or to process pixel data. + * + * The value of 4 signifies that each pixel is represented using 4 bytes, which is commonly + * associated with RGBA color models, where each component (Red, Green, Blue, and Alpha) + * occupies one byte. + */ const BYTES_PER_PIXEL = 4; // #endregion // #region Pending Capture State -/** Resolve function for the pending capture promise. */ +/** + * Represents a function type that processes a `Blob` object. + * + * This type is commonly used for asynchronous operations where a `Blob` + * result needs to be captured or processed, such as handling media streams + * or creating output from captured data. + * + * @callback CaptureResolve + * @param {Blob} blob - The `Blob` object to be processed. + */ type CaptureResolve = (blob: Blob) => void; -/** Reject function for the pending capture promise. */ +/** + * A type alias representing a function used to handle rejection scenarios during an operation. + * + * This function is typically invoked to signal that an operation has failed. + * The function accepts an Error object as a parameter, which provides details + * regarding the reason for the failure. + * + * @callback CaptureReject + * @param {Error} reason - The error object representing the reason for the rejection. + */ type CaptureReject = (reason: Error) => void; // #endregion @@ -28,9 +64,12 @@ type CaptureReject = (reason: Error) => void; // #region Helper Functions /** - * Calculates the `bytesPerRow` value required by `copyTextureToBuffer()`. - * @param width - * @returns Aligned bytes per row (multiple of 256). + * Calculates the aligned byte size per row for a given image width. + * + * The alignment ensures that the byte size per row is compliant with the specified row alignment boundary. + * + * @param {number} width - The width of the image in pixels. + * @returns {number} The aligned byte size per row. */ export function alignedBytesPerRow(width: number): number { const unaligned = width * BYTES_PER_PIXEL; @@ -39,8 +78,10 @@ export function alignedBytesPerRow(width: number): number { } /** - * Swaps BGRA pixel data into RGBA order in place. - * @param data + * Converts a pixel array from BGRA format to RGBA format by swapping the blue and red color channels. + * + * @param {Uint8ClampedArray} data - The pixel array in BGRA format. Each pixel is represented by 4 consecutive bytes (blue, green, red, alpha). + * @returns {void} This function modifies the input array in place and does not return a value. */ export function swizzleBGRAtoRGBA(data: Uint8ClampedArray): void { for (let i = 0; i < data.length; i += BYTES_PER_PIXEL) { @@ -55,16 +96,14 @@ export function swizzleBGRAtoRGBA(data: Uint8ClampedArray): void { } /** - * Converts mapped GPU pixel data into a PNG blob. + * Converts a pixel buffer into a PNG image Blob. * - * Removes row padding introduced by WebGPU alignment requirements and - * optionally swizzles BGRA input into RGBA before encoding. - * @param buffer - * @param width - * @param height - * @param paddedBytesPerRow - * @param isBGRA - * @returns Promise resolving to a PNG Blob. + * @param {ArrayBuffer} buffer - The input pixel buffer containing the raw image data. + * @param {number} width - The width of the image in pixels. + * @param {number} height - The height of the image in pixels. + * @param {number} paddedBytesPerRow - The number of bytes per row in the buffer, including padding bytes. + * @param {boolean} isBGRA - Indicates whether the pixel data is in BGRA format and needs to be converted to RGBA. + * @returns {Promise} A promise that resolves to a Blob containing the PNG image. */ export async function pixelBufferToPNG( buffer: ArrayBuffer, @@ -107,11 +146,7 @@ export async function pixelBufferToPNG( // #region FrameCapture Class /** - * Coordinates deferred capture of the next rendered frame. - * - * A capture request is queued ahead of frame submission, the renderer records a - * texture-to-buffer copy during `endFrame()`, and the captured data is resolved - * asynchronously after GPU work completes. + * Class responsible for capturing a single frame from a WebGPU rendering pipeline and converting it to a PNG Blob. */ export class FrameCapture { /** Resolve callback for the pending capture. */ -- 2.54.0 From 854262bef45a07dfdc72c7c017764429d65995f8 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 18:02:48 +0100 Subject: [PATCH 12/16] refactor(utils): fix Rect2i region markers to match method semantics Signed-off-by: Vaclav Vancura --- src/utils/Rect2i.ts | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/utils/Rect2i.ts b/src/utils/Rect2i.ts index 4fd2937..ee99c40 100644 --- a/src/utils/Rect2i.ts +++ b/src/utils/Rect2i.ts @@ -148,7 +148,7 @@ export class Rect2i { // #endregion - // #region Zero-Allocation Output Methods + // #region Static Factory Methods /** * Returns a zero-sized rectangle at origin. @@ -231,10 +231,6 @@ export class Rect2i { return Rect2i.fromValuesUnchecked((centerX | 0) - halfWidth, (centerY | 0) - halfHeight, w, h); } - // #endregion - - // #region Intersection Tests - /** * Creates a Rect2i from integer values without truncation. * Use this in hot paths when values are guaranteed to be integers. @@ -259,6 +255,10 @@ export class Rect2i { return r; } + // #endregion + + // #region Zero-Allocation Output Methods + /** * Writes the top-left corner (min) to an existing vector. * Zero allocation alternative to the min getter. @@ -344,7 +344,7 @@ export class Rect2i { // #endregion - // #region Utility + // #region Intersection Tests /** * Tests raw coordinates against this rectangle without allocating a vector. @@ -415,10 +415,6 @@ export class Rect2i { return true; } - // #endregion - - // #region In-Place Mutation Methods - /** * Calculates intersection depth for collision resolution. * Returns how much the rectangles overlap in each axis. @@ -472,6 +468,10 @@ export class Rect2i { return out; } + // #endregion + + // #region Utility Methods + /** * Checks if this rectangle equals another (all components match). * @@ -517,6 +517,10 @@ export class Rect2i { return `Rect2i(${this.x}, ${this.y}, ${this.width}, ${this.height})`; } + // #endregion + + // #region In-Place Mutation Methods + /** * Sets all components of this rectangle. * Modifies this rectangle directly for maximum performance. @@ -538,10 +542,6 @@ export class Rect2i { return this; } - // #endregion - - // #region Static Constructors - /** * Sets only the position of this rectangle, keeping the size unchanged. * Modifies this rectangle directly for maximum performance. @@ -631,10 +631,6 @@ export class Rect2i { return this; } - // #endregion - - // #region Static Factories - /** * Expands this rectangle by different amounts horizontally and vertically. * Modifies this rectangle directly for maximum performance. -- 2.54.0 From 07a6afef8b37ae4eab6662176ea83a55e1bba6be Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 18:10:11 +0100 Subject: [PATCH 13/16] refactor(utils): move contains() into Intersection Tests region Signed-off-by: Vaclav Vancura --- src/utils/Rect2i.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/Rect2i.ts b/src/utils/Rect2i.ts index ee99c40..019061a 100644 --- a/src/utils/Rect2i.ts +++ b/src/utils/Rect2i.ts @@ -329,6 +329,10 @@ export class Rect2i { return out; } + // #endregion + + // #region Intersection Tests + /** * Tests if a point lies within this rectangle. * Uses half-open interval: includes min, excludes max. @@ -342,10 +346,6 @@ export class Rect2i { ); } - // #endregion - - // #region Intersection Tests - /** * Tests raw coordinates against this rectangle without allocating a vector. * -- 2.54.0 From b3c8ebb91a8b4f78a0693f8aedad29177ddd2063 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 18:13:29 +0100 Subject: [PATCH 14/16] fix(utils): restore bitwise integer truncation in Color32 operations Signed-off-by: Vaclav Vancura --- src/utils/Color32.test.ts | 2 +- src/utils/Color32.ts | 43 ++++++++++++++++++++++----------------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/utils/Color32.test.ts b/src/utils/Color32.test.ts index cd4c111..1b4f388 100644 --- a/src/utils/Color32.test.ts +++ b/src/utils/Color32.test.ts @@ -226,7 +226,7 @@ describe('fromUint32', () => { it('unpacks ABGR correctly', () => { // ABGR: A=255, B=0, G=0, R=255 -> opaque red const packed = (255 << 24) | 255; - const c = Color32.fromUint32(packed); + const c = Color32.fromUint32(packed >>> 0); expect(c.r).toBe(255); expect(c.g).toBe(0); diff --git a/src/utils/Color32.ts b/src/utils/Color32.ts index d8a2bf6..acbdad0 100644 --- a/src/utils/Color32.ts +++ b/src/utils/Color32.ts @@ -401,7 +401,7 @@ export class Color32 { writeToFloat32Array(target: Float32Array, offset: number = 0): void { // Using direct index assignment for the best performance. // This is safe: offset is truncated to integer, the target is a typed Float32Array. - const i = offset; + const i = offset | 0; // eslint-disable-next-line security/detect-object-injection target[i] = this.r * INV_255; @@ -555,10 +555,10 @@ export class Color32 { const oneMinusT = 1 - tc; return Color32.fromRGBAUnchecked( - this.r * oneMinusT + other.r * tc, - this.g * oneMinusT + other.g * tc, - this.b * oneMinusT + other.b * tc, - this.a * oneMinusT + other.a * tc, + (this.r * oneMinusT + other.r * tc) | 0, + (this.g * oneMinusT + other.g * tc) | 0, + (this.b * oneMinusT + other.b * tc) | 0, + (this.a * oneMinusT + other.a * tc) | 0, ); } @@ -575,10 +575,10 @@ export class Color32 { const oneMinusT = 1 - tc; - this.r = this.r * oneMinusT + other.r * tc; - this.g = this.g * oneMinusT + other.g * tc; - this.b = this.b * oneMinusT + other.b * tc; - this.a = this.a * oneMinusT + other.a * tc; + this.r = (this.r * oneMinusT + other.r * tc) | 0; + this.g = (this.g * oneMinusT + other.g * tc) | 0; + this.b = (this.b * oneMinusT + other.b * tc) | 0; + this.a = (this.a * oneMinusT + other.a * tc) | 0; return this; } @@ -592,10 +592,10 @@ export class Color32 { */ multiply(other: Color32): Color32 { return Color32.fromRGBAUnchecked( - this.r * other.r * INV_255, - this.g * other.g * INV_255, - this.b * other.b * INV_255, - this.a * other.a * INV_255, + (this.r * other.r * INV_255) | 0, + (this.g * other.g * INV_255) | 0, + (this.b * other.b * INV_255) | 0, + (this.a * other.a * INV_255) | 0, ); } @@ -607,10 +607,10 @@ export class Color32 { * @returns This color instance for chaining. */ multiplyInPlace(other: Color32): this { - this.r = this.r * other.r * INV_255; - this.g = this.g * other.g * INV_255; - this.b = this.b * other.b * INV_255; - this.a = this.a * other.a * INV_255; + this.r = (this.r * other.r * INV_255) | 0; + this.g = (this.g * other.g * INV_255) | 0; + this.b = (this.b * other.b * INV_255) | 0; + this.a = (this.a * other.a * INV_255) | 0; return this; } @@ -651,7 +651,12 @@ export class Color32 { premultiplyAlpha(): Color32 { const alphaMul = this.a * INV_255; - return Color32.fromRGBAUnchecked(this.r * alphaMul, this.g * alphaMul, this.b * alphaMul, this.a); + return Color32.fromRGBAUnchecked( + (this.r * alphaMul) | 0, + (this.g * alphaMul) | 0, + (this.b * alphaMul) | 0, + this.a, + ); } // #endregion @@ -747,7 +752,7 @@ export class Color32 { export function clampByte(n: number): number { // Bitwise operations: if n < 0, use 0; if n > 255, use 255; else truncate n. // This is faster than Math.max(0, Math.min(255, n)) | 0. - return n < 0 ? 0 : n > 255 ? 255 : n; + return n < 0 ? 0 : n > 255 ? 255 : n | 0; } // #endregion -- 2.54.0 From 782529e6ed655d95d053fd7fd77d2a48a176452c Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 18:27:30 +0100 Subject: [PATCH 15/16] refactor(utils): fix Vector2i region boundaries and remove stray comment Signed-off-by: Vaclav Vancura --- src/BlitTech.test.ts | 1 - src/utils/Vector2i.ts | 16 ++++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 84edc46..7a45663 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -451,7 +451,6 @@ describe('BT.drawSprite', () => { expect(spy).toHaveBeenCalledWith(sheet, srcRect, destPos, undefined); }); - // it('forwards the tint argument', () => { const spy = vi.spyOn(BTAPI.instance, 'drawSprite').mockReturnValue(undefined); const sheet = new SpriteSheet(mockImage); diff --git a/src/utils/Vector2i.ts b/src/utils/Vector2i.ts index c7271b6..e8ace2a 100644 --- a/src/utils/Vector2i.ts +++ b/src/utils/Vector2i.ts @@ -419,10 +419,6 @@ export class Vector2i { return this.x * other.x + this.y * other.y; } - // #endregion - - // #region In-Place Mutation Methods - /** * Calculates the 2D cross-product (perpendicular dot product). * Returns the z-component of the 3D cross-product if vectors were in XY plane. @@ -609,6 +605,10 @@ export class Vector2i { return out; } + // #endregion + + // #region In-Place Mutation Methods + /** * Adds another vector to this one in place. * Modifies this vector directly for maximum performance. @@ -642,10 +642,6 @@ export class Vector2i { return this; } - // #endregion - - // #region Distance Calculations - /** * Subtracts another vector from this one in place. * Modifies this vector directly for maximum performance. @@ -814,10 +810,6 @@ export class Vector2i { return this; } - // #endregion - - // #region Normalization - /** * Clamps components to vector bounds in place. * Modifies this vector directly for maximum performance. -- 2.54.0 From 144fc836d20aae4e5cc20bd09d31008132332a04 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 27 Mar 2026 18:55:19 +0100 Subject: [PATCH 16/16] chore(gh): remove issue and PR templates Signed-off-by: Vaclav Vancura --- .github/ISSUE_TEMPLATE/bug_report.yml | 55 ---------------------- .github/ISSUE_TEMPLATE/feature_request.yml | 31 ------------ .github/PULL_REQUEST_TEMPLATE.md | 18 ------- 3 files changed, 104 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index c9abe15..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Bug Report -description: Report a bug in blit-tech -labels: ['bug'] -body: - - type: textarea - id: description - attributes: - label: Description - description: A clear description of the bug. - validations: - required: true - - - type: textarea - id: reproduction - attributes: - label: Steps to reproduce - description: Minimal steps to reproduce the issue. - placeholder: | - 1. Create a demo with ... - 2. Call BT.drawSprite(...) - 3. See error ... - validations: - required: true - - - type: textarea - id: expected - attributes: - label: Expected behavior - description: What you expected to happen. - validations: - required: true - - - type: input - id: version - attributes: - label: Version - description: blit-tech version (from package.json). - placeholder: '0.2.0' - validations: - required: true - - - type: input - id: browser - attributes: - label: Browser - description: Browser and version. - placeholder: 'Chrome 130' - validations: - required: true - - - type: textarea - id: additional - attributes: - label: Additional context - description: Any other context, screenshots, or error messages. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 7ebaf16..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Feature Request -description: Suggest a new feature or improvement -labels: ['enhancement'] -body: - - type: textarea - id: description - attributes: - label: Description - description: A clear description of the feature you'd like. - validations: - required: true - - - type: textarea - id: motivation - attributes: - label: Motivation - description: Why is this feature needed? What problem does it solve? - validations: - required: true - - - type: textarea - id: proposal - attributes: - label: Proposed solution - description: How you think this could be implemented. - - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Any alternative solutions or features you've considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 66d8ec9..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,18 +0,0 @@ -## Summary - - - -## Changes - -- - -## Test plan - -- [ ] `pnpm preflight` passes -- [ ] Manual testing completed - -## Checklist - -- [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org/) format -- [ ] All commits are signed off (DCO) -- [ ] No emoji in code, docs, or commit messages -- 2.54.0