For the complete index, see /llms.txt. A Markdown version of any documentation page is available by appending .md to its URL or by sending an Accept: text/markdown header.

Browse documentation

Assembly geometry

Taking a machine apart in the reverse of the order it was put together: each part along the axis it was fitted on, in stages, and seated again exactly — plus the room the teardown needs.

BP-04
view
variant
motion
balance
grab
explode
0%
leaders

Drag anywhere round the gearbox to turn the crank by hand. Wind `explode` up and it parks the beam level first — an exploded view of a moving four-bar would be nonsense — then takes it apart in the reverse of the order it was built. The handed pairs come off sideways, so try `iso`.

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/assembly-geometry.json

Notes

  • No collision model and no fastener model. Parts pass through each other's paths the way they do in every exploded drawing, and `progress` runs backwards as happily as forwards.
  • `lantern-geometry` re-exports all of these, so anything installed before they moved keeps compiling.

Usage

import { assemblyEnvelope, explodeAssembly } from "@/lib/robocn/assembly"

// Fitted bottom up, so it comes apart top down. Rank 0 leaves first.
const parts = [
  { id: "skid", axis: { x: 0, y: 1, z: 0 }, travel: 0, order: 0 },
  { id: "post", axis: { x: 0, y: 1, z: 0 }, travel: 30, order: 1 },
  // A handed pair is one stage, and leaves along opposite axes.
  { id: "pitman-port", axis: { x: -1, y: 0, z: 0 }, travel: 34, order: 2 },
  { id: "pitman-starboard", axis: { x: 1, y: 0, z: 0 }, travel: 34, order: 2 },
]

const apart = explodeAssembly(parts, 0.4)
apart[1].offset     // a world offset — project it and it is a screen offset
apart[1].rank       // 0 is the first part off

// The box a frame has to fit, so it zooms out with the teardown and never with the pose.
assemblyEnvelope(parts, 0.4, { min: { x: -30, y: 0, z: -236 }, max: { x: 30, y: 176, z: 4 } })

API

exports
PropTypeDefaultDescription
explodeAssembly(parts: AssemblyPart[], progress: number, options?: { overlap?: number }) => ExplodedPart[]—Takes an assembly apart in the reverse of the order it was fitted. At progress 0 every offset is exactly the zero vector; at 1 every part is exactly its own `travel` from where it sat. `overlap` runs from a strictly sequential teardown at 0 to every part moving at once at 1.
AssemblyPart{ id, axis: Vec3, travel: number, order: number }—One part: the axis it was fitted along (need not be a unit vector), how far it has to go to be clear, and when it was fitted. Parts sharing an `order` are one stage and leave together — a course of ribs, a handed pair of pitmans.
ExplodedPartAssemblyPart & { direction, rank, fraction, distance, offset }—`rank` 0 is the first part off. `offset` is in world units, and projection is linear, so `camera.project(offset.x, offset.y, offset.z)` is the translation to hang on the part.
explodeFraction(rank, count, progress, overlap?) => number—The schedule on its own: how far through its own travel the part at `rank` is, out of `count` stages.
assemblyEnvelope(parts, progress, seated: { min: Vec3, max: Vec3 }) => { min: Vec3, max: Vec3 }—The world box the assembly needs at `progress`: the seated box grown by the furthest travel along each axis. Fed to `fitFrame`, the drawing zooms out as the machine comes apart and at no other time — it takes a progress, not a pose, so the framing cannot breathe.

Source

src/lib/robocn/assembly.ts
/**
 * assembly-geometry — taking a machine apart in the order it was put together.
 *
 * Coming apart is not a body scaled up, and it is not one motion shared by
 * every part either. Each part was fitted along an axis, in an order, and the
 * teardown is that order reversed: the last thing on goes first, and a part
 * does not start moving until the parts fitted after it are already on their
 * way out. At `progress` 0 every offset is exactly the zero vector — the
 * assembly is not nearly back together, it is back together — and at 1 every
 * part is exactly its own clearance away.
 *
 * Parts sharing an `order` are one **stage** and leave together: a whole course
 * of ribs, a handed pair of pitmans. The schedule counts stages rather than
 * parts, so adding a second pitman does not slow the teardown down.
 *
 * World axes are the set's own — `x` starboard, `y` up, `z` aft — and offsets
 * come back in them, which matters: projection is linear, so a world offset
 * projects to a pure screen offset and one schedule serves all four cameras.
 *
 * There is no collision model and no fastener model. Parts pass through each
 * other's paths the way they do in every exploded drawing, and `progress` runs
 * backwards as happily as forwards.
 *
 * Design note: docs/power-lantern.md, docs/pumpjack-teardown.md.
 */

import { clamp, type Vec3 } from "@/lib/robocn/kinematics"

const finite = (value: number, fallback: number) =>
  Number.isFinite(value) ? value : fallback

const unit3 = (v: Vec3 | undefined, fallback: Vec3 = { x: 0, y: 1, z: 0 }): Vec3 => {
  const x = finite(v?.x ?? 0, 0)
  const y = finite(v?.y ?? 0, 0)
  const z = finite(v?.z ?? 0, 0)
  const length = Math.hypot(x, y, z)
  return length > 1e-12
    ? { x: x / length, y: y / length, z: z / length }
    : { ...fallback }
}

/** One part of an assembly, and how it was put on. */
export interface AssemblyPart {
  /** Stable name. Comes back on the exploded part, and makes a good `data-part`. */
  id: string
  /**
   * The direction it was fitted along, in world units. Need not be a unit
   * vector; a zero-length axis falls back to straight up.
   */
  axis: Vec3
  /** How far it has to travel to be clear of everything under it. */
  travel: number
  /** When it was fitted. 0 is the first part on the bench, and the last off. */
  order: number
}

/** A part of the assembly at some point in the teardown. */
export interface ExplodedPart extends AssemblyPart {
  /** Its unit fit axis. */
  direction: Vec3
  /** Where it stands in the teardown: 0 is the first part off. */
  rank: number
  /** How far through its own travel it is, 0 seated to 1 clear. */
  fraction: number
  /** How far it has actually moved, in world units. */
  distance: number
  /** Its offset from where it sits assembled, in world units. */
  offset: Vec3
}

export interface ExplodeOptions {
  /**
   * How much the parts' travel windows overlap, 0 to 1. At 0 the teardown is
   * strictly sequential — nothing moves until the part above it is clear. At 1
   * every part moves through the whole of `progress` together, which is the
   * shell-expanding look this is deliberately not. Default 0.45.
   */
  overlap?: number
}

/**
 * The fraction of its own travel the part at `rank` has made at `progress`.
 * Rank 0 leaves first. Pure, and the schedule {@link explodeAssembly} runs on.
 */
export function explodeFraction(
  rank: number,
  count: number,
  progress: number,
  overlap = 0.45,
): number {
  const total = Math.max(1, Math.round(finite(count, 1)))
  const index = clamp(Math.round(finite(rank, 0)), 0, total - 1)
  const t = clamp(finite(progress, 0), 0, 1)
  if (total === 1) return t
  // The window every part gets, between one part at a time and all at once.
  const blend = clamp(finite(overlap, 0.45), 0, 1)
  const window = (1 / total) * (1 - blend) + blend
  const stride = (1 - window) / (total - 1)
  return clamp((t - index * stride) / window, 0, 1)
}

/**
 * The assembly at `progress`, taken apart in the reverse of the order it was
 * fitted. The last part on is rank 0 and moves first; the first part on is the
 * last rank and moves last, which is what makes this read as a teardown.
 */
export function explodeAssembly(
  parts: readonly AssemblyPart[],
  progress: number,
  { overlap = 0.45 }: ExplodeOptions = {},
): ExplodedPart[] {
  const list = Array.isArray(parts) ? parts.filter(Boolean) : []
  if (list.length === 0) return []
  // Removal order is fitting order reversed, counted in *stages* rather than in
  // parts: everything fitted at the same time — a whole course of ribs — is one
  // stage, and leaves together.
  const stages = [...new Set(list.map((part) => finite(part.order, 0)))].sort(
    (a, b) => a - b,
  )
  const count = stages.length
  const rankOf = new Map<number, number>()
  stages.forEach((order, position) => {
    rankOf.set(order, count - 1 - position)
  })

  return list.map((part) => {
    const rank = rankOf.get(finite(part.order, 0)) ?? 0
    const direction = unit3(part.axis)
    const travel = Math.max(0, finite(part.travel, 0))
    const fraction = explodeFraction(rank, count, progress, overlap)
    const distance = travel * fraction
    return {
      ...part,
      direction,
      rank,
      fraction,
      distance,
      offset: {
        x: direction.x * distance,
        y: direction.y * distance,
        z: direction.z * distance,
      },
    }
  })
}


/**
 * The world-space box an assembly needs at `progress`, given the box it needs
 * seated. Every part travels in a straight line, so the room the teardown wants
 * is the seated box grown by the furthest travel along each axis — and growing
 * it by `progress` alone means a frame fitted to it zooms out as the machine
 * comes apart and at no other time. Feeding it a pose would make the framing
 * breathe, which is the thing `fitFrame` exists to avoid.
 */
export function assemblyEnvelope(
  parts: readonly AssemblyPart[],
  progress: number,
  seated: { min: Vec3; max: Vec3 },
): { min: Vec3; max: Vec3 } {
  const t = clamp(finite(progress, 0), 0, 1)
  const min = { ...seated.min }
  const max = { ...seated.max }
  for (const part of Array.isArray(parts) ? parts.filter(Boolean) : []) {
    const direction = unit3(part.axis)
    const travel = Math.max(0, finite(part.travel, 0)) * t
    const reach = { x: direction.x * travel, y: direction.y * travel, z: direction.z * travel }
    min.x = Math.min(min.x, seated.min.x + reach.x)
    min.y = Math.min(min.y, seated.min.y + reach.y)
    min.z = Math.min(min.z, seated.min.z + reach.z)
    max.x = Math.max(max.x, seated.max.x + reach.x)
    max.y = Math.max(max.y, seated.max.y + reach.y)
    max.z = Math.max(max.z, seated.max.z + reach.z)
  }
  return { min, max }
}