Compare commits

6 Commits
cli ... web-app

24 changed files with 12751 additions and 1 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
.venv/
wiki

16
Dockerfile.cli Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
# Install system dependencies for trimesh/shapely if needed
RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY convert_nametags.py .
ENTRYPOINT ["python", "convert_nametags.py"]

23
README.md Normal file
View File

@@ -0,0 +1,23 @@
# svg2nametag-Ren
<p align="center">
<img src="webapp/public/pwa-192x192.png" alt="Logo" width="128">
</p>
## Overview
**svg2nametag-Ren** converts 2D SVG designs into 3D printable nametags. It automatically splits your design into separate files for different colors, making it easy to print multi-color nametags on printers like the Bambu Lab series.
For detailed setup and usage guides, please check out the **[Wiki](https://git.randomhack.com/Retro/svg2NameTag-Ren/wiki)**.
## Physical Dimensions
The program automatically scales all designs to these specific measurements for a consistent, professional look:
- **Total Width**: 87.80 mm
- **Background Thickness**: 3.0 mm
- **Text/Detail Thickness**: 2.0 mm
- **Text/Detail Offset**: 3.0 mm (Text sits at 4mm from the bottom)
- **Total Height**: 5.0 mm (Text sits 3mm high and extends 2mm up)
## Ways to Use
- **Web App**: The easiest way! Just open the web page, drag your files in, and download the results. No installation required.
- **Python Tool**: For advanced users who want to process hundreds of files at once using the command line. Requires Python 3.12 or higher and some basic knowledge of the command line.

24
webapp/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

29
webapp/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Build stage
FROM node:18-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM nginx:stable-alpine
# Copy the build output
COPY --from=build /app/dist /usr/share/nginx/html
# Custom nginx config to handle WASM and SPA routing
RUN printf "server {\n\
listen 80;\n\
location / {\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
try_files \$uri \$uri/ /index.html;\n\
}\n\
location ~* \\.wasm$ {\n\
root /usr/share/nginx/html;\n\
default_type application/wasm;\n\
}\n\
}" > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

16
webapp/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

92
webapp/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.5auj614cprc"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));

File diff suppressed because it is too large Load Diff

29
webapp/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

20
webapp/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
<meta name="theme-color" content="#1a1a1a" />
<meta name="description" content="Convert SVG files to 3D printable nametags offline." />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG to Nametag Converter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

8348
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
webapp/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "webapp",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"manifold-3d": "^3.3.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-dropzone": "^15.0.0",
"react-icons": "^5.5.0",
"svg-path-parser": "^1.1.0",
"three": "^0.182.0",
"vite-plugin-pwa": "^1.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.3.1"
}
}

BIN
webapp/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
webapp/public/manifold.wasm Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

230
webapp/src/App.css Normal file
View File

@@ -0,0 +1,230 @@
:root {
--primary: #00aeef;
--primary-hover: #0090c5;
--bg-dark: #1a1a1a;
--bg-panel: #2a2a2a;
--text-main: #ffffff;
--text-muted: #aaaaaa;
--border: #444;
}
body {
margin: 0;
font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
width: 100%;
max-width: 800px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
main {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
header {
text-align: center;
}
header h1 {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
color: var(--primary);
}
.icon-spin {
animation: spin 4s linear infinite;
}
.dropzone {
background: var(--bg-panel);
border: 2px dashed var(--border);
border-radius: 12px;
padding: 3rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
/* For folder button positioning */
}
.folder-select-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.05);
/* Slight visibility */
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
}
.folder-select-btn:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
transform: scale(1.05);
}
.dropzone:hover,
.dropzone.active {
border-color: var(--primary);
background: rgba(0, 174, 239, 0.05);
}
.dropzone-content h3 {
margin: 1rem 0 0.5rem;
}
.dropzone-content p {
color: var(--text-muted);
margin: 0;
}
.controls {
background: var(--bg-panel);
padding: 1.5rem;
border-radius: 12px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.status {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.engine-status {
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.ready {
color: #4caf50;
display: flex;
align-items: center;
gap: 4px;
}
.loading {
color: #ff9800;
display: flex;
align-items: center;
gap: 4px;
}
.buttons {
display: flex;
gap: 1rem;
}
button {
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.2s;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn-secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--text-main);
color: var(--text-main);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.logs-panel {
background: #000;
border-radius: 8px;
padding: 1rem;
font-family: monospace;
height: 200px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.logs-panel h4 {
margin: 0 0 0.5rem 0;
color: var(--text-muted);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.logs-content {
overflow-y: auto;
flex: 1;
font-size: 0.9rem;
color: #0f0;
}
.log-entry {
margin-bottom: 2px;
}
.placeholder {
color: #444;
font-style: italic;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.spin {
animation: spin 1s linear infinite;
}

172
webapp/src/App.jsx Normal file
View File

@@ -0,0 +1,172 @@
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { saveAs } from 'file-saver'
import JSZip from 'jszip'
import { FaCloudUploadAlt, FaFileExport, FaCheckCircle, FaSpinner, FaTrash } from 'react-icons/fa'
import { MdError, Md3dRotation, MdFolderOpen } from 'react-icons/md'
import './App.css'
import { useManifold, convertFile } from './logic/useNametagConverter'
function App() {
const manifold = useManifold()
const [files, setFiles] = useState([])
const [processing, setProcessing] = useState(false)
const [logs, setLogs] = useState([])
const addLog = (msg) => setLogs(prev => [...prev, msg])
const onDrop = useCallback(acceptedFiles => {
// Filter for SVGs
const svgs = acceptedFiles.filter(f => f.name.toLowerCase().endsWith('.svg') && !f.name.startsWith('._'))
setFiles(prev => [...prev, ...svgs])
addLog(`Added ${svgs.length} SVG files to queue.`)
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop })
const onFolderSelect = (e) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFiles = Array.from(e.target.files);
const svgs = selectedFiles.filter(f => f.name.toLowerCase().endsWith('.svg') && !f.name.startsWith('._') && !f.name.startsWith('.'));
if (svgs.length > 0) {
setFiles(prev => [...prev, ...svgs]);
addLog(`Added ${svgs.length} SVG files from folder.`);
} else {
addLog(`No SVG files found in selected folder.`);
}
}
// Reset
e.target.value = null;
}
const processQueue = async () => {
if (!manifold) {
alert("WASM Engine still loading... please wait a moment.")
return
}
setProcessing(true)
const zip = new JSZip()
let successCount = 0;
try {
for (const file of files) {
try {
addLog(`Processing ${file.name}...`)
const result = await convertFile(file, manifold, addLog)
if (result) {
const folderName = file.name.replace('.svg', '')
const folder = zip.folder(folderName)
// Add STLs to zip
if (result.black) folder.file(`${folderName}_Black.stl`, result.black)
if (result.white) folder.file(`${folderName}_White.stl`, result.white)
if (result.cyan) folder.file(`${folderName}_Cyan.stl`, result.cyan)
addLog(` Success!`)
successCount++;
}
} catch (e) {
console.error(e)
addLog(` Error: ${e.message}`)
}
}
if (successCount > 0) {
addLog("Generating ZIP archive...")
const content = await zip.generateAsync({ type: "blob" })
saveAs(content, "Nametags_Converted.zip")
addLog("Download started.")
} else {
addLog("No files were successfully converted.")
}
} catch (err) {
addLog(`Critical Error: ${err.message}`)
} finally {
setProcessing(false)
}
}
return (
<div className="container">
<header>
<h1><Md3dRotation className="icon-spin" /> SVG to Nametag Converter</h1>
<p>Powered by WebAssembly & Manifold 3D</p>
</header>
<main>
<div {...getRootProps()} className={`dropzone ${isDragActive ? 'active' : ''}`}>
<input {...getInputProps()} />
{/* Folder Selection Button */}
<div
className="folder-select-btn"
onClick={(e) => e.stopPropagation()}
title="Select Folder to Batch Process"
>
<label htmlFor="folder-input" style={{ cursor: 'pointer', display: 'flex' }}>
<MdFolderOpen size={28} />
</label>
<input
id="folder-input"
type="file"
webkitdirectory=""
directory=""
multiple
style={{ display: 'none' }}
onChange={onFolderSelect}
/>
</div>
<div className="dropzone-content">
<FaCloudUploadAlt size={64} color={isDragActive ? "#00aeef" : "#666"} />
{isDragActive ?
<h3>Drop files to add specifically...</h3> :
<>
<h3>Drag & Drop SVGs Here</h3>
<p>Running directly in your browser. Secure & Fast.</p>
</>
}
</div>
</div>
<div className="controls">
<div className="status">
Queue: <strong>{files.length}</strong> files
<span className="engine-status">
{manifold ? <span className="ready"><FaCheckCircle /> Engine Ready</span> : <span className="loading"><FaSpinner className="spin" /> Loading Engine...</span>}
</span>
</div>
<div className="buttons">
<button
className="btn-primary"
onClick={processQueue}
disabled={processing || files.length === 0 || !manifold}
>
{processing ? <><FaSpinner className="spin" /> Converting...</> : <><FaFileExport /> Convert & Download ZIP</>}
</button>
<button
className="btn-secondary"
onClick={() => { setFiles([]); setLogs([]); }}
disabled={processing}
>
<FaTrash /> Clear Queue
</button>
</div>
</div>
<div className="logs-panel">
<h4>Activity Log</h4>
<div className="logs-content">
{logs.length === 0 ? <span className="placeholder">Logs will appear here...</span> : logs.map((l, i) => <div key={i} className="log-entry">{l}</div>)}
</div>
</div>
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

68
webapp/src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,195 @@
import { useEffect, useState } from 'react';
import Module from 'manifold-3d';
import { parseSVG } from 'svg-path-parser';
import * as THREE from 'three';
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter';
// Initialize Manifold WASM once
let wasmModule;
export const useManifold = () => {
const [manifold, setManifold] = useState(null);
useEffect(() => {
const init = async () => {
try {
if (!wasmModule) {
// Fix: Explicitly tell Manifold where to find the WASM file
// distinct from the bundle path
wasmModule = await Module({
locateFile: (path) => {
if (path.endsWith('.wasm')) {
return '/manifold.wasm';
}
return path;
}
});
wasmModule.setup();
}
setManifold(wasmModule);
} catch (e) {
console.error("WASM Init Failed:", e);
// We could set an error state here, but for now logging prevents crash
}
};
init();
}, []);
return manifold;
};
// --- Geometry Helpers ---
const getPathPoints = (d, scale = 1.0) => {
// 1. Parse SVG path data using browser native API
const pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path");
pathEl.setAttribute("d", d);
const len = pathEl.getTotalLength();
if (len < 0.1) return [];
const resolution = 0.5; // mm approx
const numPoints = Math.max(10, Math.ceil(len / resolution));
const loops = [];
let currentLoop = [];
let lastP = null;
for (let i = 0; i <= numPoints; i++) {
const p = pathEl.getPointAtLength((i / numPoints) * len);
if (lastP) {
const dist = Math.hypot(p.x - lastP.x, p.y - lastP.y);
// Detect "Move" command jumps which signify new subpaths
if (dist > 5.0) {
if (currentLoop.length > 2) loops.push(currentLoop);
currentLoop = [];
}
}
currentLoop.push([p.x * scale, -p.y * scale]);
lastP = p;
}
if (currentLoop.length > 2) loops.push(currentLoop);
return loops;
};
export const convertFile = async (file, manifold, addLog) => {
if (!manifold) return null;
// CrossSection is a top-level export, NOT a static property of Manifold class
const { Manifold, CrossSection } = manifold;
const text = await file.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, "image/svg+xml");
const paths = {
black: [],
white: [],
cyan: []
};
// 1. Scale Calculation
let minX = Infinity, maxX = -Infinity;
const backgroundNodes = [];
const allPathNodes = Array.from(doc.querySelectorAll('path'));
allPathNodes.forEach(p => {
const cls = p.getAttribute('class');
if (!cls) backgroundNodes.push(p);
});
backgroundNodes.forEach(p => {
const d = p.getAttribute('d');
if (!d) return;
const pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path");
pathEl.setAttribute("d", d);
const len = pathEl.getTotalLength();
for (let i = 0; i <= 10; i++) {
const pt = pathEl.getPointAtLength(i / 10 * len);
minX = Math.min(minX, pt.x);
maxX = Math.max(maxX, pt.x);
}
});
let scale = 1.0;
const TARGET_WIDTH = 87.80;
if (minX !== Infinity && maxX !== -Infinity) {
const currentWidth = maxX - minX;
if (currentWidth > 0) {
scale = TARGET_WIDTH / currentWidth;
addLog(` Scale factor: ${scale.toFixed(4)}`);
}
}
// 2. Process Paths with Classification
allPathNodes.forEach(p => {
const cls = p.getAttribute('class');
const d = p.getAttribute('d');
if (!d) return;
const loops = getPathPoints(d, scale);
if (cls === 'st2') paths.white.push(loops);
else if (cls === 'st1') paths.cyan.push(loops);
else paths.black.push(loops);
});
// 3. Extrusion Helper using Manifold
const createMeshBlob = (loopsList, height, z) => {
const allContours = loopsList.flat();
if (allContours.length === 0) return null;
// Create CrossSection with Even-Odd rule
// Correct usage: new CrossSection(contours, fillRule)
const cs = new CrossSection(allContours, 'EvenOdd');
// Extrude
const mesh = Manifold.extrude(cs, height, 0, 0, [1, 1]);
// Translate Z
const meshZ = mesh.translate(0, 0, z);
// Convert to Mesh for Three.js export
const meshGL = new THREE.Mesh(getBufferGeometryFromManifold(meshZ, THREE));
const exporter = new STLExporter();
const stlString = exporter.parse(meshGL, { binary: true });
return new Blob([stlString], { type: 'application/octet-stream' });
};
// Constants
const BG_THICK = 3.0;
const TXT_THICK = 2.0;
const TXT_Z = 3.0; // Starts at 3mm, Center at 4mm
return {
black: createMeshBlob(paths.black, BG_THICK, 0),
white: createMeshBlob(paths.white, TXT_THICK, TXT_Z),
cyan: createMeshBlob(paths.cyan, TXT_THICK, TXT_Z)
};
};
function getBufferGeometryFromManifold(manifoldMesh, THREE) {
const mesh = manifoldMesh.getMesh();
const geometry = new THREE.BufferGeometry();
const numVerts = mesh.vertProperties.length / mesh.numProp;
const vertices = new Float32Array(numVerts * 3);
for (let i = 0; i < numVerts; i++) {
vertices[i * 3] = mesh.vertProperties[i * mesh.numProp];
vertices[i * 3 + 1] = mesh.vertProperties[i * mesh.numProp + 1];
vertices[i * 3 + 2] = mesh.vertProperties[i * mesh.numProp + 2];
}
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
const indices = new Uint32Array(mesh.triVerts);
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
geometry.computeVertexNormals();
return geometry;
}

10
webapp/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

44
webapp/vite.config.js Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg', 'manifold.wasm'],
manifest: {
name: 'SVG Nametag Converter',
short_name: 'Nametag 3D',
description: 'Convert SVG files to 3D printable nametags offline.',
theme_color: '#1a1a1a',
background_color: '#1a1a1a',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm}'],
maximumFileSizeToCacheInBytes: 41943040, // 40 MB, just to be safe for WASM
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true
}
})
],
})