import { createHash } from 'node:crypto' import { readFile, rename, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) export const surfaceDefaultsPath = resolve(here, '..', 'config', 'surface-defaults.json') const finishNames = ['Printed ink', 'Foil', 'Holographic', 'Gold leaf', 'Frosted glass'] const materialNames = ['Paper', 'Linen', 'Metal', 'Wood', 'Leather'] function number(value, min, max, label) { if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) { throw new Error(`${label} must be between ${min} and ${max}`) } } export function validateSurfaceDefaults(value) { if (!value || typeof value !== 'object' || value.schemaVersion !== 1 || !value.finishes || typeof value.finishes !== 'object' || !value.materials || typeof value.materials !== 'object') { throw new Error('Surface defaults use an unsupported format') } for (const name of finishNames) { const item = value.finishes[name] if (!item || typeof item !== 'object') throw new Error(`Missing defaults for finish ${name}`) if (Object.keys(item).some(key => key !== 'finishStrength')) throw new Error(`Unsupported setting for finish ${name}`) number(item.finishStrength, 0, 1, `${name} finish strength`) } for (const name of materialNames) { const item = value.materials[name] if (!item || typeof item !== 'object') throw new Error(`Missing defaults for material ${name}`) if (Object.keys(item).some(key => !['surfaceDetail', 'roughness', 'metalBrushHorizontal'].includes(key))) { throw new Error(`Unsupported setting for material ${name}`) } number(item.surfaceDetail, 0, 1, `${name} surface detail`) number(item.roughness, 0.05, 0.8, `${name} roughness`) if (item.metalBrushHorizontal !== undefined && typeof item.metalBrushHorizontal !== 'boolean') { throw new Error(`${name} metal grain orientation must be a boolean`) } } return value } function revision(bytes) { return createHash('sha256').update(bytes).digest('hex').slice(0, 16) } export async function readSurfaceDefaults(writable = false, path = surfaceDefaultsPath) { const bytes = await readFile(path) return { defaults: validateSurfaceDefaults(JSON.parse(bytes)), revision: revision(bytes), writable } } export async function updateSurfaceDefaults(update, path = surfaceDefaultsPath) { const current = await readSurfaceDefaults(true, path) if (!update || update.revision !== current.revision) { const error = new Error('Surface defaults changed since this page loaded; refresh and try again') error.statusCode = 409 throw error } const next = structuredClone(current.defaults) if (update.group === 'finish' && finishNames.includes(update.name)) { next.finishes[update.name] = update.values } else if (update.group === 'material' && materialNames.includes(update.name)) { next.materials[update.name] = update.values } else { const error = new Error('Unknown surface-default group or selection') error.statusCode = 400 throw error } validateSurfaceDefaults(next) const bytes = `${JSON.stringify(next, null, 2)}\n` const temporary = `${path}.tmp` await writeFile(temporary, bytes) await rename(temporary, path) return { defaults: next, revision: revision(bytes), writable: true } }