Files
OpenFrontIO/src/client/render/gl/Camera.ts
T
evanpelle 4cd22a9b5c rename render/ files to UpperCamelCase to match client convention
The render/ tree was the only place in the client still using kebab-case
filenames. Brings ~80 files in line with the rest of src/client/
(BuildPreviewController, TransformHandler, etc.). Directories kept as
they were (name-pass/, fx-pass/, passes/, utils/, debug/) since the
codebase already mixes those.

Two collisions surfaced and got resolved: render/types/ is a directory,
not a file, so its imports kept the lowercase form; and the sed pass
incidentally normalized core/pathfinding imports, which had to be
reverted since that file is actually lowercase on disk despite some
imports having referenced it as ./Types under macOS case-insensitive
resolution.
2026-05-17 21:21:05 -07:00

199 lines
5.7 KiB
TypeScript

/**
* 2D camera: pan/zoom → column-major mat3 for WebGL2 vertex shaders.
*
* Pure viewport math — no DOM event listeners. Input handling lives
* in GameView, which calls panBy / zoomAtScreen / etc.
*
* Coordinate system:
* World: (0,0) top-left, (mapWidth, mapHeight) bottom-right, +Y down.
* Clip: (-1,-1) bottom-left, (1,1) top-right.
*
* The mat3 maps world → clip:
* sx = zoom * 2 / canvasWidth
* sy = zoom * -2 / canvasHeight (Y flip)
* tx = -offsetX * sx
* ty = -offsetY * sy
*/
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 20;
const DBLCLICK_MIN_ZOOM = 0.7;
const DBLCLICK_MAX_ZOOM = 3;
export class Camera {
offsetX: number;
offsetY: number;
zoom: number;
private mapW: number;
private mapH: number;
private canvasW = 1;
private canvasH = 1;
private mat = new Float32Array(9);
private dirty = true;
/** True until fitMap() has been called with valid canvas dimensions. */
private needsInitialFit = true;
constructor(mapWidth: number, mapHeight: number) {
this.mapW = mapWidth;
this.mapH = mapHeight;
this.offsetX = mapWidth / 2;
this.offsetY = mapHeight / 2;
this.zoom = 1;
}
/** Update canvas pixel dimensions. Triggers initial fitMap on first call. */
resize(cssWidth: number, cssHeight: number): void {
const dpr = window.devicePixelRatio || 1;
this.canvasW = Math.round(cssWidth * dpr);
this.canvasH = Math.round(cssHeight * dpr);
if (this.needsInitialFit) {
this.fitMap();
}
this.dirty = true;
}
/** Fit the map into the viewport (~90% fill). */
fitMap(): void {
this.offsetX = this.mapW / 2;
this.offsetY = this.mapH / 2;
const sx = this.canvasW / this.mapW;
const sy = this.canvasH / this.mapH;
this.zoom = Math.min(sx, sy) * 0.9;
this.dirty = true;
this.needsInitialFit = false;
}
/** Center the camera on a bounding box with padding (1.4 ≈ 71% fill). */
focusBBox(
minX: number,
minY: number,
maxX: number,
maxY: number,
padding = 1.4,
): void {
this.offsetX = (minX + maxX + 1) / 2;
this.offsetY = (minY + maxY + 1) / 2;
const bboxW = maxX - minX + 1;
const bboxH = maxY - minY + 1;
const sx = this.canvasW / bboxW;
const sy = this.canvasH / bboxH;
this.zoom = Math.max(
DBLCLICK_MIN_ZOOM,
Math.min(DBLCLICK_MAX_ZOOM, Math.min(sx, sy) / padding),
);
this.clampOffset();
this.dirty = true;
}
/** Set the camera center to a world position. */
panTo(worldX: number, worldY: number): void {
this.offsetX = worldX;
this.offsetY = worldY;
this.clampOffset();
this.dirty = true;
}
/** Shift the camera center by a world-space delta (used for drag panning). */
panBy(dx: number, dy: number): void {
this.offsetX += dx;
this.offsetY += dy;
this.clampOffset();
this.dirty = true;
}
/** Restore camera state, skipping the initial fitMap. */
setCameraState(x: number, y: number, z: number): void {
this.offsetX = x;
this.offsetY = y;
this.zoom = z;
this.needsInitialFit = false;
this.dirty = true;
}
/** Multiply zoom by a factor (centered on current view). */
zoomBy(factor: number): void {
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor));
this.clampOffset();
this.dirty = true;
}
/** Set absolute zoom level. */
zoomTo(level: number): void {
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level));
this.clampOffset();
this.dirty = true;
}
/**
* Zoom by a factor while keeping a screen point fixed in world space.
* Used for wheel-zoom: the world position under the cursor stays put.
*/
zoomAtScreen(factor: number, screenX: number, screenY: number): void {
const worldBefore = this.screenToWorld(screenX, screenY);
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor));
const worldAfter = this.screenToWorld(screenX, screenY);
this.offsetX += worldBefore.x - worldAfter.x;
this.offsetY += worldBefore.y - worldAfter.y;
this.clampOffset();
this.dirty = true;
}
/** Return the column-major mat3 camera matrix (world → clip). */
getMatrix(): Float32Array {
if (this.dirty) {
const sx = (this.zoom * 2) / this.canvasW;
const sy = (this.zoom * -2) / this.canvasH; // Y flip
const tx = -this.offsetX * sx;
const ty = -this.offsetY * sy;
const m = this.mat;
m[0] = sx;
m[1] = 0;
m[2] = 0;
m[3] = 0;
m[4] = sy;
m[5] = 0;
m[6] = tx;
m[7] = ty;
m[8] = 1;
this.dirty = false;
}
return this.mat;
}
/** Convert screen pixel position to world coordinates. */
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const dpr = window.devicePixelRatio || 1;
const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1;
const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1);
const sx = (this.zoom * 2) / this.canvasW;
const sy = (this.zoom * -2) / this.canvasH;
return {
x: (ndcX - -this.offsetX * sx) / sx,
y: (ndcY - -this.offsetY * sy) / sy,
};
}
/** Convert world coordinates to screen pixel position (CSS pixels). */
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
const dpr = window.devicePixelRatio || 1;
return {
x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr),
y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr),
};
}
private clampOffset(): void {
const halfVpW = this.canvasW / (2 * this.zoom);
const halfVpH = this.canvasH / (2 * this.zoom);
this.offsetX = Math.max(
-halfVpW,
Math.min(this.mapW + halfVpW, this.offsetX),
);
this.offsetY = Math.max(
-halfVpH,
Math.min(this.mapH + halfVpH, this.offsetY),
);
}
}