rensei
v0.3.0
Published
3D model rendering and JSCAD evaluation toolkit for AI agents — screenshot STL/JSCAD scripts from multiple angles using Three.js WebGPU in Node.js
Downloads
611
Maintainers
Readme
Agent Skill
This package ships a skill file that teaches AI coding agents how and when to use it. Install it with:
npx -y skills add remorses/renseiAgent Workflow
rensei enables AI agents to generate 3D models via an iterative feedback loop:
- Decompose the reference into a short spec: overview, envelope, feature tree, uncertainties
- Confirm ambiguous features with the user before writing code
- Write a JSCAD
.tsscript usingrensei/modelingexports - Screenshot with
rensei screenshot model.ts --view all --output views.png - Review silhouette, proportions, feature count, polarity, symmetry, and printability
- Update the
.tsscript to fix shape/dimension differences - Repeat until model matches from all orthogonal views (front, back, left, right, top, bottom, iso)
The key idea is simple: spec first, geometry second. Most bad CAD generations come from misreading the reference, not from weak JSCAD operations.
See docs/image-to-jscad-workflow.md for the full workflow.
Example: Water Filter Funnel (from reference photos)
This model was built entirely by an AI agent using the iterative screenshot workflow above. Starting from 6 reference photos of a metal water filter part, the agent:
- Analyzed the photos to understand the part's function (funnel redirecting water from wide opening to narrow nozzle)
- Identified which features were functional vs manufacturing artifacts (concentric machining rings, decorative grooves — all stripped)
- Built a simplified thin-walled conical funnel using
extrudeRotatewith a 2D profile polygon - Iterated through ~5 revisions comparing renders to photos from matching angles
- Optimized for 3D printing: minimal wall thickness, no supports needed, correct orientation for pressure resistance
Input: 6 photos of a metal part → Output: print-ready STL

Source: examples/src/water-filter.ts
Example: Mounting Plate (from a confirmed feature tree)
This example shows the same agent workflow adapted to JSCAD. Instead of copying a feature CAD tree literally, it turns the confirmed spec into a few clear boolean steps:
- Build the base plate from a rounded rectangle and
extrudeLinear - Subtract a center pocket as another rounded rectangle extrusion
- Subtract four corner holes from an array of circles
- Add standoffs as cylinders positioned from the same named dimensions
Source: examples/src/mounting-plate.ts
CLI Commands
# Screenshot a JSCAD script from all angles into one grid image
rensei screenshot model.ts --view all --output views.png
# Screenshot a single view
rensei screenshot model.ts --view iso --output render.png
# Screenshot with custom camera angle
rensei screenshot model.ts --azimuth 45 --elevation 30 --output render.png
# Convert JSCAD script to STL
rensei stl model.ts --output model.stl
# Screenshot an existing STL file
rensei screenshot model.stl --view front --output front.png--output is always a single PNG path. When you request multiple views, rensei composes them into one grid image.
Options: --size (default 1500), --zoom, --color, --background
Advanced: rensei weight <file> is documented later in the 3D printing section.
JSCAD via rensei/modeling
rensei uses JSCAD (@jscad/modeling) for CSG operations. The rensei/modeling export re-exports all JSCAD APIs as flat named exports so scripts don't need @jscad/modeling installed separately.
import { cube, sphere, subtract, union, translate } from 'rensei/modeling'Core Concepts
JSCAD models are built by creating primitive shapes, transforming them (move/rotate/scale), and combining them with boolean operations (union/subtract/intersect). Everything is immutable — operations return new geometry, never mutate.
There are 3 geometry types that flow through the entire API:
Path2— open or closed 2D path (line segments). Created byline(),arc().Geom2— closed 2D shape with area (filled polygon). Created bycircle(),rectangle(),polygon(), etc.Geom3— 3D solid mesh. Created bycube(),sphere(),cylinder(), or by extruding 2D shapes.
The general workflow: write a spec → map features to primitives → transform → combine with booleans → render → iterate → export.
Every JSCAD file exports a main() function that returns geometry or an array of geometries:
import { cube } from 'rensei/modeling'
export function main() {
return cube({ size: 10 })
}Setup and Imports
import {
// Primitives
cube, cuboid, sphere, cylinder, cylinderElliptic, ellipsoid,
geodesicSphere, roundedCuboid, roundedCylinder, torus, polyhedron,
circle, ellipse, square, rectangle, roundedRectangle, polygon,
triangle, star, line, arc,
// Booleans
union, subtract, intersect, scission,
// Transforms
translate, translateX, translateY, translateZ,
rotate, rotateX, rotateY, rotateZ,
scale, scaleX, scaleY, scaleZ,
mirror, mirrorX, mirrorY, mirrorZ,
center, centerX, centerY, centerZ, align,
// Extrusions
extrudeLinear, extrudeRotate, extrudeRectangular, extrudeHelical,
extrudeFromSlices, project, slice,
// Hulls
hull, hullChain,
// Expansions
expand, offset,
// Colors
colorize, colorNameToRgb, hexToRgb, hslToRgb, hsvToRgb,
// Measurements
measureBoundingBox, measureBoundingSphere, measureCenter,
measureCenterOfMass, measureDimensions, measureArea,
measureVolume, measureAggregateArea, measureAggregateBoundingBox,
measureAggregateVolume,
// Modifiers
generalize, snap, retessellate,
// Text
vectorText, vectorChar,
// Curves
bezier,
// Math
mat4, vec2, vec3,
// Geometries
geom2, geom3, path2,
} from 'rensei/modeling'All angles in JSCAD are in radians. Use Math.PI / 180 * degrees to convert.
3D Primitives (return Geom3)
cube
Equal-sided box centered at origin.
cube() // 2x2x2 at origin
cube({ size: 10 }) // 10x10x10
cube({ size: 5, center: [0, 0, 2.5] })Options: { center?: [x,y,z], size?: number }
cuboid
Box with different dimensions per axis.
cuboid({ size: [10, 20, 5] }) // 10 wide, 20 deep, 5 tall
cuboid({ size: [4, 4, 1], center: [0, 0, 0.5] })Options: { center?: [x,y,z], size?: [x,y,z] }
sphere
sphere() // radius 1, 32 segments
sphere({ radius: 5, segments: 64 })
sphere({ radius: 3, center: [10, 0, 0] })Options: { center?: [x,y,z], radius?: number, segments?: number, axes?: [x,y,z] }
geodesicSphere
Icosahedron-based sphere with more uniform triangle distribution.
geodesicSphere({ radius: 5, frequency: 6 })Options: { radius?: number, frequency?: number }
ellipsoid
3D ellipsoid with independent radii per axis.
ellipsoid({ radius: [5, 10, 3] }) // egg-like squashed shapeOptions: { center?: [x,y,z], radius?: [rx,ry,rz], segments?: number, axes?: [x,y,z] }
cylinder
cylinder({ height: 10, radius: 3 })
cylinder({ height: 20, radius: 5, segments: 6 }) // hexagonal prism
cylinder({ height: 10, radius: 3, center: [0, 0, 5] }) // bottom at z=0Options: { center?: [x,y,z], height?: number, radius?: number, segments?: number }
cylinderElliptic
Cylinder with different elliptical cross-sections at top and bottom. Use for cones and tapered shapes.
// Cone (tapers to near-point)
cylinderElliptic({ height: 10, startRadius: [5, 5], endRadius: [0.01, 0.01] })
// Truncated cone
cylinderElliptic({ height: 10, startRadius: [5, 5], endRadius: [2, 2] })
// Oval cross-section
cylinderElliptic({ height: 10, startRadius: [5, 3], endRadius: [5, 3] })
// Partial arc (pie-wedge cylinder)
cylinderElliptic({
height: 5, startRadius: [5, 5], endRadius: [5, 5],
startAngle: 0, endAngle: Math.PI
})Options: { center?, height?, startRadius?: [rx,ry], endRadius?: [rx,ry], startAngle?, endAngle?, segments? }
roundedCuboid
Box with rounded edges and corners.
roundedCuboid({ size: [10, 10, 5], roundRadius: 1, segments: 32 })Options: { center?, size?: [x,y,z], roundRadius?: number, segments?: number }
roundedCylinder
Cylinder with rounded (hemispherical) caps.
roundedCylinder({ height: 10, radius: 3, roundRadius: 1, segments: 32 })Options: { center?, height?, radius?, roundRadius?, segments? }
torus
Donut shape. innerRadius = tube radius, outerRadius = center-to-tube-center distance.
torus({ innerRadius: 1, outerRadius: 5 })
torus({
innerRadius: 2, outerRadius: 8,
innerSegments: 16, outerSegments: 64,
startAngle: 0, outerRotation: Math.PI * 2
})Options: { innerRadius?, outerRadius?, innerSegments?, outerSegments?, innerRotation?, outerRotation?, startAngle? }
polyhedron
Arbitrary 3D solid from vertices and face indices. Face vertices must be ordered consistently (default outward-facing CCW).
// Tetrahedron
polyhedron({
points: [[0,0,0], [10,0,0], [5,10,0], [5,5,10]],
faces: [[0,1,2], [0,3,1], [1,3,2], [0,2,3]],
orientation: 'outward'
})
// Colored faces
polyhedron({
points: [[0,0,0], [10,0,0], [5,10,0], [5,5,10]],
faces: [[0,1,2], [0,3,1], [1,3,2], [0,2,3]],
colors: [[1,0,0], [0,1,0], [0,0,1], [1,1,0]]
})Options: { points: Vec3[], faces: number[][], colors?: (RGB|RGBA)[], orientation?: 'outward'|'inward' }
2D Primitives (return Geom2)
circle
Filled 2D disc. Use startAngle/endAngle for pie slices.
circle({ radius: 5 })
circle({ radius: 10, segments: 64 })
circle({ radius: 5, startAngle: 0, endAngle: Math.PI }) // half discOptions: { center?: [x,y], radius?, startAngle?, endAngle?, segments? }
ellipse
2D ellipse with different radii.
ellipse({ radius: [10, 5] })Options: { center?: [x,y], radius?: [rx,ry], startAngle?, endAngle?, segments? }
square
Equal-sided 2D rectangle.
square({ size: 10 })Options: { center?: [x,y], size?: number }
rectangle
rectangle({ size: [20, 10] })
rectangle({ size: [5, 5], center: [10, 0] })Options: { center?: [x,y], size?: [w,h] }
roundedRectangle
roundedRectangle({ size: [20, 10], roundRadius: 2, segments: 16 })Options: { center?: [x,y], size?: [w,h], roundRadius?, segments? }
polygon
Arbitrary 2D polygon from points. Supports holes via nested point arrays + paths.
// Simple polygon
polygon({ points: [[0,0], [10,0], [10,10], [5,12], [0,10]] })
// Polygon with a hole (outer CCW, inner CW via path winding)
polygon({
points: [
[0,0], [20,0], [20,20], [0,20], // outer boundary (indices 0-3)
[5,5], [15,5], [15,15], [5,15] // inner hole (indices 4-7)
],
paths: [[0,1,2,3], [7,6,5,4]] // outer CCW, hole CW
})
// Multiple holes
polygon({
points: [
[0,0],[30,0],[30,30],[0,30], // outer
[3,3],[7,3],[7,7],[3,7], // hole 1
[13,13],[17,13],[17,17],[13,17] // hole 2
],
paths: [[0,1,2,3], [7,6,5,4], [11,10,9,8]]
})Options: { points: Vec2[] | Vec2[][], paths?: number[] | number[][], orientation?: 'counterclockwise'|'clockwise' }
triangle
Create by specifying angle/side combinations.
triangle({ type: 'SSS', values: [3, 4, 5] }) // right triangle
triangle({ type: 'SAS', values: [5, Math.PI / 3, 5] }) // equilateral-ishTypes: 'AAA', 'AAS', 'ASA', 'SAS', 'SSA', 'SSS'
star
star({ vertices: 5, outerRadius: 10, innerRadius: 5 })
star({ vertices: 8, outerRadius: 15, innerRadius: 7, startAngle: 0 })Options: { center?, vertices?, density?, outerRadius?, innerRadius?, startAngle? }
Path Primitives (return Path2)
line
Open 2D path through given points.
line([[0, 0], [5, 5], [10, 0]])
line([[0, 0], [0, 5], [2, 8], [5, 9]])arc
Circular arc as open 2D path.
arc({ radius: 10, startAngle: 0, endAngle: Math.PI / 2, segments: 32 })
arc({ radius: 5, endAngle: Math.PI, makeTangent: true })Options: { center?, radius?, startAngle?, endAngle?, segments?, makeTangent? }
Boolean Operations
All booleans work on both Geom2 and Geom3. Accept variadic args or arrays.
union — merge/add shapes
union(cube(), sphere({ center: [1, 0, 0] }))
union([partA, partB, partC]) // array formsubtract — cut/difference (MAKES HOLES)
First argument minus all subsequent. This is the primary way to create holes.
import { subtract, cuboid, cylinder } from 'rensei/modeling'
// Drill a hole through a block
subtract(
cuboid({ size: [20, 20, 5] }),
cylinder({ height: 10, radius: 3 })
)
// Multiple holes at once
subtract(plate, hole1, hole2, hole3)
subtract(plate, ...arrayOfHoles)intersect — keep only overlap
intersect(
cube({ size: 10 }),
sphere({ radius: 7 })
)scission — split disconnected pieces
Splits a geometry into separate unconnected solids. Returns Geom3[].
const pieces = scission(myComplexGeom)Transforms
All transforms are immutable. Accept single geometry or variadic/array. Every transform has per-axis shortcuts.
translate
translate([10, 0, 5], myCube)
translateX(10, myGeom)
translateY(-5, myGeom)
translateZ(20, myGeom)
// Apply to multiple geometries
translate([5, 0, 0], partA, partB, partC)rotate
Angles in radians. [rx, ry, rz] for compound rotation.
rotate([0, 0, Math.PI / 4], myCube) // 45 deg around Z
rotateX(Math.PI / 2, myCylinder) // 90 deg around X
rotateY(Math.PI, myGeom) // 180 deg around Y
rotate([Math.PI / 6, Math.PI / 4, 0], geom) // compound XY rotationscale
scale([2, 1, 0.5], myCube) // stretch X 2x, squash Z to half
scaleX(3, myGeom)
scaleY(0.5, myGeom)mirror
Reflect across a plane. Shortcuts mirror across coordinate planes.
mirrorX(myCube) // reflect across YZ plane (flip X)
mirrorY(myGeom) // reflect across XZ plane (flip Y)
mirrorZ(myGeom) // reflect across XY plane (flip Z)
mirror({ origin: [0,0,0], normal: [1,1,0] }, geom) // custom planecenter
Center geometry on specified axes.
center({ axes: [true, true, false] }, myGeom) // center X and Y, leave Z
centerX(myGeom)
centerY(myGeom)
centerZ(myGeom)
center({ axes: [true, true, true], relativeTo: [0, 0, 5] }, geom)align
Align geometry by min/max/center per axis.
align({ modes: ['min', 'center', 'none'] }, myGeom)
align({ modes: ['center', 'center', 'min'], relativeTo: [0, 0, 0] }, geom)Modes: 'center', 'min', 'max', 'none'
Extrusions (2D → 3D)
extrudeLinear
Push a 2D shape straight up along Z axis. Optional twist.
import { extrudeLinear, rectangle, circle, star, polygon, line } from 'rensei/modeling'
// Simple box from rectangle
extrudeLinear({ height: 10 }, rectangle({ size: [20, 10] }))
// Cylinder from circle
extrudeLinear({ height: 15 }, circle({ radius: 5 }))
// Twisted star (drill-bit shape)
extrudeLinear(
{ height: 20, twistAngle: Math.PI / 2, twistSteps: 20 },
star({ vertices: 5, outerRadius: 5, innerRadius: 2 })
)
// Polygon with holes extruded to 3D
extrudeLinear({ height: 5 }, polygon({
points: [[0,0],[20,0],[20,20],[0,20], [5,5],[15,5],[15,15],[5,15]],
paths: [[0,1,2,3], [7,6,5,4]]
}))
// Can also extrude Path2 (creates a thin wall)
extrudeLinear({ height: 5 }, line([[0,0], [10,0], [10,10]]))Options: { height?: number, twistAngle?: number, twistSteps?: number }
extrudeRotate
Spin a 2D shape around the Y axis (lathe operation). Creates solids of revolution.
The 2D shape must be positioned at X > 0 (right side of Y axis). It sweeps around Y.
import { extrudeRotate, polygon, circle, star } from 'rensei/modeling'
// Full 360-degree vase profile
extrudeRotate(
{ segments: 64 },
polygon({ points: [[4,0],[5,0],[5,10],[4.5,12],[3,14],[3,15],[4,15]] })
)
// Torus (circle swept around Y axis)
extrudeRotate(
{ segments: 64 },
circle({ radius: 1, center: [5, 0] })
)
// Partial revolution with cap
extrudeRotate(
{ angle: Math.PI * 0.75, segments: 32, overflow: 'cap' },
star({ center: [3, 0] })
)Options: { angle?: number, startAngle?: number, overflow?: 'cap', segments?: number }
extrudeHelical
Spiral/helix extrusion. Sweeps a 2D shape in a helix around Z axis. Perfect for springs and threads.
import { extrudeHelical, circle } from 'rensei/modeling'
// Spring
extrudeHelical(
{ height: 20, pitch: 5, segmentsPerRotation: 32 },
circle({ radius: 0.5, center: [3, 0] })
)
// Coil with custom angle
extrudeHelical(
{ angle: Math.PI * 4, pitch: 3, segmentsPerRotation: 32 },
circle({ radius: 0.3, center: [5, 0] })
)Options: { angle?, startAngle?, pitch?, height?, endOffset?, segmentsPerRotation? }
extrudeRectangular
Extrude a path or 2D shape outline with a rectangular cross-section (like adding a pipe/rail around a path).
import { extrudeRectangular, line } from 'rensei/modeling'
const path = line([[0, 0], [0, 5], [2, 8], [5, 9]])
extrudeRectangular({ size: 1, height: 1 }, path)
// With rounded corners
extrudeRectangular({ size: 0.5, height: 2, corners: 'round', segments: 16 }, path)Options: { size?, height?, corners?: 'edge'|'chamfer'|'round', segments? }
extrudeFromSlices
The most powerful extrusion. Define each cross-section slice programmatically via a callback. The callback receives (progress, index, base) where progress goes from 0 to 1, and must return a slice.
import {
extrudeFromSlices, slice, circle, rectangle,
mat4, geom2
} from 'rensei/modeling'
// Square-to-circle morph
extrudeFromSlices({
numberOfSlices: 20,
callback: (progress, count, base) => {
const shape = circle({ radius: 2 + 5 * progress, segments: 4 + count * count })
let s = slice.fromSides(geom2.toSides(shape))
s = slice.transform(
mat4.fromTranslation(mat4.create(), [0, 0, progress * 10]),
s
)
return s
}
}, circle({ radius: 2, segments: 4 }))
// Jiggly tube with varying scale
extrudeFromSlices({
numberOfSlices: 32,
callback: (progress, count, base) => {
const scaleFactor = 1 + (0.03 * Math.cos(3 * Math.PI * progress))
const scaleMatrix = mat4.fromScaling(mat4.create(), [scaleFactor, 2 - scaleFactor, 1])
const translateMatrix = mat4.fromTranslation(mat4.create(), [0, 0, progress * 20])
return slice.transform(
mat4.multiply(mat4.create(), scaleMatrix, translateMatrix),
base
)
}
}, slice.fromSides(geom2.toSides(rectangle({ size: [10, 10] }))))
// Build from raw 3D points per slice (for threads, organic shapes)
extrudeFromSlices({
numberOfSlices: 50,
callback: (progress, index, base) => {
const points = []
for (let i = 0; i < 32; i++) {
const angle = Math.PI * 2 * i / 32
const r = 5 + Math.sin(progress * Math.PI * 4) * 2
points.push([r * Math.cos(angle), r * Math.sin(angle), progress * 20])
}
return slice.fromPoints(points)
}
}, {})Options: { numberOfSlices?, capStart?: boolean, capEnd?: boolean, close?: boolean, callback: (progress, index, base) => Slice }
Slice utilities (slice):
import { slice, mat4, geom2 } from 'rensei/modeling'
slice.fromPoints(points3D) // create slice from array of [x,y,z] points
slice.fromSides(sides) // create slice from geom2 sides: geom2.toSides(myGeom2)
slice.transform(mat4, aSlice) // apply 4x4 transformation matrix
slice.reverse(aSlice) // flip winding order
slice.clone(aSlice) // deep copy
slice.equals(sliceA, sliceB) // compareproject
Project 3D geometry onto a 2D plane. Returns Geom2. Useful for creating 2D profiles from 3D shapes.
import { project } from 'rensei/modeling'
const shadow = project({}, mySphere) // project onto XY plane
project({ axis: [0, 1, 0], origin: [0, 0, 0] }, myGeom) // onto XZ planeOptions: { axis?: [x,y,z], origin?: [x,y,z] }
Hull Operations
hull
Convex hull — smallest convex shape enclosing all inputs. Like shrink-wrapping with a flat surface.
import { hull, sphere, circle, translate, cuboid } from 'rensei/modeling'
// Smooth bridge between two spheres
hull(
sphere({ radius: 3, center: [0, 0, 0] }),
sphere({ radius: 2, center: [10, 0, 5] })
)
// Rounded rectangle from circles at corners
hull(
circle({ radius: 1, center: [-5, -3] }),
circle({ radius: 1, center: [5, -3] }),
circle({ radius: 1, center: [5, 3] }),
circle({ radius: 1, center: [-5, 3] })
)
// Hull of mixed 3D shapes
hull(
translate([10, 0, 5], sphere({ radius: 2 })),
translate([0, 10, -3], sphere({ radius: 5 })),
cuboid({ size: [15, 17, 2], center: [5, 5, -10] })
)hullChain
Hull each consecutive pair, then union. Creates a connected chain of convex segments — perfect for smooth connections and text rendering.
import { hullChain, sphere } from 'rensei/modeling'
// Snake-like tube through waypoints
hullChain(
sphere({ radius: 1, center: [0, 0, 0] }),
sphere({ radius: 1.5, center: [5, 3, 2] }),
sphere({ radius: 1, center: [10, 0, 5] }),
sphere({ radius: 2, center: [15, -3, 3] })
)
// Used extensively for text rendering (see Text section)Expansions (Grow/Shrink/Offset)
expand
Grow or shrink geometry by a uniform distance. Works on Path2, Geom2, and Geom3.
delta > 0→ expand outwarddelta < 0→ contract inward (Geom2 only)corners:'round','chamfer','edge'
import { expand, cuboid, cube, rectangle, line, extrudeLinear } from 'rensei/modeling'
// Round all edges of a cuboid
expand({ delta: 1, corners: 'round', segments: 32 }, cuboid({ size: [10, 8, 4] }))
// Chamfered edges
expand({ delta: 0.5, corners: 'chamfer' }, cube({ size: 10 }))
// Shrink a 2D shape (negative delta)
expand({ delta: -2, corners: 'round', segments: 8 }, rectangle({ size: [20, 20] }))
// Turn a path into a filled shape with thickness
expand({ delta: 1.5, corners: 'round', segments: 16 }, line([[0,0], [10,5], [20,0]]))
// Turn a path into a shape, then extrude for a 3D pipe
extrudeLinear(
{ height: 5 },
expand({ delta: 1, corners: 'round', segments: 16 }, line([[0,0], [5,5], [10,0]]))
)Options: { delta?: number, corners?: 'round'|'chamfer'|'edge', segments?: number }
offset
2D only — similar to expand but returns same geometry type. Positive grows, negative shrinks.
import { offset, circle, rectangle } from 'rensei/modeling'
offset({ delta: 2, corners: 'round', segments: 16 }, circle({ radius: 5 }))
offset({ delta: -1, corners: 'chamfer' }, rectangle({ size: [10, 10] }))Options: { delta?, corners?: 'edge'|'chamfer'|'round', segments? }
Colors
colorize
Apply RGBA color. Values 0 to 1. Alpha optional (defaults to 1). Returns a new object (immutable).
import { colorize, cube, sphere, cylinder } from 'rensei/modeling'
colorize([1, 0, 0], cube()) // red
colorize([0, 0.5, 1, 0.7], sphere()) // semi-transparent blue
colorize([0.2, 0.8, 0.2], cylinder()) // greenColor conversions
import { colorize, colorNameToRgb, hexToRgb, hslToRgb, hsvToRgb, cube, sphere, cylinder } from 'rensei/modeling'
colorize(colorNameToRgb('steelblue'), cube()) // CSS color name
colorize(hexToRgb('#ff6600'), sphere()) // hex string
colorize(hslToRgb([0.6, 1, 0.5]), cylinder()) // HSL → RGB
colorize(hsvToRgb([0.3, 0.8, 0.9]), cube()) // HSV → RGBAll CSS color names are supported via colorNameToRgb.
Per-part coloring
To color different parts independently, apply colorize to each part separately and return them as an array. Each part keeps its own color.
import { colorize, cuboid, cylinder, translate, subtract } from 'rensei/modeling'
export function main() {
const base = cuboid({ size: [20, 20, 5] })
const post = translate([0, 0, 7.5], cylinder({ radius: 3, height: 10 }))
const hole = translate([0, 0, 7.5], cylinder({ radius: 1.5, height: 11 }))
const postWithHole = subtract(post, hole)
return [
colorize([0.2, 0.2, 0.8], base), // blue base
colorize([0.9, 0.3, 0.1], postWithHole), // red post
]
}Important rules:
- Apply colors after all boolean/transform operations — booleans may strip colors from inputs
- Colors are best applied as the last step before returning
- STL format does not support per-part colors — everything exports as one color. For multi-color export use 3MF, OBJ, or glTF
Measurements
import {
cube, measureBoundingBox, measureDimensions, measureCenter,
measureCenterOfMass, measureVolume, measureArea, measureBoundingSphere,
measureAggregateBoundingBox, measureAggregateArea, measureAggregateVolume
} from 'rensei/modeling'
const box = cube({ size: 10 })
measureBoundingBox(box) // [[-5,-5,-5], [5,5,5]]
measureDimensions(box) // [10, 10, 10]
measureCenter(box) // [0, 0, 0]
measureCenterOfMass(box) // [0, 0, 0]
measureVolume(box) // 1000
measureArea(box) // 600
measureBoundingSphere(box) // [[cx,cy,cz], radius]
// Aggregate — across arrays of geometries
measureAggregateBoundingBox([partA, partB])
measureAggregateArea([partA, partB])
measureAggregateVolume([partA, partB])Modifiers
generalize
Clean up geometry before export. Can snap, simplify, and triangulate.
import { generalize } from 'rensei/modeling'
generalize({ snap: true, simplify: true, triangulate: true }, myGeom)snap
Snap vertices to grid to fix floating-point precision issues after complex boolean operations.
import { snap } from 'rensei/modeling'
snap(myGeom)retessellate
Re-tessellate coplanar polygons. Useful after booleans that create co-planar faces.
import { retessellate } from 'rensei/modeling'
retessellate(myGeom)Curves (Bezier)
import { bezier } from 'rensei/modeling'
// Quadratic bezier (3 control points)
const curve2D = bezier.create([[0, 0], [5, 10], [10, 0]])
// Cubic bezier (4 control points)
const curve3D = bezier.create([[0,0,0], [3,10,0], [7,10,0], [10,0,0]])
// Higher-order (5+ control points work too)
const curve5 = bezier.create([[0,0,0], [2,5,0], [5,8,3], [8,5,0], [10,0,0]])
bezier.valueAt(0.5, curve3D) // [x,y,z] point at t=0.5
bezier.tangentAt(0.5, curve3D) // tangent vector at t=0.5
bezier.length(curve3D) // total arc length
bezier.lengths(10, curve3D) // array of lengths at 10 intervals
bezier.arcLengthToT({}, curve3D) // convert arc length to t parameterText
JSCAD text uses vector fonts — text is rendered as line segments, not filled shapes. You need to convert the segments into filled geometry using hull operations.
import {
vectorText, hullChain, union, extrudeLinear,
circle, sphere, translate
} from 'rensei/modeling'
// vectorText() returns arrays of line segment point-pairs
const segments = vectorText({ input: 'Hello', height: 10 })
// --- 2D Outline Text ---
const lineWidth = 2
const lineCorner = circle({ radius: lineWidth / 2 })
const shapes2D = segments.map(segmentPoints => {
const corners = segmentPoints.map(pt => translate(pt, lineCorner))
return hullChain(corners)
})
const text2D = union(shapes2D)
// --- 3D Flat Text (extruded) ---
const text3D = extrudeLinear({ height: 3 }, text2D)
// --- 3D Round Text (spherical stroke) ---
const lineCorner3D = sphere({ radius: 1, center: [0, 0, 1], segments: 16 })
const roundSegments = segments.map(segmentPoints => {
const corners = segmentPoints.map(pt => translate(pt, lineCorner3D))
return hullChain(corners)
})
const textRound = union(roundSegments)Options for vectorText: { xOffset?, yOffset?, height?, lineSpacing?, letterSpacing?, align?: 'left'|'center'|'right', input?: string }
Math Utilities
Used primarily with extrudeFromSlices and slice.transform.
import { mat4, vec3 } from 'rensei/modeling'
// Create identity matrix
mat4.create()
// Translation matrix
mat4.fromTranslation(mat4.create(), [x, y, z])
// Scaling matrix
mat4.fromScaling(mat4.create(), [sx, sy, sz])
// Rotation matrices
mat4.fromXRotation(mat4.create(), angleRadians)
mat4.fromYRotation(mat4.create(), angleRadians)
mat4.fromZRotation(mat4.create(), angleRadians)
// Multiply matrices (apply in sequence)
const combined = mat4.multiply(mat4.create(), matA, matB)
// Vec3 operations
vec3.create() // [0, 0, 0]
vec3.clone([1, 2, 3])
vec3.normalize(vec3.create(), [1, 2, 3])
vec3.cross(vec3.create(), vecA, vecB)
vec3.dot(vecA, vecB)
// Degrees to radians helper
const degToRad = (deg: number) => deg * Math.PI / 180Common Recipes
Making Holes (subtract cylinders)
import { cuboid, cylinder, cylinderElliptic, subtract, union } from 'rensei/modeling'
// Single hole through a plate
const plate = cuboid({ size: [30, 30, 3] })
const hole = cylinder({ height: 10, radius: 3 })
subtract(plate, hole)
// Multiple mounting holes
const mountingHoles = [[-10,-10], [10,-10], [10,10], [-10,10]].map(([x,y]) =>
cylinder({ height: 10, radius: 2, center: [x, y, 0] })
)
subtract(plate, ...mountingHoles)
// Countersunk hole
const shaft = cylinder({ height: 20, radius: 2 })
const countersink = cylinderElliptic({
height: 2, startRadius: [4, 4], endRadius: [2, 2], center: [0, 0, 1.5]
})
subtract(plate, union(shaft, countersink))Hollow Shell / Wall Thickness
import { cube, cuboid, cylinder, subtract } from 'rensei/modeling'
// Hollow box (2mm wall thickness)
const outer = cube({ size: 20 })
const inner = cube({ size: 16 }) // 2mm smaller on each side
const hollowBox = subtract(outer, inner)
// Open-top container
const openBox = subtract(
hollowBox,
cuboid({ size: [22, 22, 5], center: [0, 0, 10] }) // cut away top
)
// Hollow cylinder (pipe)
subtract(
cylinder({ height: 20, radius: 5 }),
cylinder({ height: 22, radius: 4 }) // taller to fully cut through
)Rounded Edges
import { roundedCuboid, roundedCylinder, expand, cuboid } from 'rensei/modeling'
// Built-in rounded primitives
roundedCuboid({ size: [20, 10, 5], roundRadius: 1, segments: 16 })
roundedCylinder({ height: 10, radius: 3, roundRadius: 0.5, segments: 16 })
// Or use expand on a smaller geometry for uniform rounding
expand({ delta: 1, corners: 'round', segments: 16 }, cuboid({ size: [18, 8, 3] }))
// Chamfered edges
expand({ delta: 1, corners: 'chamfer' }, cuboid({ size: [18, 8, 3] }))Screw Threads
import { extrudeFromSlices, slice } from 'rensei/modeling'
const threads = (innerR: number, outerR: number, length: number, segments: number) => {
const pitch = 2
const revolutions = length / pitch
const numSlices = 12 * revolutions
return extrudeFromSlices({
numberOfSlices: numSlices,
callback: (progress, index) => {
const points = []
for (let i = 0; i < segments; i++) {
const pointAngle = Math.PI * 2 * i / segments
const threadAngle = (2 * Math.PI * revolutions * progress) % (Math.PI * 2)
const diff = Math.abs((threadAngle - pointAngle) % (Math.PI * 2))
const phase = (diff > Math.PI ? Math.PI * 2 - diff : diff) / Math.PI
const r = Math.max(innerR, Math.min(outerR, innerR + (outerR - innerR) * (1.4 * phase - 0.2)))
points.push([r * Math.cos(pointAngle), r * Math.sin(pointAngle), length * progress])
}
return slice.fromPoints(points)
}
}, {})
}
// Usage
const screwThreads = threads(4, 5.6, 32, 32)Nuts and Bolts
import { cylinder, union, subtract, translate } from 'rensei/modeling'
// Hex head (cylinder with 6 segments = hexagon)
const hexHead = cylinder({ height: 8, radius: 10 * 1.1547, segments: 6, center: [0, 0, 4] })
// Bolt = hex head + threaded shaft
const bolt = union(
translate([0, 0, 32], hexHead),
threads(4, 5.6, 32, 32)
)
// Nut = hex block with threaded hole subtracted
const nut = subtract(
cylinder({ height: 8, radius: 10 * 1.1547, segments: 6, center: [0, 0, 4] }),
threads(4, 5.6, 8, 32)
)Extrude Along Bezier Path (Tubes)
import {
bezier, circle, geom2,
extrudeFromSlices, slice, mat4
} from 'rensei/modeling'
const tubeCurve = bezier.create([[0,0,0], [5,10,5], [10,0,10], [15,5,15]])
// Create initial circular slice
const circ = circle({ radius: 1, segments: 32 })
const circPoints = geom2.toPoints(circ)
const baseSlice = slice.fromPoints(circPoints)
const tube = extrudeFromSlices({
numberOfSlices: 60,
capStart: true,
capEnd: true,
callback: (progress, count, base) => {
const pos = bezier.valueAt(progress, tubeCurve)
const translationMatrix = mat4.fromTranslation(mat4.create(), pos)
return slice.transform(translationMatrix, base)
}
}, baseSlice)Symmetry / Mirroring
import { subtract, cuboid, cylinder, cube, sphere, union, mirrorX, mirrorY } from 'rensei/modeling'
// Build one half, mirror + union for perfect symmetry
const halfShape = subtract(
cuboid({ size: [10, 20, 5] }),
cylinder({ height: 10, radius: 3, center: [3, 5, 0] })
)
const fullShape = union(halfShape, mirrorX(halfShape))
// Two-axis symmetry (quarter → full)
const quarter = subtract(cube({ size: 10 }), sphere({ radius: 4, center: [3, 3, 0] }))
const half = union(quarter, mirrorX(quarter))
const full = union(half, mirrorY(half))Circular Pattern Array
import { cylinder, subtract } from 'rensei/modeling'
const numHoles = 8
const holeRadius = 2
const patternRadius = 15
const holes = Array.from({ length: numHoles }, (_, i) => {
const angle = (Math.PI * 2 * i) / numHoles
return cylinder({
height: 20,
radius: holeRadius,
center: [patternRadius * Math.cos(angle), patternRadius * Math.sin(angle), 0]
})
})
const disc = cylinder({ height: 5, radius: 20, segments: 64 })
subtract(disc, ...holes)Linear Pattern Array
import { cuboid, subtract } from 'rensei/modeling'
const slots = Array.from({ length: 5 }, (_, i) =>
cuboid({ size: [2, 10, 10], center: [-8 + i * 4, 0, 0] })
)
subtract(cuboid({ size: [30, 15, 5] }), ...slots)Embossed / Engraved Text
import {
cuboid, vectorText, circle, translate,
hullChain, union, extrudeLinear, subtract
} from 'rensei/modeling'
// Engrave text into a surface
const surface = cuboid({ size: [50, 15, 3] })
const textSegments = vectorText({ input: 'JSCAD', height: 8 })
const textShapes = textSegments.map(seg => {
const corners = seg.map(pt => translate(pt, circle({ radius: 0.5 })))
return hullChain(corners)
})
const text2D = union(textShapes)
const text3D = extrudeLinear({ height: 1 }, text2D)
const positioned = translate([-20, -4, 1.5], text3D)
const engraved = subtract(surface, positioned) // cut into surface
// const embossed = union(surface, positioned) // raise above surfaceLofting Between Profiles
import { extrudeFromSlices, slice, circle, mat4, geom2 } from 'rensei/modeling'
// Square at bottom → circle at top
extrudeFromSlices({
numberOfSlices: 30,
callback: (progress, count) => {
const sides = Math.round(4 + progress * 28) // 4 → 32 sides
const r = 5 + progress * 3
const shape = circle({ radius: r, segments: sides })
let s = slice.fromSides(geom2.toSides(shape))
return slice.transform(
mat4.fromTranslation(mat4.create(), [0, 0, progress * 15]),
s
)
}
}, circle({ radius: 5, segments: 4 }))Lathe / Vase / Wine Glass
Define a profile polygon (right-side silhouette) and revolve it:
import { polygon, extrudeRotate } from 'rensei/modeling'
const profile = polygon({ points: [
[0, 0], [3, 0], [3, 0.5], [0.5, 0.5], // base
[0.5, 5], [0.3, 5.5], [0.3, 8], // stem
[0.5, 8.5], [3, 10], [3.5, 12], // bowl outer
[3.2, 12], [2.8, 10], [0.3, 8.5], [0, 8.5] // bowl inner wall
]})
const glass = extrudeRotate({ segments: 64 }, profile)Snap-Fit Joints
import { union, subtract, cuboid } from 'rensei/modeling'
// Cantilever snap hook
const hook = union(
cuboid({ size: [2, 1, 10], center: [0, 0, 5] }),
cuboid({ size: [2, 1, 2], center: [0, 0.75, 10.5] })
)
// Matching socket
const socket = subtract(
cuboid({ size: [4, 3, 12], center: [0, 0, 6] }),
cuboid({ size: [2.2, 1.2, 11], center: [0, 0, 5.5] }),
cuboid({ size: [2.2, 1.8, 2.2], center: [0, 0.3, 10.5] })
)Gear (Approximation)
import { extrudeLinear, polygon } from 'rensei/modeling'
const gear = (teeth: number, mod: number, thickness: number) => {
const pitchR = mod * teeth / 2
const outerR = pitchR + mod
const innerR = pitchR - 1.25 * mod
const toothAngle = Math.PI * 2 / teeth
const points = []
for (let i = 0; i < teeth; i++) {
const a = i * toothAngle
points.push([innerR * Math.cos(a - toothAngle * 0.4), innerR * Math.sin(a - toothAngle * 0.4)])
points.push([outerR * Math.cos(a - toothAngle * 0.15), outerR * Math.sin(a - toothAngle * 0.15)])
points.push([outerR * Math.cos(a + toothAngle * 0.15), outerR * Math.sin(a + toothAngle * 0.15)])
points.push([innerR * Math.cos(a + toothAngle * 0.4), innerR * Math.sin(a + toothAngle * 0.4)])
}
return extrudeLinear({ height: thickness }, polygon({ points }))
}
// 20-tooth gear, module 2, 5mm thick
const myGear = gear(20, 2, 5)Designing for 3D Printing
JSCAD's CSG operations guarantee watertight meshes — every union, subtract, intersect produces a valid closed solid by construction. The STL serializer also auto-applies snap + triangulate before export, removing degenerate polygons. So mesh validity is rarely an issue.
The real challenges are physical printability: wall thickness, overhangs, tolerances, orientation, and bed adhesion. This section covers how to design JSCAD models that actually print well.
Filament weight estimation
rensei weight <file> computes filament weight directly from the JSCAD geometry before slicing.
rensei weight model.tsFilament weight estimate for: model.ts
─────────────────────────────────────────
Model volume: 12.15 cm³
Shell volume: 4.32 cm³ (3 shells × 0.4mm nozzle)
Inner volume: 7.83 cm³ (20% infill)
Density: 1.24 g/cm³
─────────────────────────────────────────
➜ Weight: 7.3 g
➜ Filament: 2.44 m (1.75mm diameter)
─────────────────────────────────────────
Bounding box: 50.0 × 50.0 × 28.0 mmPLA (most common, default settings):
rensei weight model.ts
# --density 1.24 --infill 20 --shells 3 --nozzle 0.4PETG (slightly denser, often lower infill for flexibility):
rensei weight model.ts --density 1.27 --infill 15ABS (lighter than PLA, needs more walls for strength):
rensei weight model.ts --density 1.05 --infill 25 --shells 4TPU (flexible filament, low infill):
rensei weight model.ts --density 1.21 --infill 10 --shells 3ASA (outdoor/UV-resistant):
rensei weight model.ts --density 1.07 --infill 20| Flag | Default | Description |
|---|---|---|
| --density | 1.24 | Filament density in g/cm³ |
| --infill | 20 | Infill percentage 0–100 |
| --shells | 3 | Number of perimeter shells |
| --nozzle | 0.4 | Nozzle diameter in mm |
| --layer-height | 0.2 | Layer height in mm |
| Material | Density (g/cm³) | |---|---| | PLA | 1.24 | | PETG | 1.27 | | ABS | 1.05 | | ASA | 1.07 | | TPU (95A) | 1.21 | | Nylon (PA12) | 1.01 | | PC | 1.20 | | PLA+ | 1.24 |
Note: This is a fast estimate based on geometry volume, not actual toolpaths. For exact weight, slice in Bambu Studio / PrusaSlicer after exporting the STL with
rensei stl model.ts.
Wall Thickness
Every wall, shell, and feature must have minimum physical thickness or the printer can't form it.
| Technology | Minimum | Recommended | |---|---|---| | FDM (0.4mm nozzle) | 0.8mm (2 perimeters) | 1.2mm+ (3 perimeters) | | Resin (SLA/MSLA) | 0.3mm | 0.5mm+ | | SLS (nylon) | 0.7mm | 1.0mm+ |
import { cylinder, subtract } from 'rensei/modeling'
// BAD: 0.4mm wall — too thin for FDM
const thinPipe = subtract(
cylinder({ height: 20, radius: 5 }),
cylinder({ height: 22, radius: 4.8 }) // 5 - 4.8 = 0.2mm wall
)
// GOOD: 1.2mm wall — solid on FDM
const thickPipe = subtract(
cylinder({ height: 20, radius: 5 }),
cylinder({ height: 22, radius: 3.8 }) // 5 - 3.8 = 1.2mm wall
)
// Verify wall thickness programmatically
const outerR = 5
const innerR = 3.8
const wallThickness = outerR - innerR // 1.2mm ✓
console.log(`Wall thickness: ${wallThickness}mm`)Don't over-thickness. Thicker isn't always better — it wastes material and print time:
| Purpose | Recommended wall | |---|---| | Non-structural shells (funnels, shrouds) | 1.5–2mm | | General structural walls | 2–3mm | | Walls for thread engraving/tapping | 2.5–3mm | | Heavy-duty load-bearing | 3–4mm |
// BAD: 6mm wall "just in case" — wastes 3x the material
const overbuilt = subtract(
cylinder({ height: 20, radius: 25 }),
cylinder({ height: 22, radius: 19 }) // 6mm wall
)
// GOOD: 2mm wall — sufficient for a water funnel
const efficient = subtract(
cylinder({ height: 20, radius: 25 }),
cylinder({ height: 22, radius: 23 }) // 2mm wall
)For hollow boxes, remember thickness applies to every side:
import { cuboid, subtract, measureDimensions } from 'rensei/modeling'
// 2mm walls on all sides of a box
const wallT = 2
const outer = [30, 20, 15]
const inner = [outer[0] - wallT * 2, outer[1] - wallT * 2, outer[2] - wallT * 2]
const hollowBox = subtract(
cuboid({ size: outer }),
cuboid({ size: inner })
)
// Verify with measurements
const dims = measureDimensions(hollowBox)
console.log(`Outer: ${dims}`) // [30, 20, 15]Flat Bottom & Build Plate Contact
Models need a flat base sitting at Z=0 for proper bed adhesion. Use align to place the bottom on the build plate.
import { align, translateZ, measureBoundingBox } from 'rensei/modeling'
// Place bottom of any geometry at Z=0
const onBed = align({ modes: ['center', 'center', 'min'], relativeTo: [0, 0, 0] }, myGeom)
// Or use translateZ after measuring
const bbox = measureBoundingBox(myGeom)
const onBed2 = translateZ(-bbox[0][2], myGeom) // shift bottom to Z=0Add a base flange to improve adhesion and reduce warping on large prints:
import { cuboid, align, union } from 'rensei/modeling'
// Add a 0.4mm chamfered brim around the base
const part = cuboid({ size: [30, 20, 15] })
const partOnBed = align({ modes: ['center', 'center', 'min'] }, part)
// Thin flange extending 3mm around the base footprint
const flange = cuboid({ size: [36, 26, 0.4] })
const flangeOnBed = align({ modes: ['center', 'center', 'min'] }, flange)
const printReady = union(partOnBed, flangeOnBed)Overhangs & the 45° Rule
FDM printers can't print in mid-air. Any surface angled more than 45° from vertical (i.e., less than 45° from horizontal) needs support material or will print poorly.
╱ 0° overhang (vertical wall) — always fine
╱
╱ 45° overhang — maximum self-supporting angle
╱
╱ 60° overhang — needs support
╱
╱ 90° overhang (horizontal ceiling) — needs supportDesign self-supporting overhangs using chamfers and tapers instead of sharp 90° ledges:
import { cuboid, union, extrudeLinear, polygon, rotateX, translate } from 'rensei/modeling'
// BAD: 90° overhang — sharp horizontal shelf needs supports
const sharpShelf = union(
cuboid({ size: [10, 10, 20], center: [0, 0, 10] }), // column
cuboid({ size: [20, 10, 3], center: [5, 0, 21.5] }) // shelf sticking out
)
// GOOD: 45° chamfer transition — self-supporting
const column = cuboid({ size: [10, 10, 20], center: [0, 0, 10] })
const shelf = cuboid({ size: [20, 10, 3], center: [5, 0, 24.5] })
// Triangular support under the shelf at 45°
const chamfer = extrudeLinear(
{ height: 10 },
polygon({ points: [[5, 0], [5, 5], [10, 5]] })
)
const chamferBlock = rotateX(Math.PI / 2,
translate([0, 5, 0], chamfer)
)
const selfSupporting = union(column, shelf, translate([0, 0, 18], chamferBlock))Tapered cylinders are better than sharp overhangs:
import { cylinder, cylinderElliptic, union } from 'rensei/modeling'
// BAD: cylinder floating above a post — 90° overhang underneath
const bad = union(
cylinder({ height: 20, radius: 3, center: [0, 0, 10] }),
cylinder({ height: 5, radius: 8, center: [0, 0, 22.5] })
)
// GOOD: tapered transition at 45°
const post = cylinder({ height: 20, radius: 3, center: [0, 0, 10] })
const taper = cylinderElliptic({
height: 5, startRadius: [3, 3], endRadius: [8, 8], center: [0, 0, 22.5]
})
const cap = cylinder({ height: 5, radius: 8, center: [0, 0, 27.5] })
const good = union(post, taper, cap)Conical Overhangs Are More Forgiving
The 45° rule applies primarily to flat/rectangular overhangs. Circular and conical surfaces can handle steeper angles because each layer is a complete circle slightly wider than the previous — the printer lays down a full ring with only a tiny unsupported extension per layer.
Flat overhang at 60°: Conical overhang at 60°:
┌────────┐ /‾‾‾\
│ FAIL │ / OK \
─────┘ └───── ────/ \────
Each layer jumps Each layer is a full
a large unsupported circle, only ~0.5mm
distance at once wider than the previousRules of thumb:
- Flat overhangs: max ~45° from vertical (the standard rule)
- Conical/circular overhangs: up to ~65–70° from vertical works fine
- A funnel cone printing narrow-end-down is self-supporting even at steep angles
- Each layer extends only
(radius_change / height) × layer_heightbeyond the previous
// This funnel has a 66° overhang from vertical — too steep for a flat shelf,
// but prints fine as a cone because each circular layer only extends 0.45mm
// beyond the previous (at 0.2mm layer height)
const funnel = extrudeRotate({ segments: 64 }, polygon({ points: [
[6, 0], // narrow end (on build plate)
[9, 12], // base of cone
[30, 20], // wide end — 24mm radius increase over 8mm height
[28, 20], // inner wall
[7, 12], // inner slope
[4.5, 12], // through-hole
[4.5, 0], // hole bottom
]}))Bridging
Horizontal spans between two supports (bridges) work up to ~10mm on FDM without supports. Beyond that, add design features:
import { cuboid, union } from 'rensei/modeling'
// Short bridge — fine without supports (8mm span)
const supports = union(
cuboid({ size: [5, 5, 20], center: [-6.5, 0, 10] }),
cuboid({ size: [5, 5, 20], center: [6.5, 0, 10] })
)
const bridge = cuboid({ size: [18, 5, 2], center: [0, 0, 21] })
const shortBridge = union(supports, bridge) // 8mm unsupported span ✓
// Long bridge — add a middle support column
const midSupport = cuboid({ size: [3, 5, 20], center: [0, 0, 10] })
const longBridge = union(supports, midSupport, bridge)Tolerances & Fit
3D printers aren't perfectly precise. Parts expand slightly (FDM) or shrink slightly (resin). Add clearance for parts that fit together.
| Fit Type | Gap per side | Use case | |---|---|---| | Loose / sliding | 0.3–0.5mm | Lids, sliding joints | | Snug | 0.15–0.25mm | Snap-fit, friction fit | | Press fit | 0.05–0.1mm | Bearings, permanent joints | | Threaded holes | +0.2mm to nominal | Bolts, screws |
import { cuboid, cylinder, union, subtract } from 'rensei/modeling'
// Male/female peg with clearance for sliding fit
const pegRadius = 4
const clearance = 0.3 // per side
// Male peg
const peg = union(
cuboid({ size: [20, 20, 5] }), // base plate
cylinder({ height: 10, radius: pegRadius, center: [0, 0, 10] }) // peg
)
// Female socket (hole is larger by clearance on each side)
const socket = subtract(
cuboid({ size: [20, 20, 15], center: [0, 0, 7.5] }),
cylinder({ height: 12, radius: pegRadius + clearance, center: [0, 0, 10] })
)
// Rectangular slot with clearance
const slotWidth = 10
const slotDepth = 3
const tab = cuboid({ size: [slotWidth, slotDepth, 5] })
const slot = cuboid({ size: [slotWidth + clearance * 2, slotDepth + clearance * 2, 6] })Screw holes — always print larger than nominal thread diameter:
import { cylinder } from 'rensei/modeling'
// M3 screw hole (nominal 3mm diameter)
// Print at 3.4mm for easy threading
const m3Hole = cylinder({ height: 20, radius: (3 + 0.4) / 2 })
// M3 clearance hole (bolt passes through without threading)
const m3Clearance = cylinder({ height: 20, radius: (3.4 + 0.4) / 2 })
// Common FDM screw hole sizes
const screwHoles = {
M2: { thread: 2.4, clearance: 2.6 }, // diameter in mm
M3: { thread: 3.4, clearance: 3.6 },
M4: { thread: 4.5, clearance: 4.8 },
M5: { thread: 5.5, clearance: 5.8 },
}Print Orientation & Layer Strength
FDM prints are weakest along the Z axis (layer adhesion). Layers bond thermally — they're never as strong as within a single layer.
Layer lines → ════════ Strong in X/Y (within layer)
════════
════════ Weak in Z (between layers)
════════Design rules:
- Load-bearing features should have layers perpendicular to the load
- Snap-fit hooks should flex along X/Y, not peel apart layers in Z
- Cylindrical parts under pressure (pipes, funnels, nozzles): print with axis vertical so hoop stress (radial expansion) stays within the XY layer plane (strong direction). Printing sideways puts hoop stress across layers → delamination under pressure
- Horizontal round holes deform into ovals — use teardrop shapes for accuracy:
import {
circle, polygon, union, rotateX,
extrudeLinear, cuboid, subtract, translate
} from 'rensei/modeling'
// Standard round hole — deforms when printed horizontally
const roundHole = rotateX(Math.PI / 2, cylinder({ height: 20, radius: 3 }))
// Teardrop hole — self-supporting, accurate diameter when printed horizontally
// Flat bottom + 45° pointed top replaces the unsupported upper arc
const teardropHole = (radius: number, depth: number) => {
const bottom = circle({ radius })
// Add a 45° diamond point at the top to avoid overhang
const topPoint = polygon({
points: [
[-radius, 0],
[0, radius], // 45° point
[radius, 0]
]
})
const profile = union(bottom, topPoint)
return rotateX(Math.PI / 2, extrudeLinear({ height: depth }, profile))
}
const plate = cuboid({ size: [30, 10, 20] })
const withTeardrop = subtract(plate, translate([0, 0, 10], teardropHole(3, 12)))Print-in-Place Joints
Hinges and ball joints that print fully assembled need generous clearance so layers don't fuse together.
import { sphere, cylinder, union, subtract } from 'rensei/modeling'
// Print-in-place ball and socket joint
const ballR = 5
const socketR = ballR + 0.4 // 0.4mm clearance all around
const socketWall = 1.5
// Ball on a stem
const ball = union(
sphere({ radius: ballR, segments: 32 }),
cylinder({ height: 10, radius: 2, center: [0, 0, -7.5] })
)
// Socket — sphere cutout with entry slot for print-in-place
const socketOuter = sphere({ radius: socketR + socketWall, segments: 32 })
const socketCutout = sphere({ radius: socketR, segments: 32 })
// Opening at top so the socket can be printed around the ball
const opening = cylinder({ height: 20, radius: ballR * 0.6, center: [0, 0, 5] })
const socket = subtract(socketOuter, socketCutout, opening)
// Print-in-place hinge pin
const hingePin = (pinR: number, clearance: number, length: number) => {
const pin = cylinder({ height: length, radius: pinR, segments: 32 })
const housing = subtract(
cylinder({ height: length, radius: pinR + clearance + 1.5, segments: 32 }),
cylinder({ height: length + 2, radius: pinR + clearance, segments: 32 })
)
return [pin, housing] // print together
}Verifying Before Export
Use JSCAD's measurement functions to sanity-check your model before slicing.
import {
measureBoundingBox, measureDimensions, measureVolume,
measureArea, generalize
} from 'rensei/modeling'
const model = myComplexAssembly()
// Check overall size — does it fit your print bed?
const bbox = measureBoundingBox(model)
const dims = measureDimensions(model)
console.log(`Size: ${dims[0].toFixed(1)} x ${dims[1].toFixed(1)} x ${dims[2].toFixed(1)} mm`)
console.log(`Bounding box: [${bbox[0].map(v => v.toFixed(1))}] to [${bbox[1].map(v => v.toFixed(1))}]`)
// Check volume — sanity check (should be > 0 for a valid solid)
const vol = measureVolume(model)
console.log(`Volume: ${vol.toFixed(1)} mm³`)
if (vol <= 0) console.warn('WARNING: Zero or negative volume — geometry may be inside-out')
// Check surface area
const area = measureArea(model)
console.log(`Surface area: ${area.toFixed(1)} mm²`)
// Check bottom is at Z=0 for bed adhesion
if (bbox[0][2] > 0.01) console.warn('WARNING: Model floating above Z=0 — use align() to place on bed')
if (bbox[0][2] < -0.01) console.warn('WARNING: Model below Z=0 — bottom will be clipped')
// Clean up geometry before export
const cleaned = generalize({ snap: true, simplify: true, triangulate: true }, model)Segment Count vs File Size
Higher segments values create smoother curves but larger STL files. The slicer re-slices anyway, so extremely high segments are wasted. Use the minimum that looks smooth enough.
| Shape | Segments | Use case | |---|---|---| | Small holes (<5mm) | 16–24 | Barely visible facets | | Medium curves | 32 | Good default | | Large visible arcs | 48–64 | Smooth finish | | Decorative/cosmetic | 64–128 | Only when surface quality matters |
import { cylinder, sphere } from 'rensei/modeling'
// Default segments (32) — good for most parts
cylinder({ height: 10, radius: 5 })
// Low segments for small hidden holes (saves file size & boolean speed)
const smallHole = cylinder({ height: 10, radius: 1.5, segments: 16 })
// High segments only for large visible curves
const smoothDome = sphere({ radius: 20, segments: 64 })Simplify for Printing — Strip Non-Functional Features
When reverse-engineering a physical part (especially machined metal), most visible features are manufacturing artifacts, not functional requirements. Concentric stepped rings, decorative grooves, scalloped surfaces — these exist because of how metal lathes and CNC mills work, not because the part needs them.
Always ask: "What does this part actually DO?" Then model only the function.
// BAD: faithfully replicating every machining ring from the metal original
// Result: 5x the polygons, 3x the material, zero functional benefit
const overEngineered = extrudeRotate({ segments: 64 }, polygon({ points: [
// ... 40 points tracing every decorative step and groove
]}))
// GOOD: simplified to the actual function (funnel from wide to narrow)
const functional = extrudeRotate({ segments: 64 }, polygon({ points: [
[9, 0], // nozzle tip
[12, 12], // nozzle base
[30, 20], // funnel outer
[30, 28], // mounting cylinder
[28, 28], // cylinder inner
[28, 20], // funnel inner
[7, 12], // nozzle inner
[7, 0], // through-hole
]}))Key principles:
- Thin-walled shells instead of solid bodies — massive weight/material savings
- Smooth cones instead of stepped rings — fewer polygons, better print quality
- Skip decorative grooves — they don't improve function and may weaken the print
- Uniform wall thickness — simpler to print, easier to reason about strength
Spaghetti Failures — Sudden Cross-Section Expansion
"Spaghetti" is when the printer extrudes filament into thin air and it falls instead of sticking. The most common cause is a sudden large cross-section expansion — a narrow base transitioning to a much wider surface with no support below it.
SPAGHETTI EXAMPLE — funnel printed narrow-end-down:
Z=28 ──── nozzle tip Ø18mm ← prints fine
Z=16 ──── nozzle base Ø32mm ← prints fine
Z=8 ──── funnel WIDE Ø60mm ← SPAGHETTI: each layer jumps 14mm outward
with nothing below to support it
Z=0 ──── bedThe 45° rule applies to the expansion rate, not just the angle. A flat shelf at 89° will fail. A conical expansion at 66° may work fine (see Conical Overhangs Are More Forgiving).
Fix strategies:
- Flip the model — print wide-end-down so the wide base has bed contact and the narrow end builds up from it
- Add a taper/chamfer — replace 90° ledges with ≤45° slopes
- Use supports — only as a last resort; design them out when possible
// BAD: wide disc printed narrow-end-down → spaghetti at the funnel transition
// GOOD: flip so the wide mounting cylinder sits flat on the bed, nozzle points up
const flipped = mirrorZ(body) // flip orientation
return align({ modes: ['center', 'center', 'min'] }, flipped)
// Or just build the profile in the correct direction from the start:
// bed (Y=0) = wide end, top (Y=max) = narrow nozzleChoosing Print Orientation
Orientation is the single most impactful decision for printability. Ask these questions in order:
1. What's the largest flat surface? → Put it on the bed. Large flat bottom = best adhesion, no warping.
2. Where are the overhangs? → Overhangs should face upward (away from the bed), never downward into thin air.
3. What are the load directions? → FDM is weakest in Z (between layers). Orient so forces act within XY layers, not across them.
4. Are there internal features? → Every internal feature must build upward from the bed, not hang from the ceiling.
ORIENTATION DECISION TREE:
Does it have a large flat face?
├─ YES → Put that face on the bed (ideal)
└─ NO → Find the face with best area coverage
Are there steep overhangs (>45°)?
├─ NO → Current orientation is probably fine
└─ YES → Can you flip/rotate to eliminate them?
├─ YES → Do it (flip the model)
└─ NO → You'll need supports
Are there internal features (filter stubs, bosses, ribs)?
├─ Connect to BED or build from floor up → printable ✓
└─ Hang from ceiling or start mid-air → cantilever, needs supports or redesign
Will it be under load?
└─ Orient so load is parallel to layer lines, not peeling layers apartFor cylindrical parts (funnels, pipes, nozzles): always print with the cylinder axis vertical (along Z). This puts hoop stress within the strong XY plane. Printing sideways puts hoop stress across layers → delamination under pressure.
extrudeRotate Profile Polygon — Avoid Self-Intersection
extrudeRotate revolves a 2D cross-section profile around the Y axis. If the profile polygon self-intersects, it creates two disconnected bodies instead of one — the slicer will flag parts as floating even though the JSCAD geometry looks correct.
The most common cause: outer and inner funnel slopes traced in antiparallel directions so they cross each other.
WRONG — outer and inner slopes cross (antiparallel):
outer: (30,8) → (12,16) ↘ direction: (-18, +8)
inner: (11.5,16) → (28,8) ↗ direction: (+16.5, -8)
These lines INTERSECT at ~(20, 12) → two disconnected bodies!
RIGHT — outer and inner slopes are parallel (same direction):
outer: (30,8) → (16,16) direction: (-14, +8)
inner: (14,16) → (28,8) direction: (+14, -8) ← ANTIPARALLEL but...
Check: t+s equations give 8/7 ≠ 1 → NO intersection ✓To verify: parametrize both segments as A + t*(B-A) and C + s*(D-C), set equal, solve for t and s. If both are in [0,1] they intersect. If the system has no solution or requires t or s outside [0,1], they don't.
Also watch for overlapping horizontal segments at the same Y level. If two floor segments at the same height share an X range, the polygon is degenerate. Keep floor segments at the same Y in non-overlapping X ranges.
Sizing rule: if a feature (like a filter cylinder) sits inside a funnel, it must fit inside the funnel inner wall:
filterOuterRadius < nozzleBaseRadius - wallIf not, increase nozzleBaseRadius until it fits, or reduce the feature size.
Bambu Studio P1S — Parameter Reference
These are the exact parameter names as they appear in Bambu Studio → Process (enable Advanced toggle to see all of them). Parameters are grouped by impact level.
Quality tab
| Parameter | Default | Impact | What to change |
|---|---|---|---|
| Layer height | 0.2mm | ★★★ Critical | Lower (0.16mm) for smoother curves/threads; keep 0.2mm for speed |
| Initial layer height | 0.2mm | ★★ Medium | Leave at 0.2mm; thicker initial layer helps adhesion |
| Seam position | Aligned | ★ Minor | Aligned = seam in one spot; Nearest = less visible but scattered |
| Only one wall on top surfaces | Top surfaces | ★ Minor | Leave default |
| Only one wall on first layer | Off | ★ Minor | Leave off |
Strength tab — Walls
| Parameter | Default | Impact | What to change | |---|---|---|---| | Wall loops | 2 | ★★★ Critical | Increase to 3–4 for functional parts, threading surfaces | | Embedding the wall into the infill | Off | ★ Minor | Leave off |
Strength tab — Top/bottom shells
| Parameter | Default | Impact | What to change | |---|---|---|---| | Top shell layers | 5 | ★★ Medium | 5 is good; reduce to 3 for faster non-visible tops | | Top shell thickness | 1mm | ★★ Medium | Tied to layer height × top shell layers | | Bottom shell layers | 3 | ★★ Medium | Increase to 4–5 if bottom needs to be watertight | | Top surface pattern | Monotonic | ★ Minor | Monotonic = smooth; leave default | | Bottom surface pattern | Monotonic | ★ Minor | Leave default | | Internal solid infill pattern | Rectilinear | ★ Minor | Leave default |
Strength tab — Sparse infill
| Parameter | Default | Impact | What to change | |---|---|---|---| | Sparse infill density | 15% | ★★★ Critical | 15% = lightweight; 25–30% for functional/structural parts; 40%+ for maximum strength | | Sparse infill pattern | Grid | ★★ Medium | Gyroid = stronger + better filament/strength ratio; Grid = faster | | Fill multiline | 1 | ★ Minor | Leave default |
Support tab
| Parameter | Default | Impact | What to change |
|---|---|---|---|
| Enable support | Off | ★★★ Critical | Only enable if model truly needs it — always try to orient without |
| Type | tree(auto) | ★★★ Critical | tree(auto) = less material, easier to remove; use for most prints |
| Threshold angle | 30° | ★★★ Critical | 30° is very aggressive (generates lots of support). Raise to 45–50° for less support on gradual overhangs. The P1S default is 30° |
| On build plate only | Off | ★★ Medium | Enable — prevents supports from touching model surfaces and scarring them |
Others tab — Bed adhesion
| Parameter | Default | Impact | What to change |
|---|---|---|---|
| Brim type | Auto | ★★ Medium | Auto adds brim when needed. Set None if part has good bed contact; Outer brim only for large flat parts that warp |
| Brim width | 5mm | ★ Minor | Leave at 5mm |
| Skirt loops | 0 | ★ Minor | Add 1–2 skirt loops to prime the nozzle before the print starts |
Others tab — Special mode
| Parameter | Default | Impact | What to change | |---|---|---|---| | Spiral vase | Off | ★★ Medium | Single continuous spiral wall — for vases/cups with no top. Ignores infill/shells | | Fuzzy Skin | None | ★ Minor | Adds textured surface — cosmetic only, leave off for functional parts | | Print sequence | By layer | ★ Minor | Leave default unless printing multiple objects |
What to Actually Change vs Leave Alone
**Change these
