partforge 0.27.0 → 0.31.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 +16 -4
- package/docs/AUTHORING-PARTS.md +99 -7
- package/docs/ERROR-PATTERNS.md +1 -1
- package/package.json +5 -2
- package/src/app-bracket.js +5 -0
- package/src/app-demo.js +5 -0
- package/src/app-faceted-vase.js +5 -0
- package/src/app-filleted-box.js +5 -0
- package/src/app-hull-sweep.js +5 -0
- package/src/app-nameplate.js +5 -0
- package/src/app-planter.js +5 -0
- package/src/app-text-smoke.js +5 -0
- package/src/framework/app.css +85 -39
- package/src/framework/chrome.css +180 -0
- package/src/framework/debug-overlay.js +16 -1
- package/src/framework/download.js +14 -6
- package/src/framework/geometry/manifold-backend.js +10 -16
- package/src/framework/geometry/mesh-stl.js +27 -0
- package/src/framework/geometry/occt-backend.js +292 -92
- package/src/framework/geometry/pose.js +47 -0
- package/src/framework/mount.js +17 -3
- package/src/framework/rail-state.js +73 -0
- package/src/framework/rail.js +321 -0
- package/src/framework/tokens.css +14 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* Reusable chrome LAYOUT for a partforge app: the shell, the viewer column
|
|
2
|
+
("stage"), the full-height controls rail, its resize seam, and the two
|
|
3
|
+
floating pill groups. This file owns the shell's layout and its structural
|
|
4
|
+
surfaces — the rail's/head's/foot's background, border, shadow, and text
|
|
5
|
+
color, and the seam pill's background — all expressed through overridable
|
|
6
|
+
--pf-* tokens. Appearance of the controls INSIDE the rail (the panel
|
|
7
|
+
widgets themselves) stays in app.css.
|
|
8
|
+
|
|
9
|
+
Class-based on purpose. partforge-cloud's sandbox builds its own DOM
|
|
10
|
+
(#viewer / #pfc-controls) and could never reuse an id-keyed sheet, so the
|
|
11
|
+
layout is expressed as .pf-* classes and exported standalone as
|
|
12
|
+
"partforge/chrome.css". app.css keeps :not(.pf-*) fallbacks so legacy
|
|
13
|
+
id-only markup renders its previous floating look untouched.
|
|
14
|
+
|
|
15
|
+
Prerequisite: this sheet consumes --pf-* custom properties (--pf-rail-w,
|
|
16
|
+
--pf-rail-pad, --pf-border, --pf-surface, --pf-bg, --pf-text, --pf-muted,
|
|
17
|
+
--pf-shadow-rail) but does not import them — a standalone consumer must
|
|
18
|
+
also load "partforge/tokens.css" (kept separate so the two stay
|
|
19
|
+
independently composable). The host is also responsible for giving
|
|
20
|
+
.pf-shell a height (e.g. `height: 100%` on an ancestor chain rooted at
|
|
21
|
+
`html, body`, or `position: absolute; inset: 0`); this sheet does not size
|
|
22
|
+
the shell itself.
|
|
23
|
+
|
|
24
|
+
See docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md. */
|
|
25
|
+
|
|
26
|
+
/* ---- shell: viewer column + rail, side by side --------------------------- */
|
|
27
|
+
.pf-shell {
|
|
28
|
+
display: flex;
|
|
29
|
+
/* containing block for the absolutely-positioned seam */
|
|
30
|
+
position: relative;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ---- stage: the viewer column, which owns its floating chrome ------------
|
|
35
|
+
min-width: 0 lets the column shrink past the canvas's intrinsic width, so
|
|
36
|
+
dragging the rail wider actually narrows the viewer instead of overflowing. */
|
|
37
|
+
.pf-stage {
|
|
38
|
+
flex: 1;
|
|
39
|
+
position: relative;
|
|
40
|
+
min-width: 0;
|
|
41
|
+
background: var(--pf-bg);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ---- rail: a full-height right edge, set back from the viewer ------------
|
|
45
|
+
Square-cornered: it is an edge, not a card. The shadow is INSET on its left
|
|
46
|
+
side — the viewer casts onto the rail, which is what makes the rail read as
|
|
47
|
+
set back. An outer shadow would read as floating above the viewer. */
|
|
48
|
+
.pf-rail {
|
|
49
|
+
flex: none;
|
|
50
|
+
width: var(--pf-rail-w);
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
min-height: 0;
|
|
54
|
+
overflow: hidden;
|
|
55
|
+
background: var(--pf-surface);
|
|
56
|
+
border-left: 1px solid var(--pf-border);
|
|
57
|
+
box-shadow: var(--pf-shadow-rail);
|
|
58
|
+
color: var(--pf-text);
|
|
59
|
+
/* Discrete changes (toggle, Home/End, double-click) animate; a drag never
|
|
60
|
+
does — an animated width fights the pointer and costs an extra WebGL
|
|
61
|
+
buffer reallocation every frame. */
|
|
62
|
+
transition: width .15s ease;
|
|
63
|
+
}
|
|
64
|
+
.pf-rail[inert] { border-left-width: 0; }
|
|
65
|
+
|
|
66
|
+
/* Head and foot are flex-fixed rather than sticky, so the scroll container is
|
|
67
|
+
exactly .pf-rail-body. On a full-height rail the export buttons must never
|
|
68
|
+
scroll out of reach. */
|
|
69
|
+
.pf-rail-head, .pf-rail-foot { flex: none; }
|
|
70
|
+
.pf-rail-head { padding: 12px var(--pf-rail-pad); border-bottom: 1px solid var(--pf-border); }
|
|
71
|
+
.pf-rail-foot { padding: 12px var(--pf-rail-pad); border-top: 1px solid var(--pf-border); }
|
|
72
|
+
.pf-rail-body {
|
|
73
|
+
flex: 1;
|
|
74
|
+
min-height: 0;
|
|
75
|
+
overflow-y: auto;
|
|
76
|
+
overscroll-behavior: contain;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Rail-head title/subtitle typography — class-only (see this file's header:
|
|
80
|
+
no host-supplied id can be assumed), so a host that adopts this sheet with
|
|
81
|
+
its own ids (e.g. partforge-cloud) gets a correctly-shaped rail head with
|
|
82
|
+
correctly-styled text, not just a correctly-shaped empty box. Legacy
|
|
83
|
+
id-only markup (a bare <h1>/.sub with no .pf-rail-head wrapper) can't be
|
|
84
|
+
reached from here without an id selector, so app.css restates the same
|
|
85
|
+
two rules scoped to #panel for that one case — see the comment there. */
|
|
86
|
+
.pf-rail-head h1 {
|
|
87
|
+
font-size: 14px; margin: 0 0 2px; color: var(--pf-text-strong); letter-spacing: -0.01em;
|
|
88
|
+
}
|
|
89
|
+
.pf-rail-head .sub {
|
|
90
|
+
font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px;
|
|
91
|
+
letter-spacing: 0.04em; text-transform: uppercase;
|
|
92
|
+
margin: 2px 0 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---- resize seam --------------------------------------------------------
|
|
96
|
+
An OVERLAY, not a flex item. partforge-cloud's seam is a real 12px column
|
|
97
|
+
because its card is inset from the window with a gutter to live in; this rail
|
|
98
|
+
is flush against the viewer behind a hairline, so a flex item would open a
|
|
99
|
+
visible stripe of page background.
|
|
100
|
+
|
|
101
|
+
max(0px, …) is what parks the seam flush at the window edge when the rail is
|
|
102
|
+
collapsed, so a fresh drag can pull it back out. The element is created by
|
|
103
|
+
rail.js — no host markup declares it. */
|
|
104
|
+
.pf-rail-seam {
|
|
105
|
+
position: absolute;
|
|
106
|
+
top: 0; bottom: 0;
|
|
107
|
+
right: max(0px, calc(var(--pf-rail-w) - 6px));
|
|
108
|
+
z-index: 20;
|
|
109
|
+
width: 12px;
|
|
110
|
+
display: flex; align-items: center; justify-content: center;
|
|
111
|
+
touch-action: none;
|
|
112
|
+
cursor: ew-resize;
|
|
113
|
+
}
|
|
114
|
+
/* Collapsed, the only legal direction is left. */
|
|
115
|
+
.pf-rail-seam[data-collapsed] { cursor: w-resize; }
|
|
116
|
+
.pf-rail-seam:focus-visible { outline: none; }
|
|
117
|
+
/* Invisible at rest; the affordance is a short centred pill that appears only
|
|
118
|
+
on hover, keyboard focus, or during a drag. */
|
|
119
|
+
.pf-rail-seam > span {
|
|
120
|
+
pointer-events: none;
|
|
121
|
+
width: 3px; height: 100px; border-radius: 999px;
|
|
122
|
+
background: transparent;
|
|
123
|
+
transition: background-color .12s ease;
|
|
124
|
+
/* Nudges only the pill 5px off the divider hairline it otherwise sits flush
|
|
125
|
+
against — the seam's own 12px hit target stays centred on the boundary
|
|
126
|
+
(that line is what a user aims at to drag) and must NOT move. */
|
|
127
|
+
transform: translateX(-5px);
|
|
128
|
+
}
|
|
129
|
+
.pf-rail-seam:hover > span,
|
|
130
|
+
[data-pf-dragging] .pf-rail-seam > span { background: var(--pf-muted); }
|
|
131
|
+
/* Keyboard focus must read as distinct from hover/drag, not just present —
|
|
132
|
+
the accent pill, not the muted one, is the only thing that says "focus
|
|
133
|
+
landed here" for a keyboard user. */
|
|
134
|
+
.pf-rail-seam:focus-visible > span { background: var(--pf-accent); }
|
|
135
|
+
|
|
136
|
+
/* While dragging, the cursor must stay correct even when the pointer is out
|
|
137
|
+
over the viewer, and the viewer must not react to it. Pointer capture keeps
|
|
138
|
+
the events coming; these two rules are the second belt. */
|
|
139
|
+
[data-pf-dragging] { cursor: ew-resize; user-select: none; }
|
|
140
|
+
[data-pf-dragging] .pf-stage { pointer-events: none; }
|
|
141
|
+
[data-pf-dragging] .pf-rail,
|
|
142
|
+
[data-pf-key-resizing] .pf-rail { transition: none; }
|
|
143
|
+
|
|
144
|
+
/* ---- floating chrome: PLACEMENT ONLY, absolute within the stage ----------
|
|
145
|
+
Deliberately no appearance here. partforge-cloud's sandbox.css re-anchors
|
|
146
|
+
#viewbar's position with its own rules but inherits the pill's chrome
|
|
147
|
+
(background/border/radius/shadow) from app.css, so that chrome must live in
|
|
148
|
+
app.css ungated — not be duplicated into a class the cloud never sets. This
|
|
149
|
+
file owns where things sit; app.css owns what they look like. */
|
|
150
|
+
.pf-float-tabs, .pf-float-viewbar { position: absolute; z-index: 15; }
|
|
151
|
+
.pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
|
|
152
|
+
.pf-float-viewbar { bottom: 12px; right: 12px; }
|
|
153
|
+
|
|
154
|
+
/* ---- stacked layout: no room for a rail beside the viewer ----------------
|
|
155
|
+
The seam is hidden and resize is absent at this width (rail.js also refuses
|
|
156
|
+
to start a drag); the #rail-toggle still collapses and restores. */
|
|
157
|
+
@media (max-width: 719px) {
|
|
158
|
+
.pf-shell { flex-direction: column; }
|
|
159
|
+
.pf-rail {
|
|
160
|
+
width: auto; height: 45vh;
|
|
161
|
+
border-left: 0; border-top: 1px solid var(--pf-border);
|
|
162
|
+
box-shadow: none;
|
|
163
|
+
}
|
|
164
|
+
.pf-rail-seam { display: none; }
|
|
165
|
+
/* Collapsed, --pf-rail-w has no effect here (width is auto, height is 45vh),
|
|
166
|
+
so hide the rail outright — otherwise the toggle's first tap leaves a
|
|
167
|
+
full-size rail on screen that is inert and unresponsive. */
|
|
168
|
+
.pf-rail[inert] { display: none; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ---- reduced motion -----------------------------------------------------
|
|
172
|
+
Collapsing the rail slides 288px of layout across the screen — the first
|
|
173
|
+
layout-scale animation in the framework, and the kind of movement a
|
|
174
|
+
vestibular-sensitive user actually feels. Honour the preference: the rail
|
|
175
|
+
still collapses and still resizes, it just arrives instead of travelling.
|
|
176
|
+
Scoped to what this layout introduces; the pre-existing busy spinner is a
|
|
177
|
+
state indicator and is left alone. */
|
|
178
|
+
@media (prefers-reduced-motion: reduce) {
|
|
179
|
+
.pf-rail, .pf-rail-seam > span { transition: none; }
|
|
180
|
+
}
|
|
@@ -5,8 +5,23 @@
|
|
|
5
5
|
export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
|
|
6
6
|
const box = document.createElement("div");
|
|
7
7
|
box.id = "pf-debug";
|
|
8
|
+
// Left edge, below the top tab row: #viewbar is bottom-right and
|
|
9
|
+
// #pf-pick/#pf-pick-toast are bottom-left, so the top-left corner has no
|
|
10
|
+
// OTHER fixed element competing for it — but #topbar's tabs are centred in
|
|
11
|
+
// the stage, and the stage starts flush at the window's left edge, so a top
|
|
12
|
+
// offset of 12px (matching the tabs') is not actually free of them: at
|
|
13
|
+
// narrow/medium widths this ~240px-wide box reaches past the stage's
|
|
14
|
+
// horizontal centre and touches the tabs no matter which top corner it
|
|
15
|
+
// anchors to (measured — see .superpowers/sdd/debug-overlay-fix.md).
|
|
16
|
+
// #topbar's pill is a fixed 38px tall regardless of viewport or label width
|
|
17
|
+
// (12px top + 38px), so clearing it VERTICALLY is what's actually
|
|
18
|
+
// width-independent: sit below the tabs instead of racing them
|
|
19
|
+
// horizontally. That also drops any dependency on --pf-rail-w, so no media
|
|
20
|
+
// query is needed for the stacked layout below 720px.
|
|
21
|
+
// Positioned inline (not in chrome.css) because this box is dev-only chrome
|
|
22
|
+
// specific to partforge itself, never exported for a host to skin or reuse.
|
|
8
23
|
Object.assign(box.style, {
|
|
9
|
-
position: "fixed",
|
|
24
|
+
position: "fixed", top: "58px", left: "12px", zIndex: "9999",
|
|
10
25
|
font: "12px ui-monospace, monospace", background: "rgba(0,0,0,0.7)",
|
|
11
26
|
color: "#e6e6e6", padding: "8px 10px", borderRadius: "6px",
|
|
12
27
|
lineHeight: "1.5", whiteSpace: "pre",
|
|
@@ -2,9 +2,16 @@ import { zipSync } from "fflate";
|
|
|
2
2
|
|
|
3
3
|
// Browser file-download helpers. Pure DOM/Blob utilities with no app state — the
|
|
4
4
|
// worker produces the bytes; these just hand them to the browser as a download.
|
|
5
|
+
//
|
|
6
|
+
// `sink` is an optional escape hatch for embedders that cannot download from
|
|
7
|
+
// their own document — e.g. partforge running inside a null-origin sandboxed
|
|
8
|
+
// iframe, where a blob: URL is blob:null and browsers such as WebKit refuse to
|
|
9
|
+
// load it. When `sink` is supplied it receives the FINAL bytes and no DOM work
|
|
10
|
+
// happens here; the embedder saves them from a context that can.
|
|
5
11
|
|
|
6
|
-
// Trigger a download of one binary blob under `filename
|
|
7
|
-
export function triggerDownload(data, filename, mime) {
|
|
12
|
+
// Trigger a download of one binary blob under `filename` (or hand it to `sink`).
|
|
13
|
+
export function triggerDownload(data, filename, mime, sink) {
|
|
14
|
+
if (typeof sink === "function") { sink({ data, filename, mime }); return; }
|
|
8
15
|
const url = URL.createObjectURL(new Blob([data], { type: mime }));
|
|
9
16
|
const a = document.createElement("a");
|
|
10
17
|
a.href = url;
|
|
@@ -14,10 +21,11 @@ export function triggerDownload(data, filename, mime) {
|
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
// Download a set of built parts: a single part downloads directly; multiple parts
|
|
17
|
-
// are bundled into one flat, store-only (level 0) zip named `zipName`.
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
// are bundled into one flat, store-only (level 0) zip named `zipName`. `sink`, if
|
|
25
|
+
// given, is forwarded to triggerDownload so it receives the final bytes.
|
|
26
|
+
export function downloadParts({ parts, ext, mime }, zipName, sink) {
|
|
27
|
+
if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime, sink);
|
|
20
28
|
const entries = {};
|
|
21
29
|
for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
|
|
22
|
-
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip");
|
|
30
|
+
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip", sink);
|
|
23
31
|
}
|
|
@@ -8,6 +8,7 @@ import { addSugar } from "./solid-sugar.js";
|
|
|
8
8
|
import { addShape2dSugar } from "./shape2d-sugar.js";
|
|
9
9
|
import { assembleRegions } from "./shape2d-regions.js";
|
|
10
10
|
import { finishKernel } from "./kernel-front.js";
|
|
11
|
+
import { meshToStl } from "./mesh-stl.js";
|
|
11
12
|
|
|
12
13
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
13
14
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
@@ -363,21 +364,14 @@ function creasedNormals(g, sharpCos, featureLabels) {
|
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
function stlFromMesh(g) {
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
let
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const ux = b[0]-a[0], uy = b[1]-a[1], uz = b[2]-a[2];
|
|
375
|
-
const vx = c[0]-a[0], vy = c[1]-a[1], vz = c[2]-a[2];
|
|
376
|
-
const nx = uy*vz - uz*vy, ny = uz*vx - ux*vz, nz = ux*vy - uy*vx;
|
|
377
|
-
const L = Math.hypot(nx, ny, nz) || 1;
|
|
378
|
-
dv.setFloat32(o, nx/L, true); dv.setFloat32(o+4, ny/L, true); dv.setFloat32(o+8, nz/L, true); o += 12;
|
|
379
|
-
for (const p of [a, b, c]) for (const x of p) { dv.setFloat32(o, x, true); o += 4; }
|
|
380
|
-
dv.setUint16(o, 0, true); o += 2;
|
|
367
|
+
const vp = g.vertProperties, np = g.numProp;
|
|
368
|
+
const nVert = (vp.length / np) | 0;
|
|
369
|
+
let positions;
|
|
370
|
+
if (np === 3) {
|
|
371
|
+
positions = vp; // already x,y,z per vertex
|
|
372
|
+
} else {
|
|
373
|
+
positions = new Float32Array(nVert * 3);
|
|
374
|
+
for (let i = 0; i < nVert; i++) { positions[i*3] = vp[i*np]; positions[i*3+1] = vp[i*np+1]; positions[i*3+2] = vp[i*np+2]; }
|
|
381
375
|
}
|
|
382
|
-
return
|
|
376
|
+
return meshToStl(positions, g.triVerts);
|
|
383
377
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure-JS binary STL writer, shared by both geometry backends. Takes a flat
|
|
2
|
+
// vertex-position array (x,y,z per vertex) and triangle indices, and returns a
|
|
3
|
+
// binary STL ArrayBuffer. STL is a triangle-mesh format, so this is the one and
|
|
4
|
+
// only STL path — OCCT and Manifold both feed it a mesh. It deliberately does
|
|
5
|
+
// NOT touch Blobs: the sandbox worker on Safari cannot read a Blob, so every
|
|
6
|
+
// export must hand back a raw ArrayBuffer.
|
|
7
|
+
export function meshToStl(positions, indices) {
|
|
8
|
+
const n = (indices.length / 3) | 0;
|
|
9
|
+
const ab = new ArrayBuffer(84 + n * 50);
|
|
10
|
+
const dv = new DataView(ab);
|
|
11
|
+
dv.setUint32(80, n, true); // triangle count (80-byte header left zero)
|
|
12
|
+
let o = 84;
|
|
13
|
+
const P = (i) => [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]];
|
|
14
|
+
for (let i = 0; i < n; i++) {
|
|
15
|
+
const a = P(indices[i * 3]), b = P(indices[i * 3 + 1]), c = P(indices[i * 3 + 2]);
|
|
16
|
+
// Per-facet flat normal from the winding. Slicers recompute this, but some
|
|
17
|
+
// viewers (macOS Preview/Quick Look) render unlit if it's left zero.
|
|
18
|
+
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
|
|
19
|
+
const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
|
|
20
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
21
|
+
const L = Math.hypot(nx, ny, nz) || 1;
|
|
22
|
+
dv.setFloat32(o, nx / L, true); dv.setFloat32(o + 4, ny / L, true); dv.setFloat32(o + 8, nz / L, true); o += 12;
|
|
23
|
+
for (const p of [a, b, c]) for (const x of p) { dv.setFloat32(o, x, true); o += 4; }
|
|
24
|
+
dv.setUint16(o, 0, true); o += 2;
|
|
25
|
+
}
|
|
26
|
+
return ab;
|
|
27
|
+
}
|