import { Point, type Vector } from '@flatten-js/core'; import { WHEEL_ZOOM_EXPONENT } from '../App.consts'; import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts'; import { mapNumberRange } from '../helpers/map-number-range.ts'; import { StateVariable } from '../helpers/undo-stack.ts'; import { getEntities, getGridEnabled, triggerReactUpdate } from '../state.ts'; import { paintColor, themeColor } from '../theme.ts'; import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController'; /** 토큰을 못 읽을 때만 쓰는 캔버스 배경 폴백. */ const CANVAS_BACKGROUND_FALLBACK = '#111'; /** * Screen coordinate system: * 0, 0 X * +----------> * | * | * | * Y v * * * World coordinate system: * Y ^ * | * | * | * +----------> * 0, 0 X * * To convert between the 2 coordinate systems, you need the screenOffset and screenScale */ // Batch-mode stroke decimation: skip chain segments shorter than this (screen px) const BATCH_LOD_PX = 0.5; // Batch-mode text smaller than this (screen px) is unreadable — skip drawing it const BATCH_MIN_TEXT_PX = 2; export class ScreenCanvasDrawController implements DrawController { private screenOffset: Point = new Point(0, 0); private screenScale = 1; private screenMouseLocation: Point; private canvasSize: Point = new Point(100, 100); // Style-run batching (static scene rendering): consecutive stroke calls // with the same style are collected into one Path2D and stroked once. private batching = false; private batchPath: Path2D | null = null; private batchKey: string | null = null; private batchStyle: { color: string; lineWidth: number; dash: number[] } | null = null; private batchLastX = Number.NaN; private batchLastY = Number.NaN; constructor(private context: CanvasRenderingContext2D) { this.screenMouseLocation = new Point(this.canvasSize.x / 2, this.canvasSize.y / 2); this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down } public getCanvasSize() { return this.canvasSize; } public setCanvasSize(newCanvasSize: Point) { this.canvasSize = newCanvasSize; } public getScreenScale() { return this.screenScale; } public setScreenScale(newScreenScale: number) { console.log(`set screen scale: ${newScreenScale}`); this.screenScale = newScreenScale; triggerReactUpdate(StateVariable.screenZoom); } public getScreenOffset() { return this.screenOffset; } public setScreenOffset(newScreenOffset: Point) { this.screenOffset = newScreenOffset; triggerReactUpdate(StateVariable.screenOffset); } public setScreenMouseLocation(newScreenMouseLocation: Point): void { this.screenMouseLocation = newScreenMouseLocation; triggerReactUpdate(StateVariable.screenMouseLocation); } public getWorldMouseLocation(): Point { return this.targetToWorld(this.screenMouseLocation); } public getScreenMouseLocation(): Point { return this.screenMouseLocation; } public panScreen(screenOffsetX: number, screenOffsetY: number) { this.screenOffset = new Point( this.screenOffset.x - screenOffsetX / this.screenScale, this.screenOffset.y - screenOffsetY / this.screenScale ); } /** * This function takes the deltaY from the mouse wheel event and zooms the screen in or out * The location of the mouse in world space is preserved * @param deltaY * @param exponent 델타 한 단위당 확대 지수 (휠·핀치가 서로 다른 값을 쓴다) */ public zoomScreen(deltaY: number, exponent: number = WHEEL_ZOOM_EXPONENT) { const worldMouseLocationBeforeZoom = this.getWorldMouseLocation(); const oldScreenScale = this.getScreenScale(); // 부호가 아니라 크기까지 본다 — 트랙패드의 잔 델타는 그만큼만 움직인다. const newScreenScale = oldScreenScale * Math.exp(-deltaY * exponent); this.setScreenScale(newScreenScale); // now get the location of the cursor in world space again // It will have changed because the scale has changed, // but we can offset our world now to fix the zoom location in screen space, // because we know how much it changed laterally between the two spatial scales. const worldMouseLocationAfterZoom = this.getWorldMouseLocation(); const offsetAdjustment = new Point( worldMouseLocationBeforeZoom.x - worldMouseLocationAfterZoom.x, worldMouseLocationBeforeZoom.y - worldMouseLocationAfterZoom.y ); // Adjust the screen offset to maintain the cursor position this.screenOffset = new Point( this.screenOffset.x + offsetAdjustment.x, this.screenOffset.y + offsetAdjustment.y ); } /** * 전체 도면(모든 엔티티)을 화면 중심에 여백을 두고 배치한다. * 가로/세로 중 더 제약이 큰 축에 맞춰 배율을 정하고, 도면 중심이 화면 중심에 * 오도록 screenOffset(월드 좌표)을 역산한다. (기존 구현은 화면 픽셀 여백값을 * 월드 좌표 offset에 그대로 대입해 중심 배치가 어긋나는 문제가 있었다.) */ public zoomToFitScreen() { const entities = getEntities(); if (!entities.length) return; const boundingBox = getBoundingBoxOfMultipleEntities(entities); const boundingWidth = boundingBox.maxX - boundingBox.minX; const boundingHeight = boundingBox.maxY - boundingBox.minY; const canvasSize = this.getCanvasSize(); // 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지) const FIT_MARGIN = 0.9; const scaleX = boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY; const scaleY = boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY; let zoomLevel = Math.min(scaleX, scaleY); if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1; this.setScreenScale(zoomLevel); // screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면 // offset = worldCenter - (화면 절반 픽셀) / zoom const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2; const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2; this.setScreenOffset( new Point( worldCenterX - canvasSize.x / 2 / zoomLevel, worldCenterY - canvasSize.y / 2 / zoomLevel ) ); } /** * Convert coordinates from World Space --> Screen Space */ public worldToTarget(worldCoordinate: Point): Point { return new Point( mapNumberRange( worldCoordinate.x, this.screenOffset.x, this.screenOffset.x + this.canvasSize.x / this.screenScale, 0, this.canvasSize.x ), mapNumberRange( worldCoordinate.y, this.screenOffset.y, this.screenOffset.y + this.canvasSize.y / this.screenScale, 0, this.canvasSize.y ) ); } public worldsToTargets(worldCoordinates: Point[]): Point[] { return worldCoordinates.map(this.worldToTarget.bind(this)); } /** * Convert coordinates from Screen Space --> World Space * (0, 0) (1920, 0) * * (0, 1080) (1920, 1080) * * convert to * * (0, 1080) (1920, 1080) * * (0, 0) (1920, 0) */ public targetToWorld(screenCoordinate: Point): Point { // map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale() return new Point( mapNumberRange( screenCoordinate.x, 0, this.canvasSize.x, this.screenOffset.x, this.screenOffset.x + this.canvasSize.x / this.screenScale ), mapNumberRange( screenCoordinate.y, 0, this.canvasSize.y, this.screenOffset.y, this.screenOffset.y + this.canvasSize.y / this.screenScale ) ); } public targetsToWorlds(screenCoordinates: Point[]): Point[] { return screenCoordinates.map(this.targetToWorld.bind(this)); } public setLineStyles( isHighlighted: boolean, isSelected: boolean, rawColor: string, lineWidth: number, dash: number[] = [] ) { // 저장된 도면 색은 다크 배경 기준 — 라이트 테마에서는 그릴 때만 대비를 맞춘다. const color = paintColor(rawColor); if (this.batching) { const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth; const effectiveDash = isSelected ? [5, 5] : dash; const key = `${color}|${effectiveWidth}|${effectiveDash.join(',')}`; if (key !== this.batchKey) { this.flushBatch(); this.batchKey = key; this.batchStyle = { color, lineWidth: effectiveWidth, dash: effectiveDash }; } return; } this.context.strokeStyle = color; this.context.lineWidth = lineWidth; this.context.setLineDash(dash); if (isHighlighted) { this.context.lineWidth = lineWidth + 1; } if (isSelected) { this.context.setLineDash([5, 5]); } } /** * Start style-run batching: consecutive stroke calls sharing a style are * accumulated into a single Path2D and stroked once (with sub-pixel * segment decimation). Used while rendering the static scene cache. */ public beginBatch() { this.flushBatch(); this.batching = true; this.batchKey = null; this.batchStyle = null; } public endBatch() { this.flushBatch(); this.batching = false; this.batchKey = null; this.batchStyle = null; } private flushBatch() { if (this.batchPath && this.batchStyle) { this.context.strokeStyle = this.batchStyle.color; this.context.lineWidth = this.batchStyle.lineWidth; this.context.setLineDash(this.batchStyle.dash); // Round caps/joins replace the per-segment endpoint dots drawn in // the unbatched path (see _drawRoundedEndpoint) this.context.lineCap = 'round'; this.context.lineJoin = 'round'; this.context.stroke(this.batchPath); this.context.lineCap = 'butt'; this.context.lineJoin = 'miter'; } this.batchPath = null; this.batchLastX = Number.NaN; this.batchLastY = Number.NaN; } public setFillStyles(fillColor: string) { this.context.fillStyle = paintColor(fillColor); } /** * Temporarily redirect all draw calls to another 2d context (eg an * offscreen canvas used as static scene cache), reusing the current * offset/scale/canvasSize without touching state or react triggers. */ public withContext(temporaryContext: CanvasRenderingContext2D, renderFunction: () => void) { const originalContext = this.context; this.context = temporaryContext; try { renderFunction(); } finally { this.context = originalContext; } } /** * Blit a pre-rendered bitmap (static scene cache) onto the canvas at a * pixel offset. Used while panning to avoid re-stroking every entity. */ public blitImage(source: CanvasImageSource, dx: number, dy: number) { this.context.drawImage(source, dx, dy); } public clear() { if (this.canvasSize === null) return; if (!this.context) return; if (this.batching) this.flushBatch(); this.context.fillStyle = themeColor('--cad-canvas', CANVAS_BACKGROUND_FALLBACK); this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y); if (getGridEnabled()) { this.context.strokeStyle = themeColor('--color-mist', '#242b35'); this.context.lineWidth = 1; this.context.setLineDash([]); this.context.beginPath(); for (let x = 0.5; x < this.canvasSize.x; x += 24) { this.context.moveTo(x, 0); this.context.lineTo(x, this.canvasSize.y); } for (let y = 0.5; y < this.canvasSize.y; y += 24) { this.context.moveTo(0, y); this.context.lineTo(this.canvasSize.x, y); } this.context.stroke(); } } /** * Draws a line from startPoint to endPoint and auto converts to screen space first * @param worldStartPoint * @param worldEndPoint */ public drawLine(worldStartPoint: Point, worldEndPoint: Point): void { const [screenStartPoint, screenEndPoint] = this.worldsToTargets([ worldStartPoint, worldEndPoint, ]); this.drawLineScreen(screenStartPoint, screenEndPoint); } /** * Needs to be public to draw UI that is zoom independent, like snap point indicators * @param screenStartPoint * @param screenEndPoint */ public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void { if (this.batching) { const startX = screenStartPoint.x; const startY = this.canvasSize.y - screenStartPoint.y; const endX = screenEndPoint.x; const endY = this.canvasSize.y - screenEndPoint.y; if (!this.batchPath) this.batchPath = new Path2D(); // Chain break: start is not where the previous segment ended const chainBroken = Math.abs(startX - this.batchLastX) > BATCH_LOD_PX || Math.abs(startY - this.batchLastY) > BATCH_LOD_PX; if (chainBroken || Number.isNaN(this.batchLastX)) { this.batchPath.moveTo(startX, startY); this.batchLastX = startX; this.batchLastY = startY; } const isTinyStep = Math.abs(endX - this.batchLastX) < BATCH_LOD_PX && Math.abs(endY - this.batchLastY) < BATCH_LOD_PX; // Decimate sub-pixel steps inside a chain; isolated segments always draw if (!isTinyStep || chainBroken) { this.batchPath.lineTo(endX, endY); this.batchLastX = endX; this.batchLastY = endY; } return; } this.context.beginPath(); this.context.moveTo(screenStartPoint.x, this.canvasSize.y - screenStartPoint.y); this.context.lineTo(screenEndPoint.x, this.canvasSize.y - screenEndPoint.y); this.context.stroke(); const lineWidth = this.context.lineWidth; const style = this.context.strokeStyle as string; this._drawRoundedEndpoint(screenStartPoint, lineWidth, style); this._drawRoundedEndpoint(screenEndPoint, lineWidth, style); } private _drawRoundedEndpoint(screenPoint: Point, lineWidth: number, style: string): void { this.context.fillStyle = style; this.context.beginPath(); this.context.arc( screenPoint.x, this.canvasSize.y - screenPoint.y, lineWidth / 2, 0, 2 * Math.PI ); this.context.fill(); } /** * Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI * @param centerPoint * @param radius * @param startAngle * @param endAngle * @param counterClockWise */ public drawArc( centerPoint: Point, radius: number, startAngle: number, endAngle: number, counterClockWise: boolean ) { const screenCenterPoint = this.worldToTarget(centerPoint); const screenRadius = radius * this.screenScale; // Flip angles over the x-axis, because we go from world to screen coordinates which flips the y-axis direction this.drawArcScreen(screenCenterPoint, screenRadius, -startAngle, -endAngle, counterClockWise); } public drawArcScreen( screenCenterPoint: Point, screenRadius: number, startAngle: number, endAngle: number, counterClockWise: boolean ) { if (this.batching) { if (screenRadius < BATCH_LOD_PX) return; // invisible at this zoom if (!this.batchPath) this.batchPath = new Path2D(); const centerX = screenCenterPoint.x; const centerY = this.canvasSize.y - screenCenterPoint.y; this.batchPath.moveTo( centerX + screenRadius * Math.cos(startAngle), centerY + screenRadius * Math.sin(startAngle) ); this.batchPath.arc(centerX, centerY, screenRadius, startAngle, endAngle, counterClockWise); // Arc end becomes the new chain tail this.batchLastX = centerX + screenRadius * Math.cos(endAngle); this.batchLastY = centerY + screenRadius * Math.sin(endAngle); return; } this.context.beginPath(); this.context.arc( screenCenterPoint.x, this.canvasSize.y - screenCenterPoint.y, screenRadius, startAngle, endAngle, counterClockWise ); this.context.stroke(); const lineWidth = this.context.lineWidth; const style = this.context.strokeStyle as string; // Calculate arc endpoints const startScreenX = screenCenterPoint.x + screenRadius * Math.cos(startAngle); // Y is inverted in canvas, but also for the arc angles, so we subtract from canvasSize.y and then add sin const startScreenY = this.canvasSize.y - screenCenterPoint.y + screenRadius * Math.sin(startAngle); const endScreenX = screenCenterPoint.x + screenRadius * Math.cos(endAngle); const endScreenY = this.canvasSize.y - screenCenterPoint.y + screenRadius * Math.sin(endAngle); // Convert back to Point objects, note that _drawRoundedEndpoint expects y to be from top of canvas const arcStartPoint = new Point(startScreenX, this.canvasSize.y - startScreenY); const arcEndPoint = new Point(endScreenX, this.canvasSize.y - endScreenY); this._drawRoundedEndpoint(arcStartPoint, lineWidth, style); this._drawRoundedEndpoint(arcEndPoint, lineWidth, style); } /** * Draw some text at the base location * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label * @param label * @param basePoint * @param options */ public drawText( label: string, basePoint: Point, options: Partial<{ textDirection?: Vector; textAlign: 'left' | 'center' | 'right'; textColor: string; fontSize: number; fontFamily: string; }> = {} ): void { const screenBasePoint = this.worldToTarget(basePoint); this.drawTextScreen(label, screenBasePoint, { ...options, fontSize: options.fontSize ? options.fontSize * this.screenScale : undefined, }); } /** * Draw some text at the base location * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label * @param basePoint * @param options */ public drawTextScreen( label: string, basePoint: Point, options: Partial<{ textDirection?: Vector; textAlign: 'left' | 'center' | 'right'; textColor: string; fontSize: number; fontFamily: string; }> = {} ): void { const opts = { ...DEFAULT_TEXT_OPTIONS, ...options, }; if (this.batching) { if (opts.fontSize < BATCH_MIN_TEXT_PX) return; // unreadable at this zoom this.flushBatch(); // keep draw order: strokes so far go under this text } this.context.save(); this.context.translate(basePoint.x, this.canvasSize.y - basePoint.y); const angle = getAngleWithXAxis( new Point(0, 0), new Point(opts.textDirection.x, -opts.textDirection.y) ); this.context.rotate(angle); this.context.font = `${opts.fontSize}px ${opts.fontFamily}`; this.context.textAlign = opts.textAlign; this.context.fillStyle = paintColor(opts.textColor); this.context.textBaseline = 'middle'; this.context.fillText(label, 0, 0); this.context.restore(); } /** * Draw an image to the canvas using world coordinates * @param imageElement * @param xMin * @param yMin * @param width * @param height * @param angle */ public drawImage( imageElement: HTMLImageElement, xMin: number, yMin: number, width: number, height: number, angle: number ): void { if (this.batching) this.flushBatch(); const [screenBasePoint, screenDimensions] = this.worldsToTargets([ new Point(xMin, yMin), new Point(width, height), ]); const screenXMin = screenBasePoint.x; const screenYMin = screenBasePoint.y; const screenWidth = screenDimensions.x; const screenHeight = screenDimensions.y; const screenCenterX = screenXMin + screenWidth / 2; const screenCenterY = screenYMin + screenHeight / 2; // Rotate and translate context this.context.translate(screenCenterX, screenCenterY); this.context.rotate(angle); // Draw image this.context.drawImage( imageElement, -screenWidth / 2, -screenHeight / 2, screenWidth, screenHeight ); // Reset context this.context.rotate(-angle); this.context.translate(-screenCenterX, -screenCenterY); } public fillRect(xMin: number, yMin: number, width: number, height: number, color: string) { const screenMinPoint = this.worldToTarget(new Point(xMin, yMin)); this.fillRectScreen( screenMinPoint.x, screenMinPoint.y, width * this.screenScale, height * this.screenScale, color ); } /** * Fill rectangle with color, but interpret the provided coordinates as screen coordinates * @param xMin * @param yMin * @param width * @param height * @param color */ public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) { if (this.batching) this.flushBatch(); // TODO see if we need to replace this with a call to fillPolygon this.context.fillStyle = paintColor(color); this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height); } /** * Fill polygon with color * @param points */ public fillPolygon(...points: Point[]) { if (this.batching) this.flushBatch(); const screenPoints = points.map(this.worldToTarget.bind(this)); this.context.beginPath(); screenPoints.forEach((screenPoint, index) => { if (index === 0) { this.context.moveTo(screenPoint.x, this.canvasSize.y - screenPoint.y); } else { this.context.lineTo(screenPoint.x, this.canvasSize.y - screenPoint.y); } }); this.context.closePath(); this.context.fill(); } }