First commit
All checks were successful
CI / test (push) Successful in 18s

This commit is contained in:
Andrey Kernichniy 2026-03-01 23:38:54 +07:00
commit d66af6eccc
12 changed files with 302 additions and 0 deletions

16
.editorconfig Normal file
View file

@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

23
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,23 @@
name: CI
on:
push:
branches: [main]
workflow_dispatch: # позволяет запускать вручную
jobs:
test:
# Название runnerа, который у тебя настроен
runs-on: [docker]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm install

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
**/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# System files
.DS_Store
Thumbs.db
playground/gxc-canvas-viewer/**/*.*

19
.prettierignore Normal file
View file

@ -0,0 +1,19 @@
## file extensions
*.*
!*.scss
!*.css
!*.js
!*.json
!*.jsx
!*.less
!*.md
!*.mdx
!*.ts
!*.tsx
!*.yml
# ignore
node_modules/**/*.*
documentation/**/*.*
dist/**/*.*
coverage/**/*.*

9
.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"bracketSpacing": true,
"printWidth": 140,
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "auto"
}

0
README.md Normal file
View file

View file

@ -0,0 +1,93 @@
import { degToRad } from "../../utils/math";
import { IDrawObject, IRenderer, IScene } from "../interfaces";
import { SequentialDrawThread } from "./draw-thread";
const drawRotatedRect = (
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
angle: number,
color: string
) => {
context.save();
// переносим систему координат в центр прямоугольника
context.translate(x + width / 2, y + height / 2);
// поворот вокруг центра
context.rotate(degToRad(angle));
// рисуем прямоугольник так, чтобы его центр оказался в (0,0)
context.fillStyle = color;
context.fillRect(-width / 2, -height / 2, width, height);
context.restore();
};
const drawRotatedStrokeRect = (
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
angle: number,
color: string
) => {
context.save(); // сохраняем систему координат
context.translate(x + width / 2, y + height / 2); // переносим начало в центр фигуры
context.rotate(degToRad(angle)); // вращаем систему координат
context.strokeStyle = color;
context.lineWidth = 2;
context.strokeRect(-width / 2, -height / 2, width, height);
context.restore(); // возвращаем всё обратно
};
export class Canvas2DRenderer implements IRenderer {
public readonly type = "2d";
private _context: CanvasRenderingContext2D;
private _thread: SequentialDrawThread<(offscreenCanvas: OffscreenCanvasRenderingContext2D, objects: IDrawObject[]) => void>;
get holder() {
return this._canvas;
}
constructor(private _canvas: HTMLCanvasElement) {
this._context = this._canvas.getContext("2d");
this._thread = new SequentialDrawThread(
(context2d, objects: IDrawObject[]) => {
const canvas = context2d.canvas;
context2d.clearRect(0, 0, canvas.width, canvas.height);
objects.forEach((drawObject) => {
if (drawObject.type === "rectangle-object") {
drawRotatedRect(
context2d,
drawObject.x,
drawObject.y,
drawObject.width,
drawObject.height,
drawObject.angle,
drawObject.color // TODO в worker не знает про toString()
);
}
});
},
this._canvas.width,
this._canvas.height,
[drawRotatedRect, drawRotatedStrokeRect, degToRad]
);
}
public async render(scene: IScene): Promise<void> {
const bitmap = await this._thread.run([scene.objects]);
const prev = this._context.globalCompositeOperation;
this._context.globalCompositeOperation = "copy";
// Важно: убедись, что размеры совпадают
this._context.drawImage(bitmap, 0, 0, this._canvas.width, this._canvas.height);
this._context.globalCompositeOperation = prev;
// Освобождаем ресурсы
if ("close" in bitmap) bitmap.close();
}
}

87
lib/src/draw-thread.ts Normal file
View file

@ -0,0 +1,87 @@
type DrawFn = (ctx: OffscreenCanvasRenderingContext2D, ...args: any[]) => void;
export class SequentialDrawThread<F extends DrawFn> {
private _worker: Worker;
private _idCounter = 0;
private _callbacks = new Map<number, { resolve: (value: any) => void; reject: (err: any) => void }>();
constructor(fn: F, width = 800, height = 600, scope?: Array<(...args: any[]) => any>) {
const source = `
${this._createScope(scope)}
const canvas = new OffscreenCanvas(${width}, ${height});
const ctx = canvas.getContext('2d');
const fn = ${fn.toString()};
let busy = false;
let pending = null;
const processTask = async (id, args) => {
busy = true;
try {
await fn(ctx, ...args);
const bitmap = canvas.transferToImageBitmap();
self.postMessage({ id, result: bitmap }, [bitmap]);
} catch (err) {
self.postMessage({ id, error: err.message || err.toString() });
} finally {
busy = false;
if (pending) {
const next = pending;
pending = null;
processTask(next.id, next.args);
}
}
}
self.onmessage = (event) => {
const { id, args } = event.data;
if (!busy) {
processTask(id, args);
} else {
pending = { id, args };
}
};
`;
const blob = new Blob([source], { type: "application/javascript" });
const url = URL.createObjectURL(blob);
this._worker = new Worker(url);
this._worker.onmessage = (event: MessageEvent) => {
const { id, result, error } = event.data;
const callback = this._callbacks.get(id);
if (!callback) return;
if (error) callback.reject(new Error(error));
else callback.resolve(result);
this._callbacks.delete(id);
};
}
public run(args: Parameters<F> extends [any, ...infer Rest] ? Rest : never): Promise<ImageBitmap> {
const id = this._idCounter++;
return new Promise<ImageBitmap>((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
this._worker.postMessage({ id, args });
});
}
public terminate() {
this._worker.terminate();
this._callbacks.clear();
}
private _createScope(scope?: Array<(...args: any[]) => any>) {
if (!scope) return "";
return scope
.map((fn) => {
if (!fn.name) {
throw new Error("Все функции в scope должны иметь имя");
}
return `const ${fn.name} = ${fn.toString()};`;
})
.join("\n");
}
}

0
lib/src/index.ts Normal file
View file

View file

6
lib/src/package.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "@gxc-solutions/renderer-canvas-2d",
"version": "0.0.1",
"main": "index.js",
"author": "GXC Solutions"
}

16
package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "gxc-renderer-canvas-2d",
"version": "0.0.0",
"main": "index.js",
"scripts": {
"clean": "rimraf dist"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {},
"devDependencies": {
"rimraf": "^6.0.1",
"typescript": "^5.9.3"
}
}