dsh-plugin-vrmpet3d 0.1.1 → 0.1.2
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/desktop/main.js +2 -1
- package/desktop/preload.js +1 -0
- package/lib/client.core.js +279 -41
- package/lib/client.js +114 -16
- package/lib/client.menu.js +14 -4
- package/lib/client.scene.js +134 -7
- package/lib/config-page.html +11 -0
- package/lib/config-page.js +153 -25
- package/lib/core.js +279 -41
- package/lib/desktop-page.js +158 -40
- package/lib/desktop.js +18 -1
- package/lib/global-page.html +34 -4
- package/lib/index.js +46 -25
- package/lib/menu.js +14 -4
- package/lib/scene.js +134 -7
- package/lib/vrm-info.js +16 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -300,6 +300,7 @@
|
|
|
300
300
|
*/
|
|
301
301
|
'.vrmpet3d-status[data-tone="gold"]{color:light-dark(#8a5a00,#ffc94d);--dsw-alias-label-shimmer:light-dark(#ffe0a3,#fff0c9)}',
|
|
302
302
|
'.vrmpet3d-status[data-tone="gold"][data-bold="1"]{font-weight:700}',
|
|
303
|
+
'.vrmpet3d-status[data-attention="1"]{pointer-events:auto;cursor:pointer;text-shadow:0 0 4px currentColor,0 0 10px currentColor,0 0 18px currentColor}',
|
|
303
304
|
'.vrmpet3d-status[data-tone="danger"]{color:light-dark(#b3261e,#ff6b6b);--dsw-alias-label-shimmer:light-dark(#ffc9c4,#ffd0cc)}',
|
|
304
305
|
/**
|
|
305
306
|
* 碎碎念: the pet's own short remark, in a bubble above the status prompt.
|
|
@@ -411,6 +412,7 @@
|
|
|
411
412
|
'.vrmpet3d-status-content{display:flex;align-items:center;min-width:0}',
|
|
412
413
|
/* `nowrap`, always: the operator's "提示文字永不换行". One line, however long the line is. */
|
|
413
414
|
'.vrmpet3d-status-text{color:inherit;font-variant-numeric:tabular-nums;white-space:nowrap}',
|
|
415
|
+
'.vrmpet3d-status-dot{pointer-events:auto;cursor:pointer}',
|
|
414
416
|
/* The words are rendered twice from ONE string: `attr()` feeds both copies, so the two cannot drift. */
|
|
415
417
|
'.vrmpet3d-status-text[data-shimmer-text]::after{content:attr(data-shimmer-text)}',
|
|
416
418
|
'.vrmpet3d-status-decoration{position:absolute;inset:0;overflow:clip;pointer-events:none;user-select:none}',
|
|
@@ -780,6 +782,8 @@
|
|
|
780
782
|
function VrmPet3DWidget(host, options) {
|
|
781
783
|
options = options || {};
|
|
782
784
|
this.host = host;
|
|
785
|
+
this.openSession = options.openSession || function () {};
|
|
786
|
+
this.conversationVisible = options.conversationVisible || function () { return true; };
|
|
783
787
|
this.core = globalThis.__VRMPET3D_CORE__;
|
|
784
788
|
this.sceneApi = globalThis.__VRMPET3D_SCENE__;
|
|
785
789
|
/**
|
|
@@ -812,6 +816,7 @@
|
|
|
812
816
|
this.placement = new Placement(this.preferences, function () { return Date.now(); }, this.core);
|
|
813
817
|
|
|
814
818
|
this.machine = new this.core.PetMachine(function () { return Date.now(); }, this.preferences.sleepAfterMs);
|
|
819
|
+
this.machine.requireViewedAcknowledgement = typeof options.conversationVisible === 'function';
|
|
815
820
|
/**
|
|
816
821
|
* The machine reads the settings it needs through the WIDGET, not from a copy.
|
|
817
822
|
*
|
|
@@ -1135,6 +1140,7 @@
|
|
|
1135
1140
|
*/
|
|
1136
1141
|
if (this.core.watchHostPreview) {
|
|
1137
1142
|
this.previewSync = this.core.watchHostPreview({
|
|
1143
|
+
onNavigate: function (id) { self.openSession(id); },
|
|
1138
1144
|
onPreview: function (preview) {
|
|
1139
1145
|
/**
|
|
1140
1146
|
* A PREVIEW ENDS A DANCE — and a preview of the DANCING state STARTS one.
|
|
@@ -1224,6 +1230,7 @@
|
|
|
1224
1230
|
// ---- DOM -----------------------------------------------------------------
|
|
1225
1231
|
|
|
1226
1232
|
VrmPet3DWidget.prototype.build = function () {
|
|
1233
|
+
var self = this;
|
|
1227
1234
|
var doc = this.document;
|
|
1228
1235
|
var root = doc.createElement('div');
|
|
1229
1236
|
root.className = 'vrmpet3d-root';
|
|
@@ -1288,6 +1295,10 @@
|
|
|
1288
1295
|
this.canvas = root.querySelector('.vrmpet3d-pet canvas');
|
|
1289
1296
|
this.badge = root.querySelector('.vrmpet3d-badge');
|
|
1290
1297
|
this.statusNode = root.querySelector('.vrmpet3d-status');
|
|
1298
|
+
this.statusNode.addEventListener('click', function (event) {
|
|
1299
|
+
event.stopPropagation();
|
|
1300
|
+
self.openStatusSession();
|
|
1301
|
+
});
|
|
1291
1302
|
this.statusIcon = root.querySelector('.vrmpet3d-status-icon');
|
|
1292
1303
|
/**
|
|
1293
1304
|
* The green dot, when a completion is waiting elsewhere.
|
|
@@ -1298,6 +1309,11 @@
|
|
|
1298
1309
|
* puts beside that session in its list, and it disappears when the user goes back there.
|
|
1299
1310
|
*/
|
|
1300
1311
|
this.statusDot = root.querySelector('.vrmpet3d-status-dot');
|
|
1312
|
+
this.statusDot.addEventListener('click', function (event) {
|
|
1313
|
+
event.stopPropagation();
|
|
1314
|
+
self.openQueuedSession('completed');
|
|
1315
|
+
});
|
|
1316
|
+
this.statusDot.title = this.language === 'en' ? 'Open completed conversation' : '打开已完成的会话';
|
|
1301
1317
|
this.statusDecoration = root.querySelector('.vrmpet3d-status-decoration');
|
|
1302
1318
|
/**
|
|
1303
1319
|
* The prompt's two text copies — the real one and the masked highlight — which always render the SAME
|
|
@@ -1762,6 +1778,7 @@
|
|
|
1762
1778
|
*/
|
|
1763
1779
|
VrmPet3DWidget.prototype.openMenuAt = function (clientX, clientY) {
|
|
1764
1780
|
if (!this.menu) return false;
|
|
1781
|
+
if (typeof this.menu.setSurfaceVisible === 'function') this.menu.setSurfaceVisible(this.canUseIndependentPet());
|
|
1765
1782
|
var doc = this.document;
|
|
1766
1783
|
var view = doc.defaultView;
|
|
1767
1784
|
var viewportWidth = Number(view && view.innerWidth) || doc.documentElement.clientWidth || 0;
|
|
@@ -1774,6 +1791,7 @@
|
|
|
1774
1791
|
* it is read when the menu is built.
|
|
1775
1792
|
*/
|
|
1776
1793
|
this.menu.setSurfaceAllowed(this.preferences.petSurface !== 'both');
|
|
1794
|
+
if (this.menu.setDanceState) this.menu.setDanceState(this.machine.state);
|
|
1777
1795
|
/**
|
|
1778
1796
|
* 重力's check mark, read at the same moment and for the same reason: a host snapshot or a settings-window
|
|
1779
1797
|
* edit can change the preference while the page keeps running, so a menu that cached it would show the
|
|
@@ -1834,6 +1852,7 @@
|
|
|
1834
1852
|
container: this.root,
|
|
1835
1853
|
language: this.language,
|
|
1836
1854
|
toSurface: 'window',
|
|
1855
|
+
showSurface: this.canUseIndependentPet(),
|
|
1837
1856
|
onChoose: function (id) { self.onMenuChoice(id); },
|
|
1838
1857
|
});
|
|
1839
1858
|
return this.menu;
|
|
@@ -2284,6 +2303,12 @@
|
|
|
2284
2303
|
* the widget's own state. "The window is up but the pet is not drawing in it" stays answerable that way, which
|
|
2285
2304
|
* is the whole reason the host reports the page's own verdict.
|
|
2286
2305
|
*/
|
|
2306
|
+
VrmPet3DWidget.prototype.canUseIndependentPet = function () {
|
|
2307
|
+
// The host can borrow Electron even when it is itself a plain Node process.
|
|
2308
|
+
// Its runtime result, rather than the UI bridge or process type, decides this.
|
|
2309
|
+
return Boolean(this.desktopStatus && this.desktopStatus.electronAvailable === true);
|
|
2310
|
+
};
|
|
2311
|
+
|
|
2287
2312
|
VrmPet3DWidget.prototype.syncDesktop = function (status, note) {
|
|
2288
2313
|
var strings = words(this.language);
|
|
2289
2314
|
this.desktopStatus = status || null;
|
|
@@ -2370,6 +2395,7 @@
|
|
|
2370
2395
|
* place that knows it. A caller that omits it gets the click pool, which is what an older cached chunk does.
|
|
2371
2396
|
*/
|
|
2372
2397
|
VrmPet3DWidget.prototype.reactSpeech = function (anatomy, gesture) {
|
|
2398
|
+
if (this.machine && this.machine.state === 'dancing') return null;
|
|
2373
2399
|
if (!this.speech) return null;
|
|
2374
2400
|
var random = this.scene && typeof this.scene.random === 'function' ? this.scene.random : null;
|
|
2375
2401
|
var line = this.speech.react(
|
|
@@ -3052,6 +3078,7 @@
|
|
|
3052
3078
|
*/
|
|
3053
3079
|
VrmPet3DWidget.prototype.startDance = function (motionId) {
|
|
3054
3080
|
if (!this.machine || typeof this.machine.setDancing !== 'function') return false;
|
|
3081
|
+
if (['resting', 'offline', 'dancing'].indexOf(this.machine.resolve(this.aggregate.snapshot())) === -1) return false;
|
|
3055
3082
|
var wanted = typeof motionId === 'string' && motionId ? motionId : null;
|
|
3056
3083
|
if (!wanted) {
|
|
3057
3084
|
var ids = this.danceClipIds();
|
|
@@ -3080,6 +3107,9 @@
|
|
|
3080
3107
|
self.scene.setPreview({ state: 'dancing', motionId: wanted });
|
|
3081
3108
|
}
|
|
3082
3109
|
});
|
|
3110
|
+
// Publish the accepted dance immediately, including when this surface is hidden.
|
|
3111
|
+
// The floating window must not wait for the next background animation frame.
|
|
3112
|
+
if (!this.disposed) this.pump();
|
|
3083
3113
|
return true;
|
|
3084
3114
|
};
|
|
3085
3115
|
|
|
@@ -3347,6 +3377,11 @@
|
|
|
3347
3377
|
running: snap.running === true,
|
|
3348
3378
|
working: Number(snap.workingCount) || 0,
|
|
3349
3379
|
waiting: Number(snap.waitingCount) || 0,
|
|
3380
|
+
statusSessionId: this.machine.state === 'waiting' ? snap.waitingSessionId
|
|
3381
|
+
: (this.machine.notice && this.machine.notice.sessionId) || null,
|
|
3382
|
+
completedSessionId: snap.completedSessionId || null,
|
|
3383
|
+
waitingSessionIds: snap.waitingSessionIds || [],
|
|
3384
|
+
completedSessionIds: snap.completedSessionIds || [],
|
|
3350
3385
|
unread: Number(snap.unreadCount) || 0,
|
|
3351
3386
|
/**
|
|
3352
3387
|
* The breakdown behind that count, as numbers — why the green dot is lit, with no session id or title in it.
|
|
@@ -3952,8 +3987,11 @@
|
|
|
3952
3987
|
}
|
|
3953
3988
|
}
|
|
3954
3989
|
|
|
3990
|
+
this.acknowledgeVisibleConversation();
|
|
3955
3991
|
var snapshot = this.aggregate.snapshot();
|
|
3956
3992
|
var state = this.machine.resolve(snapshot);
|
|
3993
|
+
this.completedUnreadCount = snapshot.completedUnreadCount;
|
|
3994
|
+
if (this.menu && this.menu.setDanceState) this.menu.setDanceState(state);
|
|
3957
3995
|
// Dance previews belong to the dance lifetime, including work interruption.
|
|
3958
3996
|
if (this.scene.preview && this.scene.preview.state === 'dancing' && state !== 'dancing') {
|
|
3959
3997
|
this.scene.setPreview(null);
|
|
@@ -3985,11 +4023,13 @@
|
|
|
3985
4023
|
*/
|
|
3986
4024
|
var clock = this.machine && typeof this.machine.statusClock === 'function' ? this.machine.statusClock() : null;
|
|
3987
4025
|
var clockKey = clock ? clock.startedAt + ':' + (clock.endedAt || '') : '';
|
|
4026
|
+
var completedKey = (snapshot.completedSessionIds || []).join(',') + '|' + (snapshot.waitingSessionIds || []).join(',');
|
|
3988
4027
|
if (state !== this.lastPublishedState || speech !== this.lastPublishedSpeech || touch !== null
|
|
3989
|
-
|| clockKey !== this.lastPublishedClockKey) {
|
|
4028
|
+
|| clockKey !== this.lastPublishedClockKey || completedKey !== this.lastPublishedCompletedKey) {
|
|
3990
4029
|
this.lastPublishedState = state;
|
|
3991
4030
|
this.lastPublishedSpeech = speech;
|
|
3992
4031
|
this.lastPublishedClockKey = clockKey;
|
|
4032
|
+
this.lastPublishedCompletedKey = completedKey;
|
|
3993
4033
|
/**
|
|
3994
4034
|
* `clock` rides along because the window has no turn frames of its own, and the UNREAD count because the window
|
|
3995
4035
|
* has no session store either: its green dot is DSH's `completionUnread`, which only this half can see.
|
|
@@ -3998,7 +4038,7 @@
|
|
|
3998
4038
|
* question with an answer (`/vrmpet3d/state`) instead of a guess. See `decisionWhy`.
|
|
3999
4039
|
*/
|
|
4000
4040
|
if (this.publishState) {
|
|
4001
|
-
this.publishState(state, speech, touch, clock, null,
|
|
4041
|
+
this.publishState(state, speech, touch, clock, null, snapshot.completedUnreadCount || 0, this.decisionWhy());
|
|
4002
4042
|
}
|
|
4003
4043
|
}
|
|
4004
4044
|
|
|
@@ -4019,7 +4059,7 @@
|
|
|
4019
4059
|
// The TURN clock, not the state's own age: 用时 is a claim about DSH's turn (`PetMachine.statusClock`), and the
|
|
4020
4060
|
// token is dropped entirely while no turn is open — DSH's own token-free 「深度求索中」.
|
|
4021
4061
|
var clock = this.machine && typeof this.machine.statusClock === 'function' ? this.machine.statusClock() : undefined;
|
|
4022
|
-
var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock, this.language);
|
|
4062
|
+
var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock, this.language, this.completedUnreadCount);
|
|
4023
4063
|
/**
|
|
4024
4064
|
* A state with no line clears the prompt rather than leaving the previous one up: a caption that
|
|
4025
4065
|
* outlives its state is how a pet ends up saying 深度求索中 over a finished turn.
|
|
@@ -4047,6 +4087,8 @@
|
|
|
4047
4087
|
* other, and the attribute is cheap. `data-tone` is what the stylesheet reads, so the colours live in CSS
|
|
4048
4088
|
* (where the two surfaces can differ) and the DECISION lives in `core.statusFor` (where they cannot).
|
|
4049
4089
|
*/
|
|
4090
|
+
if (['done', 'waiting', 'error'].indexOf(state) !== -1) node.setAttribute('data-attention', '1');
|
|
4091
|
+
else node.removeAttribute('data-attention');
|
|
4050
4092
|
if (line.tone) node.setAttribute('data-tone', line.tone);
|
|
4051
4093
|
else node.removeAttribute('data-tone');
|
|
4052
4094
|
if (line.bold) node.setAttribute('data-bold', '1');
|
|
@@ -4074,8 +4116,8 @@
|
|
|
4074
4116
|
// The dot is about ANOTHER session's unread completion, so it is drawn from the machine's count and not from
|
|
4075
4117
|
// the line: the line is what is happening here, the dot is what finished elsewhere.
|
|
4076
4118
|
if (this.statusDot) {
|
|
4077
|
-
this.statusDot.hidden = !(line.state
|
|
4078
|
-
&&
|
|
4119
|
+
this.statusDot.hidden = !(['working', 'waiting'].indexOf(line.state) !== -1
|
|
4120
|
+
&& Number(this.completedUnreadCount) > 0);
|
|
4079
4121
|
}
|
|
4080
4122
|
}
|
|
4081
4123
|
// Every frame, not only when the words change: the caption is anchored to the HEAD, so an orbit, a zoom
|
|
@@ -4206,6 +4248,27 @@
|
|
|
4206
4248
|
return line;
|
|
4207
4249
|
};
|
|
4208
4250
|
|
|
4251
|
+
VrmPet3DWidget.prototype.openStatusSession = function () {
|
|
4252
|
+
var state = this.machine.state;
|
|
4253
|
+
if (state === 'waiting' || state === 'done') return this.openQueuedSession(state === 'waiting' ? 'waiting' : 'completed');
|
|
4254
|
+
var snap = this.aggregate.snapshot();
|
|
4255
|
+
var id = state === 'waiting' ? snap.waitingSessionId
|
|
4256
|
+
: ((state === 'done' || state === 'error') && this.machine.notice ? this.machine.notice.sessionId : null);
|
|
4257
|
+
if (id) this.openSession(id);
|
|
4258
|
+
};
|
|
4259
|
+
|
|
4260
|
+
VrmPet3DWidget.prototype.openQueuedSession = function (kind) {
|
|
4261
|
+
var snap = this.aggregate.snapshot();
|
|
4262
|
+
var ids = kind === 'waiting' ? snap.waitingSessionIds : snap.completedSessionIds;
|
|
4263
|
+
if (!ids || !ids.length) return null;
|
|
4264
|
+
this.queueCursor = this.queueCursor || {};
|
|
4265
|
+
var index = ids.indexOf(this.queueCursor[kind]);
|
|
4266
|
+
var id = ids[(index + 1) % ids.length];
|
|
4267
|
+
this.queueCursor[kind] = id;
|
|
4268
|
+
this.openSession(id);
|
|
4269
|
+
return id;
|
|
4270
|
+
};
|
|
4271
|
+
|
|
4209
4272
|
/**
|
|
4210
4273
|
* Ask the host for a generated line, when the machine has asked for one and nobody has yet.
|
|
4211
4274
|
*
|
|
@@ -4436,22 +4499,27 @@
|
|
|
4436
4499
|
// …and a badge the scene wrote is retracted once the scene is happy again (see `retractSceneStatus`).
|
|
4437
4500
|
this.retractSceneStatus();
|
|
4438
4501
|
|
|
4439
|
-
// The
|
|
4440
|
-
// the pet has THIS frame: the cursor can sit anywhere in the window (the listener is
|
|
4441
|
-
// on the document), and the pet can be dragged or falling while it looks. Using the
|
|
4442
|
-
// placement instead of `getBoundingClientRect` keeps this out of layout.
|
|
4502
|
+
// The rendered eyes, rather than the slot's centre, are the zero of the aim.
|
|
4443
4503
|
if (this.preferences.lookAtPointer && this.pointer) {
|
|
4444
4504
|
var pixels = this.placement.pixels();
|
|
4505
|
+
var eye = typeof this.scene.gazeOrigin === 'function' ? this.scene.gazeOrigin() : null;
|
|
4506
|
+
var gazeView = this.document && this.document.defaultView;
|
|
4507
|
+
var gazeScreenWidth = Number(gazeView && gazeView.screen && gazeView.screen.width) || this.placement.viewportWidth;
|
|
4445
4508
|
var aim = this.core.pointerAim({
|
|
4446
4509
|
pointerX: this.pointer.x,
|
|
4447
4510
|
pointerY: this.pointer.y,
|
|
4448
|
-
centreX: pixels.left + this.placement.width / 2,
|
|
4449
|
-
centreY: pixels.top + this.placement.height / 2,
|
|
4511
|
+
centreX: pixels.left + (eye ? eye.x * this.placement.width / this.scene.width : this.placement.width / 2),
|
|
4512
|
+
centreY: pixels.top + (eye ? eye.y * this.placement.height / this.scene.height : this.placement.height / 2),
|
|
4450
4513
|
// The same two settings as the floating window's (the 模型参数 window's 视线 section), with the
|
|
4451
|
-
// same "auto" default: 0 px means
|
|
4452
|
-
spanX: this.core.resolveGazeSpan(this.preferences.gazeSpanX,
|
|
4453
|
-
spanY: this.core.resolveGazeSpan(this.preferences.gazeSpanY,
|
|
4454
|
-
curve: this.preferences.gazeCurve
|
|
4514
|
+
// same "auto" default: 0 px means half the screen width for both axes.
|
|
4515
|
+
spanX: this.core.resolveGazeSpan(this.preferences.gazeSpanX, gazeScreenWidth),
|
|
4516
|
+
spanY: this.core.resolveGazeSpan(this.preferences.gazeSpanY, gazeScreenWidth),
|
|
4517
|
+
curve: this.preferences.gazeCurve,
|
|
4518
|
+
curveY: this.preferences.gazeCurveY,
|
|
4519
|
+
curveRadial: this.preferences.gazeCurveRadial,
|
|
4520
|
+
curveSpatial: this.preferences.gazeCurveSpatial,
|
|
4521
|
+
distance: this.preferences.gazeDistance,
|
|
4522
|
+
mode: this.preferences.gazeCurveMode
|
|
4455
4523
|
});
|
|
4456
4524
|
this.scene.motion.setGaze(this.scene.gazeFromAim(aim));
|
|
4457
4525
|
} else {
|
|
@@ -4463,15 +4531,30 @@
|
|
|
4463
4531
|
|
|
4464
4532
|
// ---- session bridge ------------------------------------------------------
|
|
4465
4533
|
|
|
4534
|
+
VrmPet3DWidget.prototype.acknowledgeVisibleConversation = function () {
|
|
4535
|
+
var focused = typeof this.document.hasFocus === 'function' ? this.document.hasFocus() : this.machine.focused;
|
|
4536
|
+
if (!focused || this.document.visibilityState === 'hidden' || this.machine.interaction || !this.conversationVisible()) return;
|
|
4537
|
+
// Read the visible conversation on every host update and pump, regardless of how it was opened.
|
|
4538
|
+
var input = this.aggregate.input;
|
|
4539
|
+
this.aggregate.acknowledgeViewed(selectedSessionId(input.catalog) || input.currentSessionId);
|
|
4540
|
+
};
|
|
4541
|
+
|
|
4466
4542
|
VrmPet3DWidget.prototype.setInput = function (input) {
|
|
4467
4543
|
this.aggregate.setInput(input);
|
|
4544
|
+
this.acknowledgeVisibleConversation();
|
|
4468
4545
|
var snapshot = this.aggregate.snapshot();
|
|
4469
4546
|
this.machine.resolve(snapshot);
|
|
4470
4547
|
};
|
|
4471
4548
|
|
|
4472
4549
|
VrmPet3DWidget.prototype.boundary = function (event) {
|
|
4473
4550
|
this.aggregate.boundary(event);
|
|
4474
|
-
|
|
4551
|
+
if (event && event.sessionId && !this.aggregate.mainSession(event.sessionId)) {
|
|
4552
|
+
this.machine.resolve(this.aggregate.snapshot());
|
|
4553
|
+
return;
|
|
4554
|
+
}
|
|
4555
|
+
this.machine.boundary(this.aggregate.snapshot(), Object.assign({}, event, {
|
|
4556
|
+
confirmed: event && event.reason === 'completed' && this.aggregate.running(event.sessionId) === false,
|
|
4557
|
+
}));
|
|
4475
4558
|
};
|
|
4476
4559
|
|
|
4477
4560
|
VrmPet3DWidget.prototype.resetAggregate = function (identities) {
|
|
@@ -4721,6 +4804,10 @@
|
|
|
4721
4804
|
|
|
4722
4805
|
function selectedSessionId(catalog) {
|
|
4723
4806
|
if (!catalog) return undefined;
|
|
4807
|
+
var visible = Object.values(catalog.byId || {}).find(function (row) {
|
|
4808
|
+
return row && row.retainedBy && Number(row.retainedBy.mainView) > 0;
|
|
4809
|
+
});
|
|
4810
|
+
if (visible && typeof visible.id === 'string') return visible.id;
|
|
4724
4811
|
if (typeof catalog.currentSessionId === 'string') return catalog.currentSessionId;
|
|
4725
4812
|
if (catalog.selectedSessionId) return catalog.selectedSessionId;
|
|
4726
4813
|
if (catalog.current && typeof catalog.current.id === 'string') return catalog.current.id;
|
|
@@ -5056,6 +5143,17 @@
|
|
|
5056
5143
|
* whenever the settings change (`adoptLanguage`), because `auto` means "whatever DSH says".
|
|
5057
5144
|
*/
|
|
5058
5145
|
locale: ctx.locale,
|
|
5146
|
+
conversationVisible: function () {
|
|
5147
|
+
var layout = typeof ctx.get === 'function' ? ctx.get('layout') : ctx.layout;
|
|
5148
|
+
return !layout || !layout.panelInfo || layout.panelInfo.getSnapshot().activePanelId === null;
|
|
5149
|
+
},
|
|
5150
|
+
openSession: function (id) {
|
|
5151
|
+
var entries = ctx.slots.entries('sidebar.workspaces');
|
|
5152
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
5153
|
+
var injected = typeof entries[i].inject === 'function' ? entries[i].inject() : entries[i].inject;
|
|
5154
|
+
if (injected && typeof injected.open === 'function') { injected.open(id); return; }
|
|
5155
|
+
}
|
|
5156
|
+
},
|
|
5059
5157
|
onScopeChange: function (scope) { if (changeScope.current) changeScope.current(scope); },
|
|
5060
5158
|
/**
|
|
5061
5159
|
* Report the state to the host, so the floating window can show the same prompt.
|
package/lib/client.menu.js
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
reloadModel: 'Reload model',
|
|
114
114
|
dance: 'Dance for me',
|
|
115
115
|
modelSettings: "This model's parameters",
|
|
116
|
-
surfaceWindow: '
|
|
116
|
+
surfaceWindow: 'Switch to an independent pet',
|
|
117
117
|
surfaceApp: 'Move back inside the app',
|
|
118
118
|
surfaceOff: 'disabled while debug mode is on',
|
|
119
119
|
gravity: 'Gravity',
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
reloadModel: '重载模型',
|
|
142
142
|
dance: '跳支舞',
|
|
143
143
|
modelSettings: '当前模型参数设置',
|
|
144
|
-
surfaceWindow: '
|
|
144
|
+
surfaceWindow: '切换为独立宠物',
|
|
145
145
|
surfaceApp: '切换回窗口内',
|
|
146
146
|
surfaceOff: '调试模式下不生效',
|
|
147
147
|
gravity: '重力',
|
|
@@ -236,6 +236,7 @@
|
|
|
236
236
|
var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
|
|
237
237
|
var toSurface = options.toSurface === 'app' ? 'app' : 'window';
|
|
238
238
|
var surfaceAllowed = options.surfaceAllowed !== false;
|
|
239
|
+
var danceState = 'resting';
|
|
239
240
|
/** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
|
|
240
241
|
var showSurface = options.showSurface !== false;
|
|
241
242
|
|
|
@@ -262,6 +263,8 @@
|
|
|
262
263
|
|
|
263
264
|
/** The label for one id, from the state this menu was built with. */
|
|
264
265
|
function labelFor(id) {
|
|
266
|
+
if (id === 'dance') return danceState === 'dancing' ? (options.language === 'en' ? 'Another dance' : '换一支舞')
|
|
267
|
+
: (['resting', 'offline'].indexOf(danceState) === -1 ? (options.language === 'en' ? 'The pet is busy…' : '宠物目前很忙…') : text.dance);
|
|
265
268
|
if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
|
|
266
269
|
if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
|
|
267
270
|
return text[id] || id;
|
|
@@ -275,7 +278,8 @@
|
|
|
275
278
|
ITEMS.forEach(function (id) {
|
|
276
279
|
// The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
|
|
277
280
|
if (id === 'surface' && !showSurface) return;
|
|
278
|
-
var disabled = id === 'surface' && surfaceAllowed !== true
|
|
281
|
+
var disabled = (id === 'surface' && surfaceAllowed !== true)
|
|
282
|
+
|| (id === 'dance' && ['resting', 'offline', 'dancing'].indexOf(danceState) === -1);
|
|
279
283
|
var toggle = TOGGLES.indexOf(id) !== -1;
|
|
280
284
|
var checked = toggle && checkedState[id] === true;
|
|
281
285
|
var item = doc.createElement('button');
|
|
@@ -300,7 +304,7 @@
|
|
|
300
304
|
* waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
|
|
301
305
|
* 非debug模式生效 — spelled out for the mode that turns it off.
|
|
302
306
|
*/
|
|
303
|
-
item.title = text.surfaceOff;
|
|
307
|
+
item.title = id === 'dance' ? label : text.surfaceOff;
|
|
304
308
|
} else {
|
|
305
309
|
if (toggle && !checked) item.title = text.gravityOff;
|
|
306
310
|
item.addEventListener('click', function (event) {
|
|
@@ -456,6 +460,12 @@
|
|
|
456
460
|
setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
|
|
457
461
|
/** Whether that row may be used at all (false in debug mode). */
|
|
458
462
|
setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
|
|
463
|
+
setSurfaceVisible: function (visible) { showSurface = visible === true; },
|
|
464
|
+
setDanceState: function (state) {
|
|
465
|
+
if (danceState === state) return;
|
|
466
|
+
danceState = state;
|
|
467
|
+
if (!element.hidden) render();
|
|
468
|
+
},
|
|
459
469
|
/**
|
|
460
470
|
* Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
|
|
461
471
|
*
|
package/lib/client.scene.js
CHANGED
|
@@ -75,10 +75,20 @@
|
|
|
75
75
|
var GAZE_YAW_LIMIT = ((80 * Math.PI) / 180) / GAZE_WEIGHT;
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
78
|
+
* Automatic pitch limit, before the gaze weight. Settings expose separate actual
|
|
79
|
+
* upward/downward angles in degrees; both default to 60 degrees.
|
|
80
80
|
*/
|
|
81
|
-
var GAZE_PITCH_LIMIT =
|
|
81
|
+
var GAZE_PITCH_LIMIT = (60 * Math.PI / 180) / GAZE_WEIGHT;
|
|
82
|
+
|
|
83
|
+
function gazePitchLimits(preferences) {
|
|
84
|
+
var prefs = preferences || {};
|
|
85
|
+
function limit(key) {
|
|
86
|
+
return Number.isFinite(prefs[key])
|
|
87
|
+
? Math.max(0, Math.min(60, prefs[key])) * Math.PI / 180 / GAZE_WEIGHT
|
|
88
|
+
: GAZE_PITCH_LIMIT;
|
|
89
|
+
}
|
|
90
|
+
return { up: limit('gazePitchUp'), down: limit('gazePitchDown') };
|
|
91
|
+
}
|
|
82
92
|
|
|
83
93
|
/**
|
|
84
94
|
* How far the head may turn IN TOTAL while an authored clip is driving it, in radians.
|
|
@@ -794,7 +804,8 @@
|
|
|
794
804
|
}
|
|
795
805
|
|
|
796
806
|
var yaw = Math.max(-GAZE_YAW_LIMIT, Math.min(GAZE_YAW_LIMIT, aim.yaw)) * GAZE_WEIGHT * this.gazeBlend;
|
|
797
|
-
var
|
|
807
|
+
var pitchLimits = gazePitchLimits(this.scene && this.scene.preferences);
|
|
808
|
+
var pitch = Math.max(-pitchLimits.down, Math.min(pitchLimits.up, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
|
|
798
809
|
var axes = rest ? this.gazeAxes(head) : null;
|
|
799
810
|
|
|
800
811
|
if (layeredOnClip) {
|
|
@@ -807,8 +818,7 @@
|
|
|
807
818
|
var budget = Math.max(0, HEAD_LAYER_LIMIT - clipTurn);
|
|
808
819
|
yaw = Math.max(-budget, Math.min(budget, yaw));
|
|
809
820
|
// Pitch keeps its own (smaller) limit, but never more than what is left of the budget either.
|
|
810
|
-
pitch = Math.max(-Math.min(
|
|
811
|
-
Math.min(Math.min(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget), pitch));
|
|
821
|
+
pitch = Math.max(-budget, Math.min(budget, pitch));
|
|
812
822
|
if (axes) {
|
|
813
823
|
this._quat.setFromAxisAngle(axes.yaw, yaw);
|
|
814
824
|
this._quat2.setFromAxisAngle(axes.pitch, pitch);
|
|
@@ -1677,6 +1687,9 @@
|
|
|
1677
1687
|
if (this.disposed) return;
|
|
1678
1688
|
var w = Math.max(1, Math.round(width));
|
|
1679
1689
|
var h = Math.max(1, Math.round(height));
|
|
1690
|
+
// Layout works in fractional CSS pixels. Reapplying the same rounded size clears
|
|
1691
|
+
// the framebuffer between rAFs when a settings reply arrives during an orbit.
|
|
1692
|
+
if (this.width === w && this.height === h) return;
|
|
1680
1693
|
this.renderer.setSize(w, h, false);
|
|
1681
1694
|
this.camera.aspect = w / h;
|
|
1682
1695
|
this.camera.updateProjectionMatrix();
|
|
@@ -1763,6 +1776,8 @@
|
|
|
1763
1776
|
|
|
1764
1777
|
/** Replace the current avatar with a hardware-ready one. */
|
|
1765
1778
|
Scene.prototype.installAvatar = function (avatar, root) {
|
|
1779
|
+
(this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
|
|
1780
|
+
this.toonMaterials = null;
|
|
1766
1781
|
// Stop before the old avatar is replaced: stopping is what undoes the clip's
|
|
1767
1782
|
// pose, and it has to happen while the old model is still installed. Every
|
|
1768
1783
|
// clip is then dropped, because a clip is fitted to one model — the cached
|
|
@@ -1787,6 +1802,7 @@
|
|
|
1787
1802
|
this.frameAvatar(root);
|
|
1788
1803
|
this.stage.add(root);
|
|
1789
1804
|
this.avatar = avatar;
|
|
1805
|
+
this.captureToonMaterials(root);
|
|
1790
1806
|
this.kind = avatar.kind;
|
|
1791
1807
|
/**
|
|
1792
1808
|
* …and the collider switch, applied to the avatar that was just adopted.
|
|
@@ -1852,6 +1868,7 @@
|
|
|
1852
1868
|
* model is loaded, so on a fresh pet that call finds nothing to snapshot and the first sweep would have
|
|
1853
1869
|
* had no gravity to lean against (measured: `windState().joints === 0` on a real model with two chains).
|
|
1854
1870
|
*/
|
|
1871
|
+
this.applySprings(this.preferences);
|
|
1855
1872
|
this.captureSpringBase();
|
|
1856
1873
|
this.expressionBehaviour = { blink: 0, blinkExpression: 'blink', emotion: null, emotionWeight: 0 };
|
|
1857
1874
|
this.expressionState = { nextBlinkAt: null, blinkUntil: 0, nextIdleEmotionAt: null, emotionUntil: 0 };
|
|
@@ -1860,6 +1877,73 @@
|
|
|
1860
1877
|
this.motion.setGazeIdle(this.preferences.gazeIdleSeconds);
|
|
1861
1878
|
this.updateShadowPlane();
|
|
1862
1879
|
this.refitCamera();
|
|
1880
|
+
this.applyToonPreferences();
|
|
1881
|
+
};
|
|
1882
|
+
|
|
1883
|
+
/** Keep the loaded materials (and expression bindings) intact so the switch is reversible. */
|
|
1884
|
+
Scene.prototype.captureToonMaterials = function (root) {
|
|
1885
|
+
var self = this;
|
|
1886
|
+
this.toonMeshes = [];
|
|
1887
|
+
this.toonMaterials = [];
|
|
1888
|
+
var seen = new Map();
|
|
1889
|
+
root.traverse(function (node) {
|
|
1890
|
+
if (!node.isMesh || !node.material) return;
|
|
1891
|
+
var originals = Array.isArray(node.material) ? node.material : [node.material];
|
|
1892
|
+
var entries = originals.map(function (material) {
|
|
1893
|
+
if (!material.isMToonMaterial) return null;
|
|
1894
|
+
if (seen.has(material)) return seen.get(material);
|
|
1895
|
+
var entry = { material: material, toony: material.shadingToonyFactor, shift: material.shadingShiftFactor,
|
|
1896
|
+
shade: material.shadeColorFactor.clone(), width: material.outlineWidthFactor, plain: null };
|
|
1897
|
+
seen.set(material, entry);
|
|
1898
|
+
self.toonMaterials.push(entry);
|
|
1899
|
+
return entry;
|
|
1900
|
+
});
|
|
1901
|
+
if (entries.some(Boolean)) self.toonMeshes.push({ node: node, original: node.material, entries: entries, originals: originals });
|
|
1902
|
+
});
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
Scene.prototype.applyToonPreferences = function () {
|
|
1906
|
+
var self = this;
|
|
1907
|
+
var id = this.currentModelId || this.preferences.modelId;
|
|
1908
|
+
var prefs = (this.preferences.toon || {})[id] || {};
|
|
1909
|
+
var disabled = prefs.enabled === false;
|
|
1910
|
+
(this.toonMaterials || []).forEach(function (entry) {
|
|
1911
|
+
var material = entry.material;
|
|
1912
|
+
material.shadingToonyFactor = prefs.shadingToonyFactor === undefined ? entry.toony : prefs.shadingToonyFactor;
|
|
1913
|
+
material.shadingShiftFactor = prefs.shadingShiftFactor === undefined ? entry.shift : prefs.shadingShiftFactor;
|
|
1914
|
+
material.shadeColorFactor.copy(entry.shade);
|
|
1915
|
+
if (prefs.shadeStrength !== undefined) material.shadeColorFactor.lerp(material.color, 1 - prefs.shadeStrength);
|
|
1916
|
+
material.outlineWidthFactor = entry.width * (prefs.outlineScale === undefined ? 1 : prefs.outlineScale);
|
|
1917
|
+
if (disabled && !entry.plain) {
|
|
1918
|
+
var plain = new self.THREE.MeshStandardMaterial();
|
|
1919
|
+
['map', 'alphaMap', 'alphaTest', 'transparent', 'opacity', 'side', 'depthWrite', 'depthTest',
|
|
1920
|
+
'normalMap', 'normalScale', 'emissive', 'emissiveMap', 'emissiveIntensity', 'color'].forEach(function (key) {
|
|
1921
|
+
if (material[key] === undefined) return;
|
|
1922
|
+
if (plain[key] && typeof plain[key].copy === 'function') plain[key].copy(material[key]);
|
|
1923
|
+
else plain[key] = material[key];
|
|
1924
|
+
});
|
|
1925
|
+
plain.visible = !material.isOutline;
|
|
1926
|
+
entry.plain = plain;
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
(this.toonMeshes || []).forEach(function (mesh) {
|
|
1930
|
+
mesh.node.material = disabled
|
|
1931
|
+
? (Array.isArray(mesh.original) ? mesh.originals.map(function (m, i) { return mesh.entries[i] ? mesh.entries[i].plain : m; })
|
|
1932
|
+
: mesh.entries[0].plain)
|
|
1933
|
+
: mesh.original;
|
|
1934
|
+
});
|
|
1935
|
+
this.toonDisabled = disabled;
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
Scene.prototype.syncPlainMaterials = function () {
|
|
1939
|
+
if (!this.toonDisabled) return;
|
|
1940
|
+
(this.toonMaterials || []).forEach(function (entry) {
|
|
1941
|
+
if (!entry.plain) return;
|
|
1942
|
+
// Expressions still target the authored MToon material, including UV animation.
|
|
1943
|
+
entry.plain.color.copy(entry.material.color);
|
|
1944
|
+
entry.plain.emissive.copy(entry.material.emissive);
|
|
1945
|
+
entry.plain.opacity = entry.material.opacity;
|
|
1946
|
+
});
|
|
1863
1947
|
};
|
|
1864
1948
|
|
|
1865
1949
|
/** A soft ground blob, so the pet does not appear to float. */
|
|
@@ -3388,7 +3472,8 @@
|
|
|
3388
3472
|
var x = Math.max(-1, Math.min(1, aim.x));
|
|
3389
3473
|
var y = Math.max(-1, Math.min(1, aim.y));
|
|
3390
3474
|
// Screen y grows downward, pitch grows upward.
|
|
3391
|
-
|
|
3475
|
+
var limits = gazePitchLimits(this.preferences);
|
|
3476
|
+
return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * (y < 0 ? limits.up : limits.down) };
|
|
3392
3477
|
};
|
|
3393
3478
|
|
|
3394
3479
|
/** Advance one frame. `dt` is in seconds. */
|
|
@@ -3445,6 +3530,7 @@
|
|
|
3445
3530
|
this.advanceExpressions(dt);
|
|
3446
3531
|
this.applyExpressions(this.preferences);
|
|
3447
3532
|
|
|
3533
|
+
this.syncPlainMaterials();
|
|
3448
3534
|
this.renderer.render(this.scene, this.camera);
|
|
3449
3535
|
this.frame += 1;
|
|
3450
3536
|
};
|
|
@@ -3456,6 +3542,7 @@
|
|
|
3456
3542
|
Scene.prototype.applyPreferences = function (preferences) {
|
|
3457
3543
|
var raw = preferences && typeof preferences === 'object' ? preferences : {};
|
|
3458
3544
|
this.preferences = this.core.cleanPreferences(preferences);
|
|
3545
|
+
this.applyToonPreferences();
|
|
3459
3546
|
// A preferences push only carries `motions` when the caller meant to change
|
|
3460
3547
|
// it. cleanPreferences always produces a (possibly empty) key, so adopting it
|
|
3461
3548
|
// unconditionally would silently wipe a map set through setMotionMap.
|
|
@@ -3649,6 +3736,40 @@
|
|
|
3649
3736
|
};
|
|
3650
3737
|
};
|
|
3651
3738
|
|
|
3739
|
+
/** The rendered eyes' midpoint, in canvas CSS pixels, independent of framing and camera orbit. */
|
|
3740
|
+
Scene.prototype.gazeOrigin = function () {
|
|
3741
|
+
var avatar = this.avatar;
|
|
3742
|
+
if (!avatar || !this.THREE || !this.camera) return null;
|
|
3743
|
+
var point = this._gazeOriginVec || (this._gazeOriginVec = new this.THREE.Vector3());
|
|
3744
|
+
var other = this._gazeOtherEyeVec || (this._gazeOtherEyeVec = new this.THREE.Vector3());
|
|
3745
|
+
var humanoid = avatar.vrm && avatar.vrm.humanoid;
|
|
3746
|
+
var left = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('leftEye') : null;
|
|
3747
|
+
var right = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('rightEye') : null;
|
|
3748
|
+
left = left || (avatar.bones && avatar.bones.leftEye);
|
|
3749
|
+
right = right || (avatar.bones && avatar.bones.rightEye);
|
|
3750
|
+
var source;
|
|
3751
|
+
if (left && right) {
|
|
3752
|
+
left.getWorldPosition(point); right.getWorldPosition(other);
|
|
3753
|
+
point.add(other).multiplyScalar(0.5);
|
|
3754
|
+
source = 'eyes';
|
|
3755
|
+
} else if (avatar.vrm && avatar.vrm.lookAt && typeof avatar.vrm.lookAt.getLookAtWorldPosition === 'function') {
|
|
3756
|
+
// The loader has already converted VRM 0/1 coordinate conventions for this offset.
|
|
3757
|
+
if (avatar.root) avatar.root.updateWorldMatrix(true, true);
|
|
3758
|
+
avatar.vrm.lookAt.getLookAtWorldPosition(point);
|
|
3759
|
+
source = 'lookAt';
|
|
3760
|
+
} else {
|
|
3761
|
+
var head = gazeNodeFor(avatar);
|
|
3762
|
+
if (!head) return null;
|
|
3763
|
+
head.getWorldPosition(point);
|
|
3764
|
+
source = 'head';
|
|
3765
|
+
}
|
|
3766
|
+
this.camera.updateMatrixWorld(true);
|
|
3767
|
+
var projected = this.projectToCanvas(point);
|
|
3768
|
+
if (!projected || projected.z < -1 || projected.z > 1) return null;
|
|
3769
|
+
projected.source = source;
|
|
3770
|
+
return projected;
|
|
3771
|
+
};
|
|
3772
|
+
|
|
3652
3773
|
/**
|
|
3653
3774
|
* Apply the draught to the chains, decay it, and keep it LOCAL.
|
|
3654
3775
|
*
|
|
@@ -3873,6 +3994,10 @@
|
|
|
3873
3994
|
*/
|
|
3874
3995
|
Scene.prototype.setSpringDefaults = function (defaults) {
|
|
3875
3996
|
this.springDefaults = defaults && typeof defaults === 'object' ? defaults : null;
|
|
3997
|
+
// Catalog defaults can arrive after the avatar and preferences on either surface.
|
|
3998
|
+
// Apply them now; waiting for another preference edit makes a reload appear to fix physics.
|
|
3999
|
+
this.applySprings(this.preferences);
|
|
4000
|
+
this.captureSpringBase();
|
|
3876
4001
|
};
|
|
3877
4002
|
|
|
3878
4003
|
/**
|
|
@@ -4939,6 +5064,8 @@
|
|
|
4939
5064
|
|
|
4940
5065
|
Scene.prototype.dispose = function () {
|
|
4941
5066
|
if (this.disposed) return;
|
|
5067
|
+
(this.toonMeshes || []).forEach(function (mesh) { mesh.node.material = mesh.original; });
|
|
5068
|
+
(this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
|
|
4942
5069
|
this.disposed = true;
|
|
4943
5070
|
// Stop and uncache every mixer before the renderer goes: a mixer holds a
|
|
4944
5071
|
// binding table that references the model's bones, so leaving them behind
|
package/lib/config-page.html
CHANGED
|
@@ -53,6 +53,17 @@
|
|
|
53
53
|
<div class="row"><span class="k" data-i18n="availableInfo">可用信息</span><span class="grow note" id="model-meta">—</span></div>
|
|
54
54
|
</div>
|
|
55
55
|
|
|
56
|
+
<div class="card" id="toon-card" style="margin-top:12px">
|
|
57
|
+
<h3 data-i18n="toonTitle">卡通增强</h3>
|
|
58
|
+
<div class="row"><span class="k" data-i18n="toonState">材质状态</span><span class="grow note" id="toon-state" role="status">—</span></div>
|
|
59
|
+
<div class="row">
|
|
60
|
+
<label class="k" for="toon-mode">MToon</label>
|
|
61
|
+
<select id="toon-mode"><option value="original" data-i18n="toonOriginal">模型原始</option><option value="on" data-i18n="on">开启</option><option value="off" data-i18n="toonDisabled">关闭</option></select>
|
|
62
|
+
<button id="toon-reset" type="button" data-i18n="toonReset">恢复模型原始参数</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div id="toon-controls"></div>
|
|
65
|
+
<p class="note" data-i18n="toonNote">参数仅保存到当前模型,留空使用模型原始值。关闭 MToon 后使用普通光照材质。描边倍率只调整模型已有的描边。</p>
|
|
66
|
+
</div>
|
|
56
67
|
</section>
|
|
57
68
|
|
|
58
69
|
<section class="settings-section" id="settings-1">
|