refactor: move 3D abacus creator feature to separate branch
Removed 3D abacus creator from main branch due to Docker build failures with OpenSCAD AppImage extraction in GitHub Actions. The feature will be reworked in the feature/3d-abacus-creator branch. Removed: - src/app/create/abacus/ - 3D abacus creator page - src/app/api/abacus/ - API routes for 3D generation - src/components/3d-print/ - 3D-related components - Dockerfile: OpenSCAD and BOSL2 builder stages - Dockerfile: OpenGL/Qt runtime dependencies - Dockerfile: tmp/3d-jobs directory creation - Create hub: 3D abacus card This restores successful Docker builds and unblocks deployments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
66241c21d1
commit
c8aa602e1c
67
Dockerfile
67
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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, { buffer: Buffer; timestamp: number }>()
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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<number | undefined>(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<string | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<PageWithNav navTitle={t('navTitle')} navEmoji="🖨">
|
||||
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
|
||||
<div
|
||||
data-component="3d-print-page"
|
||||
className={css({
|
||||
maxWidth: '1200px',
|
||||
mx: 'auto',
|
||||
p: 6,
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
|
||||
<p className={css({ mb: 6, color: 'gray.600' })}>{t('pageSubtitle')}</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: '1fr 1fr' },
|
||||
gap: 8,
|
||||
})}
|
||||
>
|
||||
{/* Left column: Controls */}
|
||||
<div data-section="controls">
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
p: 6,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
mb: 4,
|
||||
})}
|
||||
>
|
||||
{t('customizationTitle')}
|
||||
</h2>
|
||||
|
||||
{/* Number of Columns */}
|
||||
<div data-setting="columns" className={css({ mb: 4 })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontWeight: 'medium',
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
{t('columns.label', { count: columns })}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="13"
|
||||
step="1"
|
||||
value={columns}
|
||||
onChange={(e) => setColumns(Number.parseInt(e.target.value, 10))}
|
||||
className={css({ width: '100%' })}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.500',
|
||||
mt: 1,
|
||||
})}
|
||||
>
|
||||
{t('columns.help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scale Factor */}
|
||||
<div data-setting="scale-factor" className={css({ mb: 4 })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontWeight: 'medium',
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
{t('scaleFactor.label', { factor: scaleFactor.toFixed(1) })}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={scaleFactor}
|
||||
onChange={(e) => setScaleFactor(Number.parseFloat(e.target.value))}
|
||||
className={css({ width: '100%' })}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.500',
|
||||
mt: 1,
|
||||
})}
|
||||
>
|
||||
{t('scaleFactor.help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Width in mm */}
|
||||
<div data-setting="width-mm" className={css({ mb: 4 })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontWeight: 'medium',
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
{t('widthMm.label')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="50"
|
||||
max="500"
|
||||
step="1"
|
||||
value={widthMm ?? ''}
|
||||
onChange={(e) => {
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.500',
|
||||
mt: 1,
|
||||
})}
|
||||
>
|
||||
{t('widthMm.help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div data-setting="format" className={css({ mb: format === '3mf' ? 4 : 6 })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontWeight: 'medium',
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
{t('format.label')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, flexWrap: 'wrap' })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormat('stl')}
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
borderRadius: '4px',
|
||||
border: '2px solid',
|
||||
borderColor: format === 'stl' ? 'blue.600' : 'gray.300',
|
||||
bg: format === 'stl' ? 'blue.50' : 'white',
|
||||
color: format === 'stl' ? 'blue.700' : 'gray.700',
|
||||
cursor: 'pointer',
|
||||
fontWeight: format === 'stl' ? 'bold' : 'normal',
|
||||
_hover: { bg: format === 'stl' ? 'blue.100' : 'gray.50' },
|
||||
})}
|
||||
>
|
||||
STL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormat('3mf')}
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
borderRadius: '4px',
|
||||
border: '2px solid',
|
||||
borderColor: format === '3mf' ? 'blue.600' : 'gray.300',
|
||||
bg: format === '3mf' ? 'blue.50' : 'white',
|
||||
color: format === '3mf' ? 'blue.700' : 'gray.700',
|
||||
cursor: 'pointer',
|
||||
fontWeight: format === '3mf' ? 'bold' : 'normal',
|
||||
_hover: { bg: format === '3mf' ? 'blue.100' : 'gray.50' },
|
||||
})}
|
||||
>
|
||||
3MF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormat('scad')}
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
borderRadius: '4px',
|
||||
border: '2px solid',
|
||||
borderColor: format === 'scad' ? 'blue.600' : 'gray.300',
|
||||
bg: format === 'scad' ? 'blue.50' : 'white',
|
||||
color: format === 'scad' ? 'blue.700' : 'gray.700',
|
||||
cursor: 'pointer',
|
||||
fontWeight: format === 'scad' ? 'bold' : 'normal',
|
||||
_hover: { bg: format === 'scad' ? 'blue.100' : 'gray.50' },
|
||||
})}
|
||||
>
|
||||
OpenSCAD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3MF Color Options */}
|
||||
{format === '3mf' && (
|
||||
<div data-section="3mf-colors" className={css({ mb: 6 })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
mb: 3,
|
||||
})}
|
||||
>
|
||||
{t('colors.title')}
|
||||
</h3>
|
||||
|
||||
{/* Frame Color */}
|
||||
<div data-setting="frame-color" className={css({ mb: 3 })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontWeight: 'medium',
|
||||
mb: 1,
|
||||
})}
|
||||
>
|
||||
{t('colors.frame')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
|
||||
<input
|
||||
type="color"
|
||||
value={frameColor}
|
||||
onChange={(e) => setFrameColor(e.target.value)}
|
||||
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={frameColor}
|
||||
onChange={(e) => setFrameColor(e.target.value)}
|
||||
placeholder="#8b7355"
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: 3,
|
||||
py: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heaven Bead Color */}
|
||||
<div data-setting="heaven-bead-color" className={css({ mb: 3 })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontWeight: 'medium',
|
||||
mb: 1,
|
||||
})}
|
||||
>
|
||||
{t('colors.heavenBead')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
|
||||
<input
|
||||
type="color"
|
||||
value={heavenBeadColor}
|
||||
onChange={(e) => setHeavenBeadColor(e.target.value)}
|
||||
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={heavenBeadColor}
|
||||
onChange={(e) => setHeavenBeadColor(e.target.value)}
|
||||
placeholder="#e8d5c4"
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: 3,
|
||||
py: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Earth Bead Color */}
|
||||
<div data-setting="earth-bead-color" className={css({ mb: 3 })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontWeight: 'medium',
|
||||
mb: 1,
|
||||
})}
|
||||
>
|
||||
{t('colors.earthBead')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
|
||||
<input
|
||||
type="color"
|
||||
value={earthBeadColor}
|
||||
onChange={(e) => setEarthBeadColor(e.target.value)}
|
||||
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={earthBeadColor}
|
||||
onChange={(e) => setEarthBeadColor(e.target.value)}
|
||||
placeholder="#6b5444"
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: 3,
|
||||
py: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decoration Color */}
|
||||
<div data-setting="decoration-color" className={css({ mb: 0 })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontWeight: 'medium',
|
||||
mb: 1,
|
||||
})}
|
||||
>
|
||||
{t('colors.decoration')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
|
||||
<input
|
||||
type="color"
|
||||
value={decorationColor}
|
||||
onChange={(e) => setDecorationColor(e.target.value)}
|
||||
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={decorationColor}
|
||||
onChange={(e) => setDecorationColor(e.target.value)}
|
||||
placeholder="#d4af37"
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: 3,
|
||||
py: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
data-action="generate"
|
||||
className={css({
|
||||
width: '100%',
|
||||
px: 6,
|
||||
py: 3,
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontWeight: 'bold',
|
||||
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
_hover: { bg: isGenerating ? 'blue.600' : 'blue.700' },
|
||||
})}
|
||||
>
|
||||
{isGenerating ? t('generate.generating') : t('generate.button')}
|
||||
</button>
|
||||
|
||||
{/* Job Status */}
|
||||
{jobId && !isComplete && (
|
||||
<div className={css({ mt: 4 })}>
|
||||
<JobMonitor jobId={jobId} onComplete={handleJobComplete} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Button */}
|
||||
{isComplete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
data-action="download"
|
||||
className={css({
|
||||
width: '100%',
|
||||
mt: 4,
|
||||
px: 6,
|
||||
py: 3,
|
||||
bg: 'green.600',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'green.700' },
|
||||
})}
|
||||
>
|
||||
{t('download', { format: format.toUpperCase() })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
data-status="error"
|
||||
className={css({
|
||||
mt: 4,
|
||||
p: 4,
|
||||
bg: 'red.100',
|
||||
borderRadius: '4px',
|
||||
color: 'red.700',
|
||||
})}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: Preview */}
|
||||
<div data-section="preview">
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
p: 6,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
mb: 4,
|
||||
})}
|
||||
>
|
||||
{t('preview.title')}
|
||||
</h2>
|
||||
<STLPreview columns={columns} scaleFactor={scaleFactor} />
|
||||
<div
|
||||
className={css({
|
||||
mt: 4,
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
<p className={css({ mb: 2 })}>{t('preview.liveDescription')}</p>
|
||||
<p className={css({ mb: 2 })}>{t('preview.note')}</p>
|
||||
<p>{t('preview.instructions')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
|
@ -309,202 +309,6 @@ export default function CreateHubPage() {
|
|||
</div>
|
||||
</Link>
|
||||
|
||||
{/* 3D Abacus Creator */}
|
||||
<Link href="/create/abacus">
|
||||
<div
|
||||
data-element="abacus-card"
|
||||
className={css({
|
||||
bg: 'white',
|
||||
borderRadius: '3xl',
|
||||
p: 8,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
_hover: {
|
||||
transform: 'translateY(-12px) scale(1.02)',
|
||||
boxShadow: '0 30px 80px rgba(0,0,0,0.35)',
|
||||
},
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '6px',
|
||||
background: 'linear-gradient(90deg, #f093fb 0%, #f5576c 100%)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Icon with gradient background */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '4xl',
|
||||
mb: 5,
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '2xl',
|
||||
background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
boxShadow: '0 8px 24px rgba(240, 147, 251, 0.4)',
|
||||
})}
|
||||
>
|
||||
🖨️
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'extrabold',
|
||||
mb: 3,
|
||||
color: 'gray.900',
|
||||
letterSpacing: 'tight',
|
||||
})}
|
||||
>
|
||||
{t('abacus.title')}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
color: 'gray.600',
|
||||
mb: 5,
|
||||
lineHeight: '1.7',
|
||||
})}
|
||||
>
|
||||
{t('abacus.description')}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<ul
|
||||
className={css({
|
||||
listStyle: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 3,
|
||||
})}
|
||||
>
|
||||
<li
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
fontSize: 'sm',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: 'full',
|
||||
bg: 'pink.100',
|
||||
color: 'pink.600',
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{t('abacus.feature1')}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
fontSize: 'sm',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: 'full',
|
||||
bg: 'pink.100',
|
||||
color: 'pink.600',
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{t('abacus.feature2')}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
fontSize: 'sm',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: 'full',
|
||||
bg: 'pink.100',
|
||||
color: 'pink.600',
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{t('abacus.feature3')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div
|
||||
className={css({
|
||||
mt: 7,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
px: 6,
|
||||
py: 3,
|
||||
borderRadius: 'xl',
|
||||
background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'md',
|
||||
boxShadow: '0 4px 15px rgba(240, 147, 251, 0.4)',
|
||||
transition: 'all 0.3s',
|
||||
_hover: {
|
||||
boxShadow: '0 6px 20px rgba(240, 147, 251, 0.5)',
|
||||
transform: 'translateX(4px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{t('abacus.button')}</span>
|
||||
<span className={css({ fontSize: 'lg' })}>→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Worksheet Creator */}
|
||||
<Link href="/create/worksheets/addition">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,146 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
type JobStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
||||
|
||||
interface Job {
|
||||
id: string
|
||||
status: JobStatus
|
||||
progress?: string
|
||||
error?: string
|
||||
createdAt: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
interface JobMonitorProps {
|
||||
jobId: string
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function JobMonitor({ jobId, onComplete }: JobMonitorProps) {
|
||||
const [job, setJob] = useState<Job | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
data-status="error"
|
||||
className={css({
|
||||
p: 4,
|
||||
bg: 'red.100',
|
||||
borderRadius: '8px',
|
||||
borderLeft: '4px solid',
|
||||
borderColor: 'red.600',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontWeight: 'bold', color: 'red.800', mb: 1 })}>Error</div>
|
||||
<div className={css({ color: 'red.700' })}>{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div data-status="loading" className={css({ p: 4, textAlign: 'center' })}>
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
pending: 'blue',
|
||||
processing: 'yellow',
|
||||
completed: 'green',
|
||||
failed: 'red',
|
||||
}
|
||||
|
||||
const statusColor = statusColors[job.status]
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="job-monitor"
|
||||
className={css({
|
||||
p: 4,
|
||||
bg: `${statusColor}.50`,
|
||||
borderRadius: '8px',
|
||||
borderLeft: '4px solid',
|
||||
borderColor: `${statusColor}.600`,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
data-status={job.status}
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: `${statusColor}.800`,
|
||||
textTransform: 'capitalize',
|
||||
})}
|
||||
>
|
||||
{job.status}
|
||||
</div>
|
||||
{(job.status === 'pending' || job.status === 'processing') && (
|
||||
<div
|
||||
className={css({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: `${statusColor}.600`,
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{job.progress && (
|
||||
<div className={css({ color: `${statusColor}.700`, fontSize: 'sm' })}>{job.progress}</div>
|
||||
)}
|
||||
{job.error && (
|
||||
<div className={css({ color: 'red.700', fontSize: 'sm', mt: 2 })}>Error: {job.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<mesh geometry={geometry}>
|
||||
<meshStandardMaterial color="#8b7355" metalness={0.1} roughness={0.6} />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
interface STLPreviewProps {
|
||||
columns: number
|
||||
scaleFactor: number
|
||||
}
|
||||
|
||||
export function STLPreview({ columns, scaleFactor }: STLPreviewProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('/3d-models/simplified.abacus.stl')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const workerRef = useRef<Worker | null>(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 (
|
||||
<div
|
||||
data-component="stl-preview"
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '500px',
|
||||
bg: 'gray.900',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{isGenerating && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
left: 4,
|
||||
zIndex: 10,
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderRadius: '4px',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
|
||||
<div
|
||||
className={css({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid white',
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
})}
|
||||
/>
|
||||
<span>Rendering preview (may take 30-60 seconds)...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
left: 4,
|
||||
zIndex: 10,
|
||||
bg: 'red.600',
|
||||
color: 'white',
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderRadius: '4px',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
<div>Preview Error:</div>
|
||||
<div className={css({ fontSize: 'xs', mt: 1, opacity: 0.9 })}>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<Canvas camera={{ position: [0, 0, 100], fov: 50 }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="orange" />
|
||||
</mesh>
|
||||
}
|
||||
>
|
||||
<Stage environment="city" intensity={0.6}>
|
||||
<STLModel url={previewUrl} key={previewUrl} />
|
||||
</Stage>
|
||||
<OrbitControls makeDefault />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue