Back to blog
Mehran Mozaffari·

Designing Physical Objects with Gemini Canvas: From Prompt to Printable STL

The Core Move: Generating a CAD Micro-App, Not a Mesh

The first thing to understand about this workflow, and the reason it's genuinely interesting rather than just another prompt-to-3D demo, is that Gemini never touches STL data. When I ask for a vase, the model doesn't attempt to conjure binary geometry out of its weights. It writes me a complete, self-contained JavaScript application that--when loaded into the Canvas iframe sandbox--constructs the mesh at runtime on my own hardware. The STL file I eventually download was never stored anywhere; it's a computation performed in my browser at the exact moment I click Export.

This is a fundamental paradigm shift from every other generative 3D approach, and the consequences ripple through everything downstream. What I get from Gemini is a bespoke CAD environment where the vase is a live mathematical function of a set of exposed parameters. The model I'm manipulating isn't a static mesh--it's something like r(z) = r₀ + A·sin(ωz + φ) evaluated across a vertical stack of layers, with the constants bound directly to slider values. When I drag the "twist angle" slider from 10° to 45°, the entire mesh regenerates in real time—not a pre-cached variation, but a fresh geometric construction. That's why the iteration speed feels qualitatively different from anything where I have to re-prompt or re-compile: the model is a function, and the slider positions are its inputs.

The key architectural consequence I want to bank here: every export is a re-computation, not a retrieval. There's no persistent source of truth beyond the current state of my parameter bindings. The mesh I slice today exists because I generated it just now, from the exact values sitting in those HTML range inputs. If I close the tab, the vase itself is gone—only the code that can recreate it remains. That's both the strength (a truly parametric object, infinitely tunable before download) and the operational caution (I must treat the .STL as a snapshot, not a reference). The parameter state is the artifact; the STL is just its latest manifestation.

flowchart LR
    A[User prompt in Gemini Chat] --> B[LLM generates JS/HTML app]
    B --> C[Canvas iframe sandbox loads]
    C --> D[Three.js runtime]
    D --> E[Parameter bindings from slider values]
    E --> F[Parametric geometry computed]
    F --> G[Mesh rendered to WebGL]
    G --> H[STLExporter serializes triangle mesh]
    H --> I[Blob URL triggers download]
    E --> J[OpenSCAD script output]
    J --> K[User compiles locally]
    G --> J

The Geometry Engine: Lathe Surfaces and the Wall Thickness Problem

The actual mesh construction is where this workflow gets its character, and it's the first place I'd look when something produces a print that slices incorrectly. Three.js offers two paths. The first, THREE.LatheGeometry, is the easy one: I hand it a profile curve—a 2D outline in the XZ plane—and it revolves that profile around the Y axis to create a solid of revolution. For anything symmetrical, it's the right tool. The second path, the custom THREE.BufferGeometry, is what I reach for when the shape is genuinely algorithmic: a vertical stack of layers where each ring's radius follows r(z) = r₀ + A·sin(ωz + φ), with the frequency ω and amplitude A controlling rib patterns and fluting. Either way, what I'm building is a vertex array, not a solid.

And here's the critical failure that I've seen trip up just about everyone who starts with this approach: LatheGeometry generates a surface, not a solid. When you revolve a profile along the axis, you get a thin, zero-thickness shell hovering in space. It renders beautifully in WebGL—the lighting makes it look substantial, and the slicer preview might even look fine—but when Cura or PrusaSlicer opens the resulting STL, it reports non-manifold edges and completely ignores any notion of interior vs. exterior. There's no volume there for infill to fill.

The fix requires an explicit second surface. I need the code to generate an outer profile r_out(z) and a concentric inner shell r_in(z) = r_out(z) - t_wall, where t_wall is my wall thickness parameter (typically clamped to a meaningful minimum like 1.2 mm for a multi-perimeter print). Then I have to stitch the two shells together at the top and bottom: a rim cap at the mouth, an annulus at the base. That's a separate piece of geometry—a ring of quads connecting the outer edge to the inner edge at each end—and it's the part that closes the volume. Without it, even with inner and outer surfaces, the topology remains open.

What makes this especially tricky is that the STLExporter is blind to any of this. It doesn't know which triangle is an inside wall and which is an outside wall; it just traverses the face list, extracts the vertex coordinates and normals, and writes them into an ASCII or binary STL structure. The exporter has zero topological knowledge. It will happily serialize an open shell, a self-intersecting blob, or a mesh with inverted winding order—and only the slicer will discover the problem later. That's the fundamental mismatch at the heart of this workflow: Three.js thinks in surfaces and lighting-ready triangulations; the slicer thinks in closed volumes and manufacturable solids. Bridging those two requires the generated code to be explicitly manifold-aware, and that's a prompt-logic concern, not a rendering one.

Parametric Control and the Real-Time Feedback Loop

The interaction layer is where the whole concept either sings or stalls. The code that Gemini writes normally wires up HTML range inputs, or a small GUI library like dat.gui or lil-gui, to the geometry formulas I discussed above. Each slider maps to a variable—height, twistAngle, ribAmplitude, ribFrequency, wallThickness—and when a slider's value changes, the bound variable updates and the mesh regenerates. This is a genuinely tight loop, and it's the thing that makes the experience feel nothing at all like OpenSCAD. I'm not editing a script and recompiling to see what changed; I'm dragging a knob and watching a WebGL canvas redraw at 60 FPS.

The tradeoff I keep coming back to is interactive fidelity vs. mesh resolution. The slider update path is cheap as long as the geometry is coarse. But the moment I push the vertical steps parameter past a few thousand, and cascade that with a couple hundred radial segments, Three.js is suddenly managing millions of vertices, and the synchronous rebuild of the BufferGeometry vertex array can lock up the UI thread entirely. Then the WebGL context has to upload that new buffer, and a sufficiently aggressive config will simply crash the canvas context with CONTEXT_LOST_WEBGL. I've learned to think of resolution as the limiting constraint: it's a tradeoff between how smooth the printed vase looks and how silky the slider drag remains.

The contrast with OpenSCAD is instructive here, and it's the second thing that makes this workflow compelling. With OpenSCAD, changing a parameter means firing a recompile, waiting for the CSG engine to re-evaluate, and inspecting the new render. It's a batch loop. With the Gemini Canvas approach, the initial LLM generation consumes tokens and API credits, but every subsequent slider move runs entirely locally on my hardware. I can spend an hour exploring twist angles and rib frequencies at 60 FPS without spending a single additional token. That's a fundamentally different economics: the model is a fixed-cost asset I've been handed, and the exploration is free after that. For iterative design, that's an enormous advantage over any service that re-prompts per variation.

sequenceDiagram
    participant U as User
    participant I as HTML Input
    participant A as JavaScript App
    participant G as Three.js Geometry
    participant R as Render Loop
    participant E as STLExporter
    participant B as Browser

    U->>I: Moves slider
    I->>A: Input event fires
    A->>A: Bound variable updates
    A->>G: Recompute LatheGeometry vertex array
    G->>R: Update WebGL buffer
    R->>R: Draw new frame
    U->>A: Clicks Export
    A->>E: Traverses current mesh state
    E->>B: Blob URL triggers download

Sandbox Reality: Downloads, CDN Imports, and Silent Failures

The first time you click "Export STL" and nothing happens, you'll know you've hit the sandbox wall. The Canvas iframe isn't a normal browser tab—it's a constrained execution environment, and the constraints have a way of revealing themselves as maddening silence rather than helpful errors.

The download mechanism itself is the classic failure point. The generated code typically constructs a Blob from the serialized STL data, creates a URL from it, then synthesizes an anchor element with a download attribute and programmatically clicks it. That's a perfectly standard pattern in a regular browser context. Inside a sandboxed iframe without the allow-downloads permission attribute, that click simply does nothing. Not an error, not a console warning—just a download that never starts. If the sandbox also restricts popups, any fallback that opens a new tab or window will be silently swallowed too. The pattern I've learned to watch for: the mesh renders, the sliders work, the export button is clickable, and the button press is entirely inert.

CDN imports are the second silent killer. The generated code often pulls Three.js and STLExporter from unpkg, cdnjs, or jsDelivr via ES module import statements. This works fine when the sandbox allows external script fetching, but strict Content Security Policies—particularly in corporate Workspace tiers—can block unvetted domains outright. The result is a blank canvas: the app loads, the HTML shell renders, but the Three.js runtime never initializes because the module specifier fails to resolve. In many cases, you'll see nothing at all in the console, just an iframe with empty WebGL content.

Failure Mode Gemini Canvas Claude Artifacts ChatGPT Code Interpreter
Blob URL download interception Missing allow-downloads attribute blocks anchor click silently Same iframe header restrictions; occasional block of synthetic blob downloads Server-side Python generates file; sandbox sometimes blocks direct download links
CDN module import failures CSP may block unpkg/cdnjs/jsDelivr; blank screen on module resolution error More mature ESM/CDN support but still can fail on unvetted external scripts N/A—executes server-side, no browser module resolution
Sandbox permission restrictions allow-downloads and allow-popups required; missing attributes cause silent failures Similar iframe constraints; header strictness varies Python sandbox isolation limits file access but handles downloads via response headers

The pragmatic workaround is to design for failure from the start. When I prompt for the export functionality, I ask for a fallback path: if the Blob download is blocked, the app should present a modal showing the Base64-encoded STL data URL with a copy button, so I can paste it into a local file. Better still, I ask for a self-contained inline module—no external CDN imports at all. Inlining Three.js and the exporter directly into the HTML means no module resolution to fail. It's a larger file, and the initial generation takes longer, but the resulting app is far more robust in constrained contexts.

The deeper lesson: the sandbox isn't the same as a browser, and the failure modes aren't the same as a browser. Testing your export path in a normal tab proves nothing about whether it'll work inside the iframe. I've learned to treat the download as a feature that needs an explicit, user-visible fallback, not something I assume will work by default.

Why the Exporter Triangle-Nerfs Your Design

Here's the uncomfortable truth that most people discover after their first successful export: the STL you just downloaded is a lie about your vase's geometry. Not a malicious lie—just a reduction, one that's baked into the format itself.

STL is fundamentally a triangle soup. Every face in the mesh is described independently, as its own set of three vertices and a normal vector. There is no shared vertex data, no adjacency information, no topology. The exporter walks through the triangle list of the Three.js mesh, extracts the coordinates and normals it finds, and writes them into a binary or ASCII structure. The vase that looked like a perfect, smooth solid of revolution in the WebGL preview—rendered with interpolation and lighting that sold the illusion of curvature—is frozen in the STL as a polygonal approximation. Every curve is faceted. Every smooth transition is a series of flat planes. The elegant r(z) = r₀ + A·sin(ωz + φ) that you tuned with sliders has been sampled into discrete triangles and locked there.

The reason this matters is that your eyes were seeing something the file doesn't contain. Three.js renders with vertex- and fragment-level interpolation, which means the shading smooths over the underlying triangulation. You see a vase that looks like a continuous surface. The STL contains only what the exporter found in the vertex buffer: a finite number of triangles approximating that surface. The finer your subdivision parameters, the less visible the faceting—but the file size grows quadratically, and the slider-lockup risk I discussed earlier looms larger.

Compare this to STEP and B-Rep formats, which are the standard for actual CAD. A STEP file doesn't store triangle coordinates. It stores exact curve definitions—the analytic equations for every arc, spline, and surface in the model. The vase's profile curve is saved as a precise mathematical function, not a sampling of it. It also carries volumetric semantics: the model knows which surfaces bound a solid region, knows about walls and cavities explicitly, and can expose tolerance information for manufacturing. When you import a STEP file into a slicer or CAD program, it can reconstruct the exact geometry at any resolution, because the underlying math is preserved.

So when I evaluate whether this workflow is right for a given object, I ask one question: do I care about the exact shape or just the approximate shape? For a decorative vase, the faceting is irrelevant. At a fine enough mesh resolution, the printed object will look correct. But if the part is meant to fit a pipe, mate with a bracket, or sit under a specific load, the STL's triangle approximation introduces error I can't afford. The wall thickness, the diameter at a joint, the flatness of a mating face—all of these become approximations in STL, and the approximations compound with every parameter I tune. For that class of parts, the correct tool is prompt-to-openSCAD or CadQuery, not the Three.js exporter, because the scripting approach produces real solids with exact math behind them.

Against Neural 3D, Text-to-CAD, and the Customizer Template Stack

To understand what this workflow actually is, I have to place it in the landscape of competitors, because each one solves a fundamentally different problem.

Meshy, Tripo3D, and Luma Genie represent the neural 3D path: diffusion models and SDF networks that synthesize textured polygon meshes directly from natural language prompts. Their strength is organic complexity—characters, creatures, sculptures that don't conform to any mathematical description. Ask one for "a vase shaped like a twisted root system" and it'll produce something visually striking. But there's no parameter to control. I can't slide a value to change wall thickness by 1.2 mm, or specify exactly twelve helical ribs. The output is a one-shot generation with fixed geometry. If I need to iterate on a precise dimension, I'm re-prompting and hoping, not adjusting. For 3D printing, this is a non-starter for functional parts: the meshes come with messy triangulations, non-manifold edges, and cavities that require repair before slicing.

Zoo Text-to-CAD and the KittyCAD approach sit at the opposite extreme. These systems convert natural language directly into industrial B-Rep models—STEP files with exact curves, tolerance information, and true solid geometry. The CSG operations guarantee watertight, printable solids. The downside is the runtime: I need a dedicated CAD kernel or compiler, and the output doesn't come with an instant slider GUI. It's a prompt-and-compile workflow, not a prompt-and-interact one. For manufacturing-grade fits and mechanical precision, this is the right tool. But it lacks the immediate visual feedback loop that makes the Gemini Canvas approach feel like design exploration rather than batch processing.

MakerWorld Parametric Model Maker and Thingiverse Customizer represent a third path: pre-authored parametric templates. These are human-validated, manifold-safe OpenSCAD or JavaScript generators with standardized parameters and direct slicer integration. The reliability is excellent—these templates are guaranteed to print. But they're constrained. I can't generate a novel topology from scratch. I'm not designing a vase; I'm configuring someone else's vase design within the ranges that designer chose to expose. The generative freedom of the LLM writing novel math doesn't exist here.

So where does the Gemini Canvas approach land? It occupies a very specific niche: a zero-install, interactive, algorithmically-driven shape generator. The model writes me a complete, self-contained web app that I can tune with sliders in real time, with no CAD software installed, no OpenSCAD compilation step, and no external platform required. That's a capabilities envelope none of the other approaches touch—the ability to say "make me a vase with helical ribs, and let me drag a slider to change how many ribs there are" and get a live, tunable 3D model in seconds.

But I need to be honest about what it isn't. This is a design sketchpad, not a manufacturing tool. The mesh generation is surfaces that need explicit prompting to become solids. The STL export is a triangle approximation of the actual mathematical shape, not a B-Rep representation. And there's no tolerance information, no exact curve preservation, no mechanical validation. If I'm exploring form factors, iterating on aesthetic parameters, or generating a one-off decorative piece, this is unmatched. If I'm building a part that will interface with other machined components, I'd reach for the text-to-CAD path instead. The workflow's power is in the conceptual exploration stage—when I'm deciding what shape to make, not when I'm verifying that the shape will function.

The Manifold Problem and What Slicers Actually Need

The moment you import a Gemini-generated STL into Cura or PrusaSlicer, you enter a different verification regime than the one you were operating in. In the Canvas iframe, you were checking visual plausibility: does this look like a vase, does the twist angle produce something I'd want on a shelf? The slicer doesn't care about any of that. It asks one question—is this a closed volume?—and if the answer is no, it has nothing to say to you. There's no error message that tells you "your mesh is open at the bottom." There's just a model that slices into nothing, or slices into a bizarre thin-walled ghost that collapses under its own weight.

The failure manifests in several distinct ways, and identifying which one you're dealing with requires looking at the mesh, not the render. Open edges are the most common: the LatheGeometry never got capped, so the top and bottom rims are simply absent. The slicer sees a tube with no ends and treats it as an infinitely thin shell. Holes are a related variant—partial gaps in the surface where the procedural index buffer skipped a face. And inverted normals are the nastiest of the three: the geometry is closed, but the winding order says the inside is outside. The slicer tries to compute infill and gets a meaningless result because its notion of interior vs. exterior is inverted.

Vase Mode exists as a workaround for the zero-thickness shell problem. When you enable spiralize outer contour in a slicer, it ignores the mesh's lack of volume entirely and traces a single continuous perimeter from bottom to top, extruding exactly one wall thickness. It's elegant, and for decorative vases it produces perfectly good prints. But it's a mode, not a fix. You can't get infill with it. You can't get a solid base with it unless the geometry already has a closed bottom. And it constrains you to single-perimeter shells, which limits structural integrity.

The overhang question is equally insidious because it's invisible in the browser. When your fluid-flow equation produces an inward-curving profile that exceeds 45 degrees from vertical, the slicer will need supports for that region. You won't see any indication of this in WebGL—the render looks fine. The resolution is to treat the slicer as a separate verification step, not a downstream formality. Clamp your sliders so the geometry can't produce unprintable overhangs, or accept that you'll add supports. But you need to know which one you're accepting.

The 1 Unit = 1 mm Assumption Nobody Tells You About

There's a failure mode in this workflow that never appears in the browser, never shows up in the console, and only reveals itself when you look at the dimensions of your imported model in the slicer. The issue is that Three.js coordinates are unitless floats—they're just numbers in a 3D vector space, with no notion of what physical entity they represent. Meanwhile, the STL format contains zero metadata about units. The specification is silent on whether 1.0 means one millimeter, one centimeter, or one parsec. Slicers universally assume that 1.0 unit equals 1.0 millimeter. That's a convention baked into every slicer on the market, and it's the only thing making your model physically interpretable.

The problem arises because Gemini-generated code frequently normalizes coordinates for display convenience. The developer—or the LLM acting as one—thinks in viewport space. The vase needs to fit on screen, so the diameter gets set to something like 2.0 units and the height to 5.0 units. That's no problem for the WebGL render; it looks like a perfectly proportioned vase. But when the STLExporter serializes those exact numbers, the slicer reads a vase that's 2 millimeters in diameter and 5 millimeters tall. You just made a vase the size of a grain of rice. Conversely, if the code doesn't normalize and lets values run large, you could import a model that's 100 meters tall—which will either be silently scaled by a slicer's auto-fit function, or produce an absurdly thin wall if you don't catch it.

This is a design failure, not a rendering one, and it's completely invisible until the model is on the print bed. The fix is to bake the convention into the generation prompt: explicitly specify that 1 unit = 1 mm, clamp the default height to something within standard FDM limits (250 mm is a safe ceiling for most consumer machines), clamp diameter to 180 mm for the same reason, and ensure the wall thickness parameter has a floor of 1.2 mm so the slicer's multi-perimeter settings actually have material to work with. These aren't nice-to-have constraints; they're the difference between a model that prints correctly and a model that imports at the wrong scale and produces a part you can't use.

Where You'd Use This: Three Project Ideas to Skip the CAD Learning Curve

The real utility of this workflow becomes obvious when you think about specific objects you'd want to generate and tune without installing parametric CAD software or learning OpenSCAD. I've got three concrete builds in mind that make the stack's strengths apparent—and each one has a specific gotcha I'd want you to design around from the start.

The Geometric Fluid Vase Generator is the most direct application of the parametric approach. The idea is a Canvas app where the vase's wall profile isn't an arbitrary sine wave but something derived from a fluid-flow equation. You take a single control—fluid viscosity, simulated as a flow rate function—and generate r(z) from the resulting curve. The sliders control material height, drain angle, and internal cavity diameter. The math uses LatheGeometry or a custom BufferGeometry built directly from the fluid equation. The design constraint you need to clamp: the fluid curve will naturally produce undercuts where the surface curves inward, and those become overhangs exceeding 45 degrees. If you don't cap the drain angle parameter, the slicer will demand supports that ruin the print. You also need inner and outer shells with a capped bottom, not an open-ended LatheGeometry. The export path uses STLExporter.parse(mesh, { binary: true }) with inline imports for the exporter addon.

The STL Repair Scaffold for Generated Meshes is a different kind of tool—a diagnostic, not a generator. You'd build a Canvas app that imports an STL file via FileReader, checks for manifoldness by counting open edges using a vertex-to-face adjacency map, and renders a visual heatmap over the geometry showing which regions are non-watertight. This is especially useful when you're working with neural 3D outputs that come with messy triangulations. The app should report issues, not silently patch them. The danger is letting it auto-close holes: inverted normals look perfectly fine in a WebGL render but break slicer infill, and an auto-repair that fixes the topology while flipping the orientation produces a part that slices catastrophically. Keep it as a verification tool, and export a patched version only when you can verify winding order after every repair.

The Portable Modular Building Block System shows the workflow's reach beyond vessels—it's the one I'd use if I wanted to design functional, mateable parts. The generator produces a parametric connector piece with prisms and cylinders, using a consistent wall thickness model and a fixed 1.2mm clearance offset encoded as a parameter. You'd use THREE.CylinderGeometry with manual additive meshing, not LatheGeometry, and clamp wall thickness to prevent walls below 1.2mm. The tricky part is the union operation: merging multiple prisms and cylinders in code requires exact vertex alignment, or the intersection becomes non-manifold. If the union math gets too fragile, generate the parts as separate STL exports and let the slicer's assembly tools handle the merge. The fixed unit scale—1 unit = 1 mm—is non-negotiable here because the parts need to actually fit together when printed, not just look correct on a screen.

Resources

Updated 2026-09-15 by Mehran Mozaffari.

Related posts