partforge 0.23.0 → 0.24.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.
@@ -35,6 +35,33 @@ supplies it via `framework/app.css`, imported by `mount`).
35
35
 
36
36
  ---
37
37
 
38
+ ## Before geometry: state the engineering intent
39
+
40
+ For a decorative or low-consequence part, a short dimensional description may be
41
+ enough. For anything that mates with another object, carries load, or could cause harm
42
+ if it fails, write down the engineering intent **before** writing `build`:
43
+
44
+ - the coordinate frame, origin, and named datums;
45
+ - the allowed envelope and the interfaces that must align (mating faces, axes, hole
46
+ patterns, fits, clearances, and tolerances);
47
+ - the manufacturing process and material assumptions;
48
+ - load cases, support regions, intended load paths, and safety factors when structural
49
+ behavior matters;
50
+ - numbered acceptance claims with units and thresholds; and
51
+ - unresolved assumptions that need the user or an engineer to answer.
52
+
53
+ This may live in the task/specification, a companion design note, or comments next to
54
+ the part — partforge does not prescribe a blueprint schema yet. Do not silently invent
55
+ missing loads, material properties, tolerances, or safety factors. Ask, or record the
56
+ property as unverified.
57
+
58
+ Treat user/specification acceptance claims as **higher authority** than agent-authored
59
+ geometry and checks. An agent may add conservative checks, but must not delete a claim
60
+ or loosen its threshold merely to make a failing design pass; changing the contract
61
+ requires explicit approval.
62
+
63
+ ---
64
+
38
65
  ## The `PartDefinition` contract
39
66
 
40
67
  A part is a default-exported object. Full shape (optional fields marked `?`):
@@ -744,7 +771,9 @@ empty/degenerate results; `holes` is the informative topology number.)
744
771
 
745
772
  `render` writes one PNG per angle (`iso`, `front`, `top` by default; choose with
746
773
  `--views iso,front`, output dir with `--out`) to `render/`. The view defaults to
747
- the part's first declared view.
774
+ the part's first declared view. Treat renders as complementary evidence, not a ruler:
775
+ use several views for complex parts and the interactive viewer's cutaway for hidden
776
+ interfaces, but rely on `measure` / `verify` for dimensions, contact, and clearance.
748
777
 
749
778
  The `measure` function is also exported for vitest (boot a Manifold kernel as in
750
779
  "Testing a part", then `measure(kernel, part, "<view>")`):
@@ -797,6 +826,12 @@ JSON is pretty-printed across multiple lines) for robust machine parsing. With
797
826
  so even if a later `verify` throw crashes the run the file is there — it just
798
827
  lacks the `verify` key.
799
828
 
829
+ **Fresh-evidence rule.** A passing report is evidence only for the source, parameters,
830
+ view, backend, and framework version that produced it. Any relevant edit makes the old
831
+ result stale. Before reporting a part complete, run `measure` / `verify` again on the
832
+ current source and inspect current renders where visual requirements remain. Do not cite
833
+ a command that ran before the last geometry or expectation change as evidence.
834
+
800
835
  **Part-authored hints.** Any `verify.expect` metric accepts `{ expr, hint }` in
801
836
  place of a bare expression — use it to name the governing parameter:
802
837
 
@@ -813,8 +848,8 @@ verify: {
813
848
  ## Self-verification (the `verify` block)
814
849
 
815
850
  A part can declare how it should be checked, co-located with its schema, so
816
- `partforge measure` (and vitest) can prove it is both **printable** and **correct**.
817
- Add an optional top-level `verify` block:
851
+ `partforge measure` (and vitest) can enforce selected **geometric**, **assembly**, and
852
+ **DFM** properties. Add an optional top-level `verify` block:
818
853
 
819
854
  ```js
820
855
  verify: {
@@ -833,13 +868,32 @@ verify: {
833
868
  **What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`)
834
869
  and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
835
870
  facts `measure` already reports — `holes` (through-bores / genus), `volume`,
836
- `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`; and `_view` assertions
837
- `bbox`, `volume`, `overlaps`, plus the pair-wise `contacts` / `clearance` below.
871
+ `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `bounds` (per-sub-part
872
+ and aggregate axis-aligned `{min,max}` corner positions where the geometry sits, vs
873
+ `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
874
+ centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
875
+ `volume`, `overlaps`, `centerOfMass`, `boundsMin`, `boundsMax`, plus the pair-wise
876
+ `contacts` / `clearance` below.
877
+
878
+ Passing these checks does **not** prove structural strength, fatigue life, stability,
879
+ manufacturing tolerance stack-up, regulatory compliance, or safe real-world use.
880
+ Load-bearing or safety-relevant parts need appropriate analytical/simulation evidence
881
+ (for example FEA with declared materials, loads, supports, and safety factors) plus
882
+ qualified human review. If no such evidence exists, say that physical performance is
883
+ unverified.
838
884
 
839
885
  **Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
840
886
  `"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
841
- `bbox`, a componentwise vector `"<=[x,y,z]"` / `">=[x,y,z]"` where `*` skips an axis.
842
- The parser is strict — a malformed assertion fails loudly.
887
+ `bbox`, `centerOfMass`, `boundsMin`, `boundsMax`, a componentwise vector `"<=[x,y,z]"` /
888
+ `">=[x,y,z]"` where `*` skips an axis. The parser is strict — a malformed assertion
889
+ fails loudly.
890
+
891
+ ```js
892
+ verify: { expect: {
893
+ stand: { boundsMin: ">=[0,0,0]", centerOfMass: "<=[*,*,25]" }, // sits in +octant, mass kept low
894
+ _view: { boundsMax: "<=[220,220,250]" }, // whole assembly fits the bed
895
+ } }
896
+ ```
843
897
 
844
898
  **Gates vs. warnings:** exact facts are **gates** (a failure sets a non-zero exit code);
845
899
  `minWall` is computed (a ray/shot wall-thickness measurement) and reported as a
@@ -915,6 +969,12 @@ Checks run across the **default config plus every preset** (or your `cases` list
915
969
  preset that changes only parameters no on-screen sub-part reads is deduplicated, so
916
970
  coverage is cheap.
917
971
 
972
+ When an agent authors both geometry and `verify`, the check is useful feedback but not
973
+ an independent oracle. Preserve externally supplied acceptance claims verbatim (ideally
974
+ with stable IDs in the surrounding specification), and test boundary/tolerance cases in
975
+ addition to friendly defaults and presets. A repair should change the design, not relax
976
+ the requirement that exposed the failure.
977
+
918
978
  ---
919
979
 
920
980
  ## Fillet & chamfer (automatic OCCT backend)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",
@@ -1,7 +1,7 @@
1
1
  import { buildView } from "./build.js";
2
2
  import { assemblyOverlaps } from "../framework/assembly.js";
3
3
  import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
4
- import { bounds, meshArea } from "./mesh.js";
4
+ import { bounds, meshArea, meshCentroid } from "./mesh.js";
5
5
  import { minWall } from "./min-wall.js";
6
6
 
7
7
  const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
@@ -26,6 +26,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
26
26
  return {
27
27
  name,
28
28
  bbox: size(b),
29
+ bounds: { min: b.min, max: b.max },
30
+ centerOfMass: meshCentroid(mesh.positions, mesh.indices),
29
31
  volume: solid.volume(),
30
32
  surfaceArea: meshArea(mesh.positions, mesh.indices),
31
33
  triangleCount: mesh.triangles,
@@ -54,8 +56,16 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
54
56
  (g) => g.distance > CONTACT_EPS && g.distance < gapThreshold && !overlapping.has(pairKey(g.a, g.b)),
55
57
  );
56
58
 
59
+ const ub = subparts.length ? unionBounds(subBounds) : { min: [0, 0, 0], max: [0, 0, 0] };
60
+ const weighted = subparts.filter((s) => s.centerOfMass !== null);
61
+ const totalVol = weighted.reduce((a, s) => a + s.volume, 0);
62
+ const aggCom = weighted.length && Math.abs(totalVol) > 1e-9
63
+ ? [0, 1, 2].map((i) => weighted.reduce((a, s) => a + s.volume * s.centerOfMass[i], 0) / totalVol)
64
+ : null;
57
65
  const aggregate = {
58
- bbox: subparts.length ? size(unionBounds(subBounds)) : [0, 0, 0],
66
+ bbox: size(ub),
67
+ bounds: { min: ub.min, max: ub.max },
68
+ centerOfMass: aggCom,
59
69
  volume: subparts.reduce((a, s) => a + s.volume, 0),
60
70
  surfaceArea: subparts.reduce((a, s) => a + s.surfaceArea, 0),
61
71
  triangleCount: subparts.reduce((a, s) => a + s.triangleCount, 0),
@@ -12,6 +12,31 @@ export function meshVolume(positions, indices) {
12
12
  }
13
13
  return Math.abs(V);
14
14
  }
15
+ // Volume-weighted centroid (uniform-density center of mass) of a triangle mesh,
16
+ // via the same signed-tetrahedron decomposition as meshVolume. `indices` is
17
+ // optional: omit for a flat soup (3 verts/triangle). Returns [x,y,z], or null when
18
+ // the mesh encloses ~no volume (open/degenerate), where a centroid is undefined.
19
+ // Signed V (not abs) is used so the winding sign cancels in C/V.
20
+ export function meshCentroid(positions, indices) {
21
+ const n = indices ? indices.length : positions.length / 3;
22
+ let V = 0, cx = 0, cy = 0, cz = 0;
23
+ for (let i = 0; i < n; i += 3) {
24
+ const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
25
+ const ax = positions[a], ay = positions[a + 1], az = positions[a + 2];
26
+ const bx = positions[b], by = positions[b + 1], bz = positions[b + 2];
27
+ const dx = positions[c], dy = positions[c + 1], dz = positions[c + 2];
28
+ // signed volume of tetra (origin, a, b, c) = a · (b × c) / 6
29
+ const v = (ax * (by * dz - bz * dy) - ay * (bx * dz - bz * dx) + az * (bx * dy - by * dx)) / 6;
30
+ V += v;
31
+ // tetra centroid = (0 + a + b + c) / 4, weighted by its signed volume
32
+ cx += v * (ax + bx + dx) / 4;
33
+ cy += v * (ay + by + dy) / 4;
34
+ cz += v * (az + bz + dz) / 4;
35
+ }
36
+ if (Math.abs(V) < 1e-9) return null;
37
+ return [cx / V, cy / V, cz / V];
38
+ }
39
+
15
40
  export function bboxSize(positions) {
16
41
  const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
17
42
  for (let i = 0; i < positions.length; i += 3) for (let a = 0; a < 3; a++) {
@@ -25,6 +25,12 @@ export const SUBPART_METRICS = {
25
25
  hint: "triangle count is out of range — tessellation quality or feature count changed unexpectedly" },
26
26
  bbox: { kind: "gate", extract: (s) => s.bbox,
27
27
  hint: "bounding box is out of range — check the governing dimensions and the part's orientation" },
28
+ centerOfMass: { kind: "gate", extract: (s) => s.centerOfMass,
29
+ hint: "center of mass is outside the expected region — mass is distributed differently than intended; check feature placement or a mis-scaled sub-part" },
30
+ boundsMin: { kind: "gate", extract: (s) => s.bounds?.min,
31
+ hint: "the low corner is out of range — the part is positioned or oriented differently than expected" },
32
+ boundsMax: { kind: "gate", extract: (s) => s.bounds?.max,
33
+ hint: "the high corner is out of range — the part is positioned or oriented differently than expected" },
28
34
  minWall: { kind: "warn", extract: (s) => s.minWall,
29
35
  hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
30
36
  pattern: "minwall-sliver-triangles",
@@ -38,6 +44,12 @@ export const VIEW_METRICS = {
38
44
  overlaps: { kind: "gate", extract: (r) => r.overlaps.length,
39
45
  hint: "sub-parts interpenetrate near the reported location — adjust placement or add clearance in derive()",
40
46
  locate: (r) => r.overlaps[0]?.location ?? null },
47
+ centerOfMass: { kind: "gate", extract: (r) => r.aggregate.centerOfMass,
48
+ hint: "the assembly's center of mass is outside the expected region — a sub-part is mis-placed or mis-scaled" },
49
+ boundsMin: { kind: "gate", extract: (r) => r.aggregate.bounds?.min,
50
+ hint: "the assembly's low corner is out of range — check placement or orientation" },
51
+ boundsMax: { kind: "gate", extract: (r) => r.aggregate.bounds?.max,
52
+ hint: "the assembly's high corner is out of range — check placement or orientation" },
41
53
  };
42
54
 
43
55
  // An expectation is a bare expression (string/number/boolean) or { expr, hint }.