Skip to content

Perspective street ​

Drag the street to look at it from another side. The slider moves the eye, from twelve units in front of the rotation center to infinitely far away at its right end, and dolly out and dolly in glide it to either end. The toggle switches the test that hides the faces.

The floor and the houses hang on one camera3D, and all of it is SVG.

One number moves the eye ​

ts
const camera = camera3D().scale(44).origin({ x: 480, y: 290 }).perspective(20);

.perspective(distance) puts an eye distance model units in front of the rotation center, on the axis that points at the reader. A rotated point is scaled around the center by distance / (distance − (z − center.z)): by 1 in the plane of the center, by more in front of it and by less behind it. The default, Infinity, scales everything by 1: the orthographic projection.

The plane of the center keeps the scale, so the dolly zoom needs no code of its own. It moves the eye evenly in 1 / distance, and the middle of the street keeps its size while the near end swells and the far end shrinks into the vanishing point, or the other way round on the way out. A film camera has to drive towards the scene and zoom out at the same time for this effect.

SVG sorts as always ​

ts
const houses = cubes.data(HOUSES).map((cube) =>
    Object.assign(cube, {
        centroid: cube.faces.find((face) => face.face === 'bottom').centroid
    })
);

houseGroup
    .selectAll('g.house')
    .data(houses, (d) => d[0].house)
    .join('g')
    .sort(sort);

The eye sits on the rotated z axis, so the rotated z is still the depth along the line of sight, and sort paints the far houses first, as in an orthographic view. The houses stand on one floor and sort by the centroid of their bottom face, as the mini city does: the centre of a tall house sits high up and would paint over a short house in front of it.

A projective map keeps straight lines straight, so every face is still a polygon and the paths are exact.

Faces that only the perspective shows ​

Look straight down the street and the walls that face it are edge-on in an orthographic view: they run along the view direction. In perspective the eye sits between the rows, and it sees the wall of every house that faces the street.

ccw tells them apart, because it reads the winding of the projected points: a face whose corners run counter-clockwise on the screen faces the eye. Switch the toggle to viewDirection to see the orthographic test instead:

ts
const facesTheCamera = dot(normal(face), camera.viewDirection()) > 0;

It asks whether the face turns towards a direction, but in perspective it has to turn towards a point, the eye at rotationCenter + perspective · viewDirection. The walls along the street vanish, a few walls that turn away are drawn, and the readout counts both. At the right end of the slider the two tests agree again.

Text at a rotated point ​

ts
const roof = house.faces.find((face) => face.face === 'top');
const at = perspective(roof.centroid, view);
const growth = 1 / (1 - (roof.centroid.z - center.z) / distance);

text.attr('x', at.x)
    .attr('y', at.y)
    .attr('font-size', 13 * growth);

The house numbers sit on the roofs. perspective(point, options) is the projection itself: it maps the rotated centroid of the roof to the screen, as orthographic does in the bar chart. A text or a circle keeps its size on the screen, so the growth of the roof scales the font too.

Where it ends ​

SVG cannot clip a polygon that reaches behind the eye, and a point there has no place on the screen. The scene has to lie in front of the eye: the street fits in a ball of radius 10 around the rotation center, and the slider stops at 12.

iso3D stays orthographic whatever the camera says. An isometric view is orthographic by definition, and the SVG matrix of a face is affine, which the image of a plane in perspective is not.

The whole source ​

The figure lives in docs/examples/perspectiveStreet.ts in the repository, its styles in docs/examples/perspectiveStreet.css, and the drag in docs/examples/rotatingFigure.ts. The figure, ready to copy:

ts
import { easeCubicInOut, select, timer } from 'd3';
import type { Timer } from 'd3';
import { box, camera3D, cubes3D, normal, perspective, planes3D, sort } from '../../index';
import type { Camera3DInstance, Cube, CubeFace, Point3D } from '../../index';
import { joinFaces } from './cubeFaces';
import { dragRotate, turnCamera } from './rotatingFigure';
import './perspectiveStreet.css';

/*
 * A street of sixteen houses seen through `.perspective()`, drawn as SVG
 * paths. A slider and a dolly zoom move the eye, and a toggle hides the
 * faces by their winding or by the orthographic test against the view
 * direction.
 */

const WIDTH = 960;
const HEIGHT = 500;
const SCALE = 44;
/** The nearest eye: farther from the center than the scene reaches, 10 units. */
const NEAR = 12;
/** The right end of the slider, which stands for an eye infinitely far away. */
const FAR = 80;
const START = 20;
/** The camera looks down on the street from this angle above the floor, in radians. */
const TILT = 0.22;
/** The length of a dolly zoom, in milliseconds. */
const DOLLY = 2400;

type HouseVertex = Point3D & { house: number };
type House = Cube<HouseVertex>;
type Cull = 'ccw' | 'viewDirection';

/** The heights of the houses, in model units, two by two from the far end. */
const HEIGHTS = [3, 1.5, 2.5, 4, 2, 3.5, 1.5, 3, 2, 4, 1.5, 3, 2.5, 1.5, 3.5, 2];

/**
 * Two rows of eight houses, numbered like a European street: the odd ones
 * on the left, the even ones on the right. Every corner knows its house.
 */
const HOUSES: HouseVertex[][] = HEIGHTS.map((height, h) =>
    box({
        x: h % 2 === 0 ? -2 : 2,
        y: 0,
        z: 2 * Math.floor(h / 2) - 7,
        width: 1.6,
        depth: 1.6,
        height
    }).map((corner) => ({ ...corner, house: h }))
);

/** A rectangle of the floor y = 0. */
function quad(x0: number, x1: number, z0: number, z1: number): Point3D[] {
    return [
        { x: x0, y: 0, z: z1 },
        { x: x1, y: 0, z: z1 },
        { x: x1, y: 0, z: z0 },
        { x: x0, y: 0, z: z0 }
    ];
}

/** The two lanes of the street, in tiles of one unit. */
const TILES = Array.from({ length: 34 }, (_, t) =>
    quad((t % 2) - 1, t % 2, Math.floor(t / 2) - 8.5, Math.floor(t / 2) - 7.5)
);
/** The floor: the two sidewalks, then the tiles of the street. */
const FLOOR = [quad(-3.5, -1, -8.5, 8.5), quad(1, 3.5, -8.5, 8.5), ...TILES];

/** The class of floor quad `q`: the sidewalks, then a checkerboard. */
function floorClass(q: number): string {
    if (q < 2) {
        return 'sidewalk';
    }
    const t = q - 2;
    return (Math.floor(t / 2) + t) % 2 === 0 ? 'street' : 'street-alt';
}

const fixed = (value: number) => value.toFixed(1).replace('-', '−');

/** How much larger than at the rotation center a rotated depth is drawn. */
const growth = (camera: Camera3DInstance, z: number) =>
    1 / (1 - (z - camera.rotationCenter().z) / camera.perspective());

/**
 * Whether a face shows: by its winding on the screen, which holds in
 * perspective, or by the orthographic test against the view direction.
 */
function shows(face: CubeFace<HouseVertex>, cull: Cull, v: Point3D): boolean {
    if (cull === 'ccw') {
        return face.ccw;
    }
    const n = normal(face);
    return n.x * v.x + n.y * v.y + n.z * v.z > 0;
}

/** Where the eye is, in words. */
function describeEye(camera: Camera3DInstance): string {
    const d = camera.perspective();
    if (d === Infinity) {
        return 'Orthographic: the eye is infinitely far away, and every ray runs along the view direction.';
    }
    const c = camera.rotationCenter();
    const v = camera.viewDirection();
    const eye = `(${fixed(c.x + d * v.x)}, ${fixed(c.y + d * v.y)}, ${fixed(c.z + d * v.z)})`;
    return `The eye sits ${fixed(d)} units in front of the rotation center, at ${eye}.`;
}

/** How the two face tests differ, in words. */
function describeFaces(houses: House[], camera: Camera3DInstance): string {
    const v = camera.viewDirection();
    const faces = houses.flatMap((house) => house.faces);
    const hidden = faces.filter((face) => face.ccw && !shows(face, 'viewDirection', v)).length;
    const extra = faces.filter((face) => !face.ccw && shows(face, 'viewDirection', v)).length;
    return `Against the view direction, ${hidden} faces the eye sees would be hidden and ${extra} that turn away would be drawn.`;
}

/**
 * Renders the street into `host` and returns the function that removes it.
 */
export function mountPerspectiveStreet(host: HTMLElement): () => void {
    host.classList.add('perspective-street');
    host.innerHTML = `
        <svg viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img"
            aria-label="A street of sixteen houses in perspective"></svg>
        <form class="ps-controls">
            <label>perspective
                <input name="distance" type="range" min="${NEAR}" max="${FAR}" step="0.5" value="${START}">
                <output></output>
            </label>
            <button type="button">dolly in</button>
            <fieldset>
                <legend>hide faces by</legend>
                <label><input type="radio" name="cull" value="ccw" checked> ccw</label>
                <label><input type="radio" name="cull" value="viewDirection"> viewDirection</label>
            </fieldset>
        </form>
        <div class="ps-readout">
            <p class="ps-eye"></p>
            <p class="ps-faces"></p>
        </div>`;

    const svg = select(host).select<SVGSVGElement>('svg');
    const floorGroup = svg.append('g');
    const houseGroup = svg.append('g');

    const form = host.querySelector('form')!;
    const slider = form.elements.namedItem('distance') as HTMLInputElement;
    const output = slider.nextElementSibling as HTMLOutputElement;
    const button = form.querySelector('button')!;
    const line = (name: string) => host.querySelector(`.ps-${name}`)!;

    const camera = camera3D()
        .scale(SCALE)
        .origin({ x: WIDTH / 2, y: HEIGHT / 2 + 40 })
        .perspective(START);
    const floor = planes3D().camera(camera);
    const cubes = cubes3D<HouseVertex>().camera(camera);

    let cull: Cull = 'ccw';
    let yaw = 0;
    let pitch = 0;
    let dolly: Timer | undefined;

    /** The houses as `cubes3D` projects them, each sorting by its footprint. */
    function project(): House[] {
        return cubes.data(HOUSES).map((cube) =>
            Object.assign(cube, {
                centroid: cube.faces.find((face) => face.face === 'bottom')!.centroid
            })
        );
    }

    /** The floor, then one group per house, painted back to front by `sort`. */
    function drawStreet(houses: House[]): void {
        floorGroup
            .selectAll('path')
            .data(floor.data(FLOOR))
            .join('path')
            .attr('class', (_, q) => floorClass(q))
            .attr('d', (plane) => floor.draw(plane));

        const v = camera.viewDirection();
        const groups = houseGroup
            .selectAll<SVGGElement, House>('g.house')
            .data(houses, (d) => d[0].house)
            .join((enter) => {
                const g = enter.append('g').attr('class', 'house');
                g.append('g');
                g.append('text');
                return g;
            })
            .sort(sort);

        joinFaces(
            groups
                .select('g')
                .selectAll<SVGPathElement, CubeFace<HouseVertex>>('path')
                .data(
                    (d) => d.faces,
                    (d) => d.face
                ),
            cubes.draw,
            (face) => shows(face, cull, v)
        );

        // The number sits on the roof: `perspective` projects the rotated
        // centroid, and the text grows with the roof.
        const view = {
            origin: camera.origin(),
            scale: camera.scale(),
            rotateCenter: camera.rotationCenter(),
            distance: camera.perspective()
        };
        groups.select('text').each(function (d) {
            const roof = d.faces.find((face) => face.face === 'top')!;
            const at = perspective(roof.centroid, view);
            select(this)
                .attr('x', at.x)
                .attr('y', at.y)
                .attr('font-size', 13 * growth(camera, roof.centroid.z))
                .attr('visibility', shows(roof, cull, v) ? null : 'hidden')
                .text(d[0].house + 1);
        });
    }

    function render(): void {
        turnCamera(camera, yaw, pitch, { tilt: TILT });

        const houses = project();
        drawStreet(houses);

        const d = camera.perspective();
        output.textContent = d === Infinity ? '∞' : fixed(d);
        button.textContent = d >= 2 * NEAR ? 'dolly in' : 'dolly out';
        line('eye').textContent = describeEye(camera);
        line('faces').textContent = describeFaces(houses, camera);
    }

    function setDistance(d: number): void {
        camera.perspective(d);
        slider.value = String(Math.min(d, FAR));
        render();
    }

    /**
     * Moves the eye to the other end, evenly in 1 / distance. The scale
     * holds in the plane of the rotation center, so the middle of the
     * street keeps its size while the ends grow and shrink.
     */
    function dollyZoom(): void {
        dolly?.stop();
        const from = 1 / camera.perspective();
        const to = camera.perspective() >= 2 * NEAR ? 1 / NEAR : 0;
        if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
            setDistance(1 / to);
            return;
        }
        dolly = timer((elapsed) => {
            const t = Math.min(1, elapsed / DOLLY);
            setDistance(1 / (from + (to - from) * easeCubicInOut(t)));
            if (t === 1) {
                dolly?.stop();
            }
        });
    }

    form.addEventListener('input', (event) => {
        if (event.target === slider) {
            dolly?.stop();
            setDistance(slider.valueAsNumber === FAR ? Infinity : slider.valueAsNumber);
        }
    });
    form.addEventListener('change', () => {
        cull = new FormData(form).get('cull') as Cull;
        render();
    });
    button.addEventListener('click', dollyZoom);

    svg.call(
        dragRotate((y, p) => {
            yaw = y;
            pitch = p;
            render();
        })
    );

    render();

    return () => {
        dolly?.stop();
        host.classList.remove('perspective-street');
        host.innerHTML = '';
    };
}

Released under the BSD-3-Clause License. Sponsor d3-3d on GitHub Sponsors.