Style
Sizes, variants, palette resolution and the capsule limb geometry — plus the CSS variables and keyframes the whole set is themed with.
solid
outline
blueprint
wire
Theming
Set a role and the same CSS goes in your own app — every robot under it follows.
Install
bunx --bun shadcn@latest add https://robocn.dev/r/robot-style.jsonUsage
:root {
--robot-shell: oklch(0.72 0.17 47);
--robot-accent: oklch(0.72 0.15 176);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| resolveRobotPalette | (props) => RobotPalette | — | Prop, then CSS variable, then built-in default, for each of shell, metal, dark, accent, glow, grid and foreground. |
| robotSurface | (role, variant, palette, weight?) => RobotSurface | — | Fill, stroke and weight for one part, under one paint variant. |
| capsulePath | (a, b, radius) => string | — | The limb silhouette every machine in the set is drawn from. |
| mountTransform / labelTransform | (mount, …) => string | — | Puts the drawing in robot coordinates for a given mount, and turns labels back the right way up. |
| px | (value: number) => number | — | Rounds a coordinate before it reaches the DOM. Trigonometry differs in the last bits between Node and the browser, and unrounded values show up as hydration mismatches. |
Source
src/lib/robocn/style.ts
/**
* robocn — shared visual language.
*
* Colour, size and form live here so every machine in the set reacts to the
* same props. Colours resolve prop -> CSS variable -> built-in default, which
* is what lets one install theme itself while a single arm can still be
* overridden inline.
*/
import {
add2,
convexHull2,
normalize2,
perpendicular2,
scale2,
sub2,
toRadians,
type Vec2,
type Vec3,
} from "@/lib/robocn/kinematics"
export type RobotSize = "xs" | "sm" | "md" | "lg" | "xl"
/** Rendered width in pixels. Geometry is fixed in the viewBox, so this only scales. */
export const robotSizes: Record<RobotSize, number> = {
xs: 96,
sm: 144,
md: 224,
lg: 320,
xl: 448,
}
export const resolveRobotSize = (size: RobotSize | number = "md") =>
typeof size === "number" ? size : robotSizes[size]
/** How a machine is drawn. Geometry never changes — only how it is painted. */
export type RobotVariant = "solid" | "outline" | "blueprint" | "wire"
/** End effector. Robots carry exactly one, and it is visible at a glance. */
export type RobotTool =
| "gripper"
| "welder"
| "painter"
| "cutter"
| "scanner"
| "vacuum"
| "magnet"
| "none"
/** What drives the tool tip when the component is not given a `target`. */
export type RobotBehavior = "pointer" | "orbit" | "sweep" | "idle" | "static"
/** Where the machine is bolted down. Flips or rotates the whole rig. */
export type RobotMount = "floor" | "ceiling" | "wall-left" | "wall-right"
export interface RobotPalette {
/** Painted body panels — the colour people read as "the robot's colour". */
shell: string
/** Bare machined metal: collars, bolts, tool bodies. */
metal: string
/** Cast joints, base, shadow side. */
dark: string
/** Status colour: tip light, active tool, live readouts. */
accent: string
/** Emissive halo around the accent. */
glow: string
/** Blueprint grid and dimension lines. */
grid: string
/** Labels and annotations. */
foreground: string
}
/**
* Each role falls back through its own CSS variable, so `--robot-shell` set on
* `:root` (or on any ancestor) retints every robot underneath it.
*/
export const defaultRobotPalette: RobotPalette = {
shell: "var(--robot-shell, oklch(0.72 0.17 47))",
metal: "var(--robot-metal, oklch(0.74 0.012 250))",
dark: "var(--robot-dark, oklch(0.32 0.02 250))",
accent: "var(--robot-accent, oklch(0.84 0.15 176))",
glow: "var(--robot-glow, oklch(0.84 0.15 176))",
grid: "var(--robot-grid, oklch(0.68 0.02 250))",
foreground: "var(--robot-foreground, currentColor)",
}
export interface RobotPaletteProps {
/** Shorthand for the shell colour. */
color?: string
accent?: string
metal?: string
dark?: string
glow?: string
grid?: string
/** Escape hatch: override any subset of roles at once. */
palette?: Partial<RobotPalette>
}
export function resolveRobotPalette({
color,
accent,
metal,
dark,
glow,
grid,
palette,
}: RobotPaletteProps = {}): RobotPalette {
return {
...defaultRobotPalette,
...palette,
...(color ? { shell: color } : null),
...(metal ? { metal } : null),
...(dark ? { dark } : null),
...(accent ? { accent, glow: glow ?? accent } : null),
...(glow ? { glow } : null),
...(grid ? { grid } : null),
}
}
export type RobotRole = "shell" | "metal" | "dark" | "accent"
export interface RobotSurface {
fill: string
stroke: string
strokeWidth: number
strokeDasharray?: string
/** Washes the fill out without touching the outline. */
fillOpacity?: number
}
/**
* Paint for one part. `weight` scales the outline with the part's size so a
* base plate and a bolt do not carry the same line.
*/
export function robotSurface(
role: RobotRole,
variant: RobotVariant,
palette: RobotPalette,
weight = 1,
): RobotSurface {
const color = palette[role]
switch (variant) {
case "outline":
return { fill: "none", stroke: color, strokeWidth: 1.1 * weight }
case "blueprint":
// Solid technical outline over a washed fill, the way a drawing reads.
return {
fill: color,
stroke: role === "dark" ? palette.grid : color,
strokeWidth: 0.8 * weight,
strokeDasharray: role === "dark" ? "2 1.5" : undefined,
fillOpacity: role === "accent" ? 0.9 : 0.12,
}
case "wire":
return {
fill: "none",
stroke: role === "accent" ? color : palette.grid,
strokeWidth: 0.9 * weight,
}
default:
return {
fill: color,
stroke: palette.dark,
strokeWidth: role === "dark" ? 0 : 0.6 * weight,
}
}
}
/** Limb colour alternates down the chain so segments read as separate parts. */
export const linkRole = (index: number): RobotRole =>
index % 2 === 0 ? "shell" : "metal"
/**
* Round a coordinate before it reaches the DOM. Trigonometry differs in the
* last bits between Node and the browser, and unrounded values show up as
* hydration mismatches; two decimals is also plenty of precision to draw with.
*/
export const px = (value: number) => Number(value.toFixed(2))
/** True when the visitor has asked the OS for less animation. */
export function prefersReducedMotion() {
if (typeof window === "undefined" || !window.matchMedia) return false
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
/**
* Transform for the drawing group: origin at the machine's base with `y`
* pointing up, which is how the kinematics thinks, and mirrored or rotated for
* the mount. Everything downstream can then be written in robot coordinates.
*/
export function mountTransform(
mount: RobotMount,
width: number,
height: number,
floor: number,
) {
switch (mount) {
// Hanging: mirror the vertical axis only, so the machine reads the same
// way up-side-down instead of also flipping left to right.
case "ceiling":
return `translate(${width / 2} ${floor})`
case "wall-left":
return `translate(${floor} ${height / 2}) rotate(-90)`
case "wall-right":
return `translate(${width - floor} ${height / 2}) rotate(90) scale(-1 1)`
default:
return `translate(${width / 2} ${height - floor}) scale(1 -1)`
}
}
/**
* Counter-transform for labels. The drawing group is mirrored or rotated so the
* geometry can be written the way the kinematics thinks; text has to be turned
* back the right way up inside it.
*/
export function labelTransform(mount: RobotMount) {
switch (mount) {
case "ceiling":
return ""
case "wall-left":
return "rotate(90)"
case "wall-right":
return "scale(-1 1) rotate(-90)"
default:
return "scale(1 -1)"
}
}
/**
* A limb: the rectangle between two joints, capped with a half circle at each
* end. Every machine in the set is drawn out of these, so a gantry rail and an
* elbow limb share one silhouette language.
*/
export function capsulePath(a: Vec2, b: Vec2, radius: number) {
const direction = normalize2(sub2(b, a), { x: 1, y: 0 })
const side = scale2(perpendicular2(direction), radius)
const a1 = add2(a, side)
const b1 = add2(b, side)
const b2 = sub2(b, side)
const a2 = sub2(a, side)
const r = px(radius)
return [
`M ${px(a1.x)} ${px(a1.y)}`,
`L ${px(b1.x)} ${px(b1.y)}`,
`A ${r} ${r} 0 0 0 ${px(b2.x)} ${px(b2.y)}`,
`L ${px(a2.x)} ${px(a2.y)}`,
`A ${r} ${r} 0 0 0 ${px(a1.x)} ${px(a1.y)}`,
"Z",
].join(" ")
}
/* -------------------------------------------------------------------------- */
/* views */
/* -------------------------------------------------------------------------- */
/**
* Where the camera stands. Geometry never changes between views — a machine is
* modelled once and projected, so heading, guards and blades stay truthful from
* every angle instead of being redrawn per view.
*/
export type RobotView = "plan" | "front" | "profile" | "iso"
/**
* World axes for a viewed machine: `x` starboard, `y` up, `z` toward the tail.
* Right-handed, and it lands nose-up in plan view, which is how the flat
* machines were already drawn.
*/
export const robotViews: Record<RobotView, { azimuth: number; elevation: number }> = {
plan: { azimuth: 0, elevation: 90 },
front: { azimuth: 180, elevation: 10 },
profile: { azimuth: 90, elevation: 10 },
iso: { azimuth: 145, elevation: 26 },
}
export interface RobotCamera {
view: RobotView
/** Screen position of a world point, in drawing units. */
project(x: number, y: number, z: number): Vec2
/** How far toward the camera a point sits. Bigger draws later. */
depth(x: number, y: number, z: number): number
/**
* SVG transform that lays a plan-view drawing onto the screen at height `y`,
* turned `spin` degrees clockwise. Anything flat — a guard ring, a propeller,
* the deck detail — is exact under it, so one piece of artwork serves every
* view. In plan the transform is the identity.
*
* `up` conjugates the transform for a group that has already been mirrored
* to draw with `y` pointing up, which is how the arms are written.
*/
plane(y?: number, spin?: number, up?: boolean): string
/**
* The same thing for artwork drawn in a *vertical* plane — an elevation
* drawing. The plane stands `offset` world units toward the viewer and is
* turned `spin` degrees about the vertical, in the same sense as `plane()`.
* Drawing coordinates are the elevation's own: x right, y down.
*
* `spin` 0 faces the `front` camera and 90 contains the fore-aft axis with
* the nose to the right, so a front-drawn machine uses `wall()` and a
* side-drawn one uses `wall(0, 90)`. In its own view the transform is the
* identity; seen edge-on it is singular and the artwork collapses to a line,
* which is what a wall seen from the side is.
*/
wall(offset?: number, spin?: number, up?: boolean): string
/** Screen rise per world unit of height. Zero looking straight down. */
lift: number
/** How much a horizontal disc keeps of its depth. One looking straight down. */
flatten: number
}
/**
* The vertical axis carries a constant unit scale, the same in every view, so
* that height comes out unforeshortened in the two elevations: `ce` is exactly
* 1 there and still exactly 0 in plan. That is what lets a machine drawn in
* elevation and a machine drawn in plan each keep its own drawing untouched in
* its own view while sharing one camera.
*/
const ELEVATION_COS = Math.cos(toRadians(robotViews.front.elevation))
export function robotCamera(view: RobotView = "plan"): RobotCamera {
const { azimuth, elevation } = robotViews[view] ?? robotViews.plan
return robotCameraAt(azimuth, elevation, view)
}
/**
* The same camera at any angle at all, rather than at one of the four the set
* names. `robotCamera` is this with the named angles filled in, so a machine
* that lets a person turn it keeps every other property — the projection, the
* depth sort, the plan and wall transforms — instead of growing a second one.
*
* `elevation` is clamped to ±90 — straight down is the plan view the set
* already draws in, and straight up is the same camera under the floor.
*/
export function robotCameraAt(
azimuth: number,
elevation: number,
view: RobotView = "plan",
): RobotCamera {
const a = toRadians(finite(azimuth, 0))
const e = toRadians(Math.max(-90, Math.min(90, finite(elevation, 0))))
const ca = Math.cos(a)
const sa = Math.sin(a)
const ce = Math.cos(e) / ELEVATION_COS
const se = Math.sin(e)
const flat = px(ca) === 1 && px(sa) === 0 && px(se) === 1
return {
view,
project: (x, y, z) => ({ x: x * ca - z * sa, y: x * sa * se - y * ce + z * ca * se }),
depth: (x, y, z) => x * ce * sa + y * se + z * ce * ca,
plane: (y = 0, spin = 0, up = false) => {
// Straight down is the identity, so plan-view drawings stay untouched.
const flip = up ? -1 : 1
const shift = px(-y * ce) * flip
const parts = flat
? (shift ? [`translate(0 ${shift})`] : [])
: [`matrix(${px(ca)} ${px(sa * se) * flip} ${px(-sa) * flip} ${px(ca * se)} 0 ${shift})`]
if (spin) parts.push(`rotate(${px(spin) * flip})`)
return parts.join(" ")
},
wall: (offset = 0, spin = 0, up = false) => {
const flip = up ? -1 : 1
const turn = toRadians(spin)
const cs = Math.cos(turn)
const sn = Math.sin(turn)
const a11 = px(sn * sa - cs * ca)
const a12 = px(-se * (cs * sa + sn * ca))
const d = px(ce)
const dx = px(offset * (sn * ca + cs * sa))
const dy = px(offset * se * (sn * sa - cs * ca))
// The plane's own view: no transform at all, so the drawing is untouched.
if (a11 === 1 && a12 === 0 && d === 1 && !dx && !dy) return ""
return `matrix(${a11} ${a12 * flip} 0 ${d} ${dx} ${dy * flip})`
},
lift: ce,
flatten: se,
}
}
/**
* The outline a solid part makes: its plan-view footprint drawn at two heights
* and wrapped in one hull. A height offset projects to a pure vertical screen
* offset, so this is exact for any convex footprint and collapses back to the
* footprint itself in plan view.
*/
export function extrudedPath(
footprint: readonly Vec2[],
camera: RobotCamera,
top: number,
bottom: number,
spin = 0,
/** For a group already mirrored to draw with `y` pointing up. */
up = false,
): string {
const turn = toRadians(spin)
const cs = Math.cos(turn)
const sn = Math.sin(turn)
const corners = footprint.flatMap((point) => {
// Footprint coordinates are plan-view: x starboard, y toward the tail.
const x = point.x * cs - point.y * sn
const z = point.x * sn + point.y * cs
const rise = camera.project(x, top, z)
const fall = camera.project(x, bottom, z)
return up
? [{ x: rise.x, y: -rise.y }, { x: fall.x, y: -fall.y }]
: [rise, fall]
})
return hullPath(corners)
}
/** The closed outline round a set of projected corners. */
function hullPath(corners: readonly Vec2[]): string {
const hull = convexHull2(corners)
if (hull.length < 3) return ""
return `${hull.map((p, i) => `${i ? "L" : "M"} ${px(p.x)} ${px(p.y)}`).join(" ")} Z`
}
/**
* The outline a *tapered* solid makes: two different footprints, one at each
* height, wrapped in one hull. `extrudedPath` is the special case where both
* are the same, and this is the general one — a chassis wider at the floor
* than at the deck, a wedge head, a truncated cone. Exact for any pair of
* convex footprints, since a height offset projects to a pure vertical screen
* offset, and it collapses to the hull of the two footprints in plan view.
*/
export function frustumPath(
bottom: readonly Vec2[],
top: readonly Vec2[],
camera: RobotCamera,
bottomY: number,
topY: number,
spin = 0,
/** For a group already mirrored to draw with `y` pointing up. */
up = false,
): string {
const turn = toRadians(spin)
const cs = Math.cos(turn)
const sn = Math.sin(turn)
const corner = (point: Vec2, height: number) => {
// Footprint coordinates are plan-view: x starboard, y toward the tail.
const x = point.x * cs - point.y * sn
const z = point.x * sn + point.y * cs
const screen = camera.project(x, height, z)
return up ? { x: screen.x, y: -screen.y } : screen
}
return hullPath([
...bottom.map((point) => corner(point, bottomY)),
...top.map((point) => corner(point, topY)),
])
}
/**
* The silhouette of a solid given its own corners in world space.
* `extrudedPath` and `frustumPath` are the special cases where the corners come
* from a plan-view footprint at two heights; this is the general one, for parts
* that stand in any plane at all — a palm slab, a sole plate, a pitched rib
* hoop. Exact for a convex solid, which is what these parts are.
*/
export function slabPath(
corners: readonly Vec3[],
camera: RobotCamera,
/** For a group already mirrored to draw with `y` pointing up. */
up = false,
): string {
return hullPath(
corners.map((corner) => {
const point = camera.project(corner.x, corner.y, corner.z)
return up ? { x: point.x, y: -point.y } : point
}),
)
}
/**
* A camera transform applied about a point in the drawing rather than about the
* viewBox origin, so an existing drawing can be pushed through the camera where
* it already stands. `zoom` pulls the camera back, which is what keeps a
* machine inside a frame that was drawn for one view. Empty in the machine's
* own view, so nothing is emitted.
*/
export function aboutPoint(transform: string, x: number, y: number, zoom = 1) {
if (!transform && zoom === 1) return ""
const scale = zoom === 1 ? "" : ` scale(${px(zoom)})`
return `translate(${px(x)} ${px(y)})${scale} ${transform} translate(${px(-x)} ${px(-y)})`.replace(/\s+/g, " ")
}
/** A circle sampled as a ring of points, ready to extrude: the round parts. */
export function circleFootprint(x: number, z: number, radius: number, steps = 12): Vec2[] {
return Array.from({ length: steps }, (_, index) => {
const angle = (index / steps) * Math.PI * 2
return { x: x + Math.cos(angle) * radius, y: z + Math.sin(angle) * radius }
})
}
/** A rounded rectangle sampled as a ring of points, ready to extrude. */
export function roundedFootprint(
halfWidth: number,
halfLength: number,
radius: number,
steps = 5,
): Vec2[] {
const r = Math.max(0, Math.min(radius, halfWidth, halfLength))
const corners: Vec2[] = [
{ x: halfWidth - r, y: halfLength - r },
{ x: -(halfWidth - r), y: halfLength - r },
{ x: -(halfWidth - r), y: -(halfLength - r) },
{ x: halfWidth - r, y: -(halfLength - r) },
]
return corners.flatMap((corner, index) =>
Array.from({ length: steps }, (_, step) => {
const angle = ((index * 90 + (step * 90) / (steps - 1)) * Math.PI) / 180
return { x: corner.x + Math.cos(angle) * r, y: corner.y + Math.sin(angle) * r }
}),
)
}
/**
* The transform that lays a projected envelope into a drawing frame: the
* largest uniform scale that fits, centred, never enlarging past `maxScale`.
*
* Feed it a *fixed* envelope — the whole box the machine moves inside, not its
* pose this frame — so the framing cannot breathe as the machine works. A
* machine drawn to fit its native view then stays inside its own frame from
* every other camera, which is otherwise the first thing a new view breaks.
*/
export interface RobotFrame {
scale: number
dx: number
dy: number
/** The SVG transform for the drawing group. */
transform: string
/** A point already projected by the camera, in the drawing's viewBox units. */
toViewBox(point: Vec2): Vec2
}
export function fitFrame(
corners: readonly Vec3[],
camera: RobotCamera,
width: number,
height: number,
margin = 8,
maxScale = 1,
): RobotFrame {
const points = corners.map((corner) => camera.project(corner.x, corner.y, corner.z))
const xs = points.length ? points.map((point) => point.x) : [0]
const ys = points.length ? points.map((point) => point.y) : [0]
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const scale = points.length
? Math.min(
maxScale,
(width - margin * 2) / Math.max(1e-6, maxX - minX),
(height - margin * 2) / Math.max(1e-6, maxY - minY),
)
: 1
const dx = width / 2 - ((minX + maxX) / 2) * scale
const dy = height / 2 - ((minY + maxY) / 2) * scale
return {
scale,
dx,
dy,
transform: points.length ? `translate(${px(dx)} ${px(dy)}) scale(${px(scale)})` : "",
toViewBox: (point) => ({ x: dx + point.x * scale, y: dy + point.y * scale }),
}
}
export function fitTransform(
corners: readonly Vec3[],
camera: RobotCamera,
width: number,
height: number,
margin = 8,
maxScale = 1,
): string {
return fitFrame(corners, camera, width, height, margin, maxScale).transform
}
/** The eight corners of a world-space box, ready for {@link fitTransform}. */
export function boxCorners(
min: Vec3,
max: Vec3,
): Vec3[] {
return [min.x, max.x].flatMap((x) =>
[min.y, max.y].flatMap((y) => [min.z, max.z].map((z) => ({ x, y, z }))),
)
}
/* -------------------------------------------------------------------------- */
/* elevation drawings */
/* -------------------------------------------------------------------------- */
const finite = (value: number, fallback = 0) =>
Number.isFinite(value) ? value : fallback
/** A length: finite, and never negative. */
const span = (value: number, fallback: number) => Math.abs(finite(value, fallback))
/**
* Which elevation a drawing is, in world axes (x starboard, y up, z aft).
* `profile` lays the drawing's x along the fore-aft axis, `front` across the
* machine. Depth is then the remaining horizontal axis in both cases.
*/
export type ElevationPlane = "profile" | "front"
/**
* One drawing point of an elevation, at `depth` out of its plane. Drawing
* coordinates are the ones the rest of the set uses — x along the drawing, y
* up from the ground — and positive `depth` is toward the camera.
*/
export function elevationPoint(
point: Vec2,
depth: number,
plane: ElevationPlane = "profile",
): Vec3 {
const x = finite(point.x, 0)
const y = finite(point.y, 0)
const out = finite(depth, 0)
return plane === "front"
? { x: -x, y, z: -out }
: { x: out, y, z: -x }
}
/**
* A link solved in an elevation, lifted into the world as the box it really
* is: `halfWidth` across the link inside the drawing plane, `halfDepth` out of
* it. Feed the corners to `slabPath` and one geometry serves all four cameras,
* so a walking beam seen from above is the beam foreshortened by its own tilt
* rather than a second piece of artwork.
*/
export function elevationSolid(
a: Vec2,
b: Vec2,
halfWidth: number,
halfDepth: number,
plane: ElevationPlane = "profile",
): Vec3[] {
const ax = finite(a.x, 0)
const ay = finite(a.y, 0)
const bx = finite(b.x, 0)
const by = finite(b.y, 0)
const width = span(halfWidth, 1)
const depth = span(halfDepth, 1)
const length = Math.hypot(bx - ax, by - ay)
// A zero-length link has no direction to take a perpendicular from; give it
// the drawing's own axes so it still comes out as a square rather than NaN.
const ux = length === 0 ? 1 : (bx - ax) / length
const uy = length === 0 ? 0 : (by - ay) / length
const corners: Vec2[] = [
{ x: ax - uy * width - ux * width, y: ay + ux * width - uy * width },
{ x: ax + uy * width - ux * width, y: ay - ux * width - uy * width },
{ x: bx + uy * width + ux * width, y: by - ux * width + uy * width },
{ x: bx - uy * width + ux * width, y: by + ux * width + uy * width },
]
return corners.flatMap((corner) => [
elevationPoint(corner, depth, plane),
elevationPoint(corner, -depth, plane),
])
}
/**
* A disc standing in an elevation — a crank web, a sheave, a drum, a wheel —
* as the cylinder it is, its axis out of the drawing plane.
*/
export function elevationDisc(
center: Vec2,
radius: number,
halfDepth: number,
plane: ElevationPlane = "profile",
steps = 16,
): Vec3[] {
const cx = finite(center.x, 0)
const cy = finite(center.y, 0)
const r = span(radius, 1)
const depth = span(halfDepth, 1)
const count = Math.max(3, Math.round(finite(steps, 16)))
return Array.from({ length: count }, (_, index) => {
const angle = (index / count) * Math.PI * 2
return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }
}).flatMap((point) => [
elevationPoint(point, depth, plane),
elevationPoint(point, -depth, plane),
])
}
/**
* The drafting board for one elevation: every helper a machine drawn in a
* vertical plane needs, already pushed through the camera.
*
* Drawing coordinates are the plane's own — x along the drawing, y up from the
* ground — and `depth` is out of it, toward the camera. So the whole machine is
* written once, in the elevation it was designed in, and comes out correct from
* all four cameras: what has real depth is a solid, what is flat detail is a
* polyline that foreshortens and finally collapses when seen edge-on, which is
* what a line drawn on a face does.
*/
export interface ElevationDraft {
/** Screen point of one drawing point. */
point(point: Vec2, depth?: number): Vec2
/** A polyline through drawing points, all at the same depth. */
path(points: readonly Vec2[], depth?: number, close?: boolean): string
/** The silhouette of an outline swept `±halfDepth` out of the plane. */
solid(outline: readonly Vec2[], halfDepth: number, offset?: number): string
/** The same for an axis-aligned rectangle. */
box(x0: number, y0: number, x1: number, y1: number, halfDepth: number, offset?: number): string
/** A member between two drawing points, `halfWidth` across it in the plane. */
bar(a: Vec2, b: Vec2, halfWidth: number, halfDepth: number, offset?: number): string
/** A disc standing in the drawing plane: a wheel, a sheave, a drum. */
disc(centre: Vec2, radius: number, halfDepth: number, offset?: number, steps?: number): string
}
export function elevationDraft(
camera: RobotCamera,
plane: ElevationPlane = "profile",
): ElevationDraft {
const project = (corner: Vec3) => camera.project(corner.x, corner.y, corner.z)
const nudge = (corners: Vec3[], offset: number) =>
offset === 0
? corners
: corners.map((corner) =>
plane === "front"
? { ...corner, z: corner.z - offset }
: { ...corner, x: corner.x + offset },
)
const point = (value: Vec2, depth = 0) => project(elevationPoint(value, depth, plane))
return {
point,
path: (points, depth = 0, close = false) =>
`${points
.map((value, index) => {
const screen = point(value, depth)
return `${index ? "L" : "M"} ${px(screen.x)} ${px(screen.y)}`
})
.join(" ")}${close ? " Z" : ""}`,
solid: (outline, halfDepth, offset = 0) =>
slabPath(
outline.flatMap((value) => [
elevationPoint(value, offset + halfDepth, plane),
elevationPoint(value, offset - halfDepth, plane),
]),
camera,
),
box: (x0, y0, x1, y1, halfDepth, offset = 0) =>
slabPath(
[
{ x: x0, y: y0 },
{ x: x1, y: y0 },
{ x: x1, y: y1 },
{ x: x0, y: y1 },
].flatMap((value) => [
elevationPoint(value, offset + halfDepth, plane),
elevationPoint(value, offset - halfDepth, plane),
]),
camera,
),
bar: (a, b, halfWidth, halfDepth, offset = 0) =>
slabPath(nudge(elevationSolid(a, b, halfWidth, halfDepth, plane), offset), camera),
disc: (centre, radius, halfDepth, offset = 0, steps = 20) =>
slabPath(nudge(elevationDisc(centre, radius, halfDepth, plane, steps), offset), camera),
}
}