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,65 @@
|
|
|
1
|
+
// Sculpt blueprint (#739): one image-generation request that turns any single
|
|
2
|
+
// reference picture into an orthographic model sheet, the drawing a sculpt is
|
|
3
|
+
// built from. The template names no subject: everything subject-specific comes
|
|
4
|
+
// from the attached reference and the operator's optional notes.
|
|
5
|
+
|
|
6
|
+
/** The sheet's fixed layout, as fractions of the image: a 2 x 2 grid. FRONT and
|
|
7
|
+
* SIDE share the top row so their head and foot lines are the same pixels;
|
|
8
|
+
* BACK and TOP fill the bottom row. A later step crops each view by these. */
|
|
9
|
+
export const BLUEPRINT_LAYOUT = Object.freeze([
|
|
10
|
+
Object.freeze({ view: "front", label: "Front", x: 0, y: 0, width: 0.5, height: 0.5 }),
|
|
11
|
+
Object.freeze({ view: "side", label: "Left side", x: 0.5, y: 0, width: 0.5, height: 0.5 }),
|
|
12
|
+
Object.freeze({ view: "back", label: "Back", x: 0, y: 0.5, width: 0.5, height: 0.5 }),
|
|
13
|
+
Object.freeze({ view: "top", label: "Top", x: 0.5, y: 0.5, width: 0.5, height: 0.5 }),
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/** Landscape, so each quarter is wide enough for a wide subject. */
|
|
17
|
+
export const BLUEPRINT_SIZE = "1536x1024";
|
|
18
|
+
|
|
19
|
+
const BLUEPRINT_POSES = Object.freeze(["keep", "neutral"]);
|
|
20
|
+
|
|
21
|
+
const POSE_RULES = {
|
|
22
|
+
keep: "Pose: exactly the pose in the reference, the same in all four views.",
|
|
23
|
+
neutral: "Pose: a neutral rest pose, the same in all four views: standing or sitting upright, symmetrical, limbs held slightly away from the body so nothing crosses or hides the torso, mouth closed unless it is always open.",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The full prompt for one model sheet. `pose` is "keep" (the reference's own
|
|
28
|
+
* pose) or "neutral"; `notes` is free text from the operator (what the subject
|
|
29
|
+
* is, what to fix), passed through as written.
|
|
30
|
+
*/
|
|
31
|
+
export function buildBlueprintPrompt({ pose = "keep", notes = "" } = {}) {
|
|
32
|
+
if (!BLUEPRINT_POSES.includes(pose)) throw new Error(`pose must be one of ${BLUEPRINT_POSES.join(", ")}`);
|
|
33
|
+
const note = String(notes ?? "").trim().replace(/\s+/g, " ").slice(0, 600);
|
|
34
|
+
return [
|
|
35
|
+
"Make an orthographic model sheet (a turnaround blueprint for building a 3D model) of the main subject in the attached reference image.",
|
|
36
|
+
"Layout: one landscape image split into a 2 x 2 grid of four equal panels. Top-left: FRONT view. Top-right: LEFT SIDE view, seen from the subject's own left, so the subject faces image left. Bottom-left: BACK view. Bottom-right: TOP view, seen from directly above, with the subject's front pointing down the image.",
|
|
37
|
+
"Projection: true orthographic in every panel. No perspective, no foreshortening, no tilt. The camera is level with the subject and looks straight at it.",
|
|
38
|
+
"Scale: one scale for all four panels. In the FRONT and LEFT SIDE panels the subject's highest point sits on the same horizontal line and its lowest point sits on the same horizontal line.",
|
|
39
|
+
"Framing: each panel shows the whole subject, centred, with a clear margin on every side. Nothing is cropped, and no part crosses into another panel.",
|
|
40
|
+
"Subject: the same subject as the reference, with the same identity, proportions, colours and markings. Add nothing and remove nothing. Show what the reference hides in the way most consistent with what it shows.",
|
|
41
|
+
"Leave out everything that is not part of the subject itself: motion lines, sparkles, speech marks, drop shadows, backgrounds, frames, stitching or paper texture, and any surface the subject is printed or sewn on.",
|
|
42
|
+
POSE_RULES[pose],
|
|
43
|
+
"Style: clean flat colours with a thin dark outline, even shadowless lighting, no cast shadows, no ground plane, no texture noise.",
|
|
44
|
+
"Background: plain pure white (#FFFFFF) in every panel, with no grid lines, borders or frames.",
|
|
45
|
+
"Do not write any text, labels, numbers or arrows.",
|
|
46
|
+
...(note ? [`Notes from the operator: ${note}`] : []),
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The /agent/image body for a sheet: the reference rides as the edited image,
|
|
51
|
+
* and `composition: "sheet"` tells the sidecar to send the prompt as written. */
|
|
52
|
+
export function blueprintRequest({ reference, pose = "keep", notes = "" }) {
|
|
53
|
+
if (!reference?.dataUrl) throw new Error("a blueprint needs a reference image");
|
|
54
|
+
return { prompt: buildBlueprintPrompt({ pose, notes }), imageDataUrl: reference.dataUrl, composition: "sheet", quality: "high", size: BLUEPRINT_SIZE };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a failed sheet tells the operator. A safety refusal is the one an
|
|
58
|
+
* operator can act on, so it says what usually causes it and what to try. */
|
|
59
|
+
export function blueprintErrorMessage(error, fallback) {
|
|
60
|
+
const text = String(error?.message ?? "");
|
|
61
|
+
if (/safety system|content policy|moderation/i.test(text)) {
|
|
62
|
+
return "The image model refused this reference. Well-known characters and brands are the usual cause. Try an original design, or describe the subject in Notes as your own take on it.";
|
|
63
|
+
}
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sculpt comparison: how close is a sculpt's silhouette to its reference image?
|
|
3
|
+
*
|
|
4
|
+
* The agent used to judge a capture against its memory of the reference. This
|
|
5
|
+
* module makes that a number and a picture, without a browser:
|
|
6
|
+
*
|
|
7
|
+
* 1. the reference becomes a foreground mask (alpha when it has one, otherwise
|
|
8
|
+
* everything that differs from the border colour);
|
|
9
|
+
* 2. the recipe's parts are rasterized as a silhouette from a view (yaw,
|
|
10
|
+
* elevation, orthographic — the reference's perspective is unknown);
|
|
11
|
+
* 3. the silhouette is fitted onto the mask's bounding box (height, centre),
|
|
12
|
+
* so the score measures shape, not framing;
|
|
13
|
+
* 4. IoU, proportion deltas, the parts that stick out or fall short, and a
|
|
14
|
+
* side-by-side sheet (reference | overlay: grey both, red missing, blue extra).
|
|
15
|
+
*
|
|
16
|
+
* Pure: image buffers in, numbers and RGBA out. The MCP tool owns the files.
|
|
17
|
+
*/
|
|
18
|
+
import { expandSculptParts, patchSculptRecipe } from "./sculpt-recipe.js";
|
|
19
|
+
|
|
20
|
+
const COMPARE_SIZE = 256;
|
|
21
|
+
|
|
22
|
+
function shrink(image, size) {
|
|
23
|
+
const scale = Math.min(1, size / Math.max(image.width, image.height));
|
|
24
|
+
const width = Math.max(1, Math.round(image.width * scale));
|
|
25
|
+
const height = Math.max(1, Math.round(image.height * scale));
|
|
26
|
+
const data = new Uint8Array(width * height * 4);
|
|
27
|
+
for (let y = 0; y < height; y += 1) {
|
|
28
|
+
const sy = Math.min(image.height - 1, Math.floor(y / scale));
|
|
29
|
+
for (let x = 0; x < width; x += 1) {
|
|
30
|
+
const sx = Math.min(image.width - 1, Math.floor(x / scale));
|
|
31
|
+
const s = (sy * image.width + sx) * 4, d = (y * width + x) * 4;
|
|
32
|
+
data[d] = image.data[s]; data[d + 1] = image.data[s + 1]; data[d + 2] = image.data[s + 2]; data[d + 3] = image.data[s + 3];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { width, height, data };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function largestComponentFilled(mask, width, height) {
|
|
39
|
+
const label = new Int32Array(width * height);
|
|
40
|
+
let best = 0, bestSize = 0, next = 0;
|
|
41
|
+
const stack = [];
|
|
42
|
+
for (let i = 0; i < mask.length; i += 1) {
|
|
43
|
+
if (!mask[i] || label[i]) continue;
|
|
44
|
+
next += 1; let size = 0; stack.push(i); label[i] = next;
|
|
45
|
+
while (stack.length) {
|
|
46
|
+
const p = stack.pop(); size += 1;
|
|
47
|
+
const x = p % width, y = (p - x) / width;
|
|
48
|
+
for (const q of [x > 0 ? p - 1 : -1, x < width - 1 ? p + 1 : -1, y > 0 ? p - width : -1, y < height - 1 ? p + width : -1]) {
|
|
49
|
+
if (q >= 0 && mask[q] && !label[q]) { label[q] = next; stack.push(q); }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (size > bestSize) { bestSize = size; best = next; }
|
|
53
|
+
}
|
|
54
|
+
const kept = new Uint8Array(mask.length);
|
|
55
|
+
for (let i = 0; i < mask.length; i += 1) kept[i] = label[i] === best ? 1 : 0;
|
|
56
|
+
const outside = new Uint8Array(mask.length);
|
|
57
|
+
for (let i = 0; i < mask.length; i += 1) {
|
|
58
|
+
const x = i % width, y = (i - x) / width;
|
|
59
|
+
if ((x === 0 || y === 0 || x === width - 1 || y === height - 1) && !kept[i] && !outside[i]) { outside[i] = 1; stack.push(i); }
|
|
60
|
+
}
|
|
61
|
+
while (stack.length) {
|
|
62
|
+
const p = stack.pop(), x = p % width, y = (p - x) / width;
|
|
63
|
+
for (const q of [x > 0 ? p - 1 : -1, x < width - 1 ? p + 1 : -1, y > 0 ? p - width : -1, y < height - 1 ? p + width : -1]) {
|
|
64
|
+
if (q >= 0 && !kept[q] && !outside[q]) { outside[q] = 1; stack.push(q); }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (let i = 0; i < mask.length; i += 1) if (!outside[i]) kept[i] = 1;
|
|
68
|
+
return { mask: kept, area: bestSize };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The subject of a reference image as a mask at COMPARE_SIZE. Alpha wins when
|
|
73
|
+
* the image has a real transparent background; otherwise the border colour is
|
|
74
|
+
* the background (a flat studio backdrop, the usual reference).
|
|
75
|
+
*/
|
|
76
|
+
export function referenceMask(image, { tolerance = 40, size = COMPARE_SIZE } = {}) {
|
|
77
|
+
const small = shrink(image, size);
|
|
78
|
+
const { width, height, data } = small;
|
|
79
|
+
const n = width * height;
|
|
80
|
+
let transparent = 0;
|
|
81
|
+
for (let i = 0; i < n; i += 1) if (data[i * 4 + 3] < 128) transparent += 1;
|
|
82
|
+
const raw = new Uint8Array(n);
|
|
83
|
+
let source;
|
|
84
|
+
if (transparent > n * 0.05) {
|
|
85
|
+
source = "alpha";
|
|
86
|
+
for (let i = 0; i < n; i += 1) raw[i] = data[i * 4 + 3] >= 128 ? 1 : 0;
|
|
87
|
+
} else {
|
|
88
|
+
source = "border-colour";
|
|
89
|
+
const border = [];
|
|
90
|
+
for (let x = 0; x < width; x += 1) border.push(x, (height - 1) * width + x);
|
|
91
|
+
for (let y = 0; y < height; y += 1) border.push(y * width, y * width + width - 1);
|
|
92
|
+
const channel = (k) => border.map((i) => data[i * 4 + k]).sort((a, b) => a - b)[border.length >> 1];
|
|
93
|
+
const bg = [channel(0), channel(1), channel(2)];
|
|
94
|
+
for (let i = 0; i < n; i += 1) {
|
|
95
|
+
const d = Math.abs(data[i * 4] - bg[0]) + Math.abs(data[i * 4 + 1] - bg[1]) + Math.abs(data[i * 4 + 2] - bg[2]);
|
|
96
|
+
raw[i] = d > tolerance ? 1 : 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const { mask, area } = largestComponentFilled(raw, width, height);
|
|
100
|
+
if (area < n * 0.01) throw new Error("No subject found in the reference: give it a flat background or transparency.");
|
|
101
|
+
if (area > n * 0.97) throw new Error("The reference has no background to separate the subject from.");
|
|
102
|
+
return { width, height, mask, source, image: small };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const DEG = Math.PI / 180;
|
|
106
|
+
|
|
107
|
+
/** Orthographic view from a camera at azimuth `yawDeg` (0 = in front, +90 = on the
|
|
108
|
+
* subject's own left, +x) and `elevationDeg` above: [screen right, screen up]. */
|
|
109
|
+
function viewProject([x, y, z], yawDeg, elevationDeg) {
|
|
110
|
+
const a = yawDeg * DEG, e = elevationDeg * DEG;
|
|
111
|
+
const right = x * Math.cos(a) - z * Math.sin(a);
|
|
112
|
+
const towardCamera = x * Math.sin(a) + z * Math.cos(a);
|
|
113
|
+
return [right, y * Math.cos(e) - towardCamera * Math.sin(e)];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function applyMatrix(m, [x, y, z]) {
|
|
117
|
+
return [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14]];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function partSurfaceSamples(part) {
|
|
121
|
+
const [sx, sy, sz] = part.size.map((v) => v / 2);
|
|
122
|
+
const points = [];
|
|
123
|
+
if (part.shape === "box") {
|
|
124
|
+
for (const x of [-sx, sx]) for (const y of [-sy, sy]) for (const z of [-sz, sz]) points.push([x, y, z]);
|
|
125
|
+
return points;
|
|
126
|
+
}
|
|
127
|
+
const p = 2 + 6 * (part.roundness ?? 0);
|
|
128
|
+
const taper = part.taper ?? 0;
|
|
129
|
+
for (let i = 0; i <= 16; i += 1) {
|
|
130
|
+
const v = -1 + (2 * i) / 16;
|
|
131
|
+
const k = taper > 0 ? 1 - taper * 0.6 * ((v + 1) / 2) : taper < 0 ? 1 + taper * 0.6 * ((1 - v) / 2) : 1;
|
|
132
|
+
for (let j = 0; j < 24; j += 1) {
|
|
133
|
+
const t = (j / 24) * 2 * Math.PI;
|
|
134
|
+
if (part.shape === "cylinder") {
|
|
135
|
+
const r = 1 - Math.max(0, part.taper) * ((v + 1) / 2) - Math.max(0, -part.taper) * ((1 - v) / 2);
|
|
136
|
+
points.push([Math.cos(t) * sx * r, v * sy, Math.sin(t) * sz * r]);
|
|
137
|
+
} else if (part.shape === "torus" || part.shape === "frame") {
|
|
138
|
+
points.push([Math.cos(t) * sx, Math.sin(t) * sy, v * sz]);
|
|
139
|
+
if (part.shape === "frame") points.push([Math.sign(Math.cos(t)) * sx, Math.sign(Math.sin(t)) * sy, v * sz]);
|
|
140
|
+
} else {
|
|
141
|
+
const ring = Math.max(0, 1 - Math.abs(v) ** p) ** (1 / p);
|
|
142
|
+
const c = Math.cos(t), s = Math.sin(t);
|
|
143
|
+
const norm = (Math.abs(c) ** p + Math.abs(s) ** p) ** (1 / p);
|
|
144
|
+
points.push([(c / norm) * sx * ring * k, v * sy, (s / norm) * sz * ring * k]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return points;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function convexHull(points) {
|
|
152
|
+
const pts = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
153
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
154
|
+
const lower = [], upper = [];
|
|
155
|
+
for (const p of pts) { while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), p) <= 0) lower.pop(); lower.push(p); }
|
|
156
|
+
for (const p of pts.reverse()) { while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), p) <= 0) upper.pop(); upper.push(p); }
|
|
157
|
+
return lower.slice(0, -1).concat(upper.slice(0, -1));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Projected convex outline of every drawn part, in view metres. Frames keep their hole. */
|
|
161
|
+
export function sculptViewPolygons(recipe, { yaw = 0, elevation = 0 } = {}) {
|
|
162
|
+
return expandSculptParts(recipe).map((part) => {
|
|
163
|
+
const local = partSurfaceSamples(part);
|
|
164
|
+
const polygon = convexHull(local.map((point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation)));
|
|
165
|
+
let hole = null;
|
|
166
|
+
if (part.shape === "frame" || part.shape === "torus") {
|
|
167
|
+
const ix = part.size[0] / 2 - (part.shape === "frame" ? part.border : part.size[2]);
|
|
168
|
+
const iy = part.size[1] / 2 - (part.shape === "frame" ? part.border : part.size[2]);
|
|
169
|
+
if (ix > 0 && iy > 0) {
|
|
170
|
+
const corners = [[-ix, -iy], [ix, -iy], [ix, iy], [-ix, iy]].flatMap(([x, y]) => [[x, y, -part.size[2] / 2], [x, y, part.size[2] / 2]]);
|
|
171
|
+
const projected = corners.map((point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation));
|
|
172
|
+
// See-through only where the front and back openings overlap; the back
|
|
173
|
+
// face's opening alone is a slightly generous stand-in.
|
|
174
|
+
const opening = convexHull(projected.filter((_, i) => i % 2 === 0));
|
|
175
|
+
hole = opening.length >= 3 ? opening : null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { id: part.id, polygon, hole };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function insidePolygon(polygon, x, y) {
|
|
183
|
+
let inside = false;
|
|
184
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
185
|
+
const [xi, yi] = polygon[i], [xj, yj] = polygon[j];
|
|
186
|
+
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
187
|
+
}
|
|
188
|
+
return inside;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function boundsOf(polygons) {
|
|
192
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
193
|
+
for (const { polygon } of polygons) for (const [x, y] of polygon) {
|
|
194
|
+
minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y);
|
|
195
|
+
}
|
|
196
|
+
return { minX, maxX, minY, maxY };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function maskBounds(mask, width, height) {
|
|
200
|
+
let minX = width, maxX = -1, minY = height, maxY = -1;
|
|
201
|
+
for (let i = 0; i < mask.length; i += 1) if (mask[i]) {
|
|
202
|
+
const x = i % width, y = (i - x) / width;
|
|
203
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
204
|
+
}
|
|
205
|
+
return { minX, maxX: maxX + 1, minY, maxY: maxY + 1 };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Rasterize the sculpt so its bounding box sits on `box` (pixel space, height
|
|
210
|
+
* matched, centred). Returns the mask and a per-pixel owner (part index + 1).
|
|
211
|
+
*/
|
|
212
|
+
function rasterizeFitted(polygons, width, height, box, shiftX = 0) {
|
|
213
|
+
const world = boundsOf(polygons);
|
|
214
|
+
const scale = (box.maxY - box.minY) / Math.max(1e-6, world.maxY - world.minY);
|
|
215
|
+
const cx = (box.minX + box.maxX) / 2 - ((world.minX + world.maxX) / 2) * scale + shiftX;
|
|
216
|
+
const top = box.minY + world.maxY * scale;
|
|
217
|
+
const mask = new Uint8Array(width * height), owner = new Int32Array(width * height);
|
|
218
|
+
const pixel = polygons.map(({ id, polygon, hole }) => ({
|
|
219
|
+
id,
|
|
220
|
+
polygon: polygon.map(([x, y]) => [cx + x * scale, top - y * scale]),
|
|
221
|
+
hole: hole?.map(([x, y]) => [cx + x * scale, top - y * scale]) ?? null,
|
|
222
|
+
}));
|
|
223
|
+
pixel.forEach(({ polygon, hole }, index) => {
|
|
224
|
+
if (polygon.length < 3) return;
|
|
225
|
+
const b = boundsOf([{ polygon }]);
|
|
226
|
+
for (let y = Math.max(0, Math.floor(b.minY)); y < Math.min(height, Math.ceil(b.maxY)); y += 1) {
|
|
227
|
+
for (let x = Math.max(0, Math.floor(b.minX)); x < Math.min(width, Math.ceil(b.maxX)); x += 1) {
|
|
228
|
+
if (!insidePolygon(polygon, x + 0.5, y + 0.5)) continue;
|
|
229
|
+
if (hole && insidePolygon(hole, x + 0.5, y + 0.5)) continue;
|
|
230
|
+
mask[y * width + x] = 1;
|
|
231
|
+
owner[y * width + x] = index + 1;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
return { mask, owner, parts: pixel, shiftX };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const MISSING = [220, 40, 40], EXTRA = [40, 90, 220], BOTH = [190, 190, 190];
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Compare a recipe with a reference image (RGBA). `yaw` is the reference
|
|
242
|
+
* camera's azimuth: 0 is straight on, positive moves the camera round to the
|
|
243
|
+
* subject's own left (+x) side; `elevation` looks down from above.
|
|
244
|
+
*/
|
|
245
|
+
export function compareSculptToReference(recipe, image, { yaw = 0, elevation = 0 } = {}) {
|
|
246
|
+
const ref = referenceMask(image);
|
|
247
|
+
const { width, height } = ref;
|
|
248
|
+
const box = maskBounds(ref.mask, width, height);
|
|
249
|
+
const polygons = sculptViewPolygons(recipe, { yaw, elevation });
|
|
250
|
+
// Height and top are matched; the horizontal offset is searched, because a
|
|
251
|
+
// centred bounding box blames the body when only an arm sticks out.
|
|
252
|
+
const overlap = (sil) => {
|
|
253
|
+
let both = 0, union = 0;
|
|
254
|
+
for (let i = 0; i < sil.mask.length; i += 1) { both += ref.mask[i] & sil.mask[i]; union += ref.mask[i] | sil.mask[i]; }
|
|
255
|
+
return both / Math.max(1, union);
|
|
256
|
+
};
|
|
257
|
+
let sil = rasterizeFitted(polygons, width, height, box), bestScore = overlap(sil);
|
|
258
|
+
const reach = Math.round((box.maxX - box.minX) / 2);
|
|
259
|
+
for (let step = 8; step >= 1; step >>= 1) {
|
|
260
|
+
for (const direction of [-1, 1]) {
|
|
261
|
+
for (let shift = sil.shiftX + direction * step; Math.abs(shift) <= reach; shift += direction * step) {
|
|
262
|
+
const candidate = rasterizeFitted(polygons, width, height, box, shift), score = overlap(candidate);
|
|
263
|
+
if (score <= bestScore) break;
|
|
264
|
+
sil = candidate; bestScore = score;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
let both = 0, missing = 0, extra = 0;
|
|
269
|
+
const extraByPart = new Map();
|
|
270
|
+
for (let i = 0; i < ref.mask.length; i += 1) {
|
|
271
|
+
const r = ref.mask[i], s = sil.mask[i];
|
|
272
|
+
if (r && s) both += 1;
|
|
273
|
+
else if (r) missing += 1;
|
|
274
|
+
else if (s) { extra += 1; const id = sil.parts[sil.owner[i] - 1].id; extraByPart.set(id, (extraByPart.get(id) ?? 0) + 1); }
|
|
275
|
+
}
|
|
276
|
+
const area = both + missing;
|
|
277
|
+
// Missing reference pixels, in thirds of the subject's height, so the agent knows where to add.
|
|
278
|
+
const bandMissing = [0, 0, 0];
|
|
279
|
+
const bandHeight = (box.maxY - box.minY) / 3;
|
|
280
|
+
for (let i = 0; i < ref.mask.length; i += 1) if (ref.mask[i] && !sil.mask[i]) {
|
|
281
|
+
const y = (i - (i % width)) / width;
|
|
282
|
+
bandMissing[Math.min(2, Math.max(0, Math.floor((y - box.minY) / bandHeight)))] += 1;
|
|
283
|
+
}
|
|
284
|
+
const silBox = maskBounds(sil.mask, width, height);
|
|
285
|
+
const refAspect = (box.maxX - box.minX) / (box.maxY - box.minY);
|
|
286
|
+
const silAspect = (silBox.maxX - silBox.minX) / Math.max(1, silBox.maxY - silBox.minY);
|
|
287
|
+
const round = (v, d = 3) => Number(v.toFixed(d));
|
|
288
|
+
const report = {
|
|
289
|
+
iou: round(both / Math.max(1, both + missing + extra)),
|
|
290
|
+
missingFraction: round(missing / Math.max(1, area)),
|
|
291
|
+
extraFraction: round(extra / Math.max(1, area)),
|
|
292
|
+
widthToHeight: { reference: round(refAspect), sculpt: round(silAspect), delta: round(silAspect - refAspect) },
|
|
293
|
+
missingByBand: { top: round(bandMissing[0] / Math.max(1, area)), middle: round(bandMissing[1] / Math.max(1, area)), bottom: round(bandMissing[2] / Math.max(1, area)) },
|
|
294
|
+
extraByPart: [...extraByPart.entries()].map(([id, px]) => ({ id, fraction: round(px / Math.max(1, area)) })).filter((row) => row.fraction >= 0.005).sort((a, b) => b.fraction - a.fraction).slice(0, 6),
|
|
295
|
+
mask: ref.source,
|
|
296
|
+
view: { yaw, elevation },
|
|
297
|
+
};
|
|
298
|
+
const sheet = new Uint8Array(width * 2 * height * 4);
|
|
299
|
+
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
|
300
|
+
const s = (y * width + x) * 4, i = y * width + x;
|
|
301
|
+
const left = (y * width * 2 + x) * 4, right = left + width * 4;
|
|
302
|
+
const alpha = ref.image.data[s + 3] / 255;
|
|
303
|
+
for (let k = 0; k < 3; k += 1) sheet[left + k] = Math.round(ref.image.data[s + k] * alpha + 255 * (1 - alpha));
|
|
304
|
+
sheet[left + 3] = 255;
|
|
305
|
+
const colour = ref.mask[i] && sil.mask[i] ? BOTH : ref.mask[i] ? MISSING : sil.mask[i] ? EXTRA : [255, 255, 255];
|
|
306
|
+
sheet[right] = colour[0]; sheet[right + 1] = colour[1]; sheet[right + 2] = colour[2]; sheet[right + 3] = 255;
|
|
307
|
+
}
|
|
308
|
+
return { report, sheet: { width: width * 2, height, data: sheet } };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Try a few yaws and keep the best: the reference's angle is usually a guess. */
|
|
312
|
+
export function bestViewForReference(recipe, image, { yaws = [-45, -30, -15, 0, 15, 30, 45], elevation = 0 } = {}) {
|
|
313
|
+
let best = null;
|
|
314
|
+
for (const yaw of yaws) {
|
|
315
|
+
const result = compareSculptToReference(recipe, image, { yaw, elevation });
|
|
316
|
+
if (!best || result.report.iou > best.report.iou) best = result;
|
|
317
|
+
}
|
|
318
|
+
return best;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The one scoring path behind MCP compare_sculpt and the editor's sculpt.compare:
|
|
323
|
+
* score the recipe (at `yaw`, or the best of -45..45 when it is undefined), and
|
|
324
|
+
* when `candidates` are given, score each patched recipe from that same view and
|
|
325
|
+
* rank them with the current sculpt. Nothing is applied.
|
|
326
|
+
* Returns { result, ranked?: [{ label, result }] best first, refused?: [{ label, error }] }.
|
|
327
|
+
*/
|
|
328
|
+
export function scoreSculptAgainstReference(recipe, image, { yaw, elevation = 0, candidates } = {}) {
|
|
329
|
+
const score = (next, view) => (view === undefined ? bestViewForReference(next, image, { elevation }) : compareSculptToReference(next, image, { yaw: view, elevation }));
|
|
330
|
+
const result = score(recipe, yaw);
|
|
331
|
+
if (!candidates) return { result };
|
|
332
|
+
// Every candidate is scored from the same view as the current sculpt, so the ranking compares shapes, not angles.
|
|
333
|
+
const view = result.report.view.yaw;
|
|
334
|
+
const ranked = [{ label: "current", result }];
|
|
335
|
+
const refused = [];
|
|
336
|
+
for (const candidate of candidates) {
|
|
337
|
+
const patched = patchSculptRecipe(recipe, candidate.edits);
|
|
338
|
+
if (!patched.ok) refused.push({ label: candidate.label, error: patched.error });
|
|
339
|
+
else ranked.push({ label: candidate.label, result: score(patched.recipe, view) });
|
|
340
|
+
}
|
|
341
|
+
ranked.sort((a, b) => b.result.report.iou - a.result.report.iou);
|
|
342
|
+
return { result, ranked, refused };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** One ranking row: the candidate's score against the current sculpt's. */
|
|
346
|
+
export function rankingRow({ label, result }, current) {
|
|
347
|
+
const { report } = result;
|
|
348
|
+
return { label, iou: report.iou, delta: Number((report.iou - current.report.iou).toFixed(3)), missingByBand: report.missingByBand, extraByPart: report.extraByPart.slice(0, 3) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The reference outline measured in pixels, for the agent's first draft: the subject's
|
|
353
|
+
* width:height and, for `rows` horizontal slices from the head top down, the filled
|
|
354
|
+
* spans across that slice. x is a fraction of the subject's height from its horizontal
|
|
355
|
+
* centre, positive toward image right (the subject's own left in a front view); y is a
|
|
356
|
+
* fraction of height from the top. Measuring beats eyeballing: a measured draft scored
|
|
357
|
+
* IoU 0.945 where an eyeballed one scored about 0.75.
|
|
358
|
+
*/
|
|
359
|
+
export function measureReferenceOutline(image, { rows = 12 } = {}) {
|
|
360
|
+
const { width, height, mask } = referenceMask(image);
|
|
361
|
+
let top = height, bottom = -1, left = width, right = -1;
|
|
362
|
+
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
|
363
|
+
if (!mask[y * width + x]) continue;
|
|
364
|
+
if (y < top) top = y; if (y > bottom) bottom = y; if (x < left) left = x; if (x > right) right = x;
|
|
365
|
+
}
|
|
366
|
+
if (bottom < 0) throw new Error("no subject found in the reference image");
|
|
367
|
+
const h = bottom - top + 1, centre = (left + right + 1) / 2;
|
|
368
|
+
const round = value => Math.round(value * 100) / 100;
|
|
369
|
+
const slices = [];
|
|
370
|
+
for (let i = 0; i < rows; i += 1) {
|
|
371
|
+
const y = Math.min(bottom, Math.round(top + ((i + 0.5) / rows) * h));
|
|
372
|
+
const spans = [];
|
|
373
|
+
let start = -1;
|
|
374
|
+
for (let x = 0; x <= width; x += 1) {
|
|
375
|
+
const on = x < width && mask[y * width + x];
|
|
376
|
+
if (on && start < 0) start = x;
|
|
377
|
+
if (!on && start >= 0) { if (x - start >= 2) spans.push([round((start - centre) / h), round((x - centre) / h)]); start = -1; }
|
|
378
|
+
}
|
|
379
|
+
slices.push({ y: round((y - top) / h), spans });
|
|
380
|
+
}
|
|
381
|
+
// Where the subject sits in the image (fractions of the image), so a view can draw the slices over it.
|
|
382
|
+
const box = { left: (left) / width, top: top / height, width: (right - left + 1) / width, height: h / height, aspect: height / width };
|
|
383
|
+
return { widthToHeight: round((right - left + 1) / h), rows: slices, box };
|
|
384
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sculpt geometry: a normalized recipe (sculpt-recipe.js) becomes a three.js
|
|
3
|
+
* Group of clay meshes, one per drawn part, each named by its part id.
|
|
4
|
+
*
|
|
5
|
+
* A blob is a superellipsoid built by remapping a UV sphere along each vertex
|
|
6
|
+
* direction: |x/a|^p + |y/b|^p + |z/c|^p = 1. p = 2 is an ellipsoid and a large
|
|
7
|
+
* p flattens the faces toward a rounded box, which is what a chibi head or a
|
|
8
|
+
* pillow needs and what no stock primitive gives. Every shape stays inside its
|
|
9
|
+
* declared size, so the record's measured box (sculptStandingBox) never lies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as THREE from "three";
|
|
13
|
+
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
|
|
14
|
+
import { expandSculptParts } from "./sculpt-recipe.js";
|
|
15
|
+
|
|
16
|
+
const CLAY_ROUGHNESS = 0.82;
|
|
17
|
+
/** How far a full taper narrows a blob's end. A blob at 1.0 would come to a
|
|
18
|
+
* point, which reads as a cone; 0.6 keeps a pear a pear. */
|
|
19
|
+
const BLOB_TAPER = 0.6;
|
|
20
|
+
|
|
21
|
+
/** roundness 0..1 → superellipse exponent 2..8 */
|
|
22
|
+
const blobExponent = (roundness) => 2 + 6 * roundness;
|
|
23
|
+
|
|
24
|
+
/** The x/z scale at height fraction t (−1 bottom, +1 top). Positive taper
|
|
25
|
+
* narrows the top, negative narrows the bottom; neither end grows. */
|
|
26
|
+
function taperScale(taper, t, strength) {
|
|
27
|
+
if (taper > 0) return 1 - taper * strength * ((t + 1) / 2);
|
|
28
|
+
if (taper < 0) return 1 + taper * strength * ((1 - t) / 2);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function blobGeometry(part) {
|
|
33
|
+
const [a, b, c] = part.size.map((value) => value / 2);
|
|
34
|
+
const p = blobExponent(part.roundness);
|
|
35
|
+
const geometry = new THREE.SphereGeometry(1, 64, 48);
|
|
36
|
+
const position = geometry.getAttribute("position");
|
|
37
|
+
const v = new THREE.Vector3();
|
|
38
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
39
|
+
v.fromBufferAttribute(position, i);
|
|
40
|
+
const s = Math.abs(v.x) ** p + Math.abs(v.y) ** p + Math.abs(v.z) ** p;
|
|
41
|
+
v.multiplyScalar(s > 0 ? s ** (-1 / p) : 0);
|
|
42
|
+
const k = taperScale(part.taper, v.y, BLOB_TAPER);
|
|
43
|
+
position.setXYZ(i, v.x * a * k, v.y * b, v.z * c * k);
|
|
44
|
+
}
|
|
45
|
+
geometry.computeVertexNormals();
|
|
46
|
+
return geometry;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function boxGeometry(part) {
|
|
50
|
+
const [w, h, d] = part.size;
|
|
51
|
+
const radius = (part.roundness * Math.min(w, h, d)) / 2;
|
|
52
|
+
if (radius <= 1e-4) return new THREE.BoxGeometry(w, h, d);
|
|
53
|
+
return new RoundedBoxGeometry(w, h, d, 4, radius);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An upright cylinder with an elliptical section (size x by size z). Taper 1
|
|
57
|
+
* is a cone; −1 is a cone standing on its point. */
|
|
58
|
+
function cylinderGeometry(part) {
|
|
59
|
+
const [w, h, d] = part.size;
|
|
60
|
+
const r = w / 2;
|
|
61
|
+
const top = r * Math.max(0, 1 - Math.max(0, part.taper));
|
|
62
|
+
const bottom = r * Math.max(0, 1 - Math.max(0, -part.taper));
|
|
63
|
+
const geometry = new THREE.CylinderGeometry(top, bottom, h, 40, 1);
|
|
64
|
+
geometry.scale(1, 1, d / w);
|
|
65
|
+
return geometry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A ring in the x/y plane: size [outer x, outer y, tube diameter]. */
|
|
69
|
+
function torusGeometry(part) {
|
|
70
|
+
const [w, h, d] = part.size;
|
|
71
|
+
const tube = d / 2;
|
|
72
|
+
const geometry = new THREE.TorusGeometry(w / 2 - tube, tube, 20, 64);
|
|
73
|
+
geometry.scale(1, (h - d) / (w - d), 1);
|
|
74
|
+
return geometry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A rectangular ring in the x/y plane, `border` metres wide, size z deep. */
|
|
78
|
+
function frameGeometry(part) {
|
|
79
|
+
const [w, h, d] = part.size;
|
|
80
|
+
const outerX = w / 2, outerY = h / 2;
|
|
81
|
+
const innerX = outerX - part.border, innerY = outerY - part.border;
|
|
82
|
+
const shape = new THREE.Shape();
|
|
83
|
+
shape.moveTo(-outerX, -outerY);
|
|
84
|
+
shape.lineTo(outerX, -outerY);
|
|
85
|
+
shape.lineTo(outerX, outerY);
|
|
86
|
+
shape.lineTo(-outerX, outerY);
|
|
87
|
+
shape.closePath();
|
|
88
|
+
const hole = new THREE.Path();
|
|
89
|
+
hole.moveTo(-innerX, -innerY);
|
|
90
|
+
hole.lineTo(-innerX, innerY);
|
|
91
|
+
hole.lineTo(innerX, innerY);
|
|
92
|
+
hole.lineTo(innerX, -innerY);
|
|
93
|
+
hole.closePath();
|
|
94
|
+
shape.holes.push(hole);
|
|
95
|
+
const geometry = new THREE.ExtrudeGeometry(shape, { depth: d, bevelEnabled: false, steps: 1 });
|
|
96
|
+
geometry.translate(0, 0, -d / 2);
|
|
97
|
+
return geometry;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const BUILDERS = { blob: blobGeometry, box: boxGeometry, cylinder: cylinderGeometry, torus: torusGeometry, frame: frameGeometry };
|
|
101
|
+
|
|
102
|
+
export function sculptPartGeometry(part) {
|
|
103
|
+
const build = BUILDERS[part.shape];
|
|
104
|
+
if (!build) throw new Error(`unknown sculpt shape "${part.shape}"`);
|
|
105
|
+
return build(part);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The drawable object for a normalized recipe. `materialFor(part)` lets the
|
|
110
|
+
* caller decide the look (clay, auto-colour, depth rank); the default is the
|
|
111
|
+
* matte clay every primitive wears. Meshes carry their part id as the name and
|
|
112
|
+
* as `userData.sculptPart`, so a capture or a raycast can say which part it hit.
|
|
113
|
+
*/
|
|
114
|
+
export function buildSculptGroup(recipe, { materialFor } = {}) {
|
|
115
|
+
const group = new THREE.Group();
|
|
116
|
+
group.userData.sculpt = true;
|
|
117
|
+
const matrix = new THREE.Matrix4();
|
|
118
|
+
for (const part of expandSculptParts(recipe)) {
|
|
119
|
+
const material = materialFor ? materialFor(part) : new THREE.MeshStandardMaterial({ color: part.color, roughness: CLAY_ROUGHNESS, metalness: 0 });
|
|
120
|
+
const mesh = new THREE.Mesh(sculptPartGeometry(part), material);
|
|
121
|
+
mesh.name = part.id;
|
|
122
|
+
mesh.userData.sculptPart = part.id;
|
|
123
|
+
matrix.fromArray(part.matrix);
|
|
124
|
+
matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
|
|
125
|
+
mesh.castShadow = true;
|
|
126
|
+
mesh.receiveShadow = true;
|
|
127
|
+
group.add(mesh);
|
|
128
|
+
}
|
|
129
|
+
return group;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Frees what buildSculptGroup allocated. Materials are disposed too, because
|
|
133
|
+
* buildSculptGroup (or the caller's materialFor) minted one per part. */
|
|
134
|
+
export function disposeSculptGroup(group) {
|
|
135
|
+
group?.traverse((node) => {
|
|
136
|
+
if (!node.isMesh) return;
|
|
137
|
+
node.geometry.dispose();
|
|
138
|
+
const materials = Array.isArray(node.material) ? node.material : [node.material];
|
|
139
|
+
for (const material of materials) material.dispose();
|
|
140
|
+
});
|
|
141
|
+
}
|