more clean up
This commit is contained in:
parent
7c70f15e49
commit
c94ffcc506
6 changed files with 514 additions and 0 deletions
150
src/game-loop/brush-stroke-smoother.ts
Normal file
150
src/game-loop/brush-stroke-smoother.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import { type StrokeSegment } from './game-loop-types';
|
||||||
|
|
||||||
|
interface BrushStrokeSmootherOptions {
|
||||||
|
getCanvasPixelRatio: () => number;
|
||||||
|
getMirrorSegmentCount: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrushStrokeSmoother {
|
||||||
|
private readonly strokePoints: Array<vec2> = [];
|
||||||
|
private lastBrushPosition: vec2 | null = null;
|
||||||
|
|
||||||
|
public constructor(private readonly options: BrushStrokeSmootherOptions) {}
|
||||||
|
|
||||||
|
public addSample(position: vec2): Array<StrokeSegment> {
|
||||||
|
const previousSample = this.strokePoints[this.strokePoints.length - 1];
|
||||||
|
if (
|
||||||
|
previousSample !== undefined &&
|
||||||
|
vec2.squaredDistance(previousSample, position) <=
|
||||||
|
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.strokePoints.push(vec2.clone(position));
|
||||||
|
|
||||||
|
if (this.strokePoints.length > 3) {
|
||||||
|
this.strokePoints.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.strokePoints.length === 1) {
|
||||||
|
this.lastBrushPosition = vec2.clone(position);
|
||||||
|
return [{ from: position, to: position }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.strokePoints.length === 2) {
|
||||||
|
const [start, end] = this.strokePoints;
|
||||||
|
const midpoint = getMidpoint(start, end);
|
||||||
|
this.lastBrushPosition = midpoint;
|
||||||
|
return [{ from: start, to: midpoint }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [start, control, end] = this.strokePoints;
|
||||||
|
const curveStart = getMidpoint(start, control);
|
||||||
|
const curveEnd = getMidpoint(control, end);
|
||||||
|
this.lastBrushPosition = curveEnd;
|
||||||
|
return this.getQuadraticSegments(curveStart, control, curveEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public finish(): Array<StrokeSegment> {
|
||||||
|
if (this.strokePoints.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSample = this.strokePoints[this.strokePoints.length - 1];
|
||||||
|
if (
|
||||||
|
this.lastBrushPosition !== null &&
|
||||||
|
vec2.squaredDistance(this.lastBrushPosition, finalSample) >
|
||||||
|
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||||
|
) {
|
||||||
|
return [{ from: this.lastBrushPosition, to: finalSample }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
this.strokePoints.length = 0;
|
||||||
|
this.lastBrushPosition = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public scale(scale: vec2): void {
|
||||||
|
this.strokePoints.forEach((point) => {
|
||||||
|
vec2.mul(point, point, scale);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.lastBrushPosition !== null) {
|
||||||
|
vec2.mul(this.lastBrushPosition, this.lastBrushPosition, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getQuadraticSegments(
|
||||||
|
start: vec2,
|
||||||
|
control: vec2,
|
||||||
|
end: vec2
|
||||||
|
): Array<StrokeSegment> {
|
||||||
|
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||||
|
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
|
||||||
|
const brushRadius = Math.max(
|
||||||
|
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
||||||
|
(settings.brushSize * canvasPixelRatio) / 2
|
||||||
|
);
|
||||||
|
const segmentSpacing = Math.max(
|
||||||
|
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
||||||
|
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||||
|
);
|
||||||
|
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||||
|
const curveResolution = getBrushCurveResolution();
|
||||||
|
const maxCurveSegments = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
curveResolution /
|
||||||
|
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const segmentCount = Math.min(
|
||||||
|
maxCurveSegments,
|
||||||
|
Math.max(1, Math.ceil(curveLength / segmentSpacing))
|
||||||
|
);
|
||||||
|
|
||||||
|
let previousPoint = start;
|
||||||
|
const segments: Array<StrokeSegment> = [];
|
||||||
|
for (let i = 1; i <= segmentCount; i++) {
|
||||||
|
const point = getQuadraticPoint(start, control, end, i / segmentCount);
|
||||||
|
segments.push({ from: previousPoint, to: point });
|
||||||
|
previousPoint = point;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMidpoint = (from: vec2, to: vec2): vec2 =>
|
||||||
|
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
|
||||||
|
|
||||||
|
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
|
||||||
|
const inverseT = 1 - t;
|
||||||
|
return vec2.fromValues(
|
||||||
|
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
|
||||||
|
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBrushCurveResolution = (): number => {
|
||||||
|
const resolution = Number.isFinite(settings.brushCurveResolution)
|
||||||
|
? settings.brushCurveResolution
|
||||||
|
: appConfig.defaultSettings.brushCurveResolution;
|
||||||
|
return Math.max(1, Math.floor(resolution));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
|
||||||
|
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||||
|
? settings.brushSmoothingMinSampleDistance
|
||||||
|
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||||
|
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
|
||||||
|
};
|
||||||
71
src/game-loop/eraser-pointer-preview-controller.ts
Normal file
71
src/game-loop/eraser-pointer-preview-controller.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { EraserPreview } from './eraser-preview';
|
||||||
|
|
||||||
|
interface EraserPointerPreviewControllerOptions {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
eraserPreview: EraserPreview;
|
||||||
|
getIsSwipeActive: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EraserPointerPreviewController {
|
||||||
|
public constructor(private readonly options: EraserPointerPreviewControllerOptions) {}
|
||||||
|
|
||||||
|
public attach(): void {
|
||||||
|
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
|
||||||
|
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
|
||||||
|
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||||
|
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||||
|
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||||
|
this.canvas.addEventListener('pointercancel', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public detach(): void {
|
||||||
|
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
|
||||||
|
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
|
||||||
|
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||||
|
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||||
|
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||||
|
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEraseMode(isErasing: boolean): void {
|
||||||
|
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(event?: PointerEvent): void {
|
||||||
|
this.options.eraserPreview.update(event, this.isSwipeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get canvas(): HTMLCanvasElement {
|
||||||
|
return this.options.canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get isSwipeActive(): boolean {
|
||||||
|
return this.options.getIsSwipeActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onPointerDown = (event: PointerEvent) => {
|
||||||
|
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||||
|
this.update(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerMove = (event: PointerEvent) => {
|
||||||
|
this.update(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerUp = (event: PointerEvent) => {
|
||||||
|
this.options.eraserPreview.setPointerHoveringCanvas(
|
||||||
|
this.options.eraserPreview.isPointerInsideCanvas(event)
|
||||||
|
);
|
||||||
|
this.update(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerEnter = (event: PointerEvent) => {
|
||||||
|
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||||
|
this.update(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerLeave = () => {
|
||||||
|
this.options.eraserPreview.setPointerHoveringCanvas(false);
|
||||||
|
this.update();
|
||||||
|
};
|
||||||
|
}
|
||||||
202
src/game-loop/export-snapshot-renderer.ts
Normal file
202
src/game-loop/export-snapshot-renderer.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
|
import type { VibeId } from '../vibes';
|
||||||
|
|
||||||
|
interface ExportSnapshotRendererOptions {
|
||||||
|
device: GPUDevice;
|
||||||
|
renderPipeline: RenderPipeline;
|
||||||
|
statusElement: HTMLElement;
|
||||||
|
seed: string;
|
||||||
|
getSourceSize: () => { width: number; height: number };
|
||||||
|
getColorTextureView: () => GPUTextureView;
|
||||||
|
getSourceTextureView: () => GPUTextureView;
|
||||||
|
getVibeId: () => VibeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotLayout {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
unpaddedBytesPerRow: number;
|
||||||
|
bytesPerRow: number;
|
||||||
|
readbackBufferBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExportSnapshotRenderer {
|
||||||
|
private isExporting = false;
|
||||||
|
|
||||||
|
public constructor(private readonly options: ExportSnapshotRendererOptions) {}
|
||||||
|
|
||||||
|
public async export(): Promise<void> {
|
||||||
|
if (this.isExporting) {
|
||||||
|
this.statusElement.textContent = 'Snapshot already saving...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isExporting = true;
|
||||||
|
this.statusElement.textContent = 'Saving snapshot...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceSize = this.options.getSourceSize();
|
||||||
|
await this.renderSnapshot(getSnapshotLayout(sourceSize.width, sourceSize.height));
|
||||||
|
this.statusElement.textContent = '';
|
||||||
|
} catch (error) {
|
||||||
|
this.statusElement.textContent = 'Snapshot failed';
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
|
||||||
|
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
|
||||||
|
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||||
|
let texture: GPUTexture | null = null;
|
||||||
|
let output: GPUBuffer | null = null;
|
||||||
|
let isOutputMapped = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
texture = this.device.createTexture({
|
||||||
|
size: { width, height },
|
||||||
|
format,
|
||||||
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||||
|
});
|
||||||
|
output = this.device.createBuffer({
|
||||||
|
size: layout.readbackBufferBytes,
|
||||||
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
this.options.renderPipeline.executeToView(
|
||||||
|
commandEncoder,
|
||||||
|
this.options.getColorTextureView(),
|
||||||
|
this.options.getSourceTextureView(),
|
||||||
|
texture.createView()
|
||||||
|
);
|
||||||
|
commandEncoder.copyTextureToBuffer(
|
||||||
|
{ texture },
|
||||||
|
{ buffer: output, bytesPerRow, rowsPerImage: height },
|
||||||
|
{ width, height }
|
||||||
|
);
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
|
||||||
|
await output.mapAsync(GPUMapMode.READ);
|
||||||
|
isOutputMapped = true;
|
||||||
|
const pixels = readSnapshotPixels({
|
||||||
|
mapped: new Uint8Array(output.getMappedRange()),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
isBgra: format === 'bgra8unorm',
|
||||||
|
});
|
||||||
|
output.unmap();
|
||||||
|
isOutputMapped = false;
|
||||||
|
output.destroy();
|
||||||
|
output = null;
|
||||||
|
texture.destroy();
|
||||||
|
texture = null;
|
||||||
|
|
||||||
|
await this.downloadPixels(pixels, width, height);
|
||||||
|
} finally {
|
||||||
|
if (output && isOutputMapped) {
|
||||||
|
output.unmap();
|
||||||
|
}
|
||||||
|
output?.destroy();
|
||||||
|
texture?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadPixels(
|
||||||
|
pixels: Uint8ClampedArray<ArrayBuffer>,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<void> {
|
||||||
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Could not create export canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||||
|
const blob = await canvas.convertToBlob({
|
||||||
|
type: appConfig.exportSnapshot.mimeType,
|
||||||
|
});
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
|
||||||
|
this.options.seed
|
||||||
|
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
|
||||||
|
link.click();
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get device(): GPUDevice {
|
||||||
|
return this.options.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get statusElement(): HTMLElement {
|
||||||
|
return this.options.statusElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignTo = (value: number, alignment: number): number =>
|
||||||
|
Math.ceil(value / alignment) * alignment;
|
||||||
|
|
||||||
|
const getSnapshotDimension = (value: number): number =>
|
||||||
|
Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 1;
|
||||||
|
|
||||||
|
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
|
||||||
|
const width = getSnapshotDimension(sourceWidth);
|
||||||
|
const height = getSnapshotDimension(sourceHeight);
|
||||||
|
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
|
||||||
|
const bytesPerRow = alignTo(
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
appConfig.exportSnapshot.rowAlignmentBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
readbackBufferBytes: bytesPerRow * height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSnapshotPixels = ({
|
||||||
|
mapped,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
isBgra,
|
||||||
|
}: {
|
||||||
|
mapped: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
unpaddedBytesPerRow: number;
|
||||||
|
bytesPerRow: number;
|
||||||
|
isBgra: boolean;
|
||||||
|
}): Uint8ClampedArray<ArrayBuffer> => {
|
||||||
|
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
|
||||||
|
unpaddedBytesPerRow * height
|
||||||
|
);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const sourceOffset = y * bytesPerRow;
|
||||||
|
const targetOffset = y * unpaddedBytesPerRow;
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||||
|
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||||
|
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||||
|
pixels[target + 1] = mapped[source + 1];
|
||||||
|
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||||
|
pixels[target + 3] = mapped[source + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pixels;
|
||||||
|
};
|
||||||
42
src/game-loop/stroke-mirroring.ts
Normal file
42
src/game-loop/stroke-mirroring.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { type StrokeSegment } from './game-loop-types';
|
||||||
|
|
||||||
|
export const getMirroredStrokeSegments = (
|
||||||
|
from: vec2,
|
||||||
|
to: vec2,
|
||||||
|
canvasSize: vec2,
|
||||||
|
segmentCount: number
|
||||||
|
): Array<StrokeSegment> => {
|
||||||
|
if (segmentCount <= 1) {
|
||||||
|
return [{ from, to }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = vec2.fromValues(canvasSize[0] / 2, canvasSize[1] / 2);
|
||||||
|
const angleStep = (Math.PI * 2) / segmentCount;
|
||||||
|
const segments: Array<StrokeSegment> = [];
|
||||||
|
for (let i = 0; i < segmentCount; i++) {
|
||||||
|
const angle = angleStep * i;
|
||||||
|
segments.push({
|
||||||
|
from: rotatePointAround(from, center, angle),
|
||||||
|
to: rotatePointAround(to, center, angle),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
||||||
|
if (angle === 0) {
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetX = point[0] - center[0];
|
||||||
|
const offsetY = point[1] - center[1];
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
return vec2.fromValues(
|
||||||
|
center[0] + offsetX * cos - offsetY * sin,
|
||||||
|
center[1] + offsetX * sin + offsetY * cos
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/game-loop/stroke-output.ts
Normal file
34
src/game-loop/stroke-output.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { type BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import { type EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
|
import { type EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
|
|
||||||
|
export interface StrokeOutput {
|
||||||
|
addBrushSegment(from: vec2, to: vec2): void;
|
||||||
|
addEraseSegment(from: vec2, to: vec2): void;
|
||||||
|
clearSwipes(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PipelineStrokeOutput implements StrokeOutput {
|
||||||
|
public constructor(
|
||||||
|
private readonly brushPipeline: BrushPipeline,
|
||||||
|
private readonly eraserAgentPipeline: EraserAgentPipeline,
|
||||||
|
private readonly eraserTexturePipeline: EraserTexturePipeline
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public addBrushSegment(from: vec2, to: vec2): void {
|
||||||
|
this.brushPipeline.addSwipeSegment(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public addEraseSegment(from: vec2, to: vec2): void {
|
||||||
|
this.eraserAgentPipeline.addSwipeSegment();
|
||||||
|
this.eraserTexturePipeline.addSwipeSegment(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSwipes(): void {
|
||||||
|
this.brushPipeline.clearSwipes();
|
||||||
|
this.eraserAgentPipeline.clearSwipes();
|
||||||
|
this.eraserTexturePipeline.clearSwipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/utils/rgb-color.ts
Normal file
15
src/utils/rgb-color.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export type RgbColor = [red: number, green: number, blue: number];
|
||||||
|
|
||||||
|
const RGB_CHANNEL_MAX = 255;
|
||||||
|
|
||||||
|
const toFiniteRgbChannel = (value: number): number =>
|
||||||
|
Number.isFinite(value) ? value : 0;
|
||||||
|
|
||||||
|
const clampRgbChannel = (value: number): number =>
|
||||||
|
Math.min(RGB_CHANNEL_MAX, Math.max(0, Math.round(toFiniteRgbChannel(value))));
|
||||||
|
|
||||||
|
export const rgbColorToCss = ([red, green, blue]: RgbColor): string =>
|
||||||
|
`rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`;
|
||||||
|
|
||||||
|
export const rgbChannelToUnit = (value: number): number =>
|
||||||
|
Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX));
|
||||||
Loading…
Add table
Add a link
Reference in a new issue