Files
Aislo/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts
T

662 lines
20 KiB
TypeScript

import { Point, type Vector } from '@flatten-js/core';
import { MOUSE_ZOOM_MULTIPLIER } 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
*/
public zoomScreen(deltaY: number) {
const worldMouseLocationBeforeZoom = this.getWorldMouseLocation();
const oldScreenScale = this.getScreenScale();
const newScreenScale =
oldScreenScale * (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY)));
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();
}
}