partforge 0.121.0 → 0.122.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 +164 -3
- package/package.json +3 -1
- package/src/app-materials.js +23 -0
- package/src/framework/app.css +16 -1
- package/src/framework/cutaway.js +12 -0
- package/src/framework/geometry/arc-math.js +27 -0
- package/src/framework/geometry/contour-corners.js +130 -0
- package/src/framework/geometry/contour-ops.js +11 -125
- package/src/framework/geometry/paper-bridge.js +4 -21
- package/src/framework/geometry/threemf.js +32 -3
- package/src/framework/jobs.js +2 -1
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-materials.js +56 -0
- package/src/framework/materials/assets/SOURCES.md +90 -0
- package/src/framework/materials/assets/env-outdoor.jpg +0 -0
- package/src/framework/materials/assets/env-print-bed.jpg +0 -0
- package/src/framework/materials/assets/env-studio.jpg +0 -0
- package/src/framework/materials/assets/env-workshop.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete.jpg +0 -0
- package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
- package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-paper.jpg +0 -0
- package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
- package/src/framework/materials/assets.js +39 -0
- package/src/framework/materials/contact-shadow.js +155 -0
- package/src/framework/materials/environment.js +157 -0
- package/src/framework/materials/environments.js +82 -0
- package/src/framework/materials/patterns.js +195 -0
- package/src/framework/materials/physical.js +82 -0
- package/src/framework/materials/presets.js +89 -0
- package/src/framework/materials/print-bed.js +169 -0
- package/src/framework/materials/print-frame.js +18 -0
- package/src/framework/materials/resolve.js +117 -0
- package/src/framework/materials/tonemap-readback.js +36 -0
- package/src/framework/materials/uv.js +23 -0
- package/src/framework/mount.js +121 -4
- package/src/framework/oracle/shape-probe.js +2 -2
- package/src/framework/realistic-controls.js +78 -0
- package/src/framework/selection/feature-highlight.js +3 -0
- package/src/framework/view-state.js +27 -1
- package/src/framework/viewer.js +623 -34
- package/src/materials-worker.js +3 -0
- package/src/parts/bracket.js +2 -1
- package/src/parts/hinged-box.js +3 -1
- package/src/parts/material-swatches.js +40 -0
- package/src/parts/planter.js +2 -1
- package/src/parts/propeller.js +2 -1
- package/src/parts/scott-label.js +3 -2
- package/src/parts/screw.js +2 -1
- package/types/index.d.ts +48 -3
- package/types/part.d.ts +15 -2
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -69,7 +69,7 @@ A part is a default-exported object. Full shape (optional fields marked `?`):
|
|
|
69
69
|
|
|
70
70
|
```js
|
|
71
71
|
export default {
|
|
72
|
-
meta: { title, units, background? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
|
|
72
|
+
meta: { title, units, background?, environment? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
|
|
73
73
|
parameters, // the control-panel schema (array of sections — see below)
|
|
74
74
|
defaults, // flat { paramKey: value } — seeds params + control values
|
|
75
75
|
fonts?, // { name: source } — or (p) => ({ name: source }) when a control drives the typeface
|
|
@@ -83,7 +83,7 @@ export default {
|
|
|
83
83
|
place?: (solid, { view, purpose, p, d }) => Solid, // optional reposition; default identity
|
|
84
84
|
views, // string[] — which views show this sub-part
|
|
85
85
|
enabled?: (p) => boolean, // optional — gate a conditional sub-part
|
|
86
|
-
display?: { color?, opacity
|
|
86
|
+
display?: { color?, opacity?, material?, …overrides }, // viewer-only appearance — see "Materials and appearance"
|
|
87
87
|
export?: { name }, // filename/object name on export; defaults to the key
|
|
88
88
|
reference?: string, // name of a declared import — measure() computes a deviation fact against it (see below)
|
|
89
89
|
},
|
|
@@ -294,6 +294,97 @@ and asks for the existing positional view argument
|
|
|
294
294
|
|
|
295
295
|
---
|
|
296
296
|
|
|
297
|
+
## Materials and appearance
|
|
298
|
+
|
|
299
|
+
A sub-part's `display` block says how it LOOKS. Appearance never changes
|
|
300
|
+
geometry, exports (other than 3MF colour, below), `measure` or `verify`, and a
|
|
301
|
+
mistake in it never fails a build — lint warns and the viewer falls back.
|
|
302
|
+
|
|
303
|
+
```js
|
|
304
|
+
parts: {
|
|
305
|
+
body: { build: …, display: { material: "anodized-aluminum", color: 0xb3261e } },
|
|
306
|
+
knob: { build: …, display: { material: "abs-plastic", color: 0x111111, roughness: 0.25 } },
|
|
307
|
+
gasket: { build: …, display: { material: "rubber" } },
|
|
308
|
+
},
|
|
309
|
+
meta: { title: "…", units: "mm", environment: "studio" },
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
- `material` — a preset id from the table below. Without one the sub-part keeps
|
|
313
|
+
the default look.
|
|
314
|
+
- `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
|
|
315
|
+
marked tintable are normally coloured this way (anodizing, plastic, paint);
|
|
316
|
+
the others have an intrinsic colour (brass) but still accept one.
|
|
317
|
+
- Overrides — each clamped to its range: `roughness` (0–1), `metalness` (0–1),
|
|
318
|
+
`clearcoat` (0–1), `clearcoatRoughness` (0–1), `anisotropy` (0–1), `textureScale` (0.01–1000).
|
|
319
|
+
Transmission, index of refraction, sheen and iridescence come only from a preset.
|
|
320
|
+
- `opacity` — unchanged: a ghost stays a ghost and casts no shadow.
|
|
321
|
+
- **One material per sub-part.** Something that needs two finishes (a knurled
|
|
322
|
+
grip in rubber on an aluminium body) is two sub-parts.
|
|
323
|
+
|
|
324
|
+
**Where it shows.** The CAD view (with feature lines) shows each material's
|
|
325
|
+
colour, flattened so dark materials stay readable. **Realistic** mode — the
|
|
326
|
+
viewer's toggle — shows the full material under environment lighting, with a
|
|
327
|
+
ground and soft shadow and no feature lines. The part never moves when the
|
|
328
|
+
mode changes.
|
|
329
|
+
|
|
330
|
+
**3D-print layer lines** (`pla-print`, `petg-print`) run perpendicular to the
|
|
331
|
+
**export** pose's +Z — the way the part will be printed, not the way it is
|
|
332
|
+
displayed. If the lines run the wrong way, fix the export pose with `place()`
|
|
333
|
+
(`purpose: "export"`), not the material. Wood, carbon fibre and SLS grain are
|
|
334
|
+
fixed to the sub-part, so they never slide when the camera or an animation
|
|
335
|
+
moves.
|
|
336
|
+
|
|
337
|
+
**`textureScale` is millimetres, and what it measures depends on the preset's
|
|
338
|
+
pattern** — so a value copied from one preset family is wrong on another (0.2
|
|
339
|
+
on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
|
|
340
|
+
|
|
341
|
+
| Pattern | Presets | `textureScale` means | Default |
|
|
342
|
+
| --- | --- | --- | --- |
|
|
343
|
+
| layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
|
|
344
|
+
| SLS grain | `nylon-sls` | grain size | 0.15 |
|
|
345
|
+
| wood | `oak`, `walnut` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`) |
|
|
346
|
+
| carbon weave | `carbon-fiber` | size of one texture tile | 48 |
|
|
347
|
+
|
|
348
|
+
Presets without a pattern ignore it.
|
|
349
|
+
|
|
350
|
+
**3MF export** carries each sub-part's colour (its `color`, else its preset's
|
|
351
|
+
colour), so a multi-colour print opens in a slicer already split and coloured.
|
|
352
|
+
A part with no `color` or `material` exports exactly as before.
|
|
353
|
+
|
|
354
|
+
| Preset | Name | Tintable | Use |
|
|
355
|
+
| --- | --- | --- | --- |
|
|
356
|
+
| `machined-aluminum` | Machined aluminium | — | Bare CNC-milled aluminium, fine tool marks, satin sheen. |
|
|
357
|
+
| `brushed-aluminum` | Brushed aluminium | — | Directionally brushed aluminium panels and enclosures. |
|
|
358
|
+
| `anodized-aluminum` | Anodized aluminium | yes | Dyed anodized aluminium; tint with `color` (e.g. red, black, blue). |
|
|
359
|
+
| `bead-blasted-aluminum` | Bead-blasted aluminium | — | Matte, even-textured aluminium (laptop-shell finish). |
|
|
360
|
+
| `brushed-stainless` | Brushed stainless steel | — | Brushed 304 stainless: kitchen, marine and fastener hardware. |
|
|
361
|
+
| `polished-chrome` | Polished chrome | — | Mirror chrome plating; shows the environment strongly. |
|
|
362
|
+
| `black-oxide-steel` | Black-oxide steel | — | Blackened steel tooling and fasteners with a slight oily sheen. |
|
|
363
|
+
| `cast-iron` | Cast iron | — | Raw sand-cast iron: dark, rough and matte. |
|
|
364
|
+
| `titanium` | Titanium | — | Bare titanium: slightly warm grey, satin. |
|
|
365
|
+
| `brass` | Brass | — | Yellow brass fittings and decorative hardware. |
|
|
366
|
+
| `copper` | Copper | — | Bare copper: busbars, heat sinks, decorative parts. |
|
|
367
|
+
| `bronze` | Bronze | — | Cast bronze bushings and sculpture. |
|
|
368
|
+
| `powder-coat` | Powder coat | yes | Durable textured paint over metal; tint with `color`. |
|
|
369
|
+
| `painted-metal` | Painted metal | yes | Glossy enamel or automotive-style paint; tint with `color`. |
|
|
370
|
+
| `pla-print` | PLA print | yes | FDM-printed PLA with visible layer lines; tint with `color`. |
|
|
371
|
+
| `petg-print` | PETG print | yes | FDM-printed PETG: glossier than PLA, visible layers; tint with `color`. |
|
|
372
|
+
| `resin-print` | Resin print | yes | SLA/MSLA resin print: smooth, faintly waxy; tint with `color`. |
|
|
373
|
+
| `nylon-sls` | Nylon SLS | yes | Powder-bed nylon: matte, grainy, usually white or dyed black. |
|
|
374
|
+
| `abs-plastic` | ABS plastic | yes | Injection-moulded ABS housings and knobs; tint with `color`. |
|
|
375
|
+
| `clear-acrylic` | Clear acrylic | yes | Transparent PMMA/polycarbonate windows and covers; tint for coloured acrylic. |
|
|
376
|
+
| `rubber` | Rubber | yes | Matte elastomer: gaskets, feet, grips; tint with `color`. |
|
|
377
|
+
| `oak` | Oak | — | Light oak with open grain. |
|
|
378
|
+
| `walnut` | Walnut | — | Dark oiled walnut. |
|
|
379
|
+
| `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
|
|
380
|
+
|
|
381
|
+
**Environments** (`meta.environment`, default `studio`; viewers can switch):
|
|
382
|
+
`studio` (neutral soft boxes, paper sweep), `workshop` (warm interior, unfinished
|
|
383
|
+
maple table), `print-bed` (a dim, hard-lit enclosure over a standard-size PEI build plate (180, 220, 256 or 350 mm) marked with its size), `outdoor` (overcast sky,
|
|
384
|
+
concrete).
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
297
388
|
## Geometry: the kernel / `Solid` API
|
|
298
389
|
|
|
299
390
|
`build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
|
|
@@ -2498,7 +2589,7 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
2498
2589
|
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
|
|
2499
2590
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
2500
2591
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
2501
|
-
| `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
2592
|
+
| `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#realistic` / `#environment` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
2502
2593
|
| `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
|
|
2503
2594
|
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme`. A sibling of `#viewbar`, not a child of it: give it `class="pf-float-rail-toggle"` and it floats at the stage's top right |
|
|
2504
2595
|
|
|
@@ -2506,6 +2597,17 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
|
|
|
2506
2597
|
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
2507
2598
|
`"occt"` for STEP — handled for you).
|
|
2508
2599
|
|
|
2600
|
+
**`#realistic` (a `<button>`) / `#environment` (an empty `<select>`) are the
|
|
2601
|
+
realistic-mode viewbar controls**, resolved by id or as `elements.chrome.realistic`
|
|
2602
|
+
/ `elements.chrome.environment`. Both optional and independent: supply `#realistic`
|
|
2603
|
+
alone for a bare toggle, or add `#environment` too and `mount` fills it with the
|
|
2604
|
+
part's available environments (see "Materials and appearance" above) and keeps it
|
|
2605
|
+
in sync — hidden while the view is in CAD mode, showing the environment in effect
|
|
2606
|
+
once realistic lands. Omit either and drive `runtime.renderMode` /
|
|
2607
|
+
`runtime.environment` from your own UI instead (see below). Both preferences
|
|
2608
|
+
persist across reloads the same way the theme does, and are carried in
|
|
2609
|
+
`viewerState` (below), which outranks what is stored.
|
|
2610
|
+
|
|
2509
2611
|
**`#reframe` is supported but no longer shipped.** The framework's own pages dropped
|
|
2510
2612
|
the button on 2026-08-20: clicking a face, edge or corner on the view cube reframes
|
|
2511
2613
|
too, so a separate control was one more thing in a crowded bottom-right corner. The
|
|
@@ -2660,6 +2762,58 @@ them, and the sketch that gets sent is misaligned, not merely mis-labelled.
|
|
|
2660
2762
|
Deliberately unguarded, the same way it's always been free to call
|
|
2661
2763
|
`setCameraState` during Sketch.
|
|
2662
2764
|
|
|
2765
|
+
### `runtime.renderMode`, `runtime.environment`, `runtime.renderViews`, `runtime.declaresMaterials`
|
|
2766
|
+
|
|
2767
|
+
For an embedder driving realistic mode from its own UI instead of (or in
|
|
2768
|
+
addition to) the `#realistic` / `#environment` viewbar controls above:
|
|
2769
|
+
|
|
2770
|
+
- `runtime.renderMode` — `{ get(), set(mode), onChange(cb) }` where `mode` is
|
|
2771
|
+
`"cad"` or `"realistic"`. `set()` resolves to the mode actually in effect —
|
|
2772
|
+
`"cad"` if the realistic environment's assets fail to load — and
|
|
2773
|
+
`onChange` receives `{ mode, busy, error }` so a host can show its own
|
|
2774
|
+
loading/error state. Same shape as `runtime.projection`. It drives the live
|
|
2775
|
+
view, and `runtime.captureCurrent()` follows it by default (a "capture from
|
|
2776
|
+
viewer" captures what the user sees). It never changes what an agent sees:
|
|
2777
|
+
`runtime.captureViews()` is always CAD, in either live mode, and a
|
|
2778
|
+
realistic agent render is only ever an explicit
|
|
2779
|
+
`renderViews(…, { renderMode: "realistic" })`.
|
|
2780
|
+
- `runtime.captureCurrent({ renderMode })` — pins the showcase capture's look
|
|
2781
|
+
instead of following the live view. `"cad"` always works. `"realistic"` from
|
|
2782
|
+
a CAD view works only once the current environment's assets have loaded
|
|
2783
|
+
(the call is synchronous and can't wait for them); before that it falls
|
|
2784
|
+
back to the live look. Use `renderViews` when realistic must be guaranteed.
|
|
2785
|
+
- `runtime.environment` — `{ get(), set(id), onChange(cb), list() }` for the
|
|
2786
|
+
realistic environment (`"studio"` | `"workshop"` | `"print-bed"` |
|
|
2787
|
+
`"outdoor"`, per "Materials and appearance" above). `list()` returns every
|
|
2788
|
+
environment as `[{ id, label }]`, for building your own picker. `set()`
|
|
2789
|
+
resolves to the id actually in effect; a failed switch while realistic is
|
|
2790
|
+
showing reverts to the environment still on screen, and `onChange` hears the
|
|
2791
|
+
revert too.
|
|
2792
|
+
- `runtime.declaresMaterials` — `true` when any sub-part names a
|
|
2793
|
+
`display.material`. A part with none still supports realistic mode (every
|
|
2794
|
+
sub-part just renders under the library's `default` look), so use this to
|
|
2795
|
+
decide whether to surface the realistic toggle at all, not whether it works.
|
|
2796
|
+
- `await runtime.renderViews(viewNames, { renderMode? })` — the appearance-aware
|
|
2797
|
+
sibling of `runtime.captureViews` (canonical angles, framed to the visible
|
|
2798
|
+
assembly, grid hidden): `{ renderMode: "cad" }` (the default) is exactly
|
|
2799
|
+
`captureViews` — CAD whatever the live view shows — and `{ renderMode: "realistic" }` borrows the realistic look
|
|
2800
|
+
for the capture — waiting on the chosen environment's assets — **without**
|
|
2801
|
+
switching the live view. Rejects if the realistic assets fail to load.
|
|
2802
|
+
|
|
2803
|
+
Both preferences round-trip through `mount()`'s `viewerState`: a
|
|
2804
|
+
previous mount's `runtime.getViewerState()` carries `viewerState.renderMode`
|
|
2805
|
+
(`"cad"` or `"realistic"`) and, only when the viewer's environment was
|
|
2806
|
+
actually CHOSEN rather than merely defaulted from `meta.environment`,
|
|
2807
|
+
`viewerState.environment`. `renderMode` reports the mode the user is **headed
|
|
2808
|
+
for**, not only the one on screen: a realistic restore or switch that's still
|
|
2809
|
+
loading reports `"realistic"`, so a host that remounts on every edit (as an
|
|
2810
|
+
embedder applying edits by remounting typically does) doesn't drop the
|
|
2811
|
+
in-flight choice — a load that ultimately fails still settles back to
|
|
2812
|
+
`"cad"`. Pass `viewerState` back into the next `mount()` call to resume both
|
|
2813
|
+
where the previous mount left them; omit it on a first mount and the viewer
|
|
2814
|
+
restores its own persisted choice instead, the same way it does for the
|
|
2815
|
+
camera and projection.
|
|
2816
|
+
|
|
2663
2817
|
### The annotation payload's camera block
|
|
2664
2818
|
|
|
2665
2819
|
`onAnnotationSend(payload)` receives a `camera` block in two frames — `world`
|
|
@@ -3121,6 +3275,13 @@ motion — translate/rotate — never a reshape) (both errors). An untrusted pro
|
|
|
3121
3275
|
nothing either way and stays silent, matching `animation-track-rebuilds`'s own
|
|
3122
3276
|
trust handling.
|
|
3123
3277
|
|
|
3278
|
+
**Appearance** (all warnings) — `unknown-material` (a `display.material` the
|
|
3279
|
+
library does not know; the viewer shows the default look), `unknown-environment`
|
|
3280
|
+
(`meta.environment` not known; realistic mode uses `studio`),
|
|
3281
|
+
`material-key-unknown` (a `display` key that is not colour, opacity, material
|
|
3282
|
+
or one of the six overrides; ignored), `material-override-clamped` (an override
|
|
3283
|
+
outside its range, or not a number; clamped).
|
|
3284
|
+
|
|
3124
3285
|
**Geometry imports** — `import-unknown-name` (a build calls `k.import` with a
|
|
3125
3286
|
name the part's `imports` field doesn't declare — this throws at build time;
|
|
3126
3287
|
lint reaches it in microseconds instead), `import-mesh-on-occt` (a declared
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.122.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",
|
|
@@ -132,11 +132,13 @@
|
|
|
132
132
|
"@eslint/js": "^10.0.1",
|
|
133
133
|
"@fontsource-variable/geist": "^5.3.0",
|
|
134
134
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
135
|
+
"@monogrid/gainmap-js": "^3.4.0",
|
|
135
136
|
"eslint": "^10.10.0",
|
|
136
137
|
"globals": "^17.12.0",
|
|
137
138
|
"happy-dom": "^20.10.6",
|
|
138
139
|
"knip": "^6.35.1",
|
|
139
140
|
"playwright": "^1.49.0",
|
|
141
|
+
"sharp": "^0.35.4",
|
|
140
142
|
"typescript": "^5.9.3",
|
|
141
143
|
"vite": "^8.0.16",
|
|
142
144
|
"vitest": "^4.1.9"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
6
|
+
import materialSwatchesPart from "./parts/material-swatches.js";
|
|
7
|
+
import { mount } from "./framework/index.js";
|
|
8
|
+
|
|
9
|
+
// Dev-only contact sheet app for the material library (parts/material-swatches.js).
|
|
10
|
+
// Identical wiring to app.js — the only thing that differs per part is which
|
|
11
|
+
// definition you import and which worker entry you point at. `npm run dev`, then
|
|
12
|
+
// open /materials.html. Not part of the production build — check materials.html
|
|
13
|
+
// by eye after any preset or shader change (see AGENTS.md "Architecture").
|
|
14
|
+
// Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
|
|
15
|
+
// the embedding contract (runtime.captureCurrent) the way an embedder would.
|
|
16
|
+
window.__pfRuntime = mount(materialSwatchesPart, {
|
|
17
|
+
createWorker: (name) =>
|
|
18
|
+
new Worker(new URL("./materials-worker.js", import.meta.url), { type: "module", name }),
|
|
19
|
+
onAnnotationSend: (payload) => {
|
|
20
|
+
window.__pfLastAnnotation = payload;
|
|
21
|
+
console.log("annotation payload", payload);
|
|
22
|
+
},
|
|
23
|
+
});
|
package/src/framework/app.css
CHANGED
|
@@ -476,7 +476,7 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
476
476
|
/* keyboard focus ring shared across the panel's interactive controls */
|
|
477
477
|
.seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
|
|
478
478
|
button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
|
|
479
|
-
.pf-view-select:focus-visible,
|
|
479
|
+
#viewbar select:focus-visible, .pf-view-select:focus-visible,
|
|
480
480
|
.pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
|
|
481
481
|
outline: none;
|
|
482
482
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
@@ -595,6 +595,21 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
595
595
|
#viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
|
|
596
596
|
#viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
597
597
|
#viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
|
|
598
|
+
/* Realistic view (realistic-controls.js). The toggle is an ordinary viewbar
|
|
599
|
+
button that pulses while its environment loads; the environment picker is a
|
|
600
|
+
native <select> — the accessible choice on a phone — dressed as a viewbar
|
|
601
|
+
button, shown only while realistic. Keyed on `#viewbar select` rather than
|
|
602
|
+
an id so a host's own element in the pill gets the same look. */
|
|
603
|
+
#viewbar button[data-busy] { animation: pf-busy-pulse .8s ease-in-out infinite alternate; }
|
|
604
|
+
@keyframes pf-busy-pulse { from { opacity: 1; } to { opacity: .45; } }
|
|
605
|
+
@media (prefers-reduced-motion: reduce) { #viewbar button[data-busy] { animation: none; opacity: .6; } }
|
|
606
|
+
#viewbar select {
|
|
607
|
+
height: 34px; padding: 0 8px; border: 0; border-radius: var(--pf-radius-control);
|
|
608
|
+
background: transparent; color: var(--pf-muted-2); cursor: pointer;
|
|
609
|
+
font: inherit; font-size: 13px;
|
|
610
|
+
}
|
|
611
|
+
#viewbar select:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
612
|
+
#viewbar select[hidden] { display: none; }
|
|
598
613
|
|
|
599
614
|
/* Sketch-mode toolbar: the #viewbar pill idiom, top-centre, owning the mode
|
|
600
615
|
while the viewbar itself is hidden (mount toggles both).
|
package/src/framework/cutaway.js
CHANGED
|
@@ -493,6 +493,17 @@ export function createCutaway({
|
|
|
493
493
|
return true;
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
+
// Swap one sub-part's SOURCE material (realistic mode). The render set
|
|
497
|
+
// re-derives its clipped clone and cap colour from it and reassigns
|
|
498
|
+
// mesh.material for the current enabled state.
|
|
499
|
+
function refreshSubpartMaterial(name, meshMaterial) {
|
|
500
|
+
if (disposed) return false;
|
|
501
|
+
const entry = renderSets.get(name);
|
|
502
|
+
if (!entry) return false;
|
|
503
|
+
entry.renderSet.refreshSourceMaterial(meshMaterial);
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
|
|
496
507
|
// Per-frame maintenance while the cutaway is on: the gizmo rescales for the
|
|
497
508
|
// camera, and every visible section re-slices its outline if anything it
|
|
498
509
|
// depends on moved. Both are cheap no-ops when nothing changed.
|
|
@@ -581,6 +592,7 @@ export function createCutaway({
|
|
|
581
592
|
setVisible,
|
|
582
593
|
setEnabled,
|
|
583
594
|
resyncSubpart,
|
|
595
|
+
refreshSubpartMaterial,
|
|
584
596
|
reset,
|
|
585
597
|
flip,
|
|
586
598
|
getState,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure arc math, paper-free: circumcircle center + signed sweep for the arc through three
|
|
2
|
+
// points. Split out of paper-bridge.js so callers that only need this number crunching (the
|
|
3
|
+
// oracle's shape-probe.js, contour-ops.js's corner/tangent math) don't pull in paper-core —
|
|
4
|
+
// paper-bridge.js still re-exports arcCenterAndSweep so every existing importer of it is
|
|
5
|
+
// unaffected.
|
|
6
|
+
|
|
7
|
+
// Circumcircle center + signed sweep for the arc through (p0, via, to) — the sweep is the
|
|
8
|
+
// one passing through `via` (sign-free, winding-free), same recovery as profile.js's
|
|
9
|
+
// sampleArc. Returns null for a collinear (degenerate) triple. Shared by arcToCubicSegments
|
|
10
|
+
// (paper-bridge.js) and contour-ops.js's jointTangents (arc tangents are ⊥ radius, oriented by dA's sign).
|
|
11
|
+
export function arcCenterAndSweep(p0, via, to) {
|
|
12
|
+
const [ax, ay] = p0, [bx, by] = via, [cx, cy] = to;
|
|
13
|
+
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
|
14
|
+
if (Math.abs(d) < 1e-12) return null;
|
|
15
|
+
const sa = ax*ax + ay*ay, sb = bx*bx + by*by, sc = cx*cx + cy*cy;
|
|
16
|
+
const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
|
|
17
|
+
const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
|
|
18
|
+
const r = Math.hypot(ax - ux, ay - uy);
|
|
19
|
+
const a0 = Math.atan2(ay - uy, ax - ux);
|
|
20
|
+
const av = Math.atan2(by - uy, bx - ux);
|
|
21
|
+
const a1 = Math.atan2(cy - uy, cx - ux);
|
|
22
|
+
const twoPi = 2 * Math.PI;
|
|
23
|
+
const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
|
|
24
|
+
const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
|
|
25
|
+
const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi;
|
|
26
|
+
return { center: [ux, uy], r, dA };
|
|
27
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Paper-free corner/tangent math for the contour IR, split out of contour-ops.js so the
|
|
2
|
+
// oracle's shape-probe.js (and anything else that only needs a shape's corners/tangents, not
|
|
3
|
+
// its booleans/transforms) doesn't pull in paper-core through contour-ops.js's own
|
|
4
|
+
// paper-bridge.js import. Imports only profile.js, shape2d-regions.js and arc-math.js — never
|
|
5
|
+
// paper-bridge.js or contour-ops.js, which is what keeps this leaf paper-free.
|
|
6
|
+
import { isPathContour, tessellateContour, pointsToContour, closeContourGap } from "./profile.js";
|
|
7
|
+
import { ringArea } from "./shape2d-regions.js";
|
|
8
|
+
import { arcCenterAndSweep } from "./arc-math.js";
|
|
9
|
+
|
|
10
|
+
export const WINDING_SEGS = 64; // tessellation LOD for orientation/containment sampling
|
|
11
|
+
|
|
12
|
+
export const isPointList = (x) => Array.isArray(x) && x.length > 0 && Array.isArray(x[0]);
|
|
13
|
+
// pointsToContour always closes explicitly; a raw {start,segments} contour (hand-authored
|
|
14
|
+
// via pathProfile, or handed back from a Shape2D's own stored regions) might not —
|
|
15
|
+
// closeContourGap is a no-op when it's already closed, so every ring liftProfile hands to
|
|
16
|
+
// contour-ops' corner/transform/query functions is guaranteed explicitly closed.
|
|
17
|
+
export const liftContour = (c) => (isPointList(c) ? pointsToContour(c) : closeContourGap(c));
|
|
18
|
+
|
|
19
|
+
export function liftProfile(input) {
|
|
20
|
+
if (input && input._shape2d) return { kind: "regions", regions: input.toContours() };
|
|
21
|
+
if (isPointList(input)) return { kind: "points", regions: [{ outer: pointsToContour(input), holes: [] }] };
|
|
22
|
+
if (isPathContour(input)) return { kind: "contour", regions: [{ outer: closeContourGap(input), holes: [] }] };
|
|
23
|
+
if (Array.isArray(input) && input.every((r) => r && r.outer))
|
|
24
|
+
return { kind: "regions", regions: input.map((r) => ({ outer: liftContour(r.outer), holes: (r.holes ?? []).map(liftContour) })) };
|
|
25
|
+
if (input && input.outer)
|
|
26
|
+
return { kind: "region", regions: [{ outer: liftContour(input.outer), holes: (input.holes ?? []).map(liftContour) }] };
|
|
27
|
+
throw new Error("contour-ops: input must be [[x,y],…], a {start,segments} contour, {outer,holes}, or a region array");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const contourIsCCW = (c) => ringArea(tessellateContour(c, WINDING_SEGS)) >= 0;
|
|
31
|
+
|
|
32
|
+
// ── Corner model ────────────────────────────────────────────────────────────
|
|
33
|
+
// profileCorners() walks a contour's joints and reports each non-smooth one: the interior
|
|
34
|
+
// angle, whether it's convex (material-relative — see below), and the segment kinds either
|
|
35
|
+
// side of it. jointTangents() is the shared per-vertex tangent computation, reused by Task 6.
|
|
36
|
+
|
|
37
|
+
export const SMOOTH_JOINT_DEG = 1;
|
|
38
|
+
|
|
39
|
+
// Unit tangent of segment `s` (from `from`) at its start (dir=+1) or end (dir=-1 → arrival direction).
|
|
40
|
+
// Exported for contour-winding.js's _chain: junction ordering at a curved pinch point needs
|
|
41
|
+
// the exact endpoint tangent, not an approximation (via/c1/c2 are NOT control points that
|
|
42
|
+
// happen to sit near the tangent — via in particular is a THROUGH point near mid-sweep, so
|
|
43
|
+
// from->via is systematically biased by about sweep/4). This recovers it exactly for arcs
|
|
44
|
+
// (perpendicular to the radius at the recovered center, oriented by the sweep's sign) and
|
|
45
|
+
// cubics (including the degenerate c1===from case, where the true tangent comes from c2).
|
|
46
|
+
export function segTangent(from, s, atStart) {
|
|
47
|
+
const norm = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L, y / L]; };
|
|
48
|
+
if (s.c1) {
|
|
49
|
+
if (atStart) {
|
|
50
|
+
const d = [s.c1[0] - from[0], s.c1[1] - from[1]];
|
|
51
|
+
return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.c2[0] - from[0], s.c2[1] - from[1]]);
|
|
52
|
+
}
|
|
53
|
+
const d = [s.to[0] - s.c2[0], s.to[1] - s.c2[1]];
|
|
54
|
+
return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.to[0] - s.c1[0], s.to[1] - s.c1[1]]);
|
|
55
|
+
}
|
|
56
|
+
if (s.via) {
|
|
57
|
+
// tangent ⊥ radius, oriented along the sweep (recover center like arcToCubicSegments)
|
|
58
|
+
const c = arcCenterAndSweep(from, s.via, s.to); // {center:[x,y], dA} or null
|
|
59
|
+
if (!c) return norm([s.to[0] - from[0], s.to[1] - from[1]]);
|
|
60
|
+
const p = atStart ? from : s.to;
|
|
61
|
+
const r = [p[0] - c.center[0], p[1] - c.center[1]];
|
|
62
|
+
const t = c.dA >= 0 ? [-r[1], r[0]] : [r[1], -r[0]];
|
|
63
|
+
return norm(t);
|
|
64
|
+
}
|
|
65
|
+
return norm([s.to[0] - from[0], s.to[1] - from[1]]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function jointTangents(contour) { // per vertex i: tangent arriving at and leaving vertex i
|
|
69
|
+
const n = contour.segments.length;
|
|
70
|
+
const pts = [contour.start, ...contour.segments.map((s) => s.to)];
|
|
71
|
+
return contour.segments.map((_, i) => {
|
|
72
|
+
const prevSeg = contour.segments[(i - 1 + n) % n];
|
|
73
|
+
const prevFrom = pts[(i - 1 + n) % n];
|
|
74
|
+
return {
|
|
75
|
+
point: pts[i],
|
|
76
|
+
inTan: segTangent(prevFrom, prevSeg, false),
|
|
77
|
+
outTan: segTangent(pts[i], contour.segments[i], true),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const segType = (s) => (s.c1 ? "cubic" : s.via ? "arc" : "line");
|
|
83
|
+
|
|
84
|
+
export function contourCorners(contour) {
|
|
85
|
+
const ccw = contourIsCCW(contour);
|
|
86
|
+
const n = contour.segments.length;
|
|
87
|
+
const out = [];
|
|
88
|
+
jointTangents(contour).forEach(({ point, inTan, outTan }, i) => {
|
|
89
|
+
const cross = inTan[0] * outTan[1] - inTan[1] * outTan[0];
|
|
90
|
+
const dot = Math.min(1, Math.max(-1, inTan[0] * outTan[0] + inTan[1] * outTan[1]));
|
|
91
|
+
const turnDeg = (Math.atan2(Math.abs(cross), dot) * 180) / Math.PI;
|
|
92
|
+
if (turnDeg < SMOOTH_JOINT_DEG) return;
|
|
93
|
+
const leftTurn = cross > 0;
|
|
94
|
+
out.push({
|
|
95
|
+
index: i, point: [point[0], point[1]],
|
|
96
|
+
interiorAngleDeg: ccw === leftTurn ? 180 - turnDeg : 180 + turnDeg,
|
|
97
|
+
convex: leftTurn === ccw,
|
|
98
|
+
segTypes: [segType(contour.segments[(i - 1 + n) % n]), segType(contour.segments[i])],
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Two numbers on every corner, and they are NOT interchangeable. `index` is the joint's
|
|
105
|
+
// vertex number within its own contour (what buildCornerOpRing, simplify and the lofts
|
|
106
|
+
// key on); `position` is the corner's place in THIS returned list — the one
|
|
107
|
+
// `{corners: {indices}}` selects by. They diverge as soon as a contour has a smooth
|
|
108
|
+
// joint (a collinear midpoint, a G1 arc-line join), and on region input `index`
|
|
109
|
+
// restarts per ring while `position` runs on through the flattened order.
|
|
110
|
+
const withPositions = (corners) => corners.map((c, position) => ({ ...c, position }));
|
|
111
|
+
|
|
112
|
+
export function profileCorners(input) {
|
|
113
|
+
const { kind, regions } = liftProfile(input);
|
|
114
|
+
if (kind === "points" || kind === "contour") return withPositions(contourCorners(regions[0].outer));
|
|
115
|
+
const out = [];
|
|
116
|
+
regions.forEach((rg, regionIndex) => {
|
|
117
|
+
for (const c of contourCorners(rg.outer)) out.push({ regionIndex, ring: "outer", ...c });
|
|
118
|
+
rg.holes.forEach((h, hi) => { for (const c of contourCorners(h)) out.push({ regionIndex, ring: { hole: hi }, ...c }); });
|
|
119
|
+
});
|
|
120
|
+
return withPositions(out);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function cubicAt(p0, c1, c2, p1, t) {
|
|
124
|
+
const u = 1 - t;
|
|
125
|
+
return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * c1[k] + 3 * u * t * t * c2[k] + t * t * t * p1[k]);
|
|
126
|
+
}
|
|
127
|
+
export function cubicDeriv(p0, c1, c2, p1, t) {
|
|
128
|
+
const u = 1 - t;
|
|
129
|
+
return [0, 1].map((k) => 3 * u * u * (c1[k] - p0[k]) + 6 * u * t * (c2[k] - c1[k]) + 3 * t * t * (p1[k] - c2[k]));
|
|
130
|
+
}
|
|
@@ -1,32 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { reverseContour, closeContourGap } from "./profile.js";
|
|
2
2
|
import { ringArea, pointInRing } from "./shape2d-regions.js";
|
|
3
3
|
import { arcToCubicSegments, arcCenterAndSweep, paperScope, toPaperPath, toContour, toOpenContour } from "./paper-bridge.js";
|
|
4
|
+
import { liftProfile, contourIsCCW, contourCorners, cubicAt, cubicDeriv } from "./contour-corners.js";
|
|
4
5
|
|
|
5
6
|
// Re-exported so existing importers (test/contour-ops-lift.test.js and others) keep
|
|
6
|
-
// working unchanged — the
|
|
7
|
-
// paper-bridge.js can use
|
|
7
|
+
// working unchanged — the definition lives in profile.js (the contour-IR home) so
|
|
8
|
+
// paper-bridge.js can use it without importing this module (which already imports
|
|
8
9
|
// paper-bridge.js; importing back would cycle).
|
|
9
|
-
export {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
// closeContourGap is a no-op when it's already closed, so every ring liftProfile hands to
|
|
17
|
-
// contour-ops' corner/transform/query functions is guaranteed explicitly closed.
|
|
18
|
-
const liftContour = (c) => (isPointList(c) ? pointsToContour(c) : closeContourGap(c));
|
|
19
|
-
|
|
20
|
-
export function liftProfile(input) {
|
|
21
|
-
if (input && input._shape2d) return { kind: "regions", regions: input.toContours() };
|
|
22
|
-
if (isPointList(input)) return { kind: "points", regions: [{ outer: pointsToContour(input), holes: [] }] };
|
|
23
|
-
if (isPathContour(input)) return { kind: "contour", regions: [{ outer: closeContourGap(input), holes: [] }] };
|
|
24
|
-
if (Array.isArray(input) && input.every((r) => r && r.outer))
|
|
25
|
-
return { kind: "regions", regions: input.map((r) => ({ outer: liftContour(r.outer), holes: (r.holes ?? []).map(liftContour) })) };
|
|
26
|
-
if (input && input.outer)
|
|
27
|
-
return { kind: "region", regions: [{ outer: liftContour(input.outer), holes: (input.holes ?? []).map(liftContour) }] };
|
|
28
|
-
throw new Error("contour-ops: input must be [[x,y],…], a {start,segments} contour, {outer,holes}, or a region array");
|
|
29
|
-
}
|
|
10
|
+
export { reverseContour };
|
|
11
|
+
|
|
12
|
+
// The paper-free corner/tangent/lift math now lives in contour-corners.js — so the oracle's
|
|
13
|
+
// shape-probe.js can use profileCorners()/cubicAt() without pulling in paper-core through
|
|
14
|
+
// this module's own paper-bridge.js import below — re-exported here so every existing
|
|
15
|
+
// importer of these names from contour-ops.js keeps working unchanged.
|
|
16
|
+
export { liftProfile, contourIsCCW, SMOOTH_JOINT_DEG, segTangent, jointTangents, profileCorners, cubicAt } from "./contour-corners.js";
|
|
30
17
|
|
|
31
18
|
export function restoreProfile(kind, regions) {
|
|
32
19
|
if (kind === "regions") return regions;
|
|
@@ -43,8 +30,6 @@ export function restoreProfile(kind, regions) {
|
|
|
43
30
|
return outer; // curves were introduced — upgrade to a contour
|
|
44
31
|
}
|
|
45
32
|
|
|
46
|
-
export const contourIsCCW = (c) => ringArea(tessellateContour(c, WINDING_SEGS)) >= 0;
|
|
47
|
-
|
|
48
33
|
export function ensureRegionWinding(region) {
|
|
49
34
|
return {
|
|
50
35
|
outer: contourIsCCW(region.outer) ? region.outer : reverseContour(region.outer),
|
|
@@ -116,97 +101,6 @@ export function mirrorProfile(input, axis) {
|
|
|
116
101
|
return transformProfile(input, [a, b, b, -a, px - a * px - b * py, py - b * px + a * py]);
|
|
117
102
|
}
|
|
118
103
|
|
|
119
|
-
// ── Corner model ────────────────────────────────────────────────────────────
|
|
120
|
-
// profileCorners() walks a contour's joints and reports each non-smooth one: the interior
|
|
121
|
-
// angle, whether it's convex (material-relative — see below), and the segment kinds either
|
|
122
|
-
// side of it. jointTangents() is the shared per-vertex tangent computation, reused by Task 6.
|
|
123
|
-
|
|
124
|
-
export const SMOOTH_JOINT_DEG = 1;
|
|
125
|
-
|
|
126
|
-
// Unit tangent of segment `s` (from `from`) at its start (dir=+1) or end (dir=-1 → arrival direction).
|
|
127
|
-
// Exported for contour-winding.js's _chain: junction ordering at a curved pinch point needs
|
|
128
|
-
// the exact endpoint tangent, not an approximation (via/c1/c2 are NOT control points that
|
|
129
|
-
// happen to sit near the tangent — via in particular is a THROUGH point near mid-sweep, so
|
|
130
|
-
// from->via is systematically biased by about sweep/4). This recovers it exactly for arcs
|
|
131
|
-
// (perpendicular to the radius at the recovered center, oriented by the sweep's sign) and
|
|
132
|
-
// cubics (including the degenerate c1===from case, where the true tangent comes from c2).
|
|
133
|
-
export function segTangent(from, s, atStart) {
|
|
134
|
-
const norm = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L, y / L]; };
|
|
135
|
-
if (s.c1) {
|
|
136
|
-
if (atStart) {
|
|
137
|
-
const d = [s.c1[0] - from[0], s.c1[1] - from[1]];
|
|
138
|
-
return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.c2[0] - from[0], s.c2[1] - from[1]]);
|
|
139
|
-
}
|
|
140
|
-
const d = [s.to[0] - s.c2[0], s.to[1] - s.c2[1]];
|
|
141
|
-
return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.to[0] - s.c1[0], s.to[1] - s.c1[1]]);
|
|
142
|
-
}
|
|
143
|
-
if (s.via) {
|
|
144
|
-
// tangent ⊥ radius, oriented along the sweep (recover center like arcToCubicSegments)
|
|
145
|
-
const c = arcCenterAndSweep(from, s.via, s.to); // {center:[x,y], dA} or null
|
|
146
|
-
if (!c) return norm([s.to[0] - from[0], s.to[1] - from[1]]);
|
|
147
|
-
const p = atStart ? from : s.to;
|
|
148
|
-
const r = [p[0] - c.center[0], p[1] - c.center[1]];
|
|
149
|
-
const t = c.dA >= 0 ? [-r[1], r[0]] : [r[1], -r[0]];
|
|
150
|
-
return norm(t);
|
|
151
|
-
}
|
|
152
|
-
return norm([s.to[0] - from[0], s.to[1] - from[1]]);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function jointTangents(contour) { // per vertex i: tangent arriving at and leaving vertex i
|
|
156
|
-
const n = contour.segments.length;
|
|
157
|
-
const pts = [contour.start, ...contour.segments.map((s) => s.to)];
|
|
158
|
-
return contour.segments.map((_, i) => {
|
|
159
|
-
const prevSeg = contour.segments[(i - 1 + n) % n];
|
|
160
|
-
const prevFrom = pts[(i - 1 + n) % n];
|
|
161
|
-
return {
|
|
162
|
-
point: pts[i],
|
|
163
|
-
inTan: segTangent(prevFrom, prevSeg, false),
|
|
164
|
-
outTan: segTangent(pts[i], contour.segments[i], true),
|
|
165
|
-
};
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const segType = (s) => (s.c1 ? "cubic" : s.via ? "arc" : "line");
|
|
170
|
-
|
|
171
|
-
function contourCorners(contour) {
|
|
172
|
-
const ccw = contourIsCCW(contour);
|
|
173
|
-
const n = contour.segments.length;
|
|
174
|
-
const out = [];
|
|
175
|
-
jointTangents(contour).forEach(({ point, inTan, outTan }, i) => {
|
|
176
|
-
const cross = inTan[0] * outTan[1] - inTan[1] * outTan[0];
|
|
177
|
-
const dot = Math.min(1, Math.max(-1, inTan[0] * outTan[0] + inTan[1] * outTan[1]));
|
|
178
|
-
const turnDeg = (Math.atan2(Math.abs(cross), dot) * 180) / Math.PI;
|
|
179
|
-
if (turnDeg < SMOOTH_JOINT_DEG) return;
|
|
180
|
-
const leftTurn = cross > 0;
|
|
181
|
-
out.push({
|
|
182
|
-
index: i, point: [point[0], point[1]],
|
|
183
|
-
interiorAngleDeg: ccw === leftTurn ? 180 - turnDeg : 180 + turnDeg,
|
|
184
|
-
convex: leftTurn === ccw,
|
|
185
|
-
segTypes: [segType(contour.segments[(i - 1 + n) % n]), segType(contour.segments[i])],
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
return out;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Two numbers on every corner, and they are NOT interchangeable. `index` is the joint's
|
|
192
|
-
// vertex number within its own contour (what buildCornerOpRing, simplify and the lofts
|
|
193
|
-
// key on); `position` is the corner's place in THIS returned list — the one
|
|
194
|
-
// `{corners: {indices}}` selects by. They diverge as soon as a contour has a smooth
|
|
195
|
-
// joint (a collinear midpoint, a G1 arc-line join), and on region input `index`
|
|
196
|
-
// restarts per ring while `position` runs on through the flattened order.
|
|
197
|
-
const withPositions = (corners) => corners.map((c, position) => ({ ...c, position }));
|
|
198
|
-
|
|
199
|
-
export function profileCorners(input) {
|
|
200
|
-
const { kind, regions } = liftProfile(input);
|
|
201
|
-
if (kind === "points" || kind === "contour") return withPositions(contourCorners(regions[0].outer));
|
|
202
|
-
const out = [];
|
|
203
|
-
regions.forEach((rg, regionIndex) => {
|
|
204
|
-
for (const c of contourCorners(rg.outer)) out.push({ regionIndex, ring: "outer", ...c });
|
|
205
|
-
rg.holes.forEach((h, hi) => { for (const c of contourCorners(h)) out.push({ regionIndex, ring: { hole: hi }, ...c }); });
|
|
206
|
-
});
|
|
207
|
-
return withPositions(out);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
104
|
// ── Fillet / chamfer ─────────────────────────────────────────────────────────
|
|
211
105
|
// Resolve opts.corners against a flat corner list (contourCorners()'s order, or
|
|
212
106
|
// profileCorners()'s flattened order for region/regions input — {indices} always
|
|
@@ -280,14 +174,6 @@ const normalize2 = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L,
|
|
|
280
174
|
const rot90 = ([x, y]) => [-y, x];
|
|
281
175
|
const addScaled = (p, v, s) => [p[0] + v[0] * s, p[1] + v[1] * s];
|
|
282
176
|
|
|
283
|
-
export function cubicAt(p0, c1, c2, p1, t) {
|
|
284
|
-
const u = 1 - t;
|
|
285
|
-
return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * c1[k] + 3 * u * t * t * c2[k] + t * t * t * p1[k]);
|
|
286
|
-
}
|
|
287
|
-
function cubicDeriv(p0, c1, c2, p1, t) {
|
|
288
|
-
const u = 1 - t;
|
|
289
|
-
return [0, 1].map((k) => 3 * u * u * (c1[k] - p0[k]) + 6 * u * t * (c2[k] - c1[k]) + 3 * t * t * (p1[k] - c2[k]));
|
|
290
|
-
}
|
|
291
177
|
// Exact de Casteljau split of cubic (p0,c1,c2,p1) at t → two exact cubic pieces.
|
|
292
178
|
export function splitCubic(p0, c1, c2, p1, t) {
|
|
293
179
|
const lerp = (a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|