partforge 0.159.0 → 0.161.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 +77 -8
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +1 -1
- package/src/framework/animation.js +5 -3
- package/src/framework/lint/rules-animations.js +23 -18
- package/src/framework/lint/rules-verify.js +34 -5
- package/src/framework/mount.js +4 -0
- package/src/framework/oracle/dfm-profiles.js +27 -3
- package/src/framework/oracle/held.js +79 -0
- package/src/framework/oracle/verify.js +11 -5
- package/src/framework/style-camera.js +14 -2
- package/src/framework/view-angles.js +111 -4
- package/src/framework/viewer.js +17 -1
- package/src/testing/render.js +14 -3
- package/types/index.d.ts +10 -1
- package/types/part.d.ts +36 -4
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -310,10 +310,38 @@ Rules (all lint-enforced):
|
|
|
310
310
|
left right`) or any view-cube orientation — an edge or corner named
|
|
311
311
|
vertical, then depth, then side (`top-front-left`, `top-back-right`,
|
|
312
312
|
`front-left`, `bottom-back-right`, …; `top-front-right` is `iso`). One
|
|
313
|
-
mechanism per animation: an animation-level
|
|
314
|
-
animation-level `[[t, angle], …]` list, or per-step
|
|
313
|
+
mechanism per animation: an animation-level angle (an intro cue at t=0), an
|
|
314
|
+
animation-level `[[t, angle], …]` list, or per-step angles.
|
|
315
315
|
Cues fire during play only — scrubbing never moves the camera — and a user
|
|
316
316
|
orbit disarms the remaining cues for that run.
|
|
317
|
+
- **Any angle in between is an orbit**: wherever a cue takes a name it also
|
|
318
|
+
takes `{ azimuth, elevation, distance }`, in degrees and in the part's own
|
|
319
|
+
frame. `azimuth` 0 looks from the front (−Y) and turns toward the right (+X)
|
|
320
|
+
at 90, the back at 180 and the left at −90; `elevation` is degrees above the
|
|
321
|
+
horizontal, −90..90. `iso` is `{ azimuth: 45, elevation: 35.26 }` and
|
|
322
|
+
`front-right` is `{ azimuth: 45, elevation: 0 }`, so "about half as far down
|
|
323
|
+
as level from iso" is `{ azimuth: 45, elevation: 17 }`. `distance` is
|
|
324
|
+
optional: a multiple of the distance a named angle frames the part from (1,
|
|
325
|
+
the default, is the same framing; 1.5 pulls back; 0.6 moves in), 0.1..10, so
|
|
326
|
+
it keeps meaning the same thing when params change the part's size. The
|
|
327
|
+
camera always aims at the part's centre — a cue orbits and zooms, it never
|
|
328
|
+
pans. Lint (`animation-camera-invalid`) names what is wrong with a malformed
|
|
329
|
+
orbit: a missing or non-numeric angle, an elevation past ±90, a distance out
|
|
330
|
+
of range, or an unknown key.
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
steps: [
|
|
334
|
+
{ label: "Assembled", camera: "iso", duration: 0.8 },
|
|
335
|
+
{ label: "Expand", camera: { azimuth: 45, elevation: 17, distance: 1.3 }, duration: 2.5,
|
|
336
|
+
tracks: { explode: [[0, 0], [1, 1]] } },
|
|
337
|
+
]
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
A host can turn the view the user has on screen into one of these with
|
|
341
|
+
`runtime.getCameraCue()` — `{ cue, panned }`, where `cue` is the orbit that
|
|
342
|
+
reproduces the current angle and distance and `panned` is how far the user's
|
|
343
|
+
orbit target sits from the part's centre (a fraction of the framing radius;
|
|
344
|
+
a pan is the one thing a cue cannot carry).
|
|
317
345
|
- **A camera move takes as long as its cue asks**: a per-step `camera` sweeps
|
|
318
346
|
across that step's whole `duration`, and a listed cue sweeps until the next
|
|
319
347
|
cue (or the end). The move that settles the camera BEFORE playback starts —
|
|
@@ -3982,7 +4010,7 @@ runtime a throwing probe only degrades quietly to `{ error }`.
|
|
|
3982
4010
|
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
|
3983
4011
|
`verify-unknown-orientation`,
|
|
3984
4012
|
`verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
|
|
3985
|
-
`contacts` / `clearance` keys, which are not scalar view metrics; they are
|
|
4013
|
+
`contacts` / `clearance` / `held` keys, which are not scalar view metrics; they are
|
|
3986
4014
|
validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
|
|
3987
4015
|
|
|
3988
4016
|
**Animations block** — static validation of each view's `animations` block,
|
|
@@ -4150,7 +4178,8 @@ carries:
|
|
|
4150
4178
|
*bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
|
|
4151
4179
|
more than one place the bbox center can fall in the empty space between regions)
|
|
4152
4180
|
and the pair checks `contact` / `clearance` / `nearMiss` (the midpoint between
|
|
4153
|
-
the pair's closest surface points)
|
|
4181
|
+
the pair's closest surface points) and `held` (the first floating sub-part's
|
|
4182
|
+
centre of mass). Whole-solid metrics (bbox, volume, …) have
|
|
4154
4183
|
none.
|
|
4155
4184
|
|
|
4156
4185
|
Subpart facts include `minWall` (number or `null` — null exactly when no reading
|
|
@@ -4268,7 +4297,7 @@ A part can declare how it should be checked, co-located with its schema, so
|
|
|
4268
4297
|
```js
|
|
4269
4298
|
verify: {
|
|
4270
4299
|
process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an
|
|
4271
|
-
// inline { bed:[x,y,z], minWall,
|
|
4300
|
+
// inline { base, bed:[x,y,z], minWall, overhang, fits, holeUndersize, bridge } object
|
|
4272
4301
|
orientation: "print", // optional; ONLY with this is overhang checked — it says the
|
|
4273
4302
|
// part is laid out for its bed (Z up, bed at the lowest Z)
|
|
4274
4303
|
cases: ["defaults", "M3"], // optional; default = defaults + every preset
|
|
@@ -4276,7 +4305,8 @@ verify: {
|
|
|
4276
4305
|
spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
|
|
4277
4306
|
_view: { overlaps: 0,
|
|
4278
4307
|
contacts: [["drum", "flange"]], // these pairs must touch
|
|
4279
|
-
clearance: { "lid×body": ">=0.3" }
|
|
4308
|
+
clearance: { "lid×body": ">=0.3" }, // intended free fits
|
|
4309
|
+
held: true }, // every printed piece reaches the base
|
|
4280
4310
|
},
|
|
4281
4311
|
}
|
|
4282
4312
|
```
|
|
@@ -4303,7 +4333,30 @@ is why this is a warning and never a gate. Two more limits, stated: it is judged
|
|
|
4303
4333
|
the DISPLAY pose, so a sub-part whose `assembly` pose stands it differently from how it prints (a lid that prints
|
|
4304
4334
|
flat beside its base) is measured as displayed, and `exportable: false` sub-parts
|
|
4305
4335
|
are skipped. Switch it off under an FDM profile with an inline
|
|
4306
|
-
`{ base: "fdm-pla", overhang: null }`.
|
|
4336
|
+
`{ base: "fdm-pla", overhang: null }`.
|
|
4337
|
+
|
|
4338
|
+
**What the profile tells you.** Three more fields carry the numbers a mating
|
|
4339
|
+
feature needs. Nothing checks them; they are the one place the value is spelled,
|
|
4340
|
+
so `derive()` copies it with a comment naming the fit (`// slip fit: fdm-pla 0.25`).
|
|
4341
|
+
`fits` are diametral offsets in mm, negative is interference; `holeUndersize` is
|
|
4342
|
+
added to a horizontal bore's diameter because a printed arch sags inward; `bridge`
|
|
4343
|
+
is the longest flat span the process prints unsupported (none on resin, which
|
|
4344
|
+
prints on supports). They are guide defaults, not measurements of your printer:
|
|
4345
|
+
an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
|
|
4346
|
+
|
|
4347
|
+
| field | fdm-pla | fdm-petg | resin |
|
|
4348
|
+
| --- | --- | --- | --- |
|
|
4349
|
+
| `fits.press` — a pin that never comes out | -0.10 | -0.10 | -0.05 |
|
|
4350
|
+
| `fits.push` — pressed on by hand | 0.05 | 0.05 | 0.05 |
|
|
4351
|
+
| `fits.slip` — a lid that lifts off | 0.25 | 0.30 | 0.10 |
|
|
4352
|
+
| `fits.moving` — a hinge pin or axle | 0.35 | 0.40 | 0.15 |
|
|
4353
|
+
| `fits.printInPlace` — printed already assembled | 0.45 | 0.50 | 0.20 |
|
|
4354
|
+
| `holeUndersize` | 0.25 | 0.30 | 0.05 |
|
|
4355
|
+
| `bridge` (mm) | 10 | 10 | — |
|
|
4356
|
+
|
|
4357
|
+
`fits` are diametral; the `clearance` expectation below reads a pair's minimum surface distance, so a pin in a bore is declared at half the fit (`fits.slip` 0.25 → `clearance: { "pin×body": "0.08..0.18" }`).
|
|
4358
|
+
|
|
4359
|
+
**What `expect` gives you:** per-sub-part
|
|
4307
4360
|
assertions on the facts `measure` already reports — `holes` (through-bores / genus),
|
|
4308
4361
|
`volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
|
|
4309
4362
|
`overhangArea`, `wall` (a range — see below), `bounds` (the axis-aligned corner
|
|
@@ -4311,7 +4364,7 @@ positions, written in the same `{ min, max }` shape `measure` reports them — w
|
|
|
4311
4364
|
the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
4312
4365
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
|
4313
4366
|
`volume`, `overlaps`, `centerOfMass`, `bounds`, plus the pair-wise
|
|
4314
|
-
`contacts` / `clearance` below.
|
|
4367
|
+
`contacts` / `clearance` and the graph check `held` below.
|
|
4315
4368
|
|
|
4316
4369
|
Passing these checks does **not** prove structural strength, fatigue life, stability,
|
|
4317
4370
|
manufacturing tolerance stack-up, regulatory compliance, or safe real-world use.
|
|
@@ -4444,6 +4497,22 @@ no overlap detection there (`Solid.intersect` is Manifold-only), a sub-part
|
|
|
4444
4497
|
*fully contained* inside another reads as its surface-to-surface distance, so it
|
|
4445
4498
|
can surface as a near miss — check containment cases on Manifold.
|
|
4446
4499
|
|
|
4500
|
+
**Held (the contact graph).** `contacts` asserts a pair you named; it cannot say a
|
|
4501
|
+
sub-part touches nothing at all — a lid placed a hair above its box, a bracket
|
|
4502
|
+
beside the wall it should bolt to. `held: true` builds a graph over every
|
|
4503
|
+
exportable sub-part in the view, joining two whenever they overlap or their gap
|
|
4504
|
+
is within the same ~1 µm `contacts` tolerates, and fails naming each sub-part not
|
|
4505
|
+
connected to the base — the lowest sub-part by its bounds, or the one you name
|
|
4506
|
+
with `held: "body"`. The location is the first floating sub-part's centre of
|
|
4507
|
+
mass. A reference ghost (`exportable: false`) is left out entirely: it neither
|
|
4508
|
+
counts nor holds, since it is never printed (lint refuses one named as the base).
|
|
4509
|
+
A named base that is `enabled()`-off
|
|
4510
|
+
in a case skips that case; a name that exists nowhere throws, like a `contacts`
|
|
4511
|
+
pair. It is an expectation, never a default, for the overhang reason: a part
|
|
4512
|
+
still being shaped should not be nagged. Topological only — a part balanced on a
|
|
4513
|
+
point passes — and the same OCCT caveat applies: a sub-part fully inside another
|
|
4514
|
+
reads as not touching there. A view with fewer than two printed sub-parts passes with nothing to hold. A pin or axle running in a clearance bore, or a print-in-place piece, touches nothing in the model and reads as floating: `held` is for assemblies whose pieces seat on each other, so leave it off when a piece is captured with running clearance (`fits.moving`, `fits.printInPlace`) and declare that pair's `clearance` instead. A quick lap answers it `unevaluated`.
|
|
4515
|
+
|
|
4447
4516
|
**Running it:**
|
|
4448
4517
|
|
|
4449
4518
|
```bash
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -198,6 +198,12 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
|
|
|
198
198
|
- **Cause:** Two sub-parts that should meet don't quite — a boss shorter than the gap it must bridge, a mis-placed mating datum in `derive()`, or a union that silently missed. Renders and volume/bbox checks cannot see sub-mm joint gaps; this check exists precisely for them.
|
|
199
199
|
- **Fix:** If the pair should touch, grow the joining feature or fix the datum math so the faces meet, then declare the pair in `verify.expect._view.contacts`; if a free fit is intended, declare it under `clearance`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
|
|
200
200
|
|
|
201
|
+
## floating-subpart
|
|
202
|
+
|
|
203
|
+
- **Symptom:** A `✗ … held` failure from `verify` reporting `<name> not connected to <base>`: a sub-part that looks assembled in the preview touches nothing that reaches the base.
|
|
204
|
+
- **Cause:** The piece is placed by eye rather than against a mating datum — a lid a hair above its box, a bracket beside the wall it should bolt to, a pin whose bore it was never moved into — or it is held only by a reference ghost (`exportable: false`), which is never printed. On OCCT a sub-part fully inside another reads as not touching, since overlap detection is Manifold-only.
|
|
205
|
+
- **Fix:** Seat the part on a mating face by deriving its position from the face it sits on in `derive()`, or add the fastener, boss or tab that holds it, then keep `verify.expect._view.held` so the next edit re-checks it. If the piece is meant to run free in a clearance bore (a hinge pin, a print-in-place link), the clearance is correct and `held` is the wrong check for that assembly — remove `held` and declare the pair under `clearance`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
|
|
206
|
+
|
|
201
207
|
## expect-static-across-presets
|
|
202
208
|
|
|
203
209
|
- **Symptom:** A `verify` exact gate (`holes`, `volume`, …) fails on SOME presets only — e.g. `✗ planter holes 1 (0 != 1)` on two cases while defaults pass — and the preview looks right for every preset.
|
package/package.json
CHANGED
|
@@ -69,12 +69,14 @@ export function normalizeAnimation(name, spec) {
|
|
|
69
69
|
// params move — ignores it and uses the driver's short fixed tween, since it
|
|
70
70
|
// gates playback and a long one would read as a stall.
|
|
71
71
|
let cues;
|
|
72
|
-
// A lone angle name
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// A lone angle (a name, or an { azimuth, elevation } orbit) is only ever an
|
|
73
|
+
// intro, so it has no sweep of its own.
|
|
74
|
+
if (Array.isArray(spec.camera)) {
|
|
75
75
|
cues = spec.camera.map(([t, view], i, all) => ({
|
|
76
76
|
t, view, sweep: ((all[i + 1]?.[0] ?? 1) - t) * totalDuration,
|
|
77
77
|
}));
|
|
78
|
+
} else if (spec.camera != null) {
|
|
79
|
+
cues = [{ t: 0, view: spec.camera, sweep: null }];
|
|
78
80
|
} else {
|
|
79
81
|
cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera, sweep: s.duration }] : []));
|
|
80
82
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// letting the author wonder why nothing plays.
|
|
13
13
|
import { err, note } from "./finding.js";
|
|
14
14
|
import { EASINGS } from "../animation.js";
|
|
15
|
-
import { CANONICAL_VIEWS,
|
|
15
|
+
import { CANONICAL_VIEWS, cameraOrbitProblem, isCameraCue } from "../view-angles.js";
|
|
16
16
|
import { inView } from "../sub-part-views.js";
|
|
17
17
|
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
18
18
|
import { resolveDerivedAttributed } from "../derive.js";
|
|
@@ -374,9 +374,14 @@ export const ANIMATION_RULES = [
|
|
|
374
374
|
id: "animation-camera-invalid",
|
|
375
375
|
run: ({ part }) => {
|
|
376
376
|
const out = [];
|
|
377
|
-
const
|
|
378
|
-
// The seven canonical names plus the view cube's corners and edges.
|
|
379
|
-
const angles = `${CANONICAL_VIEWS.join(", ")},
|
|
377
|
+
const badCue = (v) => !isCameraCue(v);
|
|
378
|
+
// The seven canonical names plus the view cube's corners and edges, or a free-form orbit.
|
|
379
|
+
const angles = `${CANONICAL_VIEWS.join(", ")}, a view-cube orientation such as top-front-left or top-back-right, or an orbit { azimuth, elevation, distance? } in degrees`;
|
|
380
|
+
// What to say about one bad cue: an orbit-shaped value gets its own
|
|
381
|
+
// problem named; anything else is an unknown angle.
|
|
382
|
+
const describe = (v) => (v !== null && typeof v === "object" && !Array.isArray(v)
|
|
383
|
+
? `camera orbit ${cameraOrbitProblem(v)}`
|
|
384
|
+
: `camera ${JSON.stringify(v)} is not a camera angle`);
|
|
380
385
|
for (const { name, a, base } of animEntries(part)) {
|
|
381
386
|
const stepCameras = Array.isArray(a.steps)
|
|
382
387
|
? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
|
|
@@ -384,13 +389,13 @@ export const ANIMATION_RULES = [
|
|
|
384
389
|
if (a.camera != null && stepCameras.length) {
|
|
385
390
|
out.push(err("animation-camera-invalid",
|
|
386
391
|
`animation "${name}" mixes an animation-level \`camera\` with per-step cameras`,
|
|
387
|
-
"One camera mechanism per animation: either the animation-level
|
|
392
|
+
"One camera mechanism per animation: either the animation-level angle/cue-list, or per-step angles — not both.",
|
|
388
393
|
`${base}.camera`));
|
|
389
394
|
}
|
|
390
395
|
for (const [cam, i] of stepCameras) {
|
|
391
|
-
if (
|
|
396
|
+
if (badCue(cam)) {
|
|
392
397
|
out.push(err("animation-camera-invalid",
|
|
393
|
-
`animation "${name}" step ${i}
|
|
398
|
+
`animation "${name}" step ${i} ${describe(cam)}`,
|
|
394
399
|
`Camera cues use ${angles}.`,
|
|
395
400
|
`${base}.steps[${i}].camera`));
|
|
396
401
|
}
|
|
@@ -398,17 +403,10 @@ export const ANIMATION_RULES = [
|
|
|
398
403
|
// An explicit `camera: null` is "no camera", which is how
|
|
399
404
|
// normalizeAnimation reads it — not a malformed value to report.
|
|
400
405
|
if (a.camera == null) continue;
|
|
401
|
-
if (
|
|
402
|
-
if (badName(a.camera)) {
|
|
403
|
-
out.push(err("animation-camera-invalid",
|
|
404
|
-
`animation "${name}" camera "${a.camera}" is not a camera angle`,
|
|
405
|
-
`Camera cues use ${angles}.`,
|
|
406
|
-
`${base}.camera`));
|
|
407
|
-
}
|
|
408
|
-
} else if (Array.isArray(a.camera)) {
|
|
406
|
+
if (Array.isArray(a.camera)) {
|
|
409
407
|
const cues = a.camera;
|
|
410
408
|
const wellFormed = cues.length > 0 && cues.every((c) =>
|
|
411
|
-
Array.isArray(c) && c.length === 2 && Number.isFinite(c[0]) && c[0] >= 0 && c[0] <= 1 && !
|
|
409
|
+
Array.isArray(c) && c.length === 2 && Number.isFinite(c[0]) && c[0] >= 0 && c[0] <= 1 && !badCue(c[1]));
|
|
412
410
|
const sorted = cues.every((c, i) => i === 0 || (Array.isArray(c) && Array.isArray(cues[i - 1]) && c[0] > cues[i - 1][0]));
|
|
413
411
|
if (!wellFormed || !sorted) {
|
|
414
412
|
out.push(err("animation-camera-invalid",
|
|
@@ -416,10 +414,17 @@ export const ANIMATION_RULES = [
|
|
|
416
414
|
`Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${angles}.`,
|
|
417
415
|
`${base}.camera`));
|
|
418
416
|
}
|
|
417
|
+
} else if (typeof a.camera === "string" || typeof a.camera === "object") {
|
|
418
|
+
if (badCue(a.camera)) {
|
|
419
|
+
out.push(err("animation-camera-invalid",
|
|
420
|
+
`animation "${name}" ${describe(a.camera)}`,
|
|
421
|
+
`Camera cues use ${angles}.`,
|
|
422
|
+
`${base}.camera`));
|
|
423
|
+
}
|
|
419
424
|
} else {
|
|
420
425
|
out.push(err("animation-camera-invalid",
|
|
421
|
-
`animation "${name}" \`camera\` is neither an angle
|
|
422
|
-
"Use
|
|
426
|
+
`animation "${name}" \`camera\` is neither an angle nor a cue list`,
|
|
427
|
+
"Use an angle name, an { azimuth, elevation, distance? } orbit, or `[[t, angle], …]` cues.",
|
|
423
428
|
`${base}.camera`));
|
|
424
429
|
}
|
|
425
430
|
}
|
|
@@ -28,12 +28,13 @@ export function resolveExpect(verify, p, d) {
|
|
|
28
28
|
const isExpectation = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && "expr" in v;
|
|
29
29
|
const exprOf = (v) => (isExpectation(v) ? v.expr : v);
|
|
30
30
|
|
|
31
|
-
// `contacts` (must-touch pairs)
|
|
32
|
-
// `verify.expect._view` but are pair-
|
|
33
|
-
// verify.js's own peel (
|
|
34
|
-
// assertion-expression checks below. Validated for
|
|
31
|
+
// `contacts` (must-touch pairs), `clearance` (free-fit gaps) and `held` (the
|
|
32
|
+
// contact graph) live under `verify.expect._view` but are pair-derived checks,
|
|
33
|
+
// not scalar VIEW_METRICS — mirror verify.js's own peel (evaluateCase) so they
|
|
34
|
+
// never hit the scalar-metric / assertion-expression checks below. Validated for
|
|
35
|
+
// real by `verify-bad-pair-check`.
|
|
35
36
|
const peelPairKeys = (metrics) => {
|
|
36
|
-
const { contacts, clearance, ...rest } = metrics;
|
|
37
|
+
const { contacts, clearance, held, ...rest } = metrics;
|
|
37
38
|
return rest;
|
|
38
39
|
};
|
|
39
40
|
|
|
@@ -124,6 +125,33 @@ function checkClearance(clearance, names, path) {
|
|
|
124
125
|
return out;
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// Static analogue of held.js: `true`, or the name of the sub-part the graph is
|
|
129
|
+
// rooted at. Anything else throws at runtime, so it is an error here. A ghost
|
|
130
|
+
// (`exportable: false`) is never printed, so it cannot be the base either — the
|
|
131
|
+
// runtime would accept it and report a misleading skip.
|
|
132
|
+
function checkHeld(held, parts, path) {
|
|
133
|
+
if (held === undefined || held === null || held === true) return [];
|
|
134
|
+
const names = Object.keys(parts);
|
|
135
|
+
if (typeof held === "string") {
|
|
136
|
+
if (names.includes(held)) {
|
|
137
|
+
if (parts[held]?.exportable !== false) return [];
|
|
138
|
+
return [err("verify-bad-pair-check",
|
|
139
|
+
`\`${path}\` names "${held}", which is exportable: false and never printed, so it cannot be the base`,
|
|
140
|
+
"Root the contact graph at a printed sub-part, or `held: true` for the lowest one.",
|
|
141
|
+
path)];
|
|
142
|
+
}
|
|
143
|
+
const hint = safeSuggest(held, names);
|
|
144
|
+
return [err("verify-unknown-subpart",
|
|
145
|
+
`\`${path}\` references "${held}", which is not a sub-part`,
|
|
146
|
+
`Use \`true\` (the lowest sub-part is the base) or one of the sub-part names (${names.join(", ")})${hint ? ` — did you mean "${hint}"?` : "."}`,
|
|
147
|
+
path)];
|
|
148
|
+
}
|
|
149
|
+
return [err("verify-bad-pair-check",
|
|
150
|
+
`\`${path}\` must be true or a sub-part name, got ${describe(held)}`,
|
|
151
|
+
"Set `held: true` to root the contact graph at the lowest sub-part, or `held: \"<name>\"` to root it at that sub-part.",
|
|
152
|
+
path)];
|
|
153
|
+
}
|
|
154
|
+
|
|
127
155
|
// `clearance`'s values genuinely are assertion expressions run through the DSL
|
|
128
156
|
// (verify.js:96), unlike `contacts` (pair arrays with nothing to parse). Mirror
|
|
129
157
|
// verify.js's own normalization — a bare value or an `{ expr, hint }` wrapper,
|
|
@@ -337,6 +365,7 @@ export const VERIFY_RULES = [
|
|
|
337
365
|
return [
|
|
338
366
|
...checkContacts(view.contacts, names, "verify.expect._view.contacts"),
|
|
339
367
|
...checkClearance(view.clearance, names, "verify.expect._view.clearance"),
|
|
368
|
+
...checkHeld(view.held, part?.parts ?? {}, "verify.expect._view.held"),
|
|
340
369
|
];
|
|
341
370
|
},
|
|
342
371
|
},
|
package/src/framework/mount.js
CHANGED
|
@@ -121,6 +121,10 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
121
121
|
// Read at teardown time, so it is the LIVE pose — unlike the camera the
|
|
122
122
|
// viewer persists for a page reload, which only records the end of a drag
|
|
123
123
|
// and so misses a view-cube click, Reframe, or an animation cue.
|
|
124
|
+
// The user's current view as an animation camera cue — `{ cue, panned }`,
|
|
125
|
+
// or null with nothing on screen — so a host can turn "the way I'm looking
|
|
126
|
+
// now" into a `camera` value an author (or an agent) writes into a part.
|
|
127
|
+
getCameraCue: () => viewer.getCameraCue?.() ?? null,
|
|
124
128
|
getViewerState: () => ({
|
|
125
129
|
camera: viewer.getCameraState(),
|
|
126
130
|
projection: viewer.getProjection?.() ?? "perspective",
|
|
@@ -5,10 +5,34 @@
|
|
|
5
5
|
// (a warn, armed only by the part — see overhangAngleFor; resin prints on
|
|
6
6
|
// supports, so it carries none); `clearance` mm is carried for a future gap check
|
|
7
7
|
// (not enforced yet).
|
|
8
|
+
//
|
|
9
|
+
// `fits`, `holeUndersize` and `bridge` are the numbers an author (or the cloud
|
|
10
|
+
// agent) copies into derive() for a mating feature; nothing in this repo reads
|
|
11
|
+
// them yet. They are guide defaults from the FDM design guides, not measurements:
|
|
12
|
+
// a calibrated printer overrides them through an inline `{ base, fits: {...} }`
|
|
13
|
+
// profile, which REPLACES the fits table whole. `fits` are diametral offsets in
|
|
14
|
+
// mm (negative = interference): press (a pin that never comes out), push (a cap
|
|
15
|
+
// pressed on by hand), slip (a lid that lifts off), moving (a hinge pin or axle),
|
|
16
|
+
// printInPlace (parts printed already assembled). `holeUndersize` is added to a
|
|
17
|
+
// horizontal bore's diameter, since the top of a printed arch sags inward.
|
|
18
|
+
// `bridge` is the longest flat span the process prints unsupported; resin prints
|
|
19
|
+
// on supports, so it carries none.
|
|
8
20
|
export const PROFILES = {
|
|
9
|
-
"fdm-pla": {
|
|
10
|
-
|
|
11
|
-
|
|
21
|
+
"fdm-pla": {
|
|
22
|
+
bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45,
|
|
23
|
+
fits: { press: -0.1, push: 0.05, slip: 0.25, moving: 0.35, printInPlace: 0.45 },
|
|
24
|
+
holeUndersize: 0.25, bridge: 10,
|
|
25
|
+
},
|
|
26
|
+
"fdm-petg": {
|
|
27
|
+
bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45,
|
|
28
|
+
fits: { press: -0.1, push: 0.05, slip: 0.3, moving: 0.4, printInPlace: 0.5 },
|
|
29
|
+
holeUndersize: 0.3, bridge: 10,
|
|
30
|
+
},
|
|
31
|
+
"resin": {
|
|
32
|
+
bed: [120, 68, 160], minWall: 0.6, clearance: 0.1,
|
|
33
|
+
fits: { press: -0.05, push: 0.05, slip: 0.1, moving: 0.15, printInPlace: 0.2 },
|
|
34
|
+
holeUndersize: 0.05,
|
|
35
|
+
},
|
|
12
36
|
};
|
|
13
37
|
|
|
14
38
|
export function resolveProfile(spec) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// `_view.held`: every exportable sub-part in the view must be connected, through
|
|
2
|
+
// contact or overlap, to a base. A pair-derived check like `contacts` — it reads
|
|
3
|
+
// the gap table and overlap list measure already computed and never touches a
|
|
4
|
+
// mesh — so it lives beside pairGapChecks, outside the scalar VIEW_METRICS
|
|
5
|
+
// registry, and emits the same structured check object.
|
|
6
|
+
//
|
|
7
|
+
// Topological only: a part balanced on a point passes. Load path and strength
|
|
8
|
+
// are not this check's business (AUTHORING-PARTS.md, "Self-verification").
|
|
9
|
+
import { CONTACT_EPS } from "./gaps.js";
|
|
10
|
+
|
|
11
|
+
export const HELD_HINT = "sub-part touches nothing — seat it on a mating face or add the fastener that holds it";
|
|
12
|
+
|
|
13
|
+
// `held` is `true` (base = the lowest sub-part by bounds.min z) or a sub-part
|
|
14
|
+
// name. `subPartNames` is the part's whole vocabulary (so a base that is
|
|
15
|
+
// enabled()-off in this case skips rather than throws); `exportableNames` bounds
|
|
16
|
+
// the graph to printed sub-parts — a reference ghost neither counts nor holds.
|
|
17
|
+
export function heldCheck(facts, held, { subPartNames, exportableNames } = {}) {
|
|
18
|
+
if (held === undefined || held === null) return null;
|
|
19
|
+
if (held !== true && typeof held !== "string") {
|
|
20
|
+
throw new Error(`held: must be true or a sub-part name, got ${JSON.stringify(held)}`);
|
|
21
|
+
}
|
|
22
|
+
const base = {
|
|
23
|
+
scope: "view", subpart: null, metric: "held", kind: "gate",
|
|
24
|
+
expr: held === true ? "connected" : `connected to ${held}`,
|
|
25
|
+
};
|
|
26
|
+
const inView = facts.subparts.map((s) => s.name);
|
|
27
|
+
const known = subPartNames ? new Set(subPartNames) : new Set(inView);
|
|
28
|
+
if (typeof held === "string" && !known.has(held)) {
|
|
29
|
+
throw new Error(`held: unknown sub-part "${held}" (view has: ${inView.join(", ")})`);
|
|
30
|
+
}
|
|
31
|
+
const printed = exportableNames ? new Set(exportableNames) : null;
|
|
32
|
+
const nodes = inView.filter((n) => !printed || printed.has(n));
|
|
33
|
+
if (typeof held === "string" && !nodes.includes(held)) {
|
|
34
|
+
return { ...base, actual: null, status: "skip", pass: null, message: "sub-part disabled in this case" };
|
|
35
|
+
}
|
|
36
|
+
if (nodes.length < 2) {
|
|
37
|
+
return { ...base, actual: 0, status: "pass", pass: true, message: `${nodes.length} sub-part, nothing to hold` };
|
|
38
|
+
}
|
|
39
|
+
// Same rule as pairGapChecks' noReading: a deliberately skipped pass (quick lap)
|
|
40
|
+
// is unevaluated; a legacy result with no table stays an untagged skip.
|
|
41
|
+
if (!facts.gaps) {
|
|
42
|
+
return facts.measuredGaps === false
|
|
43
|
+
? { ...base, actual: null, status: "skip", pass: null, unevaluated: true,
|
|
44
|
+
message: "not measured (quick check)", hint: "re-run this check without `quick` to measure pair distances" }
|
|
45
|
+
: { ...base, actual: null, status: "skip", pass: null, message: "unavailable" };
|
|
46
|
+
}
|
|
47
|
+
const adj = new Map(nodes.map((n) => [n, new Set()]));
|
|
48
|
+
const join = (a, b) => {
|
|
49
|
+
if (!adj.has(a) || !adj.has(b)) return;
|
|
50
|
+
adj.get(a).add(b);
|
|
51
|
+
adj.get(b).add(a);
|
|
52
|
+
};
|
|
53
|
+
for (const o of facts.overlaps ?? []) join(o.a, o.b);
|
|
54
|
+
for (const g of facts.gaps) if (g.distance <= CONTACT_EPS) join(g.a, g.b);
|
|
55
|
+
const rowOf = (n) => facts.subparts.find((s) => s.name === n);
|
|
56
|
+
const zOf = (n) => rowOf(n)?.bounds?.min?.[2];
|
|
57
|
+
const root = held === true
|
|
58
|
+
? nodes.reduce((best, n) => (zOf(n) != null && (zOf(best) == null || zOf(n) < zOf(best)) ? n : best), nodes[0])
|
|
59
|
+
: held;
|
|
60
|
+
const seen = new Set([root]);
|
|
61
|
+
const queue = [root];
|
|
62
|
+
while (queue.length) {
|
|
63
|
+
const n = queue.shift();
|
|
64
|
+
for (const m of adj.get(n)) if (!seen.has(m)) { seen.add(m); queue.push(m); }
|
|
65
|
+
}
|
|
66
|
+
const floating = nodes.filter((n) => !seen.has(n));
|
|
67
|
+
if (floating.length === 0) {
|
|
68
|
+
return { ...base, actual: 0, status: "pass", pass: true, message: `${nodes.length} sub-parts connected to ${root}` };
|
|
69
|
+
}
|
|
70
|
+
const first = rowOf(floating[0]);
|
|
71
|
+
const location = first?.centerOfMass
|
|
72
|
+
?? (first?.bounds ? first.bounds.min.map((v, i) => (v + first.bounds.max[i]) / 2) : null);
|
|
73
|
+
return {
|
|
74
|
+
...base, actual: floating.length, status: "fail", pass: false,
|
|
75
|
+
message: `${floating.join(", ")} not connected to ${root}`,
|
|
76
|
+
hint: HELD_HINT, pattern: "floating-subpart",
|
|
77
|
+
...(location ? { location } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
|
|
2
2
|
import { measure as defaultMeasure } from "./measure.js";
|
|
3
3
|
import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
4
|
+
import { heldCheck } from "./held.js";
|
|
4
5
|
import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
|
|
5
6
|
import { expandExpectations, partGatesMinWall } from "./gates.js";
|
|
6
7
|
import { relevanceHash } from "../param-deps.js";
|
|
@@ -270,11 +271,11 @@ function sheetRowChecks(s, declaredExp) {
|
|
|
270
271
|
// `overhang` is the angle the part opted into (dfm-profiles.js overhangAngleFor),
|
|
271
272
|
// or null/undefined: only then does the profile's overhang rule apply, as a
|
|
272
273
|
// 1 mm² warning floor — a chamfer sitting exactly on the angle sheds slivers.
|
|
273
|
-
export function evaluateCase(facts, { profile, expect, subPartNames, overhang = null }) {
|
|
274
|
+
export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null }) {
|
|
274
275
|
const checks = [];
|
|
275
|
-
// contacts/clearance are
|
|
276
|
-
// before the registry loop
|
|
277
|
-
const { contacts, clearance, ...viewScalarExp } = expect?._view ?? {};
|
|
276
|
+
// contacts/clearance/held are pair-derived, not scalar view metrics — peel
|
|
277
|
+
// them off before the registry loop. held.js explains the exportable bound.
|
|
278
|
+
const { contacts, clearance, held, ...viewScalarExp } = expect?._view ?? {};
|
|
278
279
|
// No sheet part in the view: every line below runs exactly as it did before sheet
|
|
279
280
|
// parts existed (test/verify-golden.test.js). With one, the bed moves from the view
|
|
280
281
|
// to each printed sub-part — see the sheet-parts block above.
|
|
@@ -285,6 +286,8 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
|
|
|
285
286
|
};
|
|
286
287
|
for (const [metric, expr] of Object.entries(viewExp)) checks.push(...checksFor("view", null, metric, expr, VIEW_METRICS, facts));
|
|
287
288
|
checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
|
|
289
|
+
const heldResult = heldCheck(facts, held, { subPartNames, exportableNames });
|
|
290
|
+
if (heldResult) checks.push(heldResult);
|
|
288
291
|
|
|
289
292
|
// Same rule as gapsSkipped above, one metric over: a min-wall gate with no reading
|
|
290
293
|
// is a warn ("min wall unavailable"), which does not move `ok` — right when the
|
|
@@ -437,6 +440,9 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
437
440
|
};
|
|
438
441
|
|
|
439
442
|
const subPartNames = Object.keys(part.parts);
|
|
443
|
+
// A reference ghost (`exportable: false`) is never printed, so it neither counts
|
|
444
|
+
// as held nor holds anything — the same exclusion measure applies to overhang.
|
|
445
|
+
const exportableNames = subPartNames.filter((n) => part.parts[n]?.exportable !== false);
|
|
440
446
|
// An unmeasured case gets ONE check standing for the whole case rather than a
|
|
441
447
|
// silent absence: "this preset was not checked" has to be visible in the same
|
|
442
448
|
// list every other verdict lives in, or a reader counting passes sees a shorter
|
|
@@ -449,7 +455,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
449
455
|
}];
|
|
450
456
|
const caseResults = expanded.map(({ name, params, expect }) => {
|
|
451
457
|
const facts = measureCase(params);
|
|
452
|
-
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, overhang: overhangAngle }) : notMeasured(name) };
|
|
458
|
+
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle }) : notMeasured(name) };
|
|
453
459
|
});
|
|
454
460
|
// VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
|
|
455
461
|
// `expect` — used to come back `ok: true` with zero checks, and every reader
|
|
@@ -117,7 +117,19 @@ export function fitPoseToPoints(view, points, { fov, aspect = 1, fill }) {
|
|
|
117
117
|
|
|
118
118
|
// `points` (flat world xyz, optional) lets a "fit" style frame the geometry
|
|
119
119
|
// itself (fitPoseToPoints); without them it fits the box's eight corners.
|
|
120
|
-
export function stylePose(style, view, box,
|
|
120
|
+
export function stylePose(style, view, box, opts = {}) {
|
|
121
|
+
const out = framedPose(style, view, box, opts);
|
|
122
|
+
// An orbit cue's `distance` multiplies whatever framing the style chose — the
|
|
123
|
+
// canonical distance already carries it (cameraPoseForView), a fit does not.
|
|
124
|
+
const k = typeof view === "object" && view !== null ? view.distance ?? 1 : 1;
|
|
125
|
+
if (k === 1 || out.fitted === false) return strip(out);
|
|
126
|
+
const { position, target } = out.pose;
|
|
127
|
+
out.pose = { ...out.pose, position: [0, 1, 2].map((i) => target[i] + (position[i] - target[i]) * k) };
|
|
128
|
+
return strip(out);
|
|
129
|
+
}
|
|
130
|
+
const strip = ({ pose, fov, sceneBounds }) => ({ pose, fov, sceneBounds });
|
|
131
|
+
|
|
132
|
+
function framedPose(style, view, box, { aspect = 1, points } = {}) {
|
|
121
133
|
const fov = style.camera.fov;
|
|
122
134
|
const min = box?.min ?? [0, 0, 0], max = box?.max ?? [0, 0, 0];
|
|
123
135
|
const center = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
|
|
@@ -130,7 +142,7 @@ export function stylePose(style, view, box, { aspect = 1, points } = {}) {
|
|
|
130
142
|
|
|
131
143
|
if (style.camera.framing === "canonical" || diag === 0) {
|
|
132
144
|
const radius = Math.max(size[0], size[1], size[2]) / 2 || DEFAULT_RADIUS;
|
|
133
|
-
return { pose: cameraPoseForView(view, { center, radius }), fov, sceneBounds };
|
|
145
|
+
return { pose: cameraPoseForView(view, { center, radius }), fov, sceneBounds, fitted: false };
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
const fitted = points?.length >= 3 && fitPoseToPoints(view, points, { fov, aspect, fill: style.camera.fill });
|
|
@@ -93,16 +93,123 @@ const norm = (v) => {
|
|
|
93
93
|
return [v[0] / l, v[1] / l, v[2] / l];
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
// A free-form camera cue: `{ azimuth, elevation, distance? }`, for the angles no
|
|
97
|
+
// name reaches ("halfway between iso and level"). Degrees, in the MODEL frame
|
|
98
|
+
// the face names already use: azimuth 0 looks from the front (model -Y) and
|
|
99
|
+
// turns toward the right (model +X) at 90, back at 180, left at -90 (or 270);
|
|
100
|
+
// elevation is above the horizontal, 90 straight down from the top. So iso is
|
|
101
|
+
// { azimuth: 45, elevation: 35.26 } and front-right is { azimuth: 45,
|
|
102
|
+
// elevation: 0 }. `distance` multiplies the framing distance a named angle
|
|
103
|
+
// would use (1, the default, frames the part exactly as a name does; 2 is
|
|
104
|
+
// twice as far out), so a cue keeps meaning the same thing as the part's
|
|
105
|
+
// params change its size. The camera always aims at the part's centre: a cue
|
|
106
|
+
// orbits, it never pans.
|
|
107
|
+
export const CAMERA_ORBIT_KEYS = ["azimuth", "elevation", "distance"];
|
|
108
|
+
export const CAMERA_ORBIT_DISTANCE = { min: 0.1, max: 10 };
|
|
109
|
+
|
|
110
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
111
|
+
&& (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
112
|
+
|
|
113
|
+
// Why `v` is not a valid orbit cue, as a short phrase; null when it is.
|
|
114
|
+
export function cameraOrbitProblem(v) {
|
|
115
|
+
if (!isPlainObject(v)) return "is not an { azimuth, elevation } object";
|
|
116
|
+
const extra = Object.keys(v).filter((k) => !CAMERA_ORBIT_KEYS.includes(k));
|
|
117
|
+
if (extra.length) return `has unknown key${extra.length > 1 ? "s" : ""} ${extra.map((k) => `\`${k}\``).join(", ")}`;
|
|
118
|
+
if (!Number.isFinite(v.azimuth)) return "needs a finite numeric `azimuth` (degrees)";
|
|
119
|
+
if (!Number.isFinite(v.elevation)) return "needs a finite numeric `elevation` (degrees)";
|
|
120
|
+
if (v.elevation < -90 || v.elevation > 90) return "has an `elevation` outside -90..90";
|
|
121
|
+
if (v.distance !== undefined) {
|
|
122
|
+
const { min, max } = CAMERA_ORBIT_DISTANCE;
|
|
123
|
+
if (!Number.isFinite(v.distance) || v.distance < min || v.distance > max) {
|
|
124
|
+
return `has a \`distance\` outside ${min}..${max} (a multiple of the default framing distance)`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Every value a camera cue may be: an angle name from CAMERA_CUE_VIEWS, or an orbit.
|
|
131
|
+
export function isCameraCue(v) {
|
|
132
|
+
if (typeof v === "string") return CAMERA_CUE_VIEWS.includes(v);
|
|
133
|
+
return cameraOrbitProblem(v) === null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A filename- and log-safe label for a cue: the name itself, or e.g.
|
|
137
|
+
// "az30-el20" / "az30-el20-d1.5" for an orbit.
|
|
138
|
+
export function cameraCueLabel(cue) {
|
|
139
|
+
if (typeof cue === "string") return cue;
|
|
140
|
+
const n = (x) => String(Math.round(x * 100) / 100).replace("-", "m");
|
|
141
|
+
return `az${n(cue.azimuth)}-el${n(cue.elevation)}${cue.distance !== undefined && cue.distance !== 1 ? `-d${n(cue.distance)}` : ""}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const RAD = Math.PI / 180;
|
|
145
|
+
|
|
146
|
+
// World (Y-up) direction and up for an orbit. Model (x, y, z) -> world (x, z, -y)
|
|
147
|
+
// as everywhere else in this file, so model (sin az·cos el, -cos az·cos el, sin el)
|
|
148
|
+
// lands at world (sin az·cos el, sin el, cos az·cos el).
|
|
149
|
+
function orbitAngle({ azimuth, elevation }) {
|
|
150
|
+
const az = azimuth * RAD, el = elevation * RAD;
|
|
151
|
+
const h = Math.cos(el);
|
|
152
|
+
const dir = [Math.sin(az) * h, Math.sin(el), Math.cos(az) * h];
|
|
153
|
+
// Straight down (or up) a +Y up is degenerate; use the screen-up a camera
|
|
154
|
+
// approaching that pole from this azimuth would have, which at azimuth 0 is
|
|
155
|
+
// exactly the `up` the top/bottom names use.
|
|
156
|
+
let up = [0, 1, 0];
|
|
157
|
+
if (Math.abs(h) < 1e-9) {
|
|
158
|
+
const s = elevation > 0 ? -1 : 1;
|
|
159
|
+
up = [s * Math.sin(az), 0, s * Math.cos(az)];
|
|
160
|
+
}
|
|
161
|
+
return { dir, up };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const framingDistance = (radius) => radius * 2.6 + 6; // matches viewer.frameTo's framing distance
|
|
165
|
+
|
|
96
166
|
export function cameraPoseForView(view, { center, radius }) {
|
|
97
167
|
// DIRS first so the seven canonical names keep their exact existing poses;
|
|
98
|
-
// ORIENTATIONS covers the other nineteen the cube can reach.
|
|
99
|
-
|
|
100
|
-
if (
|
|
168
|
+
// ORIENTATIONS covers the other nineteen the cube can reach; an object is an orbit.
|
|
169
|
+
let a, scale = 1;
|
|
170
|
+
if (typeof view === "string") {
|
|
171
|
+
a = DIRS[view] ?? ORIENTATIONS[view];
|
|
172
|
+
if (!a) throw new Error(`unknown canonical view "${view}"`);
|
|
173
|
+
} else {
|
|
174
|
+
const problem = cameraOrbitProblem(view);
|
|
175
|
+
if (problem) throw new Error(`camera cue ${problem}`);
|
|
176
|
+
a = orbitAngle(view);
|
|
177
|
+
scale = view.distance ?? 1;
|
|
178
|
+
}
|
|
101
179
|
const d = norm(a.dir);
|
|
102
|
-
const dist = radius *
|
|
180
|
+
const dist = framingDistance(radius) * scale;
|
|
103
181
|
return {
|
|
104
182
|
position: [center[0] + d[0] * dist, center[1] + d[1] * dist, center[2] + d[2] * dist],
|
|
105
183
|
up: a.up,
|
|
106
184
|
target: [...center],
|
|
107
185
|
};
|
|
108
186
|
}
|
|
187
|
+
|
|
188
|
+
// The inverse of an orbit cue: the cue that reproduces a live camera's angle and
|
|
189
|
+
// distance, so a host (or the agent behind it) can say "keep the view the user
|
|
190
|
+
// has now". `position`/`target` are the viewer's world (Y-up) camera state, and
|
|
191
|
+
// `radius` the same framing radius cameraPoseForView takes for this scene.
|
|
192
|
+
// Rounded to 0.1° and 0.01× — finer than the eye can tell — so the result reads
|
|
193
|
+
// like something an author would write. A pan is not representable (cues always
|
|
194
|
+
// aim at the centre), so it is reported rather than silently dropped. Null for a
|
|
195
|
+
// camera sitting on its target.
|
|
196
|
+
export function cameraCueForPose({ position, target }, { center, radius }) {
|
|
197
|
+
const off = [position[0] - target[0], position[1] - target[1], position[2] - target[2]];
|
|
198
|
+
const len = Math.hypot(off[0], off[1], off[2]);
|
|
199
|
+
if (!(len > 1e-9)) return null;
|
|
200
|
+
const [x, y, z] = [off[0] / len, off[1] / len, off[2] / len];
|
|
201
|
+
const round = (v, step) => Math.round(v / step) * step;
|
|
202
|
+
const fix = (v, places) => Number(v.toFixed(places)) || 0; // || 0 folds -0
|
|
203
|
+
const elevation = fix(round(Math.asin(Math.max(-1, Math.min(1, y))) / RAD, 0.1), 1);
|
|
204
|
+
// Azimuth is meaningless at a pole; 0 matches the top/bottom names' roll.
|
|
205
|
+
const azimuth = Math.abs(elevation) >= 90 ? 0 : fix(round(Math.atan2(x, z) / RAD, 0.1), 1);
|
|
206
|
+
const { min, max } = CAMERA_ORBIT_DISTANCE;
|
|
207
|
+
const distance = fix(Math.min(max, Math.max(min, round(len / framingDistance(radius), 0.01))), 2);
|
|
208
|
+
const panned = Math.hypot(target[0] - center[0], target[1] - center[1], target[2] - center[2]);
|
|
209
|
+
return {
|
|
210
|
+
cue: distance === 1 ? { azimuth, elevation } : { azimuth, elevation, distance },
|
|
211
|
+
// How far the user's orbit target sits from the part centre the cue aims at,
|
|
212
|
+
// as a fraction of the framing radius. 0 when they never panned.
|
|
213
|
+
panned: radius > 0 ? fix(panned / radius, 2) : 0,
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -25,7 +25,7 @@ import { orthoFrustum, perspectiveDistance, isFaceAligned, FACE_ALIGN_COS } from
|
|
|
25
25
|
import { depthRangeFor } from "./depth-range.js";
|
|
26
26
|
import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
|
|
27
27
|
import { makeCaptureCamera, recenteredView, captureDepthRange } from "./capture-frame.js";
|
|
28
|
-
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
28
|
+
import { CANONICAL_VIEWS, cameraPoseForView, cameraCueForPose } from "./view-angles.js";
|
|
29
29
|
|
|
30
30
|
import { srgbEncodeInPlace, RENDER_STYLES, getRenderStyle, CAD_LIGHT_THEME, CAD_DARK_THEME } from "./renderStyles.js";
|
|
31
31
|
import { stylePose, modelToWorld } from "./style-camera.js";
|
|
@@ -1361,6 +1361,21 @@ export function createViewer(container, part) {
|
|
|
1361
1361
|
{ duration, onComplete: finish },
|
|
1362
1362
|
);
|
|
1363
1363
|
}
|
|
1364
|
+
// The user's current view as an animation camera cue (view-angles.js
|
|
1365
|
+
// cameraCueForPose): `{ cue: { azimuth, elevation, distance? }, panned }`,
|
|
1366
|
+
// measured against the same bounds and radius tweenCameraTo frames a cue
|
|
1367
|
+
// with, so playing the returned cue lands where the user is looking now
|
|
1368
|
+
// (bar any pan, which `panned` reports). getCameraState supplies the pose,
|
|
1369
|
+
// so an orthographic view reads at its perspective-equivalent distance.
|
|
1370
|
+
// Null with nothing on screen.
|
|
1371
|
+
function getCameraCue() {
|
|
1372
|
+
const box = getVisibleWorldBounds();
|
|
1373
|
+
if (!box || box.isEmpty()) return null;
|
|
1374
|
+
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
1375
|
+
const size = box.getSize(new THREE.Vector3());
|
|
1376
|
+
const { pos, target } = getCameraState();
|
|
1377
|
+
return cameraCueForPose({ position: pos, target }, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
|
|
1378
|
+
}
|
|
1364
1379
|
// Cancelling has to put damping back: the tween's own completion path is the
|
|
1365
1380
|
// only other place that does, and it never runs for a cancelled tween.
|
|
1366
1381
|
const cancelCameraTween = () => { camTween.cancel(); restoreDamping(); };
|
|
@@ -3100,6 +3115,7 @@ export function createViewer(container, part) {
|
|
|
3100
3115
|
onFrame,
|
|
3101
3116
|
onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
|
|
3102
3117
|
tweenCameraTo,
|
|
3118
|
+
getCameraCue,
|
|
3103
3119
|
holdGround,
|
|
3104
3120
|
cancelCameraTween,
|
|
3105
3121
|
orbitBy,
|
package/src/testing/render.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join, resolve, sep } from "node:path";
|
|
3
3
|
import { safeName } from "../framework/safe-name.js";
|
|
4
|
-
import { ORIENTATIONS } from "../framework/view-angles.js";
|
|
4
|
+
import { ORIENTATIONS, cameraOrbitProblem, cameraPoseForView, cameraCueLabel } from "../framework/view-angles.js";
|
|
5
5
|
import { buildView } from "../framework/oracle/build.js";
|
|
6
6
|
import { renderStyled } from "../framework/softrender/index.js";
|
|
7
7
|
import { getRenderStyle } from "../framework/renderStyles.js";
|
|
@@ -28,7 +28,16 @@ export const RENDER_VIEWS = Object.keys(RENDER_ANGLES);
|
|
|
28
28
|
// The view cube's other orientations (animation camera cues may name them, and
|
|
29
29
|
// a still defaults to its cue's angle), mapped from the viewer's Y-up world
|
|
30
30
|
// back into model space: world (x, y, z) -> model (x, -z, y).
|
|
31
|
+
// An { azimuth, elevation, distance? } orbit cue resolves too, through the
|
|
32
|
+
// viewer's own cameraPoseForView so the two cannot disagree about an angle.
|
|
31
33
|
export function renderAngle(name) {
|
|
34
|
+
if (typeof name === "object" && name !== null) {
|
|
35
|
+
if (cameraOrbitProblem(name)) return null;
|
|
36
|
+
const pose = cameraPoseForView(name, { center: [0, 0, 0], radius: 1 });
|
|
37
|
+
const toModelDir = ([x, y, z]) => [x, -z, y];
|
|
38
|
+
return { dir: toModelDir(pose.position), up: toModelDir(pose.up) };
|
|
39
|
+
}
|
|
40
|
+
if (typeof name !== "string") return null;
|
|
32
41
|
if (Object.hasOwn(RENDER_ANGLES, name)) return RENDER_ANGLES[name];
|
|
33
42
|
const o = Object.hasOwn(ORIENTATIONS, name) ? ORIENTATIONS[name] : null;
|
|
34
43
|
if (!o) return null;
|
|
@@ -60,7 +69,7 @@ export async function renderViewImages(kernel, part, view = Object.keys(part.vie
|
|
|
60
69
|
// An explicit size is the caller's choice and is honoured as given.
|
|
61
70
|
if (!size) size = resolved.camera.aspect ? [640, Math.round(640 / resolved.camera.aspect)] : [800, 600];
|
|
62
71
|
for (const angle of views) {
|
|
63
|
-
if (!renderAngle(angle)) throw new Error(`unknown angle
|
|
72
|
+
if (!renderAngle(angle)) throw new Error(`unknown angle ${JSON.stringify(angle)} (use: ${RENDER_VIEWS.join(", ")}, a view-cube orientation such as top-front-left, or an { azimuth, elevation, distance? } orbit)`);
|
|
64
73
|
}
|
|
65
74
|
// Own-key lookups only — a part named "constructor" must not inherit a value
|
|
66
75
|
// off Object.prototype and vanish from the render.
|
|
@@ -76,7 +85,9 @@ export async function renderViewImages(kernel, part, view = Object.keys(part.vie
|
|
|
76
85
|
const img = renderStyled(subparts, { view: angle, style, size, supersample, edges });
|
|
77
86
|
const png = new PNG({ width: img.width, height: img.height });
|
|
78
87
|
png.data.set(img.rgba);
|
|
79
|
-
|
|
88
|
+
// `angle` is always a string: an orbit cue comes back as its label (az30-el20),
|
|
89
|
+
// which is also what names its file.
|
|
90
|
+
return { angle: cameraCueLabel(angle), png: PNG.sync.write(png) };
|
|
80
91
|
});
|
|
81
92
|
}
|
|
82
93
|
|
package/types/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// their geometry helpers from "partforge/geometry".
|
|
6
6
|
|
|
7
7
|
import type { BackendName } from "./kernel.js";
|
|
8
|
-
import type { CanonicalView, ParamValue, PartDefinition } from "./part.js";
|
|
8
|
+
import type { CameraOrbit, CanonicalView, ParamValue, PartDefinition } from "./part.js";
|
|
9
9
|
|
|
10
10
|
export * from "./kernel.js";
|
|
11
11
|
export * from "./part.js";
|
|
@@ -669,6 +669,15 @@ export interface PartRuntime {
|
|
|
669
669
|
* view-cube click, Reframe, or an animation cue).
|
|
670
670
|
*/
|
|
671
671
|
getViewerState(): ViewerState;
|
|
672
|
+
/**
|
|
673
|
+
* The user's current view as an animation camera cue, for "keep the view I
|
|
674
|
+
* have now": write `cue` as a `camera` value and playing it lands on this
|
|
675
|
+
* angle and distance. Cues always aim at the part's centre, so a pan cannot
|
|
676
|
+
* be carried — `panned` says how far the user's orbit target sits from that
|
|
677
|
+
* centre, as a fraction of the framing radius (0 when they never panned).
|
|
678
|
+
* Null with nothing on screen. Safe after `dispose()`.
|
|
679
|
+
*/
|
|
680
|
+
getCameraCue(): { cue: CameraOrbit; panned: number } | null;
|
|
672
681
|
/**
|
|
673
682
|
* True while the `preview` GLB is on screen: from the moment it decodes until a
|
|
674
683
|
* build delivers the whole view (a build error leaves it on). Picking, hover
|
package/types/part.d.ts
CHANGED
|
@@ -491,6 +491,17 @@ export interface DfmProfile {
|
|
|
491
491
|
overhang?: number | null;
|
|
492
492
|
/** Carried for a future gap check; not enforced yet. */
|
|
493
493
|
clearance?: number;
|
|
494
|
+
/**
|
|
495
|
+
* Diametral fit offsets in mm, negative = interference: `press` (never comes
|
|
496
|
+
* out), `push` (pressed on by hand), `slip` (lifts off), `moving` (a pin or
|
|
497
|
+
* axle), `printInPlace` (printed assembled). Guide defaults; an inline profile
|
|
498
|
+
* replaces the whole table.
|
|
499
|
+
*/
|
|
500
|
+
fits?: { press: number; push: number; slip: number; moving: number; printInPlace: number };
|
|
501
|
+
/** Added to a horizontal bore's diameter — a printed arch sags inward. */
|
|
502
|
+
holeUndersize?: number;
|
|
503
|
+
/** Longest flat span printed unsupported, mm. Absent on a process that prints on supports. */
|
|
504
|
+
bridge?: number;
|
|
494
505
|
/** Inherit from a named profile and override the rest. */
|
|
495
506
|
base?: DfmProfileName | DfmProfile;
|
|
496
507
|
}
|
|
@@ -600,6 +611,11 @@ export interface ViewExpectations {
|
|
|
600
611
|
contacts?: Array<[string, string]>;
|
|
601
612
|
/** Intended free fits, keyed `"a×b"` (order-insensitive). */
|
|
602
613
|
clearance?: Record<string, Expectation>;
|
|
614
|
+
/**
|
|
615
|
+
* Every exportable sub-part must be connected, through contact or overlap, to
|
|
616
|
+
* the base: the lowest sub-part (`true`) or the named one. Topological only.
|
|
617
|
+
*/
|
|
618
|
+
held?: true | string;
|
|
603
619
|
}
|
|
604
620
|
|
|
605
621
|
/** `verify.expect`: sub-part names plus the reserved `_view` key. */
|
|
@@ -660,11 +676,27 @@ export type ViewCubeOrientation =
|
|
|
660
676
|
| "bottom-front-left" | "bottom-front-right" | "bottom-back-left" | "bottom-back-right";
|
|
661
677
|
|
|
662
678
|
/**
|
|
663
|
-
* A camera
|
|
664
|
-
*
|
|
665
|
-
*
|
|
679
|
+
* A free-form camera angle, for the ones no name reaches. Degrees, in the
|
|
680
|
+
* part's own frame: `azimuth` 0 looks from the front (-Y) and turns toward the
|
|
681
|
+
* right (+X) at 90, back at 180, left at -90; `elevation` is above the
|
|
682
|
+
* horizontal, -90..90. `iso` is `{ azimuth: 45, elevation: 35.26 }` and
|
|
683
|
+
* `front-right` is `{ azimuth: 45, elevation: 0 }`. `distance` multiplies the
|
|
684
|
+
* framing distance a named angle uses (default 1; 0.1..10). The camera always
|
|
685
|
+
* aims at the part's centre.
|
|
686
|
+
*/
|
|
687
|
+
export interface CameraOrbit {
|
|
688
|
+
azimuth: number;
|
|
689
|
+
elevation: number;
|
|
690
|
+
distance?: number;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* A camera cue: one of the canonical seven, a view-cube orientation, or a
|
|
695
|
+
* free-form `CameraOrbit`. `partforge lint` rejects anything else
|
|
696
|
+
* (`animation-camera-invalid`). Cues fire during play only; scrubbing never
|
|
697
|
+
* moves the camera.
|
|
666
698
|
*/
|
|
667
|
-
export type CameraCue = CanonicalView | ViewCubeOrientation;
|
|
699
|
+
export type CameraCue = CanonicalView | ViewCubeOrientation | CameraOrbit;
|
|
668
700
|
|
|
669
701
|
/** One step of a multi-step animation. Steps play in order; prev/next navigate them. */
|
|
670
702
|
export interface AnimationStep {
|