Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scripts/e2e-recovery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ try {
})
for (const [ok, name] of results) check(ok, name)

// Saving removed this page's checkpoint, so leave an unsaved edit behind for the reloaded page to offer.
await page.evaluate(async () => {
const { useStore } = await import('/src/state/store.ts')
useStore.setState({ showBackfaces: !useStore.getState().showBackfaces })
})
await waitStatus('Local checkpoint saved')
await page.reload({ waitUntil: 'networkidle0' })
await page.waitForSelector('[data-test="recovery-bar"]')
const before = await page.$$eval('[data-test="recovery-bar"] option', (options) => options.length)
Expand Down
14 changes: 4 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import { lengthInputText, unitWord, type DisplayUnits } from './core/units'
import { useShell } from './state/shellStore'
import { useMark } from './state/markStore'
import { useThickness } from './state/thicknessStore'
import { imageScaleX, useFlat } from './state/flatStore'
import { edgesSettled, imageScaleX, useFlat } from './state/flatStore'
import { sectionViewFrames, useSectionView } from './state/sectionViewStore'
import type { FieldScale } from './core/field/colormap'
import { deviationScale } from './core/deviation/deviation'
Expand Down Expand Up @@ -1325,20 +1325,14 @@ export default function App() {
flat: {
openImage,
closeImage: () => commitImage(null),
detectEdges: async (sensitivity) => {
detectEdges: async (sensitivity, signal) => {
const flat = useFlat.getState()
if (flat.subject.kind !== 'image' || !flatGrayRef.current) return flat.edgeCount
const before = flat.edgeVersion
const settled = edgesSettled(flat, signal)
// A new sensitivity detects again by itself (the effect above).
if (sensitivity !== flat.edgeSensitivity) flat.setEdgeSensitivity(sensitivity)
else void runEdgeDetect()
await new Promise<void>((resolve) => {
const stop = useFlat.subscribe((s) => {
if (s.edgeVersion === before) return
stop()
resolve()
})
})
await settled
return useFlat.getState().edgeCount
},
edgeIndex: () => activeEdgeIndex(),
Expand Down
29 changes: 26 additions & 3 deletions src/app/addressOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ function nameOf(url: URL): string {
}
}

/** The most a file named in the address may hold: a link anyone can send
* must not be able to fill the tab's memory, and no project is larger. */
export const MAX_ADDRESS_BYTES = 1024 * 1024 * 1024

/** Fetch the file. The errors say what to do: the status for a refusal,
* and for a failed request — most often a server that does not let this
* site read it — the server to ask. */
export async function fetchAddressFile({ url, name }: AddressOpen): Promise<File> {
export async function fetchAddressFile({ url, name }: AddressOpen, maxBytes = MAX_ADDRESS_BYTES): Promise<File> {
let res: Response
try {
res = await fetch(url, { mode: 'cors' })
Expand All @@ -90,6 +94,25 @@ export async function fetchAddressFile({ url, name }: AddressOpen): Promise<File
throw new Error(`${name} could not be fetched from ${host}. The server has to be reachable, and to let this site read the file (CORS).`)
}
if (!res.ok) throw new Error(`${name} could not be fetched: ${res.status} ${res.statusText}`.trimEnd() + '.')
const bytes = await res.arrayBuffer()
return new File([bytes], name, { type: res.headers.get('content-type') ?? '' })
const tooLarge = () => new Error(`${name} is too large to open from a link: the limit is ${Math.round(maxBytes / 1024 / 1024)} MB. Download it and open it from disk instead.`)
const length = Number(res.headers.get('content-length'))
if (length > maxBytes) throw tooLarge()
// The length a server states can be missing or wrong, so the body is read
// a piece at a time and given up on once it runs past the limit.
const chunks: Uint8Array[] = []
let total = 0
if (res.body) {
const reader = res.body.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
void reader.cancel().catch(() => {})
throw tooLarge()
}
chunks.push(value)
}
}
return new File(chunks as BlobPart[], name, { type: res.headers.get('content-type') ?? '' })
}
3 changes: 2 additions & 1 deletion src/app/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { CreaseSetting } from '../core/geometry/crease'
import type { Rigid } from '../core/deviation/rigid'
import type { SceneManager } from '../viewer/SceneManager'
import { grayscaleOf } from '../core/flat/edgeClient'
import { imagePixelsPerMm } from '../core/flat/image'
import { assertImageDecodable, imagePixelsPerMm } from '../core/flat/image'
import { is3mfFile, isModelFile, isReferenceFile, isTextureFile } from '../core/formats'
import type { MeshUnits } from '../core/meshUnits'
import { useStore } from '../state/store'
Expand Down Expand Up @@ -106,6 +106,7 @@ export async function prepareNominal(client: MeshWorkerClient, scene: SceneManag

export async function prepareImage(file: File): Promise<PreparedImage> {
const bytes = new Uint8Array(await file.arrayBuffer())
assertImageDecodable(file.name, bytes)
const meta = imagePixelsPerMm(bytes)
const bitmap = await createImageBitmap(file, { imageOrientation: 'flipY' })
try {
Expand Down
6 changes: 5 additions & 1 deletion src/app/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,16 @@ export function collectProject(
// a section in use writes its part over a kept one of the same key.
const kept = keptParts()
Object.assign(manifest, kept.parts)
members.push(...kept.members)
const ctx = { sources, scanSaved: manifest.scan !== null, addMember: (name: string, bytes: Uint8Array) => members.push({ name, bytes }) }
for (const section of projectSections()) {
const part = section.collect(ctx)
if (part !== undefined) manifest[section.key] = part
}
// A kept file goes in only under a name nothing in this session writes:
// the project it came from may have used a name the scan, reference or
// image is saved under, and the archive keeps the last file of a name.
const taken = new Set(members.map((m) => m.name))
members.push(...kept.members.filter((m) => !taken.has(m.name)))
return { manifest, members }
}

Expand Down
13 changes: 13 additions & 0 deletions src/app/recoveryStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ interface Checkpoint extends CheckpointInfo { manifest: ProjectManifest }

const lockName = (id: string) => `scanruler-recovery:${id}`

/** How many checkpoints left by closed pages are kept, and for how long.
* Every page writes a slot of its own, each with a copy of its source
* files, so without a limit the browser's storage fills session by session. */
export const RECOVERY_KEEP = 10
export const RECOVERY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000

/** The checkpoints to let go of: those past the age limit, and all but the
* newest few of the rest. */
export function staleCheckpoints(rows: readonly CheckpointInfo[], now: number, keep = RECOVERY_KEEP, maxAgeMs = RECOVERY_MAX_AGE_MS): string[] {
const newest = [...rows].sort((a, b) => b.savedAt - a.savedAt)
return newest.filter((row, i) => i >= keep || now - row.savedAt > maxAgeMs).map((row) => row.id)
}

/** Each page owns a separate slot. A tab can never overwrite another tab's
* checkpoint. Metadata reads deliberately do not load the large source files. */
export class RecoveryStorage {
Expand Down
2 changes: 2 additions & 0 deletions src/app/scanFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { unzipSync } from 'fflate'
import { is3mfFile, isModelFile, isTextureFile } from '../core/formats'
import { assertImageDecodable } from '../core/flat/image'
import type { ObjCompanions, TextureImage } from '../core/parsers/objTexture'

/** What came with a model file, by the file. */
Expand Down Expand Up @@ -113,6 +114,7 @@ export async function scanCompanions(name: string, bytes: Uint8Array, extras: re
async function decodeImage(name: string, bytes: Uint8Array): Promise<TextureImage> {
// Colours as the file has them: no colour management, and no
// premultiplying, which would darken whatever the alpha channel holds.
assertImageDecodable(name, bytes)
const bitmap = await createImageBitmap(new Blob([bytes as BlobPart]), { colorSpaceConversion: 'none', premultiplyAlpha: 'none' })
try {
const { width, height } = bitmap
Expand Down
29 changes: 24 additions & 5 deletions src/app/useRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { projectSections } from './projectSections'
import { watchUnsaved } from './project'
import { useShell } from '../state/shellStore'
import { ProjectClient } from '../core/project/projectClient'
import { RecoveryStorage, type CheckpointInfo } from './recoveryStorage'
import { RecoveryStorage, staleCheckpoints, type CheckpointInfo } from './recoveryStorage'
import { ProjectSnapshotCache, type ProjectSnapshot } from './projectSnapshot'

type Snapshot = ProjectSnapshot
Expand Down Expand Up @@ -46,6 +46,7 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope
const saved = useRef<string | null>(null)
const hadContent = useRef(false)
const refresh = useRef(() => {})
const forgetCheckpoint = useRef(() => {})
const storage = useRef<RecoveryStorage | null>(null)
const [state, setState] = useState({ dirty: false, status: '', available: [] as CheckpointInfo[], working: false })
const update = (patch: Partial<typeof state>) => setState((s) =>
Expand All @@ -62,7 +63,12 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope
const reportError = (error: unknown) => {
if (!disposed) update({ status: `Local recovery unavailable: ${error instanceof Error ? error.message : String(error)}. Use Save Project.` })
}
void db.left().then((available) => { if (!disposed) update({ available }) }, reportError)
// What closed pages left behind, less what is too old or too many to keep.
void db.left().then(async (rows) => {
const stale = new Set(staleCheckpoints(rows, Date.now()))
for (const id of stale) await db.remove(id)
if (!disposed) update({ available: rows.filter((row) => !stale.has(row.id)) })
}).catch(reportError)

// What was last found, for a question asked while the session is busy.
let dirty = false
Expand Down Expand Up @@ -118,6 +124,7 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope
scheduleInspect()
}
refresh.current = scheduleInspect
forgetCheckpoint.current = () => { checkpointKey = '' }
// Zustand stores use immutable updates; watch only project fields. Picking,
// progress and pointer motion must not serialize a large measurement set.
const watch = <T extends object>(store: { subscribe: (fn: (s: T, prev: T) => void) => () => void }, keys: (keyof T)[], notify = changed) =>
Expand Down Expand Up @@ -156,23 +163,35 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope
document.removeEventListener('visibilitychange', visibility)
window.removeEventListener('beforeunload', beforeUnload)
refresh.current = () => {}
forgetCheckpoint.current = () => {}
storage.current = null
release()
db.close()
}
}, [cache])

const markSaved = (snapshot: Snapshot) => { saved.current = cache.key(snapshot); refresh.current() }
const markSaved = (snapshot: Snapshot) => {
saved.current = cache.key(snapshot)
refresh.current()
// Saved work needs no checkpoint: a later change writes a new one, even
// back to a state checkpointed before, since the slot is gone.
forgetCheckpoint.current()
const db = storage.current
void db?.remove(db.id).catch(() => {})
}
const restored = () => { saved.current = null; update({ available: [] }); refresh.current() }
const restore = async (id: string) => {
if (!storage.current || callbacks.current.blocked()) return
const db = storage.current
if (!db || callbacks.current.blocked()) return
update({ working: true })
let worker: ProjectClient | null = null
try {
const { manifest, members } = await storage.current.read(id)
const { manifest, members } = await db.read(id)
worker = new ProjectClient()
const bytes = await worker.pack(manifest, members)
await callbacks.current.open(new File([bytes as BlobPart], 'recovered.scanruler'))
// The work is this page's now, checkpointed in this page's own slot.
await db.remove(id).catch(() => {})
} catch (error) {
update({ status: `Recovery failed: ${error instanceof Error ? error.message : String(error)}` })
} finally { worker?.dispose(); update({ working: false }) }
Expand Down
4 changes: 2 additions & 2 deletions src/commands/core/flat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,12 @@ const detectEdges: Command<{ sensitivity?: number }> = {
'Find the edges in the image again at a sensitivity from 0 (only the strongest) to 1 (every faint one) — what the edge methods of flat.fit and the snapping of every place follow. The chains are numbered afresh. Needs the browser. Returns how many edge chains were found.',
input: obj({ sensitivity: num('0 to 1.', { minimum: 0, maximum: 1 }) }),
label: () => 'detect edges',
run: async ({ sensitivity }) => {
run: async ({ sensitivity }, ctx) => {
const flat = commandHost().flat
if (!flat) throw new CommandError('unavailable', 'Edge detection needs the browser.')
requireSheet()
showWorkspace('flat')
const chains = await flat.detectEdges(sensitivity ?? useFlat.getState().edgeSensitivity)
const chains = await flat.detectEdges(sensitivity ?? useFlat.getState().edgeSensitivity, ctx.signal)
return { chains }
},
}
Expand Down
5 changes: 3 additions & 2 deletions src/commands/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ export interface FlatHost {
openImage(file: File): Promise<void>
/** Take the image off the sheet, as a project without one does. */
closeImage(): void
/** Find the image's edges again at this sensitivity, 0 to 1. */
detectEdges(sensitivity: number): Promise<number>
/** Find the image's edges again at this sensitivity, 0 to 1. The wait for
* them is given up on when `signal` aborts. */
detectEdges(sensitivity: number, signal?: AbortSignal): Promise<number>
/** What a pick on the sheet snaps to — the subject's own edges. */
edgeIndex(): EdgeIndex | null
/** The sheet on the stage, or null with nothing on it. */
Expand Down
44 changes: 44 additions & 0 deletions src/core/flat/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,47 @@ function jpegPixelsPerMm(b: Uint8Array): PixelsPerMm | null {
}
return null
}

/** The most pixels an image may hold to be decoded. A header of a few bytes
* can declare a picture of billions of pixels, which the browser would try
* to decode on the page's own thread; a flatbed scan of a whole A4 sheet at
* 1200 dpi is about 140 Mpx, so this leaves room for any real one. */
export const MAX_IMAGE_PIXELS = 200_000_000

/** Throws when the image's header declares more pixels than can safely be
* decoded. PNG and JPEG are read; another format, or a header that cannot
* be read, is left to the browser's own limits. */
export function assertImageDecodable(name: string, bytes: Uint8Array, maxPixels = MAX_IMAGE_PIXELS): void {
const size = isPng(bytes) ? pngSize(bytes) : isJpeg(bytes) ? jpegSize(bytes) : null
if (size && size.width * size.height > maxPixels) {
throw new Error(`${name} is too large to open: ${size.width} x ${size.height} pixels, more than the ${Math.round(maxPixels / 1e6)} million this app can decode.`)
}
}

/** The size in the IHDR chunk, which a PNG is required to start with. */
function pngSize(b: Uint8Array): { width: number; height: number } | null {
if (b.length < 24 || String.fromCharCode(b[12], b[13], b[14], b[15]) !== 'IHDR') return null
return { width: u32(b, 16), height: u32(b, 20) }
}

/** The size in the first start-of-frame segment: any SOFn marker but DHT
* (C4), JPG (C8) and DAC (CC), which share the range. */
function jpegSize(b: Uint8Array): { width: number; height: number } | null {
let at = 2
while (at + 4 <= b.length) {
if (b[at] !== 0xff) return null
const marker = b[at + 1]
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)) {
at += 2
continue
}
if (marker === 0xda || marker === 0xd9) return null
const length = u16(b, at + 2)
if (length < 2) return null
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return at + 9 <= b.length ? { width: u16(b, at + 7), height: u16(b, at + 5) } : null
}
at += 2 + length
}
return null
}
24 changes: 24 additions & 0 deletions src/state/flatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,3 +1167,27 @@ export const useFlat = create<FlatState>()((set, get) => ({
return { profiles }
}),
}))

/** Settles once the edge detection running from `from` is over: its edges
* found or failed, or its image replaced, which leaves its answer to no one.
* A detection can also never finish, so the wait is given up on, and the
* store let go of, when `signal` aborts. */
export function edgesSettled(from: Pick<FlatState, 'edgeVersion' | 'imageVersion'>, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason)
const done = () => {
stop()
signal?.removeEventListener('abort', abort)
}
const abort = () => {
done()
reject(signal!.reason)
}
const stop = useFlat.subscribe((s) => {
if (s.edgeVersion === from.edgeVersion && s.imageVersion === from.imageVersion) return
done()
resolve()
})
signal?.addEventListener('abort', abort)
})
}
13 changes: 12 additions & 1 deletion src/viewer/FlatScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -970,13 +970,24 @@ export class FlatScene {
this.container.appendChild(div)
this.bandDiv = div
const move = (ev: PointerEvent) => this.layoutBand(ev.clientX, ev.clientY)
const up = (ev: PointerEvent) => {
const stop = () => {
document.removeEventListener('pointermove', move)
document.removeEventListener('pointerup', up)
document.removeEventListener('pointercancel', cancel)
}
const up = (ev: PointerEvent) => {
stop()
this.finishBand(ev.clientX, ev.clientY, ev)
}
// A gesture the browser takes over selects nothing: left listening, the
// band would box-select at whatever release came next, anywhere.
const cancel = () => {
stop()
this.dropBand()
}
document.addEventListener('pointermove', move)
document.addEventListener('pointerup', up)
document.addEventListener('pointercancel', cancel)
this.layoutBand(e.clientX, e.clientY)
}

Expand Down
Loading
Loading