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