diff --git a/.npmrc b/.npmrc index 4e81c49..80b9b9b 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -regestry=https://npm.gxc-solutions.ru/ \ No newline at end of file +@gxc-solutions:registry=https://npm.gxc-solutions.ru/ \ No newline at end of file diff --git a/lib/src/canvas-2d-renderer.ts b/lib/src/canvas-2d-renderer.ts new file mode 100644 index 0000000..71aa944 --- /dev/null +++ b/lib/src/canvas-2d-renderer.ts @@ -0,0 +1,93 @@ +import { degToRad } from "@gxc-solutions/math/functions"; +import { IDrawObject, IRenderer, IScene } from "@gxc-solutions/renderer-base/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/package.json b/lib/src/package.json index f576691..5cafae1 100644 --- a/lib/src/package.json +++ b/lib/src/package.json @@ -1,10 +1,14 @@ { - "name": "@gxc-solutions/lib", + "name": "@gxc-solutions/renderer-canvas-2d", "version": "0.0.1", "main": "index.js", "author": "GXC Solutions", "publishConfig": { "access": "public", "registry": "https://npm.gxc-solutions.ru" + }, + "peerDependencies": { + "@gxc-solutions/math": "^0.0.1", + "@gxc-solutions/renderer-base": "^0.0.3" } } diff --git a/package-lock.json b/package-lock.json index be041d3..3edd442 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "template-of-lib-repo", "version": "0.0.0", "license": "ISC", + "dependencies": { + "@gxc-solutions/math": "^0.0.1", + "@gxc-solutions/renderer-base": "^0.0.3" + }, "devDependencies": { "@eslint/eslintrc": "^3.3.4", "@eslint/js": "^10.0.1", @@ -247,6 +251,16 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@gxc-solutions/math": { + "version": "0.0.1", + "resolved": "https://npm.gxc-solutions.ru/@gxc-solutions/math/-/math-0.0.1.tgz", + "integrity": "sha512-m6lxTkjXkyaUoI3+cJKpgt/AQlyApLSJ2p9D2EJ9+XzHCSjOWs29GzczPLgopYdLEWYN/YPr77V4CScGpR7qxw==" + }, + "node_modules/@gxc-solutions/renderer-base": { + "version": "0.0.3", + "resolved": "https://npm.gxc-solutions.ru/@gxc-solutions/renderer-base/-/renderer-base-0.0.3.tgz", + "integrity": "sha512-rNglFmWe3LNbTn/KV8s7waV5IPgZ7T+NUbNSChiK+TdGK8bAfuE5yyAeJ7pmvpYbAaRC4emmgnpDQlRr0Kx3nA==" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/package.json b/package.json index aa0634f..582338e 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,9 @@ "prettier": "^3.8.1", "rimraf": "^6.0.1", "typescript": "^5.9.3" + }, + "dependencies": { + "@gxc-solutions/math": "^0.0.1", + "@gxc-solutions/renderer-base": "^0.0.3" } }