This commit is contained in:
commit
d66af6eccc
12 changed files with 302 additions and 0 deletions
16
.editorconfig
Normal file
16
.editorconfig
Normal 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
23
.forgejo/workflows/ci.yml
Normal 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
33
.gitignore
vendored
Normal 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
19
.prettierignore
Normal 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
9
.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"printWidth": 140,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"endOfLine": "auto"
|
||||||
|
}
|
||||||
0
README.md
Normal file
0
README.md
Normal file
93
lib/src/canvas-2d-renderer.ts
Normal file
93
lib/src/canvas-2d-renderer.ts
Normal 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
87
lib/src/draw-thread.ts
Normal 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
0
lib/src/index.ts
Normal file
0
lib/src/interfaces/index.ts
Normal file
0
lib/src/interfaces/index.ts
Normal file
6
lib/src/package.json
Normal file
6
lib/src/package.json
Normal 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
16
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue