Sketch → constrain → solve → solid

cadjoint models CAD workflows with two trees sharing one set of parameters:

The construction tree is never part of the SDF render; it is projected through the same camera and drawn as a wireframe overlay.

import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt

from cadjoint import apply_parameters, extract_parameters, functionalize
from cadjoint.constraints import DistanceConstraint, FixedConstraint
from cadjoint.constraints.solve import project_to_manifold, solve_constraints
from cadjoint.construction import PolygonProfile, SketchPlane, extrude, revolve
from cadjoint.render import (
    Camera,
    Material,
    RenderSettings,
    Scene,
    draw_plane,
    draw_profile,
    render_scene,
)
from cadjoint.sdf import volume
from cadjoint.sdf.primitives import Plane

1. Sketch a profile

A PolygonProfile lives on a SketchPlane. Raw vertex arrays become free Vector2 parameters (2 DOF each) — editable until constrained, like a CAD sketch.

profile = PolygonProfile(
    [[0.1, -0.1], [1.9, 0.2], [1.0, 1.5]],
    plane=SketchPlane(origin=[0.0, -0.5, 0.0], normal=[0.0, 0.0, 1.0]),
    name="tri",
)
profile.vertices
[Vector2(value=Array([ 0.1, -0.1], dtype=float32), free=True, name='tri_v0', bounds=None),
 Vector2(value=Array([1.9, 0.2], dtype=float32), free=True, name='tri_v1', bounds=None),
 Vector2(value=Array([1. , 1.5], dtype=float32), free=True, name='tri_v2', bounds=None)]

2. Constrain and solve

Anchor two vertices and pin the apex with two distance constraints: 6 DOF, 6 constraint equations — exactly constrained. solve_constraints walks the construction tree, finds the constraints registered on its parameters, and solves with Levenberg–Marquardt. The solution lands on an equilateral triangle.

v0, v1, v2 = profile.vertices
FixedConstraint(v0, [0.0, 0.0])
FixedConstraint(v1, [2.0, 0.0])
DistanceConstraint(v0, v2, 2.0)
DistanceConstraint(v1, v2, 2.0)

solved = solve_constraints(profile)
for name, value in solved.items():
    print(f"{name}: {value}")
tri_v0: [-9.3816510e-11 -1.6287771e-10]
tri_v1: [2.000000e+00 1.165148e-10]
tri_v2: [1.        1.7320509]

3. Generate a solid and render with the sketch overlay

extrude produces an SDF primitive that shares the profile’s vertex parameters and places it on the sketch plane. We render the solid with the forward renderer, then draw the construction tree on top: dashed plane outline, profile edges, vertex handles.

solid = extrude(profile, depth=0.6, material=Material(color=[0.9, 0.55, 0.08], roughness=0.35))

scene = Scene(
    solid | Plane(-1.2, material=Material(color=[0.12, 0.14, 0.18], roughness=0.8)),
    camera=Camera(position=(3.2, 2.4, 4.6), target=(0.9, 0.2, 0.0), fov=0.42),
    light_directions=((0.4, 1.0, 0.5), (-0.5, 0.4, -0.3)),
    light_colors=((1.3, 1.2, 1.05), (0.25, 0.3, 0.45)),
)
settings = RenderSettings.balanced((240, 320))

# write solved values back into the shared params: the render below uses
# the constraint-solved sketch
apply_parameters(profile, solved)

image = render_scene(scene, settings)
fig, ax = plt.subplots(figsize=(8, 6))
ax.imshow(image)
draw_plane(ax, profile.plane, scene.camera, settings.resolution, extent=1.8)
draw_profile(ax, profile, scene.camera, settings.resolution)
ax.axis("off");

4. Revolve

The same profile machinery drives solids of revolution. Profile coordinates are (radial, height); the profile revolves around the sketch plane’s local Y axis.

washer = PolygonProfile([[0.9, -0.18], [1.35, -0.28], [1.35, 0.28], [0.9, 0.18]], name="washer")
ring = revolve(washer, material=Material(color=[0.78, 0.08, 0.05], roughness=0.4))

ring_scene = Scene(
    ring | Plane(-0.6, material=Material(color=[0.12, 0.14, 0.18], roughness=0.8)),
    camera=Camera(position=(2.6, 2.2, 3.8), target=(0.0, 0.0, 0.0), fov=0.5),
)
image = render_scene(ring_scene, settings)
fig, ax = plt.subplots(figsize=(8, 6))
ax.imshow(image)
draw_profile(ax, washer, ring_scene.camera, settings.resolution)
ax.axis("off");

5. Constrained optimization through the generated solid

The full differentiable-CAD loop: a quad sketch with one anchor and one pinned edge length (3 constraint equations on 8 DOF), extruded into a solid. We optimize the remaining sketch DOF so the solid’s volume hits a target, and project back onto the constraint manifold after every gradient step.

quad = PolygonProfile([[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [0.0, 1.0]], name="quad")
q0, q1, q2, q3 = quad.vertices
FixedConstraint(q0, [0.0, 0.0])
DistanceConstraint(q0, q1, 2.0)

quad_solid = extrude(quad, depth=1.5, material=Material(color=[0.05, 0.2, 0.7], roughness=0.3))
free, fixed, meta = extract_parameters(quad_solid)
fn = functionalize(quad_solid)
target = 6.0


def loss(params):
    vol = volume(fn(params, fixed), bounds=(-1, -1, -1), size=(4, 4, 2), resolution=32)
    return (vol - target) ** 2


params = dict(free)
history = []
for _ in range(40):
    value, grads = jax.value_and_grad(loss)(params)
    history.append(float(value))
    params = {k: v - 0.05 * grads[k] for k, v in params.items()}
    params = project_to_manifold(params, meta, steps=2)

vol = volume(fn(params, fixed), bounds=(-1, -1, -1), size=(4, 4, 2), resolution=64)
print(f"final volume: {float(vol):.3f} (target {target})")
print(
    f"edge length: {float(jnp.linalg.norm(params['quad_v1'] - params['quad_v0'])):.4f} (pinned 2.0)"
)

plt.figure(figsize=(6, 3))
plt.semilogy(history)
plt.xlabel("step")
plt.ylabel("loss")
plt.title("Volume target on the constraint manifold");
final volume: 6.278 (target 6.0)
edge length: 2.0000 (pinned 2.0)

6. Optimized solid with its sketch

apply_parameters is the inverse of extract_parameters: it writes a name-keyed value dict back into the tree’s free parameters. Since the solid and the sketch share their parameters, applying to either updates both — the overlay below shows the final constrained profile driving the solid.

apply_parameters(quad, params)

quad_scene = Scene(
    quad_solid | Plane(-1.0, material=Material(color=[0.12, 0.14, 0.18], roughness=0.8)),
    camera=Camera(position=(3.6, 2.8, 5.0), target=(1.0, 0.6, 0.0), fov=0.45),
)
image = render_scene(quad_scene, settings)
fig, ax = plt.subplots(figsize=(8, 6))
ax.imshow(image)
draw_plane(ax, quad.plane, quad_scene.camera, settings.resolution, extent=2.2)
draw_profile(ax, quad, quad_scene.camera, settings.resolution)
ax.axis("off");

Everything above is one parameter set: the constraint solver, the optimizer, the SDF renderer, and the overlay all read and write the same Vector2 objects. Next steps for this workflow: sketch-on-face datums (deriving a SketchPlane from an existing solid’s geometry), more 2D entities (arcs, fillets via smooth booleans), and interactive vertex dragging in the WebGPU playground.