partforge 0.23.0 → 0.25.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/docs/AUTHORING-PARTS.md +67 -7
- package/package.json +1 -1
- package/src/framework/viewer-lighting.js +55 -2
- package/src/framework/viewer.js +64 -9
- package/src/testing/measure.js +12 -2
- package/src/testing/mesh.js +25 -0
- package/src/testing/verify.js +12 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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
|
|
837
|
-
|
|
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]"` /
|
|
842
|
-
The parser is strict — a malformed assertion
|
|
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,13 +1,66 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
|
|
3
|
+
const KEY_COLOR = 0xffffff, KEY_INTENSITY = 1.45;
|
|
4
|
+
const FILL_COLOR = 0xe5efff, FILL_INTENSITY = 0.65;
|
|
5
|
+
|
|
3
6
|
export function addViewerLights(scene) {
|
|
4
7
|
const hemisphere = new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
|
|
5
|
-
const key = new THREE.DirectionalLight(
|
|
8
|
+
const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
|
|
6
9
|
key.position.set(8, 14, 10);
|
|
7
|
-
const fill = new THREE.DirectionalLight(
|
|
10
|
+
const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
|
|
8
11
|
fill.position.set(-10, 6, -8);
|
|
9
12
|
|
|
10
13
|
scene.add(hemisphere, key, fill);
|
|
11
14
|
|
|
12
15
|
return { hemisphere, key, fill };
|
|
13
16
|
}
|
|
17
|
+
|
|
18
|
+
// The key/fill above are fixed in WORLD space, which is right for a user who orbits
|
|
19
|
+
// into the lit hemisphere and wrong for an offscreen canonical-view capture: `bottom`,
|
|
20
|
+
// `back`, and `left` stare at faces the key at (8,14,10) never reaches, so they come
|
|
21
|
+
// back flat — hemisphere ambient only, with no shading gradient to reveal a chamfer or
|
|
22
|
+
// a 1 mm snap barb. These two lights are the capture-time stand-ins, swapped in for the
|
|
23
|
+
// duration of one offscreen render (see viewer.js renderOffscreen) and posed relative to
|
|
24
|
+
// the view axis, so every canonical view is exposed and shaded the same way.
|
|
25
|
+
export function createCaptureLights() {
|
|
26
|
+
const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
|
|
27
|
+
const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
|
|
28
|
+
return { key, fill };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Offsets from the camera, in camera-space multiples of the camera-to-target distance:
|
|
32
|
+
// the key sits over the viewer's shoulder (up and to the right, ~41° off the view axis),
|
|
33
|
+
// the fill opposes it from the left at eye level. Both still shine mostly ALONG the view
|
|
34
|
+
// direction, so whatever the camera can see is lit.
|
|
35
|
+
const KEY_OFFSET = { right: 0.45, up: 0.75 };
|
|
36
|
+
const FILL_OFFSET = { right: -0.7, up: 0.15 };
|
|
37
|
+
|
|
38
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
39
|
+
const cross = (a, b) => [
|
|
40
|
+
a[1] * b[2] - a[2] * b[1],
|
|
41
|
+
a[2] * b[0] - a[0] * b[2],
|
|
42
|
+
a[0] * b[1] - a[1] * b[0],
|
|
43
|
+
];
|
|
44
|
+
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
45
|
+
const norm = (v) => {
|
|
46
|
+
const l = length(v) || 1;
|
|
47
|
+
return [v[0] / l, v[1] / l, v[2] / l];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// World-space positions for the capture key/fill, given a camera pose (the same
|
|
51
|
+
// `{position, up, target}` shape cameraPoseForView returns). Pure — no THREE, no scene.
|
|
52
|
+
export function captureLightPoses({ position, up, target }) {
|
|
53
|
+
const forward = norm(sub(target, position));
|
|
54
|
+
// Camera basis. A canonical view never passes an `up` parallel to its own view axis,
|
|
55
|
+
// but a caller could, and a degenerate basis would put NaN into the light positions.
|
|
56
|
+
let right = cross(forward, up);
|
|
57
|
+
if (length(right) < 1e-6) right = cross(forward, [0, 0, 1]);
|
|
58
|
+
if (length(right) < 1e-6) right = cross(forward, [0, 1, 0]);
|
|
59
|
+
right = norm(right);
|
|
60
|
+
const trueUp = norm(cross(right, forward));
|
|
61
|
+
const dist = length(sub(target, position)) || 1;
|
|
62
|
+
const place = (offset) => [0, 1, 2].map(
|
|
63
|
+
(i) => position[i] + (right[i] * offset.right + trueUp[i] * offset.up) * dist,
|
|
64
|
+
);
|
|
65
|
+
return { key: place(KEY_OFFSET), fill: place(FILL_OFFSET) };
|
|
66
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -5,9 +5,35 @@ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
|
|
|
5
5
|
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { createCutaway } from "./cutaway.js";
|
|
8
|
-
import { addViewerLights } from "./viewer-lighting.js";
|
|
8
|
+
import { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
|
|
9
9
|
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
10
10
|
|
|
11
|
+
// three renders into a render target in the LINEAR working colour space: as of r184
|
|
12
|
+
// WebGLRenderer only applies `outputColorSpace` on the canvas path (WebGLPrograms
|
|
13
|
+
// substitutes workingColorSpace whenever a render target is bound), so readback pixels
|
|
14
|
+
// are linear no matter what the target texture's colorSpace says. Writing them straight
|
|
15
|
+
// into a JPEG is what made captured views come back muddy and dark compared to the live
|
|
16
|
+
// canvas. Encode the transfer function ourselves. The 8-bit LUT loses precision only in
|
|
17
|
+
// the deepest shadows, which a quality-0.9 JPEG would not have preserved anyway.
|
|
18
|
+
const SRGB8 = (() => {
|
|
19
|
+
const table = new Uint8Array(256);
|
|
20
|
+
for (let i = 0; i < 256; i++) {
|
|
21
|
+
const l = i / 255;
|
|
22
|
+
table[i] = Math.round(255 * (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055));
|
|
23
|
+
}
|
|
24
|
+
return table;
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
// Linear RGBA bytes → sRGB, in place. Alpha is a coverage value, not a colour: untouched.
|
|
28
|
+
export function srgbEncodeInPlace(data) {
|
|
29
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
30
|
+
data[i] = SRGB8[data[i]];
|
|
31
|
+
data[i + 1] = SRGB8[data[i + 1]];
|
|
32
|
+
data[i + 2] = SRGB8[data[i + 2]];
|
|
33
|
+
}
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
// Render a set of canonical views without disturbing the live camera/canvas.
|
|
12
38
|
// `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
|
|
13
39
|
// target → readback → JPEG data URL); injected so this is unit-testable without
|
|
@@ -57,7 +83,7 @@ export function createViewer(container, part) {
|
|
|
57
83
|
controls.autoRotateSpeed = 1.6;
|
|
58
84
|
|
|
59
85
|
// --- lights + grid --------------------------------------------------------
|
|
60
|
-
addViewerLights(scene);
|
|
86
|
+
const liveLights = addViewerLights(scene);
|
|
61
87
|
// 1 cm grid (mm units): 300 mm wide, 30 divisions -> 10 mm (1 cm) squares.
|
|
62
88
|
const GRID_SIZE = 300, GRID_DIVS = 30;
|
|
63
89
|
let floorY = 0; // world Y of the grid plane; set to the part's bbox bottom in frameTo
|
|
@@ -288,19 +314,47 @@ export function createViewer(container, part) {
|
|
|
288
314
|
// Offscreen render of the shared scene from an arbitrary pose → JPEG data URL.
|
|
289
315
|
// A separate WebGLRenderTarget + temp camera means the visible canvas and the
|
|
290
316
|
// live `camera` are never touched. WebGL pixels are bottom-up, so flip on encode.
|
|
291
|
-
|
|
317
|
+
//
|
|
318
|
+
// These captures are read by a model, not shown as a thumbnail (partforge-cloud's
|
|
319
|
+
// render_part_views tool feeds them straight to the agent), so they are sized and lit
|
|
320
|
+
// for reading small features: 1024² is the largest square that fits Anthropic's
|
|
321
|
+
// ~1.15 MP no-downscale budget, 4× MSAA keeps a thin wall from aliasing into noise,
|
|
322
|
+
// and the light rig follows the camera so no view is a flat silhouette.
|
|
323
|
+
const _rtSize = 1024;
|
|
292
324
|
let _rt = null;
|
|
325
|
+
let _capLights = null;
|
|
293
326
|
function renderOffscreen({ position, up, target }) {
|
|
294
|
-
_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize);
|
|
327
|
+
_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, { samples: 4 });
|
|
328
|
+
_capLights = _capLights ?? createCaptureLights();
|
|
295
329
|
const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
|
|
296
330
|
cam.position.set(position[0], position[1], position[2]);
|
|
297
331
|
cam.up.set(up[0], up[1], up[2]);
|
|
298
332
|
cam.lookAt(target[0], target[1], target[2]);
|
|
299
|
-
renderer.setRenderTarget(_rt);
|
|
300
|
-
renderer.render(scene, cam);
|
|
301
333
|
const buf = new Uint8Array(_rtSize * _rtSize * 4);
|
|
302
|
-
|
|
303
|
-
|
|
334
|
+
// Swap the world-fixed key/fill for the camera-relative pair, for this one render
|
|
335
|
+
// only. A DirectionalLight aims at its `target`, whose matrixWorld only updates
|
|
336
|
+
// while it is in the scene graph, so both go in and both come back out.
|
|
337
|
+
const poses = captureLightPoses({ position, up, target });
|
|
338
|
+
const { key: capKey, fill: capFill } = _capLights;
|
|
339
|
+
capKey.position.set(poses.key[0], poses.key[1], poses.key[2]);
|
|
340
|
+
capFill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
|
|
341
|
+
for (const light of [capKey, capFill]) light.target.position.set(target[0], target[1], target[2]);
|
|
342
|
+
liveLights.key.visible = false;
|
|
343
|
+
liveLights.fill.visible = false;
|
|
344
|
+
scene.add(capKey, capKey.target, capFill, capFill.target);
|
|
345
|
+
try {
|
|
346
|
+
renderer.setRenderTarget(_rt);
|
|
347
|
+
renderer.render(scene, cam);
|
|
348
|
+
// render() resolves the multisample renderbuffer into the target texture, so this
|
|
349
|
+
// reads antialiased pixels.
|
|
350
|
+
renderer.readRenderTargetPixels(_rt, 0, 0, _rtSize, _rtSize, buf);
|
|
351
|
+
} finally {
|
|
352
|
+
// Never leave the user's own view unlit or pointed at the offscreen target.
|
|
353
|
+
renderer.setRenderTarget(null);
|
|
354
|
+
scene.remove(capKey, capKey.target, capFill, capFill.target);
|
|
355
|
+
liveLights.key.visible = true;
|
|
356
|
+
liveLights.fill.visible = true;
|
|
357
|
+
}
|
|
304
358
|
const canvas = document.createElement("canvas");
|
|
305
359
|
canvas.width = _rtSize; canvas.height = _rtSize;
|
|
306
360
|
const ctx = canvas.getContext("2d");
|
|
@@ -310,8 +364,9 @@ export function createViewer(container, part) {
|
|
|
310
364
|
const src = (_rtSize - 1 - y) * _rtSize * 4;
|
|
311
365
|
img.data.set(buf.subarray(src, src + _rtSize * 4), y * _rtSize * 4);
|
|
312
366
|
}
|
|
367
|
+
srgbEncodeInPlace(img.data);
|
|
313
368
|
ctx.putImageData(img, 0, 0);
|
|
314
|
-
return canvas.toDataURL("image/jpeg", 0.
|
|
369
|
+
return canvas.toDataURL("image/jpeg", 0.9);
|
|
315
370
|
}
|
|
316
371
|
|
|
317
372
|
// Render the canonical camera angles offscreen, framed to whatever is visible,
|
package/src/testing/measure.js
CHANGED
|
@@ -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:
|
|
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),
|
package/src/testing/mesh.js
CHANGED
|
@@ -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++) {
|
package/src/testing/verify.js
CHANGED
|
@@ -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 }.
|