commit d66af6eccc2d17f7ee38b23a7cb5d04059800c43 Author: Andrey Kernichniy Date: Sun Mar 1 23:38:54 2026 +0700 First commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..59d9a3a --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..e71ba38 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3503468 --- /dev/null +++ b/.gitignore @@ -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/**/*.* \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a514cf1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,19 @@ +## file extensions +*.* +!*.scss +!*.css +!*.js +!*.json +!*.jsx +!*.less +!*.md +!*.mdx +!*.ts +!*.tsx +!*.yml + +# ignore +node_modules/**/*.* +documentation/**/*.* +dist/**/*.* +coverage/**/*.* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..304c318 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "bracketSpacing": true, + "printWidth": 140, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "auto" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/lib/src/canvas-2d-renderer.ts b/lib/src/canvas-2d-renderer.ts new file mode 100644 index 0000000..37685b3 --- /dev/null +++ b/lib/src/canvas-2d-renderer.ts @@ -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 { + 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(); + } +} diff --git a/lib/src/draw-thread.ts b/lib/src/draw-thread.ts new file mode 100644 index 0000000..b7ada7c --- /dev/null +++ b/lib/src/draw-thread.ts @@ -0,0 +1,87 @@ +type DrawFn = (ctx: OffscreenCanvasRenderingContext2D, ...args: any[]) => void; + +export class SequentialDrawThread { + private _worker: Worker; + private _idCounter = 0; + private _callbacks = new Map 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 extends [any, ...infer Rest] ? Rest : never): Promise { + const id = this._idCounter++; + return new Promise((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"); + } +} diff --git a/lib/src/index.ts b/lib/src/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/lib/src/interfaces/index.ts b/lib/src/interfaces/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/lib/src/package.json b/lib/src/package.json new file mode 100644 index 0000000..e469aab --- /dev/null +++ b/lib/src/package.json @@ -0,0 +1,6 @@ +{ + "name": "@gxc-solutions/renderer-canvas-2d", + "version": "0.0.1", + "main": "index.js", + "author": "GXC Solutions" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..09d27da --- /dev/null +++ b/package.json @@ -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" + } +}