Script: 3D Grass Greeting Card

3D Grass Greeting Card picture
Type
Typescript logo indicatortypescript
Date Created
Mar 5, 2025, 6:53:46 AM
Last Edit Date
Nov 19, 2025, 7:47:53 AM

Project Information

If you're looking for a fun greeting to send to someone, look into this project - you will only need to enter person's name and and a message and you will be able to generate a special link which you can send out to someone you want to greet with that particular text.

View Full Project

Script Code

let text1 = "BITBYBIT";
let text2 = "GREETINGS!";
const makeGrass = true;

interface Weed {
    basePosition: BABYLON.Vector3,
    randomOffset: number,
    smoothedMouseInfluence: number,
    smoothedMouseDirection: BABYLON.Vector3
};

interface ResTxt {
    movedGridOfPts: Bit.Inputs.Base.Point3[];
    wiresDrawnMesh: BABYLON.Mesh;
    pointsDrawnMesh: BABYLON.Mesh;
}

interface ResultWeed {
    weeds: Weed[],
    weedsMesh: BABYLON.LinesMesh, // The single mesh for all weeds
    text3D1: Bit.Advanced.Text3D.Text3DData<Bit.Inputs.OCCT.TopoDSShapePointer>;
    text3D2: Bit.Advanced.Text3D.Text3DData<Bit.Inputs.OCCT.TopoDSShapePointer>;
    r1: ResTxt;
    r2: ResTxt;
}

let resultWeed: ResultWeed;

// Increased segments and length for taller grass
const numSegments = 5;           // Segments per weed
const segmentLength = 0.6;       // Length of each segment
const weedHeight = numSegments * segmentLength; // Total height
const mouseEffectRadius = 3;     // Radius of mouse wind influence
const maxMouseInfluence = 1;   // Maximum bending strength
const recoveryAlpha = 0.1;       // Recovery speed (lower = slower)
const velocityInfluenceFactor = 2; // Mouse speed effect

const scene = bitbybit.babylon.scene.getScene();

const camera = bitbybit.babylon.scene.getActiveCamera() as BABYLON.ArcRotateCamera;
camera.position = new BABYLON.Vector3(0, 30, -20);
camera.target = new BABYLON.Vector3(0, 0, -4);

createGUI();

const start = async () => {
    if (!makeGrass) {

    } else {
        // **Constants**
        // **Create invisible ground at the top of the weeds**
        const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 200, height: 100 }, scene);
        ground.position.y = weedHeight;
        ground.visibility = 0;

        // **Weed creation**
        resultWeed = await createWeeds(text1, text2, numSegments, segmentLength, scene);

        // **Mouse and time tracking**
        let time = 0;
        let mouseWorldPos = new BABYLON.Vector3(0, weedHeight, 0); // Start at weed top height
        let lastMouseWorldPos = mouseWorldPos.clone();
        let mouseVelocity = new BABYLON.Vector3(0, 0, 0);

        if (scene.metadata && scene.metadata.observable) {
            scene.metadata.observable.remove();
        }

        // **Mouse movement handler**
        const resObs = scene.onPointerObservable.add((pointerInfo) => {
            if (pointerInfo.type === BABYLON.PointerEventTypes.POINTERMOVE && pointerInfo.event.buttons === 0) {
                const pickInfo = scene.pick(pointerInfo.event.clientX, pointerInfo.event.clientY);
                if (pickInfo.hit && pickInfo.pickedMesh === ground) {
                    lastMouseWorldPos = mouseWorldPos.clone();
                    mouseWorldPos = pickInfo.pickedPoint;
                    mouseVelocity = mouseWorldPos.subtract(lastMouseWorldPos);
                }
            }
        });

        if (scene.metadata) {
            scene.metadata.observable = resObs;
        } else {
            scene.metadata = {
                observable: resObs
            }
        }

        // **Render loop**
        bitbybit.time.registerRenderFunction(() => {
            time += 0.02;
            if (resultWeed.weeds.length > 0) {
                // 1. Create a single large array to hold all vertex positions for this frame.
                const allNewPositions: number[] = [];

                resultWeed.weeds.forEach(weed => {
                    // **Mouse influence as radial wind** (This logic is unchanged)
                    const weedTop = weed.basePosition.clone();
                    weedTop.y = weedHeight;
                    const fromWeedTopToMouse = mouseWorldPos.subtract(weedTop);
                    const distance = fromWeedTopToMouse.length();
                    const falloff = (distance < mouseEffectRadius) ? (1 - distance / mouseEffectRadius) : 0;
                    const velocityNorm = mouseVelocity.length() > 0 ? mouseVelocity.normalize() : new BABYLON.Vector3(0, 0, 0);
                    const toMouseNorm = fromWeedTopToMouse.length() > 0 ? fromWeedTopToMouse.normalize() : new BABYLON.Vector3(0, 0, 0);
                    const velocityDot = BABYLON.Vector3.Dot(velocityNorm, toMouseNorm);
                    const velocityInfluence = Math.max(0, velocityDot) * mouseVelocity.length() * velocityInfluenceFactor;
                    const targetMouseInfluence = falloff * maxMouseInfluence + velocityInfluence;
                    weed.smoothedMouseInfluence = BABYLON.Scalar.Lerp(weed.smoothedMouseInfluence, targetMouseInfluence, recoveryAlpha);
                    const targetDirection = fromWeedTopToMouse.length() > 0 ? fromWeedTopToMouse.normalize() : new BABYLON.Vector3(0, 0, 0);
                    weed.smoothedMouseDirection = BABYLON.Vector3.Lerp(weed.smoothedMouseDirection, targetDirection, recoveryAlpha);

                    // **Build weed segments** (This logic is unchanged)
                    const points: BABYLON.Vector3[] = [];
                    let currentPoint = weed.basePosition.clone();
                    points.push(currentPoint.clone());

                    const windVariation = Math.sin(time + weed.randomOffset) * 0.15;
                    const windStrength = 0.05 + windVariation;
                    const windAxis = new BABYLON.Vector3(1, 0, 0);

                    let mouseAxis = null;
                    if (weed.smoothedMouseInfluence > 0 && weed.smoothedMouseDirection.length() > 0) {
                        mouseAxis = BABYLON.Vector3.Cross(BABYLON.Axis.Y, weed.smoothedMouseDirection);
                    }

                    let accumulatedMouseAngle = 0;
                    for (let k = 1; k <= numSegments; k++) {
                        const windAngle = windStrength * k;
                        const windMatrix = BABYLON.Matrix.RotationAxis(windAxis, windAngle);
                        let segmentDir = new BABYLON.Vector3(0, segmentLength, 0);

                        if (mouseAxis && weed.smoothedMouseInfluence > 0) {
                            const mouseInfluenceFactor = k / numSegments;
                            const mouseAngleIncrement = weed.smoothedMouseInfluence * mouseInfluenceFactor * 0.2;
                            accumulatedMouseAngle += mouseAngleIncrement;
                            const mouseMatrix = BABYLON.Matrix.RotationAxis(mouseAxis, accumulatedMouseAngle);
                            segmentDir = BABYLON.Vector3.TransformCoordinates(segmentDir, mouseMatrix);
                        }
                        segmentDir = BABYLON.Vector3.TransformCoordinates(segmentDir, windMatrix);
                        currentPoint = currentPoint.add(segmentDir);
                        points.push(currentPoint.clone());
                    }

                    // 2. Instead of updating a single mesh, flatten the points and add them to our big array.
                    for (const p of points) {
                        allNewPositions.push(p.x, p.y, p.z);
                    }
                });

                // 3. After iterating through all weeds, update the single LineSystem mesh *once* with all new positions.
                resultWeed.weedsMesh.updateVerticesData(BABYLON.VertexBuffer.PositionKind, allNewPositions, false, false);
            }
        })
    }
}

// This function now creates a single LineSystem instead of many Lines.
async function createWeeds(text1: string, text2: string, numSegments: number, segmentLength: number, scene: BABYLON.Scene): Promise<ResultWeed> {

    const textOpt = new Bit.Advanced.Text3D.Text3DDto();
    textOpt.text = text1.toUpperCase();
    textOpt.fontType = Bit.Advanced.Text3D.fontsEnum.Roboto;
    textOpt.fontVariant = Bit.Advanced.Text3D.fontVariantsEnum.BoldItalic;

    textOpt.height = 0;
    textOpt.fontSize = 10;
    textOpt.originAlignment = Bit.Advanced.Text3D.recAlignmentEnum.centerMiddle;

    const text3D1 = await bitbybit.advanced.text3d.create(textOpt);

    textOpt.text = text2.toUpperCase();
    textOpt.fontSize = 7;
    const text3D2 = await bitbybit.advanced.text3d.create(textOpt);
    const separator = 5;

    const r1 = await createTextPoints(text3D1, separator);
    const r2 = await createTextPoints(text3D2, -separator);

    const filteredPoints = [...r1.movedGridOfPts, ...r2.movedGridOfPts];

    const weeds: Weed[] = [];
    // We will build up arrays of all lines' points and colors.
    const allInitialLines: BABYLON.Vector3[][] = [];
    const allInitialColors: BABYLON.Color4[][] = [];

    // Updated to green and yellow colors
    const baseColor = new BABYLON.Color4(0.2, 0.6, 0.2, 1); // Dark Green
    const midColor = new BABYLON.Color4(0.5, 0.8, 0.3, 1);  // Lighter Green
    let topColor = new BABYLON.Color4(0.8, 1, 0.4, 1);      // Yellow-Green

    for (let i = 0; i < filteredPoints.length; i++) {
        const x = filteredPoints[i][0];
        const z = filteredPoints[i][2];
        const basePosition = new BABYLON.Vector3(x, 0, z);

        if (i === r1.movedGridOfPts.length) {
            // Updated to a bright yellow for the second text
            topColor = new BABYLON.Color4(1, 1, 0.5, 1);    // Bright Yellow
        }

        const points: BABYLON.Vector3[] = [];
        const colors: BABYLON.Color4[] = [];
        for (let k = 0; k <= numSegments; k++) {
            points.push(new BABYLON.Vector3(x, k * segmentLength, z));
            const t = k / numSegments;
            let color;
            if (t < 0.5) {
                color = BABYLON.Color4.Lerp(baseColor, midColor, t * 2);
            } else {
                color = BABYLON.Color4.Lerp(midColor, topColor, (t - 0.5) * 2);
            }
            colors.push(color);
        }

        // Instead of creating a mesh, add the points and colors to our master arrays.
        allInitialLines.push(points);
        allInitialColors.push(colors);

        // The Weed object now only stores state, not the mesh itself.
        weeds.push({
            basePosition,
            randomOffset: Math.random() * Math.PI * 6,
            smoothedMouseInfluence: 0,
            smoothedMouseDirection: new BABYLON.Vector3(0, 0, 0)
        });
    }

    // After the loop, create the single LineSystem mesh with all the lines.
    const weedsMesh = BABYLON.MeshBuilder.CreateLineSystem("weedsSystem", {
        lines: allInitialLines,
        colors: allInitialColors,
        updatable: true
    }, scene);
    weedsMesh.isPickable = false;


    [r1.wiresDrawnMesh, r2.wiresDrawnMesh].forEach(c => {
        bitbybit.babylon.mesh.setVisibility({
            babylonMesh: c,
            visibility: 1,
            includeChildren: true,
        });
    });

    [r1.pointsDrawnMesh, r2.pointsDrawnMesh].forEach(c => {
        c.getChildMeshes().forEach(cx => cx.isVisible = true);
    });

    // Return the single mesh along with the other data.
    return { weeds, weedsMesh, text3D1, text3D2, r1, r2 };
}

async function createTextPoints(txt3D: Bit.Advanced.Text3D.Text3DData<Bit.Inputs.OCCT.TopoDSShapePointer>, upDist: number) {

    const hexGridOpt = new Bit.Inputs.Point.HexGridScaledToFitDto();

    const width = txt3D.boundingBox.x2 - txt3D.boundingBox.x1;
    const length = txt3D.boundingBox.y2 - txt3D.boundingBox.y1;

    const textWires = await bitbybit.occt.shapes.wire.getWires({ shape: txt3D.compound });
    const textWiresCompound = await bitbybit.occt.shapes.compound.makeCompound({
        shapes: textWires
    });
    const textWiresCompoundTranslated = await bitbybit.occt.transforms.translate({
        shape: textWiresCompound,
        translation: [0, 0, upDist]
    });

    const textOptions = new Bit.Inputs.Draw.DrawOcctShapeSimpleOptions();
    textOptions.edgeWidth = 10;
    textOptions.edgeColour = "#222222";

    const wiresDrawnMesh = await bitbybit.draw.drawAnyAsync({
        entity: textWiresCompoundTranslated,
        options: textOptions
    });
    bitbybit.babylon.mesh.setVisibility({
        babylonMesh: wiresDrawnMesh,
        visibility: 0,
        includeChildren: true,
    });

    hexGridOpt.pointsOnGround = true;
    hexGridOpt.centerGrid = true;
    hexGridOpt.width = width * 1.1;
    hexGridOpt.height = length * 1.1;
    // Increased hexagon counts for denser grass
    hexGridOpt.nrHexagonsInHeight = 90;
    hexGridOpt.nrHexagonsInWidth = 150;

    const gridOfPts = bitbybit.point.hexGridScaledToFit(hexGridOpt);
    const faces = await bitbybit.occt.shapes.face.getFaces({ shape: txt3D.compound });

    const opt = new Bit.Inputs.OCCT.FilterFacesPointsDto<Bit.Inputs.OCCT.TopoDSFacePointer>();
    opt.shapes = faces;
    opt.points = gridOfPts.centers;
    opt.gapTolerance = 0.1;
    const filteredPoints = await bitbybit.occt.shapes.face.filterFacesPoints(opt) as Bit.Inputs.Base.Point3[];

    const movedGridOfPts = bitbybit.point.translatePoints({
        points: filteredPoints,
        translation: [0, 0, upDist],
    });

    const optDraw = new Bit.Inputs.Draw.DrawBasicGeometryOptions();
    optDraw.size = 0.04;
    // Changed debug point color
    optDraw.colours = "#00ff00";
    const pointsDrawnMesh = await bitbybit.draw.drawAnyAsync({
        entity: movedGridOfPts,
        options: optDraw,
    });
    pointsDrawnMesh.getChildMeshes().forEach(c => c.isVisible = false);

    return { movedGridOfPts, wiresDrawnMesh, pointsDrawnMesh };
}

function createGUI() {
    const gui = bitbybit.babylon.gui;
    const textOpt = new Bit.Inputs.BabylonGui.CreateFullScreenUIDto();
    const fullScreenUI = gui.advancedDynamicTexture.createFullScreenUI(textOpt);
    const stackPanelOpt = new Bit.Inputs.BabylonGui.CreateStackPanelDto();
    const stackPanel = gui.stackPanel.createStackPanel(stackPanelOpt);
    const stackPanelOpt2 = new Bit.Inputs.BabylonGui.CreateStackPanelDto();
    const stackPanel2 = gui.stackPanel.createStackPanel(stackPanelOpt2);
    stackPanel2.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM;
    fullScreenUI.addControl(stackPanel2);
    stackPanel.paddingTopInPixels = 10;
    stackPanel2.addControl(stackPanel);

    // Changed GUI input text colors to match the new theme
    const textInput1 = createInput(gui, text1, "#99cc66", text1);
    const textInput2 = createInput(gui, text2, "#ffff99", text2);

    textInput1.onBlurObservable.add(async () => {
        textInput1.isEnabled = false;
        textInput2.isEnabled = false;
        text1 = textInput1.text;

        // Dispose of the single weeds mesh.
        if (resultWeed.weedsMesh) {
            resultWeed.weedsMesh.dispose();
        }

        resultWeed.r1.pointsDrawnMesh.dispose();
        resultWeed.r1.wiresDrawnMesh.dispose();
        resultWeed.r2.pointsDrawnMesh.dispose();
        resultWeed.r2.wiresDrawnMesh.dispose();
        const res = await createWeeds(text1, text2, numSegments, segmentLength, scene);
        resultWeed = res;
        textInput1.isEnabled = true;
        textInput2.isEnabled = true;
    });

    textInput2.onBlurObservable.add(async () => {
        textInput1.isEnabled = false;
        textInput2.isEnabled = false;
        text2 = textInput2.text;

        // Dispose of the single weeds mesh.
        if (resultWeed.weedsMesh) {
            resultWeed.weedsMesh.dispose();
        }

        resultWeed.r1.pointsDrawnMesh.dispose();
        resultWeed.r1.wiresDrawnMesh.dispose();
        resultWeed.r2.pointsDrawnMesh.dispose();
        resultWeed.r2.wiresDrawnMesh.dispose();
        const res = await createWeeds(text1, text2, numSegments, segmentLength, scene);
        resultWeed = res;
        textInput1.isEnabled = true;
        textInput2.isEnabled = true;
    });

    const buttonOptions = new Bit.Inputs.BabylonGui.CreateButtonDto();
    buttonOptions.width = "400px";
    buttonOptions.height = "150px";
    buttonOptions.label = "COPY URL FOR SHARING!";
    buttonOptions.color = "#ffffff";
    buttonOptions.background = "#00000000";
    const button = gui.button.createSimpleButton(buttonOptions);
    button.paddingTopInPixels = 20;
    button.paddingBottomInPixels = 20;

    button.onPointerClickObservable.add(() => {
        navigator.clipboard.writeText(`https://bitbybit.dev/app/bitbybit/z8JaJdkMv1FAtidWipDX/713kfZQNxnULXUXsz2Ox/preview?occt=true&jscad=false&manifold=false&title=${encodeURIComponent(text1)}&subtitle=${encodeURIComponent(text2)}`)
    })

    const imageOpt = new Bit.Inputs.BabylonGui.CreateImageDto();
    imageOpt.url = "assets/logo-gold-small.png";
    const image = gui.image.createImage(imageOpt);
    const padding = 30;
    image.paddingTopInPixels = padding;
    image.paddingBottomInPixels = padding;
    image.paddingLeftInPixels = padding;
    image.paddingRightInPixels = padding;

    image.onPointerClickObservable.add(() => {
        window.open("https://bitbybit.dev", '_blank').focus();
    });
    const txtBlockOptions1 = new Bit.Inputs.BabylonGui.CreateTextBlockDto();
    txtBlockOptions1.text = "Powered By";
    const txtBlock1 = gui.textBlock.createTextBlock(txtBlockOptions1);
    txtBlock1.height = "40px";
    txtBlock1.paddingBottomInPixels = 10;
    const txtBlockOptions = new Bit.Inputs.BabylonGui.CreateTextBlockDto();
    txtBlockOptions.text = "BITBYBIT.DEV";
    const txtBlock = gui.textBlock.createTextBlock(txtBlockOptions);
    txtBlock.height = "60px";
    txtBlock.paddingBottomInPixels = 30;

    stackPanel2.addControl(textInput1);
    stackPanel2.addControl(textInput2);
    stackPanel2.addControl(button);
    stackPanel2.addControl(image);
    stackPanel2.addControl(txtBlock1);
    stackPanel2.addControl(txtBlock);
}

function createInput(gui: Bit.BabylonGui, text: string, color: string, textVariableToUpdate: string) {
    const txtInpt1Opt = new Bit.Inputs.BabylonGui.CreateInputTextDto();
    let originalText = text;
    txtInpt1Opt.text = originalText;
    txtInpt1Opt.width = "400px";
    txtInpt1Opt.height = "100px";
    const textInput = gui.inputText.createInputText(txtInpt1Opt);
    textInput.fontSize = "40px";
    textInput.paddingTopInPixels = 10;
    textInput.paddingBottomInPixels = 10;
    textInput.color = color;
    textInput.onTextChangedObservable.add((newText) => {
        if (newText.text && newText.text.length > 20) {
            textInput.text = originalText.toUpperCase();
        }
        else {
            originalText = newText.text;
            textInput.text = newText.text.toUpperCase()
        }
    });
    return textInput;
}

start();