cozyclay 3.0.3 → 3.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 +93 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +29 -62
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +2 -0
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +4 -3
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/app-DmR7jBkz.js +5144 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
- package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
- package/dist/assets/task-board-ErQvfoaz.js +14 -0
- package/dist/assets/workflow-zQM-EKW1.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/mcp/tool-handlers.mjs +2 -2
- package/package.json +5 -3
- package/src/App.jsx +51 -282
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-tasks.js +11 -5
- package/src/command-bus.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +3 -3
- package/src/commands/objects.js +1 -1
- package/src/commands/project.js +1 -1
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/compile.js +7 -1
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +3 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +10 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +9 -8
- package/src/motion3/schema/types.js +4 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +10 -3
- package/src/motion3/skills/info.js +13 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-look.js +9 -5
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/primitive-look.js +38 -0
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +24 -35
- package/src/render-passes.js +7 -1
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +5 -9
- package/src/scenes.js +4 -5
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +1 -1
- package/src/studio-elements.js +6 -2
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +79 -46
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +25 -36
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +10 -17
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-BibyE5ar.js +0 -6
- package/dist/assets/app-DhRJ-PBz.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
- package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
- package/dist/assets/landing-DYg87MDY.js +0 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-BSA22Ofr.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- package/tools/perf/ik-camera-drag.mjs +0 -138
package/src/cuts.js
CHANGED
|
@@ -127,6 +127,17 @@ export function addShotAtFrame(shots, frame, frameCount, currentFraming, duratio
|
|
|
127
127
|
return [...list, createShot(`Shot ${list.length + 1}`, target, endFrame, keys)].sort((a, b) => a.startFrame - b.startFrame);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Add Shot when the take has no free time left: the take grows by one default
|
|
131
|
+
* shot at its end and the new shot fills that, so the button never silently does
|
|
132
|
+
* nothing. Existing shots are untouched. Null past maxFrameCount. */
|
|
133
|
+
export function appendShotGrowingTake(shots, frameCount, currentFraming, maxFrameCount) {
|
|
134
|
+
const list = Array.isArray(shots) ? shots : [];
|
|
135
|
+
const startFrame = Math.max(frameNumber(frameCount, 1), ...list.map((shot) => shot.endFrame + 1));
|
|
136
|
+
const grownCount = startFrame + DEFAULT_SHOT_FRAMES;
|
|
137
|
+
if (grownCount > maxFrameCount) return null;
|
|
138
|
+
return { shots: addShotAtFrame(list, startFrame, grownCount, currentFraming), frameCount: grownCount };
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
export function resizeShot(shots, shotId, edge, rawFrame, frameCount) {
|
|
131
142
|
const shot = shotById(shots, shotId);
|
|
132
143
|
if (edge !== "start" && edge !== "end") return shots;
|
package/src/domains/cast.js
CHANGED
|
@@ -546,7 +546,7 @@ export function useCast(appContext) {
|
|
|
546
546
|
return current.length;
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
-
/**
|
|
549
|
+
/** Click-to-place authoring: each empty-floor press in the Shot view drops
|
|
550
550
|
the next waypoint where it was clicked; the frame gap comes from walking
|
|
551
551
|
distance. The bird's-eye board selects and drags existing waypoints. */
|
|
552
552
|
function addFloorWaypoint(point) {
|
|
@@ -715,7 +715,8 @@ export function useCast(appContext) {
|
|
|
715
715
|
}
|
|
716
716
|
function undoScene() {
|
|
717
717
|
const receipt = appContext.bus.run('edit.undo');
|
|
718
|
-
|
|
718
|
+
// A refusal says why: the last edit belongs to a running task (parallel tasks), which its own card undoes.
|
|
719
|
+
if (receipt.status === 'noop' || receipt.ok === false) appContext.notify(receipt.ok === false ? ko(receipt.message ?? "Undo was refused", `실행 취소 불가: ${receipt.message ?? ""}`) : ko("Nothing to undo", "실행 취소할 작업이 없어요"));
|
|
719
720
|
return receipt;
|
|
720
721
|
}
|
|
721
722
|
function redoScene() {
|
package/src/domains/motion.js
CHANGED
|
@@ -29,8 +29,8 @@ import {
|
|
|
29
29
|
} from "../motion/ik.js";
|
|
30
30
|
import * as THREE from "three";
|
|
31
31
|
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
32
|
-
|
|
33
32
|
import { applyTakeFrame } from "../motion3/playback.js";
|
|
33
|
+
import { advancePlayback, installPlaybackApplier } from "./playback.js";
|
|
34
34
|
import { isKo, ko } from "../locale.js";
|
|
35
35
|
import { isProxyFigure } from "../scenes.js";
|
|
36
36
|
import { refuseRigOnly } from "../character-kind.js";
|
|
@@ -625,15 +625,7 @@ export function useMotion(appContext) {
|
|
|
625
625
|
|
|
626
626
|
|
|
627
627
|
function advanceFrame(steps = 1) {
|
|
628
|
-
|
|
629
|
-
const previewEnd = appContext.shared.cameraPreviewEndRef.current;
|
|
630
|
-
if (previewEnd != null && appContext.shared.tlFrameRef.current + count >= previewEnd) {
|
|
631
|
-
appContext.shared.cameraPreviewEndRef.current = null;
|
|
632
|
-
appContext.shared.setTlFrame(previewEnd);
|
|
633
|
-
appContext.shared.setTlPlaying(false);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
appContext.shared.setTlFrame((f) => (f + count) % appContext.shared.frameCountRef.current);
|
|
628
|
+
advancePlayback(appContext.shared, steps);
|
|
637
629
|
}
|
|
638
630
|
|
|
639
631
|
function stepFrame(delta) {
|
|
@@ -677,6 +669,7 @@ export function useMotion(appContext) {
|
|
|
677
669
|
appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
|
|
678
670
|
}
|
|
679
671
|
};
|
|
672
|
+
installPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, IK_CORRECTION_BLEND_FRAMES);
|
|
680
673
|
|
|
681
674
|
function toggleIkMode() {
|
|
682
675
|
const next = !ikMode;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { advancePlaybackFrame, registerPlaybackFrameApplier } from "../motion/playback-frame-store.js";
|
|
2
|
+
|
|
3
|
+
function createPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, blendFrames) {
|
|
4
|
+
return (frame) => {
|
|
5
|
+
appContext.shared.tlFrameRef.current = frame;
|
|
6
|
+
appContext.shared.propFrameRef.current = frame;
|
|
7
|
+
appContext.patchTimeline({ currentFrame: frame });
|
|
8
|
+
if (appContext.shared.activeRig) appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, frame);
|
|
9
|
+
poseOtherCastMembers(frame);
|
|
10
|
+
const state = appContext.shared.ikStateRef.current;
|
|
11
|
+
if (!appContext.shared.posing && state?.chains && (appContext.shared.ikMode || state.keys.size > 0)) {
|
|
12
|
+
ikEvaluate(state.chains, state, frame, state.fkJoints, motion ? blendFrames : 0);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function installPlaybackApplier(...args) {
|
|
18
|
+
return registerPlaybackFrameApplier(createPlaybackApplier(...args));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A camera-shot preview stops at its shot's end; otherwise the playhead wraps.
|
|
22
|
+
export function advancePlayback(shared, steps) {
|
|
23
|
+
const count = Math.max(1, Math.floor(steps));
|
|
24
|
+
const previewEnd = shared.cameraPreviewEndRef.current;
|
|
25
|
+
if (previewEnd != null && shared.tlFrameRef.current + count >= previewEnd) {
|
|
26
|
+
shared.cameraPreviewEndRef.current = null;
|
|
27
|
+
shared.setTlFrame(previewEnd);
|
|
28
|
+
shared.setTlPlaying(false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
advancePlaybackFrame(shared.frameCountRef.current, count);
|
|
32
|
+
}
|
package/src/domains/scenes.js
CHANGED
|
@@ -208,9 +208,8 @@ export function useScenes(appContext) {
|
|
|
208
208
|
// A first-run author should choose a document (or explicitly start a named
|
|
209
209
|
// local draft). Keep this as a light startup sheet so the studio remains
|
|
210
210
|
// inspectable while the choice is pending; it never traps the topbar.
|
|
211
|
-
// ?
|
|
212
|
-
|
|
213
|
-
const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !appContext.shared.cameraTutorialQuery && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
|
|
211
|
+
// A ?scene= launch opens its starter instead of the chooser.
|
|
212
|
+
const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
|
|
214
213
|
|
|
215
214
|
const [projectManifest, setProjectManifest] = useState({ items: [], totals: { embedded: 0, external: 0, missing: 0, bytes: 0 }, missing: [] });
|
|
216
215
|
|
|
@@ -400,10 +399,6 @@ export function useScenes(appContext) {
|
|
|
400
399
|
if (!["valid", "migrated"].includes(checked.status)) throw new StudioProtocolError("INVALID_ARGUMENT", "Invalid project scene document.");
|
|
401
400
|
project = { ...project, scenesDocument: checked.document };
|
|
402
401
|
appContext.shared.studioDocumentEpochRef.current = crypto.randomUUID();
|
|
403
|
-
appContext.shared.tutorialProjectEpochRef.current += 1;
|
|
404
|
-
appContext.shared.tutorialSeedEpochRef.current = null;
|
|
405
|
-
appContext.shared.setTutorialSeedPending(false);
|
|
406
|
-
appContext.shared.setCameraTutorialHandoff(null);
|
|
407
402
|
appContext.shared.exportShotIdRef.current = null;
|
|
408
403
|
const source = project.scenesDocument;
|
|
409
404
|
// A project FILE carries its own scene document and never passes the
|
|
@@ -425,25 +420,16 @@ export function useScenes(appContext) {
|
|
|
425
420
|
domain.setDirty(false);
|
|
426
421
|
storeProjectSession(project.name, projectPrevisMode);
|
|
427
422
|
setProjectStartupOpen(false);
|
|
428
|
-
// Whatever document this is, it is no longer the scene the tutorial opened
|
|
429
|
-
// for itself; startCameraTutorial re-arms the flag after its own open.
|
|
430
|
-
appContext.shared.tutorialStarterRef.current = false;
|
|
431
423
|
setProjectManifest(resourceManifest({ scenesDocument: doc, workflow: resolvedWorkflow, poseLibrary: mergedCustomPoses, assets: project.assets ?? [], workflowOutputRefs }));
|
|
432
424
|
track("project:opened", { age_bucket: bucketProjectAge(Date.now() - (project.savedAt ?? Date.now())) });
|
|
433
425
|
}
|
|
434
426
|
|
|
435
427
|
/** Open a bundled starter scene as a fresh, saveable project. Used by the
|
|
436
|
-
* first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`)
|
|
437
|
-
* how the landing-page tutorial hands people into the local studio. */
|
|
428
|
+
* first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`). */
|
|
438
429
|
async function openStarterScene(id, source = "starter", context = null, name = null) {
|
|
439
430
|
if (source === "starter" && domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return false;
|
|
440
|
-
const before = source === "tutorial" ? collectProjectSnapshot("Tutorial") : null;
|
|
441
|
-
const epoch = appContext.shared.tutorialProjectEpochRef.current;
|
|
442
431
|
const url = playgroundSceneUrl(`?scene=${encodeURIComponent(id)}`);
|
|
443
432
|
const project = url ? await fetchSceneProject(url) : null;
|
|
444
|
-
// A pending tutorial fetch has no authority over work authored/opened
|
|
445
|
-
// while it was loading, including an unnamed project.
|
|
446
|
-
if (source === "tutorial" && (epoch !== appContext.shared.tutorialProjectEpochRef.current || before !== collectProjectSnapshot("Tutorial"))) return false;
|
|
447
433
|
if (!project) {
|
|
448
434
|
appContext.notify(ko("That starter scene is not in this build", "이 빌드에는 그 시작 장면이 없어요"));
|
|
449
435
|
return false;
|
|
@@ -596,11 +582,6 @@ export function useScenes(appContext) {
|
|
|
596
582
|
}
|
|
597
583
|
|
|
598
584
|
function openScene(scene, nextScenes) {
|
|
599
|
-
appContext.shared.tutorialProjectEpochRef.current += 1;
|
|
600
|
-
appContext.shared.tutorialSeedEpochRef.current = null;
|
|
601
|
-
appContext.shared.setTutorialSeedPending(false);
|
|
602
|
-
appContext.shared.setCameraTutorial(false);
|
|
603
|
-
appContext.shared.setCameraTutorialHandoff(null);
|
|
604
585
|
appContext.shared.exportShotIdRef.current = null;
|
|
605
586
|
appContext.shared.studioSceneEpochRef.current = crypto.randomUUID();
|
|
606
587
|
const shotState = restoredShotState(scene);
|
package/src/domains/shots.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { aimAt } from '../controls.jsx';
|
|
3
3
|
import { PRESETS, DEFAULT_DURATION_S, TIMELINE_FPS } from '../app-stage.jsx';
|
|
4
|
+
import { usePlaybackFrameState } from '../motion/playback-frame-store.js';
|
|
4
5
|
import { CAMERA_MOVES, SUBJECT_HEIGHT_M, focalMmToFov, fovToFocalMm } from '../shot.js';
|
|
5
6
|
import { CAMERA_PRESETS, cameraPresetFraming } from '../camera-move.js';
|
|
6
7
|
import { createDocumentStore } from '../document-store.js';
|
|
@@ -236,9 +237,8 @@ export function useShots(appContext) {
|
|
|
236
237
|
const [moveFollow] = useState(true);
|
|
237
238
|
const [railDraw, setRailDraw] = useState(false);
|
|
238
239
|
const [craneSelectedIndex, setCraneSelectedIndex] = useState(null);
|
|
239
|
-
const [tlFrame,
|
|
240
|
-
const
|
|
241
|
-
const activeShotIdx = shotIndexAtFrame(shots, tlFrame), activeShot = shots[activeShotIdx] ?? null;
|
|
240
|
+
const [tlFrame, setTlFrameState] = useState(0), [tlFps, setTlFps] = useState(TIMELINE_FPS);
|
|
241
|
+
const { setTlFrame, commitPlaybackFrame, activeShotIdx, activeShot } = usePlaybackFrameState({ frameCount: tlFrameCount, setReactFrame: setTlFrameState, shots });
|
|
242
242
|
const cameraKeys = activeShot?.cameraKeys ?? [], activeCamera = createCameraBlock(activeShot?.camera);
|
|
243
243
|
const craneActive = !!activeCamera.craneHeight, cameraRail = activeCamera.cameraRail;
|
|
244
244
|
const activeShotDuration = activeShot ? activeShot.endFrame - activeShot.startFrame + 1 : 0;
|
|
@@ -364,7 +364,7 @@ export function useShots(appContext) {
|
|
|
364
364
|
const travel = Math.ceil(curve.length / speed * tlFps) + Math.round(tlFps * 0.5);
|
|
365
365
|
const endFrame = Math.min(tlFrameCount - 1, activeShot.startFrame + Math.max(travel, activeShot.endFrame - activeShot.startFrame));
|
|
366
366
|
setTimelineShotRange(activeShot.id, activeShot.startFrame, endFrame);
|
|
367
|
-
//
|
|
367
|
+
// In the landing playground a drawn rail opens the player
|
|
368
368
|
// on the shot camera, but leave playback to the visitor.
|
|
369
369
|
appContext.shared.enterPreview(); appContext.shared.setTlPlaying(false); setTlFrame(activeShot.startFrame);
|
|
370
370
|
appContext.notify(isKo ? '레일 완성 — 샷 카메라 시점으로 전환했습니다. ▶ 로 재생, Esc 로 복귀' : 'Rail drawn — you are looking through the shot camera. Press ▶ to ride it; Esc goes back to flying.'); return;
|
|
@@ -418,7 +418,7 @@ export function useShots(appContext) {
|
|
|
418
418
|
}
|
|
419
419
|
return { ...domain, shots, setShots, editShots, fovDeg, setFovDeg, cameraMove, customMove, startupShotState,
|
|
420
420
|
movePlaying, setMovePlaying, moveFollow, railDraw, setRailDraw, craneSelectedIndex, setCraneSelectedIndex,
|
|
421
|
-
tlFrame, setTlFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
|
|
421
|
+
tlFrame, setTlFrame, commitPlaybackFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
|
|
422
422
|
activeCamera, craneActive, cameraRail, activeShotDuration, hasCameraKeys, changeActiveCamera, changeShotTargetModel,
|
|
423
423
|
syncActiveCameraFraming, commitManualCameraFraming, beginCameraFramingGesture, beginTimelineEditGesture,
|
|
424
424
|
setShotCameraRail, clearShotCameraRail, changeCameraRail, addCameraKeyframe, moveCameraKeyframe, removeCameraKeyframe, clearMove,
|
package/src/dualview.jsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
3
|
import * as THREE from "three";
|
|
4
|
+
import { fitAspect } from "./shot-frame.js";
|
|
5
|
+
import { updateVisibleViewOffsets } from "./viewport-visible-rect.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Two live viewports out of one scene.
|
|
@@ -104,17 +106,6 @@ const EDGE_FRAGMENT = `
|
|
|
104
106
|
}
|
|
105
107
|
`;
|
|
106
108
|
|
|
107
|
-
/** largest centred sub-rect of `rect` with the given aspect */
|
|
108
|
-
export function fitAspect(rect, aspect) {
|
|
109
|
-
let w = rect.w;
|
|
110
|
-
let h = w / aspect;
|
|
111
|
-
if (h > rect.h) {
|
|
112
|
-
h = rect.h;
|
|
113
|
-
w = h * aspect;
|
|
114
|
-
}
|
|
115
|
-
return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
109
|
export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCamRef, planCamRef, poserCamRef, editorCamRef, ikMode = false, planIsMain, playMode = false, lookThrough = false, insetCollapsed = false, planZoom = 1, shotAspect = SHOT_ASPECT, editorLook = false }) {
|
|
119
110
|
const invalidate = useThree((state) => state.invalidate);
|
|
120
111
|
const lastPoserPose = useRef({ position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false });
|
|
@@ -245,6 +236,7 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
|
|
|
245
236
|
return { x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height };
|
|
246
237
|
};
|
|
247
238
|
const mainRect = rectOf(mainRef.current);
|
|
239
|
+
updateVisibleViewOffsets({ editorCam, poserCam, canvas: gl.domElement.getBoundingClientRect(), aspect: mainRect.w / mainRect.h });
|
|
248
240
|
// A collapsed inset is just its tag pill: skip the whole inset pass
|
|
249
241
|
// instead of drawing a squished viewport into it.
|
|
250
242
|
const insetRect = insetCollapsed ? { x: 0, y: 0, w: 0, h: 0 } : rectOf(insetRef.current);
|
|
@@ -451,8 +443,6 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
|
|
|
451
443
|
// IK mode: the main pane is the poser working view (free navigation,
|
|
452
444
|
// handle layer visible); the inset is the FROZEN shot camera — the
|
|
453
445
|
// separate camera-placement screen the framing lives on.
|
|
454
|
-
poserCam.aspect = mainRect.w / mainRect.h;
|
|
455
|
-
poserCam.updateProjectionMatrix();
|
|
456
446
|
drawEditorPane(poserCam, mainRect, null, !navigatingCamera);
|
|
457
447
|
if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(shotCam, insetRect, fitAspect(insetRect, shotAspect), !navigatingCamera);
|
|
458
448
|
drawPreviewCard();
|
|
@@ -465,8 +455,6 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
|
|
|
465
455
|
// navigation that never touches the recording. The shot camera only
|
|
466
456
|
// appears in its own preview pane, framed to the export aspect and
|
|
467
457
|
// stripped of editing chrome, exactly like an exported frame.
|
|
468
|
-
editorCam.aspect = mainRect.w / mainRect.h;
|
|
469
|
-
editorCam.updateProjectionMatrix();
|
|
470
458
|
// A right-drag is a camera gesture even with IK off. Keep the main
|
|
471
459
|
// image readable while the camera is moving by deferring the expensive
|
|
472
460
|
// edge/ink pass until the gesture ends; the plan inset stays frozen.
|
|
@@ -479,6 +467,14 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
|
|
|
479
467
|
// can fly the recording lens with the same bindings as the free camera.
|
|
480
468
|
// The QA hook (`window.__cozyclay.setLookThrough`) draws the same path.
|
|
481
469
|
// Look-through is the recording lens: it keeps the authored stage.
|
|
470
|
+
// The pane is the open stage less the rails (shell.css), so the
|
|
471
|
+
// canvas around it is bars too: clear it, or the last full-canvas
|
|
472
|
+
// draw stays there and reads as picture the export does not have.
|
|
473
|
+
if (lookThrough) {
|
|
474
|
+
gl.setScissorTest(false);
|
|
475
|
+
gl.setClearColor(letterbox, 1);
|
|
476
|
+
gl.clear(true, true, false);
|
|
477
|
+
}
|
|
482
478
|
draw(shotCam, shotPane, fitAspect(shotPane, shotAspect), !navigatingCamera);
|
|
483
479
|
if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(planCam, planPane, null, false);
|
|
484
480
|
drawPreviewCard();
|
package/src/hierarchy-panel.css
CHANGED
|
@@ -326,13 +326,27 @@
|
|
|
326
326
|
outline: var(--outliner-border-width, 1px) solid var(--select, #e8a33d);
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
/* The context menu is portaled to <body> inside this layer, which keeps the
|
|
330
|
+
`.v2-outliner` style scope without a box of its own (hierarchy-panel.jsx).
|
|
331
|
+
It and the scene switcher's list share the popover tokens (themes.css). */
|
|
332
|
+
.v2-outliner.v2-outliner-layer {
|
|
333
|
+
display: contents;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.v2-outliner .hierarchy-context-menu,
|
|
337
|
+
.dropdown-menu.hierarchy-scene-menu {
|
|
338
|
+
z-index: var(--z-popover, 90);
|
|
339
|
+
border: var(--outliner-border-width, 1px) solid var(--popover-border, var(--row-divider, #1d1d20));
|
|
340
|
+
border-radius: var(--popover-radius, 6px);
|
|
341
|
+
background: var(--popover-surface, var(--surface-raised, #1e1e21));
|
|
342
|
+
-webkit-backdrop-filter: var(--popover-blur, none);
|
|
343
|
+
backdrop-filter: var(--popover-blur, none);
|
|
344
|
+
box-shadow: var(--popover-shadow, 0 8px 24px rgba(0, 0, 0, .45));
|
|
345
|
+
}
|
|
346
|
+
|
|
329
347
|
.v2-outliner .hierarchy-context-menu {
|
|
330
348
|
min-width: var(--outliner-menu-width, 220px);
|
|
331
349
|
padding: var(--outliner-menu-padding, 4px);
|
|
332
|
-
border: var(--outliner-border-width, 1px) solid var(--row-divider, #1d1d20);
|
|
333
|
-
border-radius: var(--r-2, 6px);
|
|
334
|
-
background: var(--surface-raised, #1e1e21);
|
|
335
|
-
box-shadow: 0 var(--sp-1, 8px) var(--sp-3, 16px) var(--overlay, rgba(12,12,13,.78));
|
|
336
350
|
}
|
|
337
351
|
|
|
338
352
|
.v2-outliner .hierarchy-context-item {
|
package/src/hierarchy-panel.jsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import { ko, isKo } from "./locale.js";
|
|
3
4
|
import { buildHierarchyNodes } from "./hierarchy-model.js";
|
|
4
5
|
import { sceneObjectIdFromHierarchy } from "./scene-objects.js";
|
|
5
6
|
import { CatalogueEntries, displayObjectLabel } from "./object-catalog.jsx";
|
|
6
7
|
import { Dropdown } from "./ui.jsx";
|
|
8
|
+
import { usePopover } from "./popover.js";
|
|
7
9
|
import { isImeComposing } from "./ime.js";
|
|
8
10
|
import "./hierarchy-panel.css";
|
|
9
11
|
|
|
@@ -97,6 +99,7 @@ function ScenePill({ scenes, activeSceneId, onSceneSelect, onSceneCreate, onRena
|
|
|
97
99
|
value={activeSceneId}
|
|
98
100
|
options={options}
|
|
99
101
|
ariaLabel={ko("Select scene", "장면 선택")}
|
|
102
|
+
menuClassName="hierarchy-scene-menu"
|
|
100
103
|
onChange={(value) => {
|
|
101
104
|
if (value === NEW_SCENE_OPTION) onSceneCreate?.();
|
|
102
105
|
else if (value !== activeSceneId) onSceneSelect?.(value);
|
|
@@ -194,11 +197,9 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
|
194
197
|
const [deleteArmed, setDeleteArmed] = useState(false);
|
|
195
198
|
useEffect(() => setDeleteArmed(false), [menu]);
|
|
196
199
|
|
|
200
|
+
usePopover(menu, onClose, [rootRef]);
|
|
197
201
|
useEffect(() => {
|
|
198
202
|
if (!menu) return undefined;
|
|
199
|
-
const onDocDown = (event) => {
|
|
200
|
-
if (rootRef.current && !rootRef.current.contains(event.target)) onClose();
|
|
201
|
-
};
|
|
202
203
|
const onKey = (event) => {
|
|
203
204
|
if (event.key !== "Escape") return;
|
|
204
205
|
// Capture phase + stopImmediatePropagation: closing the menu must
|
|
@@ -206,12 +207,8 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
|
206
207
|
event.stopImmediatePropagation();
|
|
207
208
|
onClose();
|
|
208
209
|
};
|
|
209
|
-
document.addEventListener("mousedown", onDocDown);
|
|
210
210
|
window.addEventListener("keydown", onKey, true);
|
|
211
|
-
return () =>
|
|
212
|
-
document.removeEventListener("mousedown", onDocDown);
|
|
213
|
-
window.removeEventListener("keydown", onKey, true);
|
|
214
|
-
};
|
|
211
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
215
212
|
}, [menu, onClose]);
|
|
216
213
|
|
|
217
214
|
if (!menu) return null;
|
|
@@ -221,8 +218,11 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
|
221
218
|
left: Math.max(4, Math.min(menu.x, window.innerWidth - 244)),
|
|
222
219
|
top: Math.max(4, Math.min(menu.y, window.innerHeight - menu.height)),
|
|
223
220
|
};
|
|
224
|
-
|
|
225
|
-
|
|
221
|
+
// Portaled: the glass column clips and blurs its children, so an in-place
|
|
222
|
+
// menu was cut at the column edge and sat under body-level popovers. The
|
|
223
|
+
// display:contents layer keeps the `.v2-outliner` style scope.
|
|
224
|
+
return createPortal(
|
|
225
|
+
<div className="v2-outliner v2-outliner-layer"><div className="hierarchy-context-menu" role="menu" style={style} ref={rootRef}>
|
|
226
226
|
{menu.kind === "scene" ? (
|
|
227
227
|
<>
|
|
228
228
|
<button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("rename", menu.id)}>
|
|
@@ -292,7 +292,8 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
|
292
292
|
) : (
|
|
293
293
|
<CatalogueEntries onPick={onAddObject} />
|
|
294
294
|
)}
|
|
295
|
-
</div
|
|
295
|
+
</div></div>,
|
|
296
|
+
document.body,
|
|
296
297
|
);
|
|
297
298
|
}
|
|
298
299
|
|
package/src/kits/held.js
CHANGED
|
@@ -38,9 +38,10 @@ export const bat = {
|
|
|
38
38
|
|
|
39
39
|
export const ball = {
|
|
40
40
|
label: "Baseball", prop: "ballGrip",
|
|
41
|
-
description: "7.4 cm baseball held in the right fingers",
|
|
41
|
+
description: "7.4 cm baseball held in the right palm and fingers",
|
|
42
42
|
props: { ballGrip: { hand: "r", origin: RIGHT_FINGERS, points: { centre: [0, 0, -0.035] } } },
|
|
43
|
-
|
|
43
|
+
// no pinned point: it rests against the palm where the hand takes it and the fingers close round it (motion3 seat.js)
|
|
44
|
+
carry: { prop: "ballGrip", grasp: "spherical" },
|
|
44
45
|
parts: [{ role: "body", kind: "sphere", at: [0, -0.037, 0], scale: [0.074, 0.074, 0.074], color: "#f1ede3" }],
|
|
45
46
|
};
|
|
46
47
|
|
package/src/light-blockers.js
CHANGED
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
import { practicalShadows, withShadowCapWarnings } from "./object-look.js";
|
|
20
20
|
import { isEffectivelyHidden } from "./object-hidden.js";
|
|
21
21
|
|
|
22
|
-
/** Renderers whose meshes cast shadows (props.jsx
|
|
22
|
+
/** Renderers whose meshes cast shadows (props.jsx primitives, sculpts, imported meshes).
|
|
23
23
|
* A plane and a cutout are cards: nothing can be inside them. */
|
|
24
|
-
const CASTERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "
|
|
24
|
+
const CASTERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "sculpt", "mesh"]);
|
|
25
25
|
const DEG = Math.PI / 180;
|
|
26
26
|
const EPS = 1e-6;
|
|
27
27
|
const MAX_WARNINGS = 4;
|
package/src/main.jsx
CHANGED
|
@@ -12,6 +12,7 @@ import { registerPwa } from "./pwa.js";
|
|
|
12
12
|
import { LOCALE } from "./locale.js";
|
|
13
13
|
import { initAnalytics } from "./analytics.js";
|
|
14
14
|
import { fetchPlaygroundProject, isPlaygroundEmbed, playgroundSceneUrl, stashPlaygroundProject } from "./playground.js";
|
|
15
|
+
import { preloadBodies } from "./motion3/body.js";
|
|
15
16
|
|
|
16
17
|
registerPwa();
|
|
17
18
|
void initAnalytics();
|
|
@@ -34,6 +35,10 @@ async function boot() {
|
|
|
34
35
|
</ErrorBoundary>
|
|
35
36
|
</StrictMode>,
|
|
36
37
|
);
|
|
38
|
+
// The motion compiler's body fits are not in the first-load bundle (motion3/body.js): fetch them once
|
|
39
|
+
// the first screen is up, so a saved take compiled on this thread does not have to wait for them.
|
|
40
|
+
const idle = globalThis.requestIdleCallback ?? (run => setTimeout(run, 200));
|
|
41
|
+
idle(() => preloadBodies().catch(error => console.warn("[cozyclay] could not preload the motion body fits", error)));
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
void boot();
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Material presets as three.js materials (the table is material-presets.js),
|
|
3
|
+
* one factory for sculpt parts and plain primitives. `lookMaterial(look)`
|
|
4
|
+
* mints a material per call, which the caller owns and disposes; its detail
|
|
5
|
+
* comes from pattern textures (material-textures.js) generated once per
|
|
6
|
+
* pattern on first draw and shared by every material that uses it, so no one
|
|
7
|
+
* owner may free them (material.dispose() leaves uniforms alone).
|
|
8
|
+
*
|
|
9
|
+
* Mapping is triplanar in the mesh's own frame, measured in metres (the
|
|
10
|
+
* frame's scale is divided back out): the pattern rides with a moving prop,
|
|
11
|
+
* keeps its size on a stretched cube and never reads a geometry's UVs, which
|
|
12
|
+
* on a blob's remapped UV sphere would pinch at the poles. A directional
|
|
13
|
+
* pattern (grain, thatch, brick courses) runs along the axis the vertex
|
|
14
|
+
* shader picks from the part's size and its tilt in the world. The shader
|
|
15
|
+
* text is the same for every preset (only uniforms differ), so all of them
|
|
16
|
+
* share one compiled program per material class.
|
|
17
|
+
*
|
|
18
|
+
* Reflections: the studio stage look has no scene environment, and a metal
|
|
19
|
+
* there would reflect black. While the scene has none, the reflective presets
|
|
20
|
+
* take a RoomEnvironment prefiltered once per renderer; on a stage with an
|
|
21
|
+
* environment they reflect that one, at its intensity, like everything else.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as THREE from "three";
|
|
25
|
+
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
|
26
|
+
import { FINISH_ROUGHNESS, MATERIAL_LOOKS, lookTint } from "./material-presets.js";
|
|
27
|
+
import { PATTERN_SIZE, patternPixels } from "./material-textures.js";
|
|
28
|
+
|
|
29
|
+
const VERTEX_HEAD = `
|
|
30
|
+
uniform vec3 lookExtent;
|
|
31
|
+
uniform float lookTile;
|
|
32
|
+
uniform float lookGrain;
|
|
33
|
+
varying vec3 vLookPos;
|
|
34
|
+
varying vec3 vLookNormal;`;
|
|
35
|
+
|
|
36
|
+
// lookGrain: 0 the part's own y, 1 its longest side, 2 its most upright side
|
|
37
|
+
// (a slab's thickness never counts). A downward axis is flipped so a thatch
|
|
38
|
+
// course's lower edge stays at the bottom.
|
|
39
|
+
const VERTEX_BODY = `
|
|
40
|
+
mat3 lookFrame = mat3( modelMatrix );
|
|
41
|
+
#ifdef USE_INSTANCING
|
|
42
|
+
lookFrame = lookFrame * mat3( instanceMatrix );
|
|
43
|
+
#endif
|
|
44
|
+
vec3 lookSize = max( vec3( length( lookFrame[ 0 ] ), length( lookFrame[ 1 ] ), length( lookFrame[ 2 ] ) ), vec3( 1e-6 ) );
|
|
45
|
+
vec3 lookExt = lookExtent * lookSize;
|
|
46
|
+
vec3 lookUp = vec3( lookFrame[ 0 ].y, lookFrame[ 1 ].y, lookFrame[ 2 ].y ) / lookSize;
|
|
47
|
+
int lookAxis = 1;
|
|
48
|
+
if ( lookGrain > 1.5 ) {
|
|
49
|
+
vec3 score = abs( lookUp );
|
|
50
|
+
float thin = min( lookExt.x, min( lookExt.y, lookExt.z ) );
|
|
51
|
+
float mid = lookExt.x + lookExt.y + lookExt.z - thin - max( lookExt.x, max( lookExt.y, lookExt.z ) );
|
|
52
|
+
if ( thin < 0.35 * mid ) score -= 2.0 * vec3( equal( lookExt, vec3( thin ) ) );
|
|
53
|
+
lookAxis = score.x >= score.y && score.x >= score.z ? 0 : ( score.y >= score.z ? 1 : 2 );
|
|
54
|
+
} else if ( lookGrain > 0.5 ) {
|
|
55
|
+
lookAxis = lookExt.x >= lookExt.y && lookExt.x >= lookExt.z ? 0 : ( lookExt.y >= lookExt.z ? 1 : 2 );
|
|
56
|
+
}
|
|
57
|
+
vec3 lookP = transformed * lookSize / lookTile;
|
|
58
|
+
vec3 lookN = objectNormal / lookSize;
|
|
59
|
+
if ( lookAxis == 0 ) { lookP = lookP.yxz; lookN = lookN.yxz; }
|
|
60
|
+
else if ( lookAxis == 2 ) { lookP = lookP.xzy; lookN = lookN.xzy; }
|
|
61
|
+
if ( lookGrain > 1.5 && lookUp[ lookAxis ] < 0.0 ) { lookP.y = - lookP.y; lookN.y = - lookN.y; }
|
|
62
|
+
vLookPos = lookP;
|
|
63
|
+
vLookNormal = lookN;`;
|
|
64
|
+
|
|
65
|
+
// Bump is Mikkelsen's surface gradient with unnormalized screen derivatives,
|
|
66
|
+
// so `lookAmount.y` is a real relief depth in metres at any distance.
|
|
67
|
+
const FRAGMENT_HEAD = `
|
|
68
|
+
uniform sampler2D lookMap;
|
|
69
|
+
uniform vec4 lookAmount;
|
|
70
|
+
uniform vec3 lookMask;
|
|
71
|
+
varying vec3 vLookPos;
|
|
72
|
+
varying vec3 vLookNormal;
|
|
73
|
+
vec4 lookTriplanar( vec3 q, vec3 w ) {
|
|
74
|
+
return texture2D( lookMap, q.zy ) * w.x + texture2D( lookMap, q.xz ) * w.y + texture2D( lookMap, q.xy ) * w.z;
|
|
75
|
+
}
|
|
76
|
+
vec3 lookPerturb( vec3 pos, vec3 n, vec2 dH, float face ) {
|
|
77
|
+
vec3 sx = dFdx( pos ), sy = dFdy( pos );
|
|
78
|
+
vec3 r1 = cross( sy, n ), r2 = cross( n, sx );
|
|
79
|
+
float det = dot( sx, r1 ) * face;
|
|
80
|
+
if ( abs( det ) < 1e-14 ) return n;
|
|
81
|
+
return normalize( abs( det ) * n - sign( det ) * ( dH.x * r1 + dH.y * r2 ) );
|
|
82
|
+
}`;
|
|
83
|
+
|
|
84
|
+
const FRAGMENT_COLOR = `
|
|
85
|
+
vec3 lookW = pow( abs( normalize( vLookNormal ) ), vec3( 4.0 ) );
|
|
86
|
+
lookW /= lookW.x + lookW.y + lookW.z;
|
|
87
|
+
vec4 lookTex = lookTriplanar( vLookPos, lookW );
|
|
88
|
+
vec2 lookDH = lookAmount.y * ( vec2( lookTriplanar( vLookPos + dFdx( vLookPos ), lookW ).g, lookTriplanar( vLookPos + dFdy( vLookPos ), lookW ).g ) - lookTex.g );
|
|
89
|
+
diffuseColor.rgb *= max( 0.0, 1.0 + ( lookTex.r - 0.5 ) * 2.0 * lookAmount.x );
|
|
90
|
+
diffuseColor.rgb = mix( diffuseColor.rgb, lookMask, lookTex.a * lookAmount.w );`;
|
|
91
|
+
|
|
92
|
+
const FRAGMENT_ROUGHNESS = "\nroughnessFactor = clamp( roughnessFactor + ( lookTex.b - 0.5 ) * 2.0 * lookAmount.z, 0.03, 1.0 );";
|
|
93
|
+
const FRAGMENT_NORMAL = "\nnormal = lookPerturb( - vViewPosition, normal, lookDH, faceDirection );";
|
|
94
|
+
|
|
95
|
+
const after = (marker, code) => (source) => source.replace(marker, `${marker}${code}`);
|
|
96
|
+
const patchVertex = [after("#include <common>", VERTEX_HEAD), after("#include <begin_vertex>", VERTEX_BODY)];
|
|
97
|
+
const patchFragment = [after("#include <common>", FRAGMENT_HEAD), after("#include <color_fragment>", FRAGMENT_COLOR),
|
|
98
|
+
after("#include <roughnessmap_fragment>", FRAGMENT_ROUGHNESS), after("#include <normal_fragment_maps>", FRAGMENT_NORMAL)];
|
|
99
|
+
const GRAIN = { up: 0, long: 1, hang: 2 };
|
|
100
|
+
|
|
101
|
+
const patternTextures = new Map();
|
|
102
|
+
/** The shared tile for a pattern, made on first use. Never disposed: every
|
|
103
|
+
* material of the preset, now and later, samples the same one. */
|
|
104
|
+
function patternTexture(pattern) {
|
|
105
|
+
let texture = patternTextures.get(pattern);
|
|
106
|
+
if (!texture) {
|
|
107
|
+
texture = new THREE.DataTexture(patternPixels(pattern), PATTERN_SIZE, PATTERN_SIZE);
|
|
108
|
+
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
|
109
|
+
texture.magFilter = THREE.LinearFilter;
|
|
110
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
111
|
+
texture.generateMipmaps = true;
|
|
112
|
+
texture.anisotropy = 8;
|
|
113
|
+
texture.needsUpdate = true;
|
|
114
|
+
patternTextures.set(pattern, texture);
|
|
115
|
+
}
|
|
116
|
+
return texture;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Hooks the pattern into a material. The texture is fetched at compile time,
|
|
120
|
+
* which only a renderer does, so Node never generates one. */
|
|
121
|
+
function addPattern(material, preset, size) {
|
|
122
|
+
const uniforms = {
|
|
123
|
+
lookMap: { value: null },
|
|
124
|
+
lookTile: { value: preset.tile },
|
|
125
|
+
lookExtent: { value: new THREE.Vector3(...size) },
|
|
126
|
+
lookGrain: { value: GRAIN[preset.grain ?? "up"] },
|
|
127
|
+
lookAmount: { value: new THREE.Vector4(preset.contrast ?? 0, preset.bump ?? 0, preset.roughVar ?? 0, preset.mask ? 0.9 : 0) },
|
|
128
|
+
lookMask: { value: new THREE.Color(preset.mask ?? "#000000") },
|
|
129
|
+
};
|
|
130
|
+
material.onBeforeCompile = (shader) => {
|
|
131
|
+
uniforms.lookMap.value ??= patternTexture(preset.pattern);
|
|
132
|
+
Object.assign(shader.uniforms, uniforms);
|
|
133
|
+
shader.vertexShader = patchVertex.reduce((source, patch) => patch(source), shader.vertexShader);
|
|
134
|
+
shader.fragmentShader = patchFragment.reduce((source, patch) => patch(source), shader.fragmentShader);
|
|
135
|
+
};
|
|
136
|
+
material.customProgramCacheKey = () => "cozyclay-look-1";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const studioMaps = new WeakMap();
|
|
140
|
+
/** A neutral room to reflect, prefiltered once per renderer and kept. */
|
|
141
|
+
function studioReflections(renderer) {
|
|
142
|
+
let map = studioMaps.get(renderer);
|
|
143
|
+
if (!map) {
|
|
144
|
+
const generator = new THREE.PMREMGenerator(renderer), room = new RoomEnvironment();
|
|
145
|
+
map = generator.fromScene(room, 0.04).texture;
|
|
146
|
+
room.dispose();
|
|
147
|
+
generator.dispose();
|
|
148
|
+
studioMaps.set(renderer, map);
|
|
149
|
+
}
|
|
150
|
+
return map;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Runs before each draw: the room while the scene has no environment, the
|
|
154
|
+
* scene's own (envMap null) once it has one. three swaps the program itself. */
|
|
155
|
+
function reflectWhenBare(intensity) {
|
|
156
|
+
return function onBeforeRender(renderer, scene) {
|
|
157
|
+
const own = scene?.environment ? null : studioReflections(renderer);
|
|
158
|
+
if (this.envMap === own) return;
|
|
159
|
+
this.envMap = own;
|
|
160
|
+
this.envMapIntensity = intensity;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const WHITE = new THREE.Color("#ffffff");
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A material for `look` = { material, finish, color, size, side }: the preset
|
|
168
|
+
* name (unknown or absent is clay), the finish, the authored colour, the
|
|
169
|
+
* surface's extent in its mesh frame (metres; sizes the glass thickness and
|
|
170
|
+
* steers the grain) and an optional side. `extra` is set last (polygon offset).
|
|
171
|
+
*/
|
|
172
|
+
export function lookMaterial(look, extra = null) {
|
|
173
|
+
const name = Object.hasOwn(MATERIAL_LOOKS, look.material ?? "") ? look.material : "clay";
|
|
174
|
+
const preset = MATERIAL_LOOKS[name], size = look.size ?? [1, 1, 1];
|
|
175
|
+
const roughness = FINISH_ROUGHNESS[look.finish] ?? preset.roughness;
|
|
176
|
+
const color = new THREE.Color(lookTint(look.color, name));
|
|
177
|
+
const params = { color, roughness, metalness: preset.metalness ?? 0, ...(look.side === undefined ? {} : { side: look.side }), ...extra };
|
|
178
|
+
let material;
|
|
179
|
+
if (preset.physical) {
|
|
180
|
+
const physical = { ...preset.physical };
|
|
181
|
+
if (physical.transmission) physical.thickness = Math.min(0.5, Math.max(0.002, Math.min(...size) / 2));
|
|
182
|
+
if (physical.sheen) physical.sheenColor = color.clone().lerp(WHITE, 0.25);
|
|
183
|
+
if (physical.clearcoat && look.finish) Object.assign(physical, { clearcoat: look.finish === "matte" ? 0 : 1, clearcoatRoughness: roughness / 2 });
|
|
184
|
+
material = new THREE.MeshPhysicalMaterial({ ...params, ...physical });
|
|
185
|
+
} else material = new THREE.MeshStandardMaterial(params);
|
|
186
|
+
if (preset.pattern) addPattern(material, preset, size);
|
|
187
|
+
if (preset.env) material.onBeforeRender = reflectWhenBare(preset.env);
|
|
188
|
+
return material;
|
|
189
|
+
}
|