diff --git a/Dockerfile b/Dockerfile index 544a506b..ee098e26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,44 +91,7 @@ RUN ARCH=$(uname -m) && \ mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \ chmod +x /usr/local/bin/typst -# BOSL2 builder stage - clone and minimize the library -FROM node:18-slim AS bosl2-builder -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir -p /bosl2 && \ - cd /bosl2 && \ - git clone --depth 1 https://github.com/BelfrySCAD/BOSL2.git . && \ - # Remove unnecessary files to minimize size - rm -rf .git .github tests tutorials examples images *.md CONTRIBUTING* LICENSE* && \ - # Keep only .scad files and essential directories - find . -type f ! -name "*.scad" -delete && \ - find . -type d -empty -delete - -# OpenSCAD builder stage - download and prepare newer OpenSCAD binary -FROM node:18-slim AS openscad-builder -RUN apt-get update && apt-get install -y --no-install-recommends \ - wget \ - ca-certificates \ - file \ - libfuse2 \ - && rm -rf /var/lib/apt/lists/* - -# Download latest OpenSCAD AppImage and extract it -# Using 2024.11 which has CGAL fixes for intersection operations -# APPIMAGE_EXTRACT_AND_RUN=1 allows extraction without FUSE mounting -RUN export APPIMAGE_EXTRACT_AND_RUN=1 && \ - wget -q https://files.openscad.org/OpenSCAD-2024.11.18-x86_64.AppImage -O /tmp/openscad.AppImage && \ - chmod +x /tmp/openscad.AppImage && \ - cd /tmp && \ - ./openscad.AppImage --appimage-extract && \ - mv squashfs-root/usr/bin/openscad /usr/local/bin/openscad && \ - mv squashfs-root/usr/lib /usr/local/openscad-lib && \ - chmod +x /usr/local/bin/openscad - -# Production image - Using Debian base for OpenSCAD availability +# Production image FROM node:18-slim AS runner WORKDIR /app @@ -138,36 +101,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3-pip \ qpdf \ ca-certificates \ - libgomp1 \ - libglu1-mesa \ - libglew2.2 \ - libfreetype6 \ - libfontconfig1 \ - libharfbuzz0b \ - libxml2 \ - libzip4 \ - libdouble-conversion3 \ - libqt5core5a \ - libqt5gui5 \ - libqt5widgets5 \ - libqt5concurrent5 \ - libqt5multimedia5 \ - libqt5network5 \ - libqt5dbus5 \ && rm -rf /var/lib/apt/lists/* # Copy typst binary from typst-builder stage COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst -# Copy newer OpenSCAD from openscad-builder stage -COPY --from=openscad-builder /usr/local/bin/openscad /usr/local/bin/openscad -COPY --from=openscad-builder /usr/local/openscad-lib /usr/local/openscad-lib -ENV LD_LIBRARY_PATH=/usr/local/openscad-lib:$LD_LIBRARY_PATH - -# Copy minimized BOSL2 library from bosl2-builder stage -RUN mkdir -p /usr/share/openscad/libraries -COPY --from=bosl2-builder /bosl2 /usr/share/openscad/libraries/BOSL2 - # Create non-root user RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs @@ -212,9 +150,6 @@ WORKDIR /app/apps/web # Create data directory for SQLite database RUN mkdir -p data && chown nextjs:nodejs data -# Create tmp directory for 3D job outputs -RUN mkdir -p tmp/3d-jobs && chown nextjs:nodejs tmp - USER nextjs EXPOSE 3000 ENV PORT 3000 diff --git a/apps/web/src/app/api/abacus/download/[jobId]/route.ts b/apps/web/src/app/api/abacus/download/[jobId]/route.ts deleted file mode 100644 index 5f7ae5ed..00000000 --- a/apps/web/src/app/api/abacus/download/[jobId]/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { JobManager } from '@/lib/3d-printing/jobManager' -import { NextResponse } from 'next/server' - -export async function GET(request: Request, { params }: { params: Promise<{ jobId: string }> }) { - try { - const { jobId } = await params - const job = JobManager.getJob(jobId) - - if (!job) { - return NextResponse.json({ error: 'Job not found' }, { status: 404 }) - } - - if (job.status !== 'completed') { - return NextResponse.json( - { error: `Job is ${job.status}, not ready for download` }, - { status: 400 } - ) - } - - const fileBuffer = await JobManager.getJobOutput(jobId) - - // Determine content type and filename - const contentTypes = { - stl: 'model/stl', - '3mf': 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml', - scad: 'text/plain', - } - - const contentType = contentTypes[job.params.format] - const filename = `abacus.${job.params.format}` - - // Convert Buffer to Uint8Array for NextResponse - const uint8Array = new Uint8Array(fileBuffer) - - return new NextResponse(uint8Array, { - headers: { - 'Content-Type': contentType, - 'Content-Disposition': `attachment; filename="${filename}"`, - 'Content-Length': fileBuffer.length.toString(), - }, - }) - } catch (error) { - console.error('Error downloading job:', error) - return NextResponse.json({ error: 'Failed to download file' }, { status: 500 }) - } -} diff --git a/apps/web/src/app/api/abacus/generate/route.ts b/apps/web/src/app/api/abacus/generate/route.ts deleted file mode 100644 index 4c57e95f..00000000 --- a/apps/web/src/app/api/abacus/generate/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { JobManager } from '@/lib/3d-printing/jobManager' -import type { AbacusParams } from '@/lib/3d-printing/jobManager' -import { NextResponse } from 'next/server' - -export async function POST(request: Request) { - try { - const body = await request.json() - - // Validate parameters - const columns = Number.parseInt(body.columns, 10) - const scaleFactor = Number.parseFloat(body.scaleFactor) - const widthMm = body.widthMm ? Number.parseFloat(body.widthMm) : undefined - const format = body.format - - // Validation - if (Number.isNaN(columns) || columns < 1 || columns > 13) { - return NextResponse.json({ error: 'columns must be between 1 and 13' }, { status: 400 }) - } - - if (Number.isNaN(scaleFactor) || scaleFactor < 0.5 || scaleFactor > 3) { - return NextResponse.json({ error: 'scaleFactor must be between 0.5 and 3' }, { status: 400 }) - } - - if (widthMm !== undefined && (Number.isNaN(widthMm) || widthMm < 50 || widthMm > 500)) { - return NextResponse.json({ error: 'widthMm must be between 50 and 500' }, { status: 400 }) - } - - if (!['stl', '3mf', 'scad'].includes(format)) { - return NextResponse.json({ error: 'format must be stl, 3mf, or scad' }, { status: 400 }) - } - - const params: AbacusParams = { - columns, - scaleFactor, - widthMm, - format, - // 3MF colors (optional) - frameColor: body.frameColor, - heavenBeadColor: body.heavenBeadColor, - earthBeadColor: body.earthBeadColor, - decorationColor: body.decorationColor, - } - - const jobId = await JobManager.createJob(params) - - return NextResponse.json( - { - jobId, - message: 'Job created successfully', - }, - { status: 202 } - ) - } catch (error) { - console.error('Error creating job:', error) - return NextResponse.json({ error: 'Failed to create job' }, { status: 500 }) - } -} diff --git a/apps/web/src/app/api/abacus/preview/route.ts b/apps/web/src/app/api/abacus/preview/route.ts deleted file mode 100644 index 8ef771ab..00000000 --- a/apps/web/src/app/api/abacus/preview/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { JobManager } from '@/lib/3d-printing/jobManager' -import type { AbacusParams } from '@/lib/3d-printing/jobManager' -import { NextResponse } from 'next/server' - -// Allow up to 90 seconds for OpenSCAD rendering -export const maxDuration = 90 - -// Cache for preview STLs to avoid regenerating on every request -const previewCache = new Map() -const CACHE_TTL = 300000 // 5 minutes - -function getCacheKey(params: AbacusParams): string { - return `${params.columns}-${params.scaleFactor}` -} - -export async function POST(request: Request) { - try { - const body = await request.json() - - // Validate parameters - const columns = Number.parseInt(body.columns, 10) - const scaleFactor = Number.parseFloat(body.scaleFactor) - - // Validation - if (Number.isNaN(columns) || columns < 1 || columns > 13) { - return NextResponse.json({ error: 'columns must be between 1 and 13' }, { status: 400 }) - } - - if (Number.isNaN(scaleFactor) || scaleFactor < 0.5 || scaleFactor > 3) { - return NextResponse.json({ error: 'scaleFactor must be between 0.5 and 3' }, { status: 400 }) - } - - const params: AbacusParams = { - columns, - scaleFactor, - format: 'stl', // Always STL for preview - } - - // Check cache first - const cacheKey = getCacheKey(params) - const cached = previewCache.get(cacheKey) - const now = Date.now() - - if (cached && now - cached.timestamp < CACHE_TTL) { - // Return cached preview - const uint8Array = new Uint8Array(cached.buffer) - return new NextResponse(uint8Array, { - headers: { - 'Content-Type': 'model/stl', - 'Cache-Control': 'public, max-age=300', // Cache for 5 minutes - }, - }) - } - - // Generate new preview - const jobId = await JobManager.createJob(params) - - // Wait for job to complete (with timeout) - const startTime = Date.now() - const timeout = 90000 // 90 seconds max wait (OpenSCAD can take 40-60s) - - while (Date.now() - startTime < timeout) { - const job = JobManager.getJob(jobId) - if (!job) { - return NextResponse.json({ error: 'Job not found' }, { status: 404 }) - } - - if (job.status === 'completed') { - const buffer = await JobManager.getJobOutput(jobId) - - // Cache the result - previewCache.set(cacheKey, { buffer, timestamp: now }) - - // Clean up old cache entries - for (const [key, value] of previewCache.entries()) { - if (now - value.timestamp > CACHE_TTL) { - previewCache.delete(key) - } - } - - // Clean up the job - await JobManager.cleanupJob(jobId) - - const uint8Array = new Uint8Array(buffer) - return new NextResponse(uint8Array, { - headers: { - 'Content-Type': 'model/stl', - 'Cache-Control': 'public, max-age=300', - }, - }) - } - - if (job.status === 'failed') { - return NextResponse.json( - { error: job.error || 'Preview generation failed' }, - { status: 500 } - ) - } - - // Wait 500ms before checking again - await new Promise((resolve) => setTimeout(resolve, 500)) - } - - return NextResponse.json({ error: 'Preview generation timeout' }, { status: 408 }) - } catch (error) { - console.error('Error generating preview:', error) - return NextResponse.json({ error: 'Failed to generate preview' }, { status: 500 }) - } -} diff --git a/apps/web/src/app/api/abacus/status/[jobId]/route.ts b/apps/web/src/app/api/abacus/status/[jobId]/route.ts deleted file mode 100644 index e0ea9bf7..00000000 --- a/apps/web/src/app/api/abacus/status/[jobId]/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { JobManager } from '@/lib/3d-printing/jobManager' -import { NextResponse } from 'next/server' - -export async function GET(request: Request, { params }: { params: Promise<{ jobId: string }> }) { - try { - const { jobId } = await params - const job = JobManager.getJob(jobId) - - if (!job) { - return NextResponse.json({ error: 'Job not found' }, { status: 404 }) - } - - return NextResponse.json({ - id: job.id, - status: job.status, - progress: job.progress, - error: job.error, - createdAt: job.createdAt, - completedAt: job.completedAt, - }) - } catch (error) { - console.error('Error fetching job status:', error) - return NextResponse.json({ error: 'Failed to fetch job status' }, { status: 500 }) - } -} diff --git a/apps/web/src/app/create/abacus/page.tsx b/apps/web/src/app/create/abacus/page.tsx deleted file mode 100644 index 7ba141bf..00000000 --- a/apps/web/src/app/create/abacus/page.tsx +++ /dev/null @@ -1,570 +0,0 @@ -'use client' - -import { useTranslations } from 'next-intl' -import { JobMonitor } from '@/components/3d-print/JobMonitor' -import { PageWithNav } from '@/components/PageWithNav' -import { STLPreview } from '@/components/3d-print/STLPreview' -import { useState } from 'react' -import { css } from '../../../../styled-system/css' - -export default function ThreeDPrintPage() { - const t = useTranslations('create.abacus') - // New unified parameter system - const [columns, setColumns] = useState(4) - const [scaleFactor, setScaleFactor] = useState(1.5) - const [widthMm, setWidthMm] = useState(undefined) - const [format, setFormat] = useState<'stl' | '3mf' | 'scad'>('stl') - - // 3MF color options - const [frameColor, setFrameColor] = useState('#8b7355') - const [heavenBeadColor, setHeavenBeadColor] = useState('#e8d5c4') - const [earthBeadColor, setEarthBeadColor] = useState('#6b5444') - const [decorationColor, setDecorationColor] = useState('#d4af37') - - const [jobId, setJobId] = useState(null) - const [isGenerating, setIsGenerating] = useState(false) - const [isComplete, setIsComplete] = useState(false) - const [error, setError] = useState(null) - - const handleGenerate = async () => { - setIsGenerating(true) - setError(null) - setIsComplete(false) - - try { - const response = await fetch('/api/abacus/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - columns, - scaleFactor, - widthMm, - format, - // Include 3MF colors if format is 3mf - ...(format === '3mf' && { - frameColor, - heavenBeadColor, - earthBeadColor, - decorationColor, - }), - }), - }) - - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to generate file') - } - - const data = await response.json() - setJobId(data.jobId) - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error') - setIsGenerating(false) - } - } - - const handleJobComplete = () => { - setIsComplete(true) - setIsGenerating(false) - } - - const handleDownload = () => { - if (!jobId) return - window.location.href = `/api/abacus/download/${jobId}` - } - - return ( - -
-
-

- {t('pageTitle')} -

- -

{t('pageSubtitle')}

- -
- {/* Left column: Controls */} -
-
-

- {t('customizationTitle')} -

- - {/* Number of Columns */} -
- - setColumns(Number.parseInt(e.target.value, 10))} - className={css({ width: '100%' })} - /> -
- {t('columns.help')} -
-
- - {/* Scale Factor */} -
- - setScaleFactor(Number.parseFloat(e.target.value))} - className={css({ width: '100%' })} - /> -
- {t('scaleFactor.help')} -
-
- - {/* Optional Width in mm */} -
- - { - const value = e.target.value - setWidthMm(value ? Number.parseFloat(value) : undefined) - }} - placeholder={t('widthMm.placeholder')} - className={css({ - width: '100%', - px: 3, - py: 2, - border: '1px solid', - borderColor: 'gray.300', - borderRadius: '4px', - _focus: { - outline: 'none', - borderColor: 'blue.500', - }, - })} - /> -
- {t('widthMm.help')} -
-
- - {/* Format Selection */} -
- -
- - - -
-
- - {/* 3MF Color Options */} - {format === '3mf' && ( -
-

- {t('colors.title')} -

- - {/* Frame Color */} -
- -
- setFrameColor(e.target.value)} - className={css({ width: '60px', height: '40px', cursor: 'pointer' })} - /> - setFrameColor(e.target.value)} - placeholder="#8b7355" - className={css({ - flex: 1, - px: 3, - py: 2, - border: '1px solid', - borderColor: 'gray.300', - borderRadius: '4px', - fontFamily: 'monospace', - })} - /> -
-
- - {/* Heaven Bead Color */} -
- -
- setHeavenBeadColor(e.target.value)} - className={css({ width: '60px', height: '40px', cursor: 'pointer' })} - /> - setHeavenBeadColor(e.target.value)} - placeholder="#e8d5c4" - className={css({ - flex: 1, - px: 3, - py: 2, - border: '1px solid', - borderColor: 'gray.300', - borderRadius: '4px', - fontFamily: 'monospace', - })} - /> -
-
- - {/* Earth Bead Color */} -
- -
- setEarthBeadColor(e.target.value)} - className={css({ width: '60px', height: '40px', cursor: 'pointer' })} - /> - setEarthBeadColor(e.target.value)} - placeholder="#6b5444" - className={css({ - flex: 1, - px: 3, - py: 2, - border: '1px solid', - borderColor: 'gray.300', - borderRadius: '4px', - fontFamily: 'monospace', - })} - /> -
-
- - {/* Decoration Color */} -
- -
- setDecorationColor(e.target.value)} - className={css({ width: '60px', height: '40px', cursor: 'pointer' })} - /> - setDecorationColor(e.target.value)} - placeholder="#d4af37" - className={css({ - flex: 1, - px: 3, - py: 2, - border: '1px solid', - borderColor: 'gray.300', - borderRadius: '4px', - fontFamily: 'monospace', - })} - /> -
-
-
- )} - - {/* Generate Button */} - - - {/* Job Status */} - {jobId && !isComplete && ( -
- -
- )} - - {/* Download Button */} - {isComplete && ( - - )} - - {/* Error Message */} - {error && ( -
- {error} -
- )} -
-
- - {/* Right column: Preview */} -
-
-

- {t('preview.title')} -

- -
-

{t('preview.liveDescription')}

-

{t('preview.note')}

-

{t('preview.instructions')}

-
-
-
-
-
-
-
- ) -} diff --git a/apps/web/src/app/create/page.tsx b/apps/web/src/app/create/page.tsx index d48feb01..60cd94a1 100644 --- a/apps/web/src/app/create/page.tsx +++ b/apps/web/src/app/create/page.tsx @@ -309,202 +309,6 @@ export default function CreateHubPage() { - {/* 3D Abacus Creator */} - -
- {/* Icon with gradient background */} -
- 🖨️ -
- - {/* Title */} -

- {t('abacus.title')} -

- - {/* Description */} -

- {t('abacus.description')} -

- - {/* Features */} -
    -
  • - - ✓ - - {t('abacus.feature1')} -
  • -
  • - - ✓ - - {t('abacus.feature2')} -
  • -
  • - - ✓ - - {t('abacus.feature3')} -
  • -
- - {/* CTA Button */} -
-
- {t('abacus.button')} - -
-
-
- - {/* Worksheet Creator */}
void -} - -export function JobMonitor({ jobId, onComplete }: JobMonitorProps) { - const [job, setJob] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let pollInterval: NodeJS.Timeout - - const pollStatus = async () => { - try { - const response = await fetch(`/api/abacus/status/${jobId}`) - if (!response.ok) { - throw new Error('Failed to fetch job status') - } - const data = await response.json() - setJob(data) - - if (data.status === 'completed') { - onComplete() - clearInterval(pollInterval) - } else if (data.status === 'failed') { - setError(data.error || 'Job failed') - clearInterval(pollInterval) - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error') - clearInterval(pollInterval) - } - } - - // Poll immediately - pollStatus() - - // Then poll every 1 second - pollInterval = setInterval(pollStatus, 1000) - - return () => clearInterval(pollInterval) - }, [jobId, onComplete]) - - if (error) { - return ( -
-
Error
-
{error}
-
- ) - } - - if (!job) { - return ( -
- Loading... -
- ) - } - - const statusColors = { - pending: 'blue', - processing: 'yellow', - completed: 'green', - failed: 'red', - } - - const statusColor = statusColors[job.status] - - return ( -
-
-
- {job.status} -
- {(job.status === 'pending' || job.status === 'processing') && ( -
- )} -
- {job.progress && ( -
{job.progress}
- )} - {job.error && ( -
Error: {job.error}
- )} -
- ) -} diff --git a/apps/web/src/components/3d-print/STLPreview.tsx b/apps/web/src/components/3d-print/STLPreview.tsx deleted file mode 100644 index befd19bc..00000000 --- a/apps/web/src/components/3d-print/STLPreview.tsx +++ /dev/null @@ -1,199 +0,0 @@ -'use client' - -import { OrbitControls, Stage } from '@react-three/drei' -import { Canvas, useLoader } from '@react-three/fiber' -import { Suspense, useEffect, useRef, useState } from 'react' -// @ts-expect-error - STLLoader doesn't have TypeScript declarations -import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js' -import { css } from '../../../styled-system/css' - -interface STLModelProps { - url: string -} - -function STLModel({ url }: STLModelProps) { - const geometry = useLoader(STLLoader, url) - - return ( - - - - ) -} - -interface STLPreviewProps { - columns: number - scaleFactor: number -} - -export function STLPreview({ columns, scaleFactor }: STLPreviewProps) { - const [previewUrl, setPreviewUrl] = useState('/3d-models/simplified.abacus.stl') - const [isGenerating, setIsGenerating] = useState(false) - const [error, setError] = useState(null) - const workerRef = useRef(null) - - // Initialize worker - useEffect(() => { - const worker = new Worker(new URL('../../workers/openscad.worker.ts', import.meta.url), { - type: 'module', - }) - - worker.onmessage = (event: MessageEvent) => { - const { data } = event - - switch (data.type) { - case 'ready': - console.log('[STLPreview] Worker ready') - break - - case 'result': { - // Create blob from STL data - const blob = new Blob([data.stl], { type: 'application/octet-stream' }) - const objectUrl = URL.createObjectURL(blob) - - // Revoke old URL if it exists - if (previewUrl && previewUrl.startsWith('blob:')) { - URL.revokeObjectURL(previewUrl) - } - - setPreviewUrl(objectUrl) - setIsGenerating(false) - setError(null) - break - } - - case 'error': - console.error('[STLPreview] Worker error:', data.error) - setError(data.error) - setIsGenerating(false) - // Fallback to showing the base STL - setPreviewUrl('/3d-models/simplified.abacus.stl') - break - - default: - console.warn('[STLPreview] Unknown message type:', data) - } - } - - worker.onerror = (error) => { - console.error('[STLPreview] Worker error:', error) - setError('Worker failed to load') - setIsGenerating(false) - } - - workerRef.current = worker - - return () => { - worker.terminate() - workerRef.current = null - // Clean up any blob URLs - if (previewUrl && previewUrl.startsWith('blob:')) { - URL.revokeObjectURL(previewUrl) - } - } - }, []) - - // Trigger rendering when parameters change - useEffect(() => { - if (!workerRef.current) return - - setIsGenerating(true) - setError(null) - - // Debounce: Wait 500ms after parameters change before regenerating - const timeoutId = setTimeout(() => { - workerRef.current?.postMessage({ - type: 'render', - columns, - scaleFactor, - }) - }, 500) - - return () => { - clearTimeout(timeoutId) - } - }, [columns, scaleFactor]) - - return ( -
- {isGenerating && ( -
-
-
- Rendering preview (may take 30-60 seconds)... -
-
- )} - {error && ( -
-
Preview Error:
-
{error}
-
- )} - - - - - - } - > - - - - - - -
- ) -}