
typescriptThis script is large, so only its beginning is shown here. Open it in the editor to see all of it.
// main.ts
import { Configurator } from "./app/configurator";
import { defaultParams } from "./drone/params";
import { loadLogo } from "./parts/emblem";
import { DroneView } from "./scene/drone-view";
import { Flight } from "./scene/flight";
import { Stage } from "./scene/stage";
import { mountUi } from "./ui/drone-ui";
const stage = new Stage();
const drone = new DroneView(stage);
const flight = new Flight(drone);
bitbybit.time.registerRenderFunction((elapsedMs) => flight.tick(elapsedMs));
const logo = await loadLogo();
const ui = mountUi(stage.scene, defaultParams, {
onChange: (params) => configurator.request(params),
onSpeed: (rpm) => flight.setRpm(rpm),
onEdges: (visible) => drone.setEdgesVisible(visible),
onExport: (format) => void configurator.download(format),
});
const configurator = new Configurator({ stage, drone, ui, logo });
if (!logo) {
ui.setStatus("The emblem was skipped: the logo could not be fetched from the CDN.");
}
await configurator.rebuild();
// app/configurator.ts
import { billOfMaterials, withDocument, type Bom } from "../cad/assembly";
import { downloadAssembly, type ExportFormat } from "../cad/export";
import type { Shape } from "../cad/kernel";
import { buildModel } from "../drone/catalogue";
import { defaultParams, type DroneParams } from "../drone/params";
import type { DroneModel } from "../drone/structure";
import type { DroneView } from "../scene/drone-view";
import type { Stage } from "../scene/stage";
import type { DroneUi } from "../ui/drone-ui";
const REBUILD_DELAY_MS = 120;
export interface ConfiguratorSetup {
stage: Stage;
drone: DroneView;
ui: DroneUi;
logo: Shape | undefined;
}
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function summary(model: DroneModel, bom: Bom, elapsedMs: number): string {
const seconds = (elapsedMs / 1000).toFixed(1);
return `${bom.parts} parts placed ${bom.instances} times in ${model.counts.modules} sub-assemblies, one leg placed ${model.counts.references} times, ${seconds} s.`;
}
export class Configurator {
private params: DroneParams = { ...defaultParams };
private current: DroneModel | undefined;
private generation = 0;
private pending: ReturnType<typeof setTimeout> | undefined;
constructor(private readonly setup: ConfiguratorSetup) { }
request(params: DroneParams): void {
this.params = params;
clearTimeout(this.pending);
this.pending = setTimeout(() => void this.rebuild(), REBUILD_DELAY_MS);
}
async rebuild(): Promise<void> {
const { stage, drone, ui, logo } = this.setup;
const mine = ++this.generation;
const superseded = (): boolean => mine !== this.generation;
const started = performance.now();
ui.setStatus(this.current ? "Rebuilding..." : "Building 27 parts on the OpenCascade kernel...");
try {
const model = await buildModel(this.params, logo, this.current);
if (superseded()) return;
ui.setParams(model.params);
const looks = await drone.prepare(model);
if (superseded()) return;
drone.show(model, looks);
stage.frame(model);
this.current = model;
const bom = await withDocument(model, undefined, billOfMaterials);
ui.setBom(bom, model.counts.bolts);
ui.setStatus(summary(model, bom, performance.now() - started));
} catch (error) {
ui.setStatus(`Could not build the drone: ${describe(error)}`);
}
}
async download(format: ExportFormat): Promise<void> {
const model = this.current;
if (!model) return;
const { ui } = this.setup;
const label = format.toUpperCase();
ui.setStatus(`Writing the ${label} assembly...`);
try {
const bytes = await downloadAssembly(model, format);
ui.setStatus(`${label} downloaded: ${(bytes / 1024).toFixed(0)} kB with the part tree, names and colours.`);
} catch (error) {
ui.setStatus(`Could not write the ${label}: ${describe(error)}`);
}
}
}
// cad/assembly.ts
import { materialSpec, rgba } from "../drone/materials";
import { ROOT_ID, type DroneModel, type InstanceNode } from "../drone/structure";
import { occt, turnX, type Document, type Shape, type Transform } from "./kernel";
type PartDef = Bit.Models.OCCT.AssemblyPartDef<Shape>;
type NodeDef = Bit.Models.OCCT.AssemblyNodeDef;
type LoadedPartDef = Bit.Models.OCCT.AssemblyLoadedPartDef;
export interface BomRow {
name: string;
count: number;
}
export interface Bom {
rows: BomRow[];
parts: number;
instances: number;
}
interface BuiltDocument {
document: Document;
sources: Document[];
}
export const Z_UP: Transform = turnX(90);
const manager = occt.assembly.manager;
async function partDefs(model: DroneModel, ids: Set<string>): Promise<PartDef[]> {
const defs: PartDef[] = [];
for (const part of model.parts) {
if (!ids.has(part.id)) continue;
const colorRgba = rgba(materialSpec(part.material, model.params.finish).colour);
defs.push(await manager.createPart({ id: part.id, shape: part.shape, name: part.name, colorRgba }));
}
return defs;
}
async function instanceDef(model: DroneModel, node: InstanceNode): Promise<NodeDef> {
const base = { id: node.id, partId: node.partId, name: node.name, parentId: node.parentId, matrix: node.placement };
return node.material
? manager.createInstanceNode({ ...base, colorRgba: rgba(materialSpec(node.material, model.params.finish).colour) })
: manager.createInstanceNode(base);
}
async function definitionDocuments(model: DroneModel): Promise<{ sources: Document[]; imported: LoadedPartDef[] }> {
const definitions = model.nodes.filter((node) => node.kind === "definition");
const sources: Document[] = [];
const imported: LoadedPartDef[] = [];
for (const [index, definition] of definitions.entries()) {
const children = model.nodes.filter((node): node is InstanceNode => node.kind === "instance" && node.parentId === definition.id);
const parts = await partDefs(model, new Set(children.map((child) => child.partId)));
const nodes = [await manager.createAssemblyNode({ id: definition.id, name: definition.name })];
for (const child of children) nodes.push(await instanceDef(model, child));
const structure = await manager.combineStructure({ parts, nodes, clearDocument: false });
sources.push(await manager.buildAssemblyDocument({ structure }));
imported.push(await manager.createImportedPart({ id: definition.id, sourceDocumentIndex: index, name: definition.name }));
}
return { sources, imported };
}
export async function buildDocument(model: DroneModel, frame?: Transform): Promise<BuiltDocument> {
const { sources, imported } = await definitionDocuments(model);
const definitionIds = new Set(model.nodes.filter((node) => node.kind === "definition").map((node) => node.id));
const topLevel = model.nodes.filter((node): node is InstanceNode => node.kind === "instance" && !definitionIds.has(node.parentId));
const parts = await partDefs(model, new Set(topLevel.map((node) => node.partId)));
const nodes = [await manager.createAssemblyNode({ id: ROOT_ID, name: "Drone" })];
for (const node of model.nodes) {
if (node.kind === "module") {
const matrix = frame ? [...node.placement, ...frame] : node.placement;
nodes.push(await manager.createAssemblyNode({ id: node.id, name: node.name, parentId: node.parentId ?? ROOT_ID, matrix }));
} else if (node.kind === "reference") {
nodes.push(await manager.createInstanceNode({ id: node.id, partId: node.definitionId, name: node.name, parentId: node.parentId, matrix: node.placement }));
} else if (node.kind === "instance" && !definitionIds.has(node.parentId)) {
nodes.push(await instanceDef(model, node));
}
}
const structure = await manager.combineStructure({ parts, nodes, loadedParts: imported, clearDocument: false });
const document = await manager.buildAssemblyDocument({ structure, sourceDocuments: sources });
return { document, sources };
}
export async function withDocument<T>(model: DroneModel, frame: Transform | undefined, use: (document: Document) => Promise<T>): Promise<T> {
const built = await buildDocument(model, frame);
try {
return await use(built.document);
} finally {
for (const document of [built.document, ...built.sources]) {
await manager.deleteDocument({ document });
}
}
}
export async function billOfMaterials(document: Document): Promise<Bom> {
const tree = await occt.assembly.query.getAssemblyHierarchy({ document });
const counts = new Map<string, number>();
for (const node of tree.nodes) {
if (node.nodeType !== "instance-part") continue;
const name = node.definitionName ?? node.name;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
const rows = [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
return { rows, parts: rows.length, instances: rows.reduce((sum, row) => sum + row.count, 0) };
}
// cad/export.ts
import type { DroneModel } from "../drone/structure";
import { withDocument, Z_UP } from "./assembly";
import { occt } from "./kernel";
export type ExportFormat = "step" | "glb";
const manager = occt.assembly.manager;
function fileNameOf(model: DroneModel, format: ExportFormat): string {
return `drone-${model.params.arms}-arms-${model.params.armLength}mm.${format}`;
}
function writeStep(model: DroneModel, fileName: string): Promise<Uint8Array> {
return withDocument(model, Z_UP, (document) => manager.exportDocumentToStep({
document,
fileName,
author: "Drone assembly",
organization: "bitbybit.dev",
compress: false,
tryDownload: true,
}));
}
function writeGlb(model: DroneModel, fileName: string): Promise<Uint8Array> {
return withDocument(model, undefined, (document) => manager.exportDocumentToGltf({
document,
fileName,
meshDeflection: 0.05,
meshAngle: 0.5,
internalVerticesMode: false,
controlSurfaceDeflection: false,
mergeFaces: true,
forceUVExport: false,
tryDownload: true,
}));
}
export async function downloadAssembly(model: DroneModel, format: ExportFormat): Promise<number> {
const fileName = fileNameOf(model, format);
const bytes = format === "step" ? await writeStep(model, fileName) : await writeGlb(model, fileName);
return bytes.byteLength;
}
// cad/kernel.ts
export type Shape = Bit.Inputs.OCCT.TopoDSShapePointer;
export type Wire = Bit.Inputs.OCCT.TopoDSWirePointer;
export type Document = Bit.Inputs.OCCT.TDocStdDocumentPointer;
export type Point3 = Bit.Inputs.Base.Point3;
export type Transform = Bit.Inputs.Base.TransformMatrixes;
export const occt = bitbybit.occt;
export const ORIGIN: Point3 = [0, 0, 0];
export function move(to: Point3): Transform {
return bitbybit.babylon.transforms.translationXYZ({ translation: to });
}
export function turnX(angle: number): Transform {
return bitbybit.babylon.transforms.rotationCenterX({ center: ORIGIN, angle });
}
export function turnY(angle: number): Transform {
return bitbybit.babylon.transforms.rotationCenterY({ center: ORIGIN, angle });
}
export function placed(at: Point3, rotation: Transform): Transform {
return [...rotation, ...move(at)];
}
// drone/catalogue.ts
import type { Shape } from "../cad/kernel";
import { canopy, honeycomb, plate, standoff } from "../parts/airframe";
import { arm } from "../parts/arm";
import { antenna, gps, led } from "../parts/electronics";
import { emblem, hatch } from "../parts/emblem";
import { bolt, nut } from "../parts/fasteners";
import { damper, foot, leg, spring } from "../parts/landing-gear";
import { battery, camera, gimbal, lens, strap } from "../parts/payload";
import { mirrored, motor, propeller, stator } from "../parts/propulsion";
import { armRootWidth } from "./dimensions";
import { layout } from "./layout";
import type { MaterialId } from "./materials";
import { clampParams, type DroneParams } from "./params";
import { partOf, type DroneModel, type Part, type PartId } from "./structure";
interface PartSpec {
id: PartId;
material: MaterialId;
kind: Part["kind"];
name: (params: DroneParams) => string;
key: (params: DroneParams, logo: Shape | undefined) => string;
build: (params: DroneParams, logo: Shape | undefined, built: Part[]) => Promise<Shape>;
}
function fixed(id: PartId, name: string, material: MaterialId, build: () => Promise<Shape>, kind: Part["kind"] = "solid"): PartSpec {
return { id, material, kind, name: () => name, key: () => id, build };
}
const SPECS: PartSpec[] = [
fixed("plate", "Carbon plate", "carbon", plate),
fixed("canopy", "Canopy", "paint", canopy),
fixed("honeycomb", "Canopy honeycomb", "paint", honeycomb, "wires"),
{
id: "hatch",
material: "paint",
kind: "solid",
name: () => "Hatch",
key: (_params, logo) => (logo ? "hatch:logo" : "hatch:plain"),
build: (_params, logo) => hatch(logo),
},
fixed("standoff", "Standoff M3x10", "accent", standoff),
fixed("bolt-m3x8", "Bolt M3x8", "steel", () => bolt(8)),
fixed("bolt-m3x12", "Bolt M3x12", "steel", () => bolt(12)),
fixed("bolt-m3x16", "Bolt M3x16", "steel", () => bolt(16)),
{
id: "arm",
material: "carbon",
kind: "solid",
name: (params) => `Arm ${params.armLength} mm`,
key: (params) => `arm:${params.armLength}:${params.arms}`,
build: (params) => arm(params.armLength, armRootWidth(params.arms)),
},
fixed("motor", "Motor 2806", "aluminium", motor),
fixed("stator", "Stator", "copper", stator),
{
id: "propeller-cw",
material: "accent",
kind: "solid",
name: (params) => `Propeller ${params.propDiameter} mm CW`,
key: (params) => `propeller-cw:${params.propDiameter}`,
build: (params) => propeller(params.propDiameter),
},
{
id: "propeller-ccw",
material: "accent",
kind: "solid",
name: (params) => `Propeller ${params.propDiameter} mm CCW`,
key: (params) => `propeller-ccw:${params.propDiameter}`,
build: (_params, _logo, built) => mirrored(partOf(built, "propeller-cw").shape),
},
fixed("nut", "Propeller nut M5", "aluminium", nut),
fixed("led", "LED", "ledGreen", led),
fixed("leg", "Landing leg", "carbon", leg),
fixed("damper", "Landing damper", "aluminium", damper),
fixed("spring", "Damper spring", "steel", spring),
fixed("foot", "Rubber foot", "rubber", foot),
fixed("gimbal", "Gimbal", "aluminium", gimbal),
fixed("camera", "Camera", "polymer", camera),
fixed("lens", "Lens", "glass", lens),
fixed("battery", "Battery 4S", "polymer", battery),
fixed("strap", "Battery strap", "accent", strap),
fixed("gps", "GPS", "polymer", gps),
fixed("antenna", "Antenna", "rubber", antenna),
];
async function buildParts(params: DroneParams, logo: Shape | undefined, previous: Part[]): Promise<Part[]> {
const built: Part[] = [];
for (const spec of SPECS) {
const key = spec.key(params, logo);
const kept = previous.find((part) => part.id === spec.id && part.key === key);
built.push(kept ?? {
id: spec.id,
key,
name: spec.name(params),
material: spec.material,
kind: spec.kind,
shape: await spec.build(params, logo, built),
});
}
if (logo) {
const kept = previous.find((part) => part.id === "emblem");
built.push(kept ?? { id: "emblem", key: "emblem", name: "Emblem", material: "emblem", kind: "solid", shape: await emblem(logo) });
}
return built;
}
export async function buildModel(input: DroneParams, logo: Shape | undefined, previous?: DroneModel): Promise<DroneModel> {
const params = clampParams(input);
const parts = await buildParts(params, logo, previous?.parts ?? []);
return { ...layout(params, logo !== undefined), parts };
}
// drone/dimensions.ts
export const FRAME = {
plate: { length: 150, width: 96, thickness: 3, corner: 26, openingRadius: 6, hex: { width: 56, depth: 20, across: 5, rows: 2, scale: 0.8, at: 60 } },
gap: 10,
armRoot: 28,
arm: { rootWidth: 26, tipWidth: 16, height: 10, padRadius: 15, hexFrom: 62, hexPitch: 24, hexWall: 3.5, hexRound: 1.2 },
hatch: { length: 50, width: 34, corner: 9, thickness: 1.4 },
honeycomb: { grow: 1.02, rows: 3, acrossLong: 8, acrossShort: 5, scale: 0.8, round: 0.35 },
motor: { baseRadius: 13, bellRadius: 14, wall: 1.6, shaftRadius: 2.5, height: 28, boltCircle: 8, slots: 8 },
propeller: { hubRadius: 8, hubBelow: 5, hubAbove: 7, junctionFillet: 0.6 },
canopy: { height: 32, forward: 4, hatchForward: 10 },
standoff: { radius: 3.2, ring: 40 },
leg: { drop: 46, reach: 26, tube: 4, ring: 52, boss: 5.5, flange: 20, boltOffset: 7.5, entry: 12, bends: [12, 6] as [number, number] },
damper: { cap: 6.5, capHeight: 4, barrel: 5.5, collar: 6.2, rod: 2.2, length: 30 },
spring: { radius: 4.2, wire: 0.9, pitch: 2.8, height: 12.5, bottom: 29.8 },
foot: { height: 10, rings: [[0, 3.2], [-2, 7], [-5, 9.5], [-8, 9], [-10, 7]] as [number, number][] },
bolt: { headRadius: 2.75, headHeight: 3, shankRadius: 1.5 },
emblem: { width: 26, thickness: 1, inset: 0.96 },
gps: { mastRadius: 2.5, mastHeight: 34, puckRadius: 11, puckHeight: 6 },
antenna: { radius: 1.5, length: 42 },
} as const;
export const PLATE_TOP = FRAME.plate.thickness;
export const ARM_TOP = PLATE_TOP + FRAME.gap;
export const TOP_PLATE_TOP = ARM_TOP + FRAME.plate.thickness;
export const CANOPY_TOP = TOP_PLATE_TOP + FRAME.canopy.height;
export const MOTOR_TOP = ARM_TOP + FRAME.motor.height;
export const GROUND_LIFT = FRAME.leg.drop + FRAME.damper.length + FRAME.foot.height;
export function armRootWidth(arms: number): number {
return Math.min(FRAME.arm.rootWidth, Math.round(2 * FRAME.armRoot * Math.sin(Math.PI / arms) * 0.9));
}
// drone/layout.ts
import { move, placed, turnX, turnY, type Point3, type Transform } from "../cad/kernel";
import { ARM_TOP, CANOPY_TOP, FRAME, GROUND_LIFT, MOTOR_TOP, PLATE_TOP, TOP_PLATE_TOP } from "./dimensions";
import type { MaterialId } from "./materials";
import { clampParams, type ArmCount, type DroneParams } from "./params";
import type { AssemblyNode, Counts, InstanceNode, Layout, PartId } from "./structure";
const LIFT: Point3 = [0, GROUND_LIFT, 0];
const HERE: Point3 = [0, 0, 0];
const DOWN = turnX(180);
const LEG_ANGLES = [45, 135, 225, 315];
const MOTOR_BOLT_ANGLES = [45, 135, 225, 315];
const PROPELLER_STAGGER = 37;
class Tree {
readonly nodes: AssemblyNode[] = [];
module(id: string, name: string, placement: Transform): void {
this.nodes.push({ kind: "module", id, name, placement });
}
definition(id: string, name: string): void {
this.nodes.push({ kind: "definition", id, name });
}
reference(parentId: string, id: string, name: string, definitionId: string, placement: Transform): void {
this.nodes.push({ kind: "reference", id, name, parentId, definitionId, placement });
}
instance(parentId: string, id: string, name: string, partId: PartId, placement: Transform, material?: MaterialId): void {
const node: InstanceNode = { kind: "instance", id, name, parentId, partId, placement };
if (material) node.material = material;
this.nodes.push(node);
}
}
export function armAngles(arms: ArmCount): number[] {
return Array.from({ length: arms }, (_, i) => (360 / arms) * i + 180 / arms);
}
function direction(angle: number): Point3 {
const radians = (angle * Math.PI) / 180;
return [Math.cos(radians), 0, -Math.sin(radians)];
}
function onRing(radius: number, angle: number, y: number): Point3 {
const [x, , z] = direction(angle);
return [radius * x, y, radius * z];
}
function placeAirframe(tree: Tree, arms: ArmCount, withEmblem: boolean): void {
const hatchAt: Point3 = [0, CANOPY_TOP, FRAME.canopy.hatchForward];
const rear = -FRAME.plate.length / 2;
tree.module("airframe", "Airframe", move(LIFT));
tree.instance("airframe", "plate-bottom", "Bottom plate", "plate", move(HERE));
tree.instance("airframe", "plate-top", "Top plate", "plate", move([0, ARM_TOP, 0]));
tree.instance("airframe", "canopy", "Canopy", "canopy", move([0, TOP_PLATE_TOP, 0]));
tree.instance("airframe", "honeycomb", "Canopy honeycomb", "honeycomb", move([0, TOP_PLATE_TOP, 0]));
tree.instance("airframe", "hatch", "Hatch", "hatch", move(hatchAt));
if (withEmblem) {
tree.instance("airframe", "emblem", "Emblem", "emblem", move(hatchAt));
}
tree.instance("airframe", "gps", "GPS", "gps", move([0, TOP_PLATE_TOP, rear + 5]));
tree.instance("airframe", "antenna-1", "Antenna 1", "antenna", placed([-14, 0, rear + 8], turnX(225)));
tree.instance("airframe", "antenna-2", "Antenna 2", "antenna", placed([14, 0, rear + 8], turnX(225)));
armAngles(arms).forEach((angle, i) => {
const n = i + 1;
const between = angle + 180 / arms;
tree.instance("airframe", `standoff-${n}`, `Standoff ${n}`, "standoff", move(onRing(FRAME.standoff.ring, between, PLATE_TOP)));
tree.instance("airframe", `standoff-${n}-top-bolt`, `Standoff ${n} top bolt`, "bolt-m3x8", placed(onRing(FRAME.standoff.ring, between, TOP_PLATE_TOP), DOWN));
tree.instance("airframe", `standoff-${n}-bottom-bolt`, `Standoff ${n} bottom bolt`, "bolt-m3x8", move(onRing(FRAME.standoff.ring, between, 0)));
});
}
function placeArms(tree: Tree, params: DroneParams): void {
const { arms, armLength } = params;
const hubY = MOTOR_TOP - 6 + FRAME.propeller.hubBelow;
armAngles(arms).forEach((angle, i) => {
const n = i + 1;
const id = `arm-${n}`;
const name = `Arm ${n}`;
const facesForward = direction(angle)[2] > 0;
const propeller: PartId = i % 2 === 0 ? "propeller-cw" : "propeller-ccw";
tree.module(id, name, placed(LIFT, turnY(angle)));
tree.instance(id, `${id}-tube`, `${name} tube`, "arm", move(HERE));
tree.instance(id, `${id}-clamp-bolt-1`, `${name} clamp bolt 1`, "bolt-m3x16", placed([FRAME.armRoot + 6, TOP_PLATE_TOP, 0], DOWN));
tree.instance(id, `${id}-clamp-bolt-2`, `${name} clamp bolt 2`, "bolt-m3x16", placed([FRAME.armRoot + 16, TOP_PLATE_TOP, 0], DOWN));
tree.instance(id, `${id}-motor`, `${name} motor`, "motor", move([armLength, ARM_TOP, 0]));
tree.instance(id, `${id}-stator`, `${name} stator`, "stator", move([armLength, ARM_TOP, 0]));
MOTOR_BOLT_ANGLES.forEach((boltAngle, k) => {
const radians = (boltAngle * Math.PI) / 180;
const at: Point3 = [armLength + FRAME.motor.boltCircle * Math.cos(radians), PLATE_TOP, FRAME.motor.boltCircle * Math.sin(radians)];
tree.instance(id, `${id}-motor-bolt-${k + 1}`, `${name} motor bolt ${k + 1}`, "bolt-m3x12", move(at));
});
tree.instance(id, `${id}-propeller`, `${name} propeller`, propeller, placed([armLength, hubY, 0], turnY(i * PROPELLER_STAGGER)));
tree.instance(id, `${id}-nut`, `${name} propeller nut`, "nut", move([armLength, hubY + FRAME.propeller.hubAbove, 0]));
tree.instance(id, `${id}-led`, `${name} LED`, "led", move([armLength - 24, PLATE_TOP, 0]), facesForward ? "ledGreen" : "ledRed");
});
}
function placeLandingGear(tree: Tree): void {
const damperTop = -FRAME.leg.drop;
tree.definition("leg", "Leg");
tree.instance("leg", "leg-tube", "Leg tube", "leg", move(HERE));
tree.instance("leg", "leg-damper", "Damper", "damper", move([FRAME.leg.reach, damperTop, 0]));
tree.instance("leg", "leg-spring", "Damper spring", "spring", move([FRAME.leg.reach, damperTop, 0]));
tree.instance("leg", "leg-foot", "Foot", "foot", move([FRAME.leg.reach, damperTop - FRAME.damper.length, 0]));
tree.instance("leg", "leg-bolt-1", "Leg bolt 1", "bolt-m3x8", move([-FRAME.leg.boltOffset, -3, 0]));
tree.instance("leg", "leg-bolt-2", "Leg bolt 2", "bolt-m3x8", move([FRAME.leg.boltOffset, -3, 0]));
tree.module("gear", "Landing gear", move(LIFT));
LEG_ANGLES.forEach((angle, i) => {
tree.reference("gear", `leg-${i + 1}`, `Leg ${i + 1}`, "leg", placed(onRing(FRAME.leg.ring, angle, 0), turnY(angle)));
});
}
function placePayload(tree: Tree): void {
tree.module("payload", "Payload", move(LIFT));
tree.instance("payload", "gimbal", "Gimbal", "gimbal", move([0, -26, 55]));
tree.instance("payload", "camera", "Camera", "camera", move([0, -26, 55]));
tree.instance("payload", "lens", "Lens", "lens", move([0, -26, 55]));
tree.instance("payload", "battery", "Battery", "battery", move([0, -16, -20]));
tree.instance("payload", "strap-1", "Battery strap 1", "strap", move([0, -16, -42]));
tree.instance("payload", "strap-2", "Battery strap 2", "strap", move([0, -16, 2]));
}
function countPlacements(nodes: AssemblyNode[]): Counts {
const references = new Map<string, number>();
for (const node of nodes) {
if (node.kind === "reference") {
references.set(node.definitionId, (references.get(node.definitionId) ?? 0) + 1);
}
}
const definitionIds = new Set(nodes.filter((node) => node.kind === "definition").map((node) => node.id));
const timesPlaced = (node: InstanceNode): number => (definitionIds.has(node.parentId) ? references.get(node.parentId) ?? 0 : 1);
const total = (list: InstanceNode[]): number => list.reduce((sum, node) => sum + timesPlaced(node), 0);
const instances = nodes.filter((node): node is InstanceNode => node.kind === "instance");
return {
instances: total(instances),
bolts: total(instances.filter((node) => node.partId.startsWith("bolt"))),
modules: nodes.filter((node) => node.kind === "module").length,
definitions: definitionIds.size,
references: nodes.filter((node) => node.kind === "reference").length,
};
}
export function layout(input: DroneParams, withEmblem = true): Layout {
const params = clampParams(input);
const tree = new Tree();
placeAirframe(tree, params.arms, withEmblem);
placeArms(tree, params);
placeLandingGear(tree);
placePayload(tree);
return { params, nodes: tree.nodes, lift: GROUND_LIFT, counts: countPlacements(tree.nodes) };
}
// drone/materials.ts
export type FinishId = "midnight" | "arctic" | "signal";
export type MaterialId =
| "carbon" | "paint" | "accent" | "emblem" | "aluminium" | "copper"
| "steel" | "polymer" | "rubber" | "glass" | "ledGreen" | "ledRed";
export interface MaterialSpec {
colour: string;
metalness: number;
roughness: number;
clearcoat: number;
emissive?: string;
}
export interface Finish {
label: string;
paint: string;
accent: string;
emblem: string;
}
export const FINISH_IDS: FinishId[] = ["midnight", "arctic", "signal"];
export const FINISHES: Record<FinishId, Finish> = {
midnight: { label: "Midnight", paint: "#1f2b4d", accent: "#f0cebb", emblem: "#f4f4f4" },
arctic: { label: "Arctic", paint: "#e9eaec", accent: "#ff7a1a", emblem: "#111214" },
signal: { label: "Signal", paint: "#ff5a1f", accent: "#f4f4f4", emblem: "#111214" },
};
const FIXED: Record<Exclude<MaterialId, "paint" | "accent" | "emblem">, MaterialSpec> = {
carbon: { colour: "#23262b", metalness: 0.35, roughness: 0.45, clearcoat: 0.3 },
aluminium: { colour: "#b9bec6", metalness: 1, roughness: 0.32, clearcoat: 0 },
copper: { colour: "#b87333", metalness: 1, roughness: 0.5, clearcoat: 0 },
steel: { colour: "#1c1c1e", metalness: 0.9, roughness: 0.42, clearcoat: 0 },
polymer: { colour: "#2a2d31", metalness: 0.05, roughness: 0.4, clearcoat: 0.6 },
rubber: { colour: "#111214", metalness: 0, roughness: 0.92, clearcoat: 0 },
glass: { colour: "#0c1522", metalness: 0.1, roughness: 0.05, clearcoat: 1 },
ledGreen: { colour: "#22ff66", metalness: 0, roughness: 0.2, clearcoat: 1, emissive: "#22ff66" },
ledRed: { colour: "#ff2a2a", metalness: 0, roughness: 0.2, clearcoat: 1, emissive: "#ff2a2a" },
};
export function materialSpec(id: MaterialId, finish: FinishId): MaterialSpec {
const f = FINISHES[finish];
if (id === "paint") return { colour: f.paint, metalness: 0.1, roughness: 0.25, clearcoat: 1 };
if (id === "accent") return { colour: f.accent, metalness: 0.85, roughness: 0.35, clearcoat: 0 };
if (id === "emblem") return { colour: f.emblem, metalness: 0.2, roughness: 0.5, clearcoat: 0.3 };
return FIXED[id];
}
export function rgba(hex: string): { r: number; g: number; b: number; a: number } {
const value = parseInt(hex.slice(1), 16);
return { r: ((value >> 16) & 255) / 255, g: ((value >> 8) & 255) / 255, b: (value & 255) / 255, a: 1 };
}
export function luminance(hex: string): number {
const { r, g, b } = rgba(hex);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function edgeColour(surface: string): string {
return luminance(surface) < 0.3 ? "#f2f2f2" : "#0d0e10";
}
// drone/params.ts
import type { FinishId } from "./materials";
export type ArmCount = 4 | 6 | 8;
export interface DroneParams {
arms: ArmCount;
armLength: number;
propDiameter: number;
finish: FinishId;
}
export interface Limit {
min: number;
max: number;
step: number;
}
export const UNITS = "mm";
export const ARM_COUNTS: ArmCount[] = [4, 6, 8];
export const LIMITS = {
armLength: { min: 120, max: 260, step: 5 },
propDiameter: { min: 100, max: 300, step: 5 },
} as const;
export const TIP_CLEARANCE = 10;
const CANOPY_REACH = 58;
export const defaultParams: DroneParams = { arms: 4, armLength: 185, propDiameter: 230, finish: "midnight" };
export function maxPropDiameter(arms: ArmCount, armLength: number): number {
const neighbours = 2 * armLength * Math.sin(Math.PI / arms) - TIP_CLEARANCE;
const canopy = 2 * (armLength - CANOPY_REACH - TIP_CLEARANCE);
const step = LIMITS.propDiameter.step;
return Math.floor(Math.min(neighbours, canopy) / step) * step;
}
function clamp(value: number, limit: Limit): number {
return Math.min(Math.max(value, limit.min), limit.max);
}
export function clampParams(params: DroneParams): DroneParams {
const arms = ARM_COUNTS.includes(params.arms) ? params.arms : 4;
const armLength = clamp(params.armLength, LIMITS.armLength);
const propDiameter = Math.min(clamp(params.propDiameter, LIMITS.propDiameter), maxPropDiameter(arms, armLength));
return { ...params, arms, armLength, propDiameter };
}
// drone/structure.ts
import type { Shape, Transform } from "../cad/kernel";
import type { MaterialId } from "./materials";
import type { DroneParams } from "./params";
export type PartId =
| "plate" | "canopy" | "hatch" | "emblem" | "honeycomb" | "standoff" | "bolt-m3x8" | "bolt-m3x12" | "bolt-m3x16"
| "arm" | "motor" | "stator" | "propeller-cw" | "propeller-ccw" | "nut" | "led"
| "leg" | "damper" | "spring" | "foot" | "gimbal" | "camera" | "lens" | "battery" | "strap" | "gps" | "antenna";
export interface Part {
id: PartId;
key: string;
name: string;
material: MaterialId;
kind: "solid" | "wires";
shape: Shape;
}
export interface ModuleNode {
kind: "module";
id: string;
name: string;
parentId?: string;
placement: Transform;
}
export interface DefinitionNode {
kind: "definition";
id: string;
name: string;
}
export interface ReferenceNode {
kind: "reference";
id: string;
name: string;
parentId: string;
definitionId: string;
placement: Transform;
}
export interface InstanceNode {
kind: "instance";
id: string;
name: string;
parentId: string;
partId: PartId;
placement: Transform;
material?: MaterialId;
}
export type AssemblyNode = ModuleNode | DefinitionNode | ReferenceNode | InstanceNode;
export interface Counts {
instances: number;
bolts: number;
modules: number;
definitions: number;
references: number;
}
export interface Layout {
params: DroneParams;
nodes: AssemblyNode[];
lift: number;
counts: Counts;
}
export interface DroneModel extends Layout {
parts: Part[];
}
export const ROOT_ID = "drone";
export function partOf(parts: Part[], id: PartId): Part {
const part = parts.find((candidate) => candidate.id === id);
if (!part) throw new Error(`the ${id} part has not been built`);
return part;
}
export function materialOf(part: Part, node: InstanceNode): MaterialId {
return node.material ?? part.material;
}
// parts/airframe.ts
import { occt, type Shape, type Wire } from "../cad/kernel";
import { FRAME } from "../drone/dimensions";
import { faceWithHoles, roundedRect } from "./profiles";
type CanopySection = [y: number, length: number, width: number, radius: number, forward: number];
const LEG_OPENING_ANGLES = [45, 135, 225, 315];
export async function plate(): Promise<Shape> {
const p = FRAME.plate;
const outline = await roundedRect(p.width, p.length, p.corner, [0, 0, 0]);
const openings: Wire[] = [];
for (const angle of LEG_OPENING_ANGLES) {
const a = (angle * Math.PI) / 180;
const center: [number, number, number] = [FRAME.leg.ring * Math.cos(a), 0, -FRAME.leg.ring * Math.sin(a)];
openings.push(await occt.shapes.wire.createCircleWire({ radius: p.openingRadius, center, direction: [0, 1, 0] }));
}
const cells = await occt.shapes.wire.hexagonsInGrid({
width: p.hex.width,
height: p.hex.depth,
nrHexagonsInWidth: p.hex.across,
nrHexagonsInHeight: p.hex.rows,
flatTop: true,
scalePatternWidth: [p.hex.scale],
scalePatternHeight: [p.hex.scale],
});
const vents: Wire[] = [];
for (const z of [p.hex.at, -p.hex.at]) {
for (const cell of cells) {
vents.push(await occt.transforms.translate({ shape: cell, translation: [0, 0, z] }));
}
}
const face = await faceWithHoles(outline, [...openings, ...vents]);
return occt.operations.extrude({ shape: face, direction: [0, p.thickness, 0] });
}
export async function canopy(): Promise<Shape> {
const c = FRAME.canopy;
const sections: CanopySection[] = [
[0, 140, 90, 26, c.forward],
[12, 132, 84, 24, c.forward + 2],
[22, 110, 70, 19, c.forward + 5],
[29, 76, 48, 13, c.hatchForward],
[c.height, 52, 36, 10, c.hatchForward],
];
const wires: Wire[] = [];
for (const [y, length, width, radius, forward] of sections) {
wires.push(await roundedRect(width, length, radius, [0, y, forward]));
}
return occt.operations.loft({ shapes: wires, makeSolid: true });
}
export async function honeycomb(): Promise<Shape> {
const h = FRAME.honeycomb;
const shell = await canopy();
const centre = await occt.operations.boundingBoxCenterOfShape({ shape: shell });
const grown = await occt.transforms.scale3d({ shape: shell, scale: [h.grow, h.grow, h.grow], center: [centre[0], 0, centre[2]] });
const faces = await occt.shapes.face.getFaces({ shape: grown });
const areas = await occt.shapes.face.getFacesAreas({ shapes: faces });
const largestSide = Math.max(...areas.slice(0, -2));
const wires: Wire[] = [];
for (const [i, face] of faces.entries()) {
const normal = await occt.shapes.face.normalOnUV({ shape: face, paramU: 0.5, paramV: 0.5 });
const facesUpOrDown = Math.abs(normal[1]) > 0.9;
if (facesUpOrDown) continue;
const across = (areas[i] ?? 0) > largestSide * 0.7 ? h.acrossLong : h.acrossShort;
wires.push(...await occt.shapes.face.subdivideToHexagonWires({
shape: face,
nrHexagonsU: across,
nrHexagonsV: h.rows,
flatU: false,
scalePatternU: [h.scale],
scalePatternV: [h.scale],
filletPattern: [h.round],
offsetFromBorderU: 0.05,
offsetFromBorderV: 0.08,
}));
}
return occt.shapes.compound.makeCompound({ shapes: wires });
}
export async function standoff(): Promise<Shape> {
return occt.shapes.solid.createCylinder({ radius: FRAME.standoff.radius, height: FRAME.gap, center: [0, 0, 0], direction: [0, 1, 0] });
}
// parts/arm.ts
import { occt, type Shape, type Wire } from "../cad/kernel";
import { FRAME, PLATE_TOP } from "../drone/dimensions";
import { faceWithHoles } from "./profiles";
export interface Cutout {
x: number;
radius: number;
}
function tipX(armLength: number): number {
return armLength - FRAME.arm.padRadius + 3;
}
export function armCutouts(armLength: number, rootWidth: number): Cutout[] {
const a = FRAME.arm;
const tip = tipX(armLength);
const cutouts: Cutout[] = [];
for (let x = a.hexFrom; x < armLength - a.padRadius - 19; x += a.hexPitch) {
const width = rootWidth + (a.tipWidth - rootWidth) * ((x - FRAME.armRoot) / (tip - FRAME.armRoot));
cutouts.push({ x, radius: Math.max(width / 2 - a.hexWall, 2.5) });
}
return cutouts;
}
export async function arm(armLength: number, rootWidth: number): Promise<Shape> {
const a = FRAME.arm;
const tip = tipX(armLength);
const plan = await occt.shapes.wire.createPolygonWire({
points: [
[FRAME.armRoot, 0, -rootWidth / 2],
[tip, 0, -a.tipWidth / 2],
[tip, 0, a.tipWidth / 2],
[FRAME.armRoot, 0, rootWidth / 2],
],
});
const outline = await occt.fillets.fillet2d({ shape: plan, radius: 4 });
const cutouts: Wire[] = [];
for (const cutout of armCutouts(armLength, rootWidth)) {
const hexagon = await occt.shapes.wire.createNGonWire({ nrCorners: 6, radius: cutout.radius, center: [cutout.x, 0, 0], direction: [0, 1, 0] });
cutouts.push(await occt.fillets.fillet2d({ shape: hexagon, radius: a.hexRound }));
}
const face = await faceWithHoles(outline, cutouts);
const slab = await occt.operations.extrude({ shape: face, direction: [0, a.height, 0] });
const lifted = await occt.transforms.translate({ shape: slab, translation: [0, PLATE_TOP, 0] });
const pad = await occt.shapes.solid.createCylinder({ radius: a.padRadius, height: a.height, center: [armLength, PLATE_TOP, 0], direction: [0, 1, 0] });
return occt.booleans.union({ shapes: [lifted, pad], keepEdges: false });
}
// parts/electronics.ts
import { occt, type Shape } from "../cad/kernel";
import { FRAME } from "../drone/dimensions";
export async function led(): Promise<Shape> {
const dome = await occt.shapes.solid.createSphere({ radius: 3, center: [0, 0, 0] });
const upper = await occt.shapes.solid.createBox({ width: 8, length: 8, height: 4, center: [0, 2, 0] });
return occt.booleans.difference({ shape: dome, shapes: [upper], keepEdges: false });
}
export async function gps(): Promise<Shape> {
const g = FRAME.gps;
const mast = await occt.shapes.solid.createCylinder({ radius: g.mastRadius, height: g.mastHeight, center: [0, 0, 0], direction: [0, 1, 0] });
const puck = await occt.shapes.solid.createCylinder({ radius: g.puckRadius, height: g.puckHeight, center: [0, g.mastHeight, 0], direction: [0, 1, 0] });
const rounded = await occt.fillets.filletEdges({ shape: puck, radius: 1.5 });
return occt.booleans.union({ shapes: [mast, rounded], keepEdges: false });
}
export async function antenna(): Promise<Shape> {
const a = FRAME.antenna;
const rod = await occt.shapes.solid.createCylinder({ radius: a.radius, height: a.length, center: [0, 0, 0], direction: [0, 1, 0] });
const tip = await occt.shapes.solid.createSphere({ radius: a.radius, center: [0, a.length, 0] });
return occt.booleans.union({ shapes: [rod, tip], keepEdges: false });
}
// parts/emblem.ts
import { occt, type Shape } from "../cad/kernel";
import { FRAME } from "../drone/dimensions";
import { faceWithHoles, roundedRect } from "./profiles";
const LOGO_URL = "https://git-cdn.bitbybit.dev/latest/assets/logo/logo-faces.stp";
export async function loadLogo(): Promise<Shape | undefined> {
try {
const response = await fetch(LOGO_URL);
if (!response.ok) return undefined;
const text = await response.text();
return await occt.io.loadSTEPorIGESFromText({ text, fileType: Bit.Inputs.OCCT.fileTypeEnum.step, adjustZtoY: true });
} catch {
return undefined;
}
}
async function logoFaces(logo: Shape, width: number): Promise<Shape> {
const mirrored = await occt.transforms.mirrorAlongNormal({ shape: logo, origin: [0, 0, 0], normal: [1, 0, 0] });
const size = await occt.operations.boundingBoxSizeOfShape({ shape: mirrored });
const scaled = await occt.transforms.scale({ shape: mirrored, factor: width / Math.max(size[0], size[2]) });
const box = await occt.operations.boundingBoxOfShape({ shape: scaled });
return occt.transforms.translate({ shape: scaled, translation: [-box.center[0], -box.min[1], -box.center[2]] });
}
export async function hatch(logo: Shape | undefined): Promise<Shape> {
const h = FRAME.hatch;
const outline = await roundedRect(h.width, h.length, h.corner, [0, 0, 0]);
const holes = logo ? await occt.shapes.wire.getWires({ shape: await logoFaces(logo, FRAME.emblem.width) }) : [];
const face = await faceWithHoles(outline, holes);
return occt.operations.extrude({ shape: face, direction: [0, h.thickness, 0] });
}
export async function emblem(logo: Shape): Promise<Shape> {
const { width, thickness, inset } = FRAME.emblem;
const faces = await occt.shapes.face.getFaces({ shape: await logoFaces(logo, width * inset) });
const solids: Shape[] = [];
for (const face of faces) {
solids.push(await occt.operations.extrude({ shape: face, direction: [0, thickness, 0] }));
}
return occt.shapes.compound.makeCompound({ shapes: solids });
}
// parts/fasteners.ts
import { occt, type Shape } from "../cad/kernel";
import { FRAME } from "../drone/dimensions";
import { prism, revolved } from "./profiles";
export async function bolt(length: number): Promise<Shape> {
const b = FRAME.bolt;
const body = await revolved([
[0, -b.headHeight, 0], [b.headRadius - 0.4, -b.headHeight, 0], [b.headRadius, -b.headHeight + 0.4, 0], [b.headRadius, 0, 0],
[b.shankRadius, 0, 0], [b.shankRadius, length - 0.4, 0], [b.shankRadius - 0.4, length, 0], [0, length, 0],
]);
const socketOutline = await occt.shapes.wire.createNGonWire({ nrCorners: 6, radius: 1.45, center: [0, -b.headHeight - 0.5, 0], direction: [0, 1, 0] });
const socket = await prism(socketOutline, [0, 2.5, 0]);
return occt.booleans.difference({ shape: body, shapes: [socket], keepEdges: false });
}
export async function nut(): Promise<Shape> {
const height = 3.5;
const hexOutline = await occt.shapes.wire.createNGonWire({ nrCorners: 6, radius: 3.6, center: [0, 0, 0], direction: [0, 1, 0] });
const hex = await prism(hexOutline, [0, height, 0]);
const chamfered = await revolved([[0, 0, 0], [2.9, 0, 0], [3.7, 0.7, 0], [3.7, height - 0.7, 0], [2.9, height, 0], [0, height, 0]]);
return occt.booleans.intersection({ shapes: [hex, chamfered], keepEdges: false });
}
// parts/landing-gear.ts
import { occt, type Shape, type Wire } from "../cad/kernel";
import { FRAME } from "../drone/dimensions";
import { revolved } from "./profiles";
const TRIHEDRON = Bit.Inputs.OCCT.geomFillTrihedronEnum;
export async function leg(): Promise<Shape> {
const l = FRAME.leg;
const path = await occt.shapes.wire.createPolylineWire({
points: [[0, 0, 0], [6, -18, 0], [l.reach, -l.drop + l.entry, 0], [l.reach, -l.drop - FRAME.damper.capHeight / 2, 0]],
});
const rounded = await occt.fillets.fillet3DWire({ shape: path, radiusList: [...l.bends], indexes: [0, 1], direction: [0, 0, 1] });
const tube = await occt.operations.pipeWireCylindrical({ shape: rounded, radius: l.tube, makeSolid: true, trihedronEnum: TRIHEDRON.isConstantNormal, forceApproxC1: false });
const flange = await occt.shapes.solid.createBox({ width: l.flange, length: 12, height: 3, center: [0, -1.5, 0] });
const boss = await occt.shapes.solid.createCylinder({ radius: l.boss, height: 3 + FRAME.plate.thickness + 0.4, center: [0, -3, 0], direction: [0, 1, 0] });
return occt.booleans.union({ shapes: [tube, flange, boss], keepEdges: false });
}
export async function damper(): Promise<Shape> {
const d = FRAME.damper;
return revolved([
[0, 0, 0], [d.cap, 0, 0], [d.cap, -d.capHeight, 0], [d.barrel, -d.capHeight - 0.5, 0], [d.barrel, -14, 0], [d.collar, -14, 0], [d.collar, -16.5, 0],
[4, -16.5, 0], [4, -18, 0], [d.rod, -18, 0], [d.rod, -d.length, 0], [0, -d.length, 0],
]);
}
export async function spring(): Promise<Shape> {
const s = FRAME.spring;
const helix = await occt.shapes.wire.createHelixWire({ radius: s.radius, pitch: s.pitch, height: s.height, center: [0, -s.bottom, 0], direction: [0, 1, 0], clockwise: false, tolerance: 1e-4 });
return occt.operations.pipeWireCylindrical({ shape: helix, radius: s.wire, makeSolid: true, trihedronEnum: TRIHEDRON.isCorrectedFrenet, forceApproxC1: false });
}
export async function foot(): Promise<Shape> {
const rings: Wire[] = [];
for (const [y, radius] of FRAME.foot.rings) {
rings.push(await occt.shapes.wire.createCircleWire({ radius, center: [0, y, 0], direction: [0, 1, 0] }));
}
return occt.operations.loft({ shapes: rings, makeSolid: true });
}
// parts/payload.ts
import { occt, type Shape } from "../cad/kernel";
import { prism } from "./profiles";
export async function gimbal(): Promise<Shape> {
const outline = await occt.shapes.wire.createPolygonWire({ points: [[-4, 19, -4], [19.5, 19, -4], [19.5, -7, -4], [14.5, -7, -4], [14.5, 15, -4], [-4, 15, -4]] });
const rounded = await occt.fillets.fillet2d({ shape: outline, radius: 1.5 });
const bracket = await prism(rounded, [0, 0, 8]);
const yaw = await occt.shapes.solid.createCylinder({ radius: 9, height: 8, center: [0, 18, 0], direction: [0, 1, 0] });
const roll = await occt.shapes.solid.createCylinder({ radius: 6, height: 5, center: [14.5, 0, 0], direction: [-1, 0, 0] });
return occt.booleans.union({ shapes: [yaw, bracket, roll], keepEdges: false });
}
export async function camera(): Promise<Shape> {
const body = await occt.shapes.solid.createBox({ width: 24, length: 20, height: 18, center: [0, 0, 0] });
const soft = await occt.fillets.filletEdges({ shape: body, radius: 3 });
const barrel = await occt.shapes.solid.createCylinder({ radius: 6.5, height: 9, center: [0, 0, 8], direction: [0, 0, 1] });
return occt.booleans.union({ shapes: [soft, barrel], keepEdges: false });
}
export async function lens(): Promise<Shape> {
return occt.shapes.solid.createCylinder({ radius: 5.2, height: 1.5, center: [0, 0, 16.5], direction: [0, 0, 1] });
}
export async function battery(): Promise<Shape> {
const brick = await occt.shapes.solid.createBox({ width: 36, length: 72, height: 26, center: [0, 0, 0] });
return occt.fillets.filletEdges({ shape: brick, radius: 3 });
}
export async function strap(): Promise<Shape> {
const outer = await occt.shapes.solid.createBox({ width: 40, length: 10, height: 30, center: [0, 0, 0] });
const inner = await occt.shapes.solid.createBox({ width: 36.5, length: 12, height: 26.5, center: [0, 0, 0] });
return occt.booleans.difference({ shape: outer, shapes: [inner], keepEdges: false });
}
// parts/profiles.ts
import { occt, type Point3, type Shape, type Wire } from "../cad/kernel";
const UP: Point3 = [0, 1, 0];
export async function roundedRect(width: number, length: number, radius: number, center: Point3, direction: Point3 = UP): Promise<Wire> {
const rectangle = await occt.shapes.wire.createRectangleWire({ width, length, center, direction });
return occt.fillets.fillet2d({ shape: rectangle, radius });
}
export async function prism(outline: Wire, direction: Point3): Promise<Shape> {
const face = await occt.shapes.face.createFaceFromWire({ shape: outline, planar: true });
return occt.operations.extrude({ shape: face, direction });
}
export async function revolved(profile: Point3[]): Promise<Shape> {
const wire = await occt.shapes.wire.createPolygonWire({ points: profile });
const face = await occt.shapes.face.createFaceFromWire({ shape: wire, planar: true });
return occt.operations.revolve({ shape: face, angle: 360, direction: UP, copy: false });
}
function winding(points: Point3[]): number {
let sum = 0;
for (let i = 0; i < points.length; i++) {
const [ax = 0, , az = 0] = points[i] ?? [];
const [bx = 0, , bz = 0] = points[(i + 1) % points.length] ?? [];
sum += ax * bz - bx * az;
}
return sum;
}
export async function faceWithHoles(outline: Wire, holes: Wire[]): Promise<Shape> {
if (holes.length === 0) {
return occt.shapes.face.createFaceFromWire({ shape: outline, planar: true });
}
const all = await occt.shapes.compound.makeCompound({ shapes: [outline, ...holes] });
const runs = await occt.shapes.wire.wiresToPoints({ shape: all, angularDeflection: 0.1, curvatureDeflection: 0.1, minimumOfPoints: 2, uTolerance: 1e-9, minimumLength: 1e-7 });
const [outlineRun = [], ...holeRuns] = runs;
const outer = Math.sign(winding(outlineRun));
const cutting: Wire[] = [];
for (const [i, hole] of holes.entries()) {
const sameWay = Math.sign(winding(holeRuns[i] ?? [])) === outer;
cutting.push(sameWay ? await occt.shapes.wire.reversedWire({ shape: hole }) : hole);
}
return occt.shapes.face.createFaceFromWires({ shapes: [outline, ...cutting], planar: true });
}
// parts/propulsion.ts
import { occt, type Shape, type Wire } from "../cad/kernel";
import { FRAME } from "../drone/dimensions";
import { revolved } from "./profiles";
type BladeStation = [t: number, chord: number, thickness: number, twist: number, sweep: number];
const BLADE: BladeStation[] = [
[0, 12, 3.2, 38, 0],
[0.25, 17, 2.4, 24, 0.6],
[0.5, 15.5, 1.8, 16, 1.8],
[0.78, 11, 1.4, 11, 3.6],
[1, 4.5, 1, 8, 6],
];
export async function motor(): Promise<Shape> {
const m = FRAME.motor;
const bellTop = 20;
const body = await revolved([
[0, 0, 0], [m.baseRadius, 0, 0], [m.baseRadius, 3, 0], [8, 3, 0], [8, 6, 0],
[m.bellRadius, 6, 0], [m.bellRadius, bellTop - 1, 0], [m.bellRadius - 1.2, bellTop, 0], [6, bellTop, 0], [6, bellTop + 2, 0],
[m.shaftRadius, bellTop + 2, 0], [m.shaftRadius, m.height, 0], [0, m.height, 0],
]);
const cavity = await occt.shapes.solid.createCylinder({ radius: m.bellRadius - m.wall, height: 12, center: [0, 6, 0], direction: [0, 1, 0] });
const bearingTube = await occt.shapes.solid.createCylinder({ radius: 4, height: 14, center: [0, 5, 0], direction: [0, 1, 0] });
const ring = await occt.booleans.difference({ shape: cavity, shapes: [bearingTube], keepEdges: false });
const tools: Shape[] = [ring];
for (let i = 0; i < m.slots; i++) {
const slot = await occt.shapes.solid.createBox({ width: 2.2, length: 8, height: 9, center: [0, 13, m.bellRadius - 2] });
tools.push(await occt.transforms.rotate({ shape: slot, axis: [0, 1, 0], angle: (360 / m.slots) * i }));
}
return occt.booleans.difference({ shape: body, shapes: tools, keepEdges: false });
}
export async function stator(): Promise<Shape> {
const radius = FRAME.motor.bellRadius - FRAME.motor.wall - 0.6;
return occt.shapes.solid.createCylinder({ radius, height: 11, center: [0, 6.5, 0], direction: [0, 1, 0] });
}
async function blade(diameter: number): Promise<Shape> {
const root = FRAME.propeller.hubRadius - 2;
const span = diameter / 2 - root;
const sections: Wire[] = [];
for (const [t, chord, thickness, twist, sweep] of BLADE) {
const r = root + span * t;
const ellipse = await occt.shapes.wire.createEllipseWire({ center: [r, 0, sweep], direction: [1, 0, 0], radiusMinor: thickness / 2, radiusMajor: chord / 2 });
sections.push(await occt.transforms.rotateAroundCenter({ shape: ellipse, angle: twist, center: [r, 0, sweep], axis: [1, 0, 0] }));
}
return occt.operations.loft({ shapes: sections, makeSolid: true });
}
async function hub(): Promise<Shape> {
const p = FRAME.propeller;
return revolved([
[0, -p.hubBelow, 0], [p.hubRadius, -p.hubBelow, 0], [p.hubRadius, p.hubAbove - 2.5, 0],
[p.hubRadius - 1.5, p.hubAbove - 0.8, 0], [4.5, p.hubAbove, 0], [0, p.hubAbove, 0],
]);
}
async function hubJunctionEdges(shape: Shape): Promise<Shape[]> {
const edges = await occt.shapes.edge.getEdges({ shape });
const centers = await occt.shapes.edge.getEdgesCentersOfMass({ shapes: edges });
const junction: Shape[] = [];
for (const [i, edge] of edges.entries()) {
const c = centers[i];
if (!c) continue;
const onHubWall = Math.abs(Math.hypot(c[0], c[2]) - FRAME.propeller.hubRadius) < 0.6;
if (onHubWall && !await occt.shapes.edge.isEdgeCircular({ shape: edge })) junction.push(edge);
}
return junction;
}
export async function propeller(diameter: number): Promise<Shape> {
const first = await blade(diameter);
const second = await occt.transforms.rotate({ shape: first, axis: [0, 1, 0], angle: 180 });
const fused = await occt.booleans.union({ shapes: [await hub(), first, second], keepEdges: false });
const junction = await hubJunctionEdges(fused);
if (junction.length === 0) return fused;
try {
return await occt.fillets.filletEdgesListOneRadius({ shape: fused, edges: junction, radius: FRAME.propeller.junctionFillet });
} catch {
return fused;
}
}
export async function mirrored(shape: Shape): Promise<Shape> {
return occt.transforms.mirrorAlongNormal({ shape, origin: [0, 0, 0], normal: [0, 0, 1] });
}
// scene/drone-view.ts
import type { Transform } from "../cad/kernel";
import { materialOf, partOf, ROOT_ID, type DroneModel, type InstanceNode, type PartId } from "../drone/structure";
import type { Aircraft, Rotor } from "./flight";
import { PbrMaterials } from "./pbr-materials";
import { prototypeKey, Prototypes, type Prototype } from "./prototypes";
import type { Stage } from "./stage";
export type Looks = ReadonlyMap<string, Prototype>;
const ROTOR_HANDEDNESS: Partial<Record<PartId, 1 | -1>> = { "propeller-cw": -1, "propeller-ccw": 1 };
function applyPlacement(node: BABYLON.TransformNode, placement: Transform): void {
const matrix = placement.reduce((total, step) => total.multiply(BABYLON.Matrix.FromArray(step)), BABYLON.Matrix.Identity());
const scaling = new BABYLON.Vector3();
const rotation = new BABYLON.Quaternion();
const position = new BABYLON.Vector3();
matrix.decompose(scaling, rotation, position);
node.scaling = scaling;
node.rotationQuaternion = rotation;
node.position = position;
}
function lookKey(model: DroneModel, node: InstanceNode): string {
const part = partOf(model.parts, node.partId);
return prototypeKey(part, materialOf(part, node), model.params.finish);
}
function definitionChildren(model: DroneModel, definitionId: string): InstanceNode[] {
return model.nodes.filter((node): node is InstanceNode => node.kind === "instance" && node.parentId === definitionId);
}
export class DroneView implements Aircraft {
readonly root: BABYLON.TransformNode;
readonly rotors: Rotor[] = [];
private readonly hideableEdges: BABYLON.InstancedMesh[] = [];
private readonly materials: PbrMaterials;
private readonly prototypes: Prototypes;
private edgesVisible = true;
constructor(private readonly stage: Stage) {
this.root = new BABYLON.TransformNode("Drone", stage.scene);
this.materials = new PbrMaterials(stage.scene);
this.prototypes = new Prototypes(this.materials);
}
async prepare(model: DroneModel): Promise<Looks> {
this.materials.refinish(model.params.finish);
const looks = new Map<string, Prototype>();
for (const node of model.nodes) {
if (node.kind !== "instance") continue;
const key = lookKey(model, node);
if (looks.has(key)) continue;
const part = partOf(model.parts, node.partId);
looks.set(key, await this.prototypes.get(part, materialOf(part, node), model.params.finish));
}
return looks;
}
show(model: DroneModel, looks: Looks): void {
this.clear();
const groups = new Map<string, BABYLON.TransformNode>([[ROOT_ID, this.root]]);
const groupOf = (id: string): BABYLON.TransformNode => {
const group = groups.get(id);
if (!group) throw new Error(`the ${id} group is placed after its children`);
return group;
};
const definitionIds = new Set(model.nodes.filter((node) => node.kind === "definition").map((node) => node.id));
for (const node of model.nodes) {
if (node.kind === "module") {
groups.set(node.id, this.group(node.name, node.placement, groupOf(node.parentId ?? ROOT_ID)));
} else if (node.kind === "instance" && !definitionIds.has(node.parentId)) {
this.placeInstance(model, looks, node, groupOf(node.parentId));
} else if (node.kind === "reference") {
const copy = this.group(node.name, node.placement, groupOf(node.parentId));
for (const child of definitionChildren(model, node.definitionId)) {
this.placeInstance(model, looks, child, copy);
}
}
}
this.prototypes.keepOnly(new Set(looks.keys()));
this.setEdgesVisible(this.edgesVisible);
}
setEdgesVisible(visible: boolean): void {
this.edgesVisible = visible;
for (const edges of this.hideableEdges) {
edges.isVisible = visible;
}
}
private placeInstance(model: DroneModel, looks: Looks, node: InstanceNode, parent: BABYLON.TransformNode): void {
const look = looks.get(lookKey(model, node));
if (!look) throw new Error(`${node.name} has no drawn prototype`);
const holder = this.group(node.name, node.placement, parent);
for (const face of look.faces) {
const instance = face.createInstance(`${node.name} surface`);
instance.parent = holder;
this.stage.castShadow(instance);
}
for (const edge of look.edges) {
const instance = edge.createInstance(`${node.name} edges`);
instance.parent = holder;
if (look.edgesCanHide) this.hideableEdges.push(instance);
}
const handed = ROTOR_HANDEDNESS[node.partId];
if (handed) this.rotors.push({ node: holder, handed });
}
private group(name: string, placement: Transform, parent: BABYLON.TransformNode): BABYLON.TransformNode {
const node = new BABYLON.TransformNode(name, this.stage.scene);
node.parent = parent;
applyPlacement(node, placement);
return node;
}
private clear(): void {
for (const child of this.root.getChildren()) {
child.dispose();
}
this.rotors.length = 0;
this.hideableEdges.length = 0;
}
}
// scene/flight.ts
export const FLIGHT_CEILING = 90;
export const ROTOR_RPM = { min: 0, max: 360, step: 10, initial: 240 } as const;
const HOVER = { approach: 1.6, bob: 5, bobHz: 0.35, sway: 0.03, swayHz: 0.21 };
const LONGEST_FRAME_SECONDS = 0.1;
const TURN = 2 * Math.PI;
export interface Rotor {
node: BABYLON.TransformNode;
handed: 1 | -1;
}
export interface Aircraft {
readonly root: BABYLON.TransformNode;
readonly rotors: readonly Rotor[];
}
export class Flight {
private rotorSpeed = 0;
private throttle = 0;
private altitude = 0;
private clock = 0;
constructor(private readonly aircraft: Aircraft) {
this.setRpm(ROTOR_RPM.initial);
}
setRpm(rpm: number): void {
this.rotorSpeed = (rpm * TURN) / 60;
this.throttle = rpm / ROTOR_RPM.max;
}
tick(elapsedMs: number): void {
const seconds = Math.min(elapsedMs / 1000, LONGEST_FRAME_SECONDS);
this.clock += seconds;
this.spinRotors(seconds);
this.climbTowardsThrottle(seconds);
this.hover();
}
private spinRotors(seconds: number): void {
for (const rotor of this.aircraft.rotors) {
rotor.node.addRotation(0, rotor.handed * this.rotorSpeed * seconds, 0);
}
}
private climbTowardsThrottle(seconds: number): void {
const target = FLIGHT_CEILING * this.throttle;
this.altitude += (target - this.altitude) * Math.min(1, HOVER.approach * seconds);
if (this.altitude < 0.05 && target === 0) this.altitude = 0;
}
private hover(): void {
const airborne = this.altitude / FLIGHT_CEILING;
const wave = (hz: number, phase = 0): number => Math.sin(TURN * hz * this.clock + phase);
const body = this.aircraft.root;
body.position.y = this.altitude + wave(HOVER.bobHz) * HOVER.bob * airborne;
body.rotation.z = wave(HOVER.swayHz) * HOVER.sway * airborne;
body.rotation.x = wave(HOVER.swayHz * 1.37, 1) * HOVER.sway * 0.7 * airborne;
}
}
// scene/pbr-materials.ts
import { materialSpec, type FinishId, type MaterialId, type MaterialSpec } from "../drone/materials";
const CLEARCOAT_ROUGHNESS = 0.12;
const EMISSIVE_STRENGTH = 1.6;
const DEPTH_BEHIND_EDGES = 2;
function paint(material: BABYLON.PBRMetallicRoughnessMaterial, spec: MaterialSpec): void {
material.baseColor = BABYLON.Color3.FromHexString(spec.colour);
material.metallic = spec.metalness;
material.roughness = spec.roughness;
material.clearCoat.isEnabled = spec.clearcoat > 0;
material.clearCoat.intensity = spec.clearcoat;
material.emissiveColor = spec.emissive
? BABYLON.Color3.FromHexString(spec.emissive).scale(EMISSIVE_STRENGTH)
: BABYLON.Color3.Black();
}
export class PbrMaterials {
private readonly cache = new Map<MaterialId, BABYLON.PBRMetallicRoughnessMaterial>();
constructor(private readonly scene: BABYLON.Scene) { }
get(id: MaterialId, finish: FinishId): BABYLON.PBRMetallicRoughnessMaterial {
const material = this.cache.get(id) ?? this.create(id);
paint(material, materialSpec(id, finish));
return material;
}
refinish(finish: FinishId): void {
for (const [id, material] of this.cache) {
paint(material, materialSpec(id, finish));
}
}
private create(id: MaterialId): BABYLON.PBRMetallicRoughnessMaterial {
const material = new BABYLON.PBRMetallicRoughnessMaterial(id, this.scene);
material.zOffset = DEPTH_BEHIND_EDGES;
material.clearCoat.roughness = CLEARCOAT_ROUGHNESS;
this.cache.set(id, material);
return material;
}
}
// scene/prototypes.ts
import { edgeColour, materialSpec, type FinishId, type MaterialId } from "../drone/materials";
import type { Part } from "../drone/structure";
import type { PbrMaterials } from "./pbr-materials";
const PRECISION = 0.1;
const EDGE_WIDTH = 20;
const WIRE_WIDTH = 20;
export interface Prototype {
readonly root: BABYLON.Mesh;
readonly faces: BABYLON.Mesh[];
readonly edges: BABYLON.Mesh[];
readonly edgesCanHide: boolean;
}
export function prototypeKey(part: Part, material: MaterialId, finish: FinishId): string {
return `${part.key}|${material}|${edgeColour(materialSpec(material, finish).colour)}`;
}
export class Prototypes {
private readonly drawn = new Map<string, Promise<Prototype>>();
constructor(private readonly materials: PbrMaterials) { }
get(part: Part, material: MaterialId, finish: FinishId): Promise<Prototype> {
const key = prototypeKey(part, material, finish);
const existing = this.drawn.get(key);
if (existing) return existing;
const drawing = this.draw(part, material, finish);
this.drawn.set(key, drawing);
drawing.catch(() => this.drawn.delete(key));
return drawing;
}
keepOnly(keys: ReadonlySet<string>): void {
for (const [key, drawing] of this.drawn) {
if (keys.has(key)) continue;
this.drawn.delete(key);
drawing.then((prototype) => prototype.root.dispose(), () => undefined);
}
}
private async draw(part: Part, material: MaterialId, finish: FinishId): Promise<Prototype> {
const faceMaterial = this.materials.get(material, finish);
const options = new Bit.Inputs.Draw.DrawOcctShapeOptions();
options.precision = PRECISION;
options.faceMaterial = faceMaterial;
options.drawFaces = partEditor plans for 3D development, API keys for server-side CAD algorithms
Cloud storage, private projects & 3D tools for Bitbybit editors
* "Unlimited" means high limits that most users won't reach. Compare the plans
Run CAD algorithms on managed cloud servers from your own backend applications
Custom software development, dedicated servers & CAD automation at scale.