diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 22:24:22 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 22:24:22 +0900 |
| commit | 63c1a9dfa8b04ab9725a9a49ef34cd3fc6ade56c (patch) | |
| tree | 9a5b0970585a927bb37db09d23ba7bb1fc4190be /public/bluebey-studio/src/urlState.js | |
| parent | 39a6c31beed18f0d1fc211fda919fae7ce2d9505 (diff) | |
bluebey-studio: 小物の3軸回転・線画対応と、まんがのコマ枠・共有リンク短縮
小物にX/Y/Zの3軸回転とフラット/線画スタイル対応を追加し、鉛筆の芯を円錐に埋め、ノートと消しゴムを追加。まんがは各コマに枠を描きzip出力を廃止。共有リンクは既定値の省略で約2,429→321文字に短縮。縦書きは句読点のぶら下げと長音・小書き文字の位置を補正。線画の帽子にトーンを付け、removeMeshでマテリアルを復元して切替時の破損を修正。
Diffstat (limited to 'public/bluebey-studio/src/urlState.js')
| -rw-r--r-- | public/bluebey-studio/src/urlState.js | 79 |
1 files changed, 77 insertions, 2 deletions
diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js index a3a7e48..d6fb6bd 100644 --- a/public/bluebey-studio/src/urlState.js +++ b/public/bluebey-studio/src/urlState.js @@ -24,8 +24,16 @@ * `decodeState` is deliberately total: any malformed, truncated or hostile input * returns `null` rather than throwing, because it is fed whatever came out of * the address bar. + * + * Most of a session is still at its default, and a default costs bytes on every + * link. `pruneDefaults` drops exactly the fields that equal `defaultState()`, and + * `restoreDefaults` merges what is left back onto a fresh default, so the link + * carries only what the user actually changed. The pruning is lossless and the + * decoder still accepts the older, full-state links. */ +import { applyPatch, defaultState } from './presets.js'; + /** Above this length a `data:` background image is a photo, not a link. */ const MAX_INLINE_IMAGE = 2048; @@ -53,6 +61,73 @@ export function stripForUrl(state) { return copy; } +/** Plain objects only: not `null`, not an array. */ +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Structural equality for the JSON-shaped values this module deals in. */ +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (typeof a !== 'object') return Number.isNaN(a) && Number.isNaN(b); + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + return a.length === b.length && a.every((item, i) => deepEqual(item, b[i])); + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key])); +} + +/** A detached copy of a JSON-shaped value. */ +function cloneValue(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +/** Marks a value that equals its default, so its key can be left out entirely. */ +const OMIT = Symbol('urlState.omit'); + +/** + * `value` with every sub-tree that deep-equals the matching `def` removed. + * Arrays are kept whole - never pruned element by element - because that is how + * `applyPatch` replaces them; objects are pruned key by key. `OMIT` means the + * value matched its default and can be dropped from the parent. + */ +function pruneValue(value, def) { + if (deepEqual(value, def)) return OMIT; + if (isPlainObject(value)) { + if (!isPlainObject(def)) return cloneValue(value); + const out = {}; + for (const [key, child] of Object.entries(value)) { + const pruned = pruneValue(child, def[key]); + if (pruned !== OMIT) out[key] = pruned; + } + return Object.keys(out).length === 0 ? OMIT : out; + } + return Array.isArray(value) ? cloneValue(value) : value; +} + +/** + * A copy of `state` with every value equal to `defaultState()` omitted. Lossless: + * `restoreDefaults` merges the result onto a fresh default. Arrays that differ + * from the default are carried whole so `applyPatch` can replace them. + */ +export function pruneDefaults(state) { + const pruned = pruneValue(state, defaultState()); + return pruned === OMIT ? {} : pruned; +} + +/** + * The inverse of `pruneDefaults`: a full state, with every field the partial does + * not mention left at its default. Merging a previously full state is idempotent, + * so links made by the older encoder still decode. + */ +export function restoreDefaults(partial) { + return applyPatch(defaultState(), partial); +} + /** True when the encoded fragment is bigger than a URL can comfortably hold. */ export function isTooLong(text, limit = 1800) { return typeof text === 'string' && text.length > limit; @@ -117,7 +192,7 @@ async function inflateRaw(bytes) { * @returns {Promise<string>} a URL-safe string (tag + base64url) */ export async function encodeState(state) { - const json = JSON.stringify(stripForUrl(state) ?? null); + const json = JSON.stringify(pruneDefaults(stripForUrl(state))); const bytes = new TextEncoder().encode(json); if (typeof CompressionStream === 'function') { @@ -155,7 +230,7 @@ export async function decodeState(text) { const parsed = JSON.parse(new TextDecoder().decode(bytes)); if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - return { state: parsed }; + return { state: restoreDefaults(parsed) }; } catch { return null; } |
