Skip to content

Mesh inspector ​

Drag the figure to rotate it and click a face to inspect it, or type its number. The checkboxes show and hide the faces, the edges, the normals and the back faces. The button resets them and clears the selection.

A vase of triangles ​

The vase is a profile turned around the y axis by lathe: a list of rings from the middle of the foot to the lip, each a radius and a height. Between two rings every quad becomes two triangles. The first ring has no radius, so the foot is a fan around its centre; the top is left open, so the inside of the vase shows:

ts
const MESH = lathe(PROFILE, SEGMENTS);

The order of the three points of a triangle is the point of it: lathe winds every triangle counter-clockwise when it is seen from outside the vase.

Front or back ​

triangles3D hands back every triangle with a ccw flag: whether its points run counter-clockwise on the screen after the rotation. A triangle that is counter-clockwise from outside is clockwise from inside, so with every triangle wound the same way, ccw tells a face that looks at the camera from one that looks away. Uncheck back faces and the figure hides the faces that look away, without taking them out of the data:

ts
.attr('visibility', (d) => (isShown(d) ? null : 'hidden'))

The vase is open, so its inside is all back faces: you look at the faces of the far wall from behind. With the back faces hidden, the inside of the vase is gone. The blue faces are the outside, the orange ones the inside.

The flag is the sign of one number. The normal of a face, turned with the mesh, points at the viewer exactly when ccw is true. Its z is the cosine between the normal and the line of sight, so it also shades the face, from dark when it is seen edge-on to light when it is seen head-on:

ts
.style('fill', (d) =>
    !faces.checked ? 'none' : (d.ccw ? OUTSIDE : INSIDE)(Math.abs(d.nz))
)

The inspector shows both for the selected face, and they agree at every angle.

Normals with lines3D ​

A normal is a segment from the centroid of its face, a quarter of a unit along the face's normal. The normals are computed once in model space and go through lines3D. Both renderers hang on one camera, and every render turns the camera, so a normal stays on its face:

ts
const camera = camera3D().scale(SCALE).origin(ORIGIN);
const faces3d = triangles3D().camera(camera);
const normals3d = lines3D().camera(camera);

turnCamera(camera, yaw, pitch, { yaw: START_YAW, tilt: START_TILT });

One painter's list ​

Hiding the back faces is not enough to draw the vase. Its neck is narrower than its belly and its lip, so faces that all look at the camera still cover each other. The faces and the normals go into one group, and the sort utility orders both by the depth of their centroid. It only reads centroid.z, which triangles3D and lines3D both set:

ts
group.selectAll('path, line').sort(sort);

A normal is painted after the faces behind it and before the faces in front of it. A normal that points away from the camera ends up under its own face, and a normal of the far wall disappears behind the near wall.

Picking a face ​

data returns new arrays, so the index of every triangle is stamped on after the projection and is the key of the join:

ts
.map((d, i) => Object.assign(d, { id: i, nz: normal(d.map((p) => p.rotated)).z }))

A click reads the face under the pointer from the datum of its path. The faces take pointer-events="visible", so with the faces unchecked the wireframe can still be clicked, while a hidden back face cannot. The normals take no pointer events, so a click goes through them to the face. The drag swallows the click that ends it, so turning the vase never selects a face. Every redraw re-sorts the faces, and the browser drops a click whose face has moved in the DOM, so nothing redraws while a button is down: a press that wanders less than four pixels is still a click and does not turn the vase. A free pointer outlines the face that a click would select.

The number field selects without a pointer. A face selected there can be a hidden back face; the inspector says so.

No stylesheet ​

The figure brings its own styles, so the module is all there is to copy. The colors are light-dark() pairs set with style, as in the floor-plan explorer.

The whole source ​

The figure lives in docs/examples/meshInspector.ts in the repository, the SVG and the drag in docs/examples/rotatingFigure.ts and the ends of the normals in docs/examples/segments.ts. The figure, ready to copy:

ts
import { format, interpolateLab, select } from 'd3';
import { camera3D, lathe, lines3D, normal, sort, triangles3D } from '../../index';
import type { HasCentroid, Line, Point3D, Triangle } from '../../index';
import { rotatingFigure, turnCamera } from './rotatingFigure';
import { placeSegments } from './segments';

/*
 * A mesh inspector: a low-poly vase of triangles drawn with triangles3D and
 * the normal of every face drawn with lines3D. Every triangle is wound the
 * same way, so after the rotation `ccw` tells a face that looks at the camera
 * from one that looks away, and the inside of the open vase is all back
 * faces. The faces and the normals are painted back to front in one list.
 * The figure brings its own styles, so the module works without a stylesheet.
 */

const ORIGIN = { x: 480, y: 250 };
const SCALE = 160;
const START_YAW = Math.PI / 6;
const START_TILT = Math.PI / 5;
// The faces around the vase, and the length of a normal in model units.
const SEGMENTS = 12;
const NORMAL = 0.25;

// The profile of the vase from the middle of the foot to the lip: the
// radius and the height of every ring. The screen y grows downwards, so the
// lip has the smallest y. The first ring has no radius, so the foot is
// closed and the top is left open.
const PROFILE = [
    { x: 0, y: 1 },
    { x: 0.45, y: 1 },
    { x: 0.8, y: 0.7 },
    { x: 0.95, y: 0.3 },
    { x: 0.75, y: -0.1 },
    { x: 0.38, y: -0.45 },
    { x: 0.4, y: -0.75 },
    { x: 0.65, y: -1 }
];

// The colors of both themes. `light-dark()` follows the color-scheme of the
// page, so a theme switch recolors the figure without a redraw. It is CSS,
// not an SVG attribute value, so the colors are set with `style`.
const INK = 'light-dark(#1b1e21, #e6e8e9)';
const MUTED = 'light-dark(#5f6468, #a3a8ab)';
const LINE = 'light-dark(#c4c7c9, #3d4144)';
const PAPER = 'light-dark(#ffffff, #0c0d0e)';
const BUTTON = 'light-dark(#f0f1f1, #17181a)';
const ACCENT = 'light-dark(#c2255c, #f783ac)';

// The outside of the vase in blue, the inside in orange, from a face seen
// edge-on to one seen head-on. Both read on either page color.
const OUTSIDE = interpolateLab('#0d366b', '#86b6ef');
const INSIDE = interpolateLab('#5a2207', '#f4a261');

// The checkboxes: the name, the label and whether it starts checked.
const TOGGLES: [string, string, boolean][] = [
    ['faces', 'faces', true],
    ['edges', 'edges', true],
    ['normals', 'normals', false],
    ['backs', 'back faces', true]
];

/** A projected face with the index it was built at, the key of the join, and the z of its normal. */
type Facet = Triangle<Point3D> & { id: number; nz: number };

// The vase: the profile turned around the y axis. `lathe` winds every
// triangle counter-clockwise as seen from outside.
const MESH = lathe(PROFILE, SEGMENTS);

// The normals never change in model space: they turn with the mesh.
const NORMALS = MESH.map(normal);
const STEMS = MESH.map((triangle, i) => {
    const [a, b, c] = triangle;
    const n = NORMALS[i];
    const base = { x: (a.x + b.x + c.x) / 3, y: (a.y + b.y + c.y) / 3, z: (a.z + b.z + c.z) / 3 };
    return [base, { x: base.x + n.x * NORMAL, y: base.y + n.y * NORMAL, z: base.z + n.z * NORMAL }];
});

const fixed = format('.2f');
const vector = (p: Point3D) => `(${fixed(p.x)}, ${fixed(p.y)}, ${fixed(p.z)})`;

/**
 * Mounts the figure into `host` and returns a function that removes it.
 */
export function mountMeshInspector(host: HTMLElement): () => void {
    // A normal starts on the centroid of its face, so as long as both
    // renderers hang on one camera it stays on the face as the mesh turns.
    const camera = camera3D().scale(SCALE).origin(ORIGIN);
    const faces3d = triangles3D<Point3D>().camera(camera);
    const normals3d = lines3D<Point3D>().camera(camera);

    let yaw = 0;
    let pitch = 0;
    let selected: number | undefined;
    let hovered: number | undefined;

    const { group, button } = rotatingFigure(
        host,
        'mesh',
        `A vase of ${MESH.length} triangles. Drag it to rotate it and click a face to inspect it, or type the number of a face.`,
        (dragYaw, dragPitch) => {
            yaw = dragYaw;
            pitch = dragPitch;
            // The outlined face turns away from under the pointer.
            hovered = undefined;
            render();
        }
    );

    // The box, the button and the controls are HTML and take their styles
    // inline.
    select(host).attr(
        'style',
        `position: relative; overflow: hidden; margin: 1.5rem 0; border: 1px solid ${LINE}; border-radius: 12px; background: ${PAPER}; color: ${INK}`
    );
    button
        .text('reset')
        .attr(
            'style',
            `position: absolute; top: 0.75rem; right: 0.75rem; padding: 0.35rem 0.9rem; border: 1px solid ${LINE}; border-radius: 8px; background: ${BUTTON}; color: ${INK}; font-size: 0.85rem; cursor: pointer`
        );

    const row = 'display: flex; align-items: center; gap: 0.4rem';
    host.insertAdjacentHTML(
        'beforeend',
        `<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1.5rem; padding: 0.75rem; border-top: 1px solid ${LINE}; font-size: 0.85rem">
            ${TOGGLES.map(([name, label]) => `<label style="${row}"><input name="${name}" type="checkbox">${label}</label>`).join('')}
            <label style="${row}">face <input name="face" type="number" min="0" max="${MESH.length - 1}" step="1" style="width: 4.5em; padding: 0.15rem 0.4rem; border: 1px solid ${LINE}; border-radius: 6px; background: ${BUTTON}; color: ${INK}"></label>
            <p style="flex-basis: 100%; margin: 0; min-height: 3em; line-height: 1.5; font-variant-numeric: tabular-nums"><span aria-live="polite"></span> <span style="color: ${MUTED}"></span></p>
        </div>`
    );

    const svg = select(host)
        .select<SVGSVGElement>('svg')
        .attr('style', 'display: block; width: 100%; height: auto; cursor: grab; touch-action: none');
    const [faces, edges, normals, backs] = TOGGLES.map(
        ([name]) => host.querySelector(`[name=${name}]`) as HTMLInputElement
    );
    const picker = host.querySelector('[name=face]') as HTMLInputElement;
    const [named, readout] = Array.from(host.querySelectorAll<HTMLElement>('p span'));

    /** A face that looks away is only painted while the back faces are on. */
    const isShown = (d: Facet) => d.ccw || backs.checked;

    /** Projects the faces and the normals, styles them from the toggles and paints them back to front. */
    function render(): void {
        turnCamera(camera, yaw, pitch, { yaw: START_YAW, tilt: START_TILT });

        // The projected triangles are new arrays, so the key and the normal
        // in view space are stamped on after the projection. Its z is the
        // cosine to the line of sight, which shades the face, and its sign
        // agrees with `ccw`.
        const facets: Facet[] = faces3d
            .data(MESH)
            .map((d, i) => Object.assign(d, { id: i, nz: normal(d.map((p) => p.rotated)).z }));

        // A face that turns away keeps its element and is only hidden, so a
        // drag never adds or removes one.
        group
            .selectAll<SVGPathElement, Facet>('path')
            .data(facets, (d) => d.id)
            .join((enter) =>
                enter
                    .append('path')
                    .attr('stroke-linejoin', 'round')
                    // Clickable without a fill, but not while hidden.
                    .attr('pointer-events', 'visible')
            )
            .attr('visibility', (d) => (isShown(d) ? null : 'hidden'))
            .style('fill', (d) =>
                !faces.checked ? 'none' : (d.ccw ? OUTSIDE : INSIDE)(Math.abs(d.nz))
            )
            .style('stroke', (d) =>
                d.id === selected || d.id === hovered ? ACCENT : edges.checked ? INK : 'none'
            )
            .attr('stroke-opacity', (d) =>
                d.id === selected ? 1 : d.id === hovered ? 0.6 : faces.checked ? 0.3 : 0.7
            )
            .attr('stroke-width', (d) => (d.id === selected ? 2.5 : d.id === hovered ? 2 : 1))
            .attr('d', faces3d.draw);

        // The normals keep the order of the faces, so the join goes by the
        // index. The normal of the selected face shows even with the normals
        // off.
        group
            .selectAll<SVGLineElement, Line<Point3D>>('line')
            .data(normals3d.data(STEMS))
            .join((enter) =>
                enter.append('line').attr('stroke-linecap', 'round').attr('pointer-events', 'none')
            )
            .call(placeSegments)
            .attr('visibility', (_, i) =>
                (normals.checked || i === selected) && isShown(facets[i]) ? null : 'hidden'
            )
            .style('stroke', (_, i) => (i === selected ? ACCENT : MUTED))
            .attr('stroke-width', (_, i) => (i === selected ? 2.5 : 1.25));

        // One painter's list: the faces and the normals both have a
        // centroid, so a normal that points away hides behind its face.
        group.selectAll<SVGElement, HasCentroid>('path, line').sort(sort);

        const d = selected === undefined ? undefined : facets[selected];
        if (!d) {
            readout.textContent = '';
            return;
        }
        const painted = facets.filter(isShown);
        readout.textContent = `${d.ccw ? 'Faces the camera' : 'Faces away'}: ccw ${d.ccw}, normal z in view ${fixed(d.nz)}, depth ${fixed(d.centroid.z)}. ${
            isShown(d)
                ? `Painted ${painted.filter((f) => sort(f, d) < 0).length + 1} of ${painted.length}.`
                : 'Hidden while the back faces are off.'
        }`;
    }

    /** Selects a face, or none, and describes the part of it that never changes. */
    function inspect(id: number | undefined): void {
        selected = id;
        picker.value = id === undefined ? '' : String(id);
        named.textContent =
            id === undefined
                ? 'Click a face or type its number to inspect it.'
                : `Face ${id} of ${MESH.length}: vertices ${MESH[id].map(vector).join(', ')}, outward normal ${vector(NORMALS[id])}.`;
        render();
    }

    /** Checks the toggles as they start and clears the selection. */
    function reset(): void {
        TOGGLES.forEach(([, , on], k) => {
            [faces, edges, normals, backs][k].checked = on;
        });
        inspect(undefined);
    }

    /** The id of the face an event targets, if any. */
    function faceAt(event: Event): number | undefined {
        const target = event.target as Element;
        return target.matches('path') ? select<Element, Facet>(target).datum().id : undefined;
    }

    // A click on the selected face or next to the vase clears the selection.
    // d3.drag swallows the click that ends a drag, so turning the vase never
    // selects a face.
    svg.on('click', (event: MouseEvent) => {
        const id = faceAt(event);
        inspect(id === selected ? undefined : id);
    });

    // A free pointer outlines the face a click would select. Every redraw
    // re-sorts the faces, and the browser drops a click whose face moved in
    // the DOM, so the outline never redraws while a button is down.
    svg.on('pointermove pointerleave', (event: PointerEvent) => {
        const id = faceAt(event);
        if (!event.buttons && id !== hovered) {
            hovered = id;
            render();
        }
    });

    picker.addEventListener('input', () => {
        const id = picker.valueAsNumber;
        inspect(Number.isInteger(id) && id >= 0 && id < MESH.length ? id : undefined);
    });
    for (const input of [faces, edges, normals, backs]) {
        input.addEventListener('change', render);
    }
    button.on('click', reset);

    reset();

    return () => {
        select(host).attr('style', null);
        host.innerHTML = '';
    };
}

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