partforge 0.163.1 → 0.164.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/bin/cli.js CHANGED
@@ -559,14 +559,17 @@ function printVerify(v) {
559
559
  for (const c of v.cases) {
560
560
  console.log(` ${c.name}`);
561
561
  for (const ch of c.checks) {
562
- const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
563
- console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
562
+ // A cost has a price (supports, a brim), so it gets `$`, not a warning sign.
563
+ const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠"
564
+ : ch.status === "cost" ? "$" : "·";
565
+ console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})${ch.accepted ? " (accepted)" : ""}`);
564
566
  // A measurement caveat prints whatever the verdict — "passed, but sampled"
565
567
  // is precisely the line a reader must not miss.
566
568
  if (ch.note) console.log(` note: ${ch.note}`);
567
- if (ch.status === "fail" || ch.status === "warn") {
569
+ if (ch.status === "fail" || ch.status === "warn" || ch.status === "cost") {
568
570
  if (ch.location) console.log(` at [${ch.location.map((n) => n.toFixed(1)).join(", ")}]`);
569
- if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (${patternRef(ch.pattern)})` : ""}`);
571
+ // An accepted cost's remedy is moot: the user already chose to pay it.
572
+ if (ch.hint && !ch.accepted) console.log(` hint: ${ch.hint}${ch.pattern ? ` (${patternRef(ch.pattern)})` : ""}`);
570
573
  }
571
574
  }
572
575
  }
@@ -584,7 +587,8 @@ function printVerify(v) {
584
587
  : v.unevaluated?.length ? `verdict withheld (${v.unevaluated.length} check(s) not evaluated)`
585
588
  : v.declared ? "nothing verified (every declared check skipped)"
586
589
  : "nothing verified (no expectations declared)";
587
- console.log(` result: ${verdict}${w ? `, ${w} warning(s)` : ""}`);
590
+ const c = v.costs?.length ?? 0;
591
+ console.log(` result: ${verdict}${w ? `, ${w} warning(s)` : ""}${c ? `, ${c} cost(s)` : ""}`);
588
592
  }
589
593
 
590
594
  function printLint(r) {
@@ -1919,6 +1919,128 @@ that tessellates nothing, unlike `toRegions()`), `fillet(r, opts?)`, `chamfer(di
1919
1919
  opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])`, `isEmpty()` (no
1920
1920
  regions left — see the vanishing-features rule above).
1921
1921
 
1922
+ ## Printed parts
1923
+
1924
+ 3D-printed parts: FDM (filament) and resin. Everything that is not a sheet
1925
+ part is printed, but a part says so on purpose by declaring its print process in
1926
+ `verify.process`; that one key arms the process's print checks below from the first
1927
+ build (all of them under FDM; bed fit and min wall under `resin`). Name
1928
+ the process the user will print with, never one written by habit: it is the declaration
1929
+ the checks, the fits and the advice all read.
1930
+
1931
+ ### Declaring the process
1932
+
1933
+ `process` is a profile id — `fdm-pla`, `fdm-petg` (the `fdm` process) or `resin` (the
1934
+ `resin` process) — or an inline object whose `base` names one and whose other keys
1935
+ override it: `{ base: "fdm-pla", bed: [256, 256, 256] }` for a bigger printer,
1936
+ `{ base: "fdm-pla", fits: { … } }` for a calibrated one (`fits` replaces the whole
1937
+ table). The profile numbers are in the `verify` section's table below. `accept` lists
1938
+ the costs the user has chosen to pay:
1939
+
1940
+ ```js
1941
+ verify: {
1942
+ process: "fdm-pla",
1943
+ accept: ["supports"], // the user chose supports for the organic body
1944
+ expect: { body: { holes: 1 }, _view: { overlaps: 0 } },
1945
+ }
1946
+ ```
1947
+
1948
+ ### Gates, costs and advisories
1949
+
1950
+ Every print check is in exactly one tier, decided by what the reading means:
1951
+
1952
+ | tier | checks | effect on `verify.ok` |
1953
+ | --- | --- | --- |
1954
+ | **gate** — a defect in any orientation | bed fit (`bbox` within `bed`), bed contact (`bedContactPct` ≥ 50) | fails it |
1955
+ | **cost** — a design choice with a price | overhang (`overhangArea`, id `supports`), footprint (`footprintPct` ≥ 10, id `footprint`) | never |
1956
+ | **advisory** — a reading that may not matter | min wall (`minWall`), `wall` | never; a warning |
1957
+
1958
+ A **gate** is fixed before the work is done: the piece cannot be printed this way
1959
+ whatever the user wants. A **cost** is a fact with a number and a location — mm² of
1960
+ overhang on a named piece and where it sits, or the share of a piece's silhouette on
1961
+ the bed that actually touches it — listed in `verify().costs` (never in `warnings`) with `status: "cost"`. It
1962
+ does not block a part still being shaped; it informs. Say each cost to the user once,
1963
+ in one sentence naming the piece, the number and the alternative ("the arm overhangs
1964
+ by 840 mm² and prints on supports; splitting it at the elbow avoids them"), and record
1965
+ the answer in `accept`. An **advisory** is read, and acted on only when
1966
+ it is real (a thin wall the user will hold, not a sliver at a facet seam).
1967
+
1968
+ **Accepting a cost is not switching it off.** An accepted cost is still measured and
1969
+ still listed in `costs`, with `accepted: true`, so the number stays visible; it is
1970
+ simply no longer something to raise. Switching a measurement off is the inline
1971
+ profile form, `{ base: "fdm-pla", overhang: null }` (or `footprint: null`,
1972
+ `bedContact: null`), and is for a part where the reading is meaningless, not unwelcome.
1973
+ A threshold the user named ("under 500 mm² of supports") is an `expect`:
1974
+ `overhangArea: "<=500"` — still a cost, reported in `costs` and never failing `ok`;
1975
+ the user's own limit keeps its meaning, so it is never marked accepted, whatever
1976
+ `accept` says (`accept` covers only the process's default check). Lint refuses an `accept` entry the process does not define
1977
+ (`verify-unknown-accept`): `fdm` defines `supports` and `footprint`; `resin` prints on
1978
+ supports and defines none.
1979
+
1980
+ **What arms what.** Under `fdm-pla`/`fdm-petg` the profile alone arms every check in
1981
+ the table except `wall`, which runs only where a sub-part declares a band; overhang fires past 1 mm². `resin` arms bed fit and min wall only. `orientation: "print"` declares that `build()`
1982
+ is the print pose (Z up, bed at the lowest Z); it arms nothing under a named profile,
1983
+ and with no profile at all it arms bed contact alone. An expectation you write yourself
1984
+ (`overhangArea`, `bedContactPct`, `footprintPct`) arms its own measurement under any
1985
+ profile or none; an `overhangArea` one is measured against 45° when the profile names no
1986
+ angle.
1987
+
1988
+ ### Orientation first
1989
+
1990
+ Decide each piece's print pose before its geometry. Put its largest flat face on the
1991
+ bed; when a piece has none, split it at its widest flat section so each half has one,
1992
+ and join the halves with pins or a dovetail. `build()` IS that pose — every check and
1993
+ every export reads the piece as built — and `views.assembly` poses it into place; a
1994
+ `print` view lays the pieces out on the bed beside each other (see "The
1995
+ `PartDefinition` contract"). A piece that reads well in the assembly and prints on its
1996
+ side is the most common source of both costs.
1997
+
1998
+ ### What costs supports and what does not
1999
+
2000
+ Overhangs past the profile's angle (45° from vertical on FDM) cost supports, and so
2001
+ do bridges longer than `bridge` (10 mm) and any cavity that opens downward — a socket
2002
+ under a ball, a pocket on the underside. The check counts every flat underside, a short
2003
+ bridge and the ceiling of a horizontal bore included, since a mesh cannot tell a bridge
2004
+ from a ceiling: a bridge within `bridge` prints without support, so tell the user that
2005
+ rather than redesign it. Faces within 45° of
2006
+ vertical do not cost supports: a chamfer under a ledge instead of a square step, a
2007
+ teardrop bore (a round hole with a 45° peak on top) instead of a round one. Faces on
2008
+ the bed or within 1 mm of it are never counted, so a bottom-edge chamfer, or a small fillet, is
2009
+ free.
2010
+
2011
+ **Ball joints on FDM** are a split socket (two halves that clamp the ball and screw or
2012
+ pin together) or a pinned clevis, not a socket snapped over a sphere: the snap's inside
2013
+ is a downward cavity that prints on supports nobody can reach. Build the snap only when
2014
+ the user wants it and has accepted the supports.
2015
+
2016
+ ### Small features and scale
2017
+
2018
+ A feature thinner than the profile's `minWall` (1.2 mm PLA, 1.5 mm PETG, 0.6 mm
2019
+ resin) prints as noise or not at all. A part with a scale parameter is checked at both
2020
+ ends of it: add a preset at the smallest and the largest scale and name them in
2021
+ `verify.cases`, so the small end's pins and walls are measured rather than described
2022
+ in the control's label ("below 150 mm the pins get thin").
2023
+
2024
+ ### Fits
2025
+
2026
+ Size a mating feature from the profile's fit class — `press`, `push`, `slip`,
2027
+ `moving`, `printInPlace` — copied into `derive()` with a comment naming it
2028
+ (`// slip fit: fdm-pla 0.25`), and a horizontal bore enlarged by `holeUndersize`. A
2029
+ snap is a contact; its fit is `push` or `slip`. The numbers, and how a diametral fit
2030
+ becomes a `clearance` expectation, are in the `verify` section below.
2031
+
2032
+ ### First layer
2033
+
2034
+ The first layer squashes wider than drawn (elephant's foot): give a bottom edge that
2035
+ must mate or measure true a small 45° chamfer (about 0.4 mm). Keep the underside flat
2036
+ and lowest: a knuckle, pin or boss that dips below it lifts the flat face off the bed
2037
+ and fails bed contact ([piece-hovers-above-bed](ERROR-PATTERNS.md#piece-hovers-above-bed))
2038
+ — size a print-in-place knuckle tangent to the bed and give it a block down to
2039
+ z = 0. A piece resting on a point, a line or a curve is reported as a footprint cost
2040
+ ([piece-stands-on-nothing](ERROR-PATTERNS.md#piece-stands-on-nothing)): lay it on a
2041
+ flat face, add a pad, or record the user's brim with `accept: ["footprint"]`. Never
2042
+ emboss text on the underside; deboss it, or put it on a side or the top.
2043
+
1922
2044
  ## Sheet parts
1923
2045
 
1924
2046
  Laser-cut parts from flat stock — plywood, MDF, acrylic — for a laser cutter
@@ -4008,7 +4130,7 @@ runtime a throwing probe only degrades quietly to `{ error }`.
4008
4130
 
4009
4131
  **Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
4010
4132
  `verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
4011
- `verify-unknown-orientation`,
4133
+ `verify-unknown-orientation`, `verify-unknown-accept`,
4012
4134
  `verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
4013
4135
  `contacts` / `clearance` / `held` keys, which are not scalar view metrics; they are
4014
4136
  validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
@@ -4173,7 +4295,8 @@ carries:
4173
4295
  from a sample rather than every triangle (see below); `overhangArea` sets one
4174
4296
  naming the steepest unsupported face's angle,
4175
4297
  - `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
4176
- sample point), `overhangArea` (the largest unsupported face's centroid) and
4298
+ sample point), `overhangArea` (the largest unsupported face's centroid),
4299
+ `footprintPct` (the piece's lowest point; absent when its export-pose reading failed) and
4177
4300
  `overlaps` (the center of the first offending intersection's
4178
4301
  *bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
4179
4302
  more than one place the bbox center can fall in the empty space between regions)
@@ -4188,7 +4311,8 @@ exists, e.g. the OCCT backend or min-wall measurement turned off, matching
4188
4311
  per triangle, which is unbounded work on a dense mesh, so past a sample budget
4189
4312
  it casts from a spread, deterministic subset instead — `minWallSampled` (boolean)
4190
4313
  and `minWallSamples` (`{ sampled, total }` or `null`) say whether that happened.
4191
- A part that opted into the overhang check (see the `verify` block) also carries
4314
+ A part checked for overhang (a profile carrying `overhang`, or an `overhangArea`
4315
+ expectation — see the `verify` block) also carries
4192
4316
  `overhangArea` (mm² of unsupported downward-facing surface, `null` when not
4193
4317
  checked or on an `exportable: false` sub-part), `overhangAngle` (the steepest such
4194
4318
  face, degrees from vertical) and `overhangAt` (that face's centroid); the report's
@@ -4198,7 +4322,13 @@ A part checked for bed contact (an FDM profile, see the `verify` block) carries
4198
4322
  when not checked or on an `exportable: false` sub-part), `bedContactArea` and
4199
4323
  `bedHoverArea` (mm² touching and hovering), `bedHoverGap` (how far above the bed the
4200
4324
  largest hovering face sits, `null` when nothing hovers) and `bedHoverAt` (its
4201
- centroid); the report's `measuredBedContact` says whether the pass ran.
4325
+ centroid); the report's `measuredBedContact` says whether the pass ran. The same
4326
+ pass reads `footprintPct` (the share of the piece's silhouette on the bed that actually
4327
+ touches it, in percent; `null` when not measured or the silhouette is empty) and
4328
+ `footprintAt`
4329
+ (the piece's lowest point; `null` when its export-pose reading failed), stamped by
4330
+ `measuredFootprint`; a profile carrying
4331
+ `footprint`, or a `footprintPct` expectation, arms the pass too.
4202
4332
  A sub-part that declares `wall` (see the `verify` section below) also carries
4203
4333
  `wall` — `{ value, location, band, members }` or `null`: the declared band's
4204
4334
  worst member, located, with the band it was measured against and how many rays
@@ -4302,10 +4432,11 @@ A part can declare how it should be checked, co-located with its schema, so
4302
4432
 
4303
4433
  ```js
4304
4434
  verify: {
4305
- process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an
4306
- // inline { base, bed:[x,y,z], minWall, overhang, bedContact, fits, holeUndersize, bridge } object
4307
- orientation: "print", // optional; ONLY with this is overhang checked — it says the
4308
- // part is laid out for its bed (Z up, bed at the lowest Z)
4435
+ process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an inline
4436
+ // { base, bed:[x,y,z], minWall, overhang, bedContact, footprint, fits, holeUndersize, bridge } object
4437
+ accept: ["supports"], // optional; the costs the user chose to pay (fdm: supports, footprint)
4438
+ orientation: "print", // optional; declares build() is the print pose (Z up, bed at
4439
+ // the lowest Z) — with no profile it arms bed contact
4309
4440
  cases: ["defaults", "M3"], // optional; default = defaults + every preset
4310
4441
  expect: { // design intent, by sub-part name (+ "_view")
4311
4442
  spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
@@ -4318,28 +4449,38 @@ verify: {
4318
4449
  ```
4319
4450
 
4320
4451
  **What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`),
4321
- a **min-wall** warning, and — only for a part that also declares
4322
- `orientation: "print"` — an **overhang** warning: `overhangArea` is the mm² of
4452
+ a **min-wall** warning, and on the FDM profiles a **bed-contact** gate and two
4453
+ **costs** — overhang and footprint (below). A cost is never a failure: it is a design
4454
+ choice with a price, reported in `verify().costs` with its number, location and hint
4455
+ and `status: "cost"`, and `ok` ignores it. See "Printed parts" for the tiers and how
4456
+ to raise a cost with the user.
4457
+
4458
+ **Overhang — a COST, armed by the process.** `overhangArea` is the mm² of
4323
4459
  downward-facing surface steeper than the profile's `overhang` angle (45° from vertical
4324
4460
  on the FDM profiles; resin carries none, since it prints on supports), measured per
4325
4461
  sub-part in its print (export) pose — not as the view shows it — with the bed at that
4326
- pose's own lowest Z, and warned past 1 mm²; the reported location is where the face sits
4327
- in the view. The
4328
- opt-in is deliberate: a profile says what a process can print, the orientation key
4329
- says this part is laid out for it, and a part still being shaped, or one bound for a
4330
- different process, should not be nagged about its underside. Writing your own
4462
+ pose's own lowest Z; it is a cost past 1 mm², with cost id `supports`, and the
4463
+ reported location is where the face sits in the view. A profile carrying `overhang`
4464
+ arms it from the first build; `orientation` takes no part. Writing your own
4331
4465
  `overhangArea` expectation is the other way in — it arms the measurement by itself,
4332
- against the profile's angle or 45° when the profile names none, so a declared
4333
- expectation is never answered "unavailable". Two bands next to the bed are never
4466
+ against the profile's angle or 45° when the profile names none, and is also how a
4467
+ user's limit is spelled (`overhangArea: "<=500"`). Two bands next to the bed are never
4334
4468
  counted: the footprint itself, and faces whose centroid sits within 1 mm of the bed
4335
4469
  (the lower curl of a bottom-edge fillet or chamfer, which prints fine). A bridge (a
4336
4470
  flat underside spanning two supports) and the ceiling of a horizontal bore are
4337
- reported as overhangs — the mesh alone cannot tell a bridge from a ceiling — which
4338
- is why this is a warning and never a gate. Two more limits, stated: it is judged in
4339
- the DISPLAY pose, so a sub-part whose `assembly` pose stands it differently from how it prints (a lid that prints
4340
- flat beside its base) is measured as displayed, and `exportable: false` sub-parts
4341
- are skipped. Switch it off under an FDM profile with an inline
4342
- `{ base: "fdm-pla", overhang: null }`.
4471
+ reported as overhangs — the mesh alone cannot tell a bridge from a ceiling — so read
4472
+ the location before proposing a redesign. When a sub-part's `place()` cannot be
4473
+ trusted as a rigid pose (it queries its solid), the reading falls back to the DISPLAY
4474
+ pose, so such a piece is measured as the view shows it. `exportable: false` sub-parts
4475
+ are skipped.
4476
+
4477
+ **`accept` records the user's choice.** `accept: ["supports"]` says the user chose to
4478
+ print with supports: the overhang is still measured and still listed in `costs`, with
4479
+ `accepted: true`, and is no longer something to raise. That is a different thing from
4480
+ switching the measurement off, which is the inline `{ base: "fdm-pla", overhang: null }`
4481
+ (and `footprint: null`) for a part where the reading means nothing. `accept` holds cost
4482
+ ids, not metric names — `fdm` defines `supports` and `footprint`, `resin` none — and
4483
+ lint refuses any other entry (`verify-unknown-accept`).
4343
4484
 
4344
4485
  **Bed contact — a GATE, armed by the FDM profile alone.** Every exporter and slicer
4345
4486
  lays a printed piece on its lowest point. When that point is something small — a
@@ -4362,10 +4503,9 @@ its note says how far up it sits and how much area hovers versus touches. The fi
4362
4503
  almost always to trim the piece flat at the bed plane — cut away everything below
4363
4504
  z = 0 in its print pose — or to raise or shorten the lower feature so the flat face
4364
4505
  is the lowest; size a print-in-place knuckle so its cylinder is tangent to the bed
4365
- (diameter = 2 × its centre height) and give it a support block down to z = 0. Unlike
4366
- overhang, no orientation key is needed: an overhang is a design choice a part still
4367
- being shaped may not have made, but a hovering underside is a defect in whatever
4368
- orientation the piece exports. Resin prints on supports, so `resin` never checks it
4506
+ (diameter = 2 × its centre height) and give it a support block down to z = 0. It is a
4507
+ gate where overhang is a cost: an overhang is a design choice that prints on supports,
4508
+ but a hovering underside is a defect in whatever orientation the piece exports. Resin prints on supports, so `resin` never checks it
4369
4509
  unless you ask: your own `bedContactPct` expectation arms it under any profile, and
4370
4510
  `orientation: "print"` arms it on a part with no profile at all (both against 50%). A
4371
4511
  named profile otherwise decides alone. A recess meant to clear the
@@ -4375,6 +4515,23 @@ default stands, and a part that means it declares a lower `bedContactPct` (or
4375
4515
  `{ base: "fdm-pla", bedContact: null }` to switch it off). `exportable: false`
4376
4516
  sub-parts are not judged.
4377
4517
 
4518
+ **Footprint — a COST, armed by the FDM profile.** Bed contact passes a piece that
4519
+ stands on a point, a line or a curve, since nothing flat hovers; the first layer still
4520
+ has almost nothing to grip, and the piece tips or pulls loose unless it prints with a
4521
+ brim or a raft. `footprintPct` is the share of the piece's silhouette on the bed (its
4522
+ outline seen from below, in the same print pose) that actually touches it, read by bed
4523
+ contact's pass, in percent: a plate reads 100, and so do a tube on its rim, a frame of
4524
+ thin bars, spread pins and a bar laid diagonally, since each stands on all of its
4525
+ outline; a rod on a rounded tip reads about 0, a cylinder on its side a few percent
4526
+ (the facets along its lowest line), under the floor, and a wide piece balanced on a
4527
+ narrow foot reads low too. It is a cost below the profile's `footprint` (10%), with cost id
4528
+ `footprint`, located at the piece's lowest point
4529
+ ([piece-stands-on-nothing](ERROR-PATTERNS.md#piece-stands-on-nothing)). Lay the piece on
4530
+ a flat face or add a pad; when the user chooses the brim, `accept: ["footprint"]`
4531
+ records it. Your own `footprintPct` expectation arms it under any profile; `orientation`
4532
+ does not, and `{ base: "fdm-pla", footprint: null }` switches it off. `exportable: false`
4533
+ sub-parts are not judged.
4534
+
4378
4535
  **What the profile tells you.** Three more fields carry the numbers a mating
4379
4536
  feature needs. Nothing checks them; they are the one place the value is spelled,
4380
4537
  so `derive()` copies it with a comment naming the fit (`// slip fit: fdm-pla 0.25`).
@@ -4399,7 +4556,7 @@ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
4399
4556
  **What `expect` gives you:** per-sub-part
4400
4557
  assertions on the facts `measure` already reports — `holes` (through-bores / genus),
4401
4558
  `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
4402
- `overhangArea`, `bedContactPct`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4559
+ `overhangArea`, `bedContactPct`, `footprintPct`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4403
4560
  positions, written in the same `{ min, max }` shape `measure` reports them — where
4404
4561
  the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
4405
4562
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
@@ -4442,12 +4599,14 @@ verify: { expect: {
4442
4599
  } }
4443
4600
  ```
4444
4601
 
4445
- **Gates vs. warnings:** exact facts are **gates** (a failure sets a non-zero exit code);
4446
- `minWall` is computed (a ray/shot wall-thickness measurement) and reported as a
4447
- **warning** — it flags walls below the profile's minimum but never fails the build —
4448
- and so is `overhangArea` (see above). `bedContactPct` is a **gate** even though the
4449
- profile arms it (see above). `holes`/`watertight` are Manifold-only, so those
4450
- assertions **skip** on OCCT parts rather than fail.
4602
+ **Gates, costs and warnings:** exact facts are **gates** (a failure sets a non-zero
4603
+ exit code and `ok: false`), and so is `bedContactPct`, even though the profile arms it.
4604
+ `overhangArea` and `footprintPct` under a process are **costs**: listed in `costs`, never
4605
+ failing, `accepted` once `accept` names them — but an `overhangArea` or `footprintPct`
4606
+ you write in `expect` is still a cost, not a gate. `minWall` is computed (a ray/shot
4607
+ wall-thickness measurement) and reported as a **warning** — it flags walls below the
4608
+ profile's minimum but never fails the build. `holes`/`watertight` are Manifold-only, so
4609
+ those assertions **skip** on OCCT parts rather than fail.
4451
4610
 
4452
4611
  **A wall that must stay one thickness: `wall`.** `minWall` answers "is anything too
4453
4612
  thin"; `wall` answers "does this wall stay what I declared" — the question a bend, a
@@ -4553,8 +4712,8 @@ mass. A reference ghost (`exportable: false`) is left out entirely: it neither
4553
4712
  counts nor holds, since it is never printed (lint refuses one named as the base).
4554
4713
  A named base that is `enabled()`-off
4555
4714
  in a case skips that case; a name that exists nowhere throws, like a `contacts`
4556
- pair. It is an expectation, never a default, for the overhang reason: a part
4557
- still being shaped should not be nagged. Topological only — a part balanced on a
4715
+ pair. It is an expectation, never a default: a part still being shaped should not
4716
+ fail for pieces it has not joined yet. Topological only — a part balanced on a
4558
4717
  point passes — and the same OCCT caveat applies: a sub-part fully inside another
4559
4718
  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`.
4560
4719
 
@@ -210,6 +210,12 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
210
210
  - **Cause:** Something smaller than the piece's flat underside sits lower than it in the print pose — a horizontal knuckle or pin whose cylinder dips below the plate, a boss, or lettering embossed on the bottom — so exporters and slicers rest the piece on that feature and the flat face prints in mid-air.
211
211
  - **Fix:** Trim the piece flat at the bed plane (cut everything below z = 0 in its print pose), or raise or shorten the lower feature so the flat face is the lowest; size a print-in-place knuckle so its cylinder is tangent to the bed and give it a support block down to z = 0. A recess meant to clear the bed declares a lower `bedContactPct`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
212
212
 
213
+ ## piece-stands-on-nothing
214
+
215
+ - **Symptom:** A `footprintPct` cost from `verify` (in `verify.costs`, e.g. `0.42 not >= 10`) on a piece whose print pose rests on a point, a line or a curl — a sphere-tipped rod standing on its end, a cylinder lying on its side — while `bedContactPct` passes.
216
+ - **Cause:** The lowest point of the piece is not a flat face, so almost none of its silhouette on the bed actually touches it (a cylinder on its side reads a few percent, under the floor): the first layer has almost nothing to bond to, and the piece tips or pulls loose unless it prints with a brim, a raft, or in a different orientation.
217
+ - **Fix:** Lay the piece on its flat face in its print pose, add a flat foot or pad at its lowest point, or print it with a brim. When the user chose the brim, `verify.accept: ["footprint"]` records it and the cost reads accepted. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
218
+
213
219
  ## expect-static-across-presets
214
220
 
215
221
  - **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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.163.1",
3
+ "version": "0.164.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",
@@ -7,7 +7,7 @@ import { SUBPART_METRICS, VIEW_METRICS, BOUNDS_FORM, teachableMetrics, sidesSpec
7
7
  import { PROFILES, ORIENTATIONS } from "../oracle/dfm-profiles.js";
8
8
  import { parseAssertion, bareVectorHint } from "../oracle/assert-dsl.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
- import { PROCESS_IDS } from "../process/registry.js";
10
+ import { PRINT_PROCESSES, PROCESS_IDS, printProcessFor } from "../process/registry.js";
11
11
  import { SHEET_DOC_ID } from "../sheet/constants.js";
12
12
 
13
13
  // Resolve `expect` to a plain object. The function form (p, d) => ({…}) is invoked
@@ -410,8 +410,41 @@ export const VERIFY_RULES = [
410
410
  const hint = safeSuggest(orientation, ORIENTATIONS);
411
411
  return [err("verify-unknown-orientation",
412
412
  `\`verify.orientation\` is ${describe(orientation)}, which is not a known orientation`,
413
- `Use ${ORIENTATIONS.map((o) => `"${o}"`).join(", ")}${hint ? ` — did you mean "${hint}"?` : ""} (declares the part is laid out for its print bed, arming the profile's overhang check), or omit the key.`,
413
+ `Use ${ORIENTATIONS.map((o) => `"${o}"`).join(", ")}${hint ? ` — did you mean "${hint}"?` : ""} (declares that \`build()\` is the print pose: Z up, bed at the lowest Z), or omit the key.`,
414
414
  "verify.orientation")];
415
415
  },
416
416
  },
417
+ {
418
+ // `verify.accept` lists cost ids the user accepted. verify() reads it but
419
+ // never validates it, so a typo would be silently ignored without this rule.
420
+ // A process that does not resolve is verify-unknown-process's finding, not ours.
421
+ id: "verify-unknown-accept",
422
+ run: ({ part }) => {
423
+ const verify = part?.verify;
424
+ if (verify?.accept == null) return [];
425
+ if (!Array.isArray(verify.accept)) {
426
+ return [err("verify-unknown-accept",
427
+ `\`verify.accept\` is ${describe(verify.accept)}, not a list of cost ids`,
428
+ "Write `accept` as an array of cost ids, e.g. `accept: [\"supports\"]`, or omit the key.",
429
+ "verify.accept")];
430
+ }
431
+ // With no print process resolved, a cost can still fire (an authored expectation,
432
+ // an inline profile with no base), so check against every print process's ids.
433
+ const proc = printProcessFor(verify.process);
434
+ const known = proc ? Object.keys(proc.costs) : [...new Set(PRINT_PROCESSES.flatMap((d) => Object.keys(d.costs)))];
435
+ const out = [];
436
+ verify.accept.forEach((id, i) => {
437
+ if (typeof id === "string" && known.includes(id)) return;
438
+ out.push(err("verify-unknown-accept",
439
+ `\`verify.accept[${i}]\` is ${describe(id)}, which is not a cost ${proc ? "this process" : "any print process"} defines`,
440
+ !proc
441
+ ? `No print process is declared; the cost ids any print process knows are: ${known.join(", ")}.`
442
+ : known.length
443
+ ? `Known cost ids for this process: ${known.join(", ")}.`
444
+ : "This process defines no acceptable costs; remove the entry.",
445
+ `verify.accept[${i}]`));
446
+ });
447
+ return out;
448
+ },
449
+ },
417
450
  ];
@@ -65,6 +65,59 @@ export const HOVER_FLOOR = 1;
65
65
  // The footprint slack never exceeds this (mm): well under a first layer, and under the
66
66
  // smallest hover worth reporting, so a tall piece is judged the way a short one is.
67
67
  export const MAX_BED_EPS = 0.05;
68
+ // The silhouette raster (the footprint's denominator): the finest cell pitch (mm), and
69
+ // the most cells along the longer side — so a large piece costs a 512×512 bitmap at most.
70
+ const SILHOUETTE_MIN_PITCH = 0.25;
71
+ const SILHOUETTE_CELLS = 512;
72
+
73
+ // Mark one downward triangle's xy shadow on the silhouette grid: every cell whose centre
74
+ // lies inside it. Each grid row is filled as one span — the x-range where the row's
75
+ // centre line crosses the triangle — so the work is the cells the triangle covers plus
76
+ // one intersection per row, never its whole bounding rectangle (a fine fan of slivers
77
+ // over the same floor used to rescan that rectangle once per sliver). A triangle too
78
+ // thin to contain any centre (a wall thinner than a cell) marks the cells along its
79
+ // outline instead (sampled at half a pitch), so it is never dropped between centres.
80
+ // Marking edge cells for EVERY triangle was tried and over-covered a 1 mm tube wall by
81
+ // a third; centre sampling is unbiased along an edge.
82
+ function shadow(P, a, b, c, minX, minY, pitch, gx, gy, grid, mark) {
83
+ const ax = P[a], ay = P[a + 1], bx = P[b], by = P[b + 1], cx = P[c], cy = P[c + 1];
84
+ const area2 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
85
+ let hit = false;
86
+ if (area2 !== 0) {
87
+ const j0 = Math.max(0, Math.floor((Math.min(ay, by, cy) - minY) / pitch));
88
+ const j1 = Math.min(gy - 1, Math.floor((Math.max(ay, by, cy) - minY) / pitch));
89
+ for (let j = j0; j <= j1; j++) {
90
+ const y = minY + (j + 0.5) * pitch;
91
+ // Where this row's centre line enters and leaves the triangle: the extreme x of its
92
+ // crossings with the three edges (an edge lying on the line contributes both ends).
93
+ let lo = Infinity, hi = -Infinity;
94
+ const cross = (x0, y0, x1, y1) => {
95
+ if (!((y0 <= y && y <= y1) || (y1 <= y && y <= y0))) return;
96
+ if (y0 === y1) { lo = Math.min(lo, x0, x1); hi = Math.max(hi, x0, x1); return; }
97
+ const x = x0 + ((y - y0) * (x1 - x0)) / (y1 - y0);
98
+ if (x < lo) lo = x;
99
+ if (x > hi) hi = x;
100
+ };
101
+ cross(ax, ay, bx, by); cross(bx, by, cx, cy); cross(cx, cy, ax, ay);
102
+ if (lo > hi) continue;
103
+ const i0 = Math.max(0, Math.ceil((lo - minX) / pitch - 0.5));
104
+ const i1 = Math.min(gx - 1, Math.floor((hi - minX) / pitch - 0.5));
105
+ for (let i = i0; i <= i1; i++) grid[j * gx + i] = 1;
106
+ if (i1 >= i0) hit = true;
107
+ }
108
+ }
109
+ if (hit) return;
110
+ // The fallback samples the triangle shrunk by half toward its centroid, not its own
111
+ // edges: a fan sliver of a disc's bottom then marks cells inside the disc, never the
112
+ // cells its rim crosses (which over-covered a Ø5 pin by a tenth).
113
+ const gxc = (ax + bx + cx) / 3, gyc = (ay + by + cy) / 3;
114
+ const h = ([x, y]) => [(x + gxc) / 2, (y + gyc) / 2];
115
+ const [pa, pb, pc] = [h([ax, ay]), h([bx, by]), h([cx, cy])];
116
+ for (const [x0, y0, x1, y1] of [[...pa, ...pb], [...pb, ...pc], [...pc, ...pa]]) {
117
+ const steps = Math.ceil(Math.hypot(x1 - x0, y1 - y0) / (pitch / 2)) + 1;
118
+ for (let t = 0; t <= steps; t++) mark(x0 + ((x1 - x0) * t) / steps, y0 + ((y1 - y0) * t) / steps);
119
+ }
120
+ }
68
121
 
69
122
  /**
70
123
  * @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, triangles: number }} mesh
@@ -73,38 +126,64 @@ export const MAX_BED_EPS = 0.05;
73
126
  * scanned); `bedEps` the footprint slack (default 0.1% of the mesh height, floored at
74
127
  * 0.01 mm and capped at MAX_BED_EPS); `bedBand` the height above the bed within which a flat face hovers
75
128
  * (default 1 mm)
76
- * @returns {{ pct: number, contactArea: number, hoverArea: number, gap: number|null, at: number[]|null }|null}
129
+ * @returns {{ pct: number, contactArea: number, hoverArea: number, downArea: number, gap: number|null, at: number[]|null, base: number[], lowestAt: number[] }|null}
77
130
  * `pct` 0..100, the share of the near-bed flat underside on the bed; the two areas in
78
131
  * mm²; `gap` how far above the bed the largest hovering face sits, and `at` its
79
- * centroid (null when nothing hovers past the floor); null for an empty mesh.
132
+ * centroid (null when nothing hovers past the floor); `downArea` the piece's
133
+ * silhouette on the bed (mm²): the UNION of every downward-facing triangle's shadow,
134
+ * rasterized (a closed mesh's silhouette is exactly that union, since every point of
135
+ * it has a bottom-most face, which faces down), and
136
+ * `lowestAt` the mesh's lowest vertex — measure.js's footprint cost, the contact area
137
+ * over `downArea`, catches what `pct` cannot: a piece with almost no flat underside
138
+ * at all (a sphere-tipped rod on its end) hovers nothing and reads 100. `base`, the
139
+ * mesh's [dx, dy] extent, is reported but no longer divided by: a tube on its rim, a
140
+ * frame or a diagonal bar fills little of its bounding box yet stands on all of its
141
+ * underside. Null for an empty mesh.
80
142
  */
81
143
  export function bedContact(mesh, { bedZ, bedEps, bedBand = 1 } = {}) {
82
144
  const { positions, indices } = mesh;
83
145
  const n = indices ? indices.length : positions.length / 3;
84
146
  if (n < 3) return null;
85
147
 
86
- let minZ = Infinity, maxZ = -Infinity;
87
- if (bedZ === undefined || bedEps === undefined) {
88
- for (let i = 2; i < positions.length; i += 3) {
89
- const z = positions[i];
90
- if (z < minZ) minZ = z;
91
- if (z > maxZ) maxZ = z;
92
- }
148
+ // One scan for the extents: the bed (lowest Z) and the slack's height when the caller
149
+ // did not hand them in, and always the posed base (its xy extent, reported) and the
150
+ // lowest vertex (where the footprint cost is located).
151
+ let minZ = Infinity, maxZ = -Infinity, minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, low = 0;
152
+ for (let i = 0; i + 2 < positions.length; i += 3) {
153
+ const x = positions[i], y = positions[i + 1], z = positions[i + 2];
154
+ if (x < minX) minX = x;
155
+ if (x > maxX) maxX = x;
156
+ if (y < minY) minY = y;
157
+ if (y > maxY) maxY = y;
158
+ if (z < minZ) { minZ = z; low = i; }
159
+ if (z > maxZ) maxZ = z;
93
160
  }
161
+ const base = [maxX - minX, maxY - minY];
162
+ const lowestAt = [positions[low], positions[low + 1], positions[low + 2]];
94
163
  const bed = bedZ ?? minZ;
95
164
  const eps = bedEps ?? Math.min(MAX_BED_EPS, Math.max(0.01, 1e-3 * (maxZ - minZ)));
96
165
  const bandTop = bed + bedBand;
166
+ // The silhouette raster: an xy grid over the base, at most ~512 cells a side.
167
+ const pitch = Math.max(SILHOUETTE_MIN_PITCH, Math.max(base[0], base[1]) / SILHOUETTE_CELLS);
168
+ const gx = Math.max(1, Math.ceil(base[0] / pitch) + 1), gy = Math.max(1, Math.ceil(base[1] / pitch) + 1);
169
+ const grid = new Uint8Array(gx * gy);
170
+ const mark = (x, y) => {
171
+ const i = Math.min(gx - 1, Math.max(0, Math.floor((x - minX) / pitch)));
172
+ const j = Math.min(gy - 1, Math.max(0, Math.floor((y - minY) / pitch)));
173
+ grid[j * gx + i] = 1;
174
+ };
97
175
 
98
176
  let contactArea = 0, hoverArea = 0, largest = 0, gap = null, at = null;
99
177
  for (let i = 0; i < n; i += 3) {
100
178
  const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
101
179
  const az = positions[a + 2], bz = positions[b + 2], cz = positions[c + 2];
102
180
  const cenZ = (az + bz + cz) / 3;
103
- if (cenZ > bandTop) continue; // above the band: not the underside
104
181
  const ux = positions[b] - positions[a], uy = positions[b + 1] - positions[a + 1], uz = bz - az;
105
182
  const vx = positions[c] - positions[a], vy = positions[c + 1] - positions[a + 1], vz = cz - az;
106
183
  const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
107
184
  if (nz >= 0) continue; // not facing down
185
+ shadow(positions, a, b, c, minX, minY, pitch, gx, gy, grid, mark); // its shadow joins the silhouette
186
+ if (cenZ > bandTop) continue; // above the band: not the underside
108
187
  const len = Math.hypot(nx, ny, nz);
109
188
  if (len < 1e-9) continue; // degenerate: no normal
110
189
  const triArea = len / 2;
@@ -122,6 +201,9 @@ export function bedContact(mesh, { bedZ, bedEps, bedBand = 1 } = {}) {
122
201
  cenZ];
123
202
  }
124
203
  }
125
- if (hoverArea < HOVER_FLOOR) return { pct: 100, contactArea, hoverArea, gap: null, at: null };
126
- return { pct: (100 * contactArea) / (contactArea + hoverArea), contactArea, hoverArea, gap, at };
204
+ let cells = 0;
205
+ for (let i = 0; i < grid.length; i++) cells += grid[i];
206
+ const downArea = cells * pitch * pitch;
207
+ if (hoverArea < HOVER_FLOOR) return { pct: 100, contactArea, hoverArea, downArea, gap: null, at: null, base, lowestAt };
208
+ return { pct: (100 * contactArea) / (contactArea + hoverArea), contactArea, hoverArea, downArea, gap, at, base, lowestAt };
127
209
  }