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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-plugin-vrmpet3d ?host half.
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 ?the only plugin-owned browser routes are
11
- // `/plugins/<id>/client*.js` ?so a model or a `.vrma` the user drops
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 ?that is
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 ?a file offered by both catalogs, or by neither, is a class of bug
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 ?WITHOUT a trailing slash.
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 ?which is the tell that this one was wrong.
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 ?a copy, a proxy or a re-read all produce
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 ?a containment check against the wrong baseline serves
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 ?id, so a rescan hands the same file back the same id. */
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>` ?which then 404s and drops the
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 ?file mapping in one pass.
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 ?id, so a rescan hands the same file back the same id. */
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` ?the two
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 ?file mapping in one pass.
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 ?a GLB that
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 ?one capped at 64 KB and one not ?and the route without the
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>` ?stream one model or texture file.
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>` ?stream one authored-motion file.
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 ?nothing
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` ?the motion catalog, shaped like `/vrmpet3d/catalog`.
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 ?see HANDOFF sec.12), so the window is started here: the
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 ?a hard
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: 'Move to the floating window',
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
- * Pitch is left where it was: a head does not tip 90° forward or back without looking broken, and the
71
- * request was for the turn. Raise this the same way if a bigger nod is ever wanted.
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 = 0.3;
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 pitch = Math.max(-GAZE_PITCH_LIMIT, Math.min(GAZE_PITCH_LIMIT, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
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(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget),
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
- return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * GAZE_PITCH_LIMIT };
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 = extensions.VRM && extensions.VRM.materialProperties;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-vrmpet3d",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "DeepSeek Harness 的 VRM 3D 桌宠:任务状态联动、独立悬浮窗、模型动作与 AI 碎碎念。",
5
5
  "type": "module",
6
6
  "license": "MIT",