cozyclay 3.1.6 → 3.2.1
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/CHANGELOG.md +145 -0
- package/INSTALL_FOR_AGENTS.md +4 -4
- package/README.md +4 -2
- package/bin/agent/agent-failure-codes.mjs +34 -8
- package/bin/agent/agent-routes.mjs +6 -6
- package/bin/agent/agent-runner.mjs +25 -17
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -1
- package/bin/agent/openai-compatible-provider.mjs +24 -1
- package/bin/agent/studio-prompt.mjs +2 -2
- package/bin/agent/supervisor-followup.mjs +34 -0
- package/bin/agent/supervisor.mjs +6 -6
- package/bin/cozyclay.mjs +23 -60
- package/bin/star-prompt.mjs +15 -14
- package/bin/studio-routes.mjs +6 -3
- package/bin/telemetry-state.mjs +5 -7
- package/bin/update-check.mjs +61 -7
- package/bin/update-install.mjs +16 -3
- package/bin/update-restart.mjs +95 -0
- package/bin/update-routes.mjs +25 -3
- package/dist/app/index.html +13 -7
- package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
- package/dist/assets/analytics-CaTUuLPM.js +1 -0
- package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
- package/dist/assets/landing-2Fy7DU41.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
- package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
- package/dist/assets/task-board-UVGTZLRF.js +15 -0
- package/dist/assets/workflow-CSiBlX1s.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +4 -1
- package/dist/privacy/index.html +9 -6
- package/dist/sitemap.xml +2 -2
- package/dist/workflow/index.html +5 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +4 -4
- package/mcp/tool-handlers.mjs +14 -16
- package/package.json +3 -1
- package/src/App.jsx +42 -75
- package/src/analytics-controls.js +25 -3
- package/src/analytics-strict.js +16 -4
- package/src/analytics.js +32 -26
- package/src/app-version.js +13 -0
- package/src/asset-drop.js +39 -0
- package/src/asset-pane.css +34 -0
- package/src/asset-pane.jsx +4 -35
- package/src/bus-tasks.js +1 -1
- package/src/character-kind.js +8 -6
- package/src/click-friction.js +5 -4
- package/src/command-bus-telemetry.js +8 -7
- package/src/command-bus.js +2 -16
- package/src/commands/cast.js +2 -1
- package/src/commands/index.js +2 -1
- package/src/commands/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- package/src/commands/physics.js +21 -0
- package/src/commands/project.js +3 -3
- package/src/commands/sculpt-compare.js +53 -0
- package/src/content-tile.jsx +36 -0
- package/src/domains/cast.js +7 -6
- package/src/domains/motion.js +13 -14
- package/src/domains/scenes.js +41 -31
- package/src/execution-telemetry.js +7 -3
- package/src/export-menu-telemetry.js +26 -0
- package/src/finger-handles.jsx +128 -0
- package/src/friction-hint.js +82 -0
- package/src/live-control.js +2 -1
- package/src/locale-zh/panels.js +20 -0
- package/src/locale-zh/shell.js +4 -0
- package/src/locale-zh/workspace.js +6 -0
- package/src/motion/center-of-mass.js +5 -23
- package/src/motion/contact-project.js +143 -0
- package/src/motion/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/fix-collisions.js +1 -1
- package/src/motion/ik-drag.js +93 -1
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- package/src/motion/timeline.css +12 -0
- package/src/motion/timeline.jsx +2 -2
- package/src/motion3/compile-job.js +3 -2
- package/src/motion3/compiler/compile.js +3 -3
- package/src/motion3/compiler/head-lead.js +4 -2
- package/src/motion3/compiler/spring.js +95 -0
- package/src/motion3/compiler/validate-objects.js +18 -2
- package/src/motion3/compiler/validate-parts.js +1 -0
- package/src/motion3/compiler/vocab.js +1 -1
- package/src/motion3/fix/index.js +8 -4
- package/src/motion3/fix/person.js +110 -0
- package/src/motion3/free-motion.js +49 -11
- package/src/motion3/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- package/src/motion3/object-pose.js +44 -2
- package/src/motion3/people.js +16 -1
- package/src/motion3/physics-frames.js +72 -0
- package/src/motion3/physics-trail.js +45 -0
- package/src/motion3/physics-view.js +79 -0
- package/src/motion3/pipeline.js +5 -1
- package/src/motion3/qa/ballistic.js +67 -0
- package/src/motion3/qa/com.js +140 -0
- package/src/motion3/qa/ground.js +10 -3
- package/src/motion3/qa/hints-contact.js +21 -7
- package/src/motion3/qa/hints-motion.js +38 -1
- package/src/motion3/qa/key-checks.js +5 -4
- package/src/motion3/qa/patch.js +32 -4
- package/src/motion3/qa/people-checks.js +1 -1
- package/src/motion3/qa/spring-pivot.js +23 -0
- package/src/motion3/qa/style.js +13 -30
- package/src/motion3/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- package/src/motion3/schema/fields.js +4 -2
- package/src/motion3/schema/topics.js +15 -11
- package/src/motion3/schema/types.js +13 -9
- package/src/motion3/skills/catch.js +2 -1
- package/src/panels/FingerPanel.jsx +38 -0
- package/src/panels/RigControlPanel.jsx +9 -1
- package/src/panels/SculptPanel.jsx +12 -12
- package/src/panels/SculptPartTree.jsx +46 -0
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/panels/sculpt.css +11 -0
- package/src/physics-overlay-settings.js +41 -0
- package/src/physics-overlay.js +152 -0
- package/src/physics-overlay.jsx +54 -0
- package/src/physics-toggle.jsx +20 -0
- package/src/pose-fade.js +7 -3
- package/src/posestudio.jsx +15 -28
- package/src/project-browser.css +6 -0
- package/src/project-browser.jsx +9 -18
- package/src/project-start-choices.jsx +45 -0
- package/src/project.js +16 -12
- package/src/proxy-figure-shape.js +11 -0
- package/src/proxy-figure.jsx +25 -18
- package/src/scene-assets.js +17 -13
- package/src/scenes.js +22 -11
- package/src/sculpt-blueprint.js +56 -0
- package/src/sculpt-mode.js +5 -0
- package/src/sculpt-part-edit.js +115 -0
- package/src/sculpt-part-gizmo.jsx +159 -0
- package/src/sculpt-part-outline.jsx +72 -0
- package/src/sculpt-pick.js +53 -0
- package/src/sculpt-run-text.js +51 -0
- package/src/sculpt-run.js +10 -46
- package/src/sculpt-shelf-file.js +92 -0
- package/src/sculpt-shelf-tiles.jsx +69 -0
- package/src/sculpt-shelf.js +108 -0
- package/src/sculpt-stage.jsx +39 -2
- package/src/sculpt-thumb.js +72 -0
- package/src/sculpt-views.js +75 -0
- package/src/semantic-edit.js +0 -87
- package/src/settings-menu.jsx +2 -2
- package/src/shell/DetailsSlot.jsx +5 -6
- package/src/shell/LibrarySlot.jsx +8 -10
- package/src/shell/MenuBar.jsx +6 -6
- package/src/shell/PreferencesDialog.jsx +2 -3
- package/src/shell/ViewportToolbar.jsx +15 -18
- package/src/shell/add-character-entries.js +13 -0
- package/src/shell/pose-controls.js +32 -0
- package/src/shell/preference-pages.jsx +2 -11
- package/src/shell/storyboard-caption.js +21 -0
- package/src/shell/studio-shell-context.js +1 -0
- package/src/shortcut-friction.js +2 -1
- package/src/site/landing-analytics.js +28 -0
- package/src/studio-actions.js +3 -2
- package/src/studio-agent-protocol.js +4 -4
- package/src/studio-app-binding.js +4 -3
- package/src/update/UpdateChip.jsx +21 -2
- package/src/update/UpdateDialog.jsx +26 -6
- package/src/update/update-client.js +123 -17
- package/src/update/update-telemetry.js +15 -3
- package/src/use-sculpt-mode.js +62 -8
- package/src/workflow/agent-failure-codes.js +28 -9
- package/src/workflow/agent-turn-telemetry.js +1 -1
- package/src/workflow/task-board.js +2 -2
- package/tools/dev/page-shell.py +1 -1
- package/tools/dev/pages/greybox-to-video.html +21 -2
- package/tools/dev/pages/previs-software.html +3 -3
- package/tools/dev/pages/privacy.html +9 -6
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +53 -84
- package/tools/select-tests.mjs +119 -0
- package/tools/test-durations.json +455 -0
- package/tools/test-graph.mjs +181 -0
- package/tools/test-shards.mjs +78 -0
- package/tools/tutorial/README.md +74 -0
- package/tools/tutorial/assemble.mjs +43 -0
- package/tools/tutorial/captions.mjs +43 -0
- package/tools/tutorial/cdp.mjs +55 -0
- package/tools/tutorial/cursor.mjs +159 -0
- package/tools/tutorial/episode.mjs +173 -0
- package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
- package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
- package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
- package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
- package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
- package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
- package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
- package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
- package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
- package/tools/tutorial/episodes/cast.mjs +12 -0
- package/tools/tutorial/episodes/first-video.mjs +121 -0
- package/tools/tutorial/episodes/stub-tour.mjs +97 -0
- package/tools/tutorial/launch.mjs +63 -0
- package/tools/tutorial/mux.mjs +61 -0
- package/tools/tutorial/overlay.mjs +142 -0
- package/tools/tutorial/record.mjs +156 -0
- package/tools/tutorial/scenes/terminal.html +63 -0
- package/tools/tutorial/steps.mjs +111 -0
- package/tools/tutorial/terminal.mjs +43 -0
- package/tools/tutorial/voice.mjs +49 -0
- package/tools/update-test-durations.mjs +42 -0
- package/tools/verify-package-tarball.mjs +18 -12
- package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
- package/dist/assets/app-BLS1Zaru.js +0 -5144
- package/dist/assets/compile-worker-or7djI2v.js +0 -6
- package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
- package/dist/assets/task-board-CkLZGPkv.js +0 -15
- package/dist/assets/workflow-DJwOUzqJ.js +0 -179
package/src/scene-assets.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* adapter is the only part that needs a browser.
|
|
20
20
|
*/
|
|
21
21
|
import { isHdrAssetId, normalizeHdrAsset } from "./stage-hdr.js";
|
|
22
|
+
// A `sculpt-` id is a sculpt card on the shelf: the user's own library, never swept.
|
|
23
|
+
import { isSculptAssetId, normalizeSculptAsset } from "./sculpt-shelf-file.js";
|
|
22
24
|
|
|
23
25
|
/** Content-addressed, so the same bytes always land on the same id. */
|
|
24
26
|
export const ASSET_ID_PREFIX = "img-";
|
|
@@ -86,7 +88,7 @@ export function isMeshAssetId(value) {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export function isAssetId(value) {
|
|
89
|
-
return isImageAssetId(value) || isMeshAssetId(value) || isHdrAssetId(value);
|
|
91
|
+
return isImageAssetId(value) || isMeshAssetId(value) || isHdrAssetId(value) || isSculptAssetId(value);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
async function sha256Hex(bytes, subtle) {
|
|
@@ -152,6 +154,7 @@ export function normalizeAsset(record) {
|
|
|
152
154
|
if (!bytes || !bytes.byteLength) return null;
|
|
153
155
|
// An `hdr-` id is an HDR environment file (stage-hdr.js), stored verbatim.
|
|
154
156
|
if (isHdrAssetId(record.id)) return normalizeHdrAsset(record, bytes);
|
|
157
|
+
if (isSculptAssetId(record.id)) return normalizeSculptAsset(record, bytes);
|
|
155
158
|
// Prefix and MIME must agree: a mesh id with an image type (or the reverse)
|
|
156
159
|
// is a cross-wired record and cannot be drawn as either kind.
|
|
157
160
|
if (isMeshAssetId(record.id)) {
|
|
@@ -378,14 +381,23 @@ export async function deleteAssetWithGraphGuard({
|
|
|
378
381
|
return false;
|
|
379
382
|
}
|
|
380
383
|
|
|
381
|
-
|
|
384
|
+
// Stored ids that nothing points at any more, ready to be swept. A sculpt card
|
|
385
|
+
// is referenced by no scene on purpose: the shelf, not Manage storage, deletes it.
|
|
382
386
|
export function unreachableAssetIds(storedIds, scenes) {
|
|
383
387
|
const reachable = referencedAssetIds(scenes);
|
|
384
|
-
return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !reachable.has(id));
|
|
388
|
+
return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !isSculptAssetId(id) && !reachable.has(id));
|
|
385
389
|
}
|
|
386
390
|
|
|
387
391
|
/* ------------------------------------------------------------ storage ---- */
|
|
388
392
|
|
|
393
|
+
// A readwrite transaction settles on complete, error or abort.
|
|
394
|
+
const committed = (tx) =>
|
|
395
|
+
new Promise((resolve, reject) => {
|
|
396
|
+
tx.oncomplete = resolve;
|
|
397
|
+
tx.onerror = () => reject(tx.error);
|
|
398
|
+
tx.onabort = () => reject(tx.error);
|
|
399
|
+
});
|
|
400
|
+
|
|
389
401
|
const request = (req) =>
|
|
390
402
|
new Promise((resolve, reject) => {
|
|
391
403
|
req.onsuccess = () => resolve(req.result);
|
|
@@ -416,11 +428,7 @@ export async function putAsset(db, record) {
|
|
|
416
428
|
if (!asset) throw new TypeError("putAsset needs a normalizable asset record");
|
|
417
429
|
const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
|
|
418
430
|
tx.objectStore(ASSET_STORE_NAME).put(asset);
|
|
419
|
-
await
|
|
420
|
-
tx.oncomplete = resolve;
|
|
421
|
-
tx.onerror = () => reject(tx.error);
|
|
422
|
-
tx.onabort = () => reject(tx.error);
|
|
423
|
-
});
|
|
431
|
+
await committed(tx);
|
|
424
432
|
return asset;
|
|
425
433
|
}
|
|
426
434
|
|
|
@@ -440,10 +448,6 @@ export async function deleteAsset(db, id) {
|
|
|
440
448
|
if (!isAssetId(id)) return false;
|
|
441
449
|
const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
|
|
442
450
|
tx.objectStore(ASSET_STORE_NAME).delete(id);
|
|
443
|
-
await
|
|
444
|
-
tx.oncomplete = resolve;
|
|
445
|
-
tx.onerror = () => reject(tx.error);
|
|
446
|
-
tx.onabort = () => reject(tx.error);
|
|
447
|
-
});
|
|
451
|
+
await committed(tx);
|
|
448
452
|
return true;
|
|
449
453
|
}
|
package/src/scenes.js
CHANGED
|
@@ -52,10 +52,12 @@ export function isProxyFigure(character) {
|
|
|
52
52
|
export const DEFAULT_SUBJECT_ONE = "a young woman in a tan coat";
|
|
53
53
|
export const DEFAULT_SUBJECT_TWO = "a man in a dark coat";
|
|
54
54
|
|
|
55
|
+
// The one-character cast every scene used to open with. A new scene now
|
|
56
|
+
// starts on an empty stage; legacy documents that never stored a cast, and
|
|
57
|
+
// fixtures that want a body to work with, still seed this one.
|
|
58
|
+
const SEED_CHARACTER = Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE });
|
|
55
59
|
const DEFAULT_SCENE_STAGE = Object.freeze({
|
|
56
|
-
characters: Object.freeze([
|
|
57
|
-
Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE }),
|
|
58
|
-
]),
|
|
60
|
+
characters: Object.freeze([]),
|
|
59
61
|
hasCharSheet: false,
|
|
60
62
|
// The look reference for the location: a data URL so it survives save/load
|
|
61
63
|
environmentImage: null,
|
|
@@ -241,16 +243,17 @@ function migrateLegacyCast(source) {
|
|
|
241
243
|
return cast;
|
|
242
244
|
}
|
|
243
245
|
|
|
244
|
-
const STAGE_ENVELOPE_KEYS = new Set([
|
|
246
|
+
const LEGACY_CAST_KEYS = Object.freeze(["charA", "charB", "showB", "poseA", "poseB", "subject", "subject2"]), STAGE_ENVELOPE_KEYS = new Set([
|
|
245
247
|
"characters", "hasCharSheet", "environmentImage", "environment", "style", "hasEnvSheet", "shotAspect", "cameraPresetId", "sensorId", "keyLight", "lighting",
|
|
246
|
-
|
|
248
|
+
...LEGACY_CAST_KEYS,
|
|
247
249
|
]);
|
|
248
250
|
|
|
249
251
|
export function createSceneStage(stage = null) {
|
|
250
252
|
const source = plainObject(stage) ? stage : {};
|
|
251
|
-
|
|
253
|
+
// With no cast list, a pre-v3 envelope folds its fixed cast in; anything else is an empty stage.
|
|
254
|
+
const characters = Array.isArray(source.characters)
|
|
252
255
|
? source.characters.map((entry, index) => createCharacterEntry(entry, index))
|
|
253
|
-
: migrateLegacyCast(source);
|
|
256
|
+
: LEGACY_CAST_KEYS.some((key) => Object.hasOwn(source, key)) ? migrateLegacyCast(source) : [];
|
|
254
257
|
// Unknown extra keys (shotAspect was one, once) ride along so the envelope
|
|
255
258
|
// stays sealed-but-lossless; legacy cast keys are deliberately dropped.
|
|
256
259
|
const extras = {};
|
|
@@ -359,8 +362,13 @@ export function activeScene(scenes, activeSceneId) {
|
|
|
359
362
|
return index < 0 ? null : scenes[index];
|
|
360
363
|
}
|
|
361
364
|
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
// The stage a scene opened with before empty stages: one standing Y Bot.
|
|
366
|
+
export const createSeededSceneStage = () => createSceneStage({ characters: [SEED_CHARACTER] });
|
|
367
|
+
|
|
368
|
+
// A one-scene document on an empty stage. `{ seedCharacter: true }` gives
|
|
369
|
+
// the scene the old one-character cast instead.
|
|
370
|
+
export function createSceneDocument({ seedCharacter = false } = {}) {
|
|
371
|
+
const scene = { ...createScene(), ...(seedCharacter === true ? { stage: createSeededSceneStage() } : {}) };
|
|
364
372
|
return { version: SCENES_VERSION, activeSceneId: scene.id, scenes: [scene] };
|
|
365
373
|
}
|
|
366
374
|
|
|
@@ -410,7 +418,8 @@ export function readSceneDocument(raw, legacyRaw = null) {
|
|
|
410
418
|
if (!plainObject(legacy) || legacy.version !== 1 || !Array.isArray(legacy.objects)) {
|
|
411
419
|
return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: legacyRaw };
|
|
412
420
|
}
|
|
413
|
-
|
|
421
|
+
// A v1 scene had no stored cast; it always opened with the seed character.
|
|
422
|
+
const document = createSceneDocument({ seedCharacter: true });
|
|
414
423
|
document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object)));
|
|
415
424
|
return { status: "migrated", document, dropped: legacy.objects.length - document.scenes[0].objects.length };
|
|
416
425
|
}
|
|
@@ -432,7 +441,9 @@ export function readSceneDocument(raw, legacyRaw = null) {
|
|
|
432
441
|
// v4 → v5 needs no step of its own: every cast member passes through
|
|
433
442
|
// createCharacterEntry, which fills the missing posture with "stand".
|
|
434
443
|
const legacyClock = payload.version < 4;
|
|
435
|
-
|
|
444
|
+
// Before v3 the cast lived on one global stage, and a document without one
|
|
445
|
+
// opened with the seed character.
|
|
446
|
+
const repaired = repairDocument(payload, payload.version < 3 && !plainObject(payload.stage) ? createSeededSceneStage() : createSceneStage(payload.stage), legacyClock);
|
|
436
447
|
return { status: migrating ? "migrated" : "valid", ...repaired };
|
|
437
448
|
}
|
|
438
449
|
|
package/src/sculpt-blueprint.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// reference picture into an orthographic model sheet, the drawing a sculpt is
|
|
3
3
|
// built from. The template names no subject: everything subject-specific comes
|
|
4
4
|
// from the attached reference and the operator's optional notes.
|
|
5
|
+
import { referenceMask } from "./sculpt-compare.js";
|
|
5
6
|
|
|
6
7
|
/** The sheet's fixed layout, as fractions of the image: a 2 x 2 grid. FRONT and
|
|
7
8
|
* SIDE share the top row so their head and foot lines are the same pixels;
|
|
@@ -63,3 +64,58 @@ export function blueprintErrorMessage(error, fallback) {
|
|
|
63
64
|
}
|
|
64
65
|
return fallback;
|
|
65
66
|
}
|
|
67
|
+
|
|
68
|
+
/** The camera yaw each compared view was drawn from (sculpt-compare.js: 0 in
|
|
69
|
+
* front, positive toward the subject's own left). TOP looks straight down and is
|
|
70
|
+
* cropped but not compared: sculpt.compare's elevation stops at 60 degrees. */
|
|
71
|
+
export const BLUEPRINT_VIEW_YAWS = Object.freeze({ front: 0, side: 90, back: 180 });
|
|
72
|
+
|
|
73
|
+
/** One panel of an RGBA sheet, cut on whole pixels from the layout fractions. */
|
|
74
|
+
export function cropBlueprintPanel(image, { x, y, width, height }) {
|
|
75
|
+
const left = Math.round(x * image.width), top = Math.round(y * image.height);
|
|
76
|
+
const w = Math.round((x + width) * image.width) - left, h = Math.round((y + height) * image.height) - top;
|
|
77
|
+
const data = new Uint8Array(w * h * 4);
|
|
78
|
+
for (let row = 0; row < h; row += 1) {
|
|
79
|
+
const start = ((top + row) * image.width + left) * 4;
|
|
80
|
+
data.set(image.data.subarray(start, start + w * 4), row * w * 4);
|
|
81
|
+
}
|
|
82
|
+
return { width: w, height: h, data };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cut a generated sheet (RGBA) into its views by BLUEPRINT_LAYOUT and mask each
|
|
87
|
+
* one. A view whose mask fails (no subject, no background, or a subject that
|
|
88
|
+
* touches the panel edge and so was cut or spills into its neighbour) is dropped
|
|
89
|
+
* with a notice. Returns { views: [{ view, label, yaw, image, mask }], notices }.
|
|
90
|
+
*/
|
|
91
|
+
export function cropBlueprintViews(sheet, layout = BLUEPRINT_LAYOUT) {
|
|
92
|
+
const views = [], notices = [];
|
|
93
|
+
for (const panel of layout) {
|
|
94
|
+
const image = cropBlueprintPanel(sheet, panel);
|
|
95
|
+
try {
|
|
96
|
+
const { width, height, mask, source } = referenceMask(image);
|
|
97
|
+
let minX = width, maxX = -1, minY = height, maxY = -1;
|
|
98
|
+
for (let i = 0; i < mask.length; i += 1) if (mask[i]) {
|
|
99
|
+
const px = i % width, py = (i - px) / width;
|
|
100
|
+
minX = Math.min(minX, px); maxX = Math.max(maxX, px); minY = Math.min(minY, py); maxY = Math.max(maxY, py);
|
|
101
|
+
}
|
|
102
|
+
if (minX === 0 || minY === 0 || maxX === width - 1 || maxY === height - 1) throw new Error("the subject touches the panel edge");
|
|
103
|
+
const box = { x: minX / width, y: minY / height, width: (maxX - minX + 1) / width, height: (maxY - minY + 1) / height };
|
|
104
|
+
views.push({ view: panel.view, label: panel.label, yaw: BLUEPRINT_VIEW_YAWS[panel.view] ?? null, image, mask: { source, box } });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
notices.push(`${panel.label} view dropped: ${String(error?.message || error).replace(/\.$/, "")}.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { views, notices };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The cropped views of a generated sheet as PNG data URLs, through an image
|
|
113
|
+
* port ({ decode, encodePng }: commands/sculpt-images.js in the browser). */
|
|
114
|
+
export async function blueprintViewImages(sheetDataUrl, images) {
|
|
115
|
+
const { views, notices } = cropBlueprintViews(await images.decode({ dataUrl: sheetDataUrl }));
|
|
116
|
+
return { notices, views: await Promise.all(views.map(async ({ view, label, yaw, image, mask }) => ({ view, label, yaw, mask, width: image.width, height: image.height, dataUrl: await images.encodePng(image) }))) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What a sculpt run takes from a sheet: its side and back views (the front is the reference itself). */
|
|
120
|
+
export const blueprintRunViews = (sheet) => (sheet?.views ?? []).filter((view) => view.view === "side" || view.view === "back")
|
|
121
|
+
.map(({ view, yaw, dataUrl }) => ({ view, yaw, dataUrl, name: `blueprint-${view}.png` }));
|
package/src/sculpt-mode.js
CHANGED
|
@@ -115,6 +115,11 @@ export function sculptAgentClient(transport, readContext) {
|
|
|
115
115
|
* signed in, or a provider key held by the sidecar). */
|
|
116
116
|
export const agentSessionReady = (status) => Boolean(status?.signedIn || status?.providersConfigured > 0);
|
|
117
117
|
|
|
118
|
+
/** Whether the sidecar holds a ChatGPT token: /oauth/status `signedIn` is the
|
|
119
|
+
* ChatGPT login alone (a provider key only counts in providersConfigured). The
|
|
120
|
+
* blueprint's image model is ChatGPT-only, so its button needs this (#970). */
|
|
121
|
+
export const chatgptSignedIn = (status) => status?.signedIn === true;
|
|
122
|
+
|
|
118
123
|
/** A stored image asset as a data URL (the bytes the store holds, so the run
|
|
119
124
|
* scores against exactly the stored, possibly downscaled, picture). */
|
|
120
125
|
export function assetDataUrl(asset) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The part gizmo's arithmetic (#740 S8). Pure: no React, no DOM. A part's
|
|
2
|
+
// `position` and `rotation` are written in its PARENT's frame (the object
|
|
3
|
+
// frame for a root part), so a gizmo that moves the part in the world is
|
|
4
|
+
// read back through the parent: world = objectWorld × parent × local. A
|
|
5
|
+
// mirrored copy is edited through the part the recipe holds: the copy's own
|
|
6
|
+
// local values reflected across x = 0 of the same frame, which is how
|
|
7
|
+
// expandSculptParts derived the copy in the first place.
|
|
8
|
+
import * as THREE from "three";
|
|
9
|
+
import { SCULPT_MIRROR_SUFFIX, expandSculptParts } from "./sculpt-recipe.js";
|
|
10
|
+
import { tidy } from "./sculpt-recipe-read.js";
|
|
11
|
+
|
|
12
|
+
const DEG = Math.PI / 180;
|
|
13
|
+
// The recipe keeps 1e-4 (tidy); a field the drag left within that is unchanged.
|
|
14
|
+
const SAME = 1e-4;
|
|
15
|
+
|
|
16
|
+
const reflect = (local) => ({ position: [-local.position[0], local.position[1], local.position[2]], rotation: [local.rotation[0], -local.rotation[1], -local.rotation[2]] });
|
|
17
|
+
const wrap = (deg) => { const out = ((((deg + 180) % 360) + 360) % 360) - 180; return out === -180 ? 180 : out; };
|
|
18
|
+
const angleGap = (a, b) => Math.abs(wrap(a - b));
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the selected part is drawn, as the gizmo needs it: the drawn mesh id,
|
|
22
|
+
* the object-frame matrix of the frame its values are written in (`parent`,
|
|
23
|
+
* column-major), and its local position, rotation (deg, XYZ) and size. A
|
|
24
|
+
* mirrored selection gives the copy's frame and values. Null when the recipe
|
|
25
|
+
* has no such part.
|
|
26
|
+
*/
|
|
27
|
+
export function sculptPartFrame(recipe, selected) {
|
|
28
|
+
if (!recipe?.parts || !selected?.partId) return null;
|
|
29
|
+
// A cutter (op) is never drawn, so it has no mesh for the gizmo to sit on (the tree does not offer it).
|
|
30
|
+
if (recipe.parts.find((part) => part.id === selected.partId)?.op) return null;
|
|
31
|
+
const drawn = expandSculptParts(recipe);
|
|
32
|
+
const meshId = selected.mirrored ? `${selected.partId}${SCULPT_MIRROR_SUFFIX}` : selected.partId;
|
|
33
|
+
const row = drawn.find((entry) => entry.id === meshId);
|
|
34
|
+
if (!row) return null;
|
|
35
|
+
const parent = row.parent === undefined ? new THREE.Matrix4().toArray() : drawn.find((entry) => entry.id === row.parent).matrix;
|
|
36
|
+
return { meshId, parent, position: [...row.position], rotation: [...row.rotation], size: [...row.size] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A local transform (position, rotation in degrees XYZ, scale) as a matrix. */
|
|
40
|
+
function localMatrix({ position, rotation, scale = [1, 1, 1] }) {
|
|
41
|
+
const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(rotation[0] * DEG, rotation[1] * DEG, rotation[2] * DEG, "XYZ"));
|
|
42
|
+
return new THREE.Matrix4().compose(new THREE.Vector3(...position), quaternion, new THREE.Vector3(...scale));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The world matrix of the frame a part's values are written in: objectWorld × parent. */
|
|
46
|
+
export function partParentWorld(objectWorld, frame) {
|
|
47
|
+
return objectWorld.clone().multiply(new THREE.Matrix4().fromArray(frame.parent));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parent space → world: the matrix a part with `local` values draws at. */
|
|
51
|
+
export function partWorldMatrix(objectWorld, frame, local = frame) {
|
|
52
|
+
return partParentWorld(objectWorld, frame).multiply(localMatrix(local));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** World → parent space: the position, rotation (deg) and scale `world` has in the part's frame. */
|
|
56
|
+
export function partLocalFromWorld(objectWorld, frame, world) {
|
|
57
|
+
const local = partParentWorld(objectWorld, frame).invert().multiply(world);
|
|
58
|
+
const position = new THREE.Vector3(), quaternion = new THREE.Quaternion(), scale = new THREE.Vector3();
|
|
59
|
+
local.decompose(position, quaternion, scale);
|
|
60
|
+
return partLocalOf({ position, quaternion, scale });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An Object3D-like transform (position, quaternion, scale) as recipe-style local values. */
|
|
64
|
+
export function partLocalOf({ position, quaternion, scale }) {
|
|
65
|
+
const euler = new THREE.Euler().setFromQuaternion(quaternion, "XYZ");
|
|
66
|
+
return { position: position.toArray(), rotation: [euler.x / DEG, euler.y / DEG, euler.z / DEG], scale: scale.toArray() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Shift's uniform scale: every axis takes the factor of the axis that moved furthest from 1. */
|
|
70
|
+
export function uniformScale(scale) {
|
|
71
|
+
const lead = scale.reduce((best, value) => (Math.abs(value - 1) > Math.abs(best - 1) ? value : best), 1);
|
|
72
|
+
return [lead, lead, lead];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The one `object.sculpt` edit a finished drag writes: `{ set: partId,
|
|
77
|
+
* position?, rotation?, size? }` with only the fields the drag changed, or
|
|
78
|
+
* null when it changed nothing. `local` is the gizmo's result in the frame
|
|
79
|
+
* sculptPartFrame gave (scale multiplies the size). A mirrored selection
|
|
80
|
+
* writes the reflected values onto the part the recipe holds, so the pair
|
|
81
|
+
* moves together. Nothing is clamped: an undrawable size goes to the command,
|
|
82
|
+
* which refuses it with the reason.
|
|
83
|
+
*/
|
|
84
|
+
export function sculptPartEdit(recipe, selected, local) {
|
|
85
|
+
const frame = sculptPartFrame(recipe, selected);
|
|
86
|
+
if (!frame) return null;
|
|
87
|
+
const moved = selected.mirrored ? reflect(local) : local;
|
|
88
|
+
const authored = selected.mirrored ? reflect(frame) : frame;
|
|
89
|
+
const edit = { set: selected.partId };
|
|
90
|
+
const position = moved.position.map(tidy);
|
|
91
|
+
if (position.some((value, axis) => Math.abs(value - authored.position[axis]) >= SAME)) edit.position = position;
|
|
92
|
+
const rotation = moved.rotation.map((deg) => tidy(wrap(deg)));
|
|
93
|
+
if (rotation.some((value, axis) => angleGap(value, authored.rotation[axis]) >= SAME)) edit.rotation = rotation;
|
|
94
|
+
const size = frame.size.map((value, axis) => tidy(value * (local.scale?.[axis] ?? 1)));
|
|
95
|
+
if (size.some((value, axis) => Math.abs(value - frame.size[axis]) >= SAME)) edit.size = size;
|
|
96
|
+
return Object.keys(edit).length > 1 ? edit : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The drag's live preview without rebuilding geometry: every drawn part's
|
|
101
|
+
* object-frame matrix after `edit`, and how much the edited part's size
|
|
102
|
+
* grew per axis (its meshes scale by that until the rebuilt sculpt lands).
|
|
103
|
+
* `Map(meshId → { matrix, ratio })`.
|
|
104
|
+
*/
|
|
105
|
+
export function previewSculptParts(recipe, edit) {
|
|
106
|
+
const parts = recipe.parts.map((part) => (part.id === edit?.set ? { ...part, ...edit } : part));
|
|
107
|
+
const sizes = new Map(recipe.parts.map((part) => [part.id, part.size]));
|
|
108
|
+
const out = new Map();
|
|
109
|
+
for (const row of expandSculptParts({ ...recipe, parts })) {
|
|
110
|
+
const source = row.id.endsWith(SCULPT_MIRROR_SUFFIX) ? row.id.slice(0, -SCULPT_MIRROR_SUFFIX.length) : row.id;
|
|
111
|
+
const before = sizes.get(source) ?? row.size;
|
|
112
|
+
out.set(row.id, { matrix: row.matrix, ratio: row.size.map((value, axis) => (before[axis] ? value / before[axis] : 1)) });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The selected sculpt part's gizmo on the turntable (#740 S8). three.js
|
|
2
|
+
// TransformControls drives a proxy that sits in the part's PARENT frame, so
|
|
3
|
+
// its position, rotation and scale read straight back as the recipe's values
|
|
4
|
+
// (sculpt-part-edit.js). While a drag runs, the meshes follow a preview (no
|
|
5
|
+
// geometry rebuild); the drag's end writes one object.sculpt edit, one undo.
|
|
6
|
+
// A refused edit puts the meshes back and the reason shows under the tree.
|
|
7
|
+
// The gizmo lives on SCULPT_LAYER alone: the shot preview and exports never
|
|
8
|
+
// see it, and its picks use the main pane's rect, as part picking does.
|
|
9
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
11
|
+
import * as THREE from "three";
|
|
12
|
+
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
|
13
|
+
import { SCULPT_LAYER } from "./sculpt-mode.js";
|
|
14
|
+
import { partLocalOf, partParentWorld, previewSculptParts, sculptPartEdit, sculptPartFrame, uniformScale } from "./sculpt-part-edit.js";
|
|
15
|
+
|
|
16
|
+
const MODES = { move: "translate", rotate: "rotate", scale: "scale" };
|
|
17
|
+
const DEG = Math.PI / 180;
|
|
18
|
+
|
|
19
|
+
function partMeshes(nodes) {
|
|
20
|
+
const meshes = [];
|
|
21
|
+
for (const node of nodes) node.traverse((child) => { if (child.isMesh && child.userData?.sculptPart) meshes.push(child); });
|
|
22
|
+
return meshes;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SculptPartGizmo({ camera, orbitRef, nodesRef, gizmoRef, recipe, parts }) {
|
|
26
|
+
const { gl, scene, invalidate } = useThree();
|
|
27
|
+
const selected = parts?.selected ?? null;
|
|
28
|
+
const frame = useMemo(() => sculptPartFrame(recipe, selected), [recipe, selected]);
|
|
29
|
+
const [rig] = useState(() => {
|
|
30
|
+
const base = new THREE.Object3D();
|
|
31
|
+
base.matrixAutoUpdate = false;
|
|
32
|
+
const proxy = new THREE.Object3D();
|
|
33
|
+
base.add(proxy);
|
|
34
|
+
return { base, proxy };
|
|
35
|
+
});
|
|
36
|
+
const live = useRef(null);
|
|
37
|
+
useLayoutEffect(() => { live.current = { recipe, selected, commit: parts?.commit }; });
|
|
38
|
+
// { meshes: [[mesh, position, quaternion, scale]] } while a drag runs; { held } after a write until its recipe lands.
|
|
39
|
+
const dragRef = useRef(null);
|
|
40
|
+
// The controls' restore, for the selection effect: a selection change mid-drag (Esc) puts the meshes back first.
|
|
41
|
+
const restoreRef = useRef(() => {});
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!camera) return undefined;
|
|
45
|
+
const made = new TransformControls(camera, gl.domElement);
|
|
46
|
+
const canvas = gl.domElement, orbit = orbitRef;
|
|
47
|
+
// The sculpt camera spans the main pane, not the whole canvas.
|
|
48
|
+
made._getPointer = (event) => {
|
|
49
|
+
const pane = (document.querySelector(".vp-pane.vp-main") ?? canvas).getBoundingClientRect();
|
|
50
|
+
return { x: ((event.clientX - pane.left) / pane.width) * 2 - 1, y: -((event.clientY - pane.top) / pane.height) * 2 + 1, button: event.button };
|
|
51
|
+
};
|
|
52
|
+
const ray = made.getRaycaster();
|
|
53
|
+
const mask = ray.layers.mask;
|
|
54
|
+
ray.layers.set(SCULPT_LAYER);
|
|
55
|
+
const helper = made.getHelper();
|
|
56
|
+
helper.traverse((node) => node.layers.set(SCULPT_LAYER));
|
|
57
|
+
scene.add(rig.base, helper);
|
|
58
|
+
let shift = false;
|
|
59
|
+
const restore = () => {
|
|
60
|
+
for (const [mesh, position, quaternion, scale] of dragRef.current?.meshes ?? []) {
|
|
61
|
+
mesh.position.copy(position);
|
|
62
|
+
mesh.quaternion.copy(quaternion);
|
|
63
|
+
mesh.scale.copy(scale);
|
|
64
|
+
}
|
|
65
|
+
dragRef.current = null;
|
|
66
|
+
invalidate();
|
|
67
|
+
};
|
|
68
|
+
restoreRef.current = restore;
|
|
69
|
+
const editNow = () => {
|
|
70
|
+
const { recipe: current, selected: part } = live.current;
|
|
71
|
+
if (made.mode === "scale" && shift) rig.proxy.scale.fromArray(uniformScale(rig.proxy.scale.toArray()));
|
|
72
|
+
return sculptPartEdit(current, part, partLocalOf(rig.proxy));
|
|
73
|
+
};
|
|
74
|
+
const onDown = () => {
|
|
75
|
+
dragRef.current = { meshes: partMeshes(nodesRef.current).map((mesh) => [mesh, mesh.position.clone(), mesh.quaternion.clone(), mesh.scale.clone()]) };
|
|
76
|
+
};
|
|
77
|
+
const onChange = () => {
|
|
78
|
+
if (!dragRef.current?.meshes) return;
|
|
79
|
+
const preview = previewSculptParts(live.current.recipe, editNow());
|
|
80
|
+
const matrix = new THREE.Matrix4();
|
|
81
|
+
for (const [mesh] of dragRef.current.meshes) {
|
|
82
|
+
const entry = preview.get(mesh.userData.sculptPart);
|
|
83
|
+
if (!entry) continue;
|
|
84
|
+
matrix.fromArray(entry.matrix).decompose(mesh.position, mesh.quaternion, mesh.scale);
|
|
85
|
+
mesh.scale.fromArray(entry.ratio);
|
|
86
|
+
}
|
|
87
|
+
invalidate();
|
|
88
|
+
};
|
|
89
|
+
const onUp = () => {
|
|
90
|
+
if (!dragRef.current?.meshes) return;
|
|
91
|
+
const edit = editNow();
|
|
92
|
+
if (edit && live.current.commit?.(edit)) dragRef.current = { held: true };
|
|
93
|
+
else restore();
|
|
94
|
+
};
|
|
95
|
+
const onDragging = (event) => {
|
|
96
|
+
if (orbitRef.current) orbitRef.current.enabled = !event.value;
|
|
97
|
+
invalidate();
|
|
98
|
+
};
|
|
99
|
+
// A press on a handle must not also start an orbit: the orbit is off before its own listener runs.
|
|
100
|
+
const onPress = () => { if (made.axis !== null && orbitRef.current) orbitRef.current.enabled = false; };
|
|
101
|
+
const onRelease = () => { if (!made.dragging && orbitRef.current) orbitRef.current.enabled = true; };
|
|
102
|
+
const onShift = (event) => { shift = event.shiftKey; };
|
|
103
|
+
made.addEventListener("mouseDown", onDown);
|
|
104
|
+
made.addEventListener("objectChange", onChange);
|
|
105
|
+
made.addEventListener("mouseUp", onUp);
|
|
106
|
+
made.addEventListener("dragging-changed", onDragging);
|
|
107
|
+
made.addEventListener("change", invalidate);
|
|
108
|
+
canvas.addEventListener("pointerdown", onPress, true);
|
|
109
|
+
window.addEventListener("pointerup", onRelease);
|
|
110
|
+
window.addEventListener("pointermove", onShift, true);
|
|
111
|
+
window.addEventListener("keydown", onShift, true);
|
|
112
|
+
window.addEventListener("keyup", onShift, true);
|
|
113
|
+
gizmoRef.current = made;
|
|
114
|
+
return () => {
|
|
115
|
+
if (dragRef.current?.meshes) restore();
|
|
116
|
+
canvas.removeEventListener("pointerdown", onPress, true);
|
|
117
|
+
window.removeEventListener("pointerup", onRelease);
|
|
118
|
+
window.removeEventListener("pointermove", onShift, true);
|
|
119
|
+
window.removeEventListener("keydown", onShift, true);
|
|
120
|
+
window.removeEventListener("keyup", onShift, true);
|
|
121
|
+
if (orbit.current) orbit.current.enabled = true;
|
|
122
|
+
gizmoRef.current = null;
|
|
123
|
+
made.detach();
|
|
124
|
+
made.dispose();
|
|
125
|
+
scene.remove(rig.base, helper);
|
|
126
|
+
ray.layers.mask = mask;
|
|
127
|
+
};
|
|
128
|
+
}, [camera, gl, scene, rig, nodesRef, orbitRef, gizmoRef, invalidate]);
|
|
129
|
+
|
|
130
|
+
// Declared after the controls' own effect, so they exist when these run.
|
|
131
|
+
useEffect(() => { gizmoRef.current?.setMode(MODES[parts?.tool] ?? "translate"); invalidate(); }, [camera, gizmoRef, parts?.tool, invalidate]);
|
|
132
|
+
// A new recipe (the write landed, or any other edit) or selection ends the hold.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const controls = gizmoRef.current;
|
|
135
|
+
if (!controls) return;
|
|
136
|
+
if (dragRef.current?.held) dragRef.current = null;
|
|
137
|
+
// A drag the selection change interrupted (Esc before the release) writes nothing and puts the meshes back.
|
|
138
|
+
if (dragRef.current?.meshes) { restoreRef.current(); if (orbitRef.current) orbitRef.current.enabled = true; }
|
|
139
|
+
if (frame) controls.attach(rig.proxy);
|
|
140
|
+
else controls.detach();
|
|
141
|
+
invalidate();
|
|
142
|
+
}, [camera, gizmoRef, frame, rig, orbitRef, invalidate]);
|
|
143
|
+
|
|
144
|
+
// Between drags the proxy follows the part where it is drawn now.
|
|
145
|
+
useFrame(() => {
|
|
146
|
+
const controls = gizmoRef.current;
|
|
147
|
+
if (!controls || !frame || controls.dragging || dragRef.current) return;
|
|
148
|
+
const mesh = partMeshes(nodesRef.current).find((node) => node.userData.sculptPart === frame.meshId);
|
|
149
|
+
if (!mesh?.parent) return;
|
|
150
|
+
mesh.parent.updateWorldMatrix(true, false);
|
|
151
|
+
rig.base.matrix.copy(partParentWorld(mesh.parent.matrixWorld, frame));
|
|
152
|
+
rig.base.updateMatrixWorld(true);
|
|
153
|
+
rig.proxy.position.fromArray(frame.position);
|
|
154
|
+
rig.proxy.rotation.set(frame.rotation[0] * DEG, frame.rotation[1] * DEG, frame.rotation[2] * DEG, "XYZ");
|
|
155
|
+
rig.proxy.scale.set(1, 1, 1);
|
|
156
|
+
rig.proxy.updateMatrixWorld(true);
|
|
157
|
+
});
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The selected sculpt part's highlight on the turntable (#740 S7): per part
|
|
2
|
+
// mesh, an inverted hull pushed out along the normals and drawn back-faces
|
|
3
|
+
// only (a rim of the selection colour), and a thin see-through tint over its
|
|
4
|
+
// visible surface, which still reads where the part sinks into another. The
|
|
5
|
+
// shells live on SCULPT_LAYER alone: the sculpt camera sees them, the shot
|
|
6
|
+
// preview and every export never do.
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
9
|
+
import * as THREE from "three";
|
|
10
|
+
import { SCULPT_LAYER } from "./sculpt-mode.js";
|
|
11
|
+
import { sculptPartMeshIds } from "./sculpt-pick.js";
|
|
12
|
+
|
|
13
|
+
// The panel's selection colour (--select), so the part and its tree row match;
|
|
14
|
+
// the dark theme's value stands in until the stylesheet is read.
|
|
15
|
+
const OUTLINE_COLOR = "#e8a33d";
|
|
16
|
+
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
|
17
|
+
const RIM_WIDTH = 0.02;
|
|
18
|
+
const TINT_WIDTH = 0.002;
|
|
19
|
+
|
|
20
|
+
function inflated(width, options) {
|
|
21
|
+
const material = new THREE.MeshBasicMaterial({ color: OUTLINE_COLOR, ...options });
|
|
22
|
+
material.onBeforeCompile = (shader) => {
|
|
23
|
+
shader.vertexShader = shader.vertexShader.replace("#include <begin_vertex>", `#include <begin_vertex>\n\ttransformed += normal * ${width};`);
|
|
24
|
+
};
|
|
25
|
+
return material;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function highlightMaterials() {
|
|
29
|
+
return [
|
|
30
|
+
inflated(RIM_WIDTH, { side: THREE.BackSide }),
|
|
31
|
+
inflated(TINT_WIDTH, { transparent: true, opacity: 0.3, depthWrite: false }),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shellFor(source, material) {
|
|
36
|
+
const shell = source.isInstancedMesh ? new THREE.InstancedMesh(source.geometry, material, source.count) : new THREE.Mesh(source.geometry, material);
|
|
37
|
+
if (source.isInstancedMesh) shell.instanceMatrix = source.instanceMatrix;
|
|
38
|
+
shell.userData = { source, noInk: true };
|
|
39
|
+
shell.matrixAutoUpdate = false;
|
|
40
|
+
shell.frustumCulled = false;
|
|
41
|
+
// Never a pick target: a click goes through the rim to the part.
|
|
42
|
+
shell.raycast = () => {};
|
|
43
|
+
shell.layers.set(SCULPT_LAYER);
|
|
44
|
+
return shell;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function SculptPartOutline({ nodesRef, selected }) {
|
|
48
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
49
|
+
const [group] = useState(() => new THREE.Group());
|
|
50
|
+
const [materials] = useState(highlightMaterials);
|
|
51
|
+
useEffect(() => () => { for (const material of materials) material.dispose(); }, [materials]);
|
|
52
|
+
const ids = sculptPartMeshIds(selected);
|
|
53
|
+
const key = ids.join(" ");
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const css = getComputedStyle(document.documentElement).getPropertyValue("--select").trim();
|
|
56
|
+
for (const material of materials) material.color.set(HEX_COLOR.test(css) ? css : OUTLINE_COLOR);
|
|
57
|
+
invalidate();
|
|
58
|
+
}, [key, materials, invalidate]);
|
|
59
|
+
// The sculpt rebuilds its meshes when the recipe changes, so the shells
|
|
60
|
+
// follow whichever meshes carry the selected ids this frame.
|
|
61
|
+
useFrame(() => {
|
|
62
|
+
const sources = [];
|
|
63
|
+
for (const node of nodesRef.current) node.traverse((child) => { if (child.isMesh && ids.includes(child.userData?.sculptPart)) sources.push(child); });
|
|
64
|
+
const shells = group.children;
|
|
65
|
+
if (shells.length !== sources.length * materials.length || shells.some((shell, index) => shell.userData.source !== sources[Math.floor(index / materials.length)])) {
|
|
66
|
+
group.clear();
|
|
67
|
+
for (const source of sources) for (const material of materials) group.add(shellFor(source, material));
|
|
68
|
+
}
|
|
69
|
+
for (const shell of group.children) shell.matrix.copy(shell.userData.source.matrixWorld);
|
|
70
|
+
});
|
|
71
|
+
return <primitive object={group} />;
|
|
72
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Part picking in Sculpt mode (#740 S7). Pure: no React, no DOM. A sculpt's
|
|
2
|
+
// meshes carry their part id as `userData.sculptPart` (sculpt-geometry.js); a
|
|
3
|
+
// mirrored copy's id ends in SCULPT_MIRROR_SUFFIX and resolves to the part the
|
|
4
|
+
// recipe actually holds, because that is the part an edit writes.
|
|
5
|
+
import { SCULPT_MIRROR_SUFFIX } from "./sculpt-recipe.js";
|
|
6
|
+
|
|
7
|
+
/** `{ partId, mirrored }` for a mesh's part id, or null when it has none. */
|
|
8
|
+
function resolveSculptPart(name) {
|
|
9
|
+
if (typeof name !== "string" || !name) return null;
|
|
10
|
+
const mirrored = name.endsWith(SCULPT_MIRROR_SUFFIX);
|
|
11
|
+
return { partId: mirrored ? name.slice(0, -SCULPT_MIRROR_SUFFIX.length) : name, mirrored };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The part nearest along the raycaster's ray among `roots` and their
|
|
16
|
+
* descendants, or null on a miss. Only meshes tagged as sculpt parts count:
|
|
17
|
+
* a hit on anything else (the turntable, an outline shell) is passed over.
|
|
18
|
+
*/
|
|
19
|
+
export function pickSculptPart(raycaster, roots) {
|
|
20
|
+
const hits = raycaster.intersectObjects(roots, true);
|
|
21
|
+
const hit = hits.find((entry) => entry.object.userData?.sculptPart);
|
|
22
|
+
return hit ? resolveSculptPart(hit.object.userData.sculptPart) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The recipe's authored parts as an indented tree, parents before children
|
|
27
|
+
* in recipe order: `{ id, shape, depth, parent, mirror }`. `mirror` is true
|
|
28
|
+
* when the part is drawn twice, by its own flag or a mirrored ancestor's.
|
|
29
|
+
*/
|
|
30
|
+
export function sculptPartTree(recipe) {
|
|
31
|
+
const parts = Array.isArray(recipe?.parts) ? recipe.parts : [];
|
|
32
|
+
const rows = [];
|
|
33
|
+
const visit = (parent, depth, mirrored) => {
|
|
34
|
+
for (const part of parts) {
|
|
35
|
+
if ((part.parent ?? null) !== parent) continue;
|
|
36
|
+
const mirror = mirrored || part.mirror === true;
|
|
37
|
+
rows.push({ id: part.id, shape: part.shape, depth, parent, mirror, cutter: Boolean(part.op) });
|
|
38
|
+
visit(part.id, depth + 1, mirror);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
visit(null, 0, false);
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The mesh ids a selected part draws as: the part and its mirrored copy. */
|
|
46
|
+
export function sculptPartMeshIds(selected) {
|
|
47
|
+
return selected ? [selected.partId, `${selected.partId}${SCULPT_MIRROR_SUFFIX}`] : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An open menu or modal dialog owns Escape: the part selection waits until it has closed. */
|
|
51
|
+
export function overlayOwnsEscape(root = globalThis.document) {
|
|
52
|
+
return Boolean(root?.querySelector?.('[role="menu"], [role="dialog"][aria-modal="true"], dialog[open]'));
|
|
53
|
+
}
|