name: kernelcad-nurbs description: NURBS surfaces (nurbsSurface, surfaceFromCurves, surfaceFromBoundary, .thicken, .toShape) AND NURBS curves (nurbsCurve, spline3d, hermiteG2) AND multi-section sweeps (variableSweep) AND G1/G2 fillet continuity AND 2D NURBS path segments (path().spline / .nurbsSegment / .hermiteG2). Use for freeform geometry that primitives + sketches cannot express.
kernelCAD — NURBS surfaces & curves
Build free-form panels, lofted shells, organic spines, and tapered swept solids whose result enters the existing Shape pipeline (booleans, fillets, exports).
// Lofted free-form panel from sketch sections
const s0 = path().moveTo(-30, -10).lineTo(30, -10).lineTo(30, 10).lineTo(-30, 10).close();
// (use sketch('xy', { offset: <z> }).path()...close() to place sections at different z)
const panel = surfaceFromCurves([s0, s1]).thicken(2);
nurbsSurface({ controls, degree, weights?, knots?, periodic? }) returns a Surface peer to Shape. The Surface exposes these escape methods:
| Method | Returns | Notes |
|---|---|---|
.thicken(t) |
Shape (closed solid) |
Offsets both sides by t mm via BRepOffsetAPI_MakeThickSolid.MakeThickSolidBySimple. t accepts Editable<number>. |
.toShape() |
Shape (zero-volume shell) |
Single-face Shape; use as profile placeholder for future face-aware features. |
.trimTo(by) |
Surface |
Trim this surface at its intersection with by (a Surface cutter) and return the kept half. No geometry computed at capture time — the lowerer runs BRepAlgoAPI_Section and imprints the section curve with BRepFeat_SplitShape. Use before sew to align adjacent patch edges. Emits feature.surface-trim.no-intersection when the cutter misses. Shape/Curve3D cutters are deferred. |
.split(by) |
[Surface, Surface] |
Split this surface at its intersection with by (a Surface cutter) and return both resulting halves, ordered by descending area. The cutter must be a Surface; Shape/Curve3D cutters are deferred. Emits feature.surface-trim.no-intersection when the cutter misses. |
Top-level finishing ops that consume Surface instances:
sew(surfaces, opts?)— stitch N surfaces into a shell or closed solid via OCCTBRepBuilderAPI_Sewing. Edges withintolerancemm (default 1e-6) are merged.requireClosed: trueemitsfeature.surface-sew.open-shellinstead of a partial shell when the result is still open. Returns aShape.- The workflow for a multi-patch body:
trimToeach patch to shared boundary curves →sew([...], { requireClosed: true })→ optionally.draft(angleDeg, { face })for mold release.
surfaceFromCurves(sections) skins through 2+ closed Sketch cross-sections in declaration order. Section order = skin direction.
Rational NURBS surface weights are honored (v0.14.0). Supplying weights builds an exact rational surface — use them for exact circles, cylinders, spheres, and conics rather than approximating with control points. (3D nurbsCurve weights are still non-rational; that lane is deferred.)
NURBS diagnostic codes
feature.nurbs.degenerate-controls(error) —controlsis empty, jagged, contains non-finite points, orweightsdoesn't match the controls grid shape. Hint: pass a non-empty rectangular Vec3 grid spanning a 2D extent.feature.nurbs.degree-mismatch(error) —degree.u > controls.length - 1(or v-analog) or< 1. Hint: reduce degree, or add control points.
Verification gates
After authoring a NURBS surface, run before reporting done:
| Gate | Pass criterion |
|---|---|
| G-eval | kernelcad evaluate exits 0 — no feature.nurbs.degenerate-controls or feature.nurbs.degree-mismatch |
| G-controls-finite | Every control point is a finite Vec3 (no NaN, no Infinity); grid is rectangular (every row same length) |
| G-degree-leq-controls | degree.u ≤ controls.length − 1 and degree.v ≤ controls[0].length − 1 |
| G-thicken-clearance | .thicken(t) extends along the surface NORMAL by t; verify the offset solid does not overlap any neighboring part (run kernelcad interference) |
| G-opaque-renderer-trap | The studio render path uses MeshLambertMaterial (opaque-only). Color values like #dfeef4 look like glass at API level but render OPAQUE. Place numerals / hands / decals on top OR outside the dome footprint if they must remain visible; do not assume the renderer will see through them |
| G-periodic-v-vs-seam | A square-grid loft from polar samples creates a wedge seam at θ=0; use periodic: { v: true } and a polar control grid (radial × angular) to remove the seam |
The opaque-renderer trap and the periodic-V seam gate were learned in the v0.7 pocket-watch hero capture — both are real production gotchas, not hypothetical.
NURBS curves (Slice B)
Curve3D is a peer-type alongside Shape and Surface. It captures a 3D parametric curve and lowers to a TopoDS_Edge backed by Geom_BSplineCurve (direct OCCT, no replicad wrapper). Curves park their edges on session.importedGeometry and are consumed by variableSweep (and future surfaceFromBoundary / G2 blends in Slice C).
// Explicit control net — defaults to cubic non-rational.
const spine = nurbsCurve(
[[0, 0, 0], [10, 5, 0], [20, -5, 10], [30, 0, 5]],
{ degree: 3 },
);
// Catmull-Rom convenience that interpolates waypoints.
const brow = spline3d([
[-65, 35, 0],
[-20, 42, 4],
[ 20, 42, 4],
[ 65, 35, 0],
], { tension: 0.5 });
Curve3D exposes synchronous evaluation: .sample(n) (returns n+1 points), .pointAt(t), .tangentAt(t) (unit vector), .length() (arc length in mm), .domain() (always [0, 1]). Evaluation lazily lowers the curve through BRepAdaptor_Curve; per-session cache keeps repeat calls cheap.
Curve3D diagnostic codes
feature.curve3d.degenerate-controls— fewer thandegree + 1control points.feature.curve3d.weights-length-mismatch— weights array length ≠ controlPoints length.feature.curve3d.weights-non-positive— a weight is zero or negative (undefined for B-splines).feature.curve3d.knots-length-mismatch— knot count ≠ controlPoints.length + degree + 1.feature.curve3d.closed-endpoints-mismatch—closed: truebut first ≠ last (warn; OCCT closes internally).
Multi-section sweep — variableSweep (Slice B)
Blend two or more profile sketches along a spine. Lowers to BRepOffsetAPI_MakePipeShell. Use for tapered limbs (wing sections, fairings), varying-cross-section sweeps that lofts cannot express because they need an explicit spine path, eyewear temples that taper along a curved spine.
const spine = spline3d([[-50, 0, 0], [-20, 6, 4], [20, 6, 4], [50, 0, 0]]);
const big = path().moveTo(-3, -3).lineTo(3, -3).lineTo(3, 3).lineTo(-3, 3).close();
const small = path().moveTo(-1, -1).lineTo(1, -1).lineTo(1, 1).lineTo(-1, 1).close();
const limb = variableSweep(spine, [
{ t: 0, profile: big },
{ t: 1, profile: small },
]);
Spine accepts a Curve3D, a planar Sketch (its lifted outer wire is used as the rail), or a Vec3[] (auto-converted to a nurbsCurve of degree min(3, n-1)). Sections must be strictly increasing in t; the first MUST sit at t=0 and the last at t=1 (full-spine coverage).
variableSweep diagnostic codes
feature.variable-sweep.sections-out-of-order— t values not strictly increasing.feature.variable-sweep.sections-not-spanning— first t ≠ 0 or last t ≠ 1, or fewer than 2 sections.feature.variable-sweep.spine-too-short— spine is shorter than the smallest profile bounding diameter (the sweep would self-intersect).feature.variable-sweep.profile-not-planar— profile is non-planar.feature.variable-sweep.profile-empty— profile sketch is empty (path() not closed).feature.variable-sweep.frenet-degenerate— Frenet orientation undefined where spine curvature vanishes. Orientation controls are not exposed in the MCP add tool until runtime orientation support is wired.
variableSweep gotcha — section locations must be on the spine
BRepOffsetAPI_MakePipeShell::Add_2 requires a location TopoDS_Vertex that is one of the spine wire's own sub-shapes. Today the lowerer maps t=0 to the spine's first vertex and t=1 to its last vertex; intermediate t values are not yet supported (spine subdivision lands as a follow-up). Authoring scripts can still target intermediate spine positions by routing through nurbsCurve segments stitched into the spine.
Filling surfaces — surfaceFromBoundary (Slice C)
surfaceFromBoundary(curves, opts?) builds the shipped filling surface: one NURBS face through 4 boundary Curve3Ds. Lowers to BRepOffsetAPI_MakeFilling (direct OCCT) with Add_1(edge, GeomAbs_Cn, isBound=true) per boundary. Use for the front face of an eyewear shell, an ergonomic palm rest, or any 4-bounded freeform panel.
const bottom = nurbsCurve([[0, 0, 0], [25, 0, 1], [50, 0, 0]]);
const right = nurbsCurve([[50, 0, 0], [50, 12, 0.5], [50, 25, 0]]);
const top = nurbsCurve([[50, 25, 0], [25, 25, 1], [0, 25, 0]]);
const left = nurbsCurve([[0, 25, 0], [0, 12, 0.5], [0, 0, 0]]);
const panel = surfaceFromBoundary([bottom, right, top, left]).thicken(2);
The 4 curves must be passed in exact loop order: curves[0] = bottom, curves[1] = right, curves[2] = top, curves[3] = left. Adjacent endpoints must coincide within 1e-6 mm — share the corner Vec3 across both meeting curves. opts.continuity accepts a single grade ('C0' | 'C1' | 'C2') applied to all 4 edges or a length-4 array per edge; defaults to 'C0'. opts.sampling controls NbPtsOnCur (default 15).
The result is a Surface peer — chain .thicken(t) to get a closed solid or .toShape() to wrap as a zero-volume single-face shell for downstream face-aware features.
surfaceFromBoundary diagnostic codes
feature.surface-from-boundary.corner-mismatch(error) — adjacent boundary endpoints don't coincide within 1e-6 mm. Hint: share the corner Vec3 across the two meeting curves' control nets.feature.surface-from-boundary.too-few-curves(error) — fewer than 4 curves passed. Hint: build all 4 edges; a 3-sided patch is not supported.feature.surface-from-boundary.too-many-curves(error) — more than 4 curves passed. Hint: collapse to 4 by stitching adjacent curves withhermiteG2.feature.surface-from-boundary.continuity-orphan(error) —opts.continuityis an array but its length is not 4. Hint: pass a single grade or an array of 4 grades (one per edge).feature.surface-from-boundary.degenerate-patch(error) — OCCTBRepOffsetAPI_MakeFillingfailed to produce a face (typically because two opposite boundary curves overlap or the loop is non-planar at the corners). Hint: render the 4 input curves and check the loop is a closed quadrilateral with non-degenerate corners.
Quintic Hermite transitions — hermiteG2 (Slice C)
hermiteG2(a, b) builds a 6-control-point quintic NURBS Curve3D that interpolates two endpoints with matching positions, first derivatives (tangents), and second derivatives (curvatures). Use to bridge two existing nurbsCurve flanks into a single G2-continuous compound spine that variableSweep can sweep without showing a kink at the join.
const flankL = nurbsCurve([[-30, 0, 0], [-20, 4, 0], [-10, 4, 0]]);
const flankR = nurbsCurve([[ 10, 4, 0], [ 20, 4, 0], [ 30, 0, 0]]);
const bridge = hermiteG2(
{ point: [-10, 4, 0], tangent: [10, 0, 0], curvature: [0, -0.05, 0] },
{ point: [ 10, 4, 0], tangent: [10, 0, 0], curvature: [0, -0.05, 0] },
);
Tangent magnitude matters. It is the first derivative, NOT the unit tangent — magnitude controls how aggressively the curve heads out of the endpoint. Typical magnitude is in the order of the chord length between the two endpoints. A tangent of [1, 0, 0] between points 30 mm apart will sag dramatically; [15, 0, 0] shapes a balanced bridge. Curvature defaults to [0, 0, 0], which makes the curve only G1 (lifted cubic Hermite). For true G2 continuity to a neighbouring spline3d or nurbsCurve, evaluate the neighbour's .tangentAt(t) and .pointAt(t) (curvature has no synchronous getter today — use a numerical second-derivative estimate via three sample points).
hermiteG2 diagnostic codes
feature.hermite-g2.non-finite-input(error) — anyNaN/Infinityinpoint,tangent, orcurvature. Hint: confirm every Vec3 entry is a finite number.feature.hermite-g2.degenerate-tangent(error) — tangent magnitude < 1e-12 on either endpoint. Hint: pass a non-zero tangent vector; for a unit start direction, scale by chord length.
G1/G2 fillet continuity (Slice C)
Shape.fillet(radius, edges?, { continuity }) accepts 'G1' (default — tangent-continuous polynomial blend, ChFi3d_Polynomial) and 'G2' (curvature-continuous rational blend, ChFi3d_Rational). 'G2' is preferred on edges adjacent to a NURBS surface (from surfaceFromBoundary / nurbsSurface / surfaceFromCurves) so the blend does not introduce a visible curvature crease at the surface-to-fillet boundary.
const panel = surfaceFromBoundary([bottom, right, top, left]).thicken(2);
const filleted = panel.fillet(1.5, undefined, { continuity: 'G2' });
feature.fillet.continuity-not-applicable warning
Emitted when the requested 'G2' continuity cannot improve the blend at the chosen edge. Hint: requesting 'G2' on a fillet whose adjacent faces are only G1-continuous downgrades to G1 internally.
G1-vs-G2 BREP-identity gotcha — planar/cylindrical fillets
Constant-radius fillets between planar faces or between a planar face and a cylindrical face produce BREP-identical output under both 'G1' and 'G2'. OCCT's rational-fillet path only diverges from the polynomial path when the adjacent faces carry non-trivial parametric curvature (nurbsSurface / surfaceFromBoundary / surfaceFromCurves). Thread continuity: 'G2' through your authoring layer for forward-compatibility with future surface-adjacent fillets, but do NOT gate on a different lowered BREP — verify the upgrade on a NURBS-adjacent edge, not on a box corner. Eyewear front faces lifted from surfaceFromBoundary ARE NURBS-adjacent; cylindrical lens openings cut through a flat box are NOT.
2D NURBS path segments (Slice D)
The PathBuilder returned by path() accepts three NURBS-backed segment operations alongside the existing line / arc / smoothSpline primitives. Use them when the existing arc family cannot express the desired 2D outline (eyewear brow, sneaker midsole, ergonomic grip silhouette, lens-opening cutouts that aren't perfect circles).
// .spline(points) — N-waypoint B-spline interpolation. Threads a degree-3
// B-spline through every waypoint; first must match current pen position.
const brow = path()
.moveTo(-60, 0)
.spline([[-60, 0], [-30, 8], [0, 12], [30, 8], [60, 0]])
.close();
// .nurbsSegment(controlPoints, opts?) — explicit B-spline control polygon.
// controlPoints[0] must match current pen position; opts.degree default 3.
const explicit = path()
.moveTo(0, 0)
.nurbsSegment([[0, 0], [5, 10], [15, 10], [20, 0]], { degree: 3 })
.lineTo(20, -5)
.close();
// .hermiteG2(a, b) — 2D quintic-Hermite transition. a.point must match the
// current pen position. tangent magnitude is the first derivative (typical
// magnitude ~ chord length, NOT unit length). curvature optional — defaults
// to [0, 0] (degrades to G1).
const transition = path()
.moveTo(-10, 0)
.hermiteG2(
{ point: [-10, 0], tangent: [0, 5], curvature: [0, 0] },
{ point: [ 10, 0], tangent: [0, -5], curvature: [0, 0] },
)
.close();
All three methods accept Editable<number> coords so symbolic params survive into capture. Coords are mm; the lowerer composes the resulting OCCT edges with replicad-drawn edges via BRepBuilderAPI_MakeWire.
2D path NURBS diagnostic codes
feature.path.spline.degenerate-points(error) — fewer than 2 points, NaN coord, two consecutive duplicates within 1e-9 mm, no priormoveTo, orpoints[0]not matching the current pen position within 1e-6 mm (a gap disconnects the wire and OCCT silently drops the unreachable edges, so a revolve/extrude of the profile would degenerate). Hint: pass ≥ 2 distinct finite Vec2 waypoints withpoints[0]exactly at the current pen position.feature.path.nurbs-segment.degenerate-controls(error) — fewer thandegree + 1control points, non-finite coord, orcontrolPoints[0]not matching current pen position within 1e-6 mm. Hint: provide at least degree+1 finite Vec2 control points with the first matching the current pen position.feature.path.nurbs-segment.weights-non-positive(error) — weight ≤ 0. Hint: weights must be strictly positive (zero collapses the basis; negative is undefined for B-splines).feature.path.hermite-g2.start-mismatch(error) —a.pointnot matching current pen position within 1e-6 mm. Hint: aligna.pointwith the path's current position, or callmoveTofirst.
Gotchas (real, not hypothetical)
- Skinned-surface lofts can't consume NURBS sketches.
surfaceFromCurves(sections)lowers eachSketchthrough a rawDrawingcast (nurbsSurfaceLowerer.buildSkinnedSurface); the NURBS-aware sketch lowerer is bypassed in that path. Usepath().spline(...)for extruded subtractive cutouts and standalone closed profiles; do NOT passpath().spline(...)sketches assurfaceFromCurvessections. For freeform sections that need lofting, use Slice C'ssurfaceFromBoundaryfilling surface or stick to line/arc primitives in the section profile. makeBSplineApproximationcan overshoot the waypoint y-extent at the defaulttolerance: 1e-4(peak ~75% overshoot observed in Slice D Task 3). If overshoot pollutes the silhouette, either tighten the tolerance throughopts.tension, or switch to.nurbsSegment(controlPoints, ...)for explicit shape control where precision beats convenience.- Wire-discontinuity is defensively tolerated. Capture-time validation rejects obvious gaps (start-mismatch within 1e-6 mm for
.nurbsSegment/.hermiteG2), but OCCT'sassembleWiresilently bridges sub-tolerance gaps in the lowerer — this is acceptable for v1; explicit gap-gating is queued for a follow-up slice.
JS-side analytics — when to use which
Every Curve3D (from nurbsCurve, spline3d, hermiteG2) exposes a .analytics namespace of read-only computed queries. These methods do NOT mutate the curve or produce new geometry — they return data (points, parameters, polylines, intersection records) for downstream consumption.
| You want to | Method | Returns |
|---|---|---|
| Find the point on a curve nearest a query point | curve.analytics.closestPoint(pt) |
Vec3 (world-space point on curve) |
| Get the parameter of the nearest point | curve.analytics.closestParam(pt) |
number in [0, 1] |
| Place N items spaced uniformly in arc length along a curve | curve.analytics.divideByEqualArcLength(n) |
CurveLengthSample[] (n+1 records with t, pt, arcLength) |
Place items every Δ mm along a curve |
curve.analytics.divideByArcLength(Δ) |
CurveLengthSample[] |
| Get derivatives at a parameter (tangent, curvature, ...) | curve.analytics.derivatives(t, numDerivs) |
Vec3[] of length numDerivs + 1 (index 0 = point, 1 = tangent, 2 = curvature) |
| Generate a polyline preview of the curve | curve.analytics.tessellate({ tolerance }) |
Vec3[] (default tolerance 0.05 mm; viewport-grade — NOT for export) |
| Find where two curves cross | a.analytics.intersect(b) |
CurveCurveIntersection[] (each record carries tA, tB, ptA, ptB, distance) |
| Find where a curve pierces a surface | curve.analytics.intersect(surface) |
CurveSurfaceIntersection[] (each record carries tCurve, uv, pt) |
Common pattern — place N holes evenly along a curve
const rail = spline3d([
[0, 0, 0], [25, 5, 0], [50, 0, 0], [75, 5, 0], [100, 0, 0],
]);
const slots = rail.analytics.divideByEqualArcLength(8);
for (const { pt } of slots) {
body = body.cut(cylinder(5, 1).translate(pt));
}
.sample(n) returns parametric samples (clustered where knot density is high); .divideByEqualArcLength(n) returns spatial samples (evenly spaced in millimetres). For non-uniform-knot curves (every fit-through-points spline, every Catmull-Rom), the two are different. Pick divideByEqualArcLength when the agent intent is "evenly spaced along the curve."
Common pattern — snap a connector to a curve
const rail = spline3d([
[0, 0, 0], [50, 10, 0], [100, 5, 0], [150, -5, 0], [200, 0, 0],
]);
const fixturePt: [number, number, number] = [120, 30, 0];
const snapPt = rail.analytics.closestPoint(fixturePt);
const snapT = rail.analytics.closestParam(fixturePt);
const snapTangent = rail.tangentAt(snapT);
// Build the mount frame: origin on the rail, axis along the rail tangent.
const bracketFrame = { origin: snapPt, axis: snapTangent };
Common pattern — measure curvature for a G2 bridge
const left = spline3d([[-30, 0, 0], [-15, 3, 0], [0, 0, 0]]);
const right = spline3d([[20, 0, 0], [35, -3, 0], [50, 0, 0]]);
const [, leftTangent, leftCurv] = left.analytics.derivatives(1, 2); // end of left
const [, rightTangent, rightCurv] = right.analytics.derivatives(0, 2); // start of right
const bridge = hermiteG2(
{ point: left.pointAt(1), tangent: leftTangent, curvature: leftCurv },
{ point: right.pointAt(0), tangent: rightTangent, curvature: rightCurv },
);
path().spline(points, { startTangent, endTangent })
Optional 2D tangent constraints at the first and last waypoint of a 2D path spline. When omitted, the fit chooses tangents from chord-length parametrisation (existing default). Pass both to pin both endpoints; pass either alone to pin just one.
const profile = path()
.moveTo(0, 0)
.lineTo(10, 0)
.spline(
[[10, 0], [15, 5], [20, 15], [20, 30]],
{ startTangent: [1, 0], endTangent: [0, 1] },
)
.lineTo(0, 30)
.close();
The tangent magnitudes do not matter (normalised internally); only the directions. [1, 0] and [100, 0] produce the same curve.
Performance characteristics
- All
Curve3D.analytics.*calls are synchronous JS computation. Single calls finish in well under 5 ms on typical hardware for curves up to ~100 control points. tessellate(0.05)is the recommended preview default — typically 5× faster than the kernel mesher at the same tolerance.intersect(other)on two degree-3 curves with ~10 control points each finishes in well under 50 ms.
These are not real-time-graphics methods; for per-frame queries on large counts of curves, batch via Web Workers (deferred; not in v1).
When NOT to use .analytics.*
- Export:
tessellate()is viewport-grade only. STEP / STL / glTF exports go through the kernel mesher (BRepMesh_IncrementalMesh) independently. - Geometry construction: analytics methods return data, not curves. To build a curve from analytics output (e.g. a refit through closest-point samples), call
nurbsCurveorspline3dwith the points. - Set-theoretic intersection of queries:
curve.analytics.intersect(other)is geometric (curve-curve, curve-surface). Topological / set-theoretic intersection ofQuery<Face>selections useskc.q.intersection(a, b)(different method on a different receiver).
Curve3D.analytics diagnostic codes
feature.curve3d.analytics.degenerate-arclength(error) —divideByArcLength(Δ)invoked withΔ ≤ 0or larger than the curve's total length. Hint: pass a positive spacing strictly less thancurve.length().feature.curve3d.analytics.closest-point-no-converge(error) — the closest-point solver did not converge inside the iteration budget. Hint: tessellate first and seedclosestPointwith the nearest polyline vertex; degenerate inputs (zero-length curve, query point on the curve) usually surface this code.feature.curve3d.analytics.derivatives-out-of-range(error) —numDerivsexceeds the curve degree. Hint: derivatives beyonddegreeare zero by construction; reducenumDerivsto ≤degree.feature.curve3d.analytics.tessellation-tolerance-invalid(error) —toleranceis non-positive or non-finite. Hint: pass a positive number; the default 0.05 mm is the recommended viewport-grade tolerance.feature.curve3d.analytics.kernel-failed(error) — the analytics solver threw an internal error. Hint: the curve probably has degenerate control net or knot vector; inspect viasample(n)first.feature.curve3d.analytics.intersect-kernel-failed(error) — the intersection solver threw internally. Hint: one of the two operands is degenerate; tessellate both and check the polylines.feature.curve3d.analytics.intersect-no-intersection(warn) —intersect(other)returned an empty array within tolerance. Not a fatal error; surfaced as a warning when downstream code asserts at least one crossing.feature.path.spline.tangent-zero-magnitude(error) —startTangentorendTangenthas magnitude < 1e-12. Hint: pass a non-zero vector; only the direction matters, but the vector must be non-degenerate.feature.path.spline.tangent-on-2d-only(error) — tangent vectors must be 2D[number, number]arrays (path is planar). Hint: drop the third coordinate.feature.nurbs.bridge-conversion-failed(error) — internal bridge could not lift the JS-fit curve back into aGeom_BSplineCurve. Hint: reduce waypoint count or relax tolerance; surfaces with > 200 waypoints occasionally hit this on tight fits.
Reference-driven surfacing — derive sections, don't free-hand them
For an organic body (car body, helmet, fairing, ergonomic shell) do NOT type cross-section coordinates by eye and iterate in a chat loop — eyeballed waypoints never converge. Derive the curves from a reference photo:
- Trace the silhouette. Run
trace_from_image(pure-JS contour tracer) on the reference and lift the normalized[0,1]waypoints to mm via a scale anchor. Seekernelcad-from-reference/kernelcad-trace-from-imagefor the tracing pipeline and the Y-flip. - Derive sections from the traced outline. Build the profile/section curves
from the traced waypoints (
path().spline(...),nurbsCurve(...),spline3d(...)), then skin or sweep them —surfaceFromCurves(sections),variableSweep(spine, sections), orsurfaceFromBoundary([...]). Let the trace own the shape; the agent owns scale, depth, and symmetry. - Finish with SMALL edge radii. Tight radii (~20–25 mm) read as crisp-but-curved panels. Huge corner radii (≥ ~120 mm) melt the body into a featureless blob — the silhouette gate passes but the shape stops reading as the object.
Surfacing gotchas (real, verified — not hypothetical)
- Swept / lofted / revolved solids LOSE canonical face names. A
surfaceFromCurves/variableSweep/revolve result has no'top'/'bottom'faces. Select its faces with the query DSL (kc.q.face({ ... })) or list them viainspect— never the cardinal-name shortcuts. - Rational NURBS surface weights are honored (Slice E, v0.14.0). Supplying
weightsbuilds an exact rational surface — use them for exact circles, cylinders, spheres, and conics rather than approximating with control points. (3DnurbsCurveweights are still non-rational; that lane is deferred.) - Trim and sew freeform NURBS surfaces into a watertight solid (Slice E/F).
.trimTo(cutter)aligns a patch to a shared boundary by imprinting the section curve; clean curved NURBS/Coons patches are supported..split(cutter)returns both halves as[Surface, Surface]when you need both sides. Thensew([...], { requireClosed: true })fuses coincident-edged patches into a closed solid, then optionally.draft(angle, { face })tapers a face for mold release (analytic faces only; OCCT refuses to draft spline faces, emittingfeature.draft.failed). A single patch can still.thicken()to a solid. Face-face blends and standalone surface offset are deferred to a later slice.
Blockout → export → finish in a DCC tool
For photoreal or heavily-sculpted organic surfacing, the native workflow is to build the parametric, watertight blockout and printable solids in kernelCAD, then export for downstream subdivision / organic surfacing in a DCC tool. This is the intended interop handoff: kernelCAD owns the parametric, dimensioned, manufacturable body; the DCC tool owns final continuous sculpting.
# BREP handoff — named bodies + per-part colors preserved
kernelcad export step body.kcad.ts -o body.step
# Mesh handoff — triangulated, PBR materials + axis convention preserved
kernelcad export glb body.kcad.ts -o body.glb
Confirmed real export targets (the unified export tool / kernelcad export
CLI, format-enum dispatched): STEP (one named body per part, colors
preserved), GLB (MeshPhysicalMaterial PBR + KHR_materials_* extensions,
Z-up→Y-up axis option), STL (binary, watertight-verified),
3MF (watertight-gated, print units), plus DXF, SVG-drawing, URDF, SRDF,
SDF-Gazebo. For organic-surfacing handoff prefer STEP (carries true BREP for
a NURBS-capable DCC) or GLB (carries mesh + materials for a subdivision /
sculpting tool).
Related skills
kernelcad-authoring— primitives + sketches still cover most shapes; reach for NURBS only when the freeform contour can't be expressed.kernelcad-features—.thicken(t)returns a Shape that participates in all standard booleans and face/edge features.kernelcad-from-reference— when matching a domed/curved real object (lens, dial, dome);kernelcad-trace-from-imagesub-skill drives the reference-trace pipeline above.