aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 22:24:22 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 22:24:22 +0900
commit63c1a9dfa8b04ab9725a9a49ef34cd3fc6ade56c (patch)
tree9a5b0970585a927bb37db09d23ba7bb1fc4190be /public/bluebey-studio
parent39a6c31beed18f0d1fc211fda919fae7ce2d9505 (diff)
bluebey-studio: 小物の3軸回転・線画対応と、まんがのコマ枠・共有リンク短縮
小物にX/Y/Zの3軸回転とフラット/線画スタイル対応を追加し、鉛筆の芯を円錐に埋め、ノートと消しゴムを追加。まんがは各コマに枠を描きzip出力を廃止。共有リンクは既定値の省略で約2,429→321文字に短縮。縦書きは句読点のぶら下げと長音・小書き文字の位置を補正。線画の帽子にトーンを付け、removeMeshでマテリアルを復元して切替時の破損を修正。
Diffstat (limited to 'public/bluebey-studio')
-rw-r--r--public/bluebey-studio/src/caption.js87
-rw-r--r--public/bluebey-studio/src/main.js53
-rw-r--r--public/bluebey-studio/src/panel.js6
-rw-r--r--public/bluebey-studio/src/props.js34
-rw-r--r--public/bluebey-studio/src/styles.js31
-rw-r--r--public/bluebey-studio/src/urlState.js79
6 files changed, 258 insertions, 32 deletions
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js
index 1543f0e..a315971 100644
--- a/public/bluebey-studio/src/caption.js
+++ b/public/bluebey-studio/src/caption.js
@@ -59,6 +59,37 @@ const WIDE_RANGES = [
];
/**
+ * Punctuation that may hang past the bottom of a vertical column.
+ *
+ * Japanese line breaking (禁則処理) forbids starting a line with a closing
+ * mark, so when one of these would land at the top of a new column it is kept
+ * on the previous column and drawn past that column's last cell instead.
+ */
+const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']);
+
+/** Small kana, which sit toward the upper-right of their cell when stacked. */
+const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ';
+
+/**
+ * Per-character offset for vertical (縦書き) text, in em.
+ *
+ * A column draws one glyph per cell, so a few characters need a nudge to read
+ * the way a Japanese typesetter would place them: the prolonged sound mark
+ * becomes an upright tick, small kana tuck into the upper-right, and the
+ * brackets lean toward the ends of the span they enclose. Everything else
+ * stays dead-centre. Sharing this table is what keeps the canvas and the SVG
+ * export in step.
+ */
+const VERTICAL_OFFSETS = new Map();
+for (const ch of SMALL_KANA) VERTICAL_OFFSETS.set(ch, { dx: 0.12, dy: -0.12 });
+VERTICAL_OFFSETS.set('ー', { dx: 0.22, dy: -0.3 });
+for (const ch of '「『(〔') VERTICAL_OFFSETS.set(ch, { dx: 0.14, dy: -0.12 });
+for (const ch of '」』)〕') VERTICAL_OFFSETS.set(ch, { dx: -0.14, dy: 0.12 });
+
+/** Offset used for the characters that need no special placement. */
+const NO_OFFSET = Object.freeze({ dx: 0, dy: 0 });
+
+/**
* The `ctx.font` string for a caption.
*
* Canvas wants size, weight and family in one string, and the size has to carry
@@ -356,8 +387,9 @@ function drawText(ctx, layout, state, font) {
const chars = [...lines[i].text];
const x = left + (lines.length - 1 - i) * step;
for (let j = 0; j < chars.length; j++) {
- const y = top + j * fontSize + metrics.ascent;
- ctx.fillText(chars[j], x, y);
+ const offset = verticalOffsetOf(chars[j]);
+ const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
+ ctx.fillText(chars[j], x + offset.dx * fontSize, y);
}
}
return;
@@ -408,8 +440,9 @@ function liveText(state, layout, font, fill, places) {
const chars = [...lines[i].text];
const x = left + (lines.length - 1 - i) * step;
for (let j = 0; j < chars.length; j++) {
- const y = top + j * fontSize + metrics.ascent;
- tspans.push(`<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">${escapeText(chars[j])}</tspan>`);
+ const offset = verticalOffsetOf(chars[j]);
+ const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
+ tspans.push(`<tspan x="${formatNumber(x + offset.dx * fontSize, places)}" y="${formatNumber(y, places)}">${escapeText(chars[j])}</tspan>`);
}
}
return `<text ${attributes}>${tspans.join('')}</text>`;
@@ -475,9 +508,26 @@ function layoutBlock(font, text, options) {
}
/**
+ * Offset of one character inside its vertical cell, in em.
+ *
+ * Shared by `drawText` and `liveText` so a glyph that needs a nudge lands in
+ * the same place on screen and in the export.
+ */
+function verticalOffsetOf(ch) {
+ return VERTICAL_OFFSETS.get(ch) ?? NO_OFFSET;
+}
+
+/**
* Vertical (縦書き) layout: each paragraph becomes one column, characters stacked
* top to bottom, columns running right to left. A paragraph longer than the
* available height is split into more columns.
+ *
+ * Closing punctuation hangs: when `、` or `。` (or `,`/`.`) would fall at the top
+ * of a new column, it stays on the previous one and is drawn just below that
+ * column's last cell instead. Such a hanging character does not count toward
+ * the column's length, so it never makes the block a cell taller or adds a
+ * column of its own. `lines[i].hang` reports how many characters on that line
+ * are hanging.
*/
function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
const limit = Number.isFinite(maxHeight) && maxHeight > 0
@@ -487,18 +537,37 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
const chars = [...paragraph];
if (chars.length === 0) {
- columns.push('');
+ columns.push({ text: '', count: 0 });
continue;
}
- for (let i = 0; i < chars.length; i += limit) {
- columns.push(chars.slice(i, i + limit).join(''));
+ let current = '';
+ let count = 0;
+ for (const ch of chars) {
+ if (count >= limit) {
+ // A closing mark that would start a column hangs off the previous one.
+ if (HANGING_PUNCTUATION.has(ch)) {
+ current += ch;
+ continue;
+ }
+ columns.push({ text: current, count });
+ current = '';
+ count = 0;
+ }
+ current += ch;
+ count += 1;
}
+ columns.push({ text: current, count });
}
- const longest = columns.reduce((max, column) => Math.max(max, [...column].length), 0);
+ const longest = columns.reduce((max, column) => Math.max(max, column.count), 0);
return {
- lines: columns.map((column) => ({ text: column, width: fontSize })),
+ lines: columns.map((column) => ({
+ text: column.text,
+ width: fontSize,
+ hang: [...column.text].length - column.count,
+ })),
width: columns.length * fontSize * lineHeight,
height: longest * fontSize,
+ vertical: true,
};
}
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index cecdce5..23ed017 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -21,7 +21,6 @@ import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js
import { rollAll, decodeSeed } from './gacha.js';
import { encodeState, decodeState } from './urlState.js';
import { createMouthFlap, levelToMouth } from './mouthFlap.js';
-import { createZip } from './zip.js';
import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js';
import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js';
@@ -522,9 +521,9 @@ async function init() {
// Hats are built at runtime, so they are not in the GLB's mesh list. Register
// them with the style system (and the current hat) so they follow flat/line art
// and get an outline hull like every other part.
- hatStyleRegister = (mesh) => styles.addMesh(mesh);
+ hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true });
hatStyleUnregister = (mesh) => styles.removeMesh(mesh);
- for (const mesh of hatMeshes) styles.addMesh(mesh);
+ for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true });
const face = new Face({
eyeMesh: model.parts.eyeMesh,
mouthMesh: model.parts.mouthMesh,
@@ -951,11 +950,21 @@ async function init() {
if (signature === propSignature) return;
propSignature = signature;
for (const child of [...propRoot.children]) {
+ // Hand every mesh back to the style system before the prop is disposed.
+ // `removeMesh` restores each mesh's own material (a prop under 線画 is
+ // wearing the shared paper material, which must not be disposed). Collect
+ // the meshes first: `removeMesh` deletes the outline hull, a child of the
+ // prop group.
+ const meshes = [];
+ child.traverse((object) => {
+ if (styles.originals.has(object)) meshes.push(object);
+ });
+ for (const mesh of meshes) styles.removeMesh(mesh);
propRoot.remove(child);
disposeProp(child);
}
for (const item of items) {
- const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotY: 0, scale: 1 };
+ const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
let group;
try {
group = buildProp(item.kind, { colors: state.render.colors, scale: item.scale ?? def.scale ?? 1 });
@@ -964,12 +973,22 @@ async function init() {
continue;
}
group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0);
- group.rotation.y = ((item.rotY ?? def.rotY ?? 0) * DEG);
+ group.rotation.set(
+ (item.rotX ?? def.rotX ?? 0) * DEG,
+ (item.rotY ?? def.rotY ?? 0) * DEG,
+ (item.rotZ ?? def.rotZ ?? 0) * DEG,
+ );
propRoot.add(group);
+ // Register with the style system, so フラット and 線画 reach the props too
+ // and each part gets an outline hull like the body and the hats.
+ group.traverse((object) => {
+ if (object.isMesh) styles.addMesh(object);
+ });
// 看板 only: paint the saved text onto the freshly built face (no-op for
// every other prop, which has no writing surface).
applyPropText(group, item.text);
}
+ refreshOutlineExclusion?.();
app.needsRender = true;
}
@@ -2376,13 +2395,15 @@ async function init() {
}
function addProp(kind) {
- const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotY: 0, scale: 1 };
+ const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
state.props.items = [...(state.props.items ?? []), {
kind,
x: def.x ?? 0,
y: def.y ?? 0,
z: def.z ?? 0,
+ rotX: def.rotX ?? 0,
rotY: def.rotY ?? 0,
+ rotZ: def.rotZ ?? 0,
scale: def.scale ?? 1,
}];
applyState({}, { scope: 'render', sync: true });
@@ -2464,10 +2485,9 @@ async function init() {
}
/**
- * まんが: replay the panels, capture each one, and hand back a sheet of them
- * plus a zip of the individual frames.
+ * まんが: replay the panels, capture each one, and hand back a framed sheet.
*/
- async function saveStory({ scale = 2, includeZip = true } = {}) {
+ async function saveStory({ scale = 2 } = {}) {
const panels = state.story?.panels ?? [];
if (!panels.length) {
toast('コマがありません。「今の状態をコマに追加」で作ってください');
@@ -2509,6 +2529,11 @@ async function init() {
const numbered = columns >= 2 && captures.length >= 3;
if (numbered) await ensureCaptionFont();
+ // A manga page reads by its panel frames. They are drawn inside each cell so
+ // the artwork keeps its full size, and are thick enough to survive the 1×
+ // export.
+ const frame = Math.max(3, Math.round(Math.min(cellW, cellH) * 0.012));
+
const sheet = document.createElement('canvas');
sheet.width = padding * 2 + cellW * columns + gap * (columns - 1);
sheet.height = padding * 2 + cellH * rows + gap * (rows - 1);
@@ -2519,18 +2544,14 @@ async function init() {
const x = padding + (index % columns) * (cellW + gap);
const y = padding + Math.floor(index / columns) * (cellH + gap);
ctx.drawImage(capture.canvas, x, y, cellW, cellH);
+ ctx.lineWidth = frame;
+ ctx.strokeStyle = '#2a1e33';
+ ctx.strokeRect(x + frame / 2, y + frame / 2, cellW - frame, cellH - frame);
if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH);
});
const stamp = exporter.timestamp();
exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`);
- if (includeZip) {
- const files = await Promise.all(captures.map(async (capture, index) => ({
- name: `panel-${String(index + 1).padStart(2, '0')}.png`,
- data: new Uint8Array(await capture.blob.arrayBuffer()),
- })));
- exporter.downloadBlob(createZip(files), `bluebey-comic-${stamp}.zip`);
- }
toast(`まんがを書き出しました(${captures.length}コマ)`);
}
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 6d8e8ba..f7053cb 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -2105,7 +2105,9 @@ export function buildPanel(app, root) {
x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('x') }),
y: slider({ label: 'たかさ(Y)', min: 0, max: 2, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('y') }),
z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('z') }),
- rotY: slider({ label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }),
+ rotX: slider({ label: '前後の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotX') }),
+ rotY: slider({ label: '回転(よこ)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }),
+ rotZ: slider({ label: '左右の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotZ') }),
scale: slider({ label: '大きさ', min: 0.4, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: patch('scale') }),
};
// The numbers are only a fallback for the drag, so they stay folded away.
@@ -2971,7 +2973,9 @@ export function buildPanel(app, root) {
entry.sliders.x.set(item.x ?? 0);
entry.sliders.y.set(item.y ?? 0);
entry.sliders.z.set(item.z ?? 0);
+ entry.sliders.rotX.set(item.rotX ?? 0);
entry.sliders.rotY.set(item.rotY ?? 0);
+ entry.sliders.rotZ.set(item.rotZ ?? 0);
entry.sliders.scale.set(item.scale ?? 1);
entry.faceScale?.set(item.faceScale ?? 1);
applyPropFaceScale(groups[entry.index], item.faceScale);
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
index 70a258d..9fb3e49 100644
--- a/public/bluebey-studio/src/props.js
+++ b/public/bluebey-studio/src/props.js
@@ -360,7 +360,9 @@ const buildPencil = prop((group, material) => {
addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6);
addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6);
addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6);
- addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.94, 0], 6);
+ // The lead's wide base sinks into the cone (whose tip is at y = 1.88), so the
+ // two look joined, while its point still pokes out past the wood.
+ addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6);
});
/** コップ: a mug with a rim, a handle and a little drink inside. */
@@ -371,6 +373,32 @@ const buildCup = prop((group, material) => {
addMesh(group, new THREE.TorusGeometry(0.16, 0.045, 8, 16), material('paper'), 0.36, 0.36, 0);
});
+/** ノート: a fat pad of pages under a thin cover, bound along one long edge. */
+const buildNotebook = prop((group, material) => {
+ const width = 1.5;
+ const depth = 2.0;
+ const pages = 0.18;
+ const cover = 0.04;
+ // The cover pokes out past the pages, so the notebook reads from above.
+ addBox(group, material('wood', 0.6), [width + 0.08, cover, depth + 0.08], [0, cover / 2, 0]);
+ addBox(group, material('paper'), [width, pages, depth], [0, cover + pages / 2, 0]);
+ addBox(
+ group,
+ material('accent'),
+ [0.16, pages + 0.04, depth + 0.02],
+ [-width / 2 + 0.08, cover + pages / 2, 0],
+ );
+});
+
+/** 消しゴム: a chunky two-tone block, a hard accent top on a soft white base. */
+const buildEraser = prop((group, material) => {
+ const width = 0.9;
+ const depth = 0.5;
+ const half = 0.15;
+ addBox(group, material('paper'), [width, half, depth], [0, half / 2, 0]);
+ addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]);
+});
+
export const PROP_LIBRARY = [
{ id: 'podium', label: '演台', height: 2.2, build: buildPodium },
{ id: 'desk', label: '机', height: 1.5, build: buildDesk },
@@ -383,6 +411,8 @@ export const PROP_LIBRARY = [
{ id: 'futon', label: '布団', height: 0.65, build: buildFuton },
{ id: 'pencil', label: '鉛筆', height: 2.0, build: buildPencil },
{ id: 'cup', label: 'コップ', height: 0.66, build: buildCup },
+ { id: 'notebook', label: 'ノート', height: 0.22, build: buildNotebook },
+ { id: 'eraser', label: '消しゴム', height: 0.3, build: buildEraser },
];
/**
@@ -407,6 +437,8 @@ export const PROP_DEFAULTS = {
futon: { x: 4.2, y: 0, z: -0.6, rotY: -0.35, scale: 1 },
pencil: { x: -1.4, y: 0, z: 2.6, rotY: 0.3, scale: 1 },
cup: { x: 1.5, y: 0, z: 2.5, rotY: -0.4, scale: 1 },
+ notebook: { x: -3.2, y: 0, z: -2.7, rotY: 0.35, scale: 1 },
+ eraser: { x: 3.6, y: 0, z: 2.6, rotY: -0.45, scale: 1 },
};
/** The palette entry each part reads, derived from the app's colours. */
diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js
index 1b6a08d..8efdf71 100644
--- a/public/bluebey-studio/src/styles.js
+++ b/public/bluebey-studio/src/styles.js
@@ -110,6 +110,11 @@ export class Styles {
this.gradientMap = makeGradientMap();
this.toonMaterials = new Map();
this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false });
+ // A hat is not flat: in a line drawing a white hat on a white head merges
+ // into one shape, so hats get a light tone instead of paper (see addMesh).
+ this.toneMaterial = new THREE.MeshBasicMaterial({ color: toneOf(paper), toneMapped: false });
+ /** Runtime meshes that want the tone fill rather than paper in 線画. */
+ this.toneMeshes = new Set();
this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true });
this.outlineWidth = outlineWidth;
@@ -186,11 +191,15 @@ export class Styles {
/**
* Register a mesh that is built at runtime (a hat), so it follows the render
* styles and gets an outline hull like the GLB parts. `removeMesh` undoes it.
+ *
+ * `options.tone` gives the mesh the light tone fill in 線画 instead of paper,
+ * for parts whose shape would otherwise merge with the body (the hats).
*/
- addMesh(mesh) {
+ addMesh(mesh, options = {}) {
if (!mesh || this.originals.has(mesh)) return;
this.originals.set(mesh, mesh.material);
this.meshes.push(mesh);
+ if (options.tone) this.toneMeshes.add(mesh);
const kind = this.kindOf(mesh);
if (!OUTLINE_SKIP.has(kind)) {
const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
@@ -217,6 +226,7 @@ export class Styles {
/** Take a runtime mesh (and its outline hull) back out. */
removeMesh(mesh) {
if (!mesh || !this.originals.has(mesh)) return;
+ const original = this.originals.get(mesh);
const hull = mesh.userData.hull;
if (hull) {
hull.removeFromParent();
@@ -224,7 +234,12 @@ export class Styles {
if (i >= 0) this.outlineMeshes.splice(i, 1);
delete mesh.userData.hull;
}
+ // Put the mesh's own material back before dropping it: while a line style is
+ // on it is wearing the *shared* paper/tone material, and the caller is about
+ // to dispose it - which would blank every part using it.
+ if (original) mesh.material = original;
this.originals.delete(mesh);
+ this.toneMeshes.delete(mesh);
const j = this.meshes.indexOf(mesh);
if (j >= 0) this.meshes.splice(j, 1);
}
@@ -243,11 +258,14 @@ export class Styles {
const original = this.originals.get(mesh);
let material;
if (line) {
- // Paper on paper in `lineart`; invisible in `outline`, which is the pass
+ // Paper on paper in `lineart`; a light tone for hats, which would
+ // otherwise merge into the head; invisible in `outline`, which is the pass
// the SVG trace reads. Either way every edge - the body, the nose, and
// the leaves - is drawn by the screen-space outline, so no part needs a
// material trick of its own any more.
- material = value === 'lineart' ? this.paperMaterial : this.invisibleMaterial;
+ material = value === 'lineart'
+ ? (this.toneMeshes.has(mesh) ? this.toneMaterial : this.paperMaterial)
+ : this.invisibleMaterial;
} else if (value === 'flat') {
material = this.toonFor(original);
} else {
@@ -307,6 +325,7 @@ export class Styles {
setPaper(color) {
this.paper = color;
this.paperMaterial.color.set(color);
+ this.toneMaterial.color.copy(toneOf(color));
}
setOutlineVisible(visible) {
@@ -360,6 +379,7 @@ export class Styles {
for (const entry of this.hullMaterials.values()) entry.material.dispose();
this.hullMaterials.clear();
this.paperMaterial.dispose();
+ this.toneMaterial.dispose();
this.invisibleMaterial.dispose();
this.gradientMap.dispose();
for (const material of this.toonMaterials.values()) material.dispose();
@@ -367,6 +387,11 @@ export class Styles {
}
}
+/** A step towards the ink, for the light fill a hat gets in 線画. */
+function toneOf(paper) {
+ return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), 0.14);
+}
+
/** A 3-step ramp gives crisper cartoon bands than the shader's default. */
function makeGradientMap() {
const steps = new Uint8Array([90, 165, 255]);
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;
}