cozyclay 2.0.2 → 2.1.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 +61 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +211 -36
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +4 -4
- package/bin/live/cli.mjs +23 -8
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +2 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-C9pMpW7s.js +1 -0
- package/dist/assets/app-B_2XB9rw.css +1 -0
- package/dist/assets/app-DAuvKPP8.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
- package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
- package/dist/assets/workflow-jJRBR94Z.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/llms.txt +14 -0
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +813 -278
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +184 -6
- package/src/ardy/timeline.jsx +121 -36
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +0 -3
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +6 -7
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +15 -2
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +79 -28
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +49 -4
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +58 -9
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +60 -16
- package/src/shell/CompositionGuideButton.jsx +20 -0
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +18 -3
- package/src/shell/ViewportToolbar.jsx +36 -31
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shell/viewport.css +12 -0
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +213 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +60 -24
- package/src/studio-app-binding.js +56 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +92 -9
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +106 -18
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/gt-render/browser.mjs +23 -0
- package/tools/headless-studio.mjs +85 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +95 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-BxBLC6GT.js +0 -4876
- package/dist/assets/app-CJdxvnrl.css +0 -1
- package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
- package/dist/assets/workflow-B5XZwVfe.js +0 -179
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
const INITIAL_MESSAGE = "Measure the reference landmarks before placing parts.";
|
|
2
|
+
|
|
3
|
+
const initialState = ({ targetIou, maxRounds }) => ({
|
|
4
|
+
stage: "idle",
|
|
5
|
+
round: 0,
|
|
6
|
+
maxRounds,
|
|
7
|
+
iou: null,
|
|
8
|
+
targetIou,
|
|
9
|
+
elapsedMs: 0,
|
|
10
|
+
sheetDataUrl: null,
|
|
11
|
+
message: null,
|
|
12
|
+
error: null,
|
|
13
|
+
sculptId: null,
|
|
14
|
+
// What the operator watches while a turn is out: a log, what we wait on, the score history.
|
|
15
|
+
activity: [],
|
|
16
|
+
waiting: null,
|
|
17
|
+
history: [],
|
|
18
|
+
candidates: null,
|
|
19
|
+
outline: null,
|
|
20
|
+
changedParts: null,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ACTIVITY_LIMIT = 40;
|
|
24
|
+
// How long each wait usually takes, from the live runs in docs/sculpt-from-image.md.
|
|
25
|
+
const EXPECTED_WAIT_MS = Object.freeze({ measure: 1500, draft: 60000, refine: 35000, agent: 20000, tool: 3000, score: 1500 });
|
|
26
|
+
|
|
27
|
+
function toolLabel(action, args = {}, who = "Agent") {
|
|
28
|
+
if (action === "object.sculpt") return args.edits ? `${who} is applying the winning edits` : `${who} is building the recipe`;
|
|
29
|
+
if (action === "sculpt.compare") return Array.isArray(args.candidates) ? `${who} is test-fitting ${args.candidates.length} candidate fixes` : `${who} is scoring the sculpt`;
|
|
30
|
+
if (action === "inspect_studio" || action === "inspect") return `${who} is reading the editor`;
|
|
31
|
+
return `${who} is running ${action}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const shortError = error => String(error ?? "").replace(/\s*\(inspect, do not retry\)\s*$/, "").slice(0, 140);
|
|
35
|
+
|
|
36
|
+
const isAsyncIterable = value => value && typeof value[Symbol.asyncIterator] === "function";
|
|
37
|
+
const commandName = event => event?.action ?? event?.command ?? (event?.commandId ? event?.name : undefined) ?? event?.name ?? event?.id;
|
|
38
|
+
const commandArgs = event => event?.args ?? event?.arguments ?? event?.request?.args ?? {};
|
|
39
|
+
const commandResult = event => event?.result ?? event?.receipt ?? event?.output ?? event?.response ?? event?.value;
|
|
40
|
+
const affectedIds = value => Array.isArray(value?.affectedIds) ? value.affectedIds : [];
|
|
41
|
+
|
|
42
|
+
function dataUrlFromSheet(sheet) {
|
|
43
|
+
if (typeof sheet === "string") return sheet;
|
|
44
|
+
// Raw RGBA is no image a browser can show: only sheetDataUrl (a PNG) is displayed.
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The reference as sculpt.compare takes it: the stored asset when there is one.
|
|
49
|
+
const referenceArg = reference => (reference?.assetId ? { assetId: reference.assetId } : { dataUrl: reference?.dataUrl });
|
|
50
|
+
|
|
51
|
+
// The recipe rules the agent needs to get object.sculpt right the first time (sculpt-recipe.js).
|
|
52
|
+
const RECIPE_RULES = "Recipe: { \"version\": 1, \"parts\": [{ \"id\", \"shape\", \"size\": [x,y,z] metres, \"position\": [x,y,z] centre, \"rotation\": [x,y,z] degrees, \"color\": \"#rrggbb\", \"parent\"?, \"mirror\"?, \"roundness\"? 0-1, \"taper\"? }] }. id: lowercase a-z, 0-9 and -, starting with a letter or digit, at most 40 characters. shape is exactly one of blob, box, cylinder, torus, frame (no cone, sphere or other names). Up to 48 parts after mirrors. Floor is y=0 (feet touch it), front is +z, the subject's left is +x, mirror: true adds the reflection across x=0. roundness works backwards from its name: 0 is a smooth ellipsoid and 1 is a rounded box. roundness belongs only on blob and box, taper only on blob: leave both off cylinder, torus and frame. Keep roundness at 0.2 or below on every organic part (body, belly, head, face, limbs); go above 0.3 only for hard-edged objects. Use box only for hard-edged things.";
|
|
53
|
+
|
|
54
|
+
// A compact score for the next turn: what is missing where, and which parts stick out.
|
|
55
|
+
function reportText(report) {
|
|
56
|
+
if (!report) return "";
|
|
57
|
+
const extra = Array.isArray(report.extraByPart) ? report.extraByPart.slice(0, 4) : report.extraByPart;
|
|
58
|
+
return ` Score detail: ${JSON.stringify({ widthToHeight: report.widthToHeight, missingByBand: report.missingByBand, extraByPart: extra })}.`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function outlineText(outline) {
|
|
62
|
+
if (!outline) return "";
|
|
63
|
+
return ` The harness measured the reference outline in pixels (front view; x is a fraction of the subject's height from its centre, + toward image right = the subject's own left; y is a fraction of height from the head top; spans are the filled runs across each slice): ${JSON.stringify(outline)}. Put parts on these measurements.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeTurnText(reference, sculptId, continuation, outline = null) {
|
|
67
|
+
const ref = JSON.stringify(referenceArg(reference));
|
|
68
|
+
if (continuation) {
|
|
69
|
+
return [
|
|
70
|
+
`Round ${continuation.round} of ${continuation.maxRounds}. The harness scored sculpt ${sculptId} at IoU ${continuation.iou ?? "unknown"} against the reference.${reportText(continuation.report)}`,
|
|
71
|
+
`Write 3-6 candidate edit sets that fix the biggest mismatch (proportion first, then placement), rank them with ONE run_action sculpt.compare { "id": "${sculptId}", "reference": ${ref}, "candidates": [{ "label", "edits" }] },`,
|
|
72
|
+
`apply the best-ranked set with ONE run_action object.sculpt { "id": "${sculptId}", "edits": [...] }, then end the turn with one short line. Do not inspect first; the harness scores the result after your turn.`,
|
|
73
|
+
RECIPE_RULES,
|
|
74
|
+
].join(" ");
|
|
75
|
+
}
|
|
76
|
+
return [
|
|
77
|
+
"Sculpt the subject of the attached reference image as a clay recipe.",
|
|
78
|
+
outline
|
|
79
|
+
? `Use the measured outline below, scaled to a real-world height in metres.${outlineText(outline)}`
|
|
80
|
+
: "First measure the visible outline in the image: the subject's bounding box, then where the head top, each limb tip and the feet sit as fractions of its height, and convert them to metres for a real-world height.",
|
|
81
|
+
sculptId
|
|
82
|
+
? `Then rewrite sculpt ${sculptId} with ONE run_action object.sculpt { "id": "${sculptId}", "recipe": ... }, keeping parts on the measured landmarks.`
|
|
83
|
+
: "Then create it with ONE run_action object.sculpt { \"name\", \"recipe\" }, keeping parts on the measured landmarks.",
|
|
84
|
+
"No inspect calls are needed and do not call sculpt.compare: the harness scores the sculpt after your turn and tells you the result.",
|
|
85
|
+
"End the turn with one short line naming what the image hides.",
|
|
86
|
+
RECIPE_RULES,
|
|
87
|
+
].join(" ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Adapt the callback-based Studio client into the small port consumed by the
|
|
92
|
+
* sculpt controller. The controller deliberately owns the session/turn loop.
|
|
93
|
+
*/
|
|
94
|
+
// The sidecar's Studio envelope requires UUIDs for both the session and the turn.
|
|
95
|
+
export function createAgentPort(client, { sessionId = crypto.randomUUID() } = {}) {
|
|
96
|
+
return {
|
|
97
|
+
async *sendTurn({ text, attachments = [], signal, model, effort, turnId = crypto.randomUUID() }) {
|
|
98
|
+
const events = [];
|
|
99
|
+
let wake;
|
|
100
|
+
let ended = false;
|
|
101
|
+
const push = event => { events.push(event); wake?.(); wake = null; };
|
|
102
|
+
const result = client.turn({
|
|
103
|
+
surface: "studio",
|
|
104
|
+
sessionId,
|
|
105
|
+
turnId,
|
|
106
|
+
text,
|
|
107
|
+
...(model ? { model } : {}),
|
|
108
|
+
...(effort ? { effort } : {}),
|
|
109
|
+
...(attachments.length ? { attachments } : {}),
|
|
110
|
+
context: null,
|
|
111
|
+
}, push, signal);
|
|
112
|
+
Promise.resolve(result).then(() => { ended = true; wake?.(); wake = null; }, error => { ended = true; push({ type: "error", message: String(error?.message || error) }); });
|
|
113
|
+
while (!ended || events.length) {
|
|
114
|
+
if (!events.length) await new Promise(resolve => { wake = resolve; });
|
|
115
|
+
while (events.length) yield events.shift();
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createSculptRun({
|
|
122
|
+
bus,
|
|
123
|
+
agent,
|
|
124
|
+
reference,
|
|
125
|
+
sculptId = null,
|
|
126
|
+
targetIou = 0.9,
|
|
127
|
+
maxRounds = 3,
|
|
128
|
+
timeBudgetMs = 180000,
|
|
129
|
+
now = Date.now,
|
|
130
|
+
// Optional ({ id, reference }) -> sculpt.compare result. The harness scores a turn the agent left unscored.
|
|
131
|
+
score = null,
|
|
132
|
+
// Optional (reference) -> measureReferenceOutline result, put into the first turn's text.
|
|
133
|
+
measure = null,
|
|
134
|
+
// The model every turn runs on (a /agent/models key) and its effort; omitted = the sidecar's defaults.
|
|
135
|
+
// Measured on the Snorlax reference: effort "high" made the draft twice as slow for no better score.
|
|
136
|
+
model = undefined,
|
|
137
|
+
effort = undefined,
|
|
138
|
+
// How the model is named to the operator ("GPT-6 Astra"); the key when unset.
|
|
139
|
+
modelLabel = undefined,
|
|
140
|
+
} = {}) {
|
|
141
|
+
if (!agent?.sendTurn) throw new TypeError("sculpt run requires an agent port");
|
|
142
|
+
const who = modelLabel || model || "Agent";
|
|
143
|
+
const listeners = new Set();
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
let state = { ...initialState({ targetIou, maxRounds }), model: { key: model ?? null, label: who, effort: effort ?? null } };
|
|
146
|
+
let running = false;
|
|
147
|
+
let settled = false;
|
|
148
|
+
let startedAt = 0;
|
|
149
|
+
let turnSculpted = false;
|
|
150
|
+
let turnScored = false;
|
|
151
|
+
let lastReport = null;
|
|
152
|
+
// A score repeated with no edit in between says nothing new: keep its sheet, skip the log line.
|
|
153
|
+
let editedSinceScore = true;
|
|
154
|
+
let retryCount = 0;
|
|
155
|
+
let turnPromise = null;
|
|
156
|
+
|
|
157
|
+
const elapsed = () => Math.max(0, now() - startedAt);
|
|
158
|
+
const publish = patch => {
|
|
159
|
+
state = { ...state, ...patch, elapsedMs: elapsed() };
|
|
160
|
+
// An observer must never stop the run it watches.
|
|
161
|
+
for (const listener of [...listeners]) {
|
|
162
|
+
try { listener(state); } catch (error) { console.error("sculpt run listener failed", error); }
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
let activitySeq = 0;
|
|
166
|
+
const entry = (kind, text) => ({ id: ++activitySeq, atMs: elapsed(), kind, text });
|
|
167
|
+
// One line in the activity log, with whatever else changes at the same moment.
|
|
168
|
+
const note = (kind, text, patch = {}) => publish({ ...patch, activity: [...state.activity, entry(kind, text)].slice(-ACTIVITY_LIMIT) });
|
|
169
|
+
const waitFor = (what, label) => ({ what, label, since: now(), expectMs: EXPECTED_WAIT_MS[what] ?? null });
|
|
170
|
+
const toolCalls = new Map();
|
|
171
|
+
// The turn's own wait ("writing the recipe", since the turn went out): a tool call interrupts it, never resets it.
|
|
172
|
+
let turnWait = null;
|
|
173
|
+
// The turns this run sent: only their commands are the run's. Another turn,
|
|
174
|
+
// MCP or a UI edit on any sculpt is never claimed.
|
|
175
|
+
const ownTurns = new Set();
|
|
176
|
+
const onBus = event => { if (!controller.signal.aborted && ownTurns.has(event?.turnId)) onCommand(event); };
|
|
177
|
+
const onCommand = event => {
|
|
178
|
+
const name = commandName(event);
|
|
179
|
+
const args = commandArgs(event);
|
|
180
|
+
const result = commandResult(event);
|
|
181
|
+
const ids = affectedIds(result);
|
|
182
|
+
if (name === "object.sculpt" || event?.type === "object.sculpt") {
|
|
183
|
+
const id = args.id ?? ids[0] ?? event?.sculptId ?? state.sculptId;
|
|
184
|
+
if (id) {
|
|
185
|
+
turnSculpted = true;
|
|
186
|
+
turnScored = false; // a score taken before this edit no longer describes the sculpt
|
|
187
|
+
editedSinceScore = true;
|
|
188
|
+
const stage = state.round > 1 ? "refine" : "draft";
|
|
189
|
+
const edits = Array.isArray(args.edits) ? args.edits : null;
|
|
190
|
+
const ids = edits ? [...new Set(edits.map(edit => edit?.set ?? edit?.add?.id ?? edit?.remove).filter(Boolean))] : "all";
|
|
191
|
+
const parts = /(\d+) parts/.exec(String(result?.summary ?? ""))?.[1];
|
|
192
|
+
const text = edits ? `Applied ${edits.length} edit${edits.length === 1 ? "" : "s"}${ids.length ? ` to ${ids.slice(0, 4).join(", ")}${ids.length > 4 ? "…" : ""}` : ""}` : `Draft on the turntable${parts ? `: ${parts} parts` : ""}`;
|
|
193
|
+
note("ok", text, { sculptId: id, round: Math.max(1, state.round), stage, message: edits ? "Edits applied." : "Draft sculpt applied.", changedParts: { ids, nonce: activitySeq + 1 } });
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (name !== "sculpt.compare" && event?.type !== "sculpt.compare") return;
|
|
198
|
+
const report = result?.report ?? event?.report;
|
|
199
|
+
if (result && Number.isFinite(Number(result.report?.iou ?? result.iou))) lastReport = result.report ?? result;
|
|
200
|
+
const iou = Number(report?.iou ?? result?.iou ?? event?.iou);
|
|
201
|
+
const sheet = result?.sheetDataUrl ?? event?.sheetDataUrl ?? dataUrlFromSheet(result?.sheet);
|
|
202
|
+
const ranking = Array.isArray(result?.ranking) ? result.ranking : null;
|
|
203
|
+
if (ranking) {
|
|
204
|
+
// A candidate ranking scores the sculpt as it stands; the edit that follows is what counts.
|
|
205
|
+
const best = ranking.find(row => !row.refused);
|
|
206
|
+
note("score", best ? `Ranked ${ranking.length} candidate fixes: "${best.label}" leads${Number.isFinite(best.delta) ? ` (${best.delta >= 0 ? "+" : ""}${best.delta.toFixed(3)})` : ""}` : `Ranked ${ranking.length} candidate fixes`, {
|
|
207
|
+
candidates: ranking.map(row => ({ label: row.label, iou: Number.isFinite(row.iou) ? row.iou : null, delta: Number.isFinite(row.delta) ? row.delta : null, refused: Boolean(row.refused) })),
|
|
208
|
+
...(sheet ? { sheetDataUrl: sheet } : {}),
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (Number.isFinite(iou)) turnScored = true;
|
|
213
|
+
if (Number.isFinite(iou) && !editedSinceScore && iou === state.iou) {
|
|
214
|
+
publish({ stage: "score", ...(sheet ? { sheetDataUrl: sheet } : {}) });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (Number.isFinite(iou)) editedSinceScore = false;
|
|
218
|
+
const previous = state.history.at(-1)?.iou;
|
|
219
|
+
const delta = Number.isFinite(iou) && Number.isFinite(previous) ? iou - previous : null;
|
|
220
|
+
note("score", Number.isFinite(iou) ? `Scored IoU ${iou.toFixed(3)}${delta !== null ? ` (${delta >= 0 ? "+" : ""}${delta.toFixed(3)})` : ""}` : "Scored the sculpt", {
|
|
221
|
+
stage: "score",
|
|
222
|
+
...(Number.isFinite(iou) ? { iou, history: [...state.history, { round: state.round, iou, atMs: elapsed() }] } : {}),
|
|
223
|
+
...(sheet ? { sheetDataUrl: sheet } : {}),
|
|
224
|
+
message: Number.isFinite(iou) ? `Sculpt comparison scored IoU ${iou}.` : "Sculpt comparison complete.",
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
const unsubscribeBus = bus?.subscribe?.(onBus) ?? (() => {});
|
|
228
|
+
|
|
229
|
+
const consumeTurn = async text => {
|
|
230
|
+
turnSculpted = false;
|
|
231
|
+
turnScored = false;
|
|
232
|
+
const events = [];
|
|
233
|
+
let turnError = null;
|
|
234
|
+
const callback = event => {
|
|
235
|
+
events.push(event);
|
|
236
|
+
if (event?.type === "text.delta") publish({ message: `${state.message ?? ""}${event.text ?? ""}` });
|
|
237
|
+
if (event?.type === "tool.start") {
|
|
238
|
+
const action = event.args?.action ?? event.name;
|
|
239
|
+
toolCalls.set(event.callId, action);
|
|
240
|
+
publish({ waiting: waitFor("tool", toolLabel(action, event.args?.args ?? {}, who)) });
|
|
241
|
+
}
|
|
242
|
+
if (event?.type === "tool.done") {
|
|
243
|
+
const action = toolCalls.get(event.callId);
|
|
244
|
+
toolCalls.delete(event.callId);
|
|
245
|
+
// Successes reach the log through the editor's receipts; a refusal only shows up here.
|
|
246
|
+
const patch = { waiting: turnWait ?? waitFor("agent", `${who} is deciding the next step`) };
|
|
247
|
+
if (event.ok === false) note("warn", `${action === "object.sculpt" ? "Recipe refused" : `${action ?? "A tool"} failed`}: ${shortError(event.error)}. The agent is fixing it.`, patch);
|
|
248
|
+
else publish(patch);
|
|
249
|
+
}
|
|
250
|
+
// A refused or failed turn is the run's failure, not a turn that forgot to sculpt.
|
|
251
|
+
if (event?.type === "error") turnError ??= new Error(event.message || event.code || "The agent turn failed.");
|
|
252
|
+
};
|
|
253
|
+
const turnId = crypto.randomUUID();
|
|
254
|
+
ownTurns.add(turnId);
|
|
255
|
+
const returned = agent.sendTurn({ turnId, text, attachments: reference?.dataUrl ? [{ dataUrl: reference.dataUrl, name: reference.name }] : [], signal: controller.signal, ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, callback);
|
|
256
|
+
if (isAsyncIterable(returned)) {
|
|
257
|
+
for await (const event of returned) callback(event);
|
|
258
|
+
} else await returned;
|
|
259
|
+
if (turnError) throw turnError;
|
|
260
|
+
// The agent's own closing words, first line only: what it did or what the image hides.
|
|
261
|
+
const said = events.filter(event => event?.type === "text.delta").map(event => event.text ?? "").join("").replace(/[*_`#]/g, "").split(/\n+/).map(line => line.trim()).find(Boolean);
|
|
262
|
+
if (said) note("agent", `${who}: ${said.length > 160 ? `${said.slice(0, 157)}…` : said}`);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const loop = async () => {
|
|
266
|
+
startedAt = now();
|
|
267
|
+
note("step", "Measuring the reference outline", { stage: "measure", round: 0, message: INITIAL_MESSAGE, error: null, waiting: waitFor("measure", "Measuring the reference outline") });
|
|
268
|
+
let outline = null;
|
|
269
|
+
if (measure) {
|
|
270
|
+
try {
|
|
271
|
+
outline = await measure(referenceArg(reference));
|
|
272
|
+
const split = outline?.rows?.filter(row => row.spans.length > 1).length ?? 0;
|
|
273
|
+
note("ok", `Outline measured: width:height ${outline.widthToHeight}, ${outline.rows.length} slices${split ? `, ${split} with separate limbs` : ""}`, { outline });
|
|
274
|
+
} catch (error) { note("warn", `Could not measure the reference: ${shortError(error?.message || error)}`, { message: `Could not measure the reference: ${String(error?.message || error)}` }); }
|
|
275
|
+
}
|
|
276
|
+
if (controller.signal.aborted) { note("warn", "Cancelled", { stage: "cancelled", message: "Sculpt run cancelled.", waiting: null }); settled = true; return; }
|
|
277
|
+
let text = makeTurnText(reference, sculptId, false, outline);
|
|
278
|
+
while (!controller.signal.aborted) {
|
|
279
|
+
if (elapsed() >= timeBudgetMs) break;
|
|
280
|
+
const roundBefore = state.round;
|
|
281
|
+
turnWait = roundBefore === 0 ? waitFor("draft", `${who} is writing the recipe`) : waitFor("refine", `${who} is weighing fixes`);
|
|
282
|
+
// Measuring is over once the first turn goes out: the stepper moves on to Draft.
|
|
283
|
+
note("agent", roundBefore === 0 ? `Asked ${who}${effort ? ` (effort ${effort})` : ""} for a first draft` : `Round ${state.round}: asked ${who} for 3-6 candidate fixes`, {
|
|
284
|
+
waiting: turnWait, ...(roundBefore === 0 ? { stage: "draft" } : {}),
|
|
285
|
+
});
|
|
286
|
+
turnPromise = consumeTurn(text);
|
|
287
|
+
try { await turnPromise; } catch (error) {
|
|
288
|
+
if (controller.signal.aborted) break;
|
|
289
|
+
note("error", `Run failed: ${shortError(error?.message || error)}`, { stage: "failed", error: String(error?.message || error), message: "Sculpt run failed.", waiting: null });
|
|
290
|
+
settled = true;
|
|
291
|
+
return;
|
|
292
|
+
} finally { turnPromise = null; }
|
|
293
|
+
if (controller.signal.aborted) break;
|
|
294
|
+
if (!turnSculpted) {
|
|
295
|
+
if (retryCount === 0) {
|
|
296
|
+
retryCount += 1;
|
|
297
|
+
note("warn", "The agent ended without sculpting; asking again", { stage: roundBefore > 0 ? "refine" : "measure", message: "No object.sculpt command arrived; retrying with a firm reminder." });
|
|
298
|
+
text = `${text} Firm reminder: you must execute object.sculpt before ending this turn, then call sculpt.compare.`;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
note("error", "No sculpt after one retry", { stage: "failed", error: "The turn ended without an object.sculpt command.", message: "No sculpt was applied after one retry.", waiting: null });
|
|
302
|
+
settled = true;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
retryCount = 0;
|
|
306
|
+
if (!turnScored && score && state.sculptId) {
|
|
307
|
+
const args = { id: state.sculptId, reference: referenceArg(reference) };
|
|
308
|
+
publish({ waiting: waitFor("score", "Scoring the silhouette against the reference") });
|
|
309
|
+
try {
|
|
310
|
+
const value = await score(args);
|
|
311
|
+
onCommand({ action: "sculpt.compare", args, result: value?.output ?? value });
|
|
312
|
+
} catch (error) {
|
|
313
|
+
note("warn", `Scoring failed: ${shortError(error?.message || error)}`, { message: `Scoring failed: ${String(error?.message || error)}` });
|
|
314
|
+
}
|
|
315
|
+
if (controller.signal.aborted) break;
|
|
316
|
+
}
|
|
317
|
+
if (state.iou !== null && state.iou >= targetIou) break;
|
|
318
|
+
if (state.round >= maxRounds || elapsed() >= timeBudgetMs) break;
|
|
319
|
+
const nextRound = Math.max(1, state.round + 1);
|
|
320
|
+
publish({ stage: "refine", round: nextRound, message: `Preparing refinement round ${nextRound} of ${maxRounds}.` });
|
|
321
|
+
// The next turn's note says what was asked; nothing to log in between.
|
|
322
|
+
text = makeTurnText(reference, state.sculptId, { round: nextRound, maxRounds, iou: state.iou, report: lastReport });
|
|
323
|
+
}
|
|
324
|
+
if (controller.signal.aborted) {
|
|
325
|
+
note("warn", "Cancelled", { stage: "cancelled", message: "Sculpt run cancelled.", waiting: null });
|
|
326
|
+
} else {
|
|
327
|
+
const reached = state.iou !== null && state.iou >= targetIou;
|
|
328
|
+
note(reached ? "done" : "ok", state.iou === null ? "Finished without a score" : `${reached ? "Target reached" : "Finished"} at IoU ${state.iou.toFixed(3)}`, { stage: "done", message: state.iou === null ? "Sculpt run finished without a score." : `Sculpt run finished at IoU ${state.iou}.`, waiting: null });
|
|
329
|
+
}
|
|
330
|
+
settled = true;
|
|
331
|
+
};
|
|
332
|
+
// A finished run stops listening to the bus, whichever way it ended.
|
|
333
|
+
const run = async () => {
|
|
334
|
+
try { await loop(); } finally { unsubscribeBus(); }
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
start() {
|
|
339
|
+
if (running) return turnPromise ?? Promise.resolve();
|
|
340
|
+
running = true;
|
|
341
|
+
return run();
|
|
342
|
+
},
|
|
343
|
+
cancel() {
|
|
344
|
+
if (settled) return;
|
|
345
|
+
controller.abort("sculpt-run-cancelled");
|
|
346
|
+
if (!running) { unsubscribeBus(); note("warn", "Cancelled", { stage: "cancelled", message: "Sculpt run cancelled.", waiting: null }); }
|
|
347
|
+
},
|
|
348
|
+
getState: () => state,
|
|
349
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
350
|
+
};
|
|
351
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Sculpt mode (#739): the isolated turntable the main viewport shows, and the
|
|
2
|
+
// inspector host the Sculpt panel mounts into.
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
5
|
+
import { OrbitControls, PerspectiveCamera } from "@react-three/drei";
|
|
6
|
+
import * as THREE from "three";
|
|
7
|
+
import { SCULPT_LAYER, isolatedObjectIds, sculptFraming } from "./sculpt-mode.js";
|
|
8
|
+
|
|
9
|
+
const SCULPT_FOV = 35;
|
|
10
|
+
// Where a new sculpt lands (object.sculpt without placement): the origin.
|
|
11
|
+
const EMPTY_TARGET = { x: 0, y: 0, z: 0, rot: 0 };
|
|
12
|
+
const EMPTY_SIZE = { width: 1, height: 1.2, depth: 1 };
|
|
13
|
+
// The turntable is set furniture inside the WebGL scene, so its tones are
|
|
14
|
+
// three.js colours chosen against the stage background (grid-view.js).
|
|
15
|
+
const TURNTABLE_TONES = {
|
|
16
|
+
dark: { floor: "#2c2e34", ring: "#4a4d56" },
|
|
17
|
+
light: { floor: "#d6d8de", ring: "#b4b7c0" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function findObjectNodes(scene, ids) {
|
|
21
|
+
const found = [];
|
|
22
|
+
if (ids.length) scene.traverse((node) => { if (ids.includes(node.userData?.sceneObjectId)) found.push(node); });
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The glass shell's dock floats over the bottom of the main pane, so the
|
|
28
|
+
* turntable is framed for the part above it: a lens shift (a view offset whose
|
|
29
|
+
* principal point is the visible region's centre) with the lens widened so
|
|
30
|
+
* that region spans SCULPT_FOV. DualRender sets aspect = pane w/h every frame,
|
|
31
|
+
* so the full frame keeps that pixel aspect.
|
|
32
|
+
*/
|
|
33
|
+
function fitVisiblePane(camera) {
|
|
34
|
+
const pane = document.querySelector(".vp-pane.vp-main")?.getBoundingClientRect();
|
|
35
|
+
if (!pane || pane.height < 2 || pane.width < 2) return;
|
|
36
|
+
const dock = document.querySelector(".bottom-window")?.getBoundingClientRect();
|
|
37
|
+
const covered = dock && dock.left < pane.right && dock.right > pane.left && dock.top < pane.bottom
|
|
38
|
+
? Math.max(0, pane.bottom - dock.top) : 0;
|
|
39
|
+
const height = pane.height, width = pane.width;
|
|
40
|
+
const visible = Math.max(height * 0.4, height - covered);
|
|
41
|
+
if (visible >= height - 1) {
|
|
42
|
+
if (camera.view?.enabled) camera.clearViewOffset();
|
|
43
|
+
camera.fov = SCULPT_FOV;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const fullHeight = 2 * height - visible;
|
|
47
|
+
const fullWidth = (width * fullHeight) / height;
|
|
48
|
+
camera.fov = (2 * Math.atan(Math.tan(((SCULPT_FOV / 2) * Math.PI) / 180) * (fullHeight / visible)) * 180) / Math.PI;
|
|
49
|
+
camera.setViewOffset(fullWidth, fullHeight, (fullWidth - width) / 2, height - visible, width, height);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function attached(node, scene) {
|
|
53
|
+
for (let current = node; current; current = current.parent) if (current === scene) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The sculpt camera, its orbit and the turntable. Rendering is isolated by
|
|
59
|
+
* layer, never by record: each frame the target's own meshes join
|
|
60
|
+
* SCULPT_LAYER and the sculpt camera sees that layer alone, so every other
|
|
61
|
+
* object, the cast and the set drop out of the main pane while the shot
|
|
62
|
+
* preview (shot camera, layer 0) still draws the scene with the sculpt in it.
|
|
63
|
+
* Unmounting (leaving the mode by any route) takes the layer back off.
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* A lump of clay that kneads itself on the turntable while a run has no sculpt
|
|
67
|
+
* yet: squash and stretch, a slow turn. It says "working" in the viewport.
|
|
68
|
+
*/
|
|
69
|
+
function ClayLump({ at }) {
|
|
70
|
+
const ref = useRef(null);
|
|
71
|
+
useFrame(({ clock }) => {
|
|
72
|
+
const mesh = ref.current;
|
|
73
|
+
if (!mesh) return;
|
|
74
|
+
const t = clock.getElapsedTime();
|
|
75
|
+
const squash = Math.sin(t * 3.2) * 0.12;
|
|
76
|
+
mesh.scale.set(1 + squash, 1 - squash, 1 + squash * 0.6);
|
|
77
|
+
mesh.rotation.y = t * 0.8;
|
|
78
|
+
mesh.position.y = at[1] + 0.32 * (1 - squash);
|
|
79
|
+
});
|
|
80
|
+
return (
|
|
81
|
+
<mesh ref={ref} position={[at[0], at[1] + 0.32, at[2]]} castShadow>
|
|
82
|
+
<icosahedronGeometry args={[0.32, 5]} />
|
|
83
|
+
<meshStandardMaterial color="#c99a6e" roughness={0.9} metalness={0} />
|
|
84
|
+
</mesh>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// How long a changed part glows after an edit lands.
|
|
89
|
+
const FLASH_MS = 1400;
|
|
90
|
+
|
|
91
|
+
export function SculptTurntable({ camRef, objects, target, size, view, light = false, busy = false, changedParts = null }) {
|
|
92
|
+
const { scene, raycaster, invalidate } = useThree();
|
|
93
|
+
const [camera, setCamera] = useState(null);
|
|
94
|
+
// The key light aims at the sculpt; its target must sit in the scene graph.
|
|
95
|
+
const [lightTarget] = useState(() => new THREE.Object3D());
|
|
96
|
+
const controlsRef = useRef(null);
|
|
97
|
+
const furnitureRef = useRef(null);
|
|
98
|
+
const nodesRef = useRef([]);
|
|
99
|
+
const isolated = isolatedObjectIds(objects, target?.id);
|
|
100
|
+
const taggedRef = useRef(new Set());
|
|
101
|
+
const framing = sculptFraming(target ?? EMPTY_TARGET, target ? size : EMPTY_SIZE, view.view, SCULPT_FOV);
|
|
102
|
+
const floorRadius = Math.max(0.9, framing.radius * 1.1);
|
|
103
|
+
const tones = light ? TURNTABLE_TONES.light : TURNTABLE_TONES.dark;
|
|
104
|
+
// Refit on a new target, a view button, or a sculpt that grew or shrank by
|
|
105
|
+
// a step while a run lands; an orbit in between is the operator's own.
|
|
106
|
+
const sizeStep = Math.round((size?.height ?? 0) * 4);
|
|
107
|
+
// While a run works the turntable turns slowly; the operator's first grab stops it for that run.
|
|
108
|
+
const [grabbed, setGrabbed] = useState(false);
|
|
109
|
+
useEffect(() => { if (busy) setGrabbed(false); }, [busy]);
|
|
110
|
+
const spinning = busy && !grabbed;
|
|
111
|
+
// The canvas draws on demand; a spinning turntable and a kneading lump need frames.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!spinning && !(busy && !target)) return undefined;
|
|
114
|
+
let frame = requestAnimationFrame(function tick() { invalidate(); frame = requestAnimationFrame(tick); });
|
|
115
|
+
return () => cancelAnimationFrame(frame);
|
|
116
|
+
}, [spinning, busy, target, invalidate]);
|
|
117
|
+
const flashRef = useRef({ nonce: null, startedAt: 0, ids: null });
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!changedParts?.nonce || changedParts.nonce === flashRef.current.nonce) return undefined;
|
|
120
|
+
flashRef.current = { nonce: changedParts.nonce, startedAt: performance.now(), ids: changedParts.ids };
|
|
121
|
+
let frame = requestAnimationFrame(function tick() {
|
|
122
|
+
invalidate();
|
|
123
|
+
if (performance.now() - flashRef.current.startedAt < FLASH_MS + 100) frame = requestAnimationFrame(tick);
|
|
124
|
+
});
|
|
125
|
+
return () => cancelAnimationFrame(frame);
|
|
126
|
+
}, [changedParts?.nonce, invalidate]);
|
|
127
|
+
|
|
128
|
+
useLayoutEffect(() => {
|
|
129
|
+
furnitureRef.current?.traverse((node) => node.layers.set(SCULPT_LAYER));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const controls = controlsRef.current;
|
|
134
|
+
if (!camera || !controls) return;
|
|
135
|
+
camera.position.set(...framing.position);
|
|
136
|
+
controls.target.set(...framing.target);
|
|
137
|
+
controls.update();
|
|
138
|
+
invalidate();
|
|
139
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
140
|
+
}, [camera, target?.id, view.nonce, sizeStep]);
|
|
141
|
+
|
|
142
|
+
// Picks follow the picture: nothing hidden can be clicked through.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const mask = raycaster.layers.mask;
|
|
145
|
+
raycaster.layers.set(SCULPT_LAYER);
|
|
146
|
+
return () => { raycaster.layers.mask = mask; };
|
|
147
|
+
}, [raycaster]);
|
|
148
|
+
|
|
149
|
+
useEffect(() => () => {
|
|
150
|
+
for (const node of taggedRef.current) node.layers.disable(SCULPT_LAYER);
|
|
151
|
+
taggedRef.current = new Set();
|
|
152
|
+
invalidate();
|
|
153
|
+
}, [invalidate]);
|
|
154
|
+
|
|
155
|
+
// Priority 0 runs before DualRender (priority 1) draws the frame.
|
|
156
|
+
useFrame(() => {
|
|
157
|
+
if (camera) {
|
|
158
|
+
camera.layers.set(SCULPT_LAYER);
|
|
159
|
+
fitVisiblePane(camera);
|
|
160
|
+
}
|
|
161
|
+
let nodes = nodesRef.current;
|
|
162
|
+
const stale = nodes.length !== isolated.length
|
|
163
|
+
|| nodes.some((node) => !isolated.includes(node.userData?.sceneObjectId) || !attached(node, scene));
|
|
164
|
+
if (stale) nodes = findObjectNodes(scene, isolated);
|
|
165
|
+
nodesRef.current = nodes;
|
|
166
|
+
const tagged = new Set();
|
|
167
|
+
for (const node of nodes) node.traverse((child) => {
|
|
168
|
+
// Editor chrome under the object (its selection cage) is not on layer 0.
|
|
169
|
+
if (!child.layers.isEnabled(0)) return;
|
|
170
|
+
child.layers.enable(SCULPT_LAYER);
|
|
171
|
+
tagged.add(child);
|
|
172
|
+
});
|
|
173
|
+
for (const old of taggedRef.current) if (!tagged.has(old)) old.layers.disable(SCULPT_LAYER);
|
|
174
|
+
taggedRef.current = tagged;
|
|
175
|
+
// The parts an edit just touched glow warm and fade (each part owns its material).
|
|
176
|
+
const flash = flashRef.current;
|
|
177
|
+
if (flash.nonce !== null) {
|
|
178
|
+
const age = performance.now() - flash.startedAt;
|
|
179
|
+
const glow = age < FLASH_MS ? Math.sin(Math.min(1, age / FLASH_MS) * Math.PI) * 0.55 : 0;
|
|
180
|
+
for (const node of nodes) node.traverse((child) => {
|
|
181
|
+
const part = child.userData?.sculptPart;
|
|
182
|
+
if (!part || !child.material?.emissive) return;
|
|
183
|
+
const hit = flash.ids === "all" || flash.ids?.includes(part);
|
|
184
|
+
child.material.emissive.setRGB(hit ? glow : 0, hit ? glow * 0.62 : 0, hit ? glow * 0.25 : 0);
|
|
185
|
+
});
|
|
186
|
+
if (age >= FLASH_MS) flash.nonce = null;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<>
|
|
192
|
+
<PerspectiveCamera ref={(node) => { camRef.current = node; setCamera(node); }} makeDefault fov={SCULPT_FOV} near={0.05} far={100} />
|
|
193
|
+
{camera && <OrbitControls ref={controlsRef} camera={camera} enableDamping={false} minDistance={0.3} maxDistance={40} autoRotate={spinning} autoRotateSpeed={1.4}
|
|
194
|
+
onStart={() => setGrabbed(true)} onChange={() => invalidate()} />}
|
|
195
|
+
<group ref={furnitureRef}>
|
|
196
|
+
<hemisphereLight args={["#ffffff", "#3a3c42", 0.9]} />
|
|
197
|
+
<primitive object={lightTarget} position={framing.target} />
|
|
198
|
+
<directionalLight
|
|
199
|
+
target={lightTarget}
|
|
200
|
+
position={[framing.target[0] + 2.5, framing.floorY + 5, framing.target[2] + 3.5]}
|
|
201
|
+
intensity={1.6}
|
|
202
|
+
castShadow
|
|
203
|
+
shadow-mapSize={[1024, 1024]}
|
|
204
|
+
shadow-camera-left={-floorRadius}
|
|
205
|
+
shadow-camera-right={floorRadius}
|
|
206
|
+
shadow-camera-top={floorRadius}
|
|
207
|
+
shadow-camera-bottom={-floorRadius}
|
|
208
|
+
/>
|
|
209
|
+
{busy && !target && <ClayLump at={[framing.target[0], framing.floorY, framing.target[2]]} />}
|
|
210
|
+
<mesh rotation-x={-Math.PI / 2} position={[framing.target[0], framing.floorY + 0.001, framing.target[2]]} receiveShadow userData={{ noInk: true }}>
|
|
211
|
+
<circleGeometry args={[floorRadius, 96]} />
|
|
212
|
+
<meshStandardMaterial color={tones.floor} roughness={0.95} metalness={0} />
|
|
213
|
+
</mesh>
|
|
214
|
+
<mesh rotation-x={-Math.PI / 2} position={[framing.target[0], framing.floorY + 0.002, framing.target[2]]} userData={{ noInk: true }}>
|
|
215
|
+
<ringGeometry args={[floorRadius * 0.985, floorRadius, 96]} />
|
|
216
|
+
<meshBasicMaterial color={tones.ring} />
|
|
217
|
+
</mesh>
|
|
218
|
+
</group>
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
}
|
package/src/settings-menu.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { ko, localeChosen } from "./locale.js";
|
|
3
3
|
import PreferencesDialog from "./shell/PreferencesDialog.jsx";
|
|
4
4
|
|
|
5
5
|
// App settings — language and analytics — behind one labelled topbar trigger
|