Skip to main content

Projector

3D-to-2D projection for pseudo-3D canvas drawing. Uses DOMMatrix for transforms and custom perspective division for depth foreshortening.

Setup​

import { Projector } from '@shopify/klint/plugins';

const projector = new Projector({
perspective: 2, // 0 = orthographic, higher = stronger foreshortening
radius: 200 // output scale — how far from center projected points spread
});

Interfaces​

Point3D​

interface Point3D {
x: number;
y: number;
z: number;
}

ProjectedPoint​

interface ProjectedPoint {
x: number; // screen X (centered at 0,0)
y: number; // screen Y
z: number; // transformed depth (use for sorting)
scale: number; // perspective foreshortening factor (use for sizing)
}

Transform3D​

Available inside the transform callback:

interface Transform3D {
rotateX(radians: number): void;
rotateY(radians: number): void;
rotateZ(radians: number): void;
translate(x: number, y: number, z: number): void;
scale(x: number, y?: number, z?: number): void;
}

project()​

Project a single 3D point:

const p = projector.project(
{ x: 1, y: 0, z: -1 },
(t) => {
t.rotateX(K.time * 0.5);
t.rotateY(K.time * 0.3);
}
);

K.circle(K.width / 2 + p.x, K.height / 2 + p.y, 10 * p.scale);

projectAll()​

Project an array of points with the same transform (matrix built once):

const cubeVertices = [
{ x: -1, y: -1, z: -1 },
{ x: 1, y: -1, z: -1 },
{ x: 1, y: 1, z: -1 },
{ x: -1, y: 1, z: -1 },
{ x: -1, y: -1, z: 1 },
{ x: 1, y: -1, z: 1 },
{ x: 1, y: 1, z: 1 },
{ x: -1, y: 1, z: 1 },
];

const projected = projector.projectAll(cubeVertices, (t) => {
t.rotateX(K.time);
t.rotateY(K.time * 0.7);
});

projected.forEach(p => {
K.fillColor('#fff');
K.circle(K.width / 2 + p.x, K.height / 2 + p.y, 6 * p.scale);
});

projectSorted()​

Project and depth-sort points back-to-front. Each result includes index — the original position in the input array:

const colors = ['#ff0000', '#00ff00', '#0000ff', /* ... */];

const sorted = projector.projectSorted(points, (t) => {
t.rotateX(K.time);
});

sorted.forEach(p => {
K.fillColor(colors[p.index]);
K.circle(K.width / 2 + p.x, K.height / 2 + p.y, 10 * p.scale);
});

Example: Rotating Cube​

function CubeSketch() {
const projector = useMemo(() => new Projector({ perspective: 2, radius: 150 }), []);

const vertices = [
{ x: -1, y: -1, z: -1 }, { x: 1, y: -1, z: -1 },
{ x: 1, y: 1, z: -1 }, { x: -1, y: 1, z: -1 },
{ x: -1, y: -1, z: 1 }, { x: 1, y: -1, z: 1 },
{ x: 1, y: 1, z: 1 }, { x: -1, y: 1, z: 1 },
];

const edges = [
[0,1],[1,2],[2,3],[3,0],
[4,5],[5,6],[6,7],[7,4],
[0,4],[1,5],[2,6],[3,7],
];

const draw = (K) => {
K.background('#000');

const projected = projector.projectAll(vertices, (t) => {
t.rotateX(K.time * 0.5);
t.rotateY(K.time * 0.3);
});

const cx = K.width / 2;
const cy = K.height / 2;

K.strokeColor('#fff');
edges.forEach(([a, b]) => {
K.line(
cx + projected[a].x, cy + projected[a].y,
cx + projected[b].x, cy + projected[b].y
);
});

projected.forEach(p => {
K.fillColor('#00ff88');
K.circle(cx + p.x, cy + p.y, 5 * p.scale);
});
};

return (
<div style={{ width: '100vw', height: '100vh' }}>
<Klint draw={draw} />
</div>
);
}