partforge 0.133.0 → 0.133.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -319,7 +319,7 @@ and asks for the existing positional view argument
319
319
  ## Materials and appearance
320
320
 
321
321
  A sub-part's `display` block says how it LOOKS. Appearance never changes
322
- geometry, exports (other than 3MF colour, below), `measure` or `verify`, and a
322
+ geometry, exports (other than export colours, below), `measure` or `verify`, and a
323
323
  mistake in it never fails a build — lint warns and the viewer falls back.
324
324
 
325
325
  ```js
@@ -336,7 +336,7 @@ meta: { title: "…", units: "mm", environment: "studio" },
336
336
  else the viewer's blue-grey — and **realistic mode shows it as a PLA print**
337
337
  (`pla-print`'s finish and layer lines) in that same colour. So an untouched
338
338
  part looks printed in realistic mode; name a material when it is made some
339
- other way. The PLA look is realistic-only: the CAD view, 3MF colours and
339
+ other way. The PLA look is realistic-only: the CAD view, export colours and
340
340
  `declaresMaterials` are unaffected, and a part that names no material is not
341
341
  treated as declaring one.
342
342
  - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
@@ -378,9 +378,11 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
378
378
 
379
379
  Presets without a pattern ignore it.
380
380
 
381
- **3MF export** carries each sub-part's colour (its `color`, else its preset's
382
- colour), so a multi-colour print opens in a slicer already split and coloured.
383
- A part with no `color` or `material` exports exactly as before.
381
+ **3MF and STEP export** carry each sub-part's colour (its `color`, else its
382
+ preset's colour), so a multi-colour print opens in a slicer already split and
383
+ coloured, and each STEP body opens in a CAD tool in its own colour. A part with
384
+ no `color` or `material` exports a 3MF exactly as before; its STEP bodies take
385
+ the viewer's blue-grey.
384
386
 
385
387
  | Preset | Name | Tintable | Use |
386
388
  | --- | --- | --- | --- |
@@ -296,7 +296,7 @@ above. All ops return a `Solid`.
296
296
  | `vector2d(name, {shape?, width\|height\|fit, align?, valign?})` | A declared vector document → `Shape2D`. `name` = a declared name in the part's `vectors` field (`partforge-vector` JSON — authored by hand or ingested from an `.svg`; never raw `.svg`). With no `shape`, the document's own composition: every `"add"` shape unioned minus every `"subtract"` shape; `shape` selects one named shape's geometry whatever its role. Sizing follows the document's required `units`: `"artwork"` requires exactly one of `width`/`height`/`fit` in millimetres, `"mm"` places as authored (scale 1, no translate) and accepts a size option optionally; more than one is refused. Uniform scale in every case (`fit` = larger extent); `align`/`valign` position it, same as `text2d`, defaulting to centre/middle for `"artwork"` and to no translate for `"mm"`. **The composed call derives ONE transform from every region in the document**, `"add"` and `"subtract"` alike, so a size or align option cannot scale the subtracts relative to the adds; a `shape` call is measured against that shape alone. **Primitive contours (`circle`/`rect`/`polygon`) expand to ordinary path contours at the format boundary, in `vector-format.js`, so this op's kernel-facing contract is unchanged by them** — nothing below the loader learns primitives exist. Conformance: **both backends** (it lowers to `shape2d` + `union`, exactly like `text2d`). Parity: **identical across backends by construction** — the regions are curve-native (arcs/cubics), so there is no sampling step for the two backends to diverge over. |
297
297
  | `hull(inputs[])` | Convex hull of all inputs (each a `Shape2D`, a curve contour, or an `[[x,y],…]` point list) → a convex `Shape2D`. Backend-agnostic: a pure-JS monotone-chain hull over the inputs' sampled points (curved inputs tessellated at a fixed LOD), lifted via `shape2d` (see the parity note below). Throws on an empty input array or a degenerate (collinear/point-count < 3) hull. |
298
298
  | `hullChain(inputs[])` | Swept hull over an ordered sequence of ≥2 inputs (same input forms as `hull`): the union of `hull([inᵢ, inᵢ₊₁])` for each consecutive pair — e.g. a tapered link connecting a row of circles. Throws with fewer than 2 inputs. |
299
- | `toSTEP(named[])` | `[{name, solid}]` → `Promise<ArrayBuffer>` of a STEP assembly. B-rep class only. |
299
+ | `toSTEP(named[])` | `[{name, solid, color?}]` → `Promise<ArrayBuffer>` of a STEP assembly. B-rep class only. `color` (`0xRRGGBB`, optional) is the body's surface colour, written as that exact sRGB value; a body without one (or with an unusable one) is written in the viewer's no-material blue-grey (`0x9fb4cc`), never a kernel default. The framework passes each sub-part's display colour — the one its 3MF object gets. Every file is AP242, its header's `FILE_SCHEMA` and its body alike — the first export in a process included. Additive: `CONTRACT_VERSION` stays 4, the `import` precedent. |
300
300
  | `import(name)` | Previously-registered imported geometry (STEP/STL/3MF, declared in the part's `imports` field) as an ordinary `Solid`. Required on both in-repo backends (Manifold accepts mesh formats, OCCT accepts STEP); a format the routed backend can't use throws lazily, at this call, not at registration. Fed by an underscore-prefixed side-channel, not part authors — see [Conformance classes](#conformance-classes). Additive: not in `OCCT_ONLY_OPS`; needed no `CONTRACT_VERSION` bump of its own (v3 came from the mesh fillet/chamfer change, not this op). |
301
301
 
302
302
  `hull`/`hullChain` parity: point-list and curve-contour inputs hull bit-identically
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.133.0",
3
+ "version": "0.133.2",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -160,7 +160,7 @@ export const ROUTED_CAD_OPS = ["shell"];
160
160
  * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|{start:number[],segments:object[]}|Shape2D) => Shape2D} shape2d 2-D boolean value; one shared contour-storage implementation on both backends
161
161
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
162
162
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
163
- * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
163
+ * @property {(named:{name:string,solid:Solid,color?:number|null}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError); `color` is 0xRRGGBB per body, absent = the viewer's no-material colour
164
164
  * @property {(name: string) => Solid} import imported geometry declared in the part's imports field (registered pre-build by the framework via the underscore-prefixed `_registerImport`/`_importDigest`/`_acceptsStep`/`_acceptsMesh` side-channel, not a part author's calling surface)
165
165
  * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (both backends)
166
166
  * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
@@ -37,6 +37,7 @@ import { composePose, transformPositions, rotateNormals } from "./pose.js";
37
37
  import { filterBrepEdges } from "./brep-edges.js";
38
38
  import { meshToStl } from "./mesh-stl.js";
39
39
  import { heightfieldMesh, hashGridData } from "./heightfield.js";
40
+ import { writeStep } from "./occt-step.js";
40
41
  const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.25 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
41
42
 
42
43
  // Heightfield sew-time warning threshold, in triangles. Measured by the plan's
@@ -112,7 +113,7 @@ function stlBufferToShape(replicad, arrayBuffer) {
112
113
 
113
114
  export function createOcctKernel(replicad) {
114
115
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
115
- loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
116
+ loft, draw, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
116
117
 
117
118
  // Feature-skip warnings: everything occt-repair (and roundAll) skips or rescues
118
119
  // is recorded here as well as console.warned, and jobs.js drains it per
@@ -755,7 +756,10 @@ export function createOcctKernel(replicad) {
755
756
  // region-in/region-out form directly. `_`-prefixed — not part of the public kernel
756
757
  // surface.
757
758
  _offsetRegions: offsetRegions,
758
- toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))).arrayBuffer(),
759
+ // Our own writer, not replicad's exportSTEP, so colours come out right (see
760
+ // occt-step.js). `color` per body is optional; jobs.js passes the sub-part's
761
+ // printColor, the same colour its .3mf object gets.
762
+ toSTEP: async (named) => writeStep(replicad, named.map(({ name, solid, color }) => ({ name, shape: solid._mat()._s, color }))),
759
763
  // Imported geometry, registered pre-build by the framework via `_registerImport`
760
764
  // (ensureImports, Task 8). Every call clones the master shape — replicad ops
761
765
  // consume their operands, and the master must never be handed out directly, or
@@ -0,0 +1,80 @@
1
+ // STEP writer for the OCCT backend: replicad 0.23.1's `exportSTEP` (and the
2
+ // `createAssembly` it calls), changed in how each body's colour is made and in
3
+ // when the writer's model is made (so every file's header names AP242; below).
4
+ //
5
+ // replicad gets colour wrong twice. A body with no colour is painted its default
6
+ // red, so every exported part opened red in a CAD tool. And a colour it is given
7
+ // is built with `Quantity_ColorRGBA(r/255, g/255, b/255, a)` — a LINEAR-RGB
8
+ // constructor — from an sRGB hex, while OCCT's STEP writer converts linear to
9
+ // sRGB on the way out, so each colour landed lighter than asked (#3366cc was
10
+ // written as #7caae7). Here every body gets a colour (the viewer's no-material
11
+ // drafting colour when it has none of its own), converted sRGB → linear with
12
+ // OCCT's own Convert_sRGB_To_LinearRGB — the exact inverse of the writer's
13
+ // conversion — before it reaches that constructor, so a body's COLOUR_RGB is
14
+ // the 0xRRGGBB the caller passed. (Building the colour as Quantity_TOC_sRGB
15
+ // directly would be tidier, but this WASM build does not bind that enum.)
16
+ //
17
+ // Everything else — the XCAF document, naming, the writer's modes and statics,
18
+ // the FS round trip, and which native objects are handed to GC — is replicad's
19
+ // own, so this writes the same file replicad did apart from the colours and the
20
+ // first file in a process, whose header replicad left saying AP214. The
21
+ // document handle passed to Transfer is deliberately never freed: the document
22
+ // itself is owned by its WrappingObj (as replicad's AssemblyExporter owns it),
23
+ // and freeing the handle too would destroy it twice.
24
+ import { NO_MATERIAL_COLOR } from "../materials/resolve.js";
25
+
26
+ const isColor = (c) => Number.isInteger(c) && c >= 0 && c <= 0xffffff;
27
+
28
+ // 0xRRGGBB → sRGB channels in 0..1. A missing or unusable colour falls back to
29
+ // the no-material colour: appearance never fails an export.
30
+ function srgbChannels(color) {
31
+ const c = isColor(color) ? color : NO_MATERIAL_COLOR;
32
+ return [(c >> 16) & 255, (c >> 8) & 255, c & 255].map((v) => v / 255);
33
+ }
34
+
35
+ // bodies: [{ name, shape (a replicad Shape), color? (0xRRGGBB) }] → ArrayBuffer.
36
+ export function writeStep(replicad, bodies) {
37
+ const oc = replicad.getOC();
38
+ const r = replicad.GCWithScope();
39
+ const text = (s) => r(new oc.TCollection_ExtendedString_2(s, true));
40
+
41
+ const doc = new replicad.WrappingObj(new oc.TDocStd_Document(text("XmlOcaf")));
42
+ oc.XCAFDoc_ShapeTool.SetAutoNaming(false);
43
+ const mainLabel = r(doc.wrapped.Main());
44
+ const shapeTool = oc.XCAFDoc_DocumentTool.ShapeTool(mainLabel).get();
45
+ const colorTool = oc.XCAFDoc_DocumentTool.ColorTool(mainLabel).get();
46
+ for (const { name, shape, color } of bodies) {
47
+ const node = r(shapeTool.NewShape());
48
+ shapeTool.SetShape(node, shape.wrapped);
49
+ oc.TDataStd_Name.Set_1(node, text(name));
50
+ const [red, green, blue] = srgbChannels(color).map((v) => oc.Quantity_Color.Convert_sRGB_To_LinearRGB_1(v));
51
+ colorTool.SetColor_3(node, r(new oc.Quantity_ColorRGBA_5(red, green, blue, 1)), oc.XCAFDoc_ColorType.XCAFDoc_ColorSurf);
52
+ }
53
+ shapeTool.UpdateAssemblies();
54
+
55
+ const session = r(new oc.XSControl_WorkSession());
56
+ const sessionHandle = r(new oc.Handle_XSControl_WorkSession_2(session));
57
+ const writer = r(new oc.STEPCAFControl_Writer_2(sessionHandle, false));
58
+ oc.Interface_Static.SetIVal("write.surfacecurve.mode", true);
59
+ oc.Interface_Static.SetIVal("write.precision.mode", 0);
60
+ oc.Interface_Static.SetIVal("write.step.assembly", 2);
61
+ oc.Interface_Static.SetIVal("write.step.schema", 5);
62
+ // The header's FILE_SCHEMA is fixed when the writer's model is made, which
63
+ // constructing the writer does — before the statics above are set. They cannot
64
+ // go first: the process's first STEP writer is what defines them, so until then
65
+ // SetIVal is a silent no-op. So the first file in a process said AP214 (OCCT's
66
+ // default) over an AP242 body. Re-initialising with a fresh model now stamps
67
+ // the header with the schema the body is written in, on every export.
68
+ writer.Init(sessionHandle, true);
69
+ writer.SetColorMode(true);
70
+ writer.SetLayerMode(true);
71
+ writer.SetNameMode(true);
72
+ const progress = r(new oc.Message_ProgressRange_1());
73
+ writer.Transfer_1(new oc.Handle_TDocStd_Document_2(doc.wrapped), oc.STEPControl_StepModelType.STEPControl_AsIs, null, progress);
74
+
75
+ const filename = "partforge-export.step";
76
+ if (writer.Write(filename) !== oc.IFSelect_ReturnStatus.IFSelect_RetDone) throw new Error("STEP write failed");
77
+ const file = oc.FS.readFile(`/${filename}`);
78
+ oc.FS.unlink(`/${filename}`);
79
+ return file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
80
+ }
@@ -439,7 +439,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
439
439
  if (names.length === 0) throw new Error("no exportable parts selected");
440
440
  const solids = names.map((name) => {
441
441
  onProgress(`building ${label(name)}`);
442
- return { name: exportName(name), solid: posed(name, "export", onProgress) };
442
+ return { name: exportName(name), solid: posed(name, "export", onProgress), color: printColor(part.parts[name]?.display) };
443
443
  });
444
444
  onProgress("writing STEP file");
445
445
  const data = await kernel.toSTEP(solids);