This commit is contained in:
Orion Reed 2024-12-20 11:35:27 -05:00
parent 0a4debaaad
commit 7f61e53004
6 changed files with 304 additions and 36 deletions

View File

@ -2,6 +2,7 @@ import { getResizeCursorUrl, getRotateCursorUrl } from '@labs/utils/cursors';
import { DOMRectTransform, DOMRectTransformReadonly, FolkElement, Point, TransformEvent, Vector } from '@lib';
import { ResizeManager } from '@lib/resize-manger';
import { html } from '@lib/tags';
import { MAX_Z_INDEX } from '@lib/utils';
import { css, PropertyValues } from '@lit/reactive-element';
const resizeManager = new ResizeManager();
@ -70,7 +71,7 @@ const styles = css`
:host(:focus-within),
:host(:focus-visible) {
z-index: calc(infinity - 1);
z-index: calc(${MAX_Z_INDEX} - 1);
outline: solid 1px hsl(214, 84%, 56%);
}
@ -92,7 +93,7 @@ const styles = css`
aspect-ratio: 1;
display: none;
position: absolute;
z-index: calc(infinity);
z-index: calc(${MAX_Z_INDEX} - 1);
padding: 0;
}

View File

@ -1,5 +1,4 @@
import { FolkElement } from '@lib';
import { DOMTransform } from '@lib/DOMTransform';
import { html } from '@lib/tags';
import { Point } from '@lib/types';
import { css } from '@lit/reactive-element';
@ -94,8 +93,6 @@ export class FolkSpace extends FolkElement {
const matrix = new DOMMatrix()
.translate(centerX, centerY)
.multiply(new DOMMatrix([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, -1 / this.#perspective, 0, 0, 0, 1]))
.translate(-centerX, -centerY)
.translate(centerX, centerY)
.multiply(face === 'front' ? this.#frontMatrix : this.#backMatrix)
.translate(-centerX, -centerY);
@ -139,7 +136,6 @@ export class FolkSpace extends FolkElement {
y: rect.y + rect.height / 2,
};
// Transform center point
const transformedCenter = this.localToScreen(center, face);
return {
@ -167,18 +163,6 @@ export class FolkSpace extends FolkElement {
return this.transformRect(rect, face);
}
// Fallback to getBoundingClientRect
const rect = element.getBoundingClientRect();
const spaceRect = this.getBoundingClientRect();
return this.transformRect(
{
x: rect.x - spaceRect.x,
y: rect.y - spaceRect.y,
width: rect.width,
height: rect.height,
},
face,
);
return null;
}
}

View File

@ -1,15 +1,25 @@
import { FolkElement } from '@lib';
import { FolkElement, type Point } from '@lib';
import { Gizmos } from '@lib/folk-gizmos';
import { html } from '@lib/tags';
import { TransformEvent } from '@lib/TransformEvent';
import { css } from '@lit/reactive-element';
interface TransformRect {
x: number;
y: number;
width: number;
height: number;
}
export class FolkTransformedSpace extends FolkElement {
static override tagName = 'folk-transformed-space';
static #perspective = 1000;
static styles = css`
:host {
display: block;
// perspective: 1000px;
perspective: ${this.#perspective}px;
position: relative;
width: 100%;
height: 100%;
@ -49,22 +59,80 @@ export class FolkTransformedSpace extends FolkElement {
if (space instanceof HTMLElement) {
space.style.transform = this.#matrix.toString();
}
Gizmos.clear();
}
#handleTransform = (event: TransformEvent) => {
// Extract rotation angles from the transformation matrix
const rotationX = Math.atan2(this.#matrix.m32, this.#matrix.m33);
const rotationY = Math.atan2(
-this.#matrix.m31,
Math.sqrt(this.#matrix.m32 * this.#matrix.m32 + this.#matrix.m33 * this.#matrix.m33),
);
const previous = this.transformRect(event.previous);
const current = this.transformRect(event.current);
// Calculate projection factors for both axes
const projectionFactorY = 1 / Math.cos(rotationX);
const projectionFactorX = 1 / Math.cos(rotationY);
Gizmos.rect(event.current, {
color: 'rgba(0, 0, 255, 0.1)',
width: 2,
layer: 'default',
});
// Apply the transformed movement with both projection factors
event.current.x *= projectionFactorX;
event.current.y *= projectionFactorY;
Gizmos.line(event.current, current, {
color: 'gray',
width: 2,
layer: 'transformed',
});
Gizmos.point(event.current, {
color: 'blue',
size: 3,
layer: 'transformed',
});
Gizmos.point(current, {
color: 'red',
size: 3,
layer: 'transformed',
});
const delta = {
x: current.x - previous.x,
y: current.y - previous.y,
};
event.current.x += delta.x;
event.current.y += delta.y;
};
localToScreen(point: Point): Point {
const spaceRect = this.getBoundingClientRect();
const centerX = spaceRect.width / 2;
const centerY = spaceRect.height / 2;
// Use the same matrix we're using for CSS
const matrix = new DOMMatrix().translate(centerX, centerY).multiply(this.#matrix).translate(-centerX, -centerY);
const transformedPoint = matrix.transformPoint(new DOMPoint(point.x, point.y, 0, 1));
const w = transformedPoint.w || 1;
return {
x: transformedPoint.x / w,
y: transformedPoint.y / w,
};
}
/**
* Transforms a rect from an element in either face to screen coordinates
*/
transformRect(rect: TransformRect): TransformRect {
// Get center point
const center = {
x: rect.x,
y: rect.y,
};
// Transform center point
const transformedCenter = this.localToScreen(center);
return {
x: transformedCenter.x,
y: transformedCenter.y,
width: rect.width,
height: rect.height,
};
}
}

211
lib/folk-gizmos.ts Normal file
View File

@ -0,0 +1,211 @@
import { DOMRectTransform, FolkElement, type Point } from '@lib';
import { html } from '@lib/tags';
import { css } from '@lit/reactive-element';
interface GizmoOptions {
color?: string;
layer?: string;
}
interface PointOptions extends GizmoOptions {
size?: number;
}
interface LineOptions extends GizmoOptions {
width?: number;
}
interface RectOptions extends LineOptions {
fill?: string;
}
/**
* Visual debugging system that renders canvas overlays in DOM containers.
*
* Creates full-size canvas overlays that can be placed anywhere in the DOM.
* Supports multiple instances with isolated drawing layers.
*
* Usage:
* ```html
* <folk-gizmos layer="debug"></folk-gizmos>
* ```
*
* Drawing methods:
* ```ts
* Gizmos.point({x, y});
* Gizmos.line(start, end, { color: 'red' });
* Gizmos.rect(domRect, { fill: 'blue' });
* ```
*/
export class Gizmos extends FolkElement {
static override tagName = 'folk-gizmos';
static #layers = new Map<
string,
{
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
}
>();
static #defaultLayer = 'default';
static #hasLoggedDrawWarning = false;
static #hasLoggedInitMessage = false;
static styles = css`
:host {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: calc(Infinity);
}
.gizmos-canvas {
position: absolute;
width: 100%;
height: 100%;
}
`;
readonly #layer: string;
constructor() {
super();
this.#layer = this.getAttribute('layer') ?? Gizmos.#defaultLayer;
}
override createRenderRoot() {
const root = super.createRenderRoot() as ShadowRoot;
root.setHTMLUnsafe(html` <canvas class="gizmos-canvas"></canvas> `);
const canvas = root.querySelector('.gizmos-canvas') as HTMLCanvasElement;
const ctx = canvas?.getContext('2d') ?? null;
if (canvas && ctx) {
Gizmos.#layers.set(this.#layer, { canvas, ctx });
}
this.#handleResize();
window.addEventListener('resize', () => this.#handleResize());
return root;
}
/** Draws a point */
static point(point: Point, { color = 'red', size = 5, layer = Gizmos.#defaultLayer }: PointOptions = {}) {
const ctx = Gizmos.#getContext(layer);
if (!ctx) return;
ctx.beginPath();
ctx.fillStyle = color;
ctx.arc(point.x, point.y, size, 0, Math.PI * 2);
ctx.fill();
}
/** Draws a line between two points */
static line(start: Point, end: Point, { color = 'blue', width = 2, layer = Gizmos.#defaultLayer }: LineOptions = {}) {
const ctx = Gizmos.#getContext(layer);
if (!ctx) return;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
}
/** Draws a rectangle, can be a regular DOMRect or a DOMRectTransform */
static rect(
rect: DOMRect | DOMRectTransform,
{ color = 'blue', width = 2, fill, layer = Gizmos.#defaultLayer }: RectOptions = {},
) {
const ctx = Gizmos.#getContext(layer);
if (!ctx) return;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = width;
if (rect instanceof DOMRectTransform) {
// For transformed rectangles, draw using the vertices
const vertices = rect.vertices().map((p) => rect.toParentSpace(p));
ctx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < vertices.length; i++) {
ctx.lineTo(vertices[i].x, vertices[i].y);
}
ctx.closePath();
} else {
// For regular DOMRects, draw a simple rectangle
ctx.rect(rect.x, rect.y, rect.width, rect.height);
}
if (fill) {
ctx.fillStyle = fill;
ctx.fill();
}
ctx.stroke();
}
/** Clears drawings from a specific layer or all layers if no layer specified */
static clear(layer?: string) {
if (layer) {
const layerData = Gizmos.#layers.get(layer);
if (!layerData) return;
const { ctx, canvas } = layerData;
ctx.clearRect(0, 0, canvas.width, canvas.height);
} else {
// Clear all layers
Gizmos.#layers.forEach(({ ctx, canvas }) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
});
}
}
#handleResize() {
const layerData = Gizmos.#layers.get(this.#layer);
if (!layerData) return;
const { canvas, ctx } = layerData;
const rect = this.getBoundingClientRect();
const dpr = window.devicePixelRatio;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
}
static #getContext(layer = Gizmos.#defaultLayer) {
if (!Gizmos.#hasLoggedInitMessage) {
const gizmos = document.querySelectorAll<Gizmos>(Gizmos.tagName);
console.info(
'%cGizmos',
'font-weight: bold; color: #4CAF50;',
'\n• Gizmo elements:',
gizmos.length,
'\n• Layers:',
`[${Array.from(Gizmos.#layers.keys()).join(', ')}]`,
'\n• Default layer:',
Gizmos.#defaultLayer,
);
Gizmos.#hasLoggedInitMessage = true;
}
const layerData = Gizmos.#layers.get(layer);
if (!layerData && !Gizmos.#hasLoggedDrawWarning) {
console.warn(`Gizmos cannot draw: layer "${layer}" not found`);
Gizmos.#hasLoggedDrawWarning = true;
return null;
}
return layerData?.ctx;
}
}
if (!customElements.get('folk-gizmos')) {
customElements.define('folk-gizmos', Gizmos);
}

View File

@ -3,6 +3,8 @@
import type { Point } from './types.ts';
import { Vector } from './Vector.ts';
export const MAX_Z_INDEX = 2147483647;
// Distance squared from a point p to the line segment vw
function distanceToSegmentSq(p: Point, v: Point, w: Point): number {
const l2 = Vector.distanceSquared(v, w);
@ -49,7 +51,7 @@ function getPointsOnBezierCurveWithSplitting(
points: readonly Point[],
offset: number,
tolerance: number,
newPoints?: Point[]
newPoints?: Point[],
): Point[] {
const outPoints = newPoints || [];
if (flatness(points, offset) < tolerance) {
@ -97,7 +99,7 @@ export function simplifyPoints(
start: number,
end: number,
epsilon: number,
newPoints?: Point[]
newPoints?: Point[],
): Point[] {
const outPoints = newPoints || [];
@ -155,7 +157,7 @@ export function getSvgPathFromStroke(stroke: number[][]): string {
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
return acc;
},
['M', ...stroke[0], 'Q']
['M', ...stroke[0], 'Q'],
);
d.push('Z');

View File

@ -20,7 +20,9 @@
</style>
</head>
<body>
<folk-gizmos></folk-gizmos>
<folk-transformed-space>
<folk-gizmos layer="transformed"></folk-gizmos>
<folk-shape x="250" y="100" width="50" height="50"></folk-shape>
<folk-shape x="200" y="200" width="75" height="75" rotation="90"></folk-shape>
<folk-shape x="50" y="250" width="25" height="25" rotation="180"></folk-shape>