partforge 0.50.0 → 0.52.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/README.md +4 -0
- package/docs/AUTHORING-PARTS.md +51 -1
- package/docs/ERROR-PATTERNS.md +46 -0
- package/docs/KERNEL-CONTRACT.md +5 -4
- package/package.json +1 -1
- package/src/app-screw.js +16 -0
- package/src/framework/chrome.css +68 -0
- package/src/framework/geometry/kernel-front.js +13 -0
- package/src/framework/geometry/kernel.js +2 -1
- package/src/framework/geometry/op-options.js +7 -0
- package/src/framework/geometry/screw-profile.js +88 -0
- package/src/framework/mount.js +33 -3
- package/src/framework/rail.js +19 -2
- package/src/parts/screw.js +82 -0
- package/src/screw-worker.js +3 -0
- package/types/kernel.d.ts +13 -0
package/README.md
CHANGED
|
@@ -114,6 +114,10 @@ runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
|
114
114
|
// built-in tab bar. null hands selection back.
|
|
115
115
|
runtime.setActive(false); // park the viewer: stop the render loop, release the
|
|
116
116
|
// drawing buffer. setActive(true) restores both.
|
|
117
|
+
runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the mount's
|
|
118
|
+
// shared hover tooltip (label = the button's title or
|
|
119
|
+
// aria-label, or a per-entry getLabel()); returns
|
|
120
|
+
// { sync, hide, detach }, auto-detached on dispose()
|
|
117
121
|
const off = runtime.onContextLost(() => {}); // WebGL context loss; returns an unsubscribe
|
|
118
122
|
runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
|
|
119
123
|
```
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -312,7 +312,8 @@ future contract v2 — but are not shown here; see `docs/KERNEL-CONTRACT.md`
|
|
|
312
312
|
| `k.roundedCylinder({ r\|d, h, center?, round })` | cylinder with rounded rims — `round` = number (both) or `{ top?, bottom? }`; `round: r` with `top+bottom = h` gives a sphere (capsule when `h > 2r`); one lathe revolve, curve-exact in STEP |
|
|
313
313
|
| `k.torus({ rMajor, rMinor })` | torus centered at the origin (tube centerline in z=0); `0 < rMinor < rMajor` |
|
|
314
314
|
| `k.revolve({ profile, degrees? })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
|
|
315
|
-
| `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
|
|
315
|
+
| `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove). **Not for threads** — the profile is always circular and rides a frenet frame that rolls with the helix, tilting a tooth off-axis. For threads use `k.screwSweep` |
|
|
316
|
+
| `k.screwSweep({ profile, pitch, turns, lefthand? })` | screw-motion sweep of an **axial** lathe profile `[[r, z], …]` (same convention as `k.revolve`) — threads, worms, helical ridges. `h = pitch · turns`. The profile's axial extent must not exceed `pitch`; a profile spanning exactly `pitch` must be **periodic** (first radius == last radius) and yields a complete threaded body with no boolean (both backends) |
|
|
316
317
|
| `k.union(solids[])` | boolean union |
|
|
317
318
|
|
|
318
319
|
**`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius, z, rotate?, scale? }`
|
|
@@ -1036,6 +1037,55 @@ const hole = k.cylinder({ r: 2, h: 20 }).translate([20, 0, 0]);
|
|
|
1036
1037
|
body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
|
|
1037
1038
|
```
|
|
1038
1039
|
|
|
1040
|
+
**Helical & threaded features** (screws, threads, bolts, worms, helical ridges):
|
|
1041
|
+
|
|
1042
|
+
Use `k.screwSweep({ profile, pitch, turns })`. The profile is an **axial**
|
|
1043
|
+
`[[r, z]]` contour — the shape you would see slicing the thread down its axis —
|
|
1044
|
+
exactly `k.revolve`'s convention, with an axial rise added.
|
|
1045
|
+
|
|
1046
|
+
The strongly preferred form is **periodic**: span exactly one `pitch`, start and
|
|
1047
|
+
end at the same radius. That makes the cross-section enclose the axis, so one op
|
|
1048
|
+
gives you the whole threaded body — no union with a core cylinder, which is both
|
|
1049
|
+
faster and avoids a boolean the B-rep backend handles badly
|
|
1050
|
+
([screw-thread-vanishes-on-occt](ERROR-PATTERNS.md#screw-thread-vanishes-on-occt)).
|
|
1051
|
+
|
|
1052
|
+
```js
|
|
1053
|
+
// an ISO-ish M10x1.5 external thread: 60° flanks, crest flat P/8, root flat P/4
|
|
1054
|
+
const pitch = 1.5, majorR = 5;
|
|
1055
|
+
const rootR = majorR - (5 / 8) * (Math.sqrt(3) / 2) * pitch;
|
|
1056
|
+
const crest = pitch / 8, root = pitch / 4;
|
|
1057
|
+
const rise = (pitch - crest - root) / 2;
|
|
1058
|
+
const rod = k.screwSweep({
|
|
1059
|
+
profile: [
|
|
1060
|
+
[rootR, 0],
|
|
1061
|
+
[rootR, root], // root flat
|
|
1062
|
+
[majorR, root + rise], // up the flank
|
|
1063
|
+
[majorR, root + rise + crest], // crest flat
|
|
1064
|
+
[rootR, pitch], // down the flank, back to the start radius
|
|
1065
|
+
],
|
|
1066
|
+
pitch, turns: 6,
|
|
1067
|
+
});
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
The ends are flat z-planes, which is what a threaded rod wants; intersect a cone
|
|
1071
|
+
for a lead-in chamfer. For a bolt, build the head as its own solid — that is what
|
|
1072
|
+
**`src/parts/screw.js`** does, the worked example for this recipe: an ISO-style
|
|
1073
|
+
metric bolt, periodic thread plus a hex head, presets and all.
|
|
1074
|
+
|
|
1075
|
+
Cost scales with `turns` (= `length / pitch`), and steeply: the section is
|
|
1076
|
+
resampled every 5° of the twist, so an M10×1.5 shank costs ~10.5k triangles per
|
|
1077
|
+
turn. A 30 mm shank is 20 turns and about half a second on Manifold; hundreds of
|
|
1078
|
+
turns is millions of triangles and minutes behind the STEP button. Bound the
|
|
1079
|
+
`length` and `pitch` your schema exposes accordingly.
|
|
1080
|
+
|
|
1081
|
+
The hand-rolled equivalent, for the record: `screwSweep` is
|
|
1082
|
+
`k.extrude({ profile, h, twist })` with the axial profile remapped to polar
|
|
1083
|
+
(`ψ = −360·z/pitch`) and `twist = 360 · turns` — one full turn of twist per pitch
|
|
1084
|
+
of height *is* screw motion. The op exists because that identity is easy to
|
|
1085
|
+
want and hard to find, and because the remap must be densified (see
|
|
1086
|
+
`geometry/screw-profile.js`) or the chords between profile points cut deep into
|
|
1087
|
+
the tooth.
|
|
1088
|
+
|
|
1039
1089
|
## 2-D booleans
|
|
1040
1090
|
|
|
1041
1091
|
`k.shape2d(profile)` lifts a point list, arc profile, or region into a `Shape2D` — an opaque 2-D boolean value. You can then compose booleans, and feed the result directly to `extrude` or `revolve` without materializing intermediate regions. The same `content-hash caching` discipline applies: identical arguments produce identical geometry.
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -361,6 +361,52 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
361
361
|
- **Cause:** `defaults[key]` is not among the control's `options` values — often a value-type mismatch (`12` is not `"12"`).
|
|
362
362
|
- **Fix:** Add the default to `options`, or change the default to one of the existing options; `npx partforge lint` errors via `select-default-not-in-options`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
|
|
363
363
|
|
|
364
|
+
## screw-thread-vanishes-on-occt
|
|
365
|
+
|
|
366
|
+
- **Symptom:** a threaded part previews correctly but its STEP export is a plain
|
|
367
|
+
cylinder, or a valid-looking but implausibly small STEP file (~2 KB, a few
|
|
368
|
+
dozen entities, where a real thread is megabytes) that opens with no solid
|
|
369
|
+
geometry; on the OCCT backend the union of a thread with a core returns
|
|
370
|
+
exactly the core's volume, or `0`, with no error thrown.
|
|
371
|
+
- **Cause:** the thread was built as a thin sub-pitch helical sliver and unioned
|
|
372
|
+
onto a core. OCCT's boolean fails on a near-self-touching swept operand and
|
|
373
|
+
silently returns the other operand — or nothing — rather than throwing.
|
|
374
|
+
- **Fix:** build the thread in the **periodic** form instead — a profile spanning
|
|
375
|
+
exactly one `pitch` with equal first and last radius encloses the axis, so
|
|
376
|
+
`k.screwSweep` yields the whole threaded body with no boolean at all. See
|
|
377
|
+
[AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Helical & threaded features".
|
|
378
|
+
|
|
379
|
+
The hazard is specific to that sliver-riding-a-core shape, not to unions
|
|
380
|
+
involving screw geometry in general: a filled periodic `screwSweep` rod
|
|
381
|
+
unioned with an unrelated solid — a bolt head, say — booleans correctly. A
|
|
382
|
+
measured rod (585.545) unioned with a head (804.248) returned 1324.732 —
|
|
383
|
+
inside the geometrically expected range, not the bare-rod or empty-solid
|
|
384
|
+
signature above. It's the thin near-self-touching sliver that OCCT's boolean
|
|
385
|
+
mishandles, not screw geometry as such.
|
|
386
|
+
|
|
387
|
+
## occt-bbox-too-large-on-twist
|
|
388
|
+
|
|
389
|
+
- **Symptom:** `solid.boundingBox()` inside a `build()` reports a solid far larger
|
|
390
|
+
than it is — on a twisted solid (`extrude`/`prism` with `twist`, or
|
|
391
|
+
`k.screwSweep`) whose true max radius is 5, OCCT reports **7.209** where
|
|
392
|
+
Manifold reports **5.000** — so anything placed off that query lands ~44% too
|
|
393
|
+
far out in the STEP export while the preview looks right. The axial extent is
|
|
394
|
+
exact; it is the twisted directions that inflate.
|
|
395
|
+
- **Cause:** OCCT derives the bounding box of a twisted B-spline surface from its
|
|
396
|
+
**control hull**, not from the surface. The control points of a twisted section
|
|
397
|
+
bow outward, so the box is a valid outer bound but a loose one. Volume and the
|
|
398
|
+
meshed surface are exact; only the bbox query is loose.
|
|
399
|
+
- **Fix:** don't place geometry off `solid.boundingBox()` on a twisted solid —
|
|
400
|
+
compute the extent from the parameters that built it (they are right there in
|
|
401
|
+
`p`/`d`), or bound the twisted part with an untwisted proxy solid.
|
|
402
|
+
|
|
403
|
+
**The `measure` / `verify` gate is not affected**: `src/framework/oracle/measure.js`
|
|
404
|
+
takes its bbox from `bounds(mesh.positions)` — the meshed surface — never from
|
|
405
|
+
`solid.boundingBox()`, so `bbox` assertions read 5.000 on both backends. The
|
|
406
|
+
exposure is a `build()` that queries a twisted solid's box itself, which is the
|
|
407
|
+
normal idiom for placing something relative to a solid and now silently disagrees
|
|
408
|
+
between the Manifold preview and the OCCT STEP export.
|
|
409
|
+
|
|
364
410
|
# Hardware library
|
|
365
411
|
|
|
366
412
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -131,9 +131,9 @@ for free.
|
|
|
131
131
|
| `loft` | `{rings, ruled?, closed?}` | `(rings, {ruled?,closed?})` |
|
|
132
132
|
| `sweep` | `{profile, path, closed?, cornerRadius?, ruled?, smooth?}` | `(profile2D, path3D, opts?)` |
|
|
133
133
|
|
|
134
|
-
`boredCylinder` and `
|
|
135
|
-
legacy form exists); they get the same unknown-key / required-key
|
|
136
|
-
the ops above.
|
|
134
|
+
`boredCylinder`, `helixSweptTube` and `screwSweep` were always options-only (no
|
|
135
|
+
positional legacy form exists); they get the same unknown-key / required-key
|
|
136
|
+
validation as the ops above.
|
|
137
137
|
`union(solids[])` and `toSTEP(named[])` take a single array — unchanged.
|
|
138
138
|
|
|
139
139
|
### Solid ops
|
|
@@ -186,7 +186,8 @@ above. All ops return a `Solid`.
|
|
|
186
186
|
| `revolve({profile, degrees?})` | Revolve a lathe profile `[[r, z], …]` (r ≥ 0) about Z; `degrees` < 360 gives a capped partial revolve. Default 360. |
|
|
187
187
|
| `loft({rings, ruled?, closed?})` | Stack polygon cross-sections (per-ring `z`/`rotate`/`scale`, equal vertex counts) with ruled walls and capped ends. Must self-correct a fully inverted result (CW rings / descending z) to an outward solid. |
|
|
188
188
|
| `sweep({profile, path, closed?, cornerRadius?, ruled?, smooth?})` | Sweep a fixed CCW profile along a polyline with a rotation-minimizing frame; sharp mitered corners, or `cornerRadius` fillets; capped ends. |
|
|
189
|
-
| `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). |
|
|
189
|
+
| `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). Circular profile on a frenet frame that rolls with the helix — **not for threads**; use `screwSweep`. |
|
|
190
|
+
| `screwSweep({profile, pitch, turns, lefthand})` | Screw-motion sweep of an axial lathe profile `[[r, z], …]` (r ≥ 0) — threads. The profile travels to `(r·cosθ, r·sinθ, z + pitch·θ/2π)`; `h = pitch · turns`. Axial extent must not exceed `pitch` or consecutive turns interpenetrate (throws). A profile spanning exactly `pitch` is **periodic**: first and last radius must agree, and it yields a complete threaded body needing no boolean. Compound: the polar-remapped, densified section extruded with `twist = 360 · turns`, exactly as composed in `kernel-front.js`; a backend may override only for caching, never for different geometry. Options-only. Parity: **within tolerance, not by construction** — both backends receive the identical densified polygon, but the mesh backend facets the twist at its own resolution while the B-rep backend builds an exact spline (`hull`'s parity class). |
|
|
190
191
|
| `union(solids[])` | Boolean union of one or more solids. |
|
|
191
192
|
| `text2d(string, {size, font?, align?, valign?, lineHeight?, tracking?, kerning?})` | Outline-font text → `Shape2D`. `size` = cap height (mm). `font` = declared name / inline bytes / default. Build-time; curve-exact on OCCT, faceted on Manifold. |
|
|
192
193
|
| `hull(inputs[])` | Convex hull of all inputs (each a `Shape2D`, a curve contour, or an `[[x,y],…]` point list) → a convex `Shape2D`. Backend-agnostic: a pure-JS monotone-chain hull over the inputs' sampled points (curved inputs tessellated at a fixed LOD), lifted via `shape2d` (see the parity note below). Throws on an empty input array or a degenerate (collinear/point-count < 3) hull. |
|
package/package.json
CHANGED
package/src/app-screw.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
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 screwPart from "./parts/screw.js";
|
|
7
|
+
import { mount } from "./framework/index.js";
|
|
8
|
+
|
|
9
|
+
// Dev-only example app for the screw reference part. `npm run dev`, then open /screw.html.
|
|
10
|
+
// The `new Worker(new URL(...))` call must stay inline here or Vite will not bundle it.
|
|
11
|
+
// Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
|
|
12
|
+
// the embedding contract (runtime.captureCurrent) the way an embedder would.
|
|
13
|
+
window.__pfRuntime = mount(screwPart, {
|
|
14
|
+
createWorker: (name) =>
|
|
15
|
+
new Worker(new URL("./screw-worker.js", import.meta.url), { type: "module", name }),
|
|
16
|
+
});
|
package/src/framework/chrome.css
CHANGED
|
@@ -79,6 +79,74 @@
|
|
|
79
79
|
.pf-rail-head, .pf-rail-foot { flex: none; }
|
|
80
80
|
.pf-rail-head { padding: 12px var(--pf-rail-pad); border-bottom: 1px solid var(--pf-border); }
|
|
81
81
|
.pf-rail-foot { padding: 12px var(--pf-rail-pad); border-top: 1px solid var(--pf-border); }
|
|
82
|
+
|
|
83
|
+
/* Foot ACTIONS, the counterpart to app.css's `#viewbar button`: any button a
|
|
84
|
+
host puts in the foot inherits the shared chrome, so a host-drawn control
|
|
85
|
+
sits beside a built-in one without restating the metrics — or, more to the
|
|
86
|
+
point, without re-deriving the states. Both hosts had independently written
|
|
87
|
+
the same padding/radius/mono stack, and only one of them had remembered to
|
|
88
|
+
exclude :disabled from :hover, so a dead button still lit up under the
|
|
89
|
+
pointer in the other. That bug is unreachable from here.
|
|
90
|
+
|
|
91
|
+
Buttons only; the ROW is the host's. A foot can hold a bare row
|
|
92
|
+
(partforge-cloud) or a labelled group (the demo pages' .dl/.dl-head), and a
|
|
93
|
+
container rule here would have to pick one. This mirrors the viewbar only
|
|
94
|
+
as far as the analogy holds: there, the pill itself is a single known
|
|
95
|
+
element partforge can own outright.
|
|
96
|
+
|
|
97
|
+
Base is the quiet outline treatment, because that is the safe thing to
|
|
98
|
+
inherit by accident; the loud one is opt-in via .pf-primary. Equal
|
|
99
|
+
specificity to app.css's `.dl-row button`, which is imported after this
|
|
100
|
+
file and therefore still wins — the legacy download row renders exactly as
|
|
101
|
+
before, and can drop .dl-row whenever it likes. */
|
|
102
|
+
.pf-rail-foot button {
|
|
103
|
+
flex: 1;
|
|
104
|
+
padding: 8px 0;
|
|
105
|
+
border: 1px solid var(--pf-border);
|
|
106
|
+
border-radius: var(--pf-radius-control);
|
|
107
|
+
background: transparent;
|
|
108
|
+
color: var(--pf-text-2);
|
|
109
|
+
font-family: var(--pf-mono);
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
font-size: 11px;
|
|
112
|
+
letter-spacing: 0.06em;
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
}
|
|
115
|
+
.pf-rail-foot button:hover:not(:disabled) {
|
|
116
|
+
border-color: var(--pf-accent);
|
|
117
|
+
color: var(--pf-text-strong);
|
|
118
|
+
}
|
|
119
|
+
.pf-rail-foot button:disabled { opacity: .45; cursor: default; }
|
|
120
|
+
|
|
121
|
+
/* The foot's main call to action — accent-filled, so it reads as the primary
|
|
122
|
+
thing to do with a finished part. */
|
|
123
|
+
.pf-rail-foot button.pf-primary {
|
|
124
|
+
border-color: var(--pf-accent);
|
|
125
|
+
background: var(--pf-accent);
|
|
126
|
+
color: var(--pf-on-accent);
|
|
127
|
+
}
|
|
128
|
+
.pf-rail-foot button.pf-primary:hover:not(:disabled) {
|
|
129
|
+
background: color-mix(in oklab, var(--pf-accent) 88%, #000);
|
|
130
|
+
border-color: color-mix(in oklab, var(--pf-accent) 88%, #000);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* A square icon-only action sitting beside the primary one. Sized to match
|
|
134
|
+
the viewbar's icon buttons, and flex-none so the primary action keeps the
|
|
135
|
+
remaining width rather than the two splitting it evenly. */
|
|
136
|
+
.pf-rail-foot button.pf-icon {
|
|
137
|
+
flex: 0 0 auto;
|
|
138
|
+
width: 34px;
|
|
139
|
+
display: inline-flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
justify-content: center;
|
|
142
|
+
color: var(--pf-muted);
|
|
143
|
+
}
|
|
144
|
+
.pf-rail-foot button.pf-icon:hover:not(:disabled) {
|
|
145
|
+
background: var(--pf-surface-2);
|
|
146
|
+
border-color: var(--pf-border);
|
|
147
|
+
color: var(--pf-text-2);
|
|
148
|
+
}
|
|
149
|
+
|
|
82
150
|
.pf-rail-body {
|
|
83
151
|
flex: 1;
|
|
84
152
|
min-height: 0;
|
|
@@ -29,6 +29,7 @@ import { beveledExtrude } from "./rim-bevel.js";
|
|
|
29
29
|
import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
|
|
30
30
|
import { convexHull, hullPoints } from "./hull.js";
|
|
31
31
|
import { latheRoundedRect, torusContour } from "./rounded-solids.js";
|
|
32
|
+
import { screwCrossSection } from "./screw-profile.js";
|
|
32
33
|
|
|
33
34
|
export function finishKernel(k) {
|
|
34
35
|
// Compound default: bored-through cylinder (tool overshoots 2 mm each end for
|
|
@@ -47,6 +48,18 @@ export function finishKernel(k) {
|
|
|
47
48
|
k.torus ??= ({ rMajor, rMinor }) =>
|
|
48
49
|
k.revolve({ profile: k.shape2d(torusContour(rMajor, rMinor)) });
|
|
49
50
|
|
|
51
|
+
// Compound default: a screw-motion sweep of an axial [[r, z]] profile. Exactly
|
|
52
|
+
// k.extrude with a polar-remapped section and one full turn of twist per pitch
|
|
53
|
+
// (see screw-profile.js for why that identity holds, and why the profile must be
|
|
54
|
+
// densified first). No backend override: both backends twist natively, so this
|
|
55
|
+
// is one implementation and STEP gets a real twisted B-rep rather than a loft.
|
|
56
|
+
k.screwSweep ??= ({ profile, pitch, turns, lefthand = false }) =>
|
|
57
|
+
k.extrude({
|
|
58
|
+
profile: screwCrossSection(profile, pitch, { lefthand }),
|
|
59
|
+
h: pitch * turns,
|
|
60
|
+
twist: (lefthand ? -360 : 360) * turns,
|
|
61
|
+
});
|
|
62
|
+
|
|
50
63
|
for (const [op, { toArgs, check }] of Object.entries(KERNEL_OP_SPECS)) {
|
|
51
64
|
const raw = k[op];
|
|
52
65
|
if (!raw) continue;
|
|
@@ -19,7 +19,7 @@ export const CONTRACT_VERSION = 1;
|
|
|
19
19
|
// Ops every backend kernel must implement.
|
|
20
20
|
export const KERNEL_OPS = [
|
|
21
21
|
"cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
|
|
22
|
-
"loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
|
|
22
|
+
"loft", "sweep", "helixSweptTube", "screwSweep", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
|
|
23
23
|
"roundedCylinder", "torus", "roundedBox",
|
|
24
24
|
];
|
|
25
25
|
|
|
@@ -110,6 +110,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
110
110
|
* @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted until v2
|
|
111
111
|
* @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
|
|
112
112
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
113
|
+
* @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
|
|
113
114
|
* @property {(solids:Solid[]) => Solid} union
|
|
114
115
|
* @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing)
|
|
115
116
|
* @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
|
|
@@ -253,6 +253,13 @@ export const KERNEL_OP_SPECS = {
|
|
|
253
253
|
boredCylinder: { toArgs: passThrough("boredCylinder", ["od", "h", "bore"], ["od", "h", "bore"]) },
|
|
254
254
|
helixSweptTube: { toArgs: passThrough("helixSweptTube",
|
|
255
255
|
["pathR", "profileR", "pitch", "turns", "z0", "lefthand"], ["pathR", "profileR", "pitch", "turns"]) },
|
|
256
|
+
screwSweep: {
|
|
257
|
+
toArgs: passThrough("screwSweep", ["profile", "pitch", "turns", "lefthand"], ["profile", "pitch", "turns"]),
|
|
258
|
+
check: (o) => {
|
|
259
|
+
if (!(o.pitch > 0)) throw new Error("screwSweep: pitch must be > 0");
|
|
260
|
+
if (!(o.turns > 0)) throw new Error("screwSweep: turns must be > 0");
|
|
261
|
+
},
|
|
262
|
+
},
|
|
256
263
|
roundedBox: { toArgs: roundedBoxArgs },
|
|
257
264
|
roundedCylinder: { toArgs: roundedCylinderArgs },
|
|
258
265
|
torus: { toArgs: torusArgs },
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Screw motion as a transverse cross-section. A profile point (r, z) in the axial
|
|
2
|
+
// half-plane travels to (r·cos θ, r·sin θ, z + pitch·θ/2π) under screw motion, and
|
|
3
|
+
// that whole solid is reproduced EXACTLY by extruding a polar-remapped section with
|
|
4
|
+
// twist = 360°·turns — one full turn of twist per pitch of height. So screwSweep
|
|
5
|
+
// needs no backend op: it is k.extrude in disguise (see kernel-front.js).
|
|
6
|
+
//
|
|
7
|
+
// The subtlety that makes this correct rather than nearly-correct: the map sends
|
|
8
|
+
// profile POINTS to polar, but the EDGES between them become straight chords where
|
|
9
|
+
// the true surface needs spiral arcs. Undensified, an ISO tooth loses ~42% of its
|
|
10
|
+
// volume. So every segment is subdivided to a fixed 5° polar step — fixed, not a
|
|
11
|
+
// per-call tolerance, so both backends see the identical polygon and the solid
|
|
12
|
+
// cache keys stay stable. "Every segment" includes the contour's implicit closing
|
|
13
|
+
// edge, except in the periodic case where that edge is a single polar point.
|
|
14
|
+
|
|
15
|
+
// Degrees of polar sweep per emitted point. Matches Manifold's twist division
|
|
16
|
+
// resolution (nDiv = ceil(|twist|/5) in manifold-backend.js), so the angular and
|
|
17
|
+
// axial sampling of the same solid agree. Converges to 0.03% of the exact volume.
|
|
18
|
+
export const SCREW_STEP_DEG = 5;
|
|
19
|
+
|
|
20
|
+
const EPS = 1e-9;
|
|
21
|
+
|
|
22
|
+
export function screwCrossSection(profile, pitch, { lefthand = false } = {}) {
|
|
23
|
+
if (!Array.isArray(profile) || profile.length < 2)
|
|
24
|
+
throw new Error("screwSweep: profile must be an array of at least 2 [r, z] points");
|
|
25
|
+
if (!(pitch > 0)) throw new Error("screwSweep: pitch must be > 0");
|
|
26
|
+
for (const p of profile) {
|
|
27
|
+
if (!Array.isArray(p) || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
|
|
28
|
+
throw new Error("screwSweep: every profile point must be a finite [r, z]");
|
|
29
|
+
if (p[0] < 0) throw new Error("screwSweep: profile radius must be ≥ 0");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const zs = profile.map(([, z]) => z);
|
|
33
|
+
const extent = Math.max(...zs) - Math.min(...zs);
|
|
34
|
+
if (extent > pitch + EPS)
|
|
35
|
+
throw new Error(
|
|
36
|
+
`screwSweep: profile axial extent ${extent} exceeds pitch ${pitch} — consecutive ` +
|
|
37
|
+
"turns would interpenetrate; reduce the profile height or increase pitch");
|
|
38
|
+
|
|
39
|
+
const n = profile.length;
|
|
40
|
+
const first = profile[0], last = profile[n - 1];
|
|
41
|
+
const periodic = extent > pitch - EPS;
|
|
42
|
+
|
|
43
|
+
// Subdivide by POLAR span, not by length: a segment with no z change sweeps no
|
|
44
|
+
// angle and needs no extra points.
|
|
45
|
+
const dense = [];
|
|
46
|
+
const densify = ([r0, z0], [r1, z1], { includeStart }) => {
|
|
47
|
+
const span = Math.abs((360 * (z1 - z0)) / pitch);
|
|
48
|
+
const steps = Math.max(1, Math.ceil(span / SCREW_STEP_DEG));
|
|
49
|
+
for (let j = includeStart ? 0 : 1; j < steps; j++)
|
|
50
|
+
dense.push([r0 + ((r1 - r0) * j) / steps, z0 + ((z1 - z0) * j) / steps]);
|
|
51
|
+
};
|
|
52
|
+
for (let i = 0; i < n - 1; i++) densify(profile[i], profile[i + 1], { includeStart: true });
|
|
53
|
+
dense.push(last);
|
|
54
|
+
|
|
55
|
+
// A profile spanning exactly one pitch closes on itself by periodicity: its last
|
|
56
|
+
// point maps to the same polar angle as its first, so it must agree in radius and
|
|
57
|
+
// the duplicate is dropped (a zero-length edge would otherwise reach the backend).
|
|
58
|
+
// Nothing is densified between them — they ARE the same polar point, and a
|
|
59
|
+
// densified edge would trace a spurious full circle back around the axis.
|
|
60
|
+
if (periodic) {
|
|
61
|
+
if (Math.abs(last[1] - first[1]) < pitch - EPS)
|
|
62
|
+
throw new Error(
|
|
63
|
+
`screwSweep: a full-pitch profile must start and end at its extreme z values — ` +
|
|
64
|
+
`the first and last points span ${Math.abs(last[1] - first[1])}, not the full ` +
|
|
65
|
+
`pitch ${pitch}; reorder the profile so it opens and closes on the wrap`);
|
|
66
|
+
if (Math.abs(first[0] - last[0]) > 1e-6)
|
|
67
|
+
throw new Error(
|
|
68
|
+
`screwSweep: a full-pitch profile must be periodic — first radius ${first[0]} ` +
|
|
69
|
+
`must equal last radius ${last[0]}`);
|
|
70
|
+
dense.pop();
|
|
71
|
+
} else {
|
|
72
|
+
// Sub-pitch: the contour's implicit closing edge (last → first) is a real edge
|
|
73
|
+
// spanning real polar angle, so it needs the same treatment as every other one.
|
|
74
|
+
// Undensified it is a straight chord across the unused part of the pitch, which
|
|
75
|
+
// turns a slim ridge into a twisted half-disc. `first` already opens the
|
|
76
|
+
// contour, so only the intermediate points are appended.
|
|
77
|
+
densify(last, first, { includeStart: false });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sign = lefthand ? 1 : -1;
|
|
81
|
+
return dense.map(([r, z]) => {
|
|
82
|
+
const psi = (sign * 2 * Math.PI * z) / pitch;
|
|
83
|
+
const x = r * Math.cos(psi);
|
|
84
|
+
let y = r * Math.sin(psi);
|
|
85
|
+
if (y === 0) y = 0; // Normalize -0 to 0
|
|
86
|
+
return [x, y];
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -5,7 +5,7 @@ import { attachViewerControls } from "./viewer-controls.js";
|
|
|
5
5
|
import { attachCutawayControls } from "./cutaway-controls.js";
|
|
6
6
|
import { attachRail } from "./rail.js";
|
|
7
7
|
import { attachMobileTabs } from "./mobile-tabs.js";
|
|
8
|
-
import { createTooltipPresenter } from "./tooltip.js";
|
|
8
|
+
import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
|
|
9
9
|
import { loadCamera } from "./view-state.js";
|
|
10
10
|
import { buildControls } from "./controls.js";
|
|
11
11
|
import { relevantParamKeys } from "./param-deps.js";
|
|
@@ -29,7 +29,11 @@ import { resolveDefaultView } from "./default-view.js";
|
|
|
29
29
|
|
|
30
30
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
31
31
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
32
|
-
|
|
32
|
+
// The default no-op tooltip binding, so a host can hold on to whatever
|
|
33
|
+
// attachTooltips returned without caring whether this mount resolved one.
|
|
34
|
+
const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {} };
|
|
35
|
+
|
|
36
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips }) {
|
|
33
37
|
return {
|
|
34
38
|
ready, dispose, setParams,
|
|
35
39
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -60,6 +64,12 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
60
64
|
// (partforge-cloud does, at the window level). Defaulted to a no-op so the
|
|
61
65
|
// handle's shape never depends on whether this mount resolved a rail.
|
|
62
66
|
setHostPane: setHostPane ?? (() => {}),
|
|
67
|
+
// Join host-owned chrome buttons to this mount's shared hover tooltip, so
|
|
68
|
+
// a host's own viewbar/rail-foot buttons match the built-in ones. Entries
|
|
69
|
+
// are [{ element, getLabel? }] (label falls back to the button's
|
|
70
|
+
// title/aria-label); returns { sync, hide, detach }. Same no-op default
|
|
71
|
+
// stance as setHostPane above.
|
|
72
|
+
attachTooltips: attachTooltips ?? (() => NOOP_TOOLTIP_BINDING),
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
75
|
|
|
@@ -116,6 +126,15 @@ function createCleanupStack() {
|
|
|
116
126
|
// runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
117
127
|
// // rail ('stage' | 'rail'), suppressing the
|
|
118
128
|
// // built-in tab bar. null hands selection back.
|
|
129
|
+
// runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the
|
|
130
|
+
// // mount's shared hover tooltip (the viewbar one).
|
|
131
|
+
// // Label = the button's title (or aria-label), or a
|
|
132
|
+
// // per-entry getLabel(); the title attribute is
|
|
133
|
+
// // absorbed while attached so it can't double up as a
|
|
134
|
+
// // native tooltip, and restored on detach. Returns
|
|
135
|
+
// // { sync, hide, detach } — call sync() after you
|
|
136
|
+
// // toggle a button's disabled state. Detached
|
|
137
|
+
// // automatically on dispose().
|
|
119
138
|
// runtime.setActive(false); // park the viewer: stop the render loop and release
|
|
120
139
|
// // both large GPU allocations (the drawing buffer and
|
|
121
140
|
// // the cached capture target). For a host that hides the
|
|
@@ -203,7 +222,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
203
222
|
cleanup.defer(() => cutawayChrome.detach());
|
|
204
223
|
// Resizable/collapsible controls rail. No-ops when the host lays out the
|
|
205
224
|
// framework itself (no #panel / no elements.rail).
|
|
206
|
-
const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell });
|
|
225
|
+
const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell, tooltip });
|
|
207
226
|
cleanup.defer(() => railChrome.detach());
|
|
208
227
|
// Narrow-layout pane tabs. Below RAIL_NARROW_BREAKPOINT the rail cannot sit
|
|
209
228
|
// beside the viewer, so exactly one pane shows and this bar picks it. Same
|
|
@@ -639,8 +658,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
639
658
|
}
|
|
640
659
|
};
|
|
641
660
|
|
|
661
|
+
// Host chrome buttons joining the mount's shared tooltip (the one the
|
|
662
|
+
// viewbar and cutaway buttons already use). Bindings are detached by
|
|
663
|
+
// dispose() via the cleanup stack; a host detaching earlier is fine —
|
|
664
|
+
// attachButtonTooltips.detach is idempotent.
|
|
665
|
+
const attachHostTooltips = (entries) => {
|
|
666
|
+
const binding = attachButtonTooltips(tooltip, entries);
|
|
667
|
+
cleanup.defer(() => binding.detach());
|
|
668
|
+
return binding;
|
|
669
|
+
};
|
|
670
|
+
|
|
642
671
|
return makeHandle({
|
|
643
672
|
ready, dispose, viewer, setParams,
|
|
673
|
+
attachTooltips: attachHostTooltips,
|
|
644
674
|
setHostPane: paneTabs.setHostPane,
|
|
645
675
|
getView: view, // () => tabsCtl.current()
|
|
646
676
|
setView: (name) => tabsCtl.select(name),
|
package/src/framework/rail.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
RAIL_DEFAULT_WIDTH, RAIL_MIN_WIDTH, RAIL_NARROW_BREAKPOINT,
|
|
3
3
|
clampRailWidth, railMaxWidth, readRailPref, resolveRailDrag, writeRailPref,
|
|
4
4
|
} from "./rail-state.js";
|
|
5
|
+
import { attachButtonTooltips } from "./tooltip.js";
|
|
5
6
|
|
|
6
7
|
const KEY_STEP = 16;
|
|
7
8
|
const KEY_STEP_SHIFT = 64;
|
|
@@ -75,7 +76,11 @@ function safeStorage() {
|
|
|
75
76
|
//
|
|
76
77
|
// Everything is optional. With no rail this returns a no-op, so hosts that lay
|
|
77
78
|
// the framework out themselves (see embed-test.html) are unaffected.
|
|
78
|
-
|
|
79
|
+
//
|
|
80
|
+
// `tooltip` is the mount's shared presenter (tooltip.js). With it, the toggle's
|
|
81
|
+
// Hide/Show-controls label renders as the same anchored pf-hover-tip the
|
|
82
|
+
// viewbar buttons use; without it, the label falls back to a native title.
|
|
83
|
+
export function attachRail({ rail, toggle, shell = rail?.parentElement, storage = safeStorage(), tooltip } = {}) {
|
|
79
84
|
if (!rail || !shell) {
|
|
80
85
|
// No rail to resolve in this document: --pf-rail-w still defaults to 288px
|
|
81
86
|
// from tokens.css, but nothing is reserving that space, so anything that
|
|
@@ -107,6 +112,12 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
107
112
|
toggle.replaceChildren(svg);
|
|
108
113
|
toggleChevron = chevron;
|
|
109
114
|
}
|
|
115
|
+
// Attached BEFORE the first apply() below writes an aria-label, so the
|
|
116
|
+
// binding's original-attribute capture (what its detach() restores) sees the
|
|
117
|
+
// host's own markup, not our first label.
|
|
118
|
+
const tooltipBinding = toggle && tooltip
|
|
119
|
+
? attachButtonTooltips(tooltip, [{ element: toggle }])
|
|
120
|
+
: null;
|
|
110
121
|
|
|
111
122
|
const seam = document.createElement("div");
|
|
112
123
|
seam.className = "pf-rail-seam";
|
|
@@ -157,8 +168,11 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
157
168
|
const label = collapsed ? "Show controls" : "Hide controls";
|
|
158
169
|
toggle.setAttribute("aria-expanded", String(!collapsed));
|
|
159
170
|
toggle.setAttribute("aria-label", label);
|
|
160
|
-
|
|
171
|
+
// The shared tooltip reads the aria-label at show time, so a native
|
|
172
|
+
// title would double up as a second, competing tooltip.
|
|
173
|
+
if (!tooltipBinding) toggle.title = label;
|
|
161
174
|
toggle.classList.toggle("on", collapsed);
|
|
175
|
+
tooltipBinding?.sync();
|
|
162
176
|
}
|
|
163
177
|
if (persist) writeRailPref(state, storage);
|
|
164
178
|
}
|
|
@@ -324,6 +338,9 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
324
338
|
shell.removeAttribute("data-pf-dragging");
|
|
325
339
|
rail.removeAttribute("inert");
|
|
326
340
|
root.style.removeProperty("--pf-rail-w");
|
|
341
|
+
// Before the attribute restore below: the binding's own detach rewrites
|
|
342
|
+
// title/aria-label from its capture, and ours must win.
|
|
343
|
+
tooltipBinding?.detach();
|
|
327
344
|
if (toggle) {
|
|
328
345
|
toggle.innerHTML = toggleOriginal.html;
|
|
329
346
|
toggle.title = toggleOriginal.title;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Reference part for k.screwSweep — an ISO-style metric bolt. The thread uses the
|
|
2
|
+
// PERIODIC profile form (spans exactly one pitch, first radius == last radius), so
|
|
3
|
+
// one screwSweep call yields the whole threaded shank with no boolean against a
|
|
4
|
+
// core. See docs/AUTHORING-PARTS.md "Helical & threaded features".
|
|
5
|
+
export default {
|
|
6
|
+
meta: { title: "Screw", units: "mm", background: 0x15181d },
|
|
7
|
+
parameters: [
|
|
8
|
+
{
|
|
9
|
+
id: "thread",
|
|
10
|
+
title: "Thread",
|
|
11
|
+
description: "Nominal thread size. Pick a preset, or open **Advanced** for exact dimensions.",
|
|
12
|
+
presets: { M6: { major: 6, pitch: 1.0, length: 20 }, M10: { major: 10, pitch: 1.5, length: 30 } },
|
|
13
|
+
advanced: [
|
|
14
|
+
{ key: "major", label: "Major diameter", unit: "mm", min: 4, max: 24, step: 0.5,
|
|
15
|
+
description: "Outside diameter measured across the thread crests." },
|
|
16
|
+
{ key: "pitch", label: "Pitch", unit: "mm", min: 0.5, max: 3, step: 0.05, control: "number",
|
|
17
|
+
description: "Axial rise per turn. A coarse pitch on a small major diameter runs the root radius down toward zero, which is why Major diameter starts at 4 mm. The 0.5 mm floor is the ISO fine pitch for the smallest diameter offered here — and a floor is needed, because cost scales with turns = length / pitch." },
|
|
18
|
+
{ key: "length", label: "Threaded length", unit: "mm", min: 5, max: 40, step: 1,
|
|
19
|
+
description: "Length of the threaded shank, excluding the head. Capped at 40 mm so the worst case reachable from these sliders — 40 mm at a 0.5 mm pitch, 80 turns — stays a couple of seconds of preview rather than minutes." },
|
|
20
|
+
{ key: "lefthand", label: "Left-hand thread", control: "toggle",
|
|
21
|
+
description: "Reverses the helix. Rare outside gas fittings and bicycle pedals." },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "head",
|
|
26
|
+
title: "Head",
|
|
27
|
+
description: "The hex head at the top of the shank.",
|
|
28
|
+
advanced: [
|
|
29
|
+
{ key: "headAcross", label: "Head width across flats", unit: "mm", min: 0, max: 40, step: 0.5,
|
|
30
|
+
description: "Spanner size. Zero gives a headless threaded rod." },
|
|
31
|
+
{ key: "headH", label: "Head height", unit: "mm", min: 1, max: 20, step: 0.5,
|
|
32
|
+
description: "Head thickness along the axis." },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
defaults: { major: 10, pitch: 1.5, length: 30, lefthand: false, headAcross: 17, headH: 6.4 },
|
|
37
|
+
// derive(): the ISO 60-degree tooth, expressed as radii the build consumes directly.
|
|
38
|
+
derive: (p) => {
|
|
39
|
+
const H = (Math.sqrt(3) / 2) * p.pitch; // sharp-V height
|
|
40
|
+
const majorR = p.major / 2;
|
|
41
|
+
const rootR = majorR - (5 / 8) * H;
|
|
42
|
+
const rootFlat = p.pitch / 4, crestFlat = p.pitch / 8;
|
|
43
|
+
return {
|
|
44
|
+
majorR, rootR, rootFlat, crestFlat,
|
|
45
|
+
rise: (p.pitch - crestFlat - rootFlat) / 2,
|
|
46
|
+
turns: p.length / p.pitch,
|
|
47
|
+
headR: p.headAcross / Math.sqrt(3), // circumradius of a hex across flats
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
parts: {
|
|
51
|
+
screw: {
|
|
52
|
+
label: "Screw",
|
|
53
|
+
views: ["screw"],
|
|
54
|
+
export: { name: "screw" },
|
|
55
|
+
build: (k, p, d) => {
|
|
56
|
+
// Periodic profile: exactly one pitch tall, first radius == last radius.
|
|
57
|
+
const shank = k.screwSweep({
|
|
58
|
+
profile: [
|
|
59
|
+
[d.rootR, 0],
|
|
60
|
+
[d.rootR, d.rootFlat],
|
|
61
|
+
[d.majorR, d.rootFlat + d.rise],
|
|
62
|
+
[d.majorR, d.rootFlat + d.rise + d.crestFlat],
|
|
63
|
+
[d.rootR, p.pitch],
|
|
64
|
+
],
|
|
65
|
+
pitch: p.pitch,
|
|
66
|
+
turns: d.turns,
|
|
67
|
+
lefthand: p.lefthand,
|
|
68
|
+
});
|
|
69
|
+
if (p.headAcross <= 0) return shank;
|
|
70
|
+
const head = k.prism({ points: hexPoints(d.headR), h: p.headH }).at([0, 0, p.length]);
|
|
71
|
+
return shank.union(head);
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
views: { screw: { label: "Screw" } },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const hexPoints = (r) =>
|
|
79
|
+
Array.from({ length: 6 }, (_, i) => {
|
|
80
|
+
const a = (Math.PI / 3) * i;
|
|
81
|
+
return [r * Math.cos(a), r * Math.sin(a)];
|
|
82
|
+
});
|
package/types/kernel.d.ts
CHANGED
|
@@ -298,6 +298,17 @@ export interface HelixSweptTubeOptions {
|
|
|
298
298
|
lefthand: boolean;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/** `k.screwSweep` — an axial lathe profile `[[r, z], …]` swept by screw motion. */
|
|
302
|
+
export interface ScrewSweepOptions {
|
|
303
|
+
/** Closed axial contour; axial extent must not exceed `pitch`. */
|
|
304
|
+
profile: number[][];
|
|
305
|
+
/** Axial rise per turn, mm. */
|
|
306
|
+
pitch: number;
|
|
307
|
+
/** Number of turns swept; total height is `pitch * turns`. Cost scales with it. */
|
|
308
|
+
turns: number;
|
|
309
|
+
lefthand?: boolean;
|
|
310
|
+
}
|
|
311
|
+
|
|
301
312
|
export interface RoundedCylinderOptions {
|
|
302
313
|
r?: number;
|
|
303
314
|
d?: number;
|
|
@@ -376,6 +387,8 @@ export interface GeometryKernel {
|
|
|
376
387
|
/** Sweep a 2-D profile along a 3-D polyline. */
|
|
377
388
|
sweep(o: SweepOptions): Solid;
|
|
378
389
|
helixSweptTube(o: HelixSweptTubeOptions): Solid;
|
|
390
|
+
/** Sweep an axial lathe profile by screw motion — threads. */
|
|
391
|
+
screwSweep(o: ScrewSweepOptions): Solid;
|
|
379
392
|
/** Rim round-overs via one lathe revolve; curve-exact in STEP. */
|
|
380
393
|
roundedCylinder(o: RoundedCylinderOptions): Solid;
|
|
381
394
|
torus(o: TorusOptions): Solid;
|