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
10 changes: 10 additions & 0 deletions PWA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# PWA updates

When you change files that the service worker caches (`index.html`, `style.css`, `js/*`, icons, manifest, etc.):

1. Open `sw.js`
2. Bump `CACHE_VERSION` (e.g. `bumpmesh-v1` → `bumpmesh-v2`)
3. If you added or renamed cached files, update the `PRECACHE` list in `sw.js`
4. Commit, push, and deploy

Users pick up the update on their next visit. The new service worker installs, replaces the old cache, and serves fresh files.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ Open http://localhost:8000 in your browser and you're ready to go.

> **Tip:** Any static server will work — the app has no server-side dependencies.

> **PWA changes:** Read [PWA.md](PWA.md) before you commit and push.

## Dependencies

Loaded via CDN ([jsDelivr](https://www.jsdelivr.com/)) — no build step or npm install needed:
Expand Down
Binary file added icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 31 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,36 @@
<meta name="twitter:image" content="https://bumpmesh.com/assets/preview.jpg" />
<meta name="twitter:image:alt" content="BumpMesh interface for previewing and exporting textured 3D models" />
<link rel="icon" type="image/png" href="logo.png" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#111114" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="BumpMesh" />
<link rel="apple-touch-icon" href="icons/icon-192.png" />
<link rel="stylesheet" href="style.css" />
<script>
// Apply saved theme before first paint to avoid flash
// Apply saved theme before first paint to avoid flash.
// PWA chrome (theme-color, iOS status bar) follows the *site* theme, not OS.
(function() {
const t = localStorage.getItem('stlt-theme');
// If user never picked a theme, respect their OS preference
// If user never picked a theme, respect their OS preference once for initial pick
const prefersDark = t ? t === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light');
const isLight = !prefersDark;
if (isLight) {
document.documentElement.setAttribute('data-theme', 'light');
}

var themeColor = document.querySelector('meta[name="theme-color"]');
var statusBar = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
if (themeColor) {
themeColor.setAttribute('content', isLight ? '#f0f0f5' : '#111114');
}
// default = light bar / dark text; black-translucent = dark translucent bar
if (statusBar) {
statusBar.setAttribute('content', isLight ? 'default' : 'black-translucent');
}
})();
</script>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
Expand Down Expand Up @@ -732,5 +753,12 @@ <h3 class="welcome-heading">New in this release <span class="welcome-date">&mdas
</div>

<script type="module" src="js/main.js"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js').catch(() => {});
});
}
</script>
</body>
</html>
22 changes: 20 additions & 2 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -900,8 +900,12 @@ let PRESETS = [];

initViewer(canvas);

// Apply saved theme to 3D viewport on startup
setViewerTheme(document.documentElement.getAttribute('data-theme') === 'light');
// Apply saved theme to 3D viewport + PWA chrome on startup
{
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
setViewerTheme(isLight);
applyPwaThemeChrome(isLight);
}

// Populate the language selector
function populateLanguageSelector() {
Expand Down Expand Up @@ -980,12 +984,26 @@ populateLanguageSelector();
}
})();

// Keep PWA browser chrome in sync with the *site* theme (not OS prefers-color-scheme).
function applyPwaThemeChrome(isLight) {
const themeColor = document.querySelector('meta[name="theme-color"]');
const statusBar = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
if (themeColor) {
themeColor.setAttribute('content', isLight ? '#f0f0f5' : '#111114');
}
// Apple: "default" = light status bar; "black-translucent" for dark UI
if (statusBar) {
statusBar.setAttribute('content', isLight ? 'default' : 'black-translucent');
}
}

// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', () => {
const isLight = document.documentElement.getAttribute('data-theme') !== 'light';
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
localStorage.setItem('stlt-theme', isLight ? 'light' : 'dark');
setViewerTheme(isLight);
applyPwaThemeChrome(isLight);
});

wireEvents();
Expand Down
32 changes: 32 additions & 0 deletions manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "BumpMesh by CNC Kitchen",
"short_name": "BumpMesh",
"description": "Add displacement textures to STL, OBJ, and 3MF models directly in your browser.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "any",
"background_color": "#111114",
"theme_color": "#111114",
"categories": ["utilities", "productivity"],
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
114 changes: 114 additions & 0 deletions sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
const CACHE_VERSION = 'bumpmesh-v2';

const PRECACHE = [
'./',
'./index.html',
'./style.css',
'./logo.png',
'./manifest.webmanifest',
'./icons/icon-192.png',
'./icons/icon-512.png',
'./js/main.js',
'./js/viewer.js',
'./js/stlLoader.js',
'./js/smartResolution.js',
'./js/presetTextures.js',
'./js/previewMaterial.js',
'./js/subdivision.js',
'./js/regularize.js',
'./js/exportPipeline.js',
'./js/exporter.js',
'./js/exclusion.js',
'./js/meshValidation.js',
'./js/i18n.js',
'./js/meshIndex.js',
'./js/mapping.js',
'./js/displacement.js',
'./js/decimation.js',
'./js/meshRepair.js',
'./js/textureAnalysis.js',
'./js/threeCompat.js',
'./js/exportWorker.js',
'./js/i18n/en.js',
'./js/i18n/de.js',
'./js/i18n/es.js',
'./js/i18n/fr.js',
'./js/i18n/it.js',
'./js/i18n/ja.js',
'./js/i18n/ko.js',
'./js/i18n/pt.js',
'./js/i18n/tr.js',
'./js/i18n/uk.js',
];

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_VERSION)
.then((cache) => cache.addAll(PRECACHE))
.then(() => self.skipWaiting()),
);
});

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key)),
))
.then(() => self.clients.claim()),
);
});

self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;

const url = new URL(request.url);

if (url.origin !== self.location.origin) {
if (url.hostname === 'cdn.jsdelivr.net') {
event.respondWith(networkFirst(request));
}
return;
}

if (request.mode === 'navigate') {
event.respondWith(networkFirst(request, './index.html'));
return;
}

event.respondWith(staleWhileRevalidate(request));
});

async function networkFirst(request, fallbackUrl) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_VERSION);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
if (fallbackUrl) {
const fallback = await caches.match(fallbackUrl);
if (fallback) return fallback;
}
throw new Error('Network unavailable');
}
}

async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_VERSION);
const cached = await cache.match(request);

const networkPromise = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => null);

return cached || networkPromise || fetch(request);
}