partforge 0.153.0 → 0.154.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.153.0",
3
+ "version": "0.154.0",
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",
@@ -11,6 +11,16 @@
11
11
  // rotation; parenting the glTF root under the pivot would turn the part twice.
12
12
  // Undoing the root here hands the viewer geometry in the frame it already owns.
13
13
  //
14
+ // Feature edges (encoder version 2): a sub-part's mesh may carry a second
15
+ // primitive, mode LINES, holding the kernel's edge segments. GLTFLoader then
16
+ // makes the node a Group of a Mesh and a LineSegments (the node's extras and
17
+ // name on the Group, not the Mesh), so each surface and its lines are matched
18
+ // through the glTF NODE they belong to. Lines go back to model frame through
19
+ // the same matrix as the positions; a node with no line primitive — version 1,
20
+ // or a sub-part whose edges were empty — decodes to an EMPTY Float32Array,
21
+ // which the viewer reads as "no feature edges" (never undefined, which would
22
+ // make it derive lines by angle).
23
+ //
14
24
  // Rejects on anything that is not a decodable GLB; the callers (mount's
15
25
  // `preview`, mountPreview) decide what a failure means for them.
16
26
  import { Matrix3, Matrix4, Vector3 } from "three";
@@ -50,15 +60,50 @@ function metaOf(extras) {
50
60
  return Object.keys(out).length ? out : null;
51
61
  }
52
62
 
63
+ // The object standing for a glTF node: the object itself when its node had one
64
+ // primitive, else the Group GLTFLoader made for a multi-primitive mesh — the
65
+ // one carrying the node's extras / name (a primitive's own Mesh has neither).
66
+ function nodeObjectOf(obj) {
67
+ const own = obj.userData ?? {};
68
+ if ("partforgeSubPart" in own || "name" in own) return obj;
69
+ const parent = obj.parent;
70
+ return parent?.isGroup && parent.userData && ("partforgeSubPart" in parent.userData || "name" in parent.userData) ? parent : obj;
71
+ }
72
+
73
+ // Line segments as endpoint pairs in model frame (an indexed LINES primitive —
74
+ // weld() indexes it — expanded back to pairs).
75
+ function readSegments(geometry, matrix) {
76
+ const position = geometry.getAttribute("position");
77
+ if (!position || position.count === 0) return null;
78
+ const points = readVec3(position, matrix, false);
79
+ if (!geometry.index) return points;
80
+ const index = geometry.index.array;
81
+ const out = new Float32Array(index.length * 3);
82
+ for (let i = 0; i < index.length; i++) out.set(points.subarray(index[i] * 3, index[i] * 3 + 3), i * 3);
83
+ return out;
84
+ }
85
+
53
86
  export async function decodePreviewGlb(bytes) {
54
87
  const data = toArrayBuffer(bytes);
55
88
  const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
56
89
  const gltf = await loader.parseAsync(data, "");
57
90
  gltf.scene.updateMatrixWorld(true);
58
91
  const meshes = [];
92
+ const edgesByNode = new Map(); // node object → model-frame segment arrays
93
+ const meshByNode = new Map(); // node object → its surface payload
59
94
  const toModel = new Matrix4();
60
95
  const normalMatrix = new Matrix3();
61
96
  gltf.scene.traverse((obj) => {
97
+ if (obj.isLineSegments) {
98
+ toModel.multiplyMatrices(Y_UP_TO_Z_UP, obj.matrixWorld);
99
+ const segments = readSegments(obj.geometry, toModel);
100
+ if (segments) {
101
+ const node = nodeObjectOf(obj);
102
+ edgesByNode.set(node, [...(edgesByNode.get(node) ?? []), segments]);
103
+ }
104
+ obj.geometry.dispose();
105
+ return;
106
+ }
62
107
  if (!obj.isMesh) return;
63
108
  const geometry = obj.geometry;
64
109
  const position = geometry.getAttribute("position");
@@ -67,19 +112,31 @@ export async function decodePreviewGlb(bytes) {
67
112
  normalMatrix.getNormalMatrix(toModel);
68
113
  const normal = geometry.getAttribute("normal");
69
114
  const indices = geometry.index ? Uint32Array.from(geometry.index.array) : null;
70
- const { partforgeSubPart, partforgeDisplay, name: originalName } = obj.userData;
71
- meshes.push({
115
+ const node = nodeObjectOf(obj);
116
+ const { partforgeSubPart, partforgeDisplay, name: originalName } = node.userData;
117
+ const payload = {
72
118
  // The writer's own record of the key, then the unsanitized glTF node name.
73
- name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? obj.name),
119
+ name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? node.name),
74
120
  positions: readVec3(position, toModel, false),
75
121
  normals: normal ? readVec3(normal, normalMatrix, true) : null,
76
122
  indices,
77
123
  triangles: (indices ? indices.length : position.count) / 3,
78
- edges: new Float32Array(0), // previews carry no feature edges (v1)
124
+ edges: new Float32Array(0), // filled below from the node's LINES primitive, if any
79
125
  display: partforgeDisplay && typeof partforgeDisplay === "object" ? partforgeDisplay : null,
80
- });
126
+ };
127
+ meshes.push(payload);
128
+ if (!meshByNode.has(node)) meshByNode.set(node, payload);
81
129
  geometry.dispose();
82
130
  });
131
+ for (const [node, parts] of edgesByNode) {
132
+ const payload = meshByNode.get(node);
133
+ if (!payload) continue; // lines with no surface: nothing to draw them on
134
+ const total = parts.reduce((n, p) => n + p.length, 0);
135
+ const edges = new Float32Array(total);
136
+ let at = 0;
137
+ for (const p of parts) { edges.set(p, at); at += p.length; }
138
+ payload.edges = edges;
139
+ }
83
140
  const extras = gltf.userData ?? {};
84
141
  return {
85
142
  view: typeof extras.partforgeView === "string" ? extras.partforgeView : null,
@@ -3,8 +3,15 @@
3
3
  // buildView all hold) as a small GLB a viewer can show before the kernel boots.
4
4
  //
5
5
  // The file, which a reader may rely on:
6
- // - one node + mesh + primitive per sub-part, all three named by the sub-part
7
- // key; POSITION + NORMAL (+ indices when the kernel sent them)
6
+ // - one node + mesh per sub-part, both named by the sub-part key; the mesh's
7
+ // FIRST primitive is the surface (TRIANGLES: POSITION + NORMAL, + indices
8
+ // when the kernel sent them); a sub-part with feature edges adds a SECOND
9
+ // primitive, mode LINES, POSITION only and no material — the kernel's edge
10
+ // segments in the same posed frame. A sub-part whose `edges` is empty or
11
+ // absent has no line primitive; a reader takes that as "no feature edges"
12
+ // (never "derive them"). Version-1 files have no line primitives at all.
13
+ // Both primitives share the mesh, so quantize() gives them ONE quantization
14
+ // volume and one node transform: the lines dequantize onto the faces.
8
15
  // - every sub-part node under ONE root node, "partforge", rotated −90° about X:
9
16
  // partforge models Z-up in millimetres and glTF is Y-up. Units stay mm — the
10
17
  // file is for partforge's own viewer; metres would only matter for AR.
@@ -14,20 +21,20 @@
14
21
  // - top-level extras { partforgeView, partforgeEncoder, partforgeMeta? }
15
22
  // - weld() → quantize() → meshopt({ level: "medium" }): 5–8 bytes a triangle,
16
23
  // the recipe measured in partforge-cloud's local_glb_benchmark.md
17
- // Not encoded (v1): feature edges, textures, patterns.
24
+ // Not encoded: textures, patterns. (Feature edges since version 2.)
18
25
  //
19
26
  // Runs in Node, a page and a Web Worker: no three.js, no DOM, no Blob
20
27
  // (test/gltf-layering.test.js). Deterministic: the same input writes the same
21
28
  // bytes, and nothing here mutates the input (exportPreview passes the viewer's
22
29
  // live buffers). Bump PREVIEW_ENCODER_VERSION whenever the bytes for the same
23
30
  // input change — downstream caches key on it.
24
- import { Document, Logger, WebIO } from "@gltf-transform/core";
31
+ import { Document, Logger, Primitive, WebIO } from "@gltf-transform/core";
25
32
  import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
26
33
  import { meshopt, quantize, weld } from "@gltf-transform/functions";
27
34
  import { MeshoptEncoder } from "meshoptimizer/encoder";
28
35
  import { cadAppearance, DISPLAY_KEYS } from "../materials/resolve.js";
29
36
 
30
- export const PREVIEW_ENCODER_VERSION = 1;
37
+ export const PREVIEW_ENCODER_VERSION = 2; // 2: feature edges as a LINES primitive
31
38
 
32
39
  // −90° about X as [x, y, z, w]: model (x, y, z) → glTF (x, z, −y), the viewer's pivot.
33
40
  const Z_UP_TO_Y_UP = [-Math.SQRT1_2, 0, 0, Math.SQRT1_2];
@@ -72,6 +79,13 @@ function checkMesh(m, seen) {
72
79
  if (m.normals != null && m.normals.length > 0 && m.normals.length !== m.positions.length) {
73
80
  throw new Error(`encodePreviewGlb: "${m.name}" normals do not match its positions`);
74
81
  }
82
+ // Any zero-length edges (typed or plain) mean "no feature edges": nothing to check.
83
+ if (m.edges != null && m.edges.length !== 0) {
84
+ if (!ArrayBuffer.isView(m.edges) && !Array.isArray(m.edges)) {
85
+ throw new TypeError(`encodePreviewGlb: "${m.name}" edges must be a typed array or an array of numbers`);
86
+ }
87
+ if (m.edges.length % 6 !== 0) throw new Error(`encodePreviewGlb: "${m.name}" edges are not whole segments`);
88
+ }
75
89
  if (indexed) {
76
90
  const vertexCount = m.positions.length / 3;
77
91
  for (let i = 0; i < m.indices.length; i++) {
@@ -126,9 +140,14 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
126
140
  .setMetallicFactor(look.metalness)
127
141
  .setRoughnessFactor(look.roughness)
128
142
  .setAlphaMode(look.opacity < 1 ? "BLEND" : "OPAQUE"));
143
+ const mesh = doc.createMesh(m.name).addPrimitive(prim);
144
+ if (m.edges?.length) {
145
+ mesh.addPrimitive(doc.createPrimitive().setMode(Primitive.Mode.LINES).setAttribute(
146
+ "POSITION", doc.createAccessor().setType("VEC3").setArray(Float32Array.from(m.edges)).setBuffer(buffer)));
147
+ }
129
148
  const extras = displayExtras(display);
130
149
  root.addChild(doc.createNode(m.name)
131
- .setMesh(doc.createMesh(m.name).addPrimitive(prim))
150
+ .setMesh(mesh)
132
151
  .setExtras(extras ? { partforgeSubPart: m.name, partforgeDisplay: extras } : { partforgeSubPart: m.name }));
133
152
  }
134
153
  if (root.listChildren().length === 0) throw new Error("encodePreviewGlb: every mesh was empty");
@@ -8,8 +8,9 @@
8
8
  // built once the GLB has decoded, from a synthetic part: one sub-part per glTF
9
9
  // mesh, carrying the `display` block the writer stored beside it, and the file's
10
10
  // background/environment meta. The CAD look is exact; realistic mode uses each
11
- // sub-part's display only — no sheet stock, no layer-line print frames (a v1
12
- // residual alongside "no edges"). The decoder loads on demand (it carries the
11
+ // sub-part's display only — no sheet stock, no layer-line print frames (a
12
+ // residual of the format). CAD feature edges come from the file (encoder
13
+ // version 2); a version-1 file decodes with none. The decoder loads on demand (it carries the
13
14
  // GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
14
15
  // Hence `ready`. Every handle method is safe before it resolves.
15
16
  import "./app.css";
@@ -1741,9 +1741,12 @@ export function createViewer(container, part) {
1741
1741
  // draw none rather than falling back. Only a payload with NO edge data at
1742
1742
  // all (edges === undefined; no current backend does this) derives by angle.
1743
1743
  const lg = new LineSegmentsGeometry();
1744
- if (edges) lg.setPositions(edges); // edges is already a well-formed (possibly zero-length) Float32Array
1745
- else lg.fromEdgesGeometry(new THREE.EdgesGeometry(out, EDGE_ANGLE));
1744
+ // The raw segment pairs drawn, kept for getSubMeshPayload (reading them back
1745
+ // out of LineSegmentsGeometry's interleaved instance buffer is awkward).
1746
+ const segments = edges ?? new THREE.EdgesGeometry(out, EDGE_ANGLE).getAttribute("position").array;
1747
+ lg.setPositions(segments); // a well-formed (possibly zero-length) Float32Array
1746
1748
  out.userData.edges = lg;
1749
+ out.userData.edgePositions = segments;
1747
1750
  return out;
1748
1751
  }
1749
1752
 
@@ -1790,7 +1793,9 @@ export function createViewer(container, part) {
1790
1793
  // The geometry drawn for one sub-part, as a fresh worker-style payload with its
1791
1794
  // fast-path pose (setSubPose) baked in — the posed mesh a headless buildView
1792
1795
  // would return. Copies: the caller (exportPreview → partforge/gltf) may keep or
1793
- // transfer them. null when nothing has been delivered under that name.
1796
+ // transfer them. null when nothing has been delivered under that name. Its
1797
+ // `edges` are the lines shown on screen: the kernel's segments, or — when the
1798
+ // delivery carried no edge data at all — the angle-derived fallback lines.
1794
1799
  const _payloadV = new THREE.Vector3();
1795
1800
  const _payloadNormal = new THREE.Matrix3();
1796
1801
  function getSubMeshPayload(name) {
@@ -1811,10 +1816,17 @@ export function createViewer(container, part) {
1811
1816
  _payloadV.fromBufferAttribute(nor, i).applyMatrix3(_payloadNormal).normalize().toArray(normals, i * 3);
1812
1817
  }
1813
1818
  }
1819
+ // Feature edges, posed by the same matrix (an empty array stays empty: "none").
1820
+ const rawEdges = geo.userData.edgePositions ?? new Float32Array(0);
1821
+ const edges = new Float32Array(rawEdges.length);
1822
+ for (let i = 0; i < rawEdges.length; i += 3) {
1823
+ _payloadV.fromArray(rawEdges, i).applyMatrix4(pose).toArray(edges, i);
1824
+ }
1814
1825
  return {
1815
1826
  name,
1816
1827
  positions,
1817
1828
  normals,
1829
+ edges,
1818
1830
  indices: geo.index ? Uint32Array.from(geo.index.array) : null,
1819
1831
  triangles: geo.userData.triangles ?? (geo.index ? geo.index.count : pos.count) / 3,
1820
1832
  };
package/types/gltf.d.ts CHANGED
@@ -20,7 +20,11 @@ export interface PreviewMesh {
20
20
  /** Absent for a non-indexed soup (Manifold); OCCT sends indices. */
21
21
  indices?: Uint32Array | null;
22
22
  triangles?: number;
23
- /** Ignored: feature edges are not encoded in this version. */
23
+ /**
24
+ * Feature-edge segments, endpoint pairs `[x0,y0,z0, x1,y1,z1, …]` in the same posed
25
+ * frame as `positions` (encoder version 2+). Written as a LINES primitive on the
26
+ * sub-part's mesh. Empty or absent writes none, and decodes as "no feature edges".
27
+ */
24
28
  edges?: Float32Array | null;
25
29
  }
26
30
 
@@ -33,7 +37,7 @@ export interface EncodePreviewInput {
33
37
  view?: string | null;
34
38
  }
35
39
 
36
- /** Bumped whenever the encoder writes different bytes for the same input. */
40
+ /** Bumped whenever the encoder writes different bytes for the same input (2: feature edges). */
37
41
  export const PREVIEW_ENCODER_VERSION: number;
38
42
 
39
43
  /** Encode one view's posed meshes as a welded, quantized, meshopt-compressed GLB (Y-up, millimetres). Deterministic. */