@@ -12,6 +12,7 @@ import { exportSTL } from './exporter.js';
1212import { buildAdjacency , bucketFill ,
1313 buildExclusionOverlayGeo , buildFaceWeights } from './exclusion.js' ;
1414import { t , initLang , setLang , getLang , applyTranslations } from './i18n.js' ;
15+ import { zipSync , unzipSync , strToU8 , strFromU8 } from 'fflate' ;
1516
1617// ── State ─────────────────────────────────────────────────────────────────────
1718
@@ -370,6 +371,8 @@ function wireEvents() {
370371 dropZone . addEventListener ( 'drop' , ( e ) => {
371372 e . preventDefault ( ) ;
372373 dropZone . classList . remove ( 'drag-over' ) ;
374+ const bmFile = [ ...e . dataTransfer . files ] . find ( f => / \. b u m p m e s h $ / i. test ( f . name ) ) ;
375+ if ( bmFile ) { importBumpmesh ( bmFile ) ; return ; }
373376 const file = [ ...e . dataTransfer . files ] . find ( f => / \. ( s t l | o b j | 3 m f ) $ / i. test ( f . name ) ) ;
374377 if ( file ) handleModelFile ( file ) ;
375378 } ) ;
@@ -1676,6 +1679,8 @@ function updatePreview() {
16761679 }
16771680
16781681 exportBtn . disabled = false ;
1682+
1683+ _saveToLocalStorage ( ) ;
16791684}
16801685
16811686// ── Displacement preview ──────────────────────────────────────────────────────
@@ -2260,3 +2265,237 @@ function runAsync(fn) {
22602265function yieldFrame ( ) {
22612266 return new Promise ( r => requestAnimationFrame ( r ) ) ;
22622267}
2268+
2269+ // ── Export/Import Settings (.bumpmesh) ───────────────────────────────────────
2270+
2271+ const exportSettingsBtn = document . getElementById ( 'export-settings-btn' ) ;
2272+ const exportDialog = document . getElementById ( 'export-dialog' ) ;
2273+ const exportGoBtn = document . getElementById ( 'export-go-btn' ) ;
2274+ const exportModelChk = document . getElementById ( 'export-model-chk' ) ;
2275+ const exportTextureChk = document . getElementById ( 'export-texture-chk' ) ;
2276+ const exportTextureRow = document . getElementById ( 'export-texture-row' ) ;
2277+ const importInput = document . getElementById ( 'import-settings-input' ) ;
2278+
2279+ // Export dialog toggle
2280+ exportSettingsBtn . addEventListener ( 'click' , ( ) => {
2281+ exportDialog . classList . toggle ( 'hidden' ) ;
2282+ // Show texture checkbox only if custom texture is loaded
2283+ exportTextureRow . classList . toggle ( 'hidden' , ! activeMapEntry || ! activeMapEntry . isCustom ) ;
2284+ // Enable model checkbox only if a model is loaded
2285+ exportModelChk . disabled = ! currentGeometry ;
2286+ } ) ;
2287+
2288+ // Close dialog when clicking outside
2289+ document . addEventListener ( 'click' , ( e ) => {
2290+ if ( ! exportDialog . contains ( e . target ) && e . target !== exportSettingsBtn && ! exportSettingsBtn . contains ( e . target ) ) {
2291+ exportDialog . classList . add ( 'hidden' ) ;
2292+ }
2293+ } ) ;
2294+
2295+ // Export: build .bumpmesh ZIP and download
2296+ exportGoBtn . addEventListener ( 'click' , async ( ) => {
2297+ exportDialog . classList . add ( 'hidden' ) ;
2298+
2299+ const includeModel = exportModelChk . checked && currentGeometry ;
2300+ const includeTexture = exportTextureChk . checked && activeMapEntry && activeMapEntry . isCustom ;
2301+
2302+ const data = {
2303+ version : 1 ,
2304+ texture : activeMapEntry ? activeMapEntry . name : null ,
2305+ settings : { ...settings } ,
2306+ } ;
2307+
2308+ const zipFiles = { } ;
2309+
2310+ // Settings JSON (always included)
2311+ zipFiles [ 'settings.json' ] = strToU8 ( JSON . stringify ( data , null , 2 ) ) ;
2312+
2313+ // Model as binary STL
2314+ if ( includeModel ) {
2315+ const posArr = currentGeometry . attributes . position . array ;
2316+ const norArr = currentGeometry . attributes . normal ? currentGeometry . attributes . normal . array : null ;
2317+ const triCount = ( posArr . length / 9 ) | 0 ;
2318+ const buf = new ArrayBuffer ( 84 + 50 * triCount ) ;
2319+ const bytes = new Uint8Array ( buf ) ;
2320+ const view = new DataView ( buf ) ;
2321+ view . setUint32 ( 80 , triCount , true ) ;
2322+ if ( norArr ) {
2323+ const posSrc = new Uint8Array ( posArr . buffer , posArr . byteOffset , posArr . byteLength ) ;
2324+ const norSrc = new Uint8Array ( norArr . buffer , norArr . byteOffset , norArr . byteLength ) ;
2325+ for ( let i = 0 ; i < triCount ; i ++ ) {
2326+ const dst = 84 + i * 50 , srcOff = i * 36 ;
2327+ bytes . set ( norSrc . subarray ( srcOff , srcOff + 12 ) , dst ) ;
2328+ bytes . set ( posSrc . subarray ( srcOff , srcOff + 36 ) , dst + 12 ) ;
2329+ }
2330+ }
2331+ zipFiles [ 'model.stl' ] = new Uint8Array ( buf ) ;
2332+ }
2333+
2334+ // Custom texture as PNG
2335+ if ( includeTexture && activeMapEntry . fullCanvas ) {
2336+ const blob = await new Promise ( r => activeMapEntry . fullCanvas . toBlob ( r , 'image/png' ) ) ;
2337+ const arrBuf = await blob . arrayBuffer ( ) ;
2338+ zipFiles [ 'texture.png' ] = new Uint8Array ( arrBuf ) ;
2339+ }
2340+
2341+ // Create ZIP and download
2342+ const zipped = zipSync ( zipFiles ) ;
2343+ const blob = new Blob ( [ zipped ] , { type : 'application/octet-stream' } ) ;
2344+ const url = URL . createObjectURL ( blob ) ;
2345+ const a = document . createElement ( 'a' ) ;
2346+ a . href = url ;
2347+ a . download = ( currentStlName || 'bumpmesh' ) + '.bumpmesh' ;
2348+ a . style . display = 'none' ;
2349+ document . body . appendChild ( a ) ;
2350+ a . click ( ) ;
2351+ document . body . removeChild ( a ) ;
2352+ setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 10000 ) ;
2353+ } ) ;
2354+
2355+ // Import: file input handler
2356+ importInput . addEventListener ( 'change' , async ( e ) => {
2357+ const file = e . target . files [ 0 ] ;
2358+ if ( ! file ) return ;
2359+ importInput . value = '' ; // reset for re-import
2360+ try {
2361+ await importBumpmesh ( file ) ;
2362+ } catch ( err ) {
2363+ alert ( t ( 'alerts.importFailed' , { msg : err . message } ) ) ;
2364+ }
2365+ } ) ;
2366+
2367+ async function importBumpmesh ( file ) {
2368+ const buf = await file . arrayBuffer ( ) ;
2369+ const unzipped = unzipSync ( new Uint8Array ( buf ) ) ;
2370+
2371+ // 1. Settings
2372+ if ( unzipped [ 'settings.json' ] ) {
2373+ const json = JSON . parse ( strFromU8 ( unzipped [ 'settings.json' ] ) ) ;
2374+ if ( json . settings ) {
2375+ // Apply settings to sliders/controls
2376+ for ( const [ key , value ] of Object . entries ( json . settings ) ) {
2377+ if ( key in settings ) settings [ key ] = value ;
2378+ }
2379+ // Update all UI elements to reflect new settings
2380+ _syncUIFromSettings ( ) ;
2381+ }
2382+ // Select texture preset if it matches
2383+ if ( json . texture && ! unzipped [ 'texture.png' ] ) {
2384+ _selectPresetByName ( json . texture ) ;
2385+ }
2386+ }
2387+
2388+ // 2. Model
2389+ if ( unzipped [ 'model.stl' ] ) {
2390+ const stlBlob = new Blob ( [ unzipped [ 'model.stl' ] ] , { type : 'application/octet-stream' } ) ;
2391+ const stlFile = new File ( [ stlBlob ] , 'model.stl' ) ;
2392+ await handleModelFile ( stlFile ) ;
2393+ }
2394+
2395+ // 3. Custom texture
2396+ if ( unzipped [ 'texture.png' ] ) {
2397+ const texBlob = new Blob ( [ unzipped [ 'texture.png' ] ] , { type : 'image/png' } ) ;
2398+ const texFile = new File ( [ texBlob ] , 'custom-texture.png' ) ;
2399+ activeMapEntry = await loadCustomTexture ( texFile ) ;
2400+ activeMapEntry . isCustom = true ;
2401+ // Update preview
2402+ updatePreview ( ) ;
2403+ }
2404+ }
2405+
2406+ // ── Helper: Sync UI from Settings ────────────────────────────────────────────
2407+
2408+ function _syncUIFromSettings ( ) {
2409+ // Mapping mode
2410+ if ( mappingSelect ) mappingSelect . value = settings . mappingMode ;
2411+ capAngleRow . style . display = settings . mappingMode === 3 ? '' : 'none' ;
2412+
2413+ // Scale sliders (logarithmic — convert value to slider position)
2414+ scaleUSlider . value = scaleToPos ( settings . scaleU ) ;
2415+ scaleUVal . value = settings . scaleU ;
2416+ scaleVSlider . value = scaleToPos ( settings . scaleV ) ;
2417+ scaleVVal . value = settings . scaleV ;
2418+
2419+ // Linear sliders + their value displays
2420+ const sliderMap = {
2421+ 'amplitude' : 'amplitude' ,
2422+ 'offset-u' : 'offsetU' ,
2423+ 'offset-v' : 'offsetV' ,
2424+ 'rotation' : 'rotation' ,
2425+ 'refine-length' : 'refineLength' ,
2426+ 'bottom-angle-limit' : 'bottomAngleLimit' ,
2427+ 'top-angle-limit' : 'topAngleLimit' ,
2428+ 'seam-blend' : 'mappingBlend' ,
2429+ 'seam-band-width' : 'seamBandWidth' ,
2430+ 'texture-smoothing' : 'textureSmoothing' ,
2431+ 'cap-angle' : 'capAngle' ,
2432+ } ;
2433+ for ( const [ sliderId , settingKey ] of Object . entries ( sliderMap ) ) {
2434+ const slider = document . getElementById ( sliderId ) ;
2435+ if ( slider ) {
2436+ slider . value = settings [ settingKey ] ;
2437+ slider . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
2438+ }
2439+ }
2440+
2441+ // Checkboxes
2442+ if ( symmetricDispToggle ) symmetricDispToggle . checked = settings . symmetricDisplacement ;
2443+
2444+ // Lock scale button
2445+ if ( lockScaleBtn ) {
2446+ lockScaleBtn . classList . toggle ( 'active' , settings . lockScale ) ;
2447+ lockScaleBtn . setAttribute ( 'aria-pressed' , String ( settings . lockScale ) ) ;
2448+ }
2449+
2450+ // Max triangles slider
2451+ if ( maxTriSlider ) {
2452+ maxTriSlider . value = settings . maxTriangles ;
2453+ maxTriSlider . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
2454+ }
2455+ }
2456+
2457+ // ── Helper: Select Preset by Name ────────────────────────────────────────────
2458+
2459+ function _selectPresetByName ( name ) {
2460+ const swatches = document . querySelectorAll ( '.preset-swatch' ) ;
2461+ for ( const swatch of swatches ) {
2462+ if ( swatch . title === name || swatch . querySelector ( '.preset-label' ) ?. textContent === name ) {
2463+ swatch . click ( ) ;
2464+ return ;
2465+ }
2466+ }
2467+ }
2468+
2469+ // ── Auto-Save (localStorage) ─────────────────────────────────────────────────
2470+
2471+ const STORAGE_KEY = 'bumpmesh-settings' ;
2472+
2473+ function _saveToLocalStorage ( ) {
2474+ const data = {
2475+ version : 1 ,
2476+ texture : activeMapEntry ? activeMapEntry . name : null ,
2477+ settings : { ...settings } ,
2478+ } ;
2479+ try { localStorage . setItem ( STORAGE_KEY , JSON . stringify ( data ) ) ; } catch ( e ) { /* quota exceeded, ignore */ }
2480+ }
2481+
2482+ function _loadFromLocalStorage ( ) {
2483+ try {
2484+ const raw = localStorage . getItem ( STORAGE_KEY ) ;
2485+ if ( ! raw ) return ;
2486+ const data = JSON . parse ( raw ) ;
2487+ if ( data . settings ) {
2488+ for ( const [ key , value ] of Object . entries ( data . settings ) ) {
2489+ if ( key in settings ) settings [ key ] = value ;
2490+ }
2491+ // Defer UI sync until DOM is ready
2492+ requestAnimationFrame ( ( ) => {
2493+ _syncUIFromSettings ( ) ;
2494+ if ( data . texture ) _selectPresetByName ( data . texture ) ;
2495+ } ) ;
2496+ }
2497+ } catch ( e ) { /* corrupted data, ignore */ }
2498+ }
2499+
2500+ // Restore settings from localStorage on startup
2501+ _loadFromLocalStorage ( ) ;
0 commit comments