partforge 0.113.0 → 0.115.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 +146 -2
- package/docs/ERROR-PATTERNS.md +6 -0
- package/docs/KERNEL-CONTRACT.md +9 -3
- package/package.json +8 -1
- package/src/framework/app.css +50 -2
- package/src/framework/geometry/circle-segs.js +56 -0
- package/src/framework/geometry/manifold-backend.js +44 -18
- package/src/framework/geometry/mesh-fillet.js +30 -15
- package/src/framework/geometry/profile.js +28 -6
- package/src/framework/lint/rules-schema.js +56 -0
- package/src/framework/lint/source-scan.js +116 -2
- package/src/framework/mount.js +21 -2
- package/src/framework/panel/author.js +5 -0
- package/src/framework/panel/json-value.js +55 -0
- package/src/framework/panel/render.js +119 -36
- package/src/framework/panel/scoped-params.js +52 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/custom.js +253 -0
- package/src/framework/panel/widgets/index.js +2 -0
- package/src/panel-values.js +6 -0
- package/types/index.d.ts +66 -0
- package/types/panel-values.d.ts +18 -0
- package/types/part.d.ts +10 -1
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -745,6 +745,7 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
|
|
|
745
745
|
| `"font"` | a typeface picker with a catalog, else a drop target | `allow`, `preview`, `sourceField` |
|
|
746
746
|
| `"image"` | an image picker with a catalog, else a drop target showing the artwork | `allow`, `sourceField` |
|
|
747
747
|
| `"vector"` | a drop target showing the artwork — no catalog exists | `sourceField` |
|
|
748
|
+
| `"custom"` | a widget the part draws itself — see "Custom controls" below | `widget`, `keys` |
|
|
748
749
|
|
|
749
750
|
Numeric controls always show the number box: drag the slider *or* type an exact
|
|
750
751
|
value. Typed values may be finer than `step` and may sit **outside `[min, max]`**:
|
|
@@ -954,6 +955,132 @@ pickers in one section both need to show divergence, give each its own section.
|
|
|
954
955
|
case per preset name (so every preset gets measured), and a repeated name throws
|
|
955
956
|
there. `duplicate-preset-name` catches it at lint time instead.
|
|
956
957
|
|
|
958
|
+
### Custom controls
|
|
959
|
+
|
|
960
|
+
A `type: "custom"` control renders a widget the part draws itself — a clickable hex
|
|
961
|
+
grid, an organizer whose walls toggle on click, a picture of the part's own outline
|
|
962
|
+
with hot regions. Reach for it when the user configures **many similar things
|
|
963
|
+
individually**, or makes a **spatial choice** no slider or select can express.
|
|
964
|
+
Never for a value an existing control already covers: a slider is still a slider.
|
|
965
|
+
|
|
966
|
+
```js
|
|
967
|
+
// part.js
|
|
968
|
+
import { tilePicker } from "./tile-picker.js";
|
|
969
|
+
|
|
970
|
+
export default {
|
|
971
|
+
defaults: {
|
|
972
|
+
tileSize: 20,
|
|
973
|
+
tiles: [{ q: 0, r: 0, height: 10 }, { q: 1, r: 0, height: 14 }],
|
|
974
|
+
},
|
|
975
|
+
parameters: [{
|
|
976
|
+
title: "Tiles",
|
|
977
|
+
controls: [
|
|
978
|
+
{ type: "slider", key: "tileSize", min: 10, max: 40 },
|
|
979
|
+
{ type: "custom", key: "tiles", label: "Tile layout", widget: tilePicker,
|
|
980
|
+
description: "Click a tile to select it. Drag to move it." },
|
|
981
|
+
],
|
|
982
|
+
}],
|
|
983
|
+
parts: { tiles: { build: (k, p) => /* p.tiles is the array */ } },
|
|
984
|
+
};
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
- `key` names the param the widget owns. It may hold a **JSON value**: numbers,
|
|
988
|
+
strings, booleans, arrays and plain objects of those, at most 16 KB serialized and
|
|
989
|
+
8 levels deep, never `null` (`custom-default-not-json`). `build()` reads it like any
|
|
990
|
+
other param. This is the one place the "a control writes one scalar" rule is relaxed.
|
|
991
|
+
- `keys` (optional) lists further scalar params the widget may also write.
|
|
992
|
+
- `widget` is a function `(host) => …`, normally imported from a sibling file so
|
|
993
|
+
`part.js` stays readable (`custom-control-widget-not-function`).
|
|
994
|
+
- `label`, `description`, `hidden`, `when` and `whenFalse` behave as on any control.
|
|
995
|
+
|
|
996
|
+
**The widget function.** It runs once per mount and draws into `host.el` with plain
|
|
997
|
+
DOM and SVG. It may return `{ update, dispose }`.
|
|
998
|
+
|
|
999
|
+
```js
|
|
1000
|
+
// tile-picker.js
|
|
1001
|
+
export function tilePicker(host) {
|
|
1002
|
+
const svg = host.h("svg", { viewBox: "0 0 200 160", width: "100%" });
|
|
1003
|
+
const detail = host.h("div");
|
|
1004
|
+
host.el.append(svg, detail);
|
|
1005
|
+
let stopDetail = null;
|
|
1006
|
+
|
|
1007
|
+
function draw() {
|
|
1008
|
+
const tiles = host.get(); // a fresh clone of the owned value
|
|
1009
|
+
const sel = host.state.selected ?? null; // survives the remount an edit performs
|
|
1010
|
+
svg.replaceChildren(...tiles.map((t, i) => host.h("polygon", {
|
|
1011
|
+
points: hexPoints(t.q, t.r), class: i === sel ? "pf-hit selected" : "pf-hit",
|
|
1012
|
+
onpointerdown: () => { host.setState({ selected: i }); draw(); },
|
|
1013
|
+
})));
|
|
1014
|
+
stopDetail?.();
|
|
1015
|
+
stopDetail = sel === null ? null : host.controls(detail, [
|
|
1016
|
+
{ type: "slider", key: "height", label: "Height", min: 4, max: 30 },
|
|
1017
|
+
], { path: `${sel}` });
|
|
1018
|
+
}
|
|
1019
|
+
draw();
|
|
1020
|
+
return { update: draw, dispose: () => stopDetail?.() };
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
**The host.**
|
|
1025
|
+
|
|
1026
|
+
| Member | Meaning |
|
|
1027
|
+
|---|---|
|
|
1028
|
+
| `host.el`, `host.doc` | The slot to draw into, and its document. |
|
|
1029
|
+
| `host.get(key?)` | A clone of the current value (default: the owned key). Any param is readable. |
|
|
1030
|
+
| `host.set(value, {key?, commit?})` | Replace a value with a **new** one — never mutate what `get` returned. Schedules the rebuild and commits, unless `commit: false`; then call `host.commit()` when the gesture ends (a drag). Throws for a key you do not own or a value outside the contract. On a **retired** widget (one whose code already threw) `set`, `commit` and `setState` are silent no-ops. |
|
|
1031
|
+
| `host.commit(keys?)` | Ends a deferred gesture. A no-op when nothing changed. |
|
|
1032
|
+
| `host.derived` | The latest `derive()` output, the same object readouts show. |
|
|
1033
|
+
| `host.state`, `host.setState(patch)` | Transient JSON (a selection, an open panel). Not a param, never persisted, but it **survives a remount**, which every edit performs. |
|
|
1034
|
+
| `host.controls(container, controls, {path})` | Mount ordinary built-in controls bound *inside* the owned value at a dotted `path` (`"3"`, `"walls.north"`). Their edits commit the owning key. Returns a disposer. One level: no custom control inside, and no `font`, `image` or `vector` sub-control at a path (their asset lookups are keyed on the part's real param names). |
|
|
1035
|
+
| `host.h(tag, attrs, ...children)` | Element builder. SVG tags get the SVG namespace; `on<event>` attrs become listeners; `class` and `style` pass through. |
|
|
1036
|
+
| `host.svg(text)` | Parse an SVG string to an element you can append and wire up. |
|
|
1037
|
+
| `host.svgFromVector(doc)` | A partforge-vector document → inline `<svg>` (the same renderer the `vector` control uses). |
|
|
1038
|
+
| `host.file(pathOrToken)` | Text of one of the part's own files, by path or a `pfc-tree://` token, or null. |
|
|
1039
|
+
| `host.disabled` | Whether a `when`/`whenFalse: "disable"` currently disables this control. |
|
|
1040
|
+
|
|
1041
|
+
`update({reason, disabled})` is called when your key changes from outside the widget
|
|
1042
|
+
(`reason: "sync"` — a preset, undo, `setParams`), when `derived` changes
|
|
1043
|
+
(`"derived"`), and once after `host.state` was restored on mount (`"restore"`). It is
|
|
1044
|
+
**not** called for your own `set`. `dispose()` runs on teardown.
|
|
1045
|
+
|
|
1046
|
+
**Rules that keep it working.**
|
|
1047
|
+
|
|
1048
|
+
1. Size SVG with `viewBox` plus `width: 100%`. The rail is 288px wide by default and
|
|
1049
|
+
narrower on a phone, where it is the bottom sheet.
|
|
1050
|
+
2. Use pointer events, not mouse events, and put `class="pf-drag"` (or
|
|
1051
|
+
`touch-action: none`) on anything dragged — otherwise a finger scrolls the sheet.
|
|
1052
|
+
3. Keep selection and similar state in `host.state`, not in a closure: every edit
|
|
1053
|
+
remounts the part and your function runs again.
|
|
1054
|
+
4. Hand `set` a new value. `get` returns a clone precisely so the stored value is never
|
|
1055
|
+
edited in place.
|
|
1056
|
+
5. A throw in creation, `update`, or a listener installed through `host.h` replaces
|
|
1057
|
+
the widget with an error card and reports it (a hosting agent sees it in the apply
|
|
1058
|
+
result as `panelErrors`); other controls keep working. A throw in `dispose` is
|
|
1059
|
+
reported the same way but leaves no card — there is nothing left to show it on. A
|
|
1060
|
+
listener you attach yourself with `addEventListener`, rather than through `host.h`,
|
|
1061
|
+
is **not** guarded — wire listeners through `host.h`, or wrap your own in try/catch.
|
|
1062
|
+
|
|
1063
|
+
**Looking native.** The slot inherits the rail's font, colours and light/dark theme.
|
|
1064
|
+
Bare `<button>`, `<input>` and `<select>` elements pick up the built-in looks
|
|
1065
|
+
automatically; the built-in classes are available by name for the exact thing:
|
|
1066
|
+
`row`, `seg` (a segmented row of buttons), `action`, `ghost`, `num`, `text-input`,
|
|
1067
|
+
`select-input`; and for SVG, `pf-hit` (clickable, with a `selected` state) and
|
|
1068
|
+
`pf-drag`. Sub-controls mounted through `host.controls` *are* the built-in widgets.
|
|
1069
|
+
|
|
1070
|
+
**Reading the part's own files.** Artwork can live beside the code (the tree is text,
|
|
1071
|
+
so an SVG, a `partforge-vector` JSON document or a JSON data file — not a PNG).
|
|
1072
|
+
Two routes: store the SVG as a string in a JS module (`assets/emblem.svg.js` exporting
|
|
1073
|
+
a template literal) and import it like any sibling file — no framework support
|
|
1074
|
+
needed; or read it with `host.file("assets/emblem.svg")` and inline it with
|
|
1075
|
+
`host.svg(text)`. Give regions ids and wire `pointerdown` on `#wall-3` to toggle
|
|
1076
|
+
`walls[3]` in the owned value.
|
|
1077
|
+
|
|
1078
|
+
**What a widget cannot do.** Never `fetch` or otherwise reach the network — a widget
|
|
1079
|
+
is a pure function of the part and its params, and a hosted sandbox may refuse the
|
|
1080
|
+
request or have no credentials to make it with. Also: no imports beyond the part's
|
|
1081
|
+
own files, nothing outside `host.el`, and no reading of `params` except through
|
|
1082
|
+
`host`.
|
|
1083
|
+
|
|
957
1084
|
### Legacy section shapes (still supported)
|
|
958
1085
|
|
|
959
1086
|
Everything above is what a **new part should write**. The original array-based shapes
|
|
@@ -1143,6 +1270,10 @@ defaulting everything to a slider:
|
|
|
1143
1270
|
reason for a two-position slider to exist.
|
|
1144
1271
|
- **A computed value the user should see but not set** → `"readout"`. It costs no
|
|
1145
1272
|
parameter and answers the "so what did that do?" question in place.
|
|
1273
|
+
- **Many similar things configured individually, or a spatial choice** (which tiles
|
|
1274
|
+
exist and how tall each is; which walls of an organizer are present) →
|
|
1275
|
+
`"custom"`: a widget the part draws, holding one JSON value. See "Custom controls"
|
|
1276
|
+
above; use it only when no built-in control expresses the choice.
|
|
1146
1277
|
|
|
1147
1278
|
Then gate what doesn't always apply. A control that is meaningless in the current mode
|
|
1148
1279
|
should carry a **`when`** rather than sit there inert — hide it by default, or use
|
|
@@ -2791,7 +2922,9 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
2791
2922
|
`preset-key-not-in-defaults`, `mixed-section-shape`,
|
|
2792
2923
|
`duplicate-preset-name`, `duplicate-node-id`, `select-options-missing`,
|
|
2793
2924
|
`select-default-not-in-options`, `log-scale-needs-positive-min`,
|
|
2794
|
-
`when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type
|
|
2925
|
+
`when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type`,
|
|
2926
|
+
`custom-control-widget-not-function`, `custom-default-not-json`,
|
|
2927
|
+
`custom-keys-not-in-defaults` (errors);
|
|
2795
2928
|
`slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
|
|
2796
2929
|
`default-not-exposed`, `readout-unknown-derived-key`, `slider-refinement-invalid`,
|
|
2797
2930
|
`group-depth`, `section-too-many-controls` (warnings).
|
|
@@ -2802,6 +2935,10 @@ can't coexist, since mixing them would make the render order arbitrary. Move
|
|
|
2802
2935
|
the legacy entries into `controls` (a toggle becomes a checkbox control,
|
|
2803
2936
|
`advanced` becomes a nested group, `presets` becomes `{ type: "preset" }`
|
|
2804
2937
|
nodes), or drop `controls` and stay legacy.
|
|
2938
|
+
`custom-default-not-json` is `control-default-not-primitive`'s counterpart for a
|
|
2939
|
+
`type: "custom"` control, whose key may hold a JSON value (see "Custom controls"):
|
|
2940
|
+
it names the first member that is not one — `null`, a function, a class instance,
|
|
2941
|
+
a forbidden key, or a value past the 16 KB / depth-8 caps.
|
|
2805
2942
|
`duplicate-preset-name` fires when the same preset name is declared twice
|
|
2806
2943
|
(legacy `presets` and/or `{ type: "preset" }` nodes both count) — preset names
|
|
2807
2944
|
are global to the part, and `verify()` expands one case per name and throws on
|
|
@@ -3505,7 +3642,14 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
|
|
|
3505
3642
|
`slotPolygon`, `ringSectorPolygon` and `circleProfile`; mirror a symmetric half with
|
|
3506
3643
|
`mirrorProfile`.
|
|
3507
3644
|
- **Preview vs print quality:** Manifold bakes segment counts in at primitive creation,
|
|
3508
|
-
so builds are quality-agnostic; the export path uses a separate
|
|
3645
|
+
so builds are quality-agnostic; the export path uses a separate "print" kernel. Preview
|
|
3646
|
+
facets every circle at 116 segments. Print sizes each circle by chord tolerance — the
|
|
3647
|
+
fewest segments that keep the facet sagitta under 0.01 mm — never fewer than the
|
|
3648
|
+
preview's 116 and never more than 480, so a small feature exports at exactly the density
|
|
3649
|
+
you previewed and only circles wider than about 54 mm get finer. A part that previews
|
|
3650
|
+
is a part that exports: the old flat 480 turned a 0.75 mm rivet into 115,200 triangles
|
|
3651
|
+
and a body with a few hundred of them into an out-of-memory trap at export
|
|
3652
|
+
([export-kernel-out-of-memory](ERROR-PATTERNS.md#export-kernel-out-of-memory)).
|
|
3509
3653
|
- **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
|
|
3510
3654
|
depend on `view` ([view-dependent-display-place](ERROR-PATTERNS.md#view-dependent-display-place)).
|
|
3511
3655
|
- **Keep geometry backend-agnostic** (kernel calls only); only STEP requires OCCT
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -823,6 +823,12 @@ between the Manifold preview and the OCCT STEP export.
|
|
|
823
823
|
- **Cause:** *(partforge ≥ 0.112.)* A hand-authored 2-D profile (a point list, a `pathProfile` contour, a `{outer, holes}` region — handed to a factory op, to `k.shape2d`, or as a `Shape2D` boolean operand) crosses itself. Manifold fills a point ring even-odd, so the crossing quietly inverts the fill on one side instead of failing. The usual author of the crossing is an arc sampled into points by hand (a `Math.cos` loop) whose sweep sign or endpoint order is wrong, or a mirrored half whose point order was not reversed. The kernel now runs `validateProfile` on the way in and reports each crossing on the build result's `warnings`; a `Shape2D` is not re-validated, `text2d`/`vector2d` lifts are trusted, and a profile over 4000 segments is skipped (for `loft`, the ceiling is the sum over all its rings). At most **three** crossings are reported per profile — the third reads `… (and N more crossings on this profile)` — so one badly-drawn star cannot evict every other warning in the build. A hole whose edge touches or runs along its outer is **not** reported: that builds exactly as drawn, and only a contour crossing itself inverts the fill.
|
|
824
824
|
- **Fix:** Rebuild the curved parts of the outline with `pathProfile(start).lineTo(p).arcTo(to, via).close()` — a three-point arc sweeps through `via`, so its direction cannot flip — or with the `partforge/geometry` helpers (`roundedProfile`, `filletPolygon`, `slotPolygon`, `ringSectorPolygon`, `circleProfile`); build a symmetric half once and `mirrorProfile` it rather than writing the mirror by hand. Confirm with `validateProfile(profile).ok` before extruding. The reported coordinate is in the profile's own frame (before any `rotate`/`at`).
|
|
825
825
|
|
|
826
|
+
## export-kernel-out-of-memory
|
|
827
|
+
|
|
828
|
+
- **Symptom:** `Out of bounds memory access` (Safari) or `memory access out of bounds` (Chrome, Node) from an STL or 3MF export — or from a build — of a part whose preview renders fine; often followed, on every later build in the same session, by `Manifold instance already deleted`, `Out of bounds call_indirect`, `call_indirect to a signature that does not match`, `table index is out of bounds`, or `null function or function signature mismatch`.
|
|
829
|
+
- **Cause:** The mesh kernel's WASM heap ran out while building the print-quality mesh, and a WASM trap leaves that kernel instance corrupt — every later call into it fails until the worker is replaced or the page reloaded. Before partforge 0.115 the print tier meshed every circle at a flat 480 segments whatever its radius, so a part with a few hundred small spheres or cylinders (a 0.75 mm rivet was 115,200 triangles — 176 of them are 20 M before a single boolean) exhausted a 4 GB heap on its first export while its whole unioned preview was a few hundred thousand triangles. Since 0.115 print sizes circles by a 0.01 mm chord tolerance, floored at the preview count, so a part that previews normally exports at roughly the preview's cost; a part that still traps is genuinely too heavy for the browser at ANY quality — usually thousands of repeated small features, or a boolean chain whose intermediates dwarf the result.
|
|
830
|
+
- **Fix:** Reload the page (or let the host replace the kernel worker) before retrying anything — the trapped instance cannot recover. Then reduce what the export has to hold at once: build repeated detail as one union of instances rather than a chain of per-feature booleans, drop feature counts that exceed what the print can show (a 0.75 mm sphere prints as a dot), or pass `segs` to `revolve` where a coarser sweep is acceptable ([Preview vs print quality](AUTHORING-PARTS.md#conventions--gotchas)). Do NOT strip visible detail from the part to dodge a pre-0.115 trap — update partforge instead; the geometry was never the problem.
|
|
831
|
+
|
|
826
832
|
# Hardware library
|
|
827
833
|
|
|
828
834
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -375,7 +375,12 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
|
|
|
375
375
|
|
|
376
376
|
`quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
|
|
377
377
|
speed and a backend may bake it at kernel creation (Manifold does). A part must never
|
|
378
|
-
depend on triangle counts, segment counts, or normals being present.
|
|
378
|
+
depend on triangle counts, segment counts, or normals being present. The in-repo
|
|
379
|
+
backends both define `print` as a **chord tolerance of 0.01 mm** (OCCT's linear
|
|
380
|
+
deflection; Manifold's per-circle segment rule in `geometry/circle-segs.js`, floored at
|
|
381
|
+
the preview's 116 segments so print is never coarser than preview and capped at 480), so
|
|
382
|
+
a small feature costs the export exactly what its preview cost — the property that makes
|
|
383
|
+
"if it previews, it exports" hold. Preview is a flat 116 on Manifold, a visual choice.
|
|
379
384
|
|
|
380
385
|
### Shading intent (toMesh normals and edges)
|
|
381
386
|
|
|
@@ -554,8 +559,9 @@ curve-exact (they integrate the real curves; they do not measure a tessellation)
|
|
|
554
559
|
so it is backend-identical too, like everything else in this list.
|
|
555
560
|
|
|
556
561
|
**Lazy materialization.** Backend geometry is built only where it is unavoidable.
|
|
557
|
-
Three readbacks tessellate to point rings at the backend's own LOD (Manifold 116
|
|
558
|
-
preview
|
|
562
|
+
Three readbacks tessellate to point rings at the backend's own LOD (Manifold 116 per
|
|
563
|
+
circle at preview and, at print, the fewest segments holding a 0.01 mm chord sagitta
|
|
564
|
+
between 116 and 480 — per arc, by its radius; OCCT 64): `toRegions()`, `simple()` (its unwrapped form), and
|
|
559
565
|
`regions()` — scission currently round-trips through `toRegions()`, so each returned
|
|
560
566
|
`Shape2D` is a faceted copy, not a curve-native slice of the original. `extrude` and
|
|
561
567
|
`revolve` materialize the shape into the backend's own form instead (Manifold: a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.115.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",
|
|
@@ -58,6 +58,10 @@
|
|
|
58
58
|
"types": "./types/ingest.d.ts",
|
|
59
59
|
"default": "./src/ingest.js"
|
|
60
60
|
},
|
|
61
|
+
"./panel-values": {
|
|
62
|
+
"types": "./types/panel-values.d.ts",
|
|
63
|
+
"default": "./src/panel-values.js"
|
|
64
|
+
},
|
|
61
65
|
"./tokens.css": "./src/framework/tokens.css",
|
|
62
66
|
"./chrome.css": "./src/framework/chrome.css"
|
|
63
67
|
},
|
|
@@ -83,6 +87,9 @@
|
|
|
83
87
|
],
|
|
84
88
|
"ingest": [
|
|
85
89
|
"./types/ingest.d.ts"
|
|
90
|
+
],
|
|
91
|
+
"panel-values": [
|
|
92
|
+
"./types/panel-values.d.ts"
|
|
86
93
|
]
|
|
87
94
|
}
|
|
88
95
|
},
|
package/src/framework/app.css
CHANGED
|
@@ -64,8 +64,10 @@ canvas { display: block; }
|
|
|
64
64
|
Condition-hidden sections (.hidden, a `when` that evaluated false) are excluded
|
|
65
65
|
for the same reason. Do not simplify this to `.section:first-child { border-top: 0 }` — that
|
|
66
66
|
matches DOM position rather than visibility, and leaves a stray divider
|
|
67
|
-
floating at the top whenever applyRelevance hides the first section.
|
|
68
|
-
|
|
67
|
+
floating at the top whenever applyRelevance hides the first section. A bare
|
|
68
|
+
sub-panel section (host.controls inside a custom control) never gets the divider
|
|
69
|
+
because the widget owns its framing. */
|
|
70
|
+
.section:not(.section-hidden):not(.hidden) ~ .section:not(.section-hidden):not(.hidden):not(.bare) {
|
|
69
71
|
border-top: 1px solid var(--pf-border);
|
|
70
72
|
}
|
|
71
73
|
/* Section disclosure header: the whole row is the click target (render.js
|
|
@@ -382,6 +384,52 @@ button.ghost { background: transparent; border: 1px solid var(--pf-border); colo
|
|
|
382
384
|
button.ghost:hover:not(:disabled) { background: var(--pf-surface-2); }
|
|
383
385
|
button.action:disabled { opacity: .5; cursor: default; }
|
|
384
386
|
|
|
387
|
+
/* Custom controls (type: "custom"): a part-authored widget in the rail. The
|
|
388
|
+
slot inherits the rail's cascade (font, colours, --pf-* tokens); these rules
|
|
389
|
+
give BARE elements the built-in controls' look so a widget written with
|
|
390
|
+
plain <button>/<input>/<select> still reads as native, and provide two
|
|
391
|
+
opt-in classes for SVG: .pf-hit (a clickable region) and .pf-drag (a
|
|
392
|
+
dragged one — touch-action: none is what lets a finger drag it on a phone
|
|
393
|
+
instead of scrolling the sheet). */
|
|
394
|
+
.pf-custom { margin: 9px 0; }
|
|
395
|
+
.pf-custom.hidden { display: none; }
|
|
396
|
+
.pf-custom-slot { display: flow-root; }
|
|
397
|
+
/* Every selector in this block is wrapped so the WHOLE thing resolves to
|
|
398
|
+
(0,0,0) — including the :not([type=...]) chain, which is otherwise an
|
|
399
|
+
attribute selector per :not() and would still out-specify a class rule
|
|
400
|
+
like .text-input (0,1,0) or .row .num (0,2,0). At true zero specificity,
|
|
401
|
+
any class rule wins, so a named built-in class (.seg button, .text-input,
|
|
402
|
+
select.select-input, …) always beats these bare-element defaults. */
|
|
403
|
+
:where(.pf-custom) :where(svg) { display: block; max-width: 100%; height: auto; }
|
|
404
|
+
:where(.pf-custom) :where(button) {
|
|
405
|
+
font: inherit; font-size: 12px; padding: 5px 9px; cursor: pointer;
|
|
406
|
+
background: transparent; border: 1px solid var(--pf-border); color: var(--pf-text-2);
|
|
407
|
+
border-radius: var(--pf-radius-control);
|
|
408
|
+
}
|
|
409
|
+
:where(.pf-custom) :where(button:hover:not(:disabled)) { background: var(--pf-surface-2); }
|
|
410
|
+
:where(.pf-custom) :where(button:disabled) { opacity: .5; cursor: default; }
|
|
411
|
+
:where(.pf-custom) :where(input:not([type="range"]):not([type="checkbox"]):not([type="radio"]), select) {
|
|
412
|
+
font: 12px/1.4 var(--pf-mono);
|
|
413
|
+
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
414
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 6px 8px;
|
|
415
|
+
}
|
|
416
|
+
:where(.pf-custom) :where(input:focus, select:focus, button:focus-visible) {
|
|
417
|
+
outline: none; border-color: var(--pf-accent);
|
|
418
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
419
|
+
}
|
|
420
|
+
.pf-custom .pf-hit { cursor: pointer; }
|
|
421
|
+
.pf-custom .pf-hit:hover { fill: color-mix(in oklab, var(--pf-accent) 25%, transparent); }
|
|
422
|
+
.pf-custom .pf-hit.selected { fill: color-mix(in oklab, var(--pf-accent) 45%, transparent); stroke: var(--pf-accent); }
|
|
423
|
+
.pf-custom .pf-drag { touch-action: none; }
|
|
424
|
+
.pf-custom-error {
|
|
425
|
+
margin: 4px 0; padding: 6px 8px; border: 1px solid var(--pf-err); border-radius: var(--pf-radius-control);
|
|
426
|
+
color: var(--pf-err); font-family: var(--pf-mono); font-size: 11px; line-height: 1.4; word-break: break-word;
|
|
427
|
+
}
|
|
428
|
+
.pf-custom-error-title { font-weight: 600; margin-bottom: 2px; }
|
|
429
|
+
/* A sub-panel inside a custom control (host.controls): no header, no indent. */
|
|
430
|
+
.section.bare > .sec-body { padding: 0; }
|
|
431
|
+
.section.bare { padding: 0; border: 0; }
|
|
432
|
+
|
|
385
433
|
.dl { margin-top: 14px; }
|
|
386
434
|
.dl-head {
|
|
387
435
|
font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Per-circle segment counts for the mesh backend's two quality tiers.
|
|
2
|
+
//
|
|
3
|
+
// `preview` is a flat count: 116 segments per full circle whatever the radius. It is
|
|
4
|
+
// a VISUAL choice — the density every part is previewed, captured and thumbnailed
|
|
5
|
+
// at — and it already holds a 0.05 mm chord sagitta out to a 136 mm radius, so
|
|
6
|
+
// nothing is gained by scaling it.
|
|
7
|
+
//
|
|
8
|
+
// `print` used to be a flat 480, sized (whether anyone meant it or not) for a circle
|
|
9
|
+
// nearly a metre across: 480 segments meet a 0.01 mm sagitta at r ≈ 467 mm. Spent on
|
|
10
|
+
// a 0.75 mm rivet sphere that is 115,200 triangles for a chord error of 1.6e-5 mm —
|
|
11
|
+
// a thousandth of any printer's resolution — and a part carrying 176 such rivets
|
|
12
|
+
// (20 M triangles of rivets before a single boolean, against a preview whose whole
|
|
13
|
+
// unioned body was 530k) trapped the WASM kernel with "memory access out of bounds"
|
|
14
|
+
// on its first STL export, on a fresh 4 GB instance. So the print tier is now
|
|
15
|
+
// tolerance-based: the fewest segments that keep the chord sagitta r·(1 − cos(π/n))
|
|
16
|
+
// under SAGITTA_TOL.print — the same 0.01 mm the OCCT backend's print tessellation
|
|
17
|
+
// uses, and the same formula `roundAllSegs` (mesh-roundall.js) and `blendSegs`
|
|
18
|
+
// (mesh-fillet.js) already apply to their own circles. Those two keep their own
|
|
19
|
+
// tolerances and clamps on purpose (roundAll has a preview tolerance and a 12..64
|
|
20
|
+
// window; the fillet blends at 1 µm) — this table is not theirs to share, and
|
|
21
|
+
// `roundAllSegs(r, "preview")` against it would collapse to a flat 12. Two clamps
|
|
22
|
+
// make the rule here safe:
|
|
23
|
+
//
|
|
24
|
+
// - FLOOR at the preview count. An export must never be coarser than the preview the
|
|
25
|
+
// user approved on screen, so below the radius where 116 segments already meet the
|
|
26
|
+
// tolerance (r ≈ 27 mm) print and preview facet identically — which is also what
|
|
27
|
+
// makes "if it previews, it exports" true for small features: the export costs what
|
|
28
|
+
// the preview already paid.
|
|
29
|
+
// - CAP at the old flat count. A circle large enough to need more than 480 keeps
|
|
30
|
+
// exactly the density it always had; nothing gets FINER than before.
|
|
31
|
+
//
|
|
32
|
+
// One rule, one place: every Manifold-backend site that facets a circle of known
|
|
33
|
+
// radius — sphere, cylinder, boredCylinder, roundedBox, revolve, and the arc and
|
|
34
|
+
// Bézier samplers behind prism/extrude/Shape2D — sizes through `circleSegs`. The
|
|
35
|
+
// helix tube's station/ring counts (TUBE in manifold-backend.js) and mesh-fillet's
|
|
36
|
+
// blend bands keep their own sizing; loft rings keep LOFT_SEGS.
|
|
37
|
+
//
|
|
38
|
+
// Pure, dependency-free: profile.js's samplers take a `(r) => n` function in place of
|
|
39
|
+
// a count, and this is what the backend hands them.
|
|
40
|
+
|
|
41
|
+
export const SEGS = { preview: 116, print: 480 }; // full-circle segments (flat / cap)
|
|
42
|
+
export const SAGITTA_TOL = { print: 0.01 }; // mm — max chord sagitta per tier
|
|
43
|
+
|
|
44
|
+
// Segments per full circle for a circle of radius `r` at `quality`. A tier with no
|
|
45
|
+
// tolerance (preview, or an unknown tier) is flat. A degenerate radius (0, negative,
|
|
46
|
+
// NaN, undefined) takes the floor: it facets like the preview and never throws.
|
|
47
|
+
export function circleSegs(r, quality) {
|
|
48
|
+
const cap = SEGS[quality] ?? SEGS.preview;
|
|
49
|
+
const tol = SAGITTA_TOL[quality];
|
|
50
|
+
if (tol === undefined) return cap;
|
|
51
|
+
const floor = SEGS.preview;
|
|
52
|
+
if (!(r > tol)) return floor;
|
|
53
|
+
// acos(1 − tol/r) is the half-angle of a chord with sagitta tol; π over it is the
|
|
54
|
+
// full-circle count. r = Infinity gives acos(1) = 0 → Infinity → the cap.
|
|
55
|
+
return Math.min(cap, Math.max(floor, Math.ceil(Math.PI / Math.acos(1 - tol / r))));
|
|
56
|
+
}
|
|
@@ -19,6 +19,7 @@ import { creasedNormals } from "./creased-normals.js";
|
|
|
19
19
|
import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
|
|
20
20
|
import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
|
|
21
21
|
import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
|
|
22
|
+
import { SEGS, circleSegs } from "./circle-segs.js";
|
|
22
23
|
import { checkBooleanResult } from "./boolean-gate.js";
|
|
23
24
|
import { KernelCapabilityError } from "./errors.js";
|
|
24
25
|
import { heightfieldMesh, hashGridData } from "./heightfield.js";
|
|
@@ -26,7 +27,10 @@ import { heightfieldMesh, hashGridData } from "./heightfield.js";
|
|
|
26
27
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
27
28
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
28
29
|
// by the export path — Manifold meshing is cheap, so we tessellate generously).
|
|
29
|
-
|
|
30
|
+
// Full-circle segment counts live in circle-segs.js: preview is a flat SEGS.preview,
|
|
31
|
+
// print sizes each circle by chord tolerance through circleSegs (floored at the
|
|
32
|
+
// preview count, capped at SEGS.print). `segs` below is the tier's cap — the hash
|
|
33
|
+
// key and the count handed to consumers that size themselves (mesh-fillet, loft).
|
|
30
34
|
const TUBE = { preview: { stationsPerTurn: 38, ringSegs: 24 }, print: { stationsPerTurn: 160, ringSegs: 40 } };
|
|
31
35
|
|
|
32
36
|
// true axis-angle rotation as a column-major 4x4 (manifold Mat4), translation 0
|
|
@@ -44,6 +48,9 @@ function axisAngleMat4(axis, deg) {
|
|
|
44
48
|
export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
45
49
|
const { Manifold, CrossSection } = wasm;
|
|
46
50
|
const segs = SEGS[quality], tube = TUBE[quality];
|
|
51
|
+
// Per-radius count for every site that facets a circle it knows the radius of; the
|
|
52
|
+
// samplers behind prism/extrude/Shape2D take it as a function (profile.js).
|
|
53
|
+
const segsAt = (r) => circleSegs(r, quality);
|
|
47
54
|
|
|
48
55
|
// Manifold/CrossSection are WASM objects with no garbage collection — every
|
|
49
56
|
// primitive and boolean op allocates a new one. Track them all and free them
|
|
@@ -166,7 +173,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
166
173
|
// on it in pure JS; no CrossSection is built until a shape is handed to a kernel op.
|
|
167
174
|
// `extrude`/`revolve` are thunks because `kernel` below is defined after this.
|
|
168
175
|
const shape2d = makeShape2dFactory({
|
|
169
|
-
segs,
|
|
176
|
+
segs: segsAt, // the readbacks (toRegions/simple) tessellate at the same per-radius LOD as the kernel ops
|
|
170
177
|
extrude: (o) => kernel.extrude(o),
|
|
171
178
|
revolve: (o) => kernel.revolve(o),
|
|
172
179
|
recordWarning,
|
|
@@ -177,7 +184,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
177
184
|
// once, and the cache's pin/dispose keeps the WASM object alive exactly as long as
|
|
178
185
|
// the entry (cleanup() skips pinned objects).
|
|
179
186
|
const csFor = (shape) => cache.lookup(h("cs2d", shape._hash, segs), () => {
|
|
180
|
-
const cs = T(CrossSection.ofPolygons(regionPolys(shape._regions,
|
|
187
|
+
const cs = T(CrossSection.ofPolygons(regionPolys(shape._regions, segsAt), "EvenOdd"));
|
|
181
188
|
return { value: cs, pin: cs, dispose: () => cs.delete?.() };
|
|
182
189
|
});
|
|
183
190
|
|
|
@@ -401,13 +408,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
401
408
|
if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
|
|
402
409
|
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
403
410
|
return cached(h("fillet", hash, r, selector ?? null, segs), () =>
|
|
404
|
-
meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
|
|
411
|
+
meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs, segsAt })));
|
|
405
412
|
},
|
|
406
413
|
_chamferRaw: (d, selector) => {
|
|
407
414
|
if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
|
|
408
415
|
if (d === 0) return wrap(m, hash);
|
|
409
416
|
return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
|
|
410
|
-
meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
417
|
+
meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs, segsAt })));
|
|
411
418
|
},
|
|
412
419
|
|
|
413
420
|
// The AUTHOR-FACING ops degrade on failure instead of failing the build (the
|
|
@@ -628,14 +635,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
628
635
|
};
|
|
629
636
|
|
|
630
637
|
const kernel = finishKernel({
|
|
631
|
-
cylinder: (rb, rt, h2, { center = false } = {}) =>
|
|
632
|
-
|
|
638
|
+
cylinder: (rb, rt, h2, { center = false } = {}) => {
|
|
639
|
+
const n = segsAt(Math.max(rb, rt)); // a cone is sized by its wider end
|
|
640
|
+
return wrap(T(Manifold.cylinder(h2, rb, rt, n, center)), h("cylinder", rb, rt, h2, center, n));
|
|
641
|
+
},
|
|
633
642
|
// Compound op: hashed ATOMICALLY from its own args, so it is a single cache
|
|
634
643
|
// node — its internal cylinders/cut are never retained. The template for
|
|
635
644
|
// future compounds: build internals with T(), return the final tracked solid.
|
|
636
|
-
boredCylinder: ({ od, h: height, bore }) => cached(h("boredCylinder", od, height, bore,
|
|
637
|
-
const body = T(Manifold.cylinder(height, od / 2, od / 2,
|
|
638
|
-
const tool0 = T(Manifold.cylinder(height + 4, bore / 2, bore / 2,
|
|
645
|
+
boredCylinder: ({ od, h: height, bore }) => cached(h("boredCylinder", od, height, bore, segsAt(od / 2), segsAt(bore / 2)), () => {
|
|
646
|
+
const body = T(Manifold.cylinder(height, od / 2, od / 2, segsAt(od / 2), false));
|
|
647
|
+
const tool0 = T(Manifold.cylinder(height + 4, bore / 2, bore / 2, segsAt(bore / 2), false));
|
|
639
648
|
const tool = T(tool0.translate([0, 0, -2])); // raw ops: track each result
|
|
640
649
|
return T(body.subtract(tool));
|
|
641
650
|
}),
|
|
@@ -644,19 +653,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
644
653
|
// spec). Reuses loftMesh's stitch/cap/winding machinery. Atomic cache
|
|
645
654
|
// node hashed from its own args, like boredCylinder.
|
|
646
655
|
roundedBox: ({ size, center, round }) => cached(
|
|
647
|
-
h("roundedBox", size, center, round.side, round.top, round.bottom,
|
|
656
|
+
h("roundedBox", size, center, round.side, round.top, round.bottom, segsAt(Math.max(round.side, round.top, round.bottom))),
|
|
648
657
|
() => {
|
|
649
|
-
|
|
658
|
+
// One count serves every corner and rim arc, so it is sized for the largest.
|
|
659
|
+
const solid = T(loftMesh(wasm, roundedBoxRings(size, round, segsAt(Math.max(round.side, round.top, round.bottom)))));
|
|
650
660
|
return center ? T(solid.translate([0, 0, -size[2] / 2])) : solid;
|
|
651
661
|
}),
|
|
652
|
-
sphere: (r) => wrap(T(Manifold.sphere(r,
|
|
662
|
+
sphere: (r) => wrap(T(Manifold.sphere(r, segsAt(r))), h("sphere", r, segsAt(r))),
|
|
653
663
|
box: (min, max) => {
|
|
654
664
|
const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
|
|
655
665
|
return wrap(T(cube.translate(min)), h("box", min, max));
|
|
656
666
|
},
|
|
657
667
|
prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
|
|
658
668
|
cached(h("prism", pts, height, twist, scaleTop, segs), () => {
|
|
659
|
-
const cs = T(CrossSection.ofPolygons([tessellateContour(pts,
|
|
669
|
+
const cs = T(CrossSection.ofPolygons([tessellateContour(pts, segsAt)]));
|
|
660
670
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
661
671
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
662
672
|
// Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
|
|
@@ -711,7 +721,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
711
721
|
const shape = profile && profile._shape2d ? profile : null;
|
|
712
722
|
return cached(h("extrude", shape ? shape._hash : profile, height, twist, scaleTop, segs), () => {
|
|
713
723
|
const cs = shape ? csFor(shape) : (() => {
|
|
714
|
-
const { outer, holes } = tessellateProfile(profile,
|
|
724
|
+
const { outer, holes } = tessellateProfile(profile, segsAt);
|
|
715
725
|
return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
|
|
716
726
|
})();
|
|
717
727
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
@@ -757,10 +767,26 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
757
767
|
// opts.segs may only COARSEN below the kernel's quality (min), never exceed it:
|
|
758
768
|
// callers use it where a small feature's sagitta bound needs fewer facets than
|
|
759
769
|
// the per-circle quality would spend (mesh-fillet's free-standing corner arcs).
|
|
770
|
+
// Density around the axis: by default sized for the profile's outermost radius —
|
|
771
|
+
// the largest circle the revolve sweeps — through the tier's per-radius rule. An
|
|
772
|
+
// explicit `segs` is the caller's own sizing (mesh-fillet's blend tools compute
|
|
773
|
+
// theirs from a 1 µm sagitta bound and rely on getting exactly that count — the
|
|
774
|
+
// dephase and horn-containment arithmetic assume it), bounded only by the tier's
|
|
775
|
+
// cap: it may still never EXCEED kernel quality, but it is not re-bounded by the
|
|
776
|
+
// part-scale rule meant for circles nobody sized by hand.
|
|
760
777
|
revolve: (pts, { degrees = 360, segs: segsOverride } = {}) => {
|
|
761
|
-
const
|
|
762
|
-
if (pts && pts._shape2d)
|
|
763
|
-
|
|
778
|
+
const densityFor = (maxR) => segsOverride != null ? Math.min(segs, segsOverride) : segsAt(maxR);
|
|
779
|
+
if (pts && pts._shape2d) {
|
|
780
|
+
// Keyed on the override, not the resolved density, so a cache hit never
|
|
781
|
+
// materializes the CrossSection just to measure its bounds.
|
|
782
|
+
return cached(h("revolve", pts._hash, degrees, segsOverride ?? null), () => {
|
|
783
|
+
const cs = csFor(pts);
|
|
784
|
+
const b = cs.bounds();
|
|
785
|
+
return T(cs.revolve(densityFor(Math.max(Math.abs(b.min[0]), Math.abs(b.max[0]))), degrees));
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
const maxR = Array.isArray(pts) ? pts.reduce((m, p) => Math.max(m, Math.abs(p?.[0] ?? 0)), 0) : 0;
|
|
789
|
+
const density = densityFor(maxR);
|
|
764
790
|
return cached(h("revolve", pts, degrees, density), () => T(Manifold.revolve([pts], density, degrees)));
|
|
765
791
|
},
|
|
766
792
|
// A one-solid union is an identity — no new WASM / cache entry (avoids double-free):
|