From d2de2452a8a03a8c550022236faa32445c21eab8 Mon Sep 17 00:00:00 2001 From: Peter Millauer Date: Sun, 10 May 2026 12:45:22 +0200 Subject: [PATCH] Reworked event streaming --- src/client/main.ts | 3 +- src/client/model/canvas-state.ts | 1 + src/client/model/rpc-callbacks.ts | 3 + src/client/services/draw/cat-mul-rom.util.ts | 124 ++++++++++++ .../services/draw/draw.client-service.ts | 185 ++++-------------- .../services/state/state.client-service.ts | 45 +++-- src/public/index.html | 27 ++- src/server/services/state/state.service.ts | 21 +- 8 files changed, 229 insertions(+), 180 deletions(-) create mode 100644 src/client/model/rpc-callbacks.ts create mode 100644 src/client/services/draw/cat-mul-rom.util.ts diff --git a/src/client/main.ts b/src/client/main.ts index a90ecb9..4baf97e 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -3,5 +3,4 @@ import { ClientDrawService } from './services/draw/draw.client-service' const stateService = new ClientStateService() const drawService = new ClientDrawService(stateService) -drawService.draw() -stateService.connect() \ No newline at end of file +stateService.connect(drawService) \ No newline at end of file diff --git a/src/client/model/canvas-state.ts b/src/client/model/canvas-state.ts index a18c123..afa7cd7 100644 --- a/src/client/model/canvas-state.ts +++ b/src/client/model/canvas-state.ts @@ -5,6 +5,7 @@ export type CanvasState = { export type Stroke = { points: Point[], color: string, + density: number, width: number } diff --git a/src/client/model/rpc-callbacks.ts b/src/client/model/rpc-callbacks.ts new file mode 100644 index 0000000..51c5b13 --- /dev/null +++ b/src/client/model/rpc-callbacks.ts @@ -0,0 +1,3 @@ +import { Stroke } from "./canvas-state"; + +export type ListenCallbackParam = { strokeId: number, stroke: Stroke } \ No newline at end of file diff --git a/src/client/services/draw/cat-mul-rom.util.ts b/src/client/services/draw/cat-mul-rom.util.ts new file mode 100644 index 0000000..06ba354 --- /dev/null +++ b/src/client/services/draw/cat-mul-rom.util.ts @@ -0,0 +1,124 @@ +import { Point } from "../../model/canvas-state"; + + +/** + * Returns a point on a Catmull-Rom spline segment at parameter t + */ +export function catmullRomPoint( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + t: number +): Point { + const t2 = t * t; + const t3 = t2 * t; + + const x = + 0.5 * + (2 * p1.x + + (-p0.x + p2.x) * t + + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3); + + const y = + 0.5 * + (2 * p1.y + + (-p0.y + p2.y) * t + + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3); + + return { x, y }; +} + +/** + * Converts a list of raw input points into a smooth Catmull-Rom spline + */ +export function getCatmullRomPath( + points: Point[], + density: number, +): Point[] { + const segmentsPerInterval = density; + + if (points.length === 0) return []; + if (points.length === 1) return [{ ...points[0] }]; + + const smoothed: Point[] = []; + const n = points.length; + + for (let i = 0; i < n - 1; i++) { + const p0 = points[Math.max(0, i - 1)]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[Math.min(n - 1, i + 2)]; + + const step = 1 / segmentsPerInterval; + + for (let s = 0; s <= segmentsPerInterval; s++) { + const t = s * step; + const pt = catmullRomPoint(p0, p1, p2, p3, t); + smoothed.push(pt); + } + } + + // Remove near-duplicates at segment boundaries + const result = smoothed.filter((pt, idx, arr) => { + if (idx === 0) return true; + const prev = arr[idx - 1]; + return Math.hypot(pt.x - prev.x, pt.y - prev.y) > 0.001; + }); + + return result; +} + +export function getBrushWidthAt(index: number, totalPoints: number, baseWidth: number = 12): number { + // Example: taper at start and end + speed-based variation + const t = index / (totalPoints - 1); + let width = baseWidth; + + // Ease in / ease out + if (t < 0.1) width *= t * 10; + if (t > 0.9) width *= (1 - t) * 10; + + return Math.max(1, width); +} + +export function drawVariableWidthSegment( + ctx: CanvasRenderingContext2D, + p1: Point, + p2: Point, + width1: number, + width2: number +): void { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.hypot(dx, dy); // More efficient than sqrt(dx*dx + dy*dy) + + if (len < 0.001) return; // Points are too close + + // Normalized perpendicular vector (rotated 90 degrees) + const nx = -dy / len; + const ny = dx / len; + + const halfW1 = width1 / 2; + const halfW2 = width2 / 2; + + // Four corners of the quadrilateral + const x1 = p1.x + nx * halfW1; + const y1 = p1.y + ny * halfW1; + const x2 = p1.x - nx * halfW1; + const y2 = p1.y - ny * halfW1; + const x3 = p2.x - nx * halfW2; + const y3 = p2.y - ny * halfW2; + const x4 = p2.x + nx * halfW2; + const y4 = p2.y + ny * halfW2; + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.lineTo(x3, y3); + ctx.lineTo(x4, y4); + ctx.closePath(); + + ctx.fill(); +} \ No newline at end of file diff --git a/src/client/services/draw/draw.client-service.ts b/src/client/services/draw/draw.client-service.ts index ad3ec9e..9ac4290 100644 --- a/src/client/services/draw/draw.client-service.ts +++ b/src/client/services/draw/draw.client-service.ts @@ -1,23 +1,30 @@ import { Point, Stroke } from "../../model/canvas-state"; import { ClientStateService } from "../state/state.client-service"; - +import { getBrushWidthAt, drawVariableWidthSegment, getCatmullRomPath } from "./cat-mul-rom.util"; export class ClientDrawService { - private readonly canvasContext: CanvasRenderingContext2D + private readonly cctx: CanvasRenderingContext2D private currentColor - private currentWidth = 12; + private currentDensity - private currentStroke?: number + private currentWidth + + private currentStrokeId?: number constructor( private readonly stateService: ClientStateService, private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement, private readonly colorPicker = document.getElementById('colorPicker')!, + + private readonly densitySlider = document.getElementById('densitySlider')!, + private readonly densityValue = document.getElementById('densityValue')!, + private readonly widthSlider = document.getElementById('widthSlider')!, - private readonly widthValue = document.getElementById('widthValue')!, + private readonly widthValue = document.getElementById('widthValue')!, + private readonly menuButton = document.getElementById('menuButton')!, private readonly drawer = document.getElementById('drawer')!, private readonly closeButton = document.getElementById('closeButton')!, @@ -25,33 +32,33 @@ export class ClientDrawService { this.resizeCanvas() this.setupCanvasEvents() window.addEventListener("resize", () => this.resizeCanvas()); - this.canvasContext = canvas.getContext("2d")! - this.currentColor = colorPicker?.getAttribute('value') ?? "#eaafff" + this.cctx = canvas.getContext("2d")! + this.currentColor = colorPicker!.getAttribute('value')! + this.currentDensity = Number(densitySlider!.getAttribute('value'))! + this.currentWidth = Number(widthSlider!.getAttribute('value'))! } draw() { + + console.log("drawing") const strokes: Stroke[] = this.stateService.getStrokes() - this.canvasContext.globalAlpha = 0.05; - this.canvasContext.fillStyle = "white"; - this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.canvasContext.globalAlpha = 1; - + this.cctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + this.cctx.globalAlpha = 1; strokes.forEach(stroke => { - this.canvasContext.fillStyle = stroke.color; - this.drawCurve(stroke.points, stroke.width) + this.cctx.fillStyle = stroke.color; + this.drawCurve(stroke.points, stroke.density, stroke.width) }) - - requestAnimationFrame(() => this.draw()) } private addPointToStroke = (e: any) => { - if (this.currentStroke === undefined) { + if (this.currentStrokeId === undefined) { return } const rect = this.canvas.getBoundingClientRect(); - this.stateService.addPoint(this.currentStroke, { + this.stateService.addPoint(this.currentStrokeId, { x: e.clientX - rect.left, y: e.clientY - rect.top, }) @@ -59,10 +66,10 @@ export class ClientDrawService { private beginStroke = (e: any) => { const rect = this.canvas.getBoundingClientRect(); - this.stateService.beginStroke({ color: this.currentColor, width: this.currentWidth, + density: this.currentDensity, points: [{ x: e.clientX - rect.left, y: e.clientY - rect.top @@ -70,7 +77,7 @@ export class ClientDrawService { , }).then((id: number) => { console.log("begin stroke", id) - this.currentStroke = id; + this.currentStrokeId = id; this.canvas.addEventListener("mousemove", this.addPointToStroke) }) } @@ -99,6 +106,12 @@ export class ClientDrawService { this.currentWidth = Number(target.value) }); + this.densitySlider?.addEventListener('input', (e) => { + const target = e.target as HTMLTextAreaElement + this.densityValue!.innerHTML = target.value + this.currentDensity = Number(target.value) + }); + this.menuButton.addEventListener('click', () => { this.drawer.classList.toggle('open'); }); @@ -125,138 +138,18 @@ export class ClientDrawService { this.canvas.height = size; } - private drawCurve(points: Point[], density: number) { + private drawCurve(points: Point[], density: number, width: number) { if (points.length < 2) return; + points = getCatmullRomPath(points, density) + for (let i = 0; i < points.length - 1; i++) { - const width1 = getBrushWidthAt(i, points.length); - const width2 = getBrushWidthAt(i + 1, points.length); + const width1 = getBrushWidthAt(i, points.length, width); + const width2 = getBrushWidthAt(i + 1, points.length, width); const p1 = points[i]; const p2 = points[i + 1]; - drawVariableWidthSegment(this.canvasContext, p1, p2, width1, width2); + drawVariableWidthSegment(this.cctx, p1, p2, width1, width2); } } -} - -/** - * Returns a point on a Catmull-Rom spline segment at parameter t - */ -export function catmullRomPoint( - p0: Point, - p1: Point, - p2: Point, - p3: Point, - t: number -): Point { - const t2 = t * t; - const t3 = t2 * t; - - const x = - 0.5 * - (2 * p1.x + - (-p0.x + p2.x) * t + - (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + - (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3); - - const y = - 0.5 * - (2 * p1.y + - (-p0.y + p2.y) * t + - (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + - (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3); - - return { x, y }; -} - -/** - * Converts a list of raw input points into a smooth Catmull-Rom spline - */ -export function getCatmullRomPath( - points: Point[], - density: number, -): Point[] { - const segmentsPerInterval = density; - - if (points.length === 0) return []; - if (points.length === 1) return [{ ...points[0] }]; - - const smoothed: Point[] = []; - const n = points.length; - - for (let i = 0; i < n - 1; i++) { - const p0 = points[Math.max(0, i - 1)]; - const p1 = points[i]; - const p2 = points[i + 1]; - const p3 = points[Math.min(n - 1, i + 2)]; - - const step = 1 / segmentsPerInterval; - - for (let s = 0; s <= segmentsPerInterval; s++) { - const t = s * step; - const pt = catmullRomPoint(p0, p1, p2, p3, t); - smoothed.push(pt); - } - } - - // Remove near-duplicates at segment boundaries - const result = smoothed.filter((pt, idx, arr) => { - if (idx === 0) return true; - const prev = arr[idx - 1]; - return Math.hypot(pt.x - prev.x, pt.y - prev.y) > 0.001; - }); - - return result; -} - -function getBrushWidthAt(index: number, totalPoints: number, baseWidth: number = 12): number { - // Example: taper at start and end + speed-based variation - const t = index / (totalPoints - 1); - let width = baseWidth; - - // Ease in / ease out - if (t < 0.1) width *= t * 10; - if (t > 0.9) width *= (1 - t) * 10; - - return Math.max(1, width); -} - -export function drawVariableWidthSegment( - ctx: CanvasRenderingContext2D, - p1: Point, - p2: Point, - width1: number, - width2: number -): void { - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const len = Math.hypot(dx, dy); // More efficient than sqrt(dx*dx + dy*dy) - - if (len < 0.001) return; // Points are too close - - // Normalized perpendicular vector (rotated 90 degrees) - const nx = -dy / len; - const ny = dx / len; - - const halfW1 = width1 / 2; - const halfW2 = width2 / 2; - - // Four corners of the quadrilateral - const x1 = p1.x + nx * halfW1; - const y1 = p1.y + ny * halfW1; - const x2 = p1.x - nx * halfW1; - const y2 = p1.y - ny * halfW1; - const x3 = p2.x - nx * halfW2; - const y3 = p2.y - ny * halfW2; - const x4 = p2.x + nx * halfW2; - const y4 = p2.y + ny * halfW2; - - ctx.beginPath(); - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); - ctx.lineTo(x3, y3); - ctx.lineTo(x4, y4); - ctx.closePath(); - - ctx.fill(); } \ No newline at end of file diff --git a/src/client/services/state/state.client-service.ts b/src/client/services/state/state.client-service.ts index e0e1f66..788a429 100644 --- a/src/client/services/state/state.client-service.ts +++ b/src/client/services/state/state.client-service.ts @@ -1,30 +1,45 @@ import { RPCSocket } from "../../../../node_modules/rpclibrary/js/Index"; import { CanvasState, Point, Stroke } from "../../model/canvas-state"; +import { ListenCallbackParam } from "../../model/rpc-callbacks"; +import { ClientDrawService } from "../draw/draw.client-service"; export class ClientStateService { private remoteService: any + private canvasState: CanvasState = { strokes: [] } + async connect(drawService: ClientDrawService) { + const sock = await new RPCSocket(8080, 'localhost').connect(); + this.remoteService = sock['StateService'] + this.canvasState = await this.getState() + await this.remoteService.listen((listenDto: ListenCallbackParam) => { + if(!this.canvasState.strokes[listenDto.strokeId]){ + this.canvasState.strokes[listenDto.strokeId] = listenDto.stroke + }else{ + this.canvasState.strokes[listenDto.strokeId].points = [...this.canvasState.strokes[listenDto.strokeId].points, ...listenDto.stroke.points] + } + drawService.draw() + }) + drawService.draw() + } + + getState = async () => { + return await this.remoteService.getState() + } + + beginStroke = async (stroke: Stroke) => { + return await this.remoteService.beginStroke(stroke) + } + + addPoint = async (strokeId: number, point: Point) => { + return this.remoteService.addPoint(strokeId, point) + } + getStrokes() { return this.canvasState?.strokes ?? [] } - async connect() { - const sock = await new RPCSocket(8080, 'localhost').connect(); - this.remoteService = sock['StateService'] - this.canvasState = await this.remoteService.listen((state: CanvasState) => { - this.canvasState = state - }) - } - - beginStroke = async (stroke: Stroke) => { - return await this.remoteService.beginStroke(stroke) - } - - addPoint = async (strokeId: number, point: Point) =>{ - return this.remoteService.addPoint(strokeId, point) - } } \ No newline at end of file diff --git a/src/public/index.html b/src/public/index.html index fa422e4..b0d793a 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -1,5 +1,6 @@ + @@ -55,7 +56,8 @@ .drawer { position: absolute; top: 0; - right: -500px; /* Fully hidden when closed */ + right: -500px; + /* Fully hidden when closed */ width: 300px; height: 100vh; background: white; @@ -137,10 +139,11 @@ } +
- +
@@ -152,21 +155,29 @@

Brush Settings

- +
- +
- +
- - - 12 px + + + 35 +
+ + +
+ + + 35
+ \ No newline at end of file diff --git a/src/server/services/state/state.service.ts b/src/server/services/state/state.service.ts index 9981552..f7107a9 100644 --- a/src/server/services/state/state.service.ts +++ b/src/server/services/state/state.service.ts @@ -1,13 +1,14 @@ import { Singleton, Initializable } from "depents"; import { RPCExporter } from "rpclibrary"; import { CanvasState, Point, Stroke } from "../../../client/model/canvas-state"; +import { ListenCallbackParam } from "../../../client/model/rpc-callbacks"; @Singleton() export class StateService implements Initializable, RPCExporter { name = 'StateService' as const private canvasState: CanvasState = { strokes: [] } - private clients: Array<(state: any) => void> = [] + private clients: Array<(param: ListenCallbackParam) => void> = [] initialize() { this.canvasState = { strokes: [] } @@ -15,34 +16,36 @@ export class StateService implements Initializable, RPCExporter { }; beginStroke = async (stroke: Stroke) => { - const strokeId = this.canvasState.strokes.length + const strokeId = this.canvasState.strokes.length this.canvasState.strokes.push(stroke) - console.log("beginStroke", strokeId, stroke) - this.updateclients() + this.updateclients(strokeId, stroke) return strokeId } addPoint = async (strokeId: number, point: Point) => { - this.canvasState.strokes.length this.canvasState.strokes[strokeId].points.push(point) - this.updateclients() + this.updateclients(strokeId, { ...this.canvasState.strokes[strokeId], points: [point] }) } listen = async (callback: (state: any) => Promise) => { - await callback(this.canvasState) this.clients = [...this.clients, callback] return this.canvasState } - private updateclients = () => { + getState = async (): Promise => { + return this.canvasState + } + + private updateclients = (strokeId: number, stroke: Stroke) => { this.clients.forEach((client) => { - client(this.canvasState) + client({ strokeId, stroke }) }) } RPCs = [ this.beginStroke, this.addPoint, + this.getState, { name: 'listen' as const, hook: (cb: any) => { this.listen(cb) }