diff --git a/labs/folk-shape.ts b/labs/folk-shape.ts
index 46f1bd7..c81981e 100644
--- a/labs/folk-shape.ts
+++ b/labs/folk-shape.ts
@@ -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;
}
diff --git a/labs/folk-space.ts b/labs/folk-space.ts
index e6453c5..bba63cd 100644
--- a/labs/folk-space.ts
+++ b/labs/folk-space.ts
@@ -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;
}
}
diff --git a/labs/folk-transformed-space.ts b/labs/folk-transformed-space.ts
index 642df67..c65c729 100644
--- a/labs/folk-transformed-space.ts
+++ b/labs/folk-transformed-space.ts
@@ -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,
+ };
+ }
}
diff --git a/lib/folk-gizmos.ts b/lib/folk-gizmos.ts
new file mode 100644
index 0000000..25fe997
--- /dev/null
+++ b/lib/folk-gizmos.ts
@@ -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
+ *
+ * ```
+ *
+ * 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` `);
+
+ 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.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);
+}
diff --git a/lib/utils.ts b/lib/utils.ts
index 3a0f1b1..a2c517a 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -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');
diff --git a/website/canvas/space-transform.html b/website/canvas/space-transform.html
index 28837de..646ced5 100644
--- a/website/canvas/space-transform.html
+++ b/website/canvas/space-transform.html
@@ -20,7 +20,9 @@
+
+