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/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-plugin-vrmpet3d
|
|
1
|
+
// dsh-plugin-vrmpet3d — host half.
|
|
2
2
|
//
|
|
3
3
|
// Responsibilities, and deliberately nothing else:
|
|
4
4
|
//
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// text, tool arguments and attachments are never read, stored or forwarded.
|
|
8
8
|
// 2. Serve the user's own model and authored-motion files to the page over the
|
|
9
9
|
// web carrier's `/vrmpet3d` routes. A plugin's `assets/` directory is NOT served
|
|
10
|
-
// to the browser by DSH
|
|
11
|
-
// `/plugins/<id>/client*.js`
|
|
10
|
+
// to the browser by DSH — the only plugin-owned browser routes are
|
|
11
|
+
// `/plugins/<id>/client*.js` — so a model or a `.vrma` the user drops
|
|
12
12
|
// on disk can only reach the page through a host route. Every route is
|
|
13
13
|
// allowlisted to the configured directories and never exposes an absolute
|
|
14
14
|
// path.
|
|
@@ -26,7 +26,7 @@ import { SettingsStore, settingsFileFor } from './settings.js';
|
|
|
26
26
|
// `VRMPET3D_*` with the pre-rename `PET3D_*` names as a fallback — see the module's own note.
|
|
27
27
|
import { envVar } from './env.js';
|
|
28
28
|
|
|
29
|
-
// `lib/core.js` is a classic script that publishes itself on the global
|
|
29
|
+
// `lib/core.js` is a classic script that publishes itself on the global — that is
|
|
30
30
|
// how the client half reads it too, so importing it here is what keeps ONE list
|
|
31
31
|
// of file extensions in the project. A host-side copy of the motion extensions is
|
|
32
32
|
// exactly the kind of mirror that drifts: the browser would offer the user a
|
|
@@ -78,7 +78,7 @@ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', '.svn', '__pycache_
|
|
|
78
78
|
*
|
|
79
79
|
* Taken from `lib/core.js` rather than listed again here. The motion catalog
|
|
80
80
|
* beside it is built from `core.MOTION_EXTENSIONS`, and the two must stay
|
|
81
|
-
* disjoint
|
|
81
|
+
* disjoint — a file offered by both catalogs, or by neither, is a class of bug
|
|
82
82
|
* that no single route can see, so membership is decided in one place.
|
|
83
83
|
*/
|
|
84
84
|
const MODEL_EXTENSIONS = new Set(core.MODEL_EXTENSIONS);
|
|
@@ -130,14 +130,14 @@ export const ROUTES = {
|
|
|
130
130
|
/** The desktop window's page, its sources, and its on/off control. */
|
|
131
131
|
window: '/vrmpet3d/window',
|
|
132
132
|
/**
|
|
133
|
-
* The page's own sources, as a prefix
|
|
133
|
+
* The page's own sources, as a prefix — WITHOUT a trailing slash.
|
|
134
134
|
*
|
|
135
135
|
* DSH's matcher decides a prefix hit with `pathname === prefix || pathname.startsWith(prefix + '/')`,
|
|
136
136
|
* so a registered prefix that already ends in `/` can only ever match the literal
|
|
137
137
|
* `/vrmpet3d/window/lib//...`, which no browser sends. The first version of this route had the
|
|
138
138
|
* trailing slash and every source 404'd: the window opened, the page loaded, the glue never
|
|
139
139
|
* ran, and the pet was simply invisible. Every other prefix here works because it has no
|
|
140
|
-
* trailing slash
|
|
140
|
+
* trailing slash — which is the tell that this one was wrong.
|
|
141
141
|
*/
|
|
142
142
|
windowLib: '/vrmpet3d/window/lib',
|
|
143
143
|
desktop: '/vrmpet3d/desktop',
|
|
@@ -1230,7 +1230,7 @@ export function motionLoops(manifest, name) {
|
|
|
1230
1230
|
*
|
|
1231
1231
|
* Compares by ID, never by object identity. `agents.list()` is a live registry
|
|
1232
1232
|
* whose returned objects are not guaranteed to be the same references that
|
|
1233
|
-
* `agents.roots()` holds
|
|
1233
|
+
* `agents.roots()` holds — a copy, a proxy or a re-read all produce
|
|
1234
1234
|
* `roots().includes(agent) === false` and would silently classify EVERY session
|
|
1235
1235
|
* as a subagent, which turns the whole pet off. That failure is invisible: the
|
|
1236
1236
|
* pet simply never reacts.
|
|
@@ -1284,7 +1284,7 @@ export function isInside(root, absolute) {
|
|
|
1284
1284
|
* directory that holds it, but a legitimate request also addresses the model's own
|
|
1285
1285
|
* file, so paths are interpreted relative to that directory. With `..` permitted,
|
|
1286
1286
|
* a path can climb above the declared directory and still resolve to something
|
|
1287
|
-
* inside the root's parent
|
|
1287
|
+
* inside the root's parent — a containment check against the wrong baseline serves
|
|
1288
1288
|
* it quite happily. Rejecting `..` outright removes the class instead of arguing
|
|
1289
1289
|
* about which baselines happen to be safe.
|
|
1290
1290
|
*/
|
|
@@ -1313,7 +1313,7 @@ export function resolveAssetPath(root, relative) {
|
|
|
1313
1313
|
|
|
1314
1314
|
let modelIdCounter = 0;
|
|
1315
1315
|
|
|
1316
|
-
/** Absolute path
|
|
1316
|
+
/** Absolute path → id, so a rescan hands the same file back the same id. */
|
|
1317
1317
|
const modelIdsByPath = new Map();
|
|
1318
1318
|
|
|
1319
1319
|
/**
|
|
@@ -1327,7 +1327,7 @@ const modelIdsByPath = new Map();
|
|
|
1327
1327
|
*
|
|
1328
1328
|
* That breaks the page in a way that looks like a broken model rather than a broken
|
|
1329
1329
|
* id: the client persists the selected `modelId`, reloads it on mount and after a
|
|
1330
|
-
* page refresh, and asks `/vrmpet3d/asset?m=<saved id>`
|
|
1330
|
+
* page refresh, and asks `/vrmpet3d/asset?m=<saved id>` — which then 404s and drops the
|
|
1331
1331
|
* pet back to the placeholder. The id is therefore a property of the path, not of the
|
|
1332
1332
|
* scan.
|
|
1333
1333
|
*
|
|
@@ -1430,7 +1430,7 @@ function scanDirectory(root, options = {}) {
|
|
|
1430
1430
|
}
|
|
1431
1431
|
|
|
1432
1432
|
/**
|
|
1433
|
-
* Build the catalog and the id
|
|
1433
|
+
* Build the catalog and the id → file mapping in one pass.
|
|
1434
1434
|
*
|
|
1435
1435
|
* @param {object} config
|
|
1436
1436
|
* @param {string[]} [config.modelDirs] extra directories supplied by the user
|
|
@@ -1498,7 +1498,7 @@ export function buildCatalog(config = {}, limits = {}) {
|
|
|
1498
1498
|
|
|
1499
1499
|
let motionIdCounter = 0;
|
|
1500
1500
|
|
|
1501
|
-
/** Absolute path
|
|
1501
|
+
/** Absolute path → id, so a rescan hands the same file back the same id. */
|
|
1502
1502
|
const motionIdsByPath = new Map();
|
|
1503
1503
|
|
|
1504
1504
|
/**
|
|
@@ -1508,7 +1508,7 @@ const motionIdsByPath = new Map();
|
|
|
1508
1508
|
*
|
|
1509
1509
|
* * The `'a'` prefix gives motions a namespace of their own. Model ids carry an
|
|
1510
1510
|
* `'m'` and come from a separate counter that also starts at 1, so without
|
|
1511
|
-
* the prefix the first motion and the first model would both be `1`
|
|
1511
|
+
* the prefix the first motion and the first model would both be `1` — the two
|
|
1512
1512
|
* maps hold different files, and a page asking one route for the other's id
|
|
1513
1513
|
* would be served the wrong kind of file by a lookup that reported success.
|
|
1514
1514
|
* * The id does not encode the path, because an id travels to the browser and
|
|
@@ -1539,7 +1539,7 @@ export function bundledMotionDir() {
|
|
|
1539
1539
|
}
|
|
1540
1540
|
|
|
1541
1541
|
/**
|
|
1542
|
-
* Build the motion catalog and the id
|
|
1542
|
+
* Build the motion catalog and the id → file mapping in one pass.
|
|
1543
1543
|
*
|
|
1544
1544
|
* The plugin's own `assets/motions` is scanned FIRST (source `bundled`), exactly as `assets/models` is for
|
|
1545
1545
|
* models, so the shipped pack is offered out of the box instead of waiting for a user to import a directory.
|
|
@@ -1548,7 +1548,7 @@ export function bundledMotionDir() {
|
|
|
1548
1548
|
* apart by the same key the model catalog uses.
|
|
1549
1549
|
*
|
|
1550
1550
|
* Membership is decided by the extension, never by the bytes. That is the only
|
|
1551
|
-
* test that can keep this catalog disjoint from the model catalog
|
|
1551
|
+
* test that can keep this catalog disjoint from the model catalog — a GLB that
|
|
1552
1552
|
* happens to carry `VRMC_vrm_animation` is still a `.glb`, and it must not turn
|
|
1553
1553
|
* up in both lists because the page fetches the two lists by different routes.
|
|
1554
1554
|
* `core.detectMotionFormat` remains the client's check that a file it was handed
|
|
@@ -1805,7 +1805,7 @@ function sendJson(req, res, payload) {
|
|
|
1805
1805
|
* Read a small JSON request body, or answer 413/400 and return without calling back.
|
|
1806
1806
|
*
|
|
1807
1807
|
* One policy for every POST route: a bounded body, an honest status for each refusal. Two
|
|
1808
|
-
* copies of this drifted before
|
|
1808
|
+
* copies of this drifted before — one capped at 64 KB and one not — and the route without the
|
|
1809
1809
|
* cap is the one that reads an arbitrary upload into memory.
|
|
1810
1810
|
*/
|
|
1811
1811
|
function readJsonBody(req, res, limit, onBody) {
|
|
@@ -2015,7 +2015,7 @@ export function createModelInfoHandler(store) {
|
|
|
2015
2015
|
}
|
|
2016
2016
|
|
|
2017
2017
|
/**
|
|
2018
|
-
* `/vrmpet3d/asset?m=<id>&p=<relative>`
|
|
2018
|
+
* `/vrmpet3d/asset?m=<id>&p=<relative>` — stream one model or texture file.
|
|
2019
2019
|
*
|
|
2020
2020
|
* Streamed rather than buffered: VRM files are routinely tens of MB and
|
|
2021
2021
|
* a read-into-memory route would spike the host's heap on every reload. Only
|
|
@@ -2163,11 +2163,11 @@ export function createModelThumbnailHandler(store) {
|
|
|
2163
2163
|
}
|
|
2164
2164
|
|
|
2165
2165
|
/**
|
|
2166
|
-
* `/vrmpet3d/motion?m=<id>`
|
|
2166
|
+
* `/vrmpet3d/motion?m=<id>` — stream one authored-motion file.
|
|
2167
2167
|
*
|
|
2168
2168
|
* Deliberately has no `p` parameter. A model needs one because its textures sit
|
|
2169
2169
|
* in the same directory; a `.vrma` is self-contained, so the only file
|
|
2170
|
-
* this route can ever name is the exact one an id was scanned from
|
|
2170
|
+
* this route can ever name is the exact one an id was scanned from — nothing
|
|
2171
2171
|
* beside it becomes reachable, and there is no relative path to get wrong.
|
|
2172
2172
|
*
|
|
2173
2173
|
* A COMPRESSED clip (`<name>.vrma.br`, the dance pack) is unpacked HERE, so the bytes that reach the renderer
|
|
@@ -2420,7 +2420,7 @@ export function apply(ctx, config = {}) {
|
|
|
2420
2420
|
};
|
|
2421
2421
|
|
|
2422
2422
|
/**
|
|
2423
|
-
* `/vrmpet3d/motions`
|
|
2423
|
+
* `/vrmpet3d/motions` — the motion catalog, shaped like `/vrmpet3d/catalog`.
|
|
2424
2424
|
*
|
|
2425
2425
|
* Its own route rather than a field of the model catalog: the two are rebuilt
|
|
2426
2426
|
* from separate directories, and the settings panel opens one picker at a time.
|
|
@@ -2797,7 +2797,7 @@ export function apply(ctx, config = {}) {
|
|
|
2797
2797
|
* The always-on-top window, as a separate process.
|
|
2798
2798
|
*
|
|
2799
2799
|
* A plugin cannot ask DSH for a window (the host half has no Electron, the client half's IPC
|
|
2800
|
-
* surface has no such channel
|
|
2800
|
+
* surface has no such channel — see HANDOFF sec.12), so the window is started here: the
|
|
2801
2801
|
* launcher in `desktop/` is run against a bare-Electron runtime borrowed from the
|
|
2802
2802
|
* installation by hard links. `lib/desktop.js` owns all of that; this half owns the routes
|
|
2803
2803
|
* that switch it on and off, and the requirement that it dies with the plugin.
|
|
@@ -2861,10 +2861,12 @@ export function apply(ctx, config = {}) {
|
|
|
2861
2861
|
* repeatable, the counter is not, so a preview that is set twice in a row is still noticed once.
|
|
2862
2862
|
*/
|
|
2863
2863
|
let previewValue = null;
|
|
2864
|
+
let navigation = { seq: 0, sessionId: null };
|
|
2865
|
+
const navigationCursor = { waiting: null, completed: null };
|
|
2864
2866
|
let previewSeq = 0;
|
|
2865
2867
|
const previewHandler = (req, res) => {
|
|
2866
2868
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
2867
|
-
sendJson(req, res, { seq: previewSeq, preview: previewValue });
|
|
2869
|
+
sendJson(req, res, { seq: previewSeq, preview: previewValue, navigation });
|
|
2868
2870
|
return;
|
|
2869
2871
|
}
|
|
2870
2872
|
if (req.method !== 'POST') {
|
|
@@ -2936,9 +2938,13 @@ export function apply(ctx, config = {}) {
|
|
|
2936
2938
|
const value = Number(raw[key]);
|
|
2937
2939
|
if (Number.isFinite(value)) out[key] = Math.floor(value);
|
|
2938
2940
|
}
|
|
2939
|
-
for (const key of ['scope', 'session', 'interaction']) {
|
|
2941
|
+
for (const key of ['scope', 'session', 'interaction', 'statusSessionId', 'completedSessionId']) {
|
|
2940
2942
|
if (typeof raw[key] === 'string' && raw[key]) out[key] = raw[key].slice(0, 64);
|
|
2941
2943
|
}
|
|
2944
|
+
for (const key of ['waitingSessionIds', 'completedSessionIds']) {
|
|
2945
|
+
if (Array.isArray(raw[key])) out[key] = [...new Set(raw[key].filter(id => typeof id === 'string' && id))]
|
|
2946
|
+
.slice(0, 256).map(id => id.slice(0, 64));
|
|
2947
|
+
}
|
|
2942
2948
|
if (Array.isArray(raw.runningIds)) {
|
|
2943
2949
|
out.runningIds = raw.runningIds
|
|
2944
2950
|
.filter((id) => typeof id === 'string' && id)
|
|
@@ -4056,6 +4062,21 @@ export function apply(ctx, config = {}) {
|
|
|
4056
4062
|
}
|
|
4057
4063
|
readJsonBody(req, res, 16 * 1024, (body) => {
|
|
4058
4064
|
const action = typeof body?.action === 'string' ? body.action : '';
|
|
4065
|
+
if (action === 'open-status-session' || action === 'open-completed-session') {
|
|
4066
|
+
let sessionId = petStateStale ? null : action === 'open-completed-session'
|
|
4067
|
+
? (['working', 'waiting'].includes(petStateValue) && petStateUnread > 0 ? petStateWhy?.completedSessionId : null)
|
|
4068
|
+
: (['done', 'waiting', 'error'].includes(petStateValue) ? petStateWhy?.statusSessionId : null);
|
|
4069
|
+
const kind = action === 'open-completed-session' || petStateValue === 'done' ? 'completed' : 'waiting';
|
|
4070
|
+
const ids = petStateWhy?.[kind === 'completed' ? 'completedSessionIds' : 'waitingSessionIds'];
|
|
4071
|
+
if (sessionId && petStateValue !== 'error' && Array.isArray(ids) && ids.length) {
|
|
4072
|
+
sessionId = ids[(ids.indexOf(navigationCursor[kind]) + 1) % ids.length];
|
|
4073
|
+
navigationCursor[kind] = sessionId;
|
|
4074
|
+
}
|
|
4075
|
+
if (!sessionId) { sendJson(req, res, { ok: false }); return; }
|
|
4076
|
+
navigation = { seq: navigation.seq + 1, sessionId };
|
|
4077
|
+
sendJson(req, res, { ok: true });
|
|
4078
|
+
return;
|
|
4079
|
+
}
|
|
4059
4080
|
/**
|
|
4060
4081
|
* The window reporting its own bubble numbers — see pageMetrics in the GET above. Answered by the HOST rather than
|
|
4061
4082
|
* forwarded to the launcher, so an older launcher cannot reject it.
|
|
@@ -4209,7 +4230,7 @@ export function apply(ctx, config = {}) {
|
|
|
4209
4230
|
*
|
|
4210
4231
|
* `ctx.inject` is the declarative fix: Cordis calls the callback with a scope in
|
|
4211
4232
|
* which `webServer` exists, whenever that becomes true, and disposes the scope if
|
|
4212
|
-
* it goes away. The service is an OPTIONAL dependency on purpose
|
|
4233
|
+
* it goes away. The service is an OPTIONAL dependency on purpose — a hard
|
|
4213
4234
|
* `inject: ['webServer']` would leave the plugin waiting for activation forever
|
|
4214
4235
|
* in a composition with no web server, which is a far worse failure than a pet
|
|
4215
4236
|
* that cannot load model files.
|
package/lib/menu.js
CHANGED
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
reloadModel: 'Reload model',
|
|
106
106
|
dance: 'Dance for me',
|
|
107
107
|
modelSettings: "This model's parameters",
|
|
108
|
-
surfaceWindow: '
|
|
108
|
+
surfaceWindow: 'Switch to an independent pet',
|
|
109
109
|
surfaceApp: 'Move back inside the app',
|
|
110
110
|
surfaceOff: 'disabled while debug mode is on',
|
|
111
111
|
gravity: 'Gravity',
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
reloadModel: '重载模型',
|
|
134
134
|
dance: '跳支舞',
|
|
135
135
|
modelSettings: '当前模型参数设置',
|
|
136
|
-
surfaceWindow: '
|
|
136
|
+
surfaceWindow: '切换为独立宠物',
|
|
137
137
|
surfaceApp: '切换回窗口内',
|
|
138
138
|
surfaceOff: '调试模式下不生效',
|
|
139
139
|
gravity: '重力',
|
|
@@ -228,6 +228,7 @@
|
|
|
228
228
|
var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
|
|
229
229
|
var toSurface = options.toSurface === 'app' ? 'app' : 'window';
|
|
230
230
|
var surfaceAllowed = options.surfaceAllowed !== false;
|
|
231
|
+
var danceState = 'resting';
|
|
231
232
|
/** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
|
|
232
233
|
var showSurface = options.showSurface !== false;
|
|
233
234
|
|
|
@@ -254,6 +255,8 @@
|
|
|
254
255
|
|
|
255
256
|
/** The label for one id, from the state this menu was built with. */
|
|
256
257
|
function labelFor(id) {
|
|
258
|
+
if (id === 'dance') return danceState === 'dancing' ? (options.language === 'en' ? 'Another dance' : '换一支舞')
|
|
259
|
+
: (['resting', 'offline'].indexOf(danceState) === -1 ? (options.language === 'en' ? 'The pet is busy…' : '宠物目前很忙…') : text.dance);
|
|
257
260
|
if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
|
|
258
261
|
if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
|
|
259
262
|
return text[id] || id;
|
|
@@ -267,7 +270,8 @@
|
|
|
267
270
|
ITEMS.forEach(function (id) {
|
|
268
271
|
// The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
|
|
269
272
|
if (id === 'surface' && !showSurface) return;
|
|
270
|
-
var disabled = id === 'surface' && surfaceAllowed !== true
|
|
273
|
+
var disabled = (id === 'surface' && surfaceAllowed !== true)
|
|
274
|
+
|| (id === 'dance' && ['resting', 'offline', 'dancing'].indexOf(danceState) === -1);
|
|
271
275
|
var toggle = TOGGLES.indexOf(id) !== -1;
|
|
272
276
|
var checked = toggle && checkedState[id] === true;
|
|
273
277
|
var item = doc.createElement('button');
|
|
@@ -292,7 +296,7 @@
|
|
|
292
296
|
* waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
|
|
293
297
|
* 非debug模式生效 — spelled out for the mode that turns it off.
|
|
294
298
|
*/
|
|
295
|
-
item.title = text.surfaceOff;
|
|
299
|
+
item.title = id === 'dance' ? label : text.surfaceOff;
|
|
296
300
|
} else {
|
|
297
301
|
if (toggle && !checked) item.title = text.gravityOff;
|
|
298
302
|
item.addEventListener('click', function (event) {
|
|
@@ -448,6 +452,12 @@
|
|
|
448
452
|
setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
|
|
449
453
|
/** Whether that row may be used at all (false in debug mode). */
|
|
450
454
|
setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
|
|
455
|
+
setSurfaceVisible: function (visible) { showSurface = visible === true; },
|
|
456
|
+
setDanceState: function (state) {
|
|
457
|
+
if (danceState === state) return;
|
|
458
|
+
danceState = state;
|
|
459
|
+
if (!element.hidden) render();
|
|
460
|
+
},
|
|
451
461
|
/**
|
|
452
462
|
* Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
|
|
453
463
|
*
|
package/lib/scene.js
CHANGED
|
@@ -67,10 +67,20 @@
|
|
|
67
67
|
var GAZE_YAW_LIMIT = ((80 * Math.PI) / 180) / GAZE_WEIGHT;
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
70
|
+
* Automatic pitch limit, before the gaze weight. Settings expose separate actual
|
|
71
|
+
* upward/downward angles in degrees; both default to 60 degrees.
|
|
72
72
|
*/
|
|
73
|
-
var GAZE_PITCH_LIMIT =
|
|
73
|
+
var GAZE_PITCH_LIMIT = (60 * Math.PI / 180) / GAZE_WEIGHT;
|
|
74
|
+
|
|
75
|
+
function gazePitchLimits(preferences) {
|
|
76
|
+
var prefs = preferences || {};
|
|
77
|
+
function limit(key) {
|
|
78
|
+
return Number.isFinite(prefs[key])
|
|
79
|
+
? Math.max(0, Math.min(60, prefs[key])) * Math.PI / 180 / GAZE_WEIGHT
|
|
80
|
+
: GAZE_PITCH_LIMIT;
|
|
81
|
+
}
|
|
82
|
+
return { up: limit('gazePitchUp'), down: limit('gazePitchDown') };
|
|
83
|
+
}
|
|
74
84
|
|
|
75
85
|
/**
|
|
76
86
|
* How far the head may turn IN TOTAL while an authored clip is driving it, in radians.
|
|
@@ -786,7 +796,8 @@
|
|
|
786
796
|
}
|
|
787
797
|
|
|
788
798
|
var yaw = Math.max(-GAZE_YAW_LIMIT, Math.min(GAZE_YAW_LIMIT, aim.yaw)) * GAZE_WEIGHT * this.gazeBlend;
|
|
789
|
-
var
|
|
799
|
+
var pitchLimits = gazePitchLimits(this.scene && this.scene.preferences);
|
|
800
|
+
var pitch = Math.max(-pitchLimits.down, Math.min(pitchLimits.up, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
|
|
790
801
|
var axes = rest ? this.gazeAxes(head) : null;
|
|
791
802
|
|
|
792
803
|
if (layeredOnClip) {
|
|
@@ -799,8 +810,7 @@
|
|
|
799
810
|
var budget = Math.max(0, HEAD_LAYER_LIMIT - clipTurn);
|
|
800
811
|
yaw = Math.max(-budget, Math.min(budget, yaw));
|
|
801
812
|
// Pitch keeps its own (smaller) limit, but never more than what is left of the budget either.
|
|
802
|
-
pitch = Math.max(-Math.min(
|
|
803
|
-
Math.min(Math.min(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget), pitch));
|
|
813
|
+
pitch = Math.max(-budget, Math.min(budget, pitch));
|
|
804
814
|
if (axes) {
|
|
805
815
|
this._quat.setFromAxisAngle(axes.yaw, yaw);
|
|
806
816
|
this._quat2.setFromAxisAngle(axes.pitch, pitch);
|
|
@@ -1669,6 +1679,9 @@
|
|
|
1669
1679
|
if (this.disposed) return;
|
|
1670
1680
|
var w = Math.max(1, Math.round(width));
|
|
1671
1681
|
var h = Math.max(1, Math.round(height));
|
|
1682
|
+
// Layout works in fractional CSS pixels. Reapplying the same rounded size clears
|
|
1683
|
+
// the framebuffer between rAFs when a settings reply arrives during an orbit.
|
|
1684
|
+
if (this.width === w && this.height === h) return;
|
|
1672
1685
|
this.renderer.setSize(w, h, false);
|
|
1673
1686
|
this.camera.aspect = w / h;
|
|
1674
1687
|
this.camera.updateProjectionMatrix();
|
|
@@ -1755,6 +1768,8 @@
|
|
|
1755
1768
|
|
|
1756
1769
|
/** Replace the current avatar with a hardware-ready one. */
|
|
1757
1770
|
Scene.prototype.installAvatar = function (avatar, root) {
|
|
1771
|
+
(this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
|
|
1772
|
+
this.toonMaterials = null;
|
|
1758
1773
|
// Stop before the old avatar is replaced: stopping is what undoes the clip's
|
|
1759
1774
|
// pose, and it has to happen while the old model is still installed. Every
|
|
1760
1775
|
// clip is then dropped, because a clip is fitted to one model — the cached
|
|
@@ -1779,6 +1794,7 @@
|
|
|
1779
1794
|
this.frameAvatar(root);
|
|
1780
1795
|
this.stage.add(root);
|
|
1781
1796
|
this.avatar = avatar;
|
|
1797
|
+
this.captureToonMaterials(root);
|
|
1782
1798
|
this.kind = avatar.kind;
|
|
1783
1799
|
/**
|
|
1784
1800
|
* …and the collider switch, applied to the avatar that was just adopted.
|
|
@@ -1844,6 +1860,7 @@
|
|
|
1844
1860
|
* model is loaded, so on a fresh pet that call finds nothing to snapshot and the first sweep would have
|
|
1845
1861
|
* had no gravity to lean against (measured: `windState().joints === 0` on a real model with two chains).
|
|
1846
1862
|
*/
|
|
1863
|
+
this.applySprings(this.preferences);
|
|
1847
1864
|
this.captureSpringBase();
|
|
1848
1865
|
this.expressionBehaviour = { blink: 0, blinkExpression: 'blink', emotion: null, emotionWeight: 0 };
|
|
1849
1866
|
this.expressionState = { nextBlinkAt: null, blinkUntil: 0, nextIdleEmotionAt: null, emotionUntil: 0 };
|
|
@@ -1852,6 +1869,73 @@
|
|
|
1852
1869
|
this.motion.setGazeIdle(this.preferences.gazeIdleSeconds);
|
|
1853
1870
|
this.updateShadowPlane();
|
|
1854
1871
|
this.refitCamera();
|
|
1872
|
+
this.applyToonPreferences();
|
|
1873
|
+
};
|
|
1874
|
+
|
|
1875
|
+
/** Keep the loaded materials (and expression bindings) intact so the switch is reversible. */
|
|
1876
|
+
Scene.prototype.captureToonMaterials = function (root) {
|
|
1877
|
+
var self = this;
|
|
1878
|
+
this.toonMeshes = [];
|
|
1879
|
+
this.toonMaterials = [];
|
|
1880
|
+
var seen = new Map();
|
|
1881
|
+
root.traverse(function (node) {
|
|
1882
|
+
if (!node.isMesh || !node.material) return;
|
|
1883
|
+
var originals = Array.isArray(node.material) ? node.material : [node.material];
|
|
1884
|
+
var entries = originals.map(function (material) {
|
|
1885
|
+
if (!material.isMToonMaterial) return null;
|
|
1886
|
+
if (seen.has(material)) return seen.get(material);
|
|
1887
|
+
var entry = { material: material, toony: material.shadingToonyFactor, shift: material.shadingShiftFactor,
|
|
1888
|
+
shade: material.shadeColorFactor.clone(), width: material.outlineWidthFactor, plain: null };
|
|
1889
|
+
seen.set(material, entry);
|
|
1890
|
+
self.toonMaterials.push(entry);
|
|
1891
|
+
return entry;
|
|
1892
|
+
});
|
|
1893
|
+
if (entries.some(Boolean)) self.toonMeshes.push({ node: node, original: node.material, entries: entries, originals: originals });
|
|
1894
|
+
});
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
Scene.prototype.applyToonPreferences = function () {
|
|
1898
|
+
var self = this;
|
|
1899
|
+
var id = this.currentModelId || this.preferences.modelId;
|
|
1900
|
+
var prefs = (this.preferences.toon || {})[id] || {};
|
|
1901
|
+
var disabled = prefs.enabled === false;
|
|
1902
|
+
(this.toonMaterials || []).forEach(function (entry) {
|
|
1903
|
+
var material = entry.material;
|
|
1904
|
+
material.shadingToonyFactor = prefs.shadingToonyFactor === undefined ? entry.toony : prefs.shadingToonyFactor;
|
|
1905
|
+
material.shadingShiftFactor = prefs.shadingShiftFactor === undefined ? entry.shift : prefs.shadingShiftFactor;
|
|
1906
|
+
material.shadeColorFactor.copy(entry.shade);
|
|
1907
|
+
if (prefs.shadeStrength !== undefined) material.shadeColorFactor.lerp(material.color, 1 - prefs.shadeStrength);
|
|
1908
|
+
material.outlineWidthFactor = entry.width * (prefs.outlineScale === undefined ? 1 : prefs.outlineScale);
|
|
1909
|
+
if (disabled && !entry.plain) {
|
|
1910
|
+
var plain = new self.THREE.MeshStandardMaterial();
|
|
1911
|
+
['map', 'alphaMap', 'alphaTest', 'transparent', 'opacity', 'side', 'depthWrite', 'depthTest',
|
|
1912
|
+
'normalMap', 'normalScale', 'emissive', 'emissiveMap', 'emissiveIntensity', 'color'].forEach(function (key) {
|
|
1913
|
+
if (material[key] === undefined) return;
|
|
1914
|
+
if (plain[key] && typeof plain[key].copy === 'function') plain[key].copy(material[key]);
|
|
1915
|
+
else plain[key] = material[key];
|
|
1916
|
+
});
|
|
1917
|
+
plain.visible = !material.isOutline;
|
|
1918
|
+
entry.plain = plain;
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
(this.toonMeshes || []).forEach(function (mesh) {
|
|
1922
|
+
mesh.node.material = disabled
|
|
1923
|
+
? (Array.isArray(mesh.original) ? mesh.originals.map(function (m, i) { return mesh.entries[i] ? mesh.entries[i].plain : m; })
|
|
1924
|
+
: mesh.entries[0].plain)
|
|
1925
|
+
: mesh.original;
|
|
1926
|
+
});
|
|
1927
|
+
this.toonDisabled = disabled;
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
Scene.prototype.syncPlainMaterials = function () {
|
|
1931
|
+
if (!this.toonDisabled) return;
|
|
1932
|
+
(this.toonMaterials || []).forEach(function (entry) {
|
|
1933
|
+
if (!entry.plain) return;
|
|
1934
|
+
// Expressions still target the authored MToon material, including UV animation.
|
|
1935
|
+
entry.plain.color.copy(entry.material.color);
|
|
1936
|
+
entry.plain.emissive.copy(entry.material.emissive);
|
|
1937
|
+
entry.plain.opacity = entry.material.opacity;
|
|
1938
|
+
});
|
|
1855
1939
|
};
|
|
1856
1940
|
|
|
1857
1941
|
/** A soft ground blob, so the pet does not appear to float. */
|
|
@@ -3380,7 +3464,8 @@
|
|
|
3380
3464
|
var x = Math.max(-1, Math.min(1, aim.x));
|
|
3381
3465
|
var y = Math.max(-1, Math.min(1, aim.y));
|
|
3382
3466
|
// Screen y grows downward, pitch grows upward.
|
|
3383
|
-
|
|
3467
|
+
var limits = gazePitchLimits(this.preferences);
|
|
3468
|
+
return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * (y < 0 ? limits.up : limits.down) };
|
|
3384
3469
|
};
|
|
3385
3470
|
|
|
3386
3471
|
/** Advance one frame. `dt` is in seconds. */
|
|
@@ -3437,6 +3522,7 @@
|
|
|
3437
3522
|
this.advanceExpressions(dt);
|
|
3438
3523
|
this.applyExpressions(this.preferences);
|
|
3439
3524
|
|
|
3525
|
+
this.syncPlainMaterials();
|
|
3440
3526
|
this.renderer.render(this.scene, this.camera);
|
|
3441
3527
|
this.frame += 1;
|
|
3442
3528
|
};
|
|
@@ -3448,6 +3534,7 @@
|
|
|
3448
3534
|
Scene.prototype.applyPreferences = function (preferences) {
|
|
3449
3535
|
var raw = preferences && typeof preferences === 'object' ? preferences : {};
|
|
3450
3536
|
this.preferences = this.core.cleanPreferences(preferences);
|
|
3537
|
+
this.applyToonPreferences();
|
|
3451
3538
|
// A preferences push only carries `motions` when the caller meant to change
|
|
3452
3539
|
// it. cleanPreferences always produces a (possibly empty) key, so adopting it
|
|
3453
3540
|
// unconditionally would silently wipe a map set through setMotionMap.
|
|
@@ -3641,6 +3728,40 @@
|
|
|
3641
3728
|
};
|
|
3642
3729
|
};
|
|
3643
3730
|
|
|
3731
|
+
/** The rendered eyes' midpoint, in canvas CSS pixels, independent of framing and camera orbit. */
|
|
3732
|
+
Scene.prototype.gazeOrigin = function () {
|
|
3733
|
+
var avatar = this.avatar;
|
|
3734
|
+
if (!avatar || !this.THREE || !this.camera) return null;
|
|
3735
|
+
var point = this._gazeOriginVec || (this._gazeOriginVec = new this.THREE.Vector3());
|
|
3736
|
+
var other = this._gazeOtherEyeVec || (this._gazeOtherEyeVec = new this.THREE.Vector3());
|
|
3737
|
+
var humanoid = avatar.vrm && avatar.vrm.humanoid;
|
|
3738
|
+
var left = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('leftEye') : null;
|
|
3739
|
+
var right = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('rightEye') : null;
|
|
3740
|
+
left = left || (avatar.bones && avatar.bones.leftEye);
|
|
3741
|
+
right = right || (avatar.bones && avatar.bones.rightEye);
|
|
3742
|
+
var source;
|
|
3743
|
+
if (left && right) {
|
|
3744
|
+
left.getWorldPosition(point); right.getWorldPosition(other);
|
|
3745
|
+
point.add(other).multiplyScalar(0.5);
|
|
3746
|
+
source = 'eyes';
|
|
3747
|
+
} else if (avatar.vrm && avatar.vrm.lookAt && typeof avatar.vrm.lookAt.getLookAtWorldPosition === 'function') {
|
|
3748
|
+
// The loader has already converted VRM 0/1 coordinate conventions for this offset.
|
|
3749
|
+
if (avatar.root) avatar.root.updateWorldMatrix(true, true);
|
|
3750
|
+
avatar.vrm.lookAt.getLookAtWorldPosition(point);
|
|
3751
|
+
source = 'lookAt';
|
|
3752
|
+
} else {
|
|
3753
|
+
var head = gazeNodeFor(avatar);
|
|
3754
|
+
if (!head) return null;
|
|
3755
|
+
head.getWorldPosition(point);
|
|
3756
|
+
source = 'head';
|
|
3757
|
+
}
|
|
3758
|
+
this.camera.updateMatrixWorld(true);
|
|
3759
|
+
var projected = this.projectToCanvas(point);
|
|
3760
|
+
if (!projected || projected.z < -1 || projected.z > 1) return null;
|
|
3761
|
+
projected.source = source;
|
|
3762
|
+
return projected;
|
|
3763
|
+
};
|
|
3764
|
+
|
|
3644
3765
|
/**
|
|
3645
3766
|
* Apply the draught to the chains, decay it, and keep it LOCAL.
|
|
3646
3767
|
*
|
|
@@ -3865,6 +3986,10 @@
|
|
|
3865
3986
|
*/
|
|
3866
3987
|
Scene.prototype.setSpringDefaults = function (defaults) {
|
|
3867
3988
|
this.springDefaults = defaults && typeof defaults === 'object' ? defaults : null;
|
|
3989
|
+
// Catalog defaults can arrive after the avatar and preferences on either surface.
|
|
3990
|
+
// Apply them now; waiting for another preference edit makes a reload appear to fix physics.
|
|
3991
|
+
this.applySprings(this.preferences);
|
|
3992
|
+
this.captureSpringBase();
|
|
3868
3993
|
};
|
|
3869
3994
|
|
|
3870
3995
|
/**
|
|
@@ -4931,6 +5056,8 @@
|
|
|
4931
5056
|
|
|
4932
5057
|
Scene.prototype.dispose = function () {
|
|
4933
5058
|
if (this.disposed) return;
|
|
5059
|
+
(this.toonMeshes || []).forEach(function (mesh) { mesh.node.material = mesh.original; });
|
|
5060
|
+
(this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
|
|
4934
5061
|
this.disposed = true;
|
|
4935
5062
|
// Stop and uncache every mixer before the renderer goes: a mixer holds a
|
|
4936
5063
|
// binding table that references the model's bones, so leaving them behind
|
package/lib/vrm-info.js
CHANGED
|
@@ -437,7 +437,7 @@ export function describeGlb(glb, label) {
|
|
|
437
437
|
(gltf.materials || []).forEach((material, index) => {
|
|
438
438
|
const pbr = material.pbrMetallicRoughness || {};
|
|
439
439
|
const extensions = material.extensions || {};
|
|
440
|
-
const mtoon =
|
|
440
|
+
const mtoon = vrm0 && (vrm0.materialProperties || [])[index];
|
|
441
441
|
const mtoon1 = extensions.VRMC_materials_mtoon || null;
|
|
442
442
|
report.materials.push({
|
|
443
443
|
index,
|
|
@@ -826,6 +826,21 @@ export function cacheStats() {
|
|
|
826
826
|
*/
|
|
827
827
|
export function compactModelInfo(info) {
|
|
828
828
|
return {
|
|
829
|
+
toon: {
|
|
830
|
+
total: (info.materials || []).length,
|
|
831
|
+
materials: (info.materials || []).filter((m) => m.mtoon1 || (m.mtoon && m.mtoon.shader === 'VRM/MToon')).map((m) => {
|
|
832
|
+
// Same VRM 0 -> MToon 1 conversion as the pinned three-vrm loader.
|
|
833
|
+
const legacy = m.mtoon && m.mtoon.floatProperties || {};
|
|
834
|
+
const shift = legacy._ShadeShift ?? 0;
|
|
835
|
+
const toony = (legacy._ShadeToony ?? 0.9) + (1 - (legacy._ShadeToony ?? 0.9)) * (0.5 + 0.5 * shift);
|
|
836
|
+
return {
|
|
837
|
+
name: m.name,
|
|
838
|
+
shadingToonyFactor: m.mtoon1 ? (m.mtoon1.shadingToonyFactor ?? 0.9) : toony,
|
|
839
|
+
shadingShiftFactor: m.mtoon1 ? (m.mtoon1.shadingShiftFactor ?? 0) : -shift - (1 - toony),
|
|
840
|
+
outline: m.mtoon1 ? Boolean(m.mtoon1.outlineWidthMode && m.mtoon1.outlineWidthMode !== 'none' && m.mtoon1.outlineWidthFactor > 0)
|
|
841
|
+
: Boolean(m.mtoon.floatProperties._OutlineWidthMode && m.mtoon.floatProperties._OutlineWidth > 0),
|
|
842
|
+
}; }),
|
|
843
|
+
},
|
|
829
844
|
supported: info.supported === true,
|
|
830
845
|
vrmSpec: info.vrmSpec,
|
|
831
846
|
generator: info.generator,
|