diff --git a/src/client/model/canvas-state.ts b/src/client/model/canvas-state.ts index a740c83..a18c123 100644 --- a/src/client/model/canvas-state.ts +++ b/src/client/model/canvas-state.ts @@ -1,9 +1,14 @@ export type CanvasState = { - cursorP?: Cursor, - cursorK?: Cursor + strokes: Stroke[], } -export type Cursor = { +export type Stroke = { + points: Point[], + color: string, + width: number +} + +export type Point = { x: number, y: number, } \ 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 66f6898..ad3ec9e 100644 --- a/src/client/services/draw/draw.client-service.ts +++ b/src/client/services/draw/draw.client-service.ts @@ -1,4 +1,4 @@ -import { Cursor } from "../../model/canvas-state"; +import { Point, Stroke } from "../../model/canvas-state"; import { ClientStateService } from "../state/state.client-service"; @@ -6,72 +6,117 @@ import { ClientStateService } from "../state/state.client-service"; export class ClientDrawService { private readonly canvasContext: CanvasRenderingContext2D - constructor( + private currentColor + private currentWidth = 12; + + private currentStroke?: number + + constructor( private readonly stateService: ClientStateService, - private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement + private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement, + private readonly colorPicker = document.getElementById('colorPicker')!, + private readonly widthSlider = document.getElementById('widthSlider')!, + private readonly widthValue = document.getElementById('widthValue')!, + private readonly menuButton = document.getElementById('menuButton')!, + private readonly drawer = document.getElementById('drawer')!, + private readonly closeButton = document.getElementById('closeButton')!, ) { this.resizeCanvas() this.setupCanvasEvents() window.addEventListener("resize", () => this.resizeCanvas()); this.canvasContext = canvas.getContext("2d")! + this.currentColor = colorPicker?.getAttribute('value') ?? "#eaafff" } draw() { - const cursors: Cursor[] = this.stateService.getCursors() - + const strokes: Stroke[] = this.stateService.getStrokes() + this.canvasContext.globalAlpha = 0.05; this.canvasContext.fillStyle = "white"; - this.canvasContext.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height); this.canvasContext.globalAlpha = 1; - cursors - .forEach(cursor => { - this.canvasContext.beginPath(); - this.canvasContext.arc(cursor.x, cursor.y, 30, 0, Math.PI * 2); - this.canvasContext.fillStyle = "#eaafff"; - this.canvasContext.fill(); - }) + strokes.forEach(stroke => { + this.canvasContext.fillStyle = stroke.color; + this.drawCurve(stroke.points, stroke.width) + }) requestAnimationFrame(() => this.draw()) } - private onMouseMove = (e: any) => { - const cursor = this.stateService.get('cursorK') - if (!cursor) { + + private addPointToStroke = (e: any) => { + if (this.currentStroke === undefined) { return } const rect = this.canvas.getBoundingClientRect(); - this.stateService.set('cursorK', { + this.stateService.addPoint(this.currentStroke, { x: e.clientX - rect.left, y: e.clientY - rect.top, }) }; - private onMouseDown = (e: any) => { + private beginStroke = (e: any) => { const rect = this.canvas.getBoundingClientRect(); - this.stateService.set('cursorK', { - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }).then(_ => { - this.canvas.addEventListener("mousemove", this.onMouseMove) - }); - + this.stateService.beginStroke({ + color: this.currentColor, + width: this.currentWidth, + points: [{ + x: e.clientX - rect.left, + y: e.clientY - rect.top + }] + , + }).then((id: number) => { + console.log("begin stroke", id) + this.currentStroke = id; + this.canvas.addEventListener("mousemove", this.addPointToStroke) + }) } private setupCanvasEvents() { - this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("mousedown", this.beginStroke); this.canvas.addEventListener("mouseup", () => { - this.canvas.removeEventListener('mousemove', this.onMouseMove) - this.stateService.set('cursorK', undefined) + this.canvas.removeEventListener('mousemove', this.addPointToStroke) }); this.canvas.removeEventListener("mouseleave", () => { - this.canvas.removeEventListener('mousemove', this.onMouseMove) - this.stateService.set('cursorK', undefined) + this.canvas.removeEventListener('mousemove', this.addPointToStroke) }); + + this.colorPicker?.addEventListener('input', (e) => { + const target = e.target as HTMLTextAreaElement + console.log(target.value) + this.currentColor = target.value + + }); + + this.widthSlider?.addEventListener('input', (e) => { + const target = e.target as HTMLTextAreaElement + this.widthValue!.innerHTML = target.value + this.currentWidth = Number(target.value) + }); + + this.menuButton.addEventListener('click', () => { + this.drawer.classList.toggle('open'); + }); + + this.closeButton.addEventListener('click', () => { + this.drawer.classList.toggle('open'); + + }); + + document.addEventListener('click', (e) => { + if(!e.target){ + return + } + if (!this.drawer.contains(e.target as Node) && !this.menuButton.contains(e.target as Node)) { + this.drawer.classList.remove('open'); + } + }); + } private resizeCanvas() { @@ -79,4 +124,139 @@ export class ClientDrawService { this.canvas.width = size; this.canvas.height = size; } + + private drawCurve(points: Point[], density: number) { + if (points.length < 2) return; + + for (let i = 0; i < points.length - 1; i++) { + const width1 = getBrushWidthAt(i, points.length); + const width2 = getBrushWidthAt(i + 1, points.length); + const p1 = points[i]; + const p2 = points[i + 1]; + drawVariableWidthSegment(this.canvasContext, 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 fe3e4b9..e0e1f66 100644 --- a/src/client/services/state/state.client-service.ts +++ b/src/client/services/state/state.client-service.ts @@ -1,36 +1,30 @@ import { RPCSocket } from "../../../../node_modules/rpclibrary/js/Index"; -import { CanvasState } from "../../model/canvas-state"; +import { CanvasState, Point, Stroke } from "../../model/canvas-state"; export class ClientStateService { private remoteService: any - private canvasState: CanvasState = {} + private canvasState: CanvasState = { strokes: [] } - getCursors() { - return [this.canvasState?.cursorK, this.canvasState?.cursorP].filter(cursor => cursor !== undefined) - } - - async set(k: K, v: CanvasState[K]) { - if(v === null || v === undefined){ - delete this.canvasState[k] - } - await this.remoteService.set(k, v) - } - - get(k: K): CanvasState[K] { - return this.canvasState[k] + 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) => { - console.log(state) - this.canvasState = state - + 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 cd218a8..fa422e4 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -7,20 +7,166 @@ - + +
+ +
+ + + + + +
+
+

Brush Settings

+ +
+ + +
+ + +
+ + +
+ + + 12 px +
+
+ - + \ 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 61b870b..9981552 100644 --- a/src/server/services/state/state.service.ts +++ b/src/server/services/state/state.service.ts @@ -1,43 +1,48 @@ import { Singleton, Initializable } from "depents"; import { RPCExporter } from "rpclibrary"; -import { CanvasState } from "../../../client/model/canvas-state"; +import { CanvasState, Point, Stroke } from "../../../client/model/canvas-state"; @Singleton() export class StateService implements Initializable, RPCExporter { name = 'StateService' as const - - private canvasState: CanvasState = {} + private canvasState: CanvasState = { strokes: [] } private clients: Array<(state: any) => void> = [] initialize() { - this.canvasState = {} + this.canvasState = { strokes: [] } this.clients = [] }; - set = async (k: K, v: CanvasState[K]): Promise => { - if (v === null || v === undefined) { - console.log("unset", k) - delete this.canvasState[k] - } else { - console.log("set", k, v) - this.canvasState[k] = v - } - this.clients.forEach((client) => { - client(this.canvasState) - }) + beginStroke = async (stroke: Stroke) => { + const strokeId = this.canvasState.strokes.length + this.canvasState.strokes.push(stroke) + console.log("beginStroke", strokeId, stroke) + this.updateclients() + return strokeId + } + addPoint = async (strokeId: number, point: Point) => { + this.canvasState.strokes.length + this.canvasState.strokes[strokeId].points.push(point) + this.updateclients() } listen = async (callback: (state: any) => Promise) => { - console.log("New client", this.canvasState) await callback(this.canvasState) this.clients = [...this.clients, callback] return this.canvasState } + private updateclients = () => { + this.clients.forEach((client) => { + client(this.canvasState) + }) + } + RPCs = [ - this.set, + this.beginStroke, + this.addPoint, { name: 'listen' as const, hook: (cb: any) => { this.listen(cb) }