Script: TypeScript Code for Parametric Tower Facade

TypeScript Code for Parametric Tower Facade picture
Type
Typescript logo indicatortypescript
Date Created
Jan 3, 2023, 2:04:42 PM
Last Edit Date
Sep 30, 2026, 11:55:11 AM

Project Information

This conceptual design was made by using cell like subdivisions of surfaces. To rationalise the freeform surface geometry we used triangulation. We have written a lot of new algorithms based on the OpenCascade technology kernel and integrated into our API. This project is open-source and shared under MIT license. You can use the script as an inspiration for your own work or learning.

View Full Project

Script Code

type Point3 = Bit.Inputs.Base.Point3;
type Vector3 = Bit.Inputs.Base.Vector3;
type UV = [number, number];
type ShapePointer = Bit.Inputs.OCCT.TopoDSShapePointer;
type FacePointer = Bit.Inputs.OCCT.TopoDSFacePointer;
type WirePointer = Bit.Inputs.OCCT.TopoDSWirePointer;

const { LoftAdvancedDto, approxParametrizationTypeEnum } = Bit.Inputs.OCCT;
const { SkyboxDto, DirectionalLightDto } = Bit.Inputs.BabylonScene;
const { DrawOcctShapeOptions } = Bit.Inputs.Draw;
const { PBRMetallicRoughnessDto } = Bit.Inputs.BabylonMaterial;

const exportGlb = false;
const meshingPrecision = 0.01;
const shapesPerDrawCall = 30;
const drawEdges = true;

// Which faces of tower-base.stp get which pattern. Entrance faces take the apex of each
// triangle from the matching cell of a second, offset face; side faces push the apex out
// along the face normal by apexHeight.
type EntranceFacade = { kind: "entrance"; face: number; apexFace: number; cellsU: number; cellsV: number };
type SideFacade = { kind: "side"; face: number; reversed: boolean; cellsU: number; cellsV: number; apexHeight: number; panelHeight: number };
type Facade = EntranceFacade | SideFacade;

const facades: Facade[] = [
    { kind: "entrance", face: 4, apexFace: 2, cellsU: 6, cellsV: 25 },
    { kind: "entrance", face: 1, apexFace: 3, cellsU: 25, cellsV: 6 },
    { kind: "side", face: 5, reversed: true, cellsU: 25, cellsV: 6, apexHeight: 0.3, panelHeight: 0.04 },
    { kind: "side", face: 6, reversed: true, cellsU: 25, cellsV: 6, apexHeight: 0.3, panelHeight: 0.04 },
    { kind: "side", face: 0, reversed: false, cellsU: 6, cellsV: 6, apexHeight: 0.2, panelHeight: 0.02 },
];

// Entrance frame sections, inset from the rounded triangle and lifted along its normal.
// Every inset stays below the fillet radius, so each section keeps its three arcs and
// all sections have the same six edges, which the loft needs.
const entranceFillet = 0.08;
const entranceSections = [
    { inset: 0.03, lift: 0.02 },
    { inset: 0.04, lift: 0.01 },
    { inset: 0.05, lift: 0.02 },
    { inset: 0.07, lift: 0 },
    { inset: 0.03, lift: -0.02 },
];
const entranceWindowSection = 3;
const sideInsets = [0.03, 0.04];
const sideLift = -0.01;

// Triangles smaller than this (inradius) are too small to frame and are skipped.
const minInradius = 0.002;

const add = (a: number[], b: number[]): Point3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const sub = (a: number[], b: number[]): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const scale = (a: number[], s: number): Vector3 => [a[0] * s, a[1] * s, a[2] * s];
const cross = (a: number[], b: number[]): Vector3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const magnitude = (a: number[]): number => Math.hypot(a[0], a[1], a[2]);
const normalize = (a: number[]): Vector3 => scale(a, 1 / magnitude(a));

const triangleNormal = (a: Point3, b: Point3, c: Point3): Vector3 => normalize(cross(sub(b, a), sub(c, a)));
const centroid = (a: Point3, b: Point3, c: Point3): Point3 => scale(add(add(a, b), c), 1 / 3) as Point3;

// The radius of the largest circle inside the triangle. A 2D fillet fits only below it,
// and an inward offset of more than it collapses the outline.
const inradius = (a: Point3, b: Point3, c: Point3): number => {
    const perimeter = magnitude(sub(b, a)) + magnitude(sub(c, b)) + magnitude(sub(a, c));
    const area = magnitude(cross(sub(b, a), sub(c, a))) / 2;
    return perimeter > 0 ? (2 * area) / perimeter : 0;
};

const describeError = (error: unknown): string => {
    const e = error as { code?: string; message?: string };
    return e?.code ?? e?.message ?? String(error);
};

// A cell's corners in loop order, so consecutive corners make its four triangle bases.
type Cell = { ring: [Point3, Point3, Point3, Point3]; center: Point3; centerNormal: Vector3 };

const sampleCells = async (face: FacePointer, cellsU: number, cellsV: number): Promise<Cell[]> => {
    const ringUVs: UV[] = [];
    const centerUVs: UV[] = [];
    for (let u = 0; u < cellsU; u++) {
        for (let v = 0; v < cellsV; v++) {
            const u0 = u / cellsU;
            const u1 = (u + 1) / cellsU;
            const v0 = v / cellsV;
            const v1 = (v + 1) / cellsV;
            ringUVs.push([u0, v0], [u1, v0], [u1, v1], [u0, v1]);
            centerUVs.push([(u0 + u1) / 2, (v0 + v1) / 2]);
        }
    }
    const [ringPoints, centers, centerNormals] = await Promise.all([
        bitbybit.occt.shapes.face.pointsOnUVs({ shape: face, paramsUV: ringUVs }),
        bitbybit.occt.shapes.face.pointsOnUVs({ shape: face, paramsUV: centerUVs }),
        bitbybit.occt.shapes.face.normalsOnUVs({ shape: face, paramsUV: centerUVs }),
    ]);
    return centers.map((center, i) => ({
        ring: ringPoints.slice(i * 4, i * 4 + 4) as Cell["ring"],
        center,
        centerNormal: centerNormals[i],
    }));
};

type Frame = { frame: ShapePointer; window: ShapePointer };

const sideLoft = (shapes: WirePointer[], closed: boolean) => {
    const options = new LoftAdvancedDto(shapes);
    options.closed = closed;
    options.straight = true;
    options.parType = approxParametrizationTypeEnum.approxCentripetal;
    options.maxUDegree = 1;
    options.periodic = false;
    options.tolerance = 1e-7;
    return bitbybit.occt.operations.loftAdvanced(options);
};

const createEntranceTriangle = async (a: Point3, b: Point3, apex: Point3): Promise<Frame | undefined> => {
    const room = inradius(a, b, apex);
    if (room < minInradius) {
        return undefined;
    }
    // Shrink the whole profile on triangles too small for it, instead of failing.
    const fit = Math.min(1, (0.9 * room) / entranceFillet);
    const normal = triangleNormal(a, b, apex);

    const outline = await bitbybit.occt.shapes.wire.createPolygonWire({ points: [a, b, apex] });
    const [rounded, nearlySharp] = await Promise.all([
        bitbybit.occt.fillets.fillet2d({ shape: outline, radius: entranceFillet * fit }),
        bitbybit.occt.fillets.fillet2d({ shape: outline, radius: 0.0001 * fit }),
    ]);
    const insets = await Promise.all(entranceSections.map(s =>
        bitbybit.occt.operations.offset({ shape: rounded, distance: -s.inset * fit, tolerance: 1e-7 })));
    const sections = await bitbybit.occt.transforms.translateShapes({
        shapes: insets,
        translations: entranceSections.map(s => scale(normal, s.lift * fit)),
    });

    const loftOptions = new LoftAdvancedDto([nearlySharp, ...sections]);
    loftOptions.closed = true;
    loftOptions.straight = true;
    const [frame, window] = await Promise.all([
        bitbybit.occt.operations.loftAdvanced(loftOptions),
        bitbybit.occt.shapes.face.createFaceFromWire({ shape: insets[entranceWindowSection], planar: true }),
    ]);
    return { frame, window };
};

const createSideTriangle = async (a: Point3, b: Point3, center: Point3, centerNormal: Vector3, apexHeight: number, panelHeight: number): Promise<Frame | undefined> => {
    const apex = add(center, scale(centerNormal, apexHeight));
    const room = inradius(a, b, apex);
    if (room < minInradius) {
        return undefined;
    }
    const fit = Math.min(1, (0.8 * room) / Math.max(...sideInsets));
    const normal = triangleNormal(a, b, apex);

    const outline = await bitbybit.occt.shapes.wire.createPolygonWire({ points: [a, b, apex] });
    const [inset1, inset2, tip] = await Promise.all([
        bitbybit.occt.operations.offset({ shape: outline, distance: -sideInsets[0] * fit, tolerance: 1e-7 }),
        bitbybit.occt.operations.offset({ shape: outline, distance: -sideInsets[1] * fit, tolerance: 1e-7 }),
        bitbybit.occt.transforms.scale3d({ shape: outline, scale: [0.001, 0.001, 0.001], center: centroid(a, b, apex) }),
    ]);
    const [inset2Lifted, tipLifted] = await bitbybit.occt.transforms.translateShapes({
        shapes: [inset2, tip],
        translations: [scale(normal, sideLift * fit), scale(normal, panelHeight * fit)],
    });

    const [frame, window] = await Promise.all([
        sideLoft([outline, inset1, inset2Lifted], true),
        sideLoft([inset2Lifted, tipLifted], false),
    ]);
    return { frame, window };
};

// Builds every triangle at once so the worker never waits for the script, and keeps going
// past a triangle the kernel cannot build: 1.4 reports such a failure as an error instead
// of an empty shape, which used to stop the whole script.
const buildAll = async (label: string, jobs: (() => Promise<Frame | undefined>)[]): Promise<Frame[]> => {
    const results = await Promise.allSettled(jobs.map(job => job()));
    const built: Frame[] = [];
    const failures = new Map<string, number>();
    let skipped = 0;
    for (const result of results) {
        if (result.status === "rejected") {
            const reason = describeError(result.reason);
            failures.set(reason, (failures.get(reason) ?? 0) + 1);
        } else if (result.value) {
            built.push(result.value);
        } else {
            skipped++;
        }
    }
    const failed = [...failures.values()].reduce((sum, n) => sum + n, 0);
    console.log(`${label}: ${built.length} of ${jobs.length} triangles built, ${skipped} too small, ${failed} failed`);
    failures.forEach((count, reason) => console.warn(`${label}: ${count} x ${reason}`));
    return built;
};

const drawInChunks = async (shapes: ShapePointer[], options: Bit.Inputs.Draw.DrawOcctShapeOptions) => {
    for (let i = 0; i < shapes.length; i += shapesPerDrawCall) {
        const compound = await bitbybit.occt.shapes.compound.makeCompound({ shapes: shapes.slice(i, i + shapesPerDrawCall) });
        await bitbybit.draw.drawAnyAsync({ entity: compound, options });
    }
};

const frameOptions = (faceColour: string) => {
    const options = new DrawOcctShapeOptions();
    options.edgeWidth = 0.2;
    options.precision = meshingPrecision;
    options.drawEdges = drawEdges;
    options.faceColour = faceColour;
    options.edgeColour = "#000000";
    return options;
};

const materialOptions = (material: Bit.Inputs.BabylonMaterial.PBRMetallicRoughnessDto) => {
    const options = new DrawOcctShapeOptions();
    options.edgeWidth = 0.2;
    options.precision = meshingPrecision;
    options.drawEdges = drawEdges;
    options.faceMaterial = bitbybit.babylon.material.pbrMetallicRoughness.create(material);
    return options;
};

const glassMaterial = new PBRMetallicRoughnessDto();
glassMaterial.baseColor = "#0000ff";
glassMaterial.backFaceCulling = false;
glassMaterial.metallic = 0.9;
glassMaterial.roughness = 0.3;
glassMaterial.alpha = 0.6;
glassMaterial.zOffset = 2;

const panelMaterial = new PBRMetallicRoughnessDto();
panelMaterial.baseColor = "#111155";
panelMaterial.backFaceCulling = false;
panelMaterial.metallic = 0.8;
panelMaterial.roughness = 0.3;

const setUpScene = () => {
    const skybox = new SkyboxDto();
    skybox.skybox = Bit.Inputs.Base.skyboxEnum.clearSky;
    skybox.blur = 0.4;
    skybox.size = 1000;
    skybox.environmentIntensity = 0.7;
    bitbybit.babylon.scene.enableSkybox(skybox);

    const light = new DirectionalLightDto();
    light.direction = [-50, -10, -30];
    light.intensity = 1;
    light.shadowGeneratorMapSize = 4000;
    bitbybit.babylon.scene.drawDirectionalLight(light);

    const setStartingCamera = () => {
        const camera = bitbybit.babylon.scene.getActiveCamera() as BABYLON.ArcRotateCamera;
        camera.setTarget(new BABYLON.Vector3(1.479, 4.817, -3.04));
        camera.setPosition(new BABYLON.Vector3(-4.807, 0.327, 4.33));
        camera.fov = 1.1;
    };

    setStartingCamera();
};

const start = async () => {
    if (!exportGlb) {
        setUpScene();
    }

    const asset = await bitbybit.asset.getFile({ fileName: "tower-base.stp" });
    const imported = await bitbybit.occt.io.loadSTEPorIGES({ assetFile: asset as File, adjustZtoY: true });
    const tower = await bitbybit.occt.transforms.scale({ shape: imported, factor: 0.1 });
    const faceAt = async (index: number, reversed = false): Promise<FacePointer> => {
        const face = await bitbybit.occt.shapes.face.getFace({ shape: tower, index });
        return reversed ? bitbybit.occt.shapes.face.reversedFace({ shape: face }) : face;
    };

    const entranceFrames: Frame[] = [];
    const sideFrames: Frame[] = [];
    for (const facade of facades) {
        const label = `face ${facade.face}`;
        if (facade.kind === "entrance") {
            const [cells, apexCells] = await Promise.all([
                faceAt(facade.face).then(face => sampleCells(face, facade.cellsU, facade.cellsV)),
                faceAt(facade.apexFace).then(face => sampleCells(face, facade.cellsU, facade.cellsV)),
            ]);
            const jobs = cells.flatMap((cell, i) => cell.ring.map((corner, k) =>
                () => createEntranceTriangle(corner, cell.ring[(k + 1) % 4], apexCells[i].center)));
            entranceFrames.push(...await buildAll(label, jobs));
        } else {
            const cells = await sampleCells(await faceAt(facade.face, facade.reversed), facade.cellsU, facade.cellsV);
            const jobs = cells.flatMap(cell => cell.ring.map((corner, k) =>
                () => createSideTriangle(corner, cell.ring[(k + 1) % 4], cell.center, cell.centerNormal, facade.apexHeight, facade.panelHeight)));
            sideFrames.push(...await buildAll(label, jobs));
        }
    }

    await drawInChunks(entranceFrames.map(t => t.frame), frameOptions("#8888ff"));
    await drawInChunks(entranceFrames.map(t => t.window), materialOptions(glassMaterial));
    await drawInChunks(sideFrames.map(t => t.frame), frameOptions("#111155"));
    await drawInChunks(sideFrames.map(t => t.window), materialOptions(panelMaterial));

    if (exportGlb) {
        bitbybit.babylon.io.exportGLB({ fileName: "parametric-structural-tower-facade.glb" });
    } else {
        const groundOptions = new DrawOcctShapeOptions();
        groundOptions.faceColour = "#5555ff";
        groundOptions.drawEdges = false;
        const ground = await bitbybit.occt.shapes.face.createCircleFace({ radius: 20, center: [0, 0, 0], direction: [0, 1, 0] });
        await bitbybit.draw.drawAnyAsync({ entity: ground, options: groundOptions });
    }

    const logCameraOnMove = () => {
        const camera = bitbybit.babylon.scene.getActiveCamera() as BABYLON.TargetCamera;
        const round = (v: BABYLON.Vector3) => [v.x, v.y, v.z].map(n => Number(n.toFixed(3)));
        let timer: ReturnType<typeof setTimeout> | undefined;
        camera.onViewMatrixChangedObservable.add(() => {
            clearTimeout(timer);
            timer = setTimeout(() => {
                console.log(`camera position: ${JSON.stringify(round(camera.position))}, target: ${JSON.stringify(round(camera.getTarget()))}`);
            }, 300);
        });
    };

    logCameraOnMove();
};

start();
Plans & Pricing

Choose Your Plan

Editor plans for 3D development, API keys for server-side CAD algorithms

B2B

ENTERPRISE

Custom pricing

Custom software development, dedicated servers & CAD automation at scale.

CAD Automation & Software
  • Custom software development
  • Cloud CAD automation pipelines
  • 3D configurators (STEP & GLTF)
  • Batch export jobs
  • Custom algorithms & deployment
Infrastructure & Support
  • Custom compute allocation
  • Dedicated / VPS server tenants
  • Long-running computation jobs
  • Custom upload limits & overage
  • SLA & premium support