@constructorfleet/sketchforge3d ​

A typed client for the SketchForge REST API, and a runner for macros: short parameterised scripts that build a part.

bash
npm install @constructorfleet/sketchforge3d

The API is headless -- it reads and writes projects in server-side storage with no browser open -- so a macro is a build script for a design, the way a Makefile is for a binary.

The MCP bridge ​

This package also carries the SketchForge MCP server. It works against local project files by default; an LLM can pass a url on an individual tool call to target a running SketchForge server instead:

json
{
  "mcpServers": {
    "sketchforge": {
      "command": "npx",
      "args": ["-y", "-p", "@constructorfleet/sketchforge3d", "sketchforge-mcp"],
      "env": { "SKETCHFORGE_PROJECTS_DIR": "/path/to/SketchForgeProjects" }
    }
  }
}

It speaks stdio JSON-RPC. Without url, project and geometry tools use the local engine and SKETCHFORGE_PROJECTS_DIR, with no browser, HTTP server, or API token. Pass url: "http://127.0.0.1:3000" to a tool call to use a running server; editor tools then require an open editor tab (/?editor=1). Remote REST operations also require SKETCHFORGE_API_TOKEN in the MCP process environment.

Two of its tools run macros -- the scripts this package's runner executes -- and only files already sitting in SKETCHFORGE_MACRO_DIR. There is deliberately no tool that accepts code to run. A Python macro needs sketchforge3d installed from PyPI, since only a checkout carries the Python source.

Connecting ​

ts
import { SketchForgeClient } from "@constructorfleet/sketchforge3d";

const client = new SketchForgeClient({
  baseUrl: "http://localhost:3000",       // with or without /api/v1
  token: process.env.SKETCHFORGE_API_TOKEN!,
});

const health = await client.check();
if (!health.ok) throw new Error(health.reason);

clientFromEnvironment() does the same from SKETCHFORGE_API_TOKEN and SKETCHFORGE_URL (default http://127.0.0.1:3000). Prefer it: the token is a password for every project on the server and does not belong in a script or in shell history.

The server needs both SKETCHFORGE_API_TOKEN and SKETCHFORGE_PROJECTS_DIR set, or the API answers 404 and 503 respectively. check() tells you which.

The client ​

ts
new SketchForgeClient({ baseUrl, token, fetch?, timeoutMs?, retryOnBusy?, headers?, expectedRevision? })

client.apiRoot: string
client.check(): Promise<{ ok: true; projects: number } | { ok: false; reason: string }>
client.project(projectId): ProjectHandle

client.projects.list(): Promise<ProjectSummary[]>
client.projects.listPage({ limit?, cursor? }): Promise<{ items: ProjectSummary[]; nextCursor: string | null }>
client.projects.get(projectId): Promise<ProjectDetail>
client.projects.create(name): Promise<ProjectSummary>
client.projects.delete(projectId): Promise<DeleteResult>
client.projects.findByName(name): Promise<ProjectSummary | null>

client.shapes.list(projectId): Promise<ShapeSummary[]>
client.shapes.listPage(projectId, { limit?, cursor? }): Promise<{ items: ShapeSummary[]; nextCursor: string | null }>
client.shapes.create(projectId, shape): Promise<ShapeSummary>
client.shapes.createMany(projectId, shapes): Promise<ShapeWriteResult>
client.shapes.update(projectId, shapeId, changes): Promise<ShapePatchResult>
client.shapes.delete(projectId, shapeId): Promise<DeleteResult>
client.shapes.deleteAll(projectId): Promise<number>

client.edges.list(projectId, shapeId): Promise<EdgeSummary[]>
client.edges.filletableIndexes(projectId, shapeId): Promise<number[]>

client.treatments.apply(projectId, shapeId, { kind, amount, angle?, edges? }): Promise<TreatmentResult>
client.treatments.fillet(projectId, shapeId, radius, edges?): Promise<TreatmentResult>
client.treatments.chamfer(projectId, shapeId, distance, { angle?, edges? }): Promise<TreatmentResult>

client.faces.list(projectId, shapeId, { topology? }?): Promise<FaceSummary[]>
client.faces.facing(projectId, shapeId, direction): Promise<number[]>

client.measurements.list(projectId, shapeId): Promise<MeasurementSummary[]>
client.measurements.withRadius(projectId, shapeId): Promise<MeasurementSummary[]>
client.measurements.between(projectId, from, to): Promise<MeasurementPair>
client.properties.get(projectId, shapeId): Promise<ShapeProperties>
client.interference.check(projectId, { ids?, clearance? }): Promise<InterferenceReport>
client.mirrors.bake(projectId, ids, axis): Promise<BakedMirrorResult>
client.explosions.explode(projectId, shapeId): Promise<ShapeWriteResult>
client.thickness.estimate(projectId, shapeId): Promise<WallThicknessEstimate>
client.drawings.dxf(projectId, { plane, offset, shapeIds?, name? }): Promise<SectionDxfArtifact>
client.exports.export(projectId, { format, shapeIds?, name?, view?, featureAngle?, section? }): Promise<ProjectExportArtifact>

client.booleans.cut(projectId, { solidIds, holeIds }): Promise<BooleanCutResult>
client.groups.group(projectId, ids): Promise<GroupResult>
client.groups.ungroup(projectId, shapeId): Promise<ShapeWriteResult>
client.parts.separate(projectId, shapeId): Promise<ShapeWriteResult>

client.shell.apply(projectId, shapeId, { thickness, openFaces? }): Promise<ShellResult>
client.shell.hollow(projectId, shapeId, thickness): Promise<ShellResult>
client.shell.tray(projectId, shapeId, thickness, openFaces?): Promise<ShellResult>
client.draftPrism.apply(projectId, shapeId, angle): Promise<TreatmentResult>

client.align.apply(projectId, { ids, axis, target, anchorId? }): Promise<AlignResult>
client.align.tops(projectId, ids, anchorId?) | bottoms(...) | centers(projectId, ids, axis, anchorId?)

client.distribute.apply(projectId, { ids, axis?, spacing? }): Promise<DistributeResult>
client.distribute.gaps(projectId, ids, axis?) | pitch(projectId, ids, axis?)

client.patterns.apply(projectId, { ids, kind?, count?, stepX?, stepZ?, centerX?, centerZ?, sweepAngle?, rotateCopies? }): Promise<PatternResult>
client.patterns.row(projectId, ids, count, stepX, stepZ?): Promise<PatternResult>
client.patterns.ring(projectId, ids, count, { x, z }, { sweepAngle?, rotateCopies? }?): Promise<PatternResult>

client.imports.apply(projectId, { fileName, bytes, mediaType? }): Promise<MeshImportResult>
client.imports.readable(fileName): boolean

Set expectedRevision to the revision from a project's previous response to send If-Match: "<revision>" with each write request. A stale value raises SketchForgeApiError with status 409 and code revision_mismatch; the error's revision is the current project revision. Use a client configured for one conditional write at a time, then create a new one with the returned revision for the next write.

shapes.create accepts SketchSweepShapeRequest for server-built sketch sweeps. Its profile contains the closed section path(s), one open spine, and optional sweepFrames; the response retains that data as sketchProfile. It also returns the stable sketchSourceId used by project feature metadata. The REST API accepts profile updates that rebuild persisted sketch sweeps. Send the replacement sketchProfile to PATCH /api/v1/shapes, targeting either the output shape ID or its sketchSourceId; the rebuild preserves the same shape and source-sketch IDs. See the REST sweep update contract.

A ProjectHandle is the same surface with the project id bound, which is what a macro works through:

ts
project.get() | delete() | listShapes() | add(shape) | addMany(shapes) | update(id, changes)
project.remove(id) | clear() | edges(id) | filletableEdges(id)
project.treat(id, treatment) | fillet(id, radius, edges?) | chamfer(id, distance, options?)
project.faces(id, { topology? }?) | facesFacing(id, direction)
project.measurements(id) | measurementsWithRadius(id) | measureBetween(from, to) | propertiesOf(id) | interferenceReport({ ids?, clearance? })
project.sectionDrawing({ plane: "xy", offset: 3, shapeIds? })
project.sectionDxf({ plane: "xy", offset: 3, shapeIds? }) | estimateWallThickness(id) | explodeStepAssembly(id)
project.export({ format, shapeIds?, name?, view?, section? })
project.cut({ solidIds, holeIds }) | group(ids) | ungroup(id) | separate(id)
project.mirrorBaked(ids, axis)
project.shell(id, { thickness, openFaces? }) | hollow(id, thickness) | tray(id, thickness, openFaces?)
project.draftPrism(id, angle)
project.align({ ids, axis, target }) | alignTops(ids, anchorId?) | distribute({ ids, axis?, spacing? })
project.pattern(request) | row(ids, count, stepX, stepZ?) | ring(ids, count, { x, z }, options?)
project.importMesh({ fileName, bytes })

Shapes are described as parameters, with a union per kind so a field that belongs to another kind is a compile error:

ts
await project.addMany([
  { kind: "box", name: "Plate", width: 120, depth: 40, height: 6 },
  { kind: "cylinder", width: 8, height: 12, sides: 6, x: -40, elevation: -1, hole: true },
  { kind: "thread", size: "M8", length: 10, hole: true, x: 40 },
  { kind: "revolve", profile: [[8, 0], [10, 0], [10, 2], [8, 2]], pitch: 6, turns: 6 },
]);

addMany is one write of the project package per request and splits lists longer than the server's 200-shape limit -- which also means a longer list is no longer atomic.

For a retained sketch sweep, replace sketchProfile to rebuild the solid while keeping the same shape and source-sketch IDs:

ts
const sweep = await client.shapes.create(projectId, { kind: "sketchSweep", profile });
const widerProfile = {
  ...sweep.sketchProfile!,
  points: sweep.sketchProfile!.points.map((point) =>
    point.sweepRole === "section" ? { ...point, x: point.x * 1.1 } : point,
  ),
};
await client.shapes.update(projectId, sweep.id, { sketchProfile: widerProfile });

exports.export covers every format in PROJECT_EXPORT_FORMATS -- stl, obj, 3mf, step, skf, zip, svg and dxf -- and returns the file whole as data: Uint8Array, with the server's filename, mimeType, bytes and shapeCount. A STEP export also reports exportedCount and the skipped shapes it could not write as exact solids. view (one of EXPORT_VIEWS) applies only to SVG, featureAngle sets the mesh crease angle from 0 to 180 degrees on SVG projections (default 30; exact solids ignore it), and section ({ plane, offset }) applies to SVG and DXF. The client refuses unsupported option combinations before sending anything, with the server's own message:

ts
import { writeFile } from "node:fs/promises";

const stl = await project.export({ format: "stl", name: "bracket" });
await writeFile(stl.filename, stl.data);
const top = await project.export({ format: "svg", view: "top" });

client.faces.list(projectId, shapeId, { topology: "exact" }) and project.faces(shapeId, { topology: "exact" }) select analytic faces, including plain revolves. Omitting the option preserves the default mesh listing; topology: "mesh" selects it explicitly.

ShapePatch also accepts faceDraft to retain one face draft on a sketch extrusion. The source face is named by its stable ID from GET /faces; the draft stores the signed local pull axis and angle. Profile edits replay it only when one planar face matches its original normal. Send faceDraft: null to clear the operation. .skf preserves the operation and source frame; prism drafts and edge treatments cannot be combined with it yet.

Failures are SketchForgeApiError (status, detail, plus looksDisabled, needsStorage, isProjectBusy, and isLimitExceeded) or SketchForgeTransportError. isLimitExceeded recognises 413 limit_exceeded and the legacy too_many_shapes code. A write that lost the project's lock is retried; a locked shape, which answers 409 for an unrelated reason, is not.

Macros ​

A macro is a file that exports a definition and builds something when it is handed a connected project:

js
// macros/bolt-plate.macro.mjs
export default {
  name: "Bolt plate",
  project: { mode: "replace", name: "Bolt plate" },
  params: { holes: 5, spacing: 25, size: "M8", thickness: 8 },

  async run({ project, params, layout, log }) {
    log(`${params.holes} ${params.size} holes`);
    await project.addMany([
      { kind: "box", name: "Plate", width: 200, depth: 40, height: params.thickness },
      ...layout.row(params.holes, params.spacing).map((at, index) => ({
        kind: "thread",
        name: `hole ${index + 1}`,
        size: params.size,
        length: params.thickness + 2,
        hole: true,
        x: at.x,
        z: at.z,
        elevation: -1,
      })),
    ]);
  },
};
bash
SKETCHFORGE_API_TOKEN=... npx sketchforge-macro macros/bolt-plate.macro.mjs --holes=7 --spacing=30

The context carries everything the macro needs -- client, project, params, layout, log -- so a macro file imports nothing and can live in any folder.

Direct command-line operations ​

Use the same sketchforge-macro executable for project management and file transfer without writing a macro:

bash
npx sketchforge-macro projects list
npx sketchforge-macro projects create "Bracket"
npx sketchforge-macro projects delete <project-id>
npx sketchforge-macro export --format stl --out bracket.stl --project <project-id>
npx sketchforge-macro import bracket.stl

Export uses --project or SKETCHFORGE_PROJECT_ID. Import writes into that selected project when given; without one, it creates a project named after the file. Direct file import supports STL, OBJ, and SVG; export supports STL, OBJ, 3MF, STEP, SKF, ZIP, SVG, and DXF.

Project modes, and why there is no default ​

The second run is what makes a macro useful or dangerous. A script that appends to a project it found doubles the part every time, and nobody notices until they open the project. So the mode is declared and the runner, not the macro body, acts on it:

projectWhat the runner does firstUse it for
"fresh"Creates a new project, name stamped with the timeSomething you generate several of to compare
{ mode: "replace", name }Finds that project and empties it, or creates itA part you iterate on; re-running is idempotent
{ mode: "reuse", name }Finds or creates it, and leaves the contents aloneAdding a feature to whatever is there
"target"Requires SKETCHFORGE_PROJECT_ID or --project, touches nothing elseOperating on a project someone else named

A module that exports a bare function instead of a definition runs in a fresh project, because that is the only mode that is safe to assume.

Parameters ​

params is both the defaults and the schema. A command-line override is coerced to the type of its default, and a name the macro does not declare is refused -- so --hoels=7 is an error rather than a silently ignored typo. Parameters are resolved before the project is created, so a bad one costs nothing.

Examples ​

examples/ holds the four worked macros, which double as the integration tests:

MacroBuilds
bolt-plate.macro.mjsA plate sized around a computed row of tapped holes
bolt-circle.macro.mjsA flange with N tapped holes on a ring of radius R
filleted-box.macro.mjsA box from three numbers, with every roundable edge filleted on the server
tap-drill-plate.macro.mjsA plate cut with an M8 thread through project.cut, exported as STL

Tapped holes are kind: "thread" with hole: true: the form is cut wider by a clearance and the shape is marked as a hole, so grouping it with the plate in the editor or through project.group taps the plate. project.cut can also subtract a thread mesh directly. tap-drill-plate.macro.mjs runs the whole cut-and-export workflow through the API with an M8 thread cutter.

Development ​

bash
npm run client:build       # compile to dist/, from the repository root
npm run client:typecheck   # including the tests
npm test                   # unit tests, in the repository's vitest run
npm run test:integration   # the examples, against a live server

The integration tests need a real server and are not part of npm test:

bash
SKETCHFORGE_PROJECTS_DIR=$(mktemp -d) SKETCHFORGE_API_TOKEN=test-token \
  npx next dev apps/web -p 3100 &
SKETCHFORGE_API_TOKEN=test-token SKETCHFORGE_URL=http://127.0.0.1:3100 npm run test:integration

Publishing ​

See docs/PUBLISHING_CLIENTS.md for what has to be configured on npm before the release workflow can publish this package.

Local MCP mode ​

The MCP uses the extracted engine package directly against project files, with no browser, HTTP server, or API token. Set SKETCHFORGE_PROJECTS_DIR to the directory containing <projectId>.json and <projectId>.skf files:

bash
SKETCHFORGE_PROJECTS_DIR="$HOME/SketchForgeProjects" npm run mcp:sketchforge

The directory uses the same <projectId>.json and <projectId>.skf files as server-side project storage. In local mode, use sketchforge_list_projects to discover projects, sketchforge_create_project to start an empty one, or sketchforge_import_project with a local .skf filePath to add an existing package. sketchforge_import_file imports .stl, .obj, or .svg files into a chosen project; its options are projectId, filePath, and optional url. The source filename supplies the imported shape name, and color and placement use importer defaults. With a url, the file is read from the MCP host and sent as base64 to POST /api/v1/imports using SKETCHFORGE_API_TOKEN. sketchforge_import_stl remains a local-only compatibility alias that also accepts name, color, x, z, and elevation; it rejects url. Local import and export paths are confined to SKETCHFORGE_MCP_FILES_DIR, or to SKETCHFORGE_PROJECTS_DIR when the new setting is unset. Set overwrite: true to replace an existing export; otherwise existing files are preserved. For compatibility, paths are unrestricted when both root variables are unset. Symlinks are resolved before checking the file root. The configured directory is created when needed. These tools return the new projectId for other project tools. sketchforge_read_scene accepts shapeIds, excludeShapeIds, includeRawShapes, and includeRawShapeIds; raw mesh geometry is omitted by default and can be requested for selected shapes. Mutation tools return compact shape metadata, while sketchforge_list_objects always returns object summaries. STL, OBJ, 3MF, ZIP-kit, SKF, STEP, and SVG exports can write to outputPath; the project name supplies the default filename. sketchforge_project_view returns the SVG drawing itself -- the kernel's hidden-line projection of the stored geometry, as the four-view grid or any one named view -- which is the readable picture of a part for an agent without vision. sketchforge_section_view returns section contours as SVG for a named xy, xz, or yz plane and an offset; the plane must cross the selected geometry. Exact shapes are cut by the kernel and mesh shapes are sliced directly, up to 2,000,000 mesh triangles across the selection.

Local mode covers project reads and edits, shape creation/import, arranging, mirroring, grouping, booleans, edge and face operations, measurements and per-shape mass properties, hollowing, mesh and STEP exports, scene topology, clearance and intersection checks, and JavaScript macros. Image capture rasterises that same drawing to a PNG (a named face is one view; home and current are the grid); it does not capture editor chrome or the live viewport camera. The npm MCP client and @constructorfleet/sketchforge-engine are published together by the release workflow. Editor discovery and editor error inspection require a URL and open editor. Python macros require a URL and the HTTP adapter.


Documentation version: 1.30.0 · Repository revision: 1cb9fee8dff4d394684c229dd67a21da539d167f · View source