From 90728692fa9dc9f6d7327c95fcdf245df1f14342 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 13:55:44 +0300 Subject: [PATCH 1/4] [fix] a big image becomes a texture instead of being refused; textures remember their image - setObjectsTexture no longer refuses files over 8 MB: the path already downscales to 1024 px, so the cap only refused photos it was built to shrink (roadmap 40 F13, image-large). The one limit left is a 64 MB decode-safety cap. - material.userData.mapSource = the content hash of the Explorer image a texture was made from (Explorer drop, UV slot drop). It rides the `map` message as an additive `source` field, the `materials` payload as `mapSources`, and a material-type switch. Any write without one (paint, undo, a file from disk, an older peer) drops the link. - texturesFromSource(hash) / linkTextureSource(uuid, slot, hash): the seams the Image editor's Save uses to re-texture every material made from an image. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/commandsHandler.svelte.js | 3 +- src/lib/explorerDrop.js | 2 +- src/lib/materialsHandler.js | 96 ++++++++++++++++++++++++++----- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index 0779a2f9f..b557fd3f0 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -575,7 +575,8 @@ export async function objectParameters(data) { let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid); // UV2: `slot` addresses one material of an ARRAY. Absent (an older peer, // or any single-material object) means slot 0 = today's behaviour. - if (mesh) applyMap(mesh, data.map, data.slot ?? 0); + // 40-image: `source` (additive) = the library image the texture was made from + if (mesh) applyMap(mesh, data.map, data.slot ?? 0, typeof data.source === 'string' ? data.source : null); } else if (data.parameter == 'materialParam') { setMaterialParam(data.uuid, data.key, data.value, false); } else if (data.parameter == 'animation') { diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js index c9800b56c..956197d7d 100644 --- a/src/lib/explorerDrop.js +++ b/src/lib/explorerDrop.js @@ -132,7 +132,7 @@ export async function applyExplorerImage(uuid, payload, slot = 0) { if (!item || item.kind !== 'image') return false; const blob = await itemBlob(item.id); if (!blob) return false; - await setObjectTexture(uuid, new File([blob], item.name, { type: blob.type || 'image/png' }), slot); + await setObjectTexture(uuid, new File([blob], item.name, { type: blob.type || 'image/png' }), slot, { source: item.hash }); return true; } diff --git a/src/lib/materialsHandler.js b/src/lib/materialsHandler.js index 59c633e1c..1f400cedd 100644 --- a/src/lib/materialsHandler.js +++ b/src/lib/materialsHandler.js @@ -100,6 +100,8 @@ registerHistoryKind('material', (entry, state) => { if (entry.param === 'type') { switchMaterialType(entry.uuid, state.value, true); } else if (entry.param === 'map') { + // 40-image: an undone/redone texture is no longer known to BE a library image, so it + // carries no source — a later image save must not paint over the step it reverted to applyMap(object, state.value, entry.slot ?? 0); broadcast({ type: 'objectParameters', @@ -162,6 +164,9 @@ export function applyMaterials(object, payload, replicate = false) { next.forEach((/** @type {any} */ material, /** @type {number} */ i) => { const url = payload.mapDataUrls?.[i]; if (url) material.userData.mapDataUrl = url; + // 40-image: and which library image it came from (absent on an older peer's payload) + const source = payload.mapSources?.[i]; + if (source) material.userData.mapSource = source; }); object.material = next.length === 1 ? next[0] : next; if (payload.groups && object.geometry) { @@ -217,6 +222,7 @@ export function materialsPayload(object) { ...serialized, // toJSON drops userData.mapDataUrl, and the Inspector/UV sidebar read it mapDataUrls: materials.map((m) => m.userData?.mapDataUrl ?? null), + mapSources: materials.map((m) => m.userData?.mapSource ?? null), groups: (object.geometry?.groups ?? []).map((/** @type {any} */ g) => ({ start: g.start, count: g.count, @@ -392,11 +398,19 @@ export function copyTextureParams(from, to) { * thumbnail. UV2: `slot` addresses a material ARRAY (an imported .obj/.mtl, a * merged mesh); omitted means slot 0, which for a single-material object is * exactly the old behaviour — so older peers' slot-less messages still land. - * @param {any} object @param {string | null} dataURL @param {number} [slot] + * + * 40-image: `source` is the content HASH of the Explorer image the texture was made from, + * kept on `userData.mapSource` so saving that image in the Image editor can find every + * material showing it. Any write WITHOUT one (a paint stroke, an undo, a file picked from + * disk, an older peer) drops the link: the texture is no longer known to be that image, + * and a later save must not paint over it. + * @param {any} object @param {string | null} dataURL @param {number} [slot] @param {string | null} [source] */ -export function applyMap(object, dataURL, slot = 0) { +export function applyMap(object, dataURL, slot = 0, source = null) { const material = materialAt(object, slot); if (!material || !('map' in material)) return; + if (source && dataURL != null) material.userData.mapSource = String(source); + else delete material.userData.mapSource; if (dataURL == null) { material.map?.dispose(); material.map = null; @@ -429,7 +443,7 @@ export function applyMap(object, dataURL, slot = 0) { /** Downscale an image file to maxSize px and encode as a compact dataURL * (exported since 17-D2 — the OBJ/.mtl import path reuses it for its textures) - * @param {File} file @param {number} maxSize */ + * @param {File | Blob} file @param {number} maxSize */ export async function downscaleImage(file, maxSize) { const bitmap = await createImageBitmap(file); const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height)); @@ -442,6 +456,16 @@ export async function downscaleImage(file, maxSize) { return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/jpeg', 0.85); } +/** + * 40-image (F13, the user's image-large report): the texture is DOWNSCALED to 1024 px + * whatever comes in, so refusing a big file at 8 MB refused photos the path was already + * built to shrink. The only limit left is a decode-safety one — a file this size is not + * an image a phone can decode at all. + */ +export const MAX_TEXTURE_SOURCE_BYTES = 64 * 1024 * 1024; +/** the longest side a texture is stored at (the dataURL rides the wire and every save) */ +export const TEXTURE_MAX_SIDE = 1024; + /** * Set one image file as the texture of MANY objects (17-D1) and replicate. * The file is decoded and downscaled ONCE and the resulting dataURL applied per @@ -451,18 +475,20 @@ export async function downscaleImage(file, maxSize) { * unreliable (the third `createImageBitmap` of one picked file can reject). * UV2: `slot` picks the material slot, so a multi-material mesh is no longer * refused — it used to toast "not supported yet" and skip. - * @param {string[]} uuids @param {File} file @param {number} [slot] + * 40-image: `opts.source` = the content hash of the Explorer image `file` holds (see applyMap). + * @param {string[]} uuids @param {File | Blob} file @param {number} [slot] @param {{source?: string | null}} [opts] * @returns {Promise} how many were textured */ -export async function setObjectsTexture(uuids, file, slot = 0) { - if (file.size > 8 * 1024 * 1024) { - showToast('Image is too large (max 8 MB)'); +export async function setObjectsTexture(uuids, file, slot = 0, opts = {}) { + if (file.size > MAX_TEXTURE_SOURCE_BYTES) { + showToast('That image is too big to read (' + Math.round(file.size / 1048576) + ' MB) — save it smaller first'); return 0; } + const source = opts.source ? String(opts.source) : null; /** @type {string} */ let dataURL; try { - dataURL = await downscaleImage(file, 1024); + dataURL = await downscaleImage(file, TEXTURE_MAX_SIDE); } catch (error) { console.log(error); showToast('Could not read the image file'); @@ -478,13 +504,15 @@ export async function setObjectsTexture(uuids, file, slot = 0) { continue; } recordMaterialChange(uuid, 'map', null, material.userData?.mapDataUrl ?? null, dataURL, slot); - applyMap(object, dataURL, slot); + applyMap(object, dataURL, slot, source); broadcast({ type: 'objectParameters', parameter: 'map', uuid: uuid, map: dataURL, - ...(slot ? { slot } : {}) + ...(slot ? { slot } : {}), + // additive: an older peer ignores it, and a message without it drops the link + ...(source ? { source } : {}) }); applied++; } @@ -493,9 +521,50 @@ export async function setObjectsTexture(uuids, file, slot = 0) { } /** Set an image file as the object's texture and replicate - * @param {string} uuid @param {File} file @param {number} [slot] */ -export async function setObjectTexture(uuid, file, slot = 0) { - await setObjectsTexture([uuid], file, slot); + * @param {string} uuid @param {File | Blob} file @param {number} [slot] @param {{source?: string | null}} [opts] */ +export async function setObjectTexture(uuid, file, slot = 0, opts = {}) { + await setObjectsTexture([uuid], file, slot, opts); +} + +/** + * 40-image: every material slot in the scene whose texture was made from the library image + * with this content hash — what a save in the Image editor re-textures. + * @param {string} hash @returns {{uuid: string, slot: number}[]} + */ +export function texturesFromSource(hash) { + const want = String(hash ?? ''); + /** @type {{uuid: string, slot: number}[]} */ + const out = []; + if (!want) return out; + get(objectsGroup)?.traverse?.((/** @type {any} */ node) => { + materialsOf(node).forEach((/** @type {any} */ m, /** @type {number} */ slot) => { + if (m?.userData?.mapSource === want && m.userData.mapDataUrl) out.push({ uuid: node.uuid, slot }); + }); + }); + return out; +} + +/** + * 40-image: name the library image a texture already IS, without re-encoding it (the Image + * editor imported a texture that had no source). The texture is unchanged, so no history + * entry; the message carries the same pixels plus the link so every peer agrees. + * @param {string} uuid @param {number} slot @param {string} hash + */ +export function linkTextureSource(uuid, slot, hash) { + const object = objectOf(uuid); + const material = materialAt(object, slot); + const url = material?.userData?.mapDataUrl; + if (!material || !url || !hash) return false; + material.userData.mapSource = String(hash); + broadcast({ + type: 'objectParameters', + parameter: 'map', + uuid, + map: url, + ...(slot ? { slot } : {}), + source: String(hash) + }); + return true; } /** @param {string} uuid @param {number} [slot] */ @@ -542,6 +611,7 @@ export function switchMaterialType(uuid, type, replicate = true) { if ('map' in fresh && old.map) { fresh.map = old.map; fresh.userData.mapDataUrl = old.userData?.mapDataUrl; + if (old.userData?.mapSource) fresh.userData.mapSource = old.userData.mapSource; } fresh.transparent = old.transparent; if ('wireframe' in fresh && old.wireframe !== undefined) fresh.wireframe = old.wireframe; From 8c7d38e56f050909d9ed3eacaa2679770b9df9c5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 14:45:04 +0300 Subject: [PATCH 2/4] [feat] image editor: crop, rotate/flip, resize, adjust, save, copy, versions (WIP) - src/lib/image/imageOps.js: pure raster maths (crop/rotate/flip/area+bilinear resize/ brightness-contrast-saturation), vitest imageOps (19 green) - src/lib/image/imageVersions.js: per-image version history in idb (Original always kept, cap 20) - src/lib/image/imageEditor.js: open/close, decode/encode, saveImage (same record, version kept, linked textures re-textured as one undo), restoreImageVersion, saveImageCopy, editMaterialTexture - ImageEditorWindow.svelte: WindowShell canvas + right sidebar (Edit / Versions), zoom/pan/pinch, crop drag, editor-local undo, phone sheet above Play - entry points: Explorer "Edit image...", preview #preview-edit, Inspector #texture-edit - storage breakdown: "Image versions" category - e2e image-editor (WIP: sections 1-8 pass; save is slow on a janky page, being profiled) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 3 + src/components/editors/Explorer.svelte | 13 + .../editors/FilePreviewWindow.svelte | 15 +- .../editors/ImageEditorWindow.svelte | 1056 +++++++++++++++++ src/components/menu/Inspector.svelte | 13 + src/components/ui/Icon.svelte | 4 + src/lib/debugHooks.js | 3 + src/lib/image/imageEditor.js | 226 ++++ src/lib/image/imageOps.js | 296 +++++ src/lib/image/imageVersions.js | 89 ++ src/lib/storageUsage.js | 31 + tests/e2e/image-editor.test.cjs | 432 +++++++ tests/unit/imageOps.test.js | 159 +++ 13 files changed, 2339 insertions(+), 1 deletion(-) create mode 100644 src/components/editors/ImageEditorWindow.svelte create mode 100644 src/lib/image/imageEditor.js create mode 100644 src/lib/image/imageOps.js create mode 100644 src/lib/image/imageVersions.js create mode 100644 tests/e2e/image-editor.test.cjs create mode 100644 tests/unit/imageOps.test.js diff --git a/src/App.svelte b/src/App.svelte index 6cd1bc9f3..6b3fcff87 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -35,6 +35,7 @@ import Explorer from './components/editors/Explorer.svelte' import TextEditorWindow from './components/editors/TextEditorWindow.svelte' import FilePreviewWindow from './components/editors/FilePreviewWindow.svelte' + import ImageEditorWindow from './components/editors/ImageEditorWindow.svelte' import { previewWindows } from './lib/fileWindows' import ModelPreviewWindow from './components/editors/ModelPreviewWindow.svelte' import DungeonMinimap from './components/play/DungeonMinimap.svelte' @@ -387,6 +388,8 @@ import { startMusicToolbox } from './lib/musicToolbox' {#await import('./components/editors/CodeWorkspace.svelte') then m}{/await} {/if} + + {#each $previewWindows as w, i (w.id)} diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 27a2467cb..766a0f521 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -4783,6 +4783,19 @@ } ] : []), + // 40-image (F13): an image in YOUR library opens in the Image editor (crop, rotate, + // resize, adjust, versions). A pack item is a reference to bytes on a CDN, so it + // has nothing to save into — copy it into the Library first. + ...(item.kind === 'image' && !item.packEntry && !item.dataUrl + ? [ + { + label: 'Edit image…', + icon: 'square-pen', + tooltip: 'Crop, rotate, resize and adjust it; saving updates every texture made from it', + action: () => import('$lib/image/imageEditor').then((m) => m.openImageEditor(item.id)) + } + ] + : []), ...(item.kind === 'text' ? [ { diff --git a/src/components/editors/FilePreviewWindow.svelte b/src/components/editors/FilePreviewWindow.svelte index 9fc3eeb1a..c1cdd84bf 100644 --- a/src/components/editors/FilePreviewWindow.svelte +++ b/src/components/editors/FilePreviewWindow.svelte @@ -32,7 +32,7 @@ stepPreview, clampPreviewOpacity } from '$lib/filePreview'; - import { activeFolder, explorerFolders, itemBlob } from '$lib/explorer'; + import { activeFolder, explorerFolders, explorerItems, itemBlob } from '$lib/explorer'; // R22 round 13 P3: the arrows may walk into a MOUNTED project's folder, whose files are // not in the library's blob store — `volumeBlob` reads them from the saved payload. import { volumeOf, volumeFolders, volumeKey, volumeBlob } from '$lib/mountedVolumes'; @@ -209,6 +209,9 @@ * Users popover makes for Watch when a peer is in another scene. */ const opacityApplies = $derived(face === 'image' || face === 'object'); + /** 40-image (F13): an image in YOUR library can be edited — a pack card or a mounted + * project's file has no record to save into */ + const editable = $derived(face === 'image' && !!target?.itemId && $explorerItems.some((i: any) => i.id === target?.itemId)); const walkId = $derived(String(target?.itemId ?? target?.folderId ?? '')); const place = $derived(previewPosition($previewSiblings.entries, walkId)); const canPrev = $derived(!!stepPreview($previewSiblings.entries, walkId, -1)); @@ -626,6 +629,16 @@ {/if} + {#if editable} + + {/if}