Compare commits
33 commits
7f628180f0
...
c40c5d97db
| Author | SHA1 | Date | |
|---|---|---|---|
| c40c5d97db | |||
| 05c8a39bd8 | |||
| a7c04b2bd8 | |||
| 646564fc73 | |||
| f300dbd394 | |||
| ed5a4379db | |||
| 6bc125be1c | |||
| 2fe3c69963 | |||
| f03da42b5e | |||
| c94ffcc506 | |||
| 7c70f15e49 | |||
| ea0304356f | |||
| 15e99380b5 | |||
| d6a8f898d1 | |||
| ced0ac56f3 | |||
| 80ed37298b | |||
| 560398fefb | |||
| 2c7d72a699 | |||
| d2da0d1617 | |||
| ce383ce34c | |||
| 1fe5015056 | |||
| 70423851ba | |||
| 20433bd9f0 | |||
| 9256377c13 | |||
| c719b7e5b3 | |||
| 10a81ba474 | |||
| 2347ecd201 | |||
| 39b0160064 | |||
| 34ac200437 | |||
| cb1df6f29e | |||
| 4e92913925 | |||
| b1acdff594 | |||
| 6588930911 |
14 changed files with 44 additions and 556 deletions
|
|
@ -1,65 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
} from './normalize-runtime-settings';
|
||||
import type { GardenRuntimeSettings } from './types';
|
||||
|
||||
describe('normalizeNumberControlValue', () => {
|
||||
it('clamps and rounds numeric controls', () => {
|
||||
expect(
|
||||
normalizeNumberControlValue(12.6, {
|
||||
folder: 'Test',
|
||||
integer: true,
|
||||
max: 10,
|
||||
min: 0,
|
||||
})
|
||||
).toBe(10);
|
||||
|
||||
expect(
|
||||
normalizeNumberControlValue(Number.NaN, {
|
||||
folder: 'Test',
|
||||
min: 3,
|
||||
})
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps only declared option values', () => {
|
||||
expect(
|
||||
normalizeNumberControlValue(2, {
|
||||
folder: 'Test',
|
||||
options: { off: 0, on: 2 },
|
||||
})
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
normalizeNumberControlValue(3, {
|
||||
folder: 'Test',
|
||||
options: { off: 0, on: 2 },
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRuntimeSettings', () => {
|
||||
it('normalizes configured runtime keys and leaves hidden keys alone', () => {
|
||||
const settings = {
|
||||
brushSize: 99,
|
||||
selectedColorIndex: 7,
|
||||
} as GardenRuntimeSettings;
|
||||
|
||||
expect(
|
||||
normalizeRuntimeSettings(settings, {
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
max: 12,
|
||||
min: 1,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
brushSize: 12,
|
||||
selectedColorIndex: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -67,7 +67,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
folder: 'Movement',
|
||||
label: 'Travel Speed',
|
||||
min: 10,
|
||||
max: 250,
|
||||
max: 500,
|
||||
step: 1,
|
||||
},
|
||||
turnSpeed: {
|
||||
|
|
@ -116,7 +116,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
|
||||
clarity: {
|
||||
folder: 'Look',
|
||||
inverted: true,
|
||||
label: 'Sharpness',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = {
|
||||
min: 0.5,
|
||||
max: 16.6,
|
||||
} as const;
|
||||
|
|
@ -12,7 +12,6 @@ export interface NumberControlConfig {
|
|||
format?: (value: number) => string;
|
||||
folder: string;
|
||||
integer?: boolean;
|
||||
inverted?: boolean;
|
||||
label?: string;
|
||||
max?: number;
|
||||
min?: number;
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
import type { FolderApi } from '@tweakpane/core';
|
||||
|
||||
import { appConfig, normalizeNumberControlValue } from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { rgbColorToCss } from '../utils/rgb-color';
|
||||
|
||||
type PaneContainer = Pick<FolderApi, 'addFolder'>;
|
||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||
|
||||
const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
|
||||
const COLOR_REACTION_STATES = [
|
||||
{ id: 'follow', label: 'Move Toward', value: 1 },
|
||||
{ id: 'ignore', label: 'Ignore', value: 0 },
|
||||
{ id: 'avoid', label: 'Move Away', value: -1 },
|
||||
] as const;
|
||||
|
||||
const colorReactionRows = [
|
||||
{
|
||||
colorIndex: 0,
|
||||
label: COLOR_REACTION_LABELS[0],
|
||||
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
|
||||
},
|
||||
{
|
||||
colorIndex: 1,
|
||||
label: COLOR_REACTION_LABELS[1],
|
||||
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
|
||||
},
|
||||
{
|
||||
colorIndex: 2,
|
||||
label: COLOR_REACTION_LABELS[2],
|
||||
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const getColorReactionStateIndex = (value: number): number =>
|
||||
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
|
||||
|
||||
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
|
||||
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
|
||||
|
||||
const getNextColorReactionState = (
|
||||
value: number
|
||||
): (typeof COLOR_REACTION_STATES)[number] => {
|
||||
const index = getColorReactionStateIndex(value);
|
||||
return COLOR_REACTION_STATES[
|
||||
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
|
||||
];
|
||||
};
|
||||
|
||||
export class ColorReactionMatrixControl {
|
||||
private readonly buttons = new Map<
|
||||
ColorReactionKey,
|
||||
{
|
||||
element: HTMLButtonElement;
|
||||
sourceColorIndex: number;
|
||||
targetColorIndex: number;
|
||||
}
|
||||
>();
|
||||
private readonly swatches: Array<{
|
||||
colorIndex: number;
|
||||
element: HTMLElement;
|
||||
}> = [];
|
||||
|
||||
public constructor(private readonly onRuntimeChange: () => void) {}
|
||||
|
||||
public addTo(container: PaneContainer): void {
|
||||
const folder = container.addFolder({
|
||||
title: 'Color Behavior',
|
||||
expanded: true,
|
||||
});
|
||||
|
||||
const matrix = document.createElement('div');
|
||||
matrix.className = 'color-reaction-matrix';
|
||||
|
||||
matrix.appendChild(this.createCorner());
|
||||
colorReactionRows.forEach((row) => {
|
||||
matrix.appendChild(this.createHeader(row.colorIndex, row.label));
|
||||
});
|
||||
|
||||
colorReactionRows.forEach((row) => {
|
||||
matrix.appendChild(this.createHeader(row.colorIndex, row.label));
|
||||
row.keys.forEach((key, columnIndex) => {
|
||||
matrix.appendChild(this.createCell(key, row.colorIndex, columnIndex));
|
||||
});
|
||||
});
|
||||
|
||||
const matrixBlade = folder.addBlade({ view: 'separator' });
|
||||
matrixBlade.element.classList.add('color-reaction-matrix-blade');
|
||||
matrixBlade.element.replaceChildren(matrix);
|
||||
this.sync();
|
||||
}
|
||||
|
||||
public sync(): void {
|
||||
this.buttons.forEach(({ element, sourceColorIndex, targetColorIndex }, key) => {
|
||||
this.syncButton(element, key, sourceColorIndex, targetColorIndex);
|
||||
});
|
||||
|
||||
this.swatches.forEach(({ colorIndex, element }) => {
|
||||
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
|
||||
});
|
||||
}
|
||||
|
||||
private createCorner(): HTMLDivElement {
|
||||
const corner = document.createElement('div');
|
||||
corner.className = 'color-reaction-matrix__corner';
|
||||
return corner;
|
||||
}
|
||||
|
||||
private createHeader(colorIndex: number, label: string): HTMLDivElement {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'color-reaction-matrix__header';
|
||||
header.setAttribute('aria-label', label);
|
||||
header.title = label;
|
||||
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'color-reaction-matrix__swatch';
|
||||
this.swatches.push({ colorIndex, element: swatch });
|
||||
header.appendChild(swatch);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
private createCell(
|
||||
key: ColorReactionKey,
|
||||
sourceColorIndex: number,
|
||||
targetColorIndex: number
|
||||
): HTMLDivElement {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'color-reaction-matrix__cell';
|
||||
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return cell;
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'color-reaction-matrix__button';
|
||||
button.type = 'button';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'color-reaction-matrix__icon';
|
||||
button.appendChild(icon);
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const currentValue = normalizeNumberControlValue(settings[key], config);
|
||||
const nextState = getNextColorReactionState(currentValue);
|
||||
settings[key] = nextState.value;
|
||||
this.syncButton(button, key, sourceColorIndex, targetColorIndex);
|
||||
this.onRuntimeChange();
|
||||
});
|
||||
|
||||
this.buttons.set(key, {
|
||||
element: button,
|
||||
sourceColorIndex,
|
||||
targetColorIndex,
|
||||
});
|
||||
cell.appendChild(button);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
private syncButton(
|
||||
button: HTMLButtonElement,
|
||||
key: ColorReactionKey,
|
||||
sourceColorIndex: number,
|
||||
targetColorIndex: number
|
||||
): void {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings[key] = normalizeNumberControlValue(settings[key], config);
|
||||
|
||||
const state = getColorReactionState(settings[key]);
|
||||
const nextState = getNextColorReactionState(settings[key]);
|
||||
const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
|
||||
const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
|
||||
|
||||
button.dataset.reaction = state.id;
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
`${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
|
||||
);
|
||||
button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
|
||||
}
|
||||
}
|
||||
|
|
@ -81,16 +81,6 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
|||
return params;
|
||||
};
|
||||
|
||||
const getInvertedNumberControlValue = (
|
||||
value: number,
|
||||
config: NumberControlConfig
|
||||
): number => {
|
||||
if (!config.inverted || config.min === undefined || config.max === undefined) {
|
||||
return value;
|
||||
}
|
||||
return config.min + config.max - value;
|
||||
};
|
||||
|
||||
export class ConfigPane {
|
||||
private readonly container: HTMLDivElement;
|
||||
private readonly closeButton: HTMLButtonElement;
|
||||
|
|
@ -274,10 +264,9 @@ export class ConfigPane {
|
|||
}
|
||||
|
||||
settings[key] = normalizeNumberControlValue(settings[key], config);
|
||||
const bindingTarget = this.getRuntimeBindingTarget(key, config);
|
||||
|
||||
container
|
||||
.addBinding(bindingTarget, key, getNumberBindingParams(config))
|
||||
.addBinding(settings, key, getNumberBindingParams(config))
|
||||
.on('change', () => {
|
||||
const nextValue = normalizeNumberControlValue(settings[key], config);
|
||||
if (nextValue !== settings[key]) {
|
||||
|
|
@ -288,25 +277,6 @@ export class ConfigPane {
|
|||
});
|
||||
}
|
||||
|
||||
private getRuntimeBindingTarget(
|
||||
key: RuntimeControlKey,
|
||||
config: NumberControlConfig
|
||||
): typeof settings | Record<RuntimeControlKey, number> {
|
||||
if (!config.inverted) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
const bindingTarget = {} as Record<RuntimeControlKey, number>;
|
||||
Object.defineProperty(bindingTarget, key, {
|
||||
enumerable: true,
|
||||
get: () => getInvertedNumberControlValue(settings[key], config),
|
||||
set: (value: number) => {
|
||||
settings[key] = getInvertedNumberControlValue(value, config);
|
||||
},
|
||||
});
|
||||
return bindingTarget;
|
||||
}
|
||||
|
||||
private getRuntimeControlConfig(
|
||||
key: RuntimeControlKey
|
||||
): NumberControlConfig | undefined {
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import {
|
||||
getEraserSizeFromSliderRatio,
|
||||
getEraserSliderRatioFromSize,
|
||||
} from './eraser-size-control';
|
||||
|
||||
describe('eraser size slider mapping', () => {
|
||||
it('maps slider position quadratically to eraser size', () => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
|
||||
expect(getEraserSizeFromSliderRatio(0)).toBe(min);
|
||||
expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25);
|
||||
expect(getEraserSizeFromSliderRatio(1)).toBe(max);
|
||||
});
|
||||
|
||||
it('maps eraser size back to the inverse slider position', () => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
const quarterRangeSize = min + (max - min) * 0.25;
|
||||
|
||||
expect(getEraserSliderRatioFromSize(min)).toBe(0);
|
||||
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
|
||||
expect(getEraserSliderRatioFromSize(max)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,28 +9,11 @@ const clampEraserSize = (value: number): number => {
|
|||
return Math.min(max, Math.max(min, Math.round(safeValue)));
|
||||
};
|
||||
|
||||
const ERASER_SLIDER_MIN = 0;
|
||||
const ERASER_SLIDER_MAX = 1;
|
||||
const ERASER_SLIDER_STEP = 0.001;
|
||||
|
||||
const clampSliderRatio = (value: number): number => {
|
||||
const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
|
||||
return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
|
||||
};
|
||||
|
||||
const getEraserSizeRatio = (size: number): number => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
return (clampEraserSize(size) - min) / (max - min);
|
||||
return (size - min) / (max - min);
|
||||
};
|
||||
|
||||
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2);
|
||||
};
|
||||
|
||||
export const getEraserSliderRatioFromSize = (size: number): number =>
|
||||
Math.sqrt(getEraserSizeRatio(size));
|
||||
|
||||
interface EraserSizeControlOptions {
|
||||
getGame: () => GameLoop | null;
|
||||
onActivate: () => void;
|
||||
|
|
@ -50,7 +33,7 @@ export class EraserSizeControl {
|
|||
this.control.addEventListener('click', this.activate);
|
||||
this.slider.addEventListener('focus', this.activate);
|
||||
this.slider.addEventListener('input', () => {
|
||||
settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
|
||||
settings.eraserSize = clampEraserSize(Number(this.slider.value));
|
||||
this.activate();
|
||||
this.render();
|
||||
this.options.onChange();
|
||||
|
|
@ -63,20 +46,19 @@ export class EraserSizeControl {
|
|||
settings.eraserSize = size;
|
||||
}
|
||||
|
||||
const sliderRatio = getEraserSliderRatioFromSize(size);
|
||||
this.slider.min = ERASER_SLIDER_MIN.toString();
|
||||
this.slider.max = ERASER_SLIDER_MAX.toString();
|
||||
this.slider.step = ERASER_SLIDER_STEP.toString();
|
||||
this.slider.value = sliderRatio.toString();
|
||||
this.slider.min = appConfig.toolbar.eraser.min.toString();
|
||||
this.slider.max = appConfig.toolbar.eraser.max.toString();
|
||||
this.slider.step = appConfig.toolbar.eraser.step.toString();
|
||||
this.slider.value = size.toString();
|
||||
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
||||
|
||||
const sizeRatio = getEraserSizeRatio(size);
|
||||
const ratio = getEraserSizeRatio(size);
|
||||
const scale =
|
||||
appConfig.toolbar.eraser.controlScaleMin +
|
||||
(appConfig.toolbar.eraser.controlScaleMax -
|
||||
appConfig.toolbar.eraser.controlScaleMin) *
|
||||
sizeRatio;
|
||||
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
|
||||
ratio;
|
||||
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
||||
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
||||
this.syncActiveState();
|
||||
this.options.getGame()?.updateEraserPreview();
|
||||
|
|
|
|||
|
|
@ -121,15 +121,12 @@ html > body {
|
|||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
max-width: calc(100% - var(--normal-margin) * 2);
|
||||
margin: var(--normal-margin);
|
||||
z-index: 5;
|
||||
|
||||
pre {
|
||||
font-size: 20px;
|
||||
color: red;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,6 @@
|
|||
--tp-blade-value-width: min(210px, 62%);
|
||||
--tp-container-unit-size: 18px;
|
||||
|
||||
padding-bottom: 10px;
|
||||
font-size: 11px;
|
||||
|
||||
// Tweakpane v4 internal class — re-verify on upgrade.
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initializeGpu } from './initialize-gpu';
|
||||
|
||||
const limits = {
|
||||
maxBufferSize: 1024,
|
||||
maxComputeWorkgroupsPerDimension: 16,
|
||||
maxStorageBufferBindingSize: 1024,
|
||||
} as unknown as GPUSupportedLimits;
|
||||
|
||||
const createDevice = (): GPUDevice =>
|
||||
({
|
||||
addEventListener: vi.fn(),
|
||||
lost: new Promise(() => undefined),
|
||||
}) as unknown as GPUDevice;
|
||||
|
||||
const createAdapter = (features: Array<GPUFeatureName> = []): GPUAdapter => {
|
||||
const device = createDevice();
|
||||
return {
|
||||
features: new Set(features),
|
||||
info: {},
|
||||
limits,
|
||||
requestDevice: vi.fn().mockResolvedValue(device),
|
||||
} as unknown as GPUAdapter;
|
||||
};
|
||||
|
||||
const stubSecureWebGpu = (requestAdapter: GPU['requestAdapter']): void => {
|
||||
vi.stubGlobal('window', { isSecureContext: true });
|
||||
vi.stubGlobal('navigator', {
|
||||
gpu: {
|
||||
requestAdapter,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('initializeGpu', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('starts with the least demanding compatibility adapter request', async () => {
|
||||
const adapter = createAdapter();
|
||||
const requestAdapter = vi.fn().mockResolvedValue(adapter);
|
||||
stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
|
||||
|
||||
await initializeGpu();
|
||||
|
||||
expect(requestAdapter).toHaveBeenNthCalledWith(1, {
|
||||
featureLevel: 'compatibility',
|
||||
});
|
||||
expect(requestAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.requestDevice).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('continues trying adapters if one request throws', async () => {
|
||||
const adapter = createAdapter();
|
||||
const requestAdapter = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('adapter request failed'))
|
||||
.mockResolvedValueOnce(adapter);
|
||||
stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
|
||||
|
||||
await expect(initializeGpu()).resolves.toBeDefined();
|
||||
expect(requestAdapter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back through core and high-performance adapter requests', async () => {
|
||||
const adapter = createAdapter();
|
||||
const requestAdapter = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(adapter);
|
||||
stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
|
||||
|
||||
await initializeGpu();
|
||||
|
||||
expect(requestAdapter).toHaveBeenNthCalledWith(1, {
|
||||
featureLevel: 'compatibility',
|
||||
});
|
||||
expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined);
|
||||
expect(requestAdapter).toHaveBeenNthCalledWith(3, {
|
||||
featureLevel: 'compatibility',
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
expect(adapter.requestDevice).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests only the core feature when the adapter exposes optional features', async () => {
|
||||
const adapter = createAdapter(['core-features-and-limits', 'timestamp-query']);
|
||||
const requestAdapter = vi.fn().mockResolvedValue(adapter);
|
||||
stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
|
||||
|
||||
await initializeGpu();
|
||||
|
||||
expect(adapter.requestDevice).toHaveBeenCalledWith({
|
||||
requiredFeatures: ['core-features-and-limits'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -15,30 +15,7 @@ const REQUESTED_LIMIT_NAMES = [
|
|||
'maxComputeWorkgroupsPerDimension',
|
||||
] as const satisfies ReadonlyArray<keyof GPUSupportedLimits>;
|
||||
|
||||
interface AdapterRequestAttempt {
|
||||
label: string;
|
||||
options?: GPURequestAdapterOptions;
|
||||
}
|
||||
|
||||
const ADAPTER_REQUEST_ATTEMPTS: ReadonlyArray<AdapterRequestAttempt> = [
|
||||
{
|
||||
label: 'compatibility-default',
|
||||
options: { featureLevel: 'compatibility' },
|
||||
},
|
||||
{
|
||||
label: 'core-default',
|
||||
},
|
||||
{
|
||||
label: 'compatibility-high-performance',
|
||||
options: { featureLevel: 'compatibility', powerPreference: 'high-performance' },
|
||||
},
|
||||
{
|
||||
label: 'core-high-performance',
|
||||
options: { powerPreference: 'high-performance' },
|
||||
},
|
||||
] as const;
|
||||
|
||||
const getRelevantLimits = (
|
||||
const getRequiredLimits = (
|
||||
limits: GPUSupportedLimits
|
||||
): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> =>
|
||||
Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record<
|
||||
|
|
@ -65,64 +42,25 @@ const getAdapterInfo = (adapter: GPUAdapter): Record<string, unknown> => {
|
|||
}
|
||||
};
|
||||
|
||||
const getRequiredFeatures = (adapter: GPUAdapter): Array<GPUFeatureName> => {
|
||||
const requiredFeatures: Array<GPUFeatureName> = [];
|
||||
|
||||
if (adapter.features.has('core-features-and-limits')) {
|
||||
requiredFeatures.push('core-features-and-limits');
|
||||
}
|
||||
|
||||
return requiredFeatures;
|
||||
};
|
||||
|
||||
type AdapterRequestOutcome = 'adapter' | 'unavailable' | 'error';
|
||||
|
||||
const describeAdapterRequest = (
|
||||
attempt: AdapterRequestAttempt,
|
||||
outcome: AdapterRequestOutcome,
|
||||
causeMessage?: string
|
||||
): Record<string, unknown> => ({
|
||||
label: attempt.label,
|
||||
featureLevel: attempt.options?.featureLevel ?? 'core',
|
||||
powerPreference: attempt.options?.powerPreference ?? 'default',
|
||||
outcome,
|
||||
...(causeMessage === undefined ? {} : { causeMessage }),
|
||||
});
|
||||
|
||||
const requestAdapter = async (
|
||||
gpu: GPU
|
||||
): Promise<{
|
||||
adapter: GPUAdapter | null;
|
||||
attempts: Array<Record<string, unknown>>;
|
||||
}> => {
|
||||
const attempts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attempt of ADAPTER_REQUEST_ATTEMPTS) {
|
||||
try {
|
||||
const adapter = await gpu.requestAdapter(attempt.options);
|
||||
attempts.push(describeAdapterRequest(attempt, adapter ? 'adapter' : 'unavailable'));
|
||||
|
||||
if (adapter) {
|
||||
return { adapter, attempts };
|
||||
gpu: GPU,
|
||||
options?: GPURequestAdapterOptions
|
||||
): Promise<GPUAdapter | null> => {
|
||||
try {
|
||||
return await gpu.requestAdapter(options);
|
||||
} catch (error) {
|
||||
throw new RuntimeError(
|
||||
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
|
||||
'Could not request a WebGPU adapter.',
|
||||
{
|
||||
cause: error,
|
||||
details: {
|
||||
causeMessage: getErrorMessage(error),
|
||||
powerPreference: options?.powerPreference ?? 'default',
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
attempts.push(describeAdapterRequest(attempt, 'error', getErrorMessage(error)));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { adapter: null, attempts };
|
||||
};
|
||||
|
||||
const formatAdapterAttemptSummary = (
|
||||
attempts: Array<Record<string, unknown>>
|
||||
): string => {
|
||||
if (attempts.length === 0) {
|
||||
return 'No adapter requests were attempted.';
|
||||
}
|
||||
|
||||
return `Adapter attempts: ${attempts
|
||||
.map((attempt) => `${attempt.label}: ${attempt.outcome}`)
|
||||
.join('; ')}`;
|
||||
};
|
||||
|
||||
export const initializeGpu = async (): Promise<GPUDevice> => {
|
||||
|
|
@ -143,43 +81,35 @@ export const initializeGpu = async (): Promise<GPUDevice> => {
|
|||
});
|
||||
}
|
||||
|
||||
const { adapter, attempts } = await requestAdapter(gpu);
|
||||
ErrorHandler.addMetadata('webgpuAdapterRequest', {
|
||||
attempts,
|
||||
selectedAttempt: attempts[attempts.length - 1]?.label ?? null,
|
||||
});
|
||||
const adapter =
|
||||
(await requestAdapter(gpu, {
|
||||
powerPreference: 'high-performance',
|
||||
})) ?? (await requestAdapter(gpu));
|
||||
|
||||
if (!adapter) {
|
||||
throw new RuntimeError(
|
||||
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
|
||||
[
|
||||
'WebGPU is available, but this browser could not provide a compatible GPU adapter.',
|
||||
formatAdapterAttemptSummary(attempts),
|
||||
].join('\n'),
|
||||
{
|
||||
details: {
|
||||
attempts,
|
||||
hasNavigatorGpu: true,
|
||||
isSecureContext: window.isSecureContext,
|
||||
platform: navigator.platform,
|
||||
userAgent: navigator.userAgent,
|
||||
},
|
||||
}
|
||||
'WebGPU is available, but this browser could not provide a compatible GPU adapter.'
|
||||
);
|
||||
}
|
||||
|
||||
const requiredFeatures = getRequiredFeatures(adapter);
|
||||
const requiredLimits = getRequiredLimits(adapter.limits);
|
||||
const requiredFeatures: Array<GPUFeatureName> = [];
|
||||
if (adapter.features.has('timestamp-query')) {
|
||||
requiredFeatures.push('timestamp-query');
|
||||
}
|
||||
ErrorHandler.addMetadata('webgpuAdapter', {
|
||||
features: Array.from(adapter.features).sort(),
|
||||
info: getAdapterInfo(adapter),
|
||||
requiredFeatures,
|
||||
relevantLimits: getRelevantLimits(adapter.limits),
|
||||
requiredLimits,
|
||||
});
|
||||
|
||||
let gpuDevice: GPUDevice;
|
||||
try {
|
||||
gpuDevice = await adapter.requestDevice({
|
||||
requiredFeatures,
|
||||
requiredLimits,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RuntimeError(
|
||||
|
|
@ -190,6 +120,7 @@ export const initializeGpu = async (): Promise<GPUDevice> => {
|
|||
details: {
|
||||
causeMessage: getErrorMessage(error),
|
||||
requiredFeatures,
|
||||
requiredLimits,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { appConfig } from './config';
|
||||
import type { VibeId, VibePreset } from './config/types';
|
||||
|
||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||
|
||||
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
|
||||
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
|
||||
|
|
@ -11,9 +11,9 @@ describe('vibe URI handling', () => {
|
|||
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
|
||||
VibeId.AuroraMycelium
|
||||
);
|
||||
expect(
|
||||
getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')
|
||||
).toBe(VibeId.VelvetObservatory);
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe(
|
||||
VibeId.VelvetObservatory
|
||||
);
|
||||
});
|
||||
|
||||
it('uses query values before path or hash fallbacks', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue