partforge 0.100.0 → 0.102.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 +17 -3
- package/docs/ERROR-PATTERNS.md +1 -0
- package/package.json +1 -1
- package/src/framework/app.css +95 -0
- package/src/framework/geometry/kernel-front.js +6 -1
- package/src/framework/geometry/occt-backend.js +32 -0
- package/src/framework/geometry/occt-coincidence.js +197 -0
- package/src/framework/mount.js +6 -1
- package/src/framework/panel/author.js +11 -0
- package/src/framework/panel/declared-source.js +128 -0
- package/src/framework/panel/render.js +1 -0
- package/src/framework/panel/widget-specs.js +6 -6
- package/src/framework/panel/widgets/file-drop.js +45 -16
- package/src/framework/panel/widgets/font.js +10 -2
- package/src/framework/panel/widgets/image.js +93 -15
- package/src/framework/panel/widgets/vector-thumb.js +136 -0
- package/src/framework/panel/widgets/vector.js +48 -5
- package/src/parts/emblem.js +22 -9
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -731,9 +731,9 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
|
|
|
731
731
|
| `"checkbox"` | an on/off box: ticked writes `on`, cleared writes `0` | `on` (default `1`) |
|
|
732
732
|
| `"select"` | a dropdown | `options` |
|
|
733
733
|
| `"radio"` | a segmented button row | `options` |
|
|
734
|
-
| `"font"` | a typeface picker
|
|
735
|
-
| `"image"` | an image picker
|
|
736
|
-
| `"vector"` | a
|
|
734
|
+
| `"font"` | a typeface picker with a catalog, else a drop target | `allow`, `preview`, `sourceField` |
|
|
735
|
+
| `"image"` | an image picker with a catalog, else a drop target showing the artwork | `allow`, `sourceField` |
|
|
736
|
+
| `"vector"` | a drop target showing the artwork — no catalog exists | `sourceField` |
|
|
737
737
|
|
|
738
738
|
Numeric controls always show the number box: drag the slider *or* type an exact
|
|
739
739
|
value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
|
|
@@ -748,6 +748,18 @@ each entry is both value and label — or the long form
|
|
|
748
748
|
types, `12` is not `"12"`). An option's `description` surfaces as a hover tooltip
|
|
749
749
|
on that one option, not as a ⓘ popover.
|
|
750
750
|
|
|
751
|
+
**`sourceField: true`** (font/image/vector) adds a raw source text box to the
|
|
752
|
+
control. It is **off by default**: the drop target already carries the preview,
|
|
753
|
+
the drag target and click-to-choose, and where a catalog is wired there is a
|
|
754
|
+
picker too, so on a 288 px rail a text box is the affordance earning its space
|
|
755
|
+
least. Turn it on when typing a source by hand is something your users will
|
|
756
|
+
actually do — pasting an `https:` URL they already have, or a host `pfc-asset:`
|
|
757
|
+
token. Hiding it changes nothing else: the same values are accepted by the same
|
|
758
|
+
allow list, and a source set in `defaults` or by the host still applies.
|
|
759
|
+
|
|
760
|
+
For a `"font"` control with no `fontCatalog` this is the only text entry there
|
|
761
|
+
is, so a standalone app that expects users to paste font URLs should set it.
|
|
762
|
+
|
|
751
763
|
**`allow` and `preview`** (font) configure the typeface control. `allow` lists the
|
|
752
764
|
source kinds a **param-supplied** value may use — what the picker writes, or what
|
|
753
765
|
arrives in a share link:
|
|
@@ -3405,6 +3417,8 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
|
|
|
3405
3417
|
of deliberate clearance, or overshoot the cut. Mesh CSG shrugs; OCCT's boolean
|
|
3406
3418
|
degenerates, so the part previews instantly and the STEP export runs for minutes
|
|
3407
3419
|
([boolean-coincident-faces-hang](ERROR-PATTERNS.md#boolean-coincident-faces-hang)).
|
|
3420
|
+
The exact kernel refuses the common form up front — an `exactly-touching surfaces`
|
|
3421
|
+
build error names the shared radius and this fix menu.
|
|
3408
3422
|
For the case that causes this most often — a tapped hole — reach for
|
|
3409
3423
|
`k.tappedBore`, which owns the bore and the thread together and cannot land them
|
|
3410
3424
|
on the same face.
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -65,6 +65,7 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
65
65
|
|
|
66
66
|
- **Symptom:** A part previews instantly but a STEP export (or any OCCT-path build) of one sub-part runs for minutes and never finishes, with no error, no warning, and no progress. Cutting each tool on its own is fast; only the combination hangs. Threaded parts are the usual victims.
|
|
67
67
|
- **Cause:** Two cut tools in the same `cutAll` (or a tool and the body) share an *exactly* coincident face — most often a bore whose radius equals a thread's root radius, so the bore wall and the thread root lie on the same cylinder. OCCT's boolean has to classify a surface that is simultaneously on both operands, and the intersection search degenerates. Manifold's mesh CSG does not care, which is why the preview is fine and only the exact kernel suffers. Measured on one real part: bore alone 0.4 s, thread alone 5.4 s, both together did not finish in fifteen minutes; moving the bore 0.05 mm brought the pair to 12.6 s.
|
|
68
|
+
- **Detected:** The exact kernel now refuses the common cylindrical form of this contact up front — several swept faces lying exactly on one cylindrical face fail the boolean immediately with `<op> between exactly-touching surfaces: … (radius <r>)` and the fix menu below, instead of grinding. Scope, honestly: the guard needs the contact to tile the cylinder (a thread does, ~6+ hugging faces per turn; a sub-turn thread can slip under it — that is the old grinding behavior, not a new one), it covers swept-face-on-cylinder contact only (two swept faces mated exactly, or contact with non-cylindrical faces, can still hang), and a hand-sunk thread whose chord-bands happen to hug the wall can be refused even though it would have built — `k.tappedBore` resolves that refusal too, since its internal composition is exempt. The rule below applies everywhere regardless.
|
|
68
69
|
- **Fix:** For a tapped hole — far and away the most common cause — use `k.tappedBore({ d, pitch, turns, depth })`, which returns the bore and its thread as one tool and cannot put them on the same face. Otherwise: give the surfaces a deliberate clearance instead of letting them land on the same number. Derive one from the other with an explicit gap — `const boreD = threadRootD - 2 * boreClearance;` with `boreClearance` around 0.05-0.1 mm — rather than reusing the same expression for both. The gap is far below a printable layer, so the fit is unchanged. The same rule covers a cut that ends exactly flush with a face (overshoot it by a few tenths, as the surrounding examples do with `+ 0.4` / `- 0.2`) and two tools that abut exactly end-to-end.
|
|
69
70
|
|
|
70
71
|
## chamfer-rescue-bisection
|
package/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -198,6 +198,101 @@ textarea.text-input { min-height: 64px; resize: vertical; }
|
|
|
198
198
|
.text-input:focus { outline: none; border-color: var(--pf-accent);
|
|
199
199
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
200
200
|
|
|
201
|
+
/* ── file drop targets ──────────────────────────────────────────────────────
|
|
202
|
+
Two forms of the same widget. The LABELLED form is a tile the user is meant
|
|
203
|
+
to find: it doubles as the thumbnail once there is something to show, so a
|
|
204
|
+
preview, a drop zone and a click-to-choose occupy one box rather than three.
|
|
205
|
+
The AMBIENT form is for a control that already has a visible way in (the font
|
|
206
|
+
control's catalog button) — it covers the control invisibly and only appears
|
|
207
|
+
while a file is over it, so dropping stays available without spending rail
|
|
208
|
+
height advertising itself. */
|
|
209
|
+
.file-drop {
|
|
210
|
+
position: relative; display: flex; flex-direction: column;
|
|
211
|
+
align-items: center; justify-content: center; gap: 6px;
|
|
212
|
+
min-height: 56px; margin-top: 6px; padding: 8px;
|
|
213
|
+
background: var(--pf-input-bg); color: var(--pf-hint);
|
|
214
|
+
border: 1px dashed var(--pf-border); border-radius: var(--pf-radius-control);
|
|
215
|
+
text-align: center; cursor: pointer;
|
|
216
|
+
}
|
|
217
|
+
.file-drop:focus-visible { outline: none; border-color: var(--pf-accent);
|
|
218
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
219
|
+
.file-drop-hint { font: 11px/1.35 var(--pf-sans); }
|
|
220
|
+
|
|
221
|
+
/* `contain` rather than `cover` because artwork and depth maps are content, not
|
|
222
|
+
decoration — cropping one to fill the box would misrepresent what is about to
|
|
223
|
+
be built. The two preview kinds then diverge, because a photograph and a piece
|
|
224
|
+
of line art want opposite things from the box around them. */
|
|
225
|
+
.file-drop > svg, .file-drop > img {
|
|
226
|
+
display: block; max-width: 100%; max-height: 96px; object-fit: contain;
|
|
227
|
+
}
|
|
228
|
+
.file-drop > svg { fill: var(--pf-text-2); }
|
|
229
|
+
/* Once there IS a preview, the frame moves off the tile and onto the artwork.
|
|
230
|
+
The tile stays full width — it is the drop target, and a full-width one is
|
|
231
|
+
easier to hit — but goes invisible, so what the eye sees is a rounded box
|
|
232
|
+
hugging the picture with the hint underneath it, rather than a picture
|
|
233
|
+
floating inside a much larger box. The border stays at 1px and only loses its
|
|
234
|
+
colour, so nothing shifts when a thumbnail arrives. */
|
|
235
|
+
.file-drop.has-thumb {
|
|
236
|
+
border-color: transparent; background: none; padding: 0; gap: 4px;
|
|
237
|
+
}
|
|
238
|
+
.file-drop.has-thumb > img, .file-drop.has-thumb > svg {
|
|
239
|
+
border: 1px solid var(--pf-border);
|
|
240
|
+
border-radius: var(--pf-radius-control);
|
|
241
|
+
background: var(--pf-input-bg);
|
|
242
|
+
}
|
|
243
|
+
/* A RASTER is a filled rectangle, so the border sits directly on the pixels —
|
|
244
|
+
padding around one reads as a mount around a picture. `border-radius` on an
|
|
245
|
+
`<img>` clips the image itself, which is what rounds the corners. Sized by
|
|
246
|
+
`max-height` rather than `height` so a small source is shown at its own size
|
|
247
|
+
instead of being upscaled into a blur.
|
|
248
|
+
`width: auto` is load-bearing: this element also carries `.image-preview`,
|
|
249
|
+
which pins it to `width: 100%` and letterboxes the picture inside a
|
|
250
|
+
rail-width box with `object-fit`. That is the right behaviour for a preview
|
|
251
|
+
that fills its container and the wrong one for a frame meant to hug, and it
|
|
252
|
+
is invisible in the markup — the box just silently stops matching the image. */
|
|
253
|
+
.file-drop.has-thumb > img { max-height: 96px; max-width: 100%; width: auto; margin: 0; }
|
|
254
|
+
/* A VECTOR is line art: strokes running into the border would collide with it
|
|
255
|
+
rather than be framed by it, so it keeps padding inside its own frame. Sizing
|
|
256
|
+
by height with `width: auto` lets the viewBox's aspect supply the width, so
|
|
257
|
+
the frame hugs the drawing instead of the SVG's default 300x150 box. */
|
|
258
|
+
.file-drop.has-thumb > svg { height: 96px; width: auto; max-width: 100%; padding: 8px; }
|
|
259
|
+
|
|
260
|
+
/* The empty-state hint and the replace hint trade places: exactly one is visible
|
|
261
|
+
in either state, so a tile is never without an instruction. */
|
|
262
|
+
.file-drop-hint-replace { display: none; }
|
|
263
|
+
.file-drop.has-thumb .file-drop-hint { display: none; }
|
|
264
|
+
.file-drop.has-thumb .file-drop-hint-replace { display: block; }
|
|
265
|
+
|
|
266
|
+
/* Ambient: no box of its own. Laid over the control, inert to the pointer so
|
|
267
|
+
every click still reaches the button underneath — the one affordance this
|
|
268
|
+
form exists to protect. */
|
|
269
|
+
/* Only the controls that actually carry an ambient overlay become positioned —
|
|
270
|
+
`.slider` is shared by every widget in the rail, and making all of them a
|
|
271
|
+
containing block to serve one would be a wide change for a narrow need. */
|
|
272
|
+
.slider:has(> .file-drop-ambient) { position: relative; }
|
|
273
|
+
|
|
274
|
+
.file-drop-ambient {
|
|
275
|
+
position: absolute; inset: 0; margin: 0; padding: 0;
|
|
276
|
+
min-height: 0; background: none; border: 0; pointer-events: none;
|
|
277
|
+
}
|
|
278
|
+
/* `dragover` is the only thing that reveals it. Pointer events stay off even
|
|
279
|
+
then: the drop event still fires on the element, and turning them on would
|
|
280
|
+
swallow a click that merely passed through mid-drag. */
|
|
281
|
+
.file-drop-ambient.file-drop-over {
|
|
282
|
+
border: 1px dashed var(--pf-accent); border-radius: var(--pf-radius-control);
|
|
283
|
+
background: color-mix(in oklab, var(--pf-accent) 12%, transparent);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* Shared drag feedback for the labelled form. */
|
|
287
|
+
.file-drop.file-drop-over {
|
|
288
|
+
border-color: var(--pf-accent);
|
|
289
|
+
background: color-mix(in oklab, var(--pf-accent) 12%, transparent);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.file-drop-error {
|
|
293
|
+
margin-top: 5px; font: 11px/1.35 var(--pf-sans); color: var(--pf-err);
|
|
294
|
+
}
|
|
295
|
+
|
|
201
296
|
/* the `type: "font"` control — a button that shows the current face IN it */
|
|
202
297
|
.font-btn { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; cursor: pointer;
|
|
203
298
|
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
@@ -118,7 +118,12 @@ export function finishKernel(k) {
|
|
|
118
118
|
// swapped, an empty solid). A wrong answer with no error is the worst of the
|
|
119
119
|
// three failure modes, and overhanging is what avoids it.
|
|
120
120
|
const threadLength = pitch * turns;
|
|
121
|
-
|
|
121
|
+
// _trustedUnion where the backend offers it (OCCT): this composition is the
|
|
122
|
+
// coincidence guard's own coached FIX, audited by the identity test and the
|
|
123
|
+
// guard suite, and its flank chord-bands legitimately hug the bore wall
|
|
124
|
+
// they overlap — the guard must not second-guess it. Manifold has no guard
|
|
125
|
+
// and no _trustedUnion; plain union is identical there.
|
|
126
|
+
return (k._trustedUnion ?? k.union)([
|
|
122
127
|
k.cylinder({ d, h: (depth ?? threadLength) + 2 * overshoot }).translate([0, 0, -overshoot]),
|
|
123
128
|
thread,
|
|
124
129
|
]);
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// param change (a lid's open angle) therefore re-runs no OCCT op at all.
|
|
16
16
|
// Ops that need the real B-rep (booleans, fillet/chamfer/shell, exports,
|
|
17
17
|
// volume, boundingBox) materialize the pending pose through replicad first.
|
|
18
|
+
import { assertNoCoincidentBoolean } from "./occt-coincidence.js";
|
|
18
19
|
import { toEdgeFinder } from "./edge-selector.js";
|
|
19
20
|
import { toFaceFinder } from "./face-selector.js";
|
|
20
21
|
import { addSugar } from "./solid-sugar.js";
|
|
@@ -117,6 +118,12 @@ export function createOcctKernel(replicad) {
|
|
|
117
118
|
// Manifold backend's fillet/chamfer degradation.
|
|
118
119
|
const buildWarnings = [];
|
|
119
120
|
const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
|
|
121
|
+
// The raw OCCT instance, for the coincident-boolean guard (occt-coincidence.js).
|
|
122
|
+
// Absent (older replicad, or a boot path that skipped setOC) the guard is a no-op —
|
|
123
|
+
// detection is an upgrade, never a dependency.
|
|
124
|
+
let occtInstance = null;
|
|
125
|
+
try { occtInstance = replicad.getOC(); } catch { /* guard disabled */ }
|
|
126
|
+
const guardBoolean = (opName, solids) => assertNoCoincidentBoolean(occtInstance, opName, solids);
|
|
120
127
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
121
128
|
// see occt-repair.js for the policies and why they differ per op.
|
|
122
129
|
const { validChamfer, safeOp } = createOcctRepair(measureVolume, recordWarning);
|
|
@@ -223,6 +230,7 @@ export function createOcctKernel(replicad) {
|
|
|
223
230
|
const key = h("cut", hash, t._hash);
|
|
224
231
|
return cached(key, () => {
|
|
225
232
|
const a = mat(), b = t._mat();
|
|
233
|
+
guardBoolean("cut", [a._s, b._s]);
|
|
226
234
|
return wrap(a._s.clone().cut(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
|
|
227
235
|
});
|
|
228
236
|
},
|
|
@@ -231,6 +239,10 @@ export function createOcctKernel(replicad) {
|
|
|
231
239
|
return cached(key, () => {
|
|
232
240
|
const a = mat(), bs = tools.map((t) => t._mat());
|
|
233
241
|
if (bs.length === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
|
|
242
|
+
// All pairs, tools included: the cut below first fuses the tools
|
|
243
|
+
// together, so tool-to-tool contact hangs exactly like target-to-tool
|
|
244
|
+
// (the measured case WAS two tools — a bore and its thread).
|
|
245
|
+
guardBoolean("cutAll", [a._s, ...bs.map((b) => b._s)]);
|
|
234
246
|
const fusedTools = bs
|
|
235
247
|
.slice(1)
|
|
236
248
|
.reduce((acc, b) => acc.fuse(b._s.clone()), bs[0]._s.clone());
|
|
@@ -245,6 +257,7 @@ export function createOcctKernel(replicad) {
|
|
|
245
257
|
const key = h("intersect", hash, t._hash);
|
|
246
258
|
return cached(key, () => {
|
|
247
259
|
const a = mat(), b = t._mat();
|
|
260
|
+
guardBoolean("intersect", [a._s, b._s]);
|
|
248
261
|
return wrap(a._s.clone().intersect(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
|
|
249
262
|
});
|
|
250
263
|
},
|
|
@@ -252,6 +265,7 @@ export function createOcctKernel(replicad) {
|
|
|
252
265
|
const key = h("union", [hash, t._hash]);
|
|
253
266
|
return cached(key, () => {
|
|
254
267
|
const a = mat(), b = t._mat();
|
|
268
|
+
guardBoolean("union", [a._s, b._s]);
|
|
255
269
|
return wrap(a._s.clone().fuse(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
|
|
256
270
|
});
|
|
257
271
|
},
|
|
@@ -628,6 +642,24 @@ export function createOcctKernel(replicad) {
|
|
|
628
642
|
prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube, heightfield,
|
|
629
643
|
sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
|
|
630
644
|
union: (solids) => {
|
|
645
|
+
const key = h("union", solids.map((s) => s._hash));
|
|
646
|
+
return cached(key, () => {
|
|
647
|
+
const ms = solids.map((s) => s._mat());
|
|
648
|
+
guardBoolean("union", ms.map((m) => m._s));
|
|
649
|
+
return wrap(
|
|
650
|
+
ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
|
|
651
|
+
ms.flatMap((m) => cloneLabels(m._labels)),
|
|
652
|
+
key,
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
},
|
|
656
|
+
// Union WITHOUT the coincidence guard — for kernel-front compositions that
|
|
657
|
+
// are audited fixes for the guarded failure (k.tappedBore's bore ∪ thread,
|
|
658
|
+
// whose flank chord-bands deliberately hug the bore wall they overlap;
|
|
659
|
+
// measured safe at 3.0s where the tangent form never finishes). `_`-prefixed:
|
|
660
|
+
// not part of the public kernel surface, and part authors never see it.
|
|
661
|
+
// Same cache key as union — the geometry is identical either way.
|
|
662
|
+
_trustedUnion: (solids) => {
|
|
631
663
|
const key = h("union", solids.map((s) => s._hash));
|
|
632
664
|
return cached(key, () => {
|
|
633
665
|
const ms = solids.map((s) => s._mat());
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Refuses the boolean OCCT cannot survive, before it runs.
|
|
2
|
+
//
|
|
3
|
+
// The degenerate case (measured on real feedback, 2026-08-31): a swept face —
|
|
4
|
+
// a thread root from screwSweep is the canonical one — lying exactly ON the
|
|
5
|
+
// other operand's cylindrical face. OCCT 7.6 detects same-domain overlap
|
|
6
|
+
// between two ANALYTIC cylinders instantly (flush stacks, re-cut holes and
|
|
7
|
+
// overlapping coaxial rods all fuse in milliseconds), but a BSpline surface
|
|
8
|
+
// coincident with a cylinder has no same-domain shortcut: the intersection
|
|
9
|
+
// walker grinds for minutes with no error, wedging the serial kernel worker,
|
|
10
|
+
// or — on OCCT 8 — "finishes" with corrupt geometry (a negative-volume fuse).
|
|
11
|
+
// Every kernel-level mitigation was measured and ruled out: SetFuzzyValue
|
|
12
|
+
// hangs at 1e-4/1e-3/1e-2, SetUseOBB hangs, glue completes only under a
|
|
13
|
+
// precondition (no volumetric overlap) that cannot be proven in general, and
|
|
14
|
+
// the WASM build binds no progress indicator so the grind cannot be aborted.
|
|
15
|
+
// The only fix is refusing the contact and coaching the author toward real
|
|
16
|
+
// clearance, real overlap, or k.tappedBore.
|
|
17
|
+
//
|
|
18
|
+
// The predicate is AREA contact, not proximity: a freeform face whose sampled
|
|
19
|
+
// points nearly all sit on the cylinder (within the sweep's own approximation
|
|
20
|
+
// band — the swept "cylinder" deviates from the true radius by ~1e-3·r, which
|
|
21
|
+
// is exactly why OCCT cannot classify it as same-domain). Faces that merely
|
|
22
|
+
// CROSS the radius (thread flanks, the tapered lead-in ramps) put only a
|
|
23
|
+
// fraction of their samples in the band and are left alone — measured at 0.5
|
|
24
|
+
// and 0.75 against the 0.9 threshold, and a construction with the documented
|
|
25
|
+
// 0.05 mm of clearance sits far outside the band entirely.
|
|
26
|
+
//
|
|
27
|
+
// Known misses, accepted: freeform-vs-freeform coincidence (two swept
|
|
28
|
+
// surfaces mated exactly) and exact contact with non-cylindrical analytic
|
|
29
|
+
// faces. This guard exists for the case users actually author — a bore plus a
|
|
30
|
+
// thread — not as a proof that every boolean terminates.
|
|
31
|
+
|
|
32
|
+
const FREEFORM_SURFACES = [
|
|
33
|
+
"GeomAbs_BSplineSurface",
|
|
34
|
+
"GeomAbs_BezierSurface",
|
|
35
|
+
"GeomAbs_SurfaceOfExtrusion",
|
|
36
|
+
"GeomAbs_SurfaceOfRevolution",
|
|
37
|
+
"GeomAbs_OffsetSurface",
|
|
38
|
+
"GeomAbs_OtherSurface",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Sampled 4×4 across each candidate face; "on the cylinder" means within
|
|
42
|
+
// max(1e-3, 2e-3·r) — twice the measured sweep-approximation band, still an
|
|
43
|
+
// order of magnitude below the 0.05 mm the authoring guidance calls real
|
|
44
|
+
// clearance — and a face is contact only when ≥90% of its samples qualify.
|
|
45
|
+
//
|
|
46
|
+
// A single hugging face is NOT enough to refuse: a twisted extrusion's flank
|
|
47
|
+
// is subdivided into narrow helical chord-bands, and whichever band straddles
|
|
48
|
+
// the wall sits inside the position tolerance over its whole area (measured:
|
|
49
|
+
// r-span 0.010 across the full thread length) — yet OCCT resolves that
|
|
50
|
+
// transversal contact in seconds. Local geometry cannot separate the two
|
|
51
|
+
// cases robustly: the band's radial drift and the coincident face's chord
|
|
52
|
+
// wobble are both ~r·1e-3, and every tangency proxy measured (surface
|
|
53
|
+
// normals: cancellation noise on helical faces; radial gradients: the
|
|
54
|
+
// crossing smears over a ~0.37 circumferential path, g≈0.03 vs wobble 0.01)
|
|
55
|
+
// lands inside the noise. What separates them decisively is EXTENT: a
|
|
56
|
+
// coincident swept surface tiles the cylinder with hugging faces (~6+ per
|
|
57
|
+
// turn, 20 measured on a 3-turn thread), while a crossing contributes the one
|
|
58
|
+
// band that happens to straddle (1–3 with phase luck). Hence
|
|
59
|
+
// MIN_CONTACT_FACES: refuse only when several distinct freeform faces hug the
|
|
60
|
+
// SAME cylinder. A sub-turn thread can slip under the threshold — that is the
|
|
61
|
+
// old behavior, not a new failure — and a hand-sunk thread whose chord-band
|
|
62
|
+
// count reaches it is refused with coaching toward k.tappedBore, whose own
|
|
63
|
+
// internal union is exempt (it is this module's audited fix, not a suspect).
|
|
64
|
+
const GRID = 4;
|
|
65
|
+
const REL_TOL = 2e-3;
|
|
66
|
+
const MIN_TOL = 1e-3;
|
|
67
|
+
const MIN_ON_FRACTION = 0.9;
|
|
68
|
+
const MIN_CONTACT_FACES = 4;
|
|
69
|
+
|
|
70
|
+
// Above this many faces on one operand, skip detection (fail open): the guard
|
|
71
|
+
// must never cost more than the boolean it protects. A part-authored solid is
|
|
72
|
+
// tens to hundreds of faces; only a large STEP import approaches this.
|
|
73
|
+
const MAX_FACES = 8000;
|
|
74
|
+
|
|
75
|
+
const enumName = (enumObj, value) =>
|
|
76
|
+
Object.keys(enumObj).find((n) => enumObj[n] === value || (enumObj[n]?.value !== undefined && enumObj[n].value === value?.value));
|
|
77
|
+
|
|
78
|
+
// One pass over a solid's faces: analytic cylinders (radius + axis + bbox) and
|
|
79
|
+
// freeform faces (adaptor kept for lazy sampling + bbox). Caller must dispose().
|
|
80
|
+
function faceProfile(oc, topo) {
|
|
81
|
+
const cylinders = [];
|
|
82
|
+
const freeforms = [];
|
|
83
|
+
let faceCount = 0;
|
|
84
|
+
const explorer = new oc.TopExp_Explorer_2(topo, oc.TopAbs_ShapeEnum.TopAbs_FACE, oc.TopAbs_ShapeEnum.TopAbs_SHAPE);
|
|
85
|
+
for (; explorer.More(); explorer.Next()) {
|
|
86
|
+
faceCount += 1;
|
|
87
|
+
if (faceCount > MAX_FACES) break;
|
|
88
|
+
const face = oc.TopoDS.Face_1(explorer.Current());
|
|
89
|
+
const adaptor = new oc.BRepAdaptor_Surface_2(face, true);
|
|
90
|
+
const surface = enumName(oc.GeomAbs_SurfaceType, adaptor.GetType());
|
|
91
|
+
const box = new oc.Bnd_Box_1();
|
|
92
|
+
oc.BRepBndLib.Add(face, box, false);
|
|
93
|
+
const mn = box.CornerMin(), mx = box.CornerMax();
|
|
94
|
+
const bbox = [mn.X(), mn.Y(), mn.Z(), mx.X(), mx.Y(), mx.Z()];
|
|
95
|
+
box.delete();
|
|
96
|
+
if (surface === "GeomAbs_Cylinder") {
|
|
97
|
+
const cyl = adaptor.Cylinder();
|
|
98
|
+
const axis = cyl.Axis(), dir = axis.Direction(), loc = axis.Location();
|
|
99
|
+
cylinders.push({
|
|
100
|
+
r: cyl.Radius(),
|
|
101
|
+
loc: [loc.X(), loc.Y(), loc.Z()],
|
|
102
|
+
dir: [dir.X(), dir.Y(), dir.Z()],
|
|
103
|
+
bbox,
|
|
104
|
+
});
|
|
105
|
+
adaptor.delete();
|
|
106
|
+
} else if (FREEFORM_SURFACES.includes(surface)) {
|
|
107
|
+
freeforms.push({ adaptor, bbox });
|
|
108
|
+
} else {
|
|
109
|
+
adaptor.delete();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
explorer.delete();
|
|
113
|
+
return {
|
|
114
|
+
cylinders,
|
|
115
|
+
freeforms,
|
|
116
|
+
overflow: faceCount > MAX_FACES,
|
|
117
|
+
dispose: () => { for (const f of freeforms) f.adaptor.delete(); },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const boxesOverlap = (a, b, pad) =>
|
|
122
|
+
a[0] <= b[3] + pad && b[0] <= a[3] + pad &&
|
|
123
|
+
a[1] <= b[4] + pad && b[1] <= a[4] + pad &&
|
|
124
|
+
a[2] <= b[5] + pad && b[2] <= a[5] + pad;
|
|
125
|
+
|
|
126
|
+
// Fraction of a freeform face's interior sample grid lying within tol of the
|
|
127
|
+
// cylinder's surface.
|
|
128
|
+
function onCylinderFraction(freeform, cyl, tol) {
|
|
129
|
+
const ad = freeform.adaptor;
|
|
130
|
+
const u0 = ad.FirstUParameter(), u1 = ad.LastUParameter();
|
|
131
|
+
const v0 = ad.FirstVParameter(), v1 = ad.LastVParameter();
|
|
132
|
+
let hits = 0;
|
|
133
|
+
for (let i = 0; i < GRID; i++) {
|
|
134
|
+
for (let j = 0; j < GRID; j++) {
|
|
135
|
+
const p = ad.Value(u0 + ((i + 0.5) / GRID) * (u1 - u0), v0 + ((j + 0.5) / GRID) * (v1 - v0));
|
|
136
|
+
const px = p.X() - cyl.loc[0], py = p.Y() - cyl.loc[1], pz = p.Z() - cyl.loc[2];
|
|
137
|
+
const t = px * cyl.dir[0] + py * cyl.dir[1] + pz * cyl.dir[2];
|
|
138
|
+
const radial = Math.hypot(px - t * cyl.dir[0], py - t * cyl.dir[1], pz - t * cyl.dir[2]);
|
|
139
|
+
if (Math.abs(radial - cyl.r) <= tol) hits += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return hits / (GRID * GRID);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function contactBetween(profileA, profileB) {
|
|
146
|
+
for (const [cylSide, freeSide] of [[profileA, profileB], [profileB, profileA]]) {
|
|
147
|
+
for (const cyl of cylSide.cylinders) {
|
|
148
|
+
const tol = Math.max(MIN_TOL, REL_TOL * cyl.r);
|
|
149
|
+
let contacts = 0;
|
|
150
|
+
for (const freeform of freeSide.freeforms) {
|
|
151
|
+
if (!boxesOverlap(cyl.bbox, freeform.bbox, tol)) continue;
|
|
152
|
+
if (onCylinderFraction(freeform, cyl, tol) >= MIN_ON_FRACTION) contacts += 1;
|
|
153
|
+
if (contacts >= MIN_CONTACT_FACES) return { radius: cyl.r };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The one entry point. `solids` are replicad Shape3D wrappers (`.wrapped` is
|
|
161
|
+
// the TopoDS shape); every unordered pair is checked, because a cutAll fuses
|
|
162
|
+
// its tools together before cutting — the contact can be tool-to-tool as
|
|
163
|
+
// easily as target-to-tool. Throws the coached error on contact; returns
|
|
164
|
+
// silently otherwise. Any internal failure returns silently too: the guard
|
|
165
|
+
// must never break a boolean that would have succeeded.
|
|
166
|
+
export function assertNoCoincidentBoolean(oc, opName, solids) {
|
|
167
|
+
if (!oc || solids.length < 2) return;
|
|
168
|
+
const profiles = [];
|
|
169
|
+
try {
|
|
170
|
+
for (const s of solids) profiles.push(faceProfile(oc, s.wrapped));
|
|
171
|
+
for (let i = 0; i < profiles.length; i++) {
|
|
172
|
+
for (let j = i + 1; j < profiles.length; j++) {
|
|
173
|
+
const contact = contactBetween(profiles[i], profiles[j]);
|
|
174
|
+
if (contact) throw coincidentBooleanError(opName, contact.radius);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (e?.code === "COINCIDENT_BOOLEAN") throw e;
|
|
179
|
+
// Detection is best-effort; a probe failure must not block the build.
|
|
180
|
+
} finally {
|
|
181
|
+
for (const p of profiles) { try { p.dispose(); } catch { /* freed with the shape */ } }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function coincidentBooleanError(opName, radius) {
|
|
186
|
+
const r = Number(radius.toFixed(4));
|
|
187
|
+
const err = new Error(
|
|
188
|
+
`${opName} between exactly-touching surfaces: a swept or curved face of one operand lies ` +
|
|
189
|
+
`exactly on a cylindrical face of the other (radius ${r}). The exact kernel cannot process ` +
|
|
190
|
+
`this contact — it grinds for minutes or returns broken geometry — so the build was refused ` +
|
|
191
|
+
`before trying. Make the surfaces genuinely overlap or genuinely clear each other (0.05 or ` +
|
|
192
|
+
`more) instead of exactly touching; for an internal thread, replace the bore + screwSweep ` +
|
|
193
|
+
`pair with k.tappedBore, which builds the same tap as one safe tool.`,
|
|
194
|
+
);
|
|
195
|
+
err.code = "COINCIDENT_BOOLEAN";
|
|
196
|
+
return err;
|
|
197
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createViewer } from "./viewer.js";
|
|
|
4
4
|
import { attachViewerControls } from "./viewer-controls.js";
|
|
5
5
|
import { attachCutawayControls } from "./cutaway-controls.js";
|
|
6
6
|
import { attachRail } from "./rail.js";
|
|
7
|
+
import { declaredSourceLookup } from "./panel/declared-source.js";
|
|
7
8
|
import { attachMobileTabs } from "./mobile-tabs.js";
|
|
8
9
|
import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
|
|
9
10
|
import { loadCamera, loadProjection, saveProjection } from "./view-state.js";
|
|
@@ -976,7 +977,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
976
977
|
}, onParamsCommit
|
|
977
978
|
? (changed) => onParamsCommit({ changed, params: { ...params } })
|
|
978
979
|
: undefined,
|
|
979
|
-
|
|
980
|
+
// What the part is actually using for each asset key, so a control opens
|
|
981
|
+
// showing the bundled default instead of an empty tile. Rebuilt per panel
|
|
982
|
+
// build, because the declaration is a function of the current params.
|
|
983
|
+
{ fontCatalog, imageCatalog, onAssetUpload,
|
|
984
|
+
declaredSource: declaredSourceLookup(part, params) });
|
|
980
985
|
cleanup.defer(() => panel.dispose());
|
|
981
986
|
panelRef = panel;
|
|
982
987
|
const updateRelevance = () => {
|
|
@@ -33,6 +33,17 @@ function authoredControl(c) {
|
|
|
33
33
|
hidden: !!c.hidden,
|
|
34
34
|
when: c.when,
|
|
35
35
|
whenFalse: c.whenFalse,
|
|
36
|
+
// Asset-control fields. This list is an explicit allow-list, so anything
|
|
37
|
+
// missing from it is silently dropped rather than failing loudly — which is
|
|
38
|
+
// how `allow` came to be ignored: an authored `allow: ["asset"]` never
|
|
39
|
+
// reached the widget, so its own typed-URL check fell back to the default
|
|
40
|
+
// list and accepted values the worker then refused and reset underneath the
|
|
41
|
+
// user. The worker-side gate was never affected (imageControlAllows and its
|
|
42
|
+
// siblings walk the raw authored tree, not this node), so the enforcement
|
|
43
|
+
// held throughout — but the panel disagreed with it.
|
|
44
|
+
allow: c.allow,
|
|
45
|
+
preview: c.preview,
|
|
46
|
+
sourceField: c.sourceField,
|
|
36
47
|
preserveOn: false,
|
|
37
48
|
marksCustom: true,
|
|
38
49
|
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// What a part is ACTUALLY using for an asset key, when the control's own param
|
|
2
|
+
// is empty.
|
|
3
|
+
//
|
|
4
|
+
// The problem this solves is not cosmetic. A part's bundled default lives in the
|
|
5
|
+
// `images`/`vectors` DECLARATION, never in `defaults` — an author cannot move it
|
|
6
|
+
// there, because the allow list passes only `https` and a bundled asset resolves
|
|
7
|
+
// to a `file:`/dev URL, so `defaults: { relief: new URL(…) }` is refused and
|
|
8
|
+
// reset. The declaration is therefore the only home for it, and the panel could
|
|
9
|
+
// not see the declaration at all: `buildControls` receives `part.parameters`.
|
|
10
|
+
// The result was a control that opened empty while the part was plainly building
|
|
11
|
+
// from an image.
|
|
12
|
+
//
|
|
13
|
+
// Main-thread only, and deliberately import-free beyond what a lookup needs: it
|
|
14
|
+
// runs during panel construction, on every rebuild.
|
|
15
|
+
|
|
16
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
17
|
+
|
|
18
|
+
// `images`/`vectors`/`fonts` may each be a plain map or a function of params —
|
|
19
|
+
// the function form is what a control drives. Resolving it can throw (it is
|
|
20
|
+
// author code running on every panel build), and a broken declaration must not
|
|
21
|
+
// take the panel down: the control simply shows nothing, which is the same thing
|
|
22
|
+
// it showed before this existed.
|
|
23
|
+
function resolveDecl(decl, params) {
|
|
24
|
+
try {
|
|
25
|
+
return typeof decl === "function" ? decl(params) : decl;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FIELD = { image: "images", vector: "vectors", font: "fonts" };
|
|
32
|
+
|
|
33
|
+
// URL-shaped for the same reason lint's probes are: a declaration may parse the
|
|
34
|
+
// value it is handed, and an arbitrary string would make it throw for reasons
|
|
35
|
+
// that have nothing to do with which asset the key feeds.
|
|
36
|
+
const SENTINEL = "pf-panel-sentinel://declared-source";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build `(kind, key) => source | undefined` for one part and its current params.
|
|
40
|
+
*
|
|
41
|
+
* Returns `undefined` when the param already holds a value: the param IS the
|
|
42
|
+
* user's choice, and the declaration for that key is derived from it. Only an
|
|
43
|
+
* empty param falls through to whatever the part declared.
|
|
44
|
+
*/
|
|
45
|
+
export function declaredSourceLookup(part, params) {
|
|
46
|
+
return (kind, key) => {
|
|
47
|
+
const own = params?.[key];
|
|
48
|
+
if (own !== undefined && own !== null && own !== "") return undefined;
|
|
49
|
+
|
|
50
|
+
const decl = part?.[FIELD[kind]];
|
|
51
|
+
const resolved = resolveDecl(decl, params ?? {});
|
|
52
|
+
if (!resolved || typeof resolved !== "object") return undefined;
|
|
53
|
+
|
|
54
|
+
// A control's param key is NOT necessarily the asset's name. emblem.js
|
|
55
|
+
// declares `vectors: (p) => ({ emblem: p.art || bundled })` — key `art`,
|
|
56
|
+
// asset `emblem`. relief.js happens to use the same word for both, which is
|
|
57
|
+
// what makes the assumption look safe until it is not.
|
|
58
|
+
//
|
|
59
|
+
// So probe, the way lint's *-control-not-in-* rules already do: resolve the
|
|
60
|
+
// declaration once with a sentinel in this key and see which asset name it
|
|
61
|
+
// came out under. A URL-shaped sentinel, because a declaration is free to
|
|
62
|
+
// parse what it is handed and an arbitrary string would make it throw for
|
|
63
|
+
// reasons unrelated to the mapping.
|
|
64
|
+
let name = key;
|
|
65
|
+
if (typeof decl === "function" && !(key in resolved)) {
|
|
66
|
+
const probe = resolveDecl(decl, { ...(params ?? {}), [key]: SENTINEL });
|
|
67
|
+
const hit = probe && typeof probe === "object"
|
|
68
|
+
&& Object.keys(probe).find((n) => probe[n] === SENTINEL);
|
|
69
|
+
if (!hit) return undefined; // this key feeds nothing — not our asset
|
|
70
|
+
name = hit;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const source = resolved[name];
|
|
74
|
+
return source == null || source === "" ? undefined : source;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A declared image source -> something an `<img>` can load, or `undefined`.
|
|
80
|
+
*
|
|
81
|
+
* The allow list gates PARAMS, not author declarations, so a `file:` or dev URL
|
|
82
|
+
* is perfectly fine to display here — the browser is loading it, nothing is
|
|
83
|
+
* being accepted from an untrusted link.
|
|
84
|
+
*/
|
|
85
|
+
export async function declaredImageUrl(source) {
|
|
86
|
+
try {
|
|
87
|
+
let v = typeof source === "function" ? await source() : source;
|
|
88
|
+
// A Vite `() => import("./x.png")` resolves to `{ default: url }`.
|
|
89
|
+
if (v && typeof v === "object" && !(v instanceof URL) && "default" in v) v = v.default;
|
|
90
|
+
if (v instanceof URL) return v.href;
|
|
91
|
+
if (typeof v === "string" && v) return v;
|
|
92
|
+
if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) {
|
|
93
|
+
return URL.createObjectURL(new Blob([v], { type: "image/png" }));
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined; // a thunk that rejects shows nothing, it does not propagate
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A declared vector source -> its parsed document, or `undefined`.
|
|
103
|
+
*
|
|
104
|
+
* Unlike an image there is nothing to point at: the thumbnail is drawn from the
|
|
105
|
+
* document's own contours, so the file has to be fetched and parsed. Results are
|
|
106
|
+
* memoised per source, because a panel rebuild would otherwise refetch on every
|
|
107
|
+
* slider drag.
|
|
108
|
+
*/
|
|
109
|
+
const vectorDocs = new Map();
|
|
110
|
+
export async function declaredVectorDoc(source) {
|
|
111
|
+
if (source == null) return undefined;
|
|
112
|
+
if (typeof source === "object" && !(source instanceof URL) && !("default" in source)) return source;
|
|
113
|
+
if (vectorDocs.has(source)) return vectorDocs.get(source);
|
|
114
|
+
const p = (async () => {
|
|
115
|
+
try {
|
|
116
|
+
let v = typeof source === "function" ? await source() : source;
|
|
117
|
+
if (v && typeof v === "object" && !(v instanceof URL) && "default" in v) v = v.default;
|
|
118
|
+
if (v && typeof v === "object" && !(v instanceof URL)) return v; // already parsed
|
|
119
|
+
const res = await fetch(v instanceof URL ? v.href : v);
|
|
120
|
+
if (!res.ok) return undefined;
|
|
121
|
+
return await res.json();
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined; // offline, 404, CORS, malformed JSON — all show nothing
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
vectorDocs.set(source, p);
|
|
127
|
+
return p;
|
|
128
|
+
}
|
|
@@ -229,6 +229,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
|
|
|
229
229
|
fontCatalog: opts.fontCatalog,
|
|
230
230
|
imageCatalog: opts.imageCatalog,
|
|
231
231
|
onAssetUpload: opts.onAssetUpload,
|
|
232
|
+
declaredSource: opts.declaredSource,
|
|
232
233
|
});
|
|
233
234
|
nodeEls.set(node.id, widget.el);
|
|
234
235
|
if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
|
|
@@ -36,9 +36,9 @@ export const WIDGET_SPECS = [
|
|
|
36
36
|
{ type: "checkbox", kind: "control", fields: LEGACY_TOGGLE },
|
|
37
37
|
{ type: "select", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
|
|
38
38
|
{ type: "radio", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
|
|
39
|
-
{ type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview"] },
|
|
40
|
-
{ type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
|
|
41
|
-
{ type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
|
|
39
|
+
{ type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview", "sourceField"] },
|
|
40
|
+
{ type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
|
|
41
|
+
{ type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
|
|
42
42
|
{ type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
|
|
43
43
|
];
|
|
44
44
|
|
|
@@ -57,9 +57,9 @@ const AUTHOR_EXTRAS = {
|
|
|
57
57
|
checkbox: ["on"],
|
|
58
58
|
select: ["options"],
|
|
59
59
|
radio: ["options"],
|
|
60
|
-
font: ["allow", "preview"],
|
|
61
|
-
image: ["allow"],
|
|
62
|
-
vector: ["allow"],
|
|
60
|
+
font: ["allow", "preview", "sourceField"],
|
|
61
|
+
image: ["allow", "sourceField"],
|
|
62
|
+
vector: ["allow", "sourceField"],
|
|
63
63
|
};
|
|
64
64
|
const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
|
|
65
65
|
([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
|
|
@@ -86,24 +86,47 @@ function el(tag, className, text) {
|
|
|
86
86
|
return node;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// `ambient` is for a control that already has a visible way in — the font
|
|
90
|
+
// control's catalog button, say. A labelled drop zone under it would spend rail
|
|
91
|
+
// height repeating the same offer, so the ambient form carries no hint, no click
|
|
92
|
+
// target and no place in the tab order: it is an overlay that shows itself only
|
|
93
|
+
// while a file is over it (see `.file-drop-ambient` in app.css). Dropping still
|
|
94
|
+
// works, it is simply not advertised.
|
|
95
|
+
//
|
|
96
|
+
// The click path is dropped rather than hidden, deliberately: an invisible
|
|
97
|
+
// overlay that still swallowed clicks would eat the button underneath it, which
|
|
98
|
+
// is the one affordance ambient mode exists to protect.
|
|
99
|
+
export function makeFileDrop({ kind, onSource, onError, onAssetUpload, ambient = false }) {
|
|
90
100
|
const row = rowFor(kind);
|
|
91
|
-
const wrap = el("div", "file-drop");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
const wrap = el("div", ambient ? "file-drop file-drop-ambient" : "file-drop");
|
|
102
|
+
if (!ambient) {
|
|
103
|
+
wrap.tabIndex = 0;
|
|
104
|
+
wrap.setAttribute("role", "button");
|
|
105
|
+
// Two hints, swapped by CSS on `.has-thumb`. The empty-state one is the only
|
|
106
|
+
// thing in an empty tile; the replace one takes over once a preview fills it.
|
|
107
|
+
// Without the second, a tile showing a part's declared artwork — now the
|
|
108
|
+
// state a control OPENS in — carried no instruction at all, because the
|
|
109
|
+
// first is hidden the moment a thumbnail appears.
|
|
110
|
+
wrap.append(el("span", "file-drop-hint", `Drop ${row?.label ?? "a file"} here, or click to choose`));
|
|
111
|
+
wrap.append(el("span", "file-drop-hint file-drop-hint-replace", "Drop to replace, or click to choose"));
|
|
112
|
+
}
|
|
96
113
|
|
|
97
114
|
// The click/keyboard path to the same handler a drop uses. Hidden rather
|
|
98
115
|
// than absent: a real `<input type="file">` is what gives this a native
|
|
99
116
|
// "Choose File" affordance and OS-level type filtering (`accept`), neither
|
|
100
117
|
// of which is worth hand-rolling.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
input
|
|
105
|
-
if (
|
|
106
|
-
|
|
118
|
+
// Ambient mode has no click path, so it gets no input at all — an unused one
|
|
119
|
+
// would still be focusable in some browsers and would show up to a screen
|
|
120
|
+
// reader as a second, unlabelled file control.
|
|
121
|
+
let input = null;
|
|
122
|
+
if (!ambient) {
|
|
123
|
+
input = document.createElement("input");
|
|
124
|
+
input.type = "file";
|
|
125
|
+
input.className = "file-drop-input";
|
|
126
|
+
input.hidden = true;
|
|
127
|
+
if (row?.accepts?.length) input.accept = row.accepts.join(",");
|
|
128
|
+
wrap.append(input);
|
|
129
|
+
}
|
|
107
130
|
|
|
108
131
|
// The converted artifact from the most recently accepted drop — a Blob (or,
|
|
109
132
|
// for a `convert: null` kind like font, the original File, which already IS
|
|
@@ -266,10 +289,15 @@ export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
|
|
|
266
289
|
wrap.addEventListener("drop", onDrop, { signal });
|
|
267
290
|
wrap.addEventListener("dragover", onDragOver, { signal });
|
|
268
291
|
wrap.addEventListener("dragleave", onDragLeave, { signal });
|
|
269
|
-
wrap.addEventListener("click", onClick, { signal });
|
|
270
|
-
wrap.addEventListener("keydown", onKeydown, { signal });
|
|
271
292
|
wrap.addEventListener("paste", onPaste, { signal });
|
|
272
|
-
input
|
|
293
|
+
// Click, keyboard and the input's own change only exist when there is an input
|
|
294
|
+
// to open — ambient mode is drop-and-paste only, so that the control's real
|
|
295
|
+
// button keeps every click.
|
|
296
|
+
if (input) {
|
|
297
|
+
wrap.addEventListener("click", onClick, { signal });
|
|
298
|
+
wrap.addEventListener("keydown", onKeydown, { signal });
|
|
299
|
+
input.addEventListener("change", onChange, { signal });
|
|
300
|
+
}
|
|
273
301
|
|
|
274
302
|
return {
|
|
275
303
|
el: wrap,
|
|
@@ -295,12 +323,13 @@ export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
|
|
|
295
323
|
// Returns `{ el, errorEl, dispose }` rather than appending anything itself —
|
|
296
324
|
// the caller still owns layout (where the drop target and error line sit
|
|
297
325
|
// relative to the field/button), only the wiring is shared.
|
|
298
|
-
export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommit, onRender }) {
|
|
326
|
+
export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommit, onRender, ambient = false }) {
|
|
299
327
|
const errorEl = el("div", "file-drop-error");
|
|
300
328
|
errorEl.hidden = true;
|
|
301
329
|
|
|
302
330
|
const drop = makeFileDrop({
|
|
303
331
|
kind,
|
|
332
|
+
ambient,
|
|
304
333
|
onAssetUpload,
|
|
305
334
|
onSource: (source) => {
|
|
306
335
|
errorEl.hidden = true;
|
|
@@ -86,7 +86,11 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog,
|
|
|
86
86
|
onCommit?.();
|
|
87
87
|
});
|
|
88
88
|
paintField();
|
|
89
|
-
|
|
89
|
+
// Same opt-in rule as image/vector: the URL box appears only for
|
|
90
|
+
// `sourceField: true`. This branch has no catalog button, so hiding it
|
|
91
|
+
// leaves the drop zone as the way in — which is why THIS branch keeps its
|
|
92
|
+
// labelled drop zone rather than going ambient like the catalog one below.
|
|
93
|
+
if (node.sourceField === true) wrap.append(field);
|
|
90
94
|
|
|
91
95
|
const drop = mountDrop("font", {
|
|
92
96
|
params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
|
|
@@ -146,7 +150,11 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog,
|
|
|
146
150
|
picker = openFontPicker?.({ node, params, allow, fontCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
|
|
147
151
|
});
|
|
148
152
|
|
|
149
|
-
|
|
153
|
+
// Ambient: this branch already has the catalog button as its visible way in, so
|
|
154
|
+
// the drop covers the control invisibly and reveals itself only while a file is
|
|
155
|
+
// over it. The no-catalog branch above stays labelled — there, the drop zone is
|
|
156
|
+
// the only affordance and hiding it would strand the user.
|
|
157
|
+
const drop = mountDrop("font", { params, node, onAssetUpload, onChange, onCommit, onRender: paint, ambient: true });
|
|
150
158
|
wrap.append(drop.el, drop.errorEl);
|
|
151
159
|
|
|
152
160
|
return {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { attachInfo } from "../info.js";
|
|
19
19
|
import { IMAGE_ALLOW_DEFAULT, imageSourceAllowed } from "../../image-source.js";
|
|
20
20
|
import { mountDrop } from "./file-drop.js";
|
|
21
|
+
import { declaredImageUrl } from "../declared-source.js";
|
|
21
22
|
|
|
22
23
|
function el(tag, className, text) {
|
|
23
24
|
const node = document.createElement(tag);
|
|
@@ -40,21 +41,70 @@ export function imageLabel(source) {
|
|
|
40
41
|
return file || source;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
// An object URL is a real resource, not a string: the browser pins the blob
|
|
45
|
+
// behind it until it is revoked, and a panel rebuild constructs a fresh widget
|
|
46
|
+
// every time. This owns the whole lifetime — one live URL at a time, the old one
|
|
47
|
+
// revoked before a new one replaces it, and everything released on dispose — so
|
|
48
|
+
// no caller has to remember. Returns `null` for a value that needs no URL.
|
|
49
|
+
function makeObjectUrlSlot() {
|
|
50
|
+
let current = null;
|
|
51
|
+
const release = () => {
|
|
52
|
+
if (current) URL.revokeObjectURL(current);
|
|
53
|
+
current = null;
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
forBytes(source) {
|
|
57
|
+
release();
|
|
58
|
+
if (!isBytes(source)) return null;
|
|
59
|
+
// Always image/png: `imageToPng` is what produced these bytes, whatever the
|
|
60
|
+
// user dropped. The type matters — a Blob with none renders nothing.
|
|
61
|
+
current = URL.createObjectURL(new Blob([source], { type: "image/png" }));
|
|
62
|
+
return current;
|
|
63
|
+
},
|
|
64
|
+
dispose: release,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Point (or unpoint) the live preview. A string source is used directly. Bytes —
|
|
69
|
+
// the partforge-cloud sandbox path, where the converted PNG travels in the param
|
|
70
|
+
// because that sandbox cannot fetch URLs — become an object URL, so the cloud
|
|
71
|
+
// gets the same thumbnail as everyone else rather than a blank tile. `onerror`
|
|
72
|
+
// still covers the remaining broken-image case: a URL that 404s or CORS refuses.
|
|
73
|
+
// Resolved ONCE per paint, never per image: the catalog rendering shows the same
|
|
74
|
+
// source in two <img>s, and asking the slot twice would revoke the URL it had
|
|
75
|
+
// just handed the first one, leaving it pointing at a dead blob.
|
|
76
|
+
function previewSrc(source, urls) {
|
|
77
|
+
return typeof source === "string" && source ? source : urls.forBytes(source);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// When the control's own param is empty, show what the PART is using: its
|
|
81
|
+
// bundled default lives in the `images` declaration, which is the only place it
|
|
82
|
+
// can live (the allow list passes only https, so a file:/dev URL cannot sit in
|
|
83
|
+
// `defaults`). Resolving it is async — a Vite thunk has to be called — so the
|
|
84
|
+
// tile paints empty first and fills in, and a source that never resolves simply
|
|
85
|
+
// leaves it empty. `seq` guards against a slow resolve landing after a newer one.
|
|
86
|
+
function paintDeclared(img, declaredSource, node, apply) {
|
|
87
|
+
if (!declaredSource) return;
|
|
88
|
+
const source = declaredSource("image", node.key);
|
|
89
|
+
if (source === undefined) return;
|
|
90
|
+
const seq = ++img._pfDeclaredSeq;
|
|
91
|
+
declaredImageUrl(source).then((url) => {
|
|
92
|
+
if (url && seq === img._pfDeclaredSeq) apply(url);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function applyPreview(img, src) {
|
|
97
|
+
if (src) {
|
|
49
98
|
img.hidden = false;
|
|
50
|
-
img.src =
|
|
99
|
+
img.src = src;
|
|
51
100
|
} else {
|
|
52
101
|
img.hidden = true;
|
|
53
102
|
img.removeAttribute("src");
|
|
54
103
|
}
|
|
55
104
|
}
|
|
56
105
|
|
|
57
|
-
export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload } = {}) {
|
|
106
|
+
export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload, declaredSource } = {}) {
|
|
107
|
+
const urls = makeObjectUrlSlot(); // one live preview URL per widget; see makeObjectUrlSlot
|
|
58
108
|
const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : IMAGE_ALLOW_DEFAULT;
|
|
59
109
|
const wrap = el("div", "slider");
|
|
60
110
|
const row = el("div", "row");
|
|
@@ -65,12 +115,12 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
65
115
|
|
|
66
116
|
const preview = document.createElement("img");
|
|
67
117
|
preview.className = "image-preview";
|
|
118
|
+
preview._pfDeclaredSeq = 0;
|
|
68
119
|
preview.alt = "";
|
|
69
120
|
preview.hidden = true;
|
|
70
121
|
// A URL that fails to load (404, CORS, revoked link) must degrade to hidden,
|
|
71
122
|
// not the browser's broken-image glyph.
|
|
72
123
|
preview.addEventListener("error", () => { preview.hidden = true; });
|
|
73
|
-
wrap.append(preview);
|
|
74
124
|
|
|
75
125
|
if (!imageCatalog) {
|
|
76
126
|
// Degraded path: a URL field. Unlike `text`, it does NOT write on every
|
|
@@ -88,7 +138,13 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
88
138
|
field.value = isBytes(v) ? "" : String(v ?? "");
|
|
89
139
|
field.placeholder = isBytes(v) ? "Uploaded image" : "";
|
|
90
140
|
field.classList.remove("warn");
|
|
91
|
-
|
|
141
|
+
const own = previewSrc(v, urls);
|
|
142
|
+
applyPreview(preview, own);
|
|
143
|
+
preview.parentElement?.classList.toggle("has-thumb", !preview.hidden);
|
|
144
|
+
if (!own) paintDeclared(preview, declaredSource, node, (url) => {
|
|
145
|
+
applyPreview(preview, url);
|
|
146
|
+
preview.parentElement?.classList.toggle("has-thumb", true);
|
|
147
|
+
});
|
|
92
148
|
};
|
|
93
149
|
field.addEventListener("change", () => {
|
|
94
150
|
if (!imageSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
|
|
@@ -98,14 +154,25 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
98
154
|
onCommit?.();
|
|
99
155
|
});
|
|
100
156
|
paintField();
|
|
101
|
-
|
|
157
|
+
// The URL box is OFF unless `sourceField: true`. The tile is already preview,
|
|
158
|
+
// drop target and click-to-choose in one, so on a 288 px rail a fourth
|
|
159
|
+
// affordance for the same job is the one earning its space least. Typing a
|
|
160
|
+
// source by hand is the rarer intent — a host token or an https URL someone
|
|
161
|
+
// already has — so it is the part that becomes opt-in, rather than the one
|
|
162
|
+
// every part pays rail height for.
|
|
163
|
+
if (node.sourceField === true) wrap.append(field);
|
|
102
164
|
|
|
103
165
|
const drop = mountDrop("image", {
|
|
104
166
|
params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
|
|
105
167
|
});
|
|
168
|
+
// The tile IS the preview: dropping, clicking to choose, and showing what is
|
|
169
|
+
// currently selected become one box rather than three stacked ones.
|
|
170
|
+
// `has-thumb` swaps the dashed empty-state border for a solid frame.
|
|
171
|
+
drop.el.setAttribute("data-pf-thumb", "");
|
|
172
|
+
drop.el.prepend(preview);
|
|
106
173
|
wrap.append(drop.el, drop.errorEl);
|
|
107
174
|
|
|
108
|
-
return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
|
|
175
|
+
return { el: wrap, sync: paintField, dispose: () => { drop.dispose(); urls.dispose(); } };
|
|
109
176
|
}
|
|
110
177
|
|
|
111
178
|
const btn = el("button", "image-btn");
|
|
@@ -131,8 +198,15 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
131
198
|
const paint = () => {
|
|
132
199
|
const src = params[node.key];
|
|
133
200
|
const seq = ++paintSeq;
|
|
134
|
-
|
|
135
|
-
|
|
201
|
+
const url = previewSrc(src, urls);
|
|
202
|
+
applyPreview(preview, url);
|
|
203
|
+
applyPreview(thumb, url);
|
|
204
|
+
preview.parentElement?.classList.toggle("has-thumb", !preview.hidden);
|
|
205
|
+
if (!url) paintDeclared(preview, declaredSource, node, (u) => {
|
|
206
|
+
applyPreview(preview, u);
|
|
207
|
+
applyPreview(thumb, u);
|
|
208
|
+
preview.parentElement?.classList.toggle("has-thumb", true);
|
|
209
|
+
});
|
|
136
210
|
const show = ({ label: text, width, height }) => {
|
|
137
211
|
if (seq !== paintSeq) return; // a newer paint already won
|
|
138
212
|
iname.textContent = width && height ? `${text} (${width}×${height})` : text;
|
|
@@ -162,12 +236,16 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
162
236
|
});
|
|
163
237
|
|
|
164
238
|
const drop = mountDrop("image", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
|
|
239
|
+
// Same merge as the degraded branch — the large preview lives in the drop tile;
|
|
240
|
+
// the catalog button keeps its own small thumb.
|
|
241
|
+
drop.el.setAttribute("data-pf-thumb", "");
|
|
242
|
+
drop.el.prepend(preview);
|
|
165
243
|
wrap.append(drop.el, drop.errorEl);
|
|
166
244
|
|
|
167
245
|
return {
|
|
168
246
|
el: wrap,
|
|
169
247
|
sync: paint,
|
|
170
|
-
dispose: () => { picker?.close(); picker = null; drop.dispose(); },
|
|
248
|
+
dispose: () => { picker?.close(); picker = null; drop.dispose(); urls.dispose(); },
|
|
171
249
|
};
|
|
172
250
|
}
|
|
173
251
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// A vector document -> a small inline <svg> preview, for the vector control's
|
|
2
|
+
// thumbnail. MAIN-THREAD ONLY (it builds DOM), but deliberately free of any
|
|
3
|
+
// heavy geometry import: `profile.js` is the one dependency and has no imports
|
|
4
|
+
// of its own. Reaching for `vector-format.js`'s expander instead would pull in
|
|
5
|
+
// contour-ops -> paper-bridge -> paper.js, ~1 MB of curve engine loaded on every
|
|
6
|
+
// page that merely SHOWS a vector control, whether or not anyone drops a file.
|
|
7
|
+
//
|
|
8
|
+
// `tessellateContour` is the geometry's OWN tessellator, which matters more than
|
|
9
|
+
// the saved bytes: a thumbnail that flattened curves its own way could show a
|
|
10
|
+
// shape the kernel would not build. Arcs are the specific trap — the format
|
|
11
|
+
// writes them as a point ON the arc, while SVG's `A` command wants radii and
|
|
12
|
+
// sweep flags, so "just map it to A" is a second interpretation waiting to
|
|
13
|
+
// diverge. At thumbnail size a tessellated arc is pixel-identical anyway.
|
|
14
|
+
import { tessellateContour } from "../../geometry/profile.js";
|
|
15
|
+
|
|
16
|
+
// Enough segments that a full circle reads as round at ~44 px, cheap enough that
|
|
17
|
+
// a document with hundreds of contours still renders in one frame.
|
|
18
|
+
const ARC_SEGS = 24;
|
|
19
|
+
|
|
20
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
21
|
+
|
|
22
|
+
const finite = (n) => typeof n === "number" && Number.isFinite(n);
|
|
23
|
+
const finitePoint = (p) => Array.isArray(p) && p.length >= 2 && finite(p[0]) && finite(p[1]);
|
|
24
|
+
|
|
25
|
+
// The four contour kinds, reduced to a ring of points. The three primitives are
|
|
26
|
+
// sugar the format defines by expansion; `toInternalDocument` normally does this,
|
|
27
|
+
// but it lives behind the paper.js import described above — and these expansions
|
|
28
|
+
// are four lines each, so the thumbnail does them directly rather than paying
|
|
29
|
+
// that cost. `path` delegates to the canonical tessellator.
|
|
30
|
+
function ring(contour) {
|
|
31
|
+
if (!contour || typeof contour !== "object") return null;
|
|
32
|
+
switch (contour.kind) {
|
|
33
|
+
case "circle": {
|
|
34
|
+
const { center: c, r } = contour;
|
|
35
|
+
if (!finitePoint(c) || !finite(r) || r <= 0) return null;
|
|
36
|
+
return Array.from({ length: ARC_SEGS }, (_, i) => {
|
|
37
|
+
const t = (i / ARC_SEGS) * Math.PI * 2;
|
|
38
|
+
return [c[0] + Math.cos(t) * r, c[1] + Math.sin(t) * r];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
case "rect": {
|
|
42
|
+
const { center: c, width: w, height: h } = contour;
|
|
43
|
+
if (!finitePoint(c) || !finite(w) || !finite(h) || w <= 0 || h <= 0) return null;
|
|
44
|
+
const hw = w / 2, hh = h / 2;
|
|
45
|
+
// Corner radius is ignored: at thumbnail scale the rounding is sub-pixel,
|
|
46
|
+
// and squaring it off never changes what the shape reads as.
|
|
47
|
+
return [[c[0] - hw, c[1] - hh], [c[0] + hw, c[1] - hh], [c[0] + hw, c[1] + hh], [c[0] - hw, c[1] + hh]];
|
|
48
|
+
}
|
|
49
|
+
case "polygon":
|
|
50
|
+
return Array.isArray(contour.points) && contour.points.length >= 3
|
|
51
|
+
&& contour.points.every(finitePoint) ? contour.points.map((p) => [p[0], p[1]]) : null;
|
|
52
|
+
case "path":
|
|
53
|
+
default: {
|
|
54
|
+
if (!finitePoint(contour.start) || !Array.isArray(contour.segments)) return null;
|
|
55
|
+
// The FILE format names an arc's midpoint `through`; the internal contour
|
|
56
|
+
// IR names it `via`, and that is what `tessellateContour` reads. A document
|
|
57
|
+
// read off disk or returned by `ingestSvg` therefore speaks `through`, and
|
|
58
|
+
// a segment with neither key is treated as a straight line — so skipping
|
|
59
|
+
// this rename does not fail loudly, it silently replaces every curve with
|
|
60
|
+
// its chord. A circle becomes a triangle, which looks like a rendering bug
|
|
61
|
+
// rather than a parsing one.
|
|
62
|
+
const segments = contour.segments.map((seg) =>
|
|
63
|
+
seg && seg.through && !seg.via ? { ...seg, via: seg.through } : seg);
|
|
64
|
+
const pts = tessellateContour({ ...contour, segments }, ARC_SEGS);
|
|
65
|
+
return Array.isArray(pts) && pts.length >= 3 && pts.every(finitePoint) ? pts : null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One subpath. Y is negated because the model frame is y-up and SVG is y-down;
|
|
71
|
+
// the viewBox is negated to match, so the flip is a coordinate convention rather
|
|
72
|
+
// than a transform the caller has to know about.
|
|
73
|
+
const subpath = (pts) =>
|
|
74
|
+
`M ${pts.map(([x, y], i) => `${i ? "L " : ""}${+x.toFixed(3)} ${+(-y).toFixed(3)}`).join(" ")} Z`;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render a partforge-vector document as an inline <svg>, or return `null` when
|
|
78
|
+
* there is nothing renderable — an empty document, or one whose coordinates are
|
|
79
|
+
* not finite. Returning null rather than throwing keeps a malformed document
|
|
80
|
+
* from taking the control down with it; the caller falls back to a placeholder.
|
|
81
|
+
*/
|
|
82
|
+
export function vectorThumb(doc) {
|
|
83
|
+
const shapes = doc?.shapes;
|
|
84
|
+
if (!shapes || typeof shapes !== "object") return null;
|
|
85
|
+
|
|
86
|
+
// Every region from every shape lands in ONE path so `evenodd` composes them:
|
|
87
|
+
// a `subtract` shape's regions then cut the shapes they overlap, which is what
|
|
88
|
+
// the document means. The known limitation is that two overlapping regions of
|
|
89
|
+
// the SAME role also cancel — real composition is a boolean the panel has no
|
|
90
|
+
// business running. At preview size that trade is invisible, and a missing hole
|
|
91
|
+
// would be far more misleading than a rare cancelled overlap.
|
|
92
|
+
const subpaths = [];
|
|
93
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
94
|
+
|
|
95
|
+
for (const shape of Object.values(shapes)) {
|
|
96
|
+
// A shape is either an array of regions, or `{ role, regions }` — §2.3.
|
|
97
|
+
const regions = Array.isArray(shape) ? shape : shape?.regions;
|
|
98
|
+
if (!Array.isArray(regions)) continue;
|
|
99
|
+
for (const region of regions) {
|
|
100
|
+
for (const contour of [region?.outer, ...(region?.holes ?? [])]) {
|
|
101
|
+
if (contour === undefined) continue;
|
|
102
|
+
const pts = ring(contour);
|
|
103
|
+
if (!pts) return null; // a bad coordinate anywhere means the preview would lie
|
|
104
|
+
for (const [x, y] of pts) {
|
|
105
|
+
if (x < minX) minX = x;
|
|
106
|
+
if (x > maxX) maxX = x;
|
|
107
|
+
if (y < minY) minY = y;
|
|
108
|
+
if (y > maxY) maxY = y;
|
|
109
|
+
}
|
|
110
|
+
subpaths.push(subpath(pts));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!subpaths.length) return null;
|
|
115
|
+
|
|
116
|
+
// `bbox` is optional and "recomputed when absent" (§2.1), so a hand-authored
|
|
117
|
+
// document routinely has none. Trusting it when present keeps the preview
|
|
118
|
+
// framed the way the document says it should be; deriving it otherwise is what
|
|
119
|
+
// makes an authored file previewable at all.
|
|
120
|
+
const b = doc.bbox;
|
|
121
|
+
const box = b && [b.minX, b.minY, b.maxX, b.maxY].every(finite)
|
|
122
|
+
? b : { minX, minY, maxX, maxY };
|
|
123
|
+
const w = box.maxX - box.minX, h = box.maxY - box.minY;
|
|
124
|
+
if (!(w > 0) || !(h > 0)) return null;
|
|
125
|
+
|
|
126
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
127
|
+
svg.setAttribute("viewBox", `${+box.minX.toFixed(3)} ${+(-box.maxY).toFixed(3)} ${+w.toFixed(3)} ${+h.toFixed(3)}`);
|
|
128
|
+
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
129
|
+
svg.setAttribute("aria-hidden", "true"); // decorative; the control carries the label
|
|
130
|
+
|
|
131
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
132
|
+
path.setAttribute("d", subpaths.join(" "));
|
|
133
|
+
path.setAttribute("fill-rule", "evenodd");
|
|
134
|
+
svg.append(path);
|
|
135
|
+
return svg;
|
|
136
|
+
}
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
import { attachInfo } from "../info.js";
|
|
20
20
|
import { VECTOR_ALLOW_DEFAULT, vectorSourceAllowed } from "../../vector-source.js";
|
|
21
21
|
import { mountDrop } from "./file-drop.js";
|
|
22
|
+
import { vectorThumb } from "./vector-thumb.js";
|
|
23
|
+
import { declaredVectorDoc } from "../declared-source.js";
|
|
22
24
|
|
|
23
25
|
function el(tag, className, text) {
|
|
24
26
|
const node = document.createElement(tag);
|
|
@@ -34,7 +36,7 @@ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
|
|
|
34
36
|
// byte-valued param.
|
|
35
37
|
const isOpaque = (v) => isBytes(v) || (v != null && typeof v === "object");
|
|
36
38
|
|
|
37
|
-
export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload } = {}) {
|
|
39
|
+
export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload, declaredSource } = {}) {
|
|
38
40
|
const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : VECTOR_ALLOW_DEFAULT;
|
|
39
41
|
const wrap = el("div", "slider");
|
|
40
42
|
const row = el("div", "row");
|
|
@@ -66,12 +68,53 @@ export function makeVector(node, params, { onChange, onCommit, info, onAssetUplo
|
|
|
66
68
|
onCommit?.();
|
|
67
69
|
});
|
|
68
70
|
paintField();
|
|
69
|
-
|
|
71
|
+
// The URL box is OFF unless `sourceField: true` — same reasoning as
|
|
72
|
+
// widgets/image.js: the tile already carries preview, drop and click-to-choose,
|
|
73
|
+
// and typing a source by hand is the rarer intent.
|
|
74
|
+
if (node.sourceField === true) wrap.append(field);
|
|
70
75
|
|
|
76
|
+
// The thumbnail IS the drop target. A vector param holds a parsed document, so
|
|
77
|
+
// there is no URL an <img> could point at — the artwork is drawn inline
|
|
78
|
+
// instead, and that tile is what a file is dropped on and what opens the file
|
|
79
|
+
// picker. One element doing all three keeps the rail's 300 px from carrying a
|
|
80
|
+
// preview, a drop zone and a button that all mean the same thing.
|
|
71
81
|
const drop = mountDrop("vector", {
|
|
72
|
-
params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
|
|
82
|
+
params, node, onAssetUpload, onChange, onCommit, onRender: () => { paintField(); paintThumb(); },
|
|
73
83
|
});
|
|
74
|
-
|
|
84
|
+
const thumb = drop.el;
|
|
85
|
+
thumb.setAttribute("data-pf-thumb", "");
|
|
75
86
|
|
|
76
|
-
|
|
87
|
+
// `vectorThumb` returns null for a document it cannot draw — malformed, empty,
|
|
88
|
+
// or carrying a coordinate that is not finite — rather than throwing. The tile
|
|
89
|
+
// stays either way, because it is the drop target: losing it on a bad document
|
|
90
|
+
// would strand the user with no way to replace it.
|
|
91
|
+
let thumbSeq = 0;
|
|
92
|
+
function showThumb(doc) {
|
|
93
|
+
const art = thumb.querySelector("svg");
|
|
94
|
+
if (art) art.remove();
|
|
95
|
+
const svg = doc ? vectorThumb(doc) : null;
|
|
96
|
+
thumb.classList.toggle("has-thumb", !!svg);
|
|
97
|
+
if (svg) thumb.prepend(svg);
|
|
98
|
+
}
|
|
99
|
+
function paintThumb() {
|
|
100
|
+
const own = params[node.key];
|
|
101
|
+
const seq = ++thumbSeq;
|
|
102
|
+
if (isOpaque(own)) { showThumb(own); return; }
|
|
103
|
+
showThumb(null);
|
|
104
|
+
// Nothing in the param — fall back to what the PART declares, which is where
|
|
105
|
+
// a bundled default has to live (the allow list passes only https, so a
|
|
106
|
+
// file:/dev URL cannot sit in `defaults`). Unlike an image there is nothing
|
|
107
|
+
// to point at: the document must be fetched and parsed before it can be
|
|
108
|
+
// drawn, so this lands a tick or two later, and a source that never resolves
|
|
109
|
+
// just leaves the tile empty — it stays a drop target either way.
|
|
110
|
+
const source = declaredSource?.("vector", node.key);
|
|
111
|
+
if (source === undefined) return;
|
|
112
|
+
declaredVectorDoc(source).then((doc) => { if (doc && seq === thumbSeq) showThumb(doc); });
|
|
113
|
+
}
|
|
114
|
+
paintThumb();
|
|
115
|
+
|
|
116
|
+
wrap.append(thumb, drop.errorEl);
|
|
117
|
+
|
|
118
|
+
const sync = () => { paintField(); paintThumb(); };
|
|
119
|
+
return { el: wrap, sync, dispose: () => drop.dispose() };
|
|
77
120
|
}
|
package/src/parts/emblem.js
CHANGED
|
@@ -27,10 +27,14 @@ import plate from "./assets/plate.vector.json" with { type: "json" };
|
|
|
27
27
|
|
|
28
28
|
export default {
|
|
29
29
|
meta: { title: "Emblem", units: "mm", background: 0x15181d },
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Function form, so the `type: "vector"` control below can drive the artwork.
|
|
31
|
+
// The bundled file is the default: dropping an SVG replaces it, clearing the
|
|
32
|
+
// control brings it back, and the part still builds with no network — which is
|
|
33
|
+
// what CI and `partforge measure` see.
|
|
34
|
+
vectors: (p) => ({
|
|
35
|
+
emblem: p.art || new URL("./assets/emblem.vector.json", import.meta.url),
|
|
32
36
|
plate,
|
|
33
|
-
},
|
|
37
|
+
}),
|
|
34
38
|
parameters: [
|
|
35
39
|
{
|
|
36
40
|
id: "plate",
|
|
@@ -45,15 +49,24 @@ export default {
|
|
|
45
49
|
id: "art",
|
|
46
50
|
title: "Artwork",
|
|
47
51
|
description: "The embossed vector art. `emblem.svg` carries a filled circle and a stroked bar, so both of ingest's geometry paths are exercised.",
|
|
48
|
-
advanced:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// Converted from the legacy `advanced` array to the new shape: a section is
|
|
53
|
+
// one or the other, never both, because mixing them makes render order
|
|
54
|
+
// arbitrary (partforge lint's `mixed-section-shape`). `advanced` becomes a
|
|
55
|
+
// nested group, which renders the same way.
|
|
56
|
+
controls: [
|
|
57
|
+
{ key: "art", type: "vector", label: "Source",
|
|
58
|
+
description: "Drop an SVG to replace the bundled emblem — it is converted to "
|
|
59
|
+
+ "partforge-vector once, in the browser, at drop time. Clear it to go back to the bundled file." },
|
|
60
|
+
{ type: "group", title: "Advanced", collapsed: "auto", controls: [
|
|
61
|
+
{ key: "emblem_w", label: "Emblem width", unit: "mm", min: 8, max: 70, step: 1,
|
|
62
|
+
description: "Width of the artwork's **tight bounding box** in mm — not its `viewBox`. Stroke thickness scales with it." },
|
|
63
|
+
{ key: "emboss", label: "Emboss height", unit: "mm", min: 0.4, max: 4, step: 0.2,
|
|
64
|
+
description: "How far the artwork stands proud of the plate." },
|
|
65
|
+
] },
|
|
53
66
|
],
|
|
54
67
|
},
|
|
55
68
|
],
|
|
56
|
-
defaults: { plate_t: 3, emblem_w: 30, emboss: 1 },
|
|
69
|
+
defaults: { art: "", plate_t: 3, emblem_w: 30, emboss: 1 },
|
|
57
70
|
parts: {
|
|
58
71
|
plate: {
|
|
59
72
|
label: "Plate",
|