partforge 0.41.0 → 0.45.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 +31 -10
- package/bin/cli.js +138 -27
- package/docs/AUTHORING-PARTS.md +164 -17
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +254 -0
- package/src/framework/animation.js +271 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/capture-build.js +59 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +30 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +441 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +147 -20
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/{testing → framework/oracle}/bvh.js +1 -1
- package/src/{testing → framework/oracle}/measure.js +1 -1
- package/src/{testing → framework/oracle}/min-wall.js +1 -1
- package/src/{testing → framework/oracle}/verify.js +3 -3
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +4 -4
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +35 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +139 -20
- package/src/framework/worker.js +5 -1
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +259 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +409 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/gaps.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
package/src/framework/app.css
CHANGED
|
@@ -217,6 +217,38 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
217
217
|
#viewbar .pf-cutaway-actions button { min-width: 44px; padding: 0 6px; }
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/* animation transport bar (placement: chrome.css's .pf-anim-bar). APPEARANCE
|
|
221
|
+
is ungated for the same reason as #viewbar above. */
|
|
222
|
+
.pf-anim-bar {
|
|
223
|
+
display: flex; align-items: center; gap: 8px;
|
|
224
|
+
padding: 6px 10px;
|
|
225
|
+
background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
226
|
+
border-radius: var(--pf-radius-control); box-shadow: var(--pf-shadow-float);
|
|
227
|
+
}
|
|
228
|
+
.pf-anim-bar button {
|
|
229
|
+
border: 0; background: transparent; color: var(--pf-muted);
|
|
230
|
+
cursor: pointer; font-size: 13px; padding: 2px 4px;
|
|
231
|
+
}
|
|
232
|
+
.pf-anim-bar button:hover { color: var(--pf-text-2); }
|
|
233
|
+
.pf-anim-title, .pf-anim-pick {
|
|
234
|
+
font-family: var(--pf-mono); font-size: 11px; color: var(--pf-text-2);
|
|
235
|
+
}
|
|
236
|
+
.pf-anim-pick {
|
|
237
|
+
background: var(--pf-input-bg); border: 1px solid var(--pf-border);
|
|
238
|
+
border-radius: var(--pf-radius-control); padding: 3px 6px;
|
|
239
|
+
}
|
|
240
|
+
.pf-anim-step {
|
|
241
|
+
font-family: var(--pf-mono); font-size: 10px; color: var(--pf-muted-2);
|
|
242
|
+
min-width: 90px; text-align: center; white-space: nowrap;
|
|
243
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
244
|
+
}
|
|
245
|
+
.pf-anim-scrub-wrap { position: relative; display: inline-flex; align-items: center; width: 140px; }
|
|
246
|
+
.pf-anim-scrub { width: 100%; accent-color: var(--pf-accent); }
|
|
247
|
+
.pf-anim-tick {
|
|
248
|
+
position: absolute; top: 50%; width: 2px; height: 8px; margin-top: -4px;
|
|
249
|
+
background: var(--pf-muted); pointer-events: none;
|
|
250
|
+
}
|
|
251
|
+
|
|
220
252
|
/* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
|
|
221
253
|
(bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
|
|
222
254
|
top-left floating #panel card. Legacy markup still floats #viewbar top-right
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { viewSubParts, resolveParams, buildPosed } from "./
|
|
1
|
+
import { viewSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
2
2
|
|
|
3
3
|
// Collision check for an assembled view: build each sub-part in its display
|
|
4
4
|
// (assembly) pose and return the pairs whose solid-intersection volume exceeds
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Chooses which geometry backend (manifold vs occt) a part should build against.
|
|
2
|
+
// This is framework-level policy, not geometry-kernel plumbing: it knows the full
|
|
3
|
+
// PartDefinition shape (meta.backend, defaults, parts[name].build), unlike
|
|
4
|
+
// everything in geometry/, which is part-agnostic.
|
|
5
|
+
import { OCCT_ONLY_OPS } from "./geometry/kernel.js";
|
|
6
|
+
import { createProbeKernel } from "./geometry/probe.js";
|
|
7
|
+
import { resolveDerived } from "./derive.js";
|
|
8
|
+
|
|
9
|
+
const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
|
|
10
|
+
|
|
11
|
+
export function detectBackend(part, params = {}) {
|
|
12
|
+
if (part.meta?.backend) return part.meta.backend;
|
|
13
|
+
const p = { ...part.defaults, ...params };
|
|
14
|
+
let d = {};
|
|
15
|
+
// A throwing derive must not escape here — this runs on the main thread mid
|
|
16
|
+
// regen (after the busy spinner goes up). Probe with an empty `d`; the worker
|
|
17
|
+
// build hits the same throw and posts a proper error for the UI.
|
|
18
|
+
try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
|
|
19
|
+
const { kernel, used } = createProbeKernel();
|
|
20
|
+
for (const name of Object.keys(part.parts)) {
|
|
21
|
+
try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
|
|
22
|
+
}
|
|
23
|
+
for (const op of used) if (OCCT_ONLY.has(op)) return "occt";
|
|
24
|
+
return "manifold";
|
|
25
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { EASINGS } from "./animation.js";
|
|
3
|
+
|
|
4
|
+
// Retargetable orbit-camera tween for animation camera cues: eased spherical
|
|
5
|
+
// interpolation of {position, target} pairs about the (linearly moving) orbit
|
|
6
|
+
// target, shortest-path in azimuth, clamped off the poles so OrbitControls
|
|
7
|
+
// never gimbal-locks on a "top"/"bottom" cue. Pure math, no clock — the viewer
|
|
8
|
+
// feeds dt seconds into update() each frame and applies the returned pose.
|
|
9
|
+
const POLE_EPS = 0.01;
|
|
10
|
+
|
|
11
|
+
function toSpherical(position, target) {
|
|
12
|
+
const off = new THREE.Vector3().fromArray(position).sub(new THREE.Vector3().fromArray(target));
|
|
13
|
+
const sph = new THREE.Spherical().setFromVector3(off);
|
|
14
|
+
sph.phi = Math.min(Math.PI - POLE_EPS, Math.max(POLE_EPS, sph.phi));
|
|
15
|
+
return sph;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createCameraTween() {
|
|
19
|
+
let tw = null; // { fromSph, toSph, fromTarget, toTarget, duration, elapsed, onComplete }
|
|
20
|
+
|
|
21
|
+
// `from` is always the CALLER's current pose, which is what makes a restart
|
|
22
|
+
// mid-flight retarget smoothly: the new tween begins wherever the camera is.
|
|
23
|
+
function start(from, to, { duration = 0.6, onComplete } = {}) {
|
|
24
|
+
const fromSph = toSpherical(from.position, from.target);
|
|
25
|
+
const toSph = toSpherical(to.position, to.target);
|
|
26
|
+
const d = toSph.theta - fromSph.theta;
|
|
27
|
+
if (d > Math.PI) toSph.theta -= 2 * Math.PI;
|
|
28
|
+
if (d < -Math.PI) toSph.theta += 2 * Math.PI;
|
|
29
|
+
tw = {
|
|
30
|
+
fromSph, toSph,
|
|
31
|
+
fromTarget: new THREE.Vector3().fromArray(from.target),
|
|
32
|
+
toTarget: new THREE.Vector3().fromArray(to.target),
|
|
33
|
+
duration, elapsed: 0, onComplete,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function update(dt) {
|
|
38
|
+
if (!tw) return null;
|
|
39
|
+
tw.elapsed += dt;
|
|
40
|
+
const done = tw.elapsed >= tw.duration;
|
|
41
|
+
const u = done ? 1 : EASINGS["ease-in-out"](tw.elapsed / tw.duration);
|
|
42
|
+
const lerp = (a, b) => a + (b - a) * u;
|
|
43
|
+
const sph = new THREE.Spherical(
|
|
44
|
+
lerp(tw.fromSph.radius, tw.toSph.radius),
|
|
45
|
+
lerp(tw.fromSph.phi, tw.toSph.phi),
|
|
46
|
+
lerp(tw.fromSph.theta, tw.toSph.theta),
|
|
47
|
+
);
|
|
48
|
+
const target = tw.fromTarget.clone().lerp(tw.toTarget, u);
|
|
49
|
+
const position = new THREE.Vector3().setFromSpherical(sph).add(target);
|
|
50
|
+
const onComplete = tw.onComplete;
|
|
51
|
+
if (done) tw = null;
|
|
52
|
+
const out = { position: position.toArray(), target: target.toArray(), done };
|
|
53
|
+
if (done) onComplete?.();
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { start, update, cancel: () => { tw = null; }, isActive: () => !!tw };
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Correlated one-shot geometry builds for captureView — a private channel that
|
|
2
|
+
// does NOT go through the regen loop. Same shape as export-controller's pending
|
|
3
|
+
// Map (export-controller.js): allocate a jobId, resolve when the matching
|
|
4
|
+
// reply arrives. Pure — no DOM, no worker; `send` is injected.
|
|
5
|
+
export function createCaptureBuild({ send }) {
|
|
6
|
+
let nextId = 1;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const pending = new Map(); // jobId -> resolve
|
|
9
|
+
|
|
10
|
+
function request({ subparts, view, params, backend }) {
|
|
11
|
+
// After teardown the workers are gone, so a fresh send would post to a terminated
|
|
12
|
+
// worker (a silent no-op) and its promise would hang forever. Resolve null instead
|
|
13
|
+
// — captureView's documented "disposed runtime resolves null" contract.
|
|
14
|
+
if (disposed) return Promise.resolve(null);
|
|
15
|
+
// String-namespaced ("cap-N") so a capture jobId can never collide with
|
|
16
|
+
// export-controller's numeric jobIds — both share the same worker message
|
|
17
|
+
// space, and exportCtl.handleMessage does a raw pending.get(m.jobId) before
|
|
18
|
+
// checking type, so a colliding id could otherwise settle the wrong promise.
|
|
19
|
+
const jobId = `cap-${nextId++}`;
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
pending.set(jobId, resolve);
|
|
22
|
+
// cache:true so the worker reuses its per-sub-part geometry memo (the
|
|
23
|
+
// expensive CSG); only the per-view place() + meshing re-run.
|
|
24
|
+
send({ type: "capture-generate", jobId, subparts, view, params, cache: true }, backend);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Returns true iff this message was a reply this controller owns (so the
|
|
29
|
+
// caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
|
|
30
|
+
// membership in `pending` first: the namespaced jobId guarantees another
|
|
31
|
+
// channel's message never matches, so a hit here is always ours. A failed
|
|
32
|
+
// build (the worker's shared catch posts a generic error/needs-occt, jobId
|
|
33
|
+
// intact) resolves to null rather than leaving the caller hanging forever —
|
|
34
|
+
// captureView treats null as "capture failed, skip".
|
|
35
|
+
function handleMessage(data) {
|
|
36
|
+
const jobId = data?.jobId;
|
|
37
|
+
if (jobId == null || !pending.has(jobId)) return false;
|
|
38
|
+
if (data.type === "capture-meshes") {
|
|
39
|
+
pending.get(jobId)(data.meshes);
|
|
40
|
+
} else if (data.type === "error" || data.type === "needs-occt") {
|
|
41
|
+
pending.get(jobId)(null);
|
|
42
|
+
} else {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
pending.delete(jobId);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Teardown / worker death: settle every in-flight request to null instead
|
|
50
|
+
// of leaving its promise permanently pending (a caller awaiting captureView
|
|
51
|
+
// across a viewer dispose must still get an answer, even a negative one).
|
|
52
|
+
function dispose() {
|
|
53
|
+
disposed = true;
|
|
54
|
+
for (const resolve of pending.values()) resolve(null);
|
|
55
|
+
pending.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { request, handleMessage, dispose };
|
|
59
|
+
}
|
package/src/framework/chrome.css
CHANGED
|
@@ -161,6 +161,14 @@
|
|
|
161
161
|
.pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
|
|
162
162
|
.pf-float-viewbar { bottom: 12px; right: 12px; }
|
|
163
163
|
|
|
164
|
+
/* --- animation transport bar (generated by animation-controls.js) ----------
|
|
165
|
+
PLACEMENT ONLY, per the rule above; appearance lives in app.css next to
|
|
166
|
+
#viewbar's, so a host that re-anchors this bar still inherits its chrome. */
|
|
167
|
+
.pf-anim-bar {
|
|
168
|
+
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
|
|
169
|
+
z-index: 15; max-width: calc(100% - 24px);
|
|
170
|
+
}
|
|
171
|
+
|
|
164
172
|
/* ---- the narrow-layout tab bar ------------------------------------------
|
|
165
173
|
Created by mobile-tabs.js (no host markup declares it). Hidden by default:
|
|
166
174
|
below the breakpoint the rail sits beside the viewer and needs no tab, so
|
|
@@ -229,6 +237,14 @@
|
|
|
229
237
|
}
|
|
230
238
|
.pf-rail-seam { display: none; }
|
|
231
239
|
.pf-tabbar { display: flex; }
|
|
240
|
+
/* Lift the transport bar clear of the viewbar. On a phone the bar is nearly
|
|
241
|
+
the full stage width (max-width: 100% - 24px) while #viewbar sits at the
|
|
242
|
+
bottom-right of the same stage, at the same z-index — so at bottom: 14px
|
|
243
|
+
the transport bar paints straight over cutaway/reframe/theme and
|
|
244
|
+
makes them unclickable. The viewbar occupies 12px…56px from the bottom
|
|
245
|
+
(12px offset + 4px padding + 34px button + 4px padding + 2px border);
|
|
246
|
+
64px clears it with an 8px gap. */
|
|
247
|
+
.pf-anim-bar { bottom: 64px; }
|
|
232
248
|
}
|
|
233
249
|
|
|
234
250
|
|
|
@@ -48,10 +48,20 @@ export function clampToRange(raw, min, max) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// --- info glyph + per-panel popover -----------------------------------------
|
|
51
|
+
// Popover top edge: below the glyph when it fits, flipped above when the
|
|
52
|
+
// viewport bottom would clip it (e.g. the animation transport bar's ⓘ, which
|
|
53
|
+
// sits at the bottom of the stage). Pure, for direct unit testing — happy-dom
|
|
54
|
+
// reports zero layout metrics, so the flip can't be exercised via the DOM.
|
|
55
|
+
export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight }) {
|
|
56
|
+
const below = glyphBottom + 6;
|
|
57
|
+
if (below + popHeight <= viewportHeight - 8) return below;
|
|
58
|
+
return Math.max(8, glyphTop - 6 - popHeight);
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
// One popover element per panel, shared by all its glyphs (only one open at a
|
|
52
62
|
// time). Document-level dismiss listeners are registered per panel and removed
|
|
53
63
|
// by panel.dispose().
|
|
54
|
-
function createInfoPopover() {
|
|
64
|
+
export function createInfoPopover() {
|
|
55
65
|
const pop = el("div", "popover");
|
|
56
66
|
pop.hidden = true;
|
|
57
67
|
document.body.append(pop);
|
|
@@ -78,7 +88,7 @@ function createInfoPopover() {
|
|
|
78
88
|
owner = glyph;
|
|
79
89
|
glyph.setAttribute("aria-expanded", "true");
|
|
80
90
|
const r = glyph.getBoundingClientRect();
|
|
81
|
-
pop.style.top = `${r.bottom
|
|
91
|
+
pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
|
|
82
92
|
pop.style.left = `${Math.max(8, r.left - 8)}px`;
|
|
83
93
|
},
|
|
84
94
|
dispose() {
|
|
@@ -91,7 +101,7 @@ function createInfoPopover() {
|
|
|
91
101
|
|
|
92
102
|
// Append a focusable ⓘ glyph to `container` that toggles the panel's shared
|
|
93
103
|
// popover with `description` (Markdown). No-op when description is empty.
|
|
94
|
-
function attachInfo(container, description, info) {
|
|
104
|
+
export function attachInfo(container, description, info) {
|
|
95
105
|
if (typeof description !== "string" || !description.trim()) return;
|
|
96
106
|
const glyph = document.createElement("button");
|
|
97
107
|
glyph.type = "button";
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
3
|
+
|
|
4
|
+
const GIZMO_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
|
|
5
|
+
const HANDLE_HOVER_THICKNESS = 1.6;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds the cutaway gizmo's three.js scene graph: the ghost plane, the
|
|
9
|
+
* translate arrow, the two rotation arcs, and the invisible hit proxies that
|
|
10
|
+
* stand in for them while picking.
|
|
11
|
+
*
|
|
12
|
+
* Pure construction - the objects come back detached, so mounting them into a
|
|
13
|
+
* scene, posing them, and reacting to the pointer all stay in
|
|
14
|
+
* `createCutawayGizmo`.
|
|
15
|
+
*
|
|
16
|
+
* `theme` is one palette out of the gizmo's theme table. Colors are baked in
|
|
17
|
+
* here rather than written as literals so the very first rendered frame
|
|
18
|
+
* already agrees with the palette that later appearance updates re-derive.
|
|
19
|
+
*/
|
|
20
|
+
export function buildGizmoScene(theme) {
|
|
21
|
+
const geometries = new Set();
|
|
22
|
+
const materials = new Set();
|
|
23
|
+
|
|
24
|
+
const group = new THREE.Group();
|
|
25
|
+
|
|
26
|
+
const fill = new THREE.Mesh(
|
|
27
|
+
new THREE.PlaneGeometry(1, 1),
|
|
28
|
+
new THREE.MeshBasicMaterial({
|
|
29
|
+
color: theme.fill,
|
|
30
|
+
opacity: 0.18,
|
|
31
|
+
transparent: true,
|
|
32
|
+
depthTest: true,
|
|
33
|
+
depthWrite: false,
|
|
34
|
+
side: THREE.DoubleSide,
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
geometries.add(fill.geometry);
|
|
38
|
+
materials.add(fill.material);
|
|
39
|
+
|
|
40
|
+
const borderGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
41
|
+
new THREE.Vector3(-0.5, -0.5, 0),
|
|
42
|
+
new THREE.Vector3(0.5, -0.5, 0),
|
|
43
|
+
new THREE.Vector3(0.5, 0.5, 0),
|
|
44
|
+
new THREE.Vector3(-0.5, 0.5, 0),
|
|
45
|
+
]);
|
|
46
|
+
const borderMaterial = new THREE.LineBasicMaterial({
|
|
47
|
+
color: theme.border,
|
|
48
|
+
opacity: 1,
|
|
49
|
+
transparent: true,
|
|
50
|
+
depthTest: false,
|
|
51
|
+
depthWrite: false,
|
|
52
|
+
});
|
|
53
|
+
const border = new THREE.LineLoop(borderGeometry, borderMaterial);
|
|
54
|
+
border.renderOrder = GIZMO_RENDER_ORDER;
|
|
55
|
+
geometries.add(borderGeometry);
|
|
56
|
+
materials.add(borderMaterial);
|
|
57
|
+
|
|
58
|
+
const handleRoot = new THREE.Group();
|
|
59
|
+
const translateVisualRoot = new THREE.Group();
|
|
60
|
+
const arcRoot = new THREE.Group();
|
|
61
|
+
const translateMaterial = new THREE.MeshBasicMaterial({
|
|
62
|
+
color: theme.translate,
|
|
63
|
+
transparent: true,
|
|
64
|
+
depthTest: true,
|
|
65
|
+
depthWrite: true,
|
|
66
|
+
});
|
|
67
|
+
const rotateXMaterial = new THREE.MeshBasicMaterial({
|
|
68
|
+
color: theme.rotateX,
|
|
69
|
+
transparent: true,
|
|
70
|
+
depthTest: true,
|
|
71
|
+
depthWrite: true,
|
|
72
|
+
});
|
|
73
|
+
const rotateYMaterial = new THREE.MeshBasicMaterial({
|
|
74
|
+
color: theme.rotateY,
|
|
75
|
+
transparent: true,
|
|
76
|
+
depthTest: true,
|
|
77
|
+
depthWrite: true,
|
|
78
|
+
});
|
|
79
|
+
materials.add(translateMaterial);
|
|
80
|
+
materials.add(rotateXMaterial);
|
|
81
|
+
materials.add(rotateYMaterial);
|
|
82
|
+
|
|
83
|
+
const shaftGeometry = new THREE.CylinderGeometry(0.025, 0.025, 0.58, 12);
|
|
84
|
+
const shaftHoverGeometry = new THREE.CylinderGeometry(
|
|
85
|
+
0.025 * HANDLE_HOVER_THICKNESS,
|
|
86
|
+
0.025 * HANDLE_HOVER_THICKNESS,
|
|
87
|
+
0.58,
|
|
88
|
+
12,
|
|
89
|
+
);
|
|
90
|
+
const shaft = new THREE.Mesh(shaftGeometry, translateMaterial);
|
|
91
|
+
shaft.rotation.x = Math.PI / 2;
|
|
92
|
+
shaft.position.z = 0.29;
|
|
93
|
+
const coneGeometry = new THREE.ConeGeometry(0.075, 0.2, 16);
|
|
94
|
+
const coneHoverGeometry = new THREE.ConeGeometry(
|
|
95
|
+
0.075 * HANDLE_HOVER_THICKNESS,
|
|
96
|
+
0.2,
|
|
97
|
+
16,
|
|
98
|
+
);
|
|
99
|
+
const cone = new THREE.Mesh(coneGeometry, translateMaterial);
|
|
100
|
+
cone.rotation.x = Math.PI / 2;
|
|
101
|
+
cone.position.z = 0.68;
|
|
102
|
+
geometries.add(shaftGeometry);
|
|
103
|
+
geometries.add(shaftHoverGeometry);
|
|
104
|
+
geometries.add(coneGeometry);
|
|
105
|
+
geometries.add(coneHoverGeometry);
|
|
106
|
+
|
|
107
|
+
const ringXGeometry = new THREE.TorusGeometry(
|
|
108
|
+
0.42,
|
|
109
|
+
0.015,
|
|
110
|
+
8,
|
|
111
|
+
64,
|
|
112
|
+
Math.PI,
|
|
113
|
+
);
|
|
114
|
+
const ringX = new THREE.Mesh(ringXGeometry, rotateXMaterial);
|
|
115
|
+
const ringXHoverGeometry = new THREE.TorusGeometry(
|
|
116
|
+
0.42,
|
|
117
|
+
0.015 * HANDLE_HOVER_THICKNESS,
|
|
118
|
+
8,
|
|
119
|
+
64,
|
|
120
|
+
Math.PI,
|
|
121
|
+
);
|
|
122
|
+
ringX.quaternion
|
|
123
|
+
.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2)
|
|
124
|
+
.multiply(new THREE.Quaternion().setFromAxisAngle(
|
|
125
|
+
new THREE.Vector3(0, 1, 0),
|
|
126
|
+
Math.PI / 2,
|
|
127
|
+
));
|
|
128
|
+
const ringYGeometry = new THREE.TorusGeometry(
|
|
129
|
+
0.42,
|
|
130
|
+
0.015,
|
|
131
|
+
8,
|
|
132
|
+
64,
|
|
133
|
+
Math.PI,
|
|
134
|
+
);
|
|
135
|
+
const ringY = new THREE.Mesh(ringYGeometry, rotateYMaterial);
|
|
136
|
+
const ringYHoverGeometry = new THREE.TorusGeometry(
|
|
137
|
+
0.42,
|
|
138
|
+
0.015 * HANDLE_HOVER_THICKNESS,
|
|
139
|
+
8,
|
|
140
|
+
64,
|
|
141
|
+
Math.PI,
|
|
142
|
+
);
|
|
143
|
+
ringY.rotation.x = -Math.PI / 2;
|
|
144
|
+
geometries.add(ringXGeometry);
|
|
145
|
+
geometries.add(ringXHoverGeometry);
|
|
146
|
+
geometries.add(ringYGeometry);
|
|
147
|
+
geometries.add(ringYHoverGeometry);
|
|
148
|
+
|
|
149
|
+
const hitMaterial = new THREE.MeshBasicMaterial({
|
|
150
|
+
color: 0xffffff,
|
|
151
|
+
opacity: 0,
|
|
152
|
+
transparent: true,
|
|
153
|
+
depthWrite: false,
|
|
154
|
+
});
|
|
155
|
+
materials.add(hitMaterial);
|
|
156
|
+
const translateHitGeometry = new THREE.CylinderGeometry(0.1, 0.1, 0.95, 10);
|
|
157
|
+
const translateHit = new THREE.Mesh(translateHitGeometry, hitMaterial);
|
|
158
|
+
translateHit.rotation.x = Math.PI / 2;
|
|
159
|
+
translateHit.position.z = 0.38;
|
|
160
|
+
translateHit.userData.cutawayHandle = "translate";
|
|
161
|
+
const rotateXHitGeometry = new THREE.TorusGeometry(
|
|
162
|
+
0.42,
|
|
163
|
+
0.12,
|
|
164
|
+
8,
|
|
165
|
+
48,
|
|
166
|
+
Math.PI,
|
|
167
|
+
);
|
|
168
|
+
const rotateXHit = new THREE.Mesh(rotateXHitGeometry, hitMaterial);
|
|
169
|
+
rotateXHit.quaternion.copy(ringX.quaternion);
|
|
170
|
+
rotateXHit.userData.cutawayHandle = "rotate-x";
|
|
171
|
+
const rotateYHitGeometry = new THREE.TorusGeometry(
|
|
172
|
+
0.42,
|
|
173
|
+
0.12,
|
|
174
|
+
8,
|
|
175
|
+
48,
|
|
176
|
+
Math.PI,
|
|
177
|
+
);
|
|
178
|
+
const rotateYHit = new THREE.Mesh(rotateYHitGeometry, hitMaterial);
|
|
179
|
+
rotateYHit.quaternion.copy(ringY.quaternion);
|
|
180
|
+
rotateYHit.userData.cutawayHandle = "rotate-y";
|
|
181
|
+
geometries.add(translateHitGeometry);
|
|
182
|
+
geometries.add(rotateXHitGeometry);
|
|
183
|
+
geometries.add(rotateYHitGeometry);
|
|
184
|
+
|
|
185
|
+
translateVisualRoot.add(shaft, cone);
|
|
186
|
+
arcRoot.add(ringX, ringY, rotateXHit, rotateYHit);
|
|
187
|
+
handleRoot.add(translateVisualRoot, translateHit, arcRoot);
|
|
188
|
+
group.add(fill, border);
|
|
189
|
+
|
|
190
|
+
const handles = {
|
|
191
|
+
translate: translateHit,
|
|
192
|
+
rotateX: rotateXHit,
|
|
193
|
+
rotateY: rotateYHit,
|
|
194
|
+
};
|
|
195
|
+
const handleVisuals = {
|
|
196
|
+
translate: translateVisualRoot,
|
|
197
|
+
rotateX: ringX,
|
|
198
|
+
rotateY: ringY,
|
|
199
|
+
};
|
|
200
|
+
const handleAppearance = {
|
|
201
|
+
translate: {
|
|
202
|
+
visual: translateVisualRoot,
|
|
203
|
+
material: translateMaterial,
|
|
204
|
+
geometryPairs: [
|
|
205
|
+
{ mesh: shaft, normal: shaftGeometry, hovered: shaftHoverGeometry },
|
|
206
|
+
{ mesh: cone, normal: coneGeometry, hovered: coneHoverGeometry },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
"rotate-x": {
|
|
210
|
+
visual: ringX,
|
|
211
|
+
material: rotateXMaterial,
|
|
212
|
+
geometryPairs: [
|
|
213
|
+
{ mesh: ringX, normal: ringXGeometry, hovered: ringXHoverGeometry },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
"rotate-y": {
|
|
217
|
+
visual: ringY,
|
|
218
|
+
material: rotateYMaterial,
|
|
219
|
+
geometryPairs: [
|
|
220
|
+
{ mesh: ringY, normal: ringYGeometry, hovered: ringYHoverGeometry },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Every geometry and material above - including the thickened hover variants
|
|
226
|
+
// that are swapped in and out of the meshes - is owned by this scene and
|
|
227
|
+
// nothing else. The caller disposes it exactly once, at teardown.
|
|
228
|
+
function dispose() {
|
|
229
|
+
for (const geometry of geometries) geometry.dispose();
|
|
230
|
+
for (const material of materials) material.dispose();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
group,
|
|
235
|
+
fill,
|
|
236
|
+
border,
|
|
237
|
+
handleRoot,
|
|
238
|
+
arcRoot,
|
|
239
|
+
handles,
|
|
240
|
+
handleVisuals,
|
|
241
|
+
handleAppearance,
|
|
242
|
+
dispose,
|
|
243
|
+
};
|
|
244
|
+
}
|