Compare commits
23 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea8399745 | ||
|
|
e0585b8ac7 | ||
|
|
ec887c895c | ||
|
|
e08fdfd676 | ||
|
|
eaaf17cd4c | ||
|
|
32f51ae739 | ||
|
|
59d594c939 | ||
|
|
1db779c49f | ||
|
|
cd15c70a25 | ||
|
|
602c648adc | ||
|
|
e1bcd24169 | ||
|
|
2df4423684 | ||
|
|
7e54c6f4fc | ||
|
|
8a170833f3 | ||
|
|
1074624b2f | ||
|
|
1021efb715 | ||
|
|
bea4842a29 | ||
|
|
bf1ed6890a | ||
|
|
247c3d9874 | ||
|
|
98863026b7 | ||
|
|
58fc5d8912 | ||
|
|
4559fb121d | ||
|
|
fab227d686 |
2253
CHANGELOG.md
2253
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
41
Dockerfile
41
Dockerfile
@@ -107,23 +107,60 @@ RUN mkdir -p /bosl2 && \
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Download latest OpenSCAD AppImage and extract it
|
||||
# Using 2024.11 which has CGAL fixes for intersection operations
|
||||
RUN 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
|
||||
FROM node:18-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install ONLY runtime dependencies (no build tools)
|
||||
# Using Debian because OpenSCAD is not available in Alpine repos
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-pip \
|
||||
qpdf \
|
||||
openscad \
|
||||
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
|
||||
|
||||
@@ -126,13 +126,14 @@ When asked to make ANY changes:
|
||||
|
||||
**CRITICAL: The user manages running the dev server, NOT Claude Code.**
|
||||
|
||||
- ❌ DO NOT run `npm run dev` or `npm start`
|
||||
- ❌ DO NOT run `pnpm dev`, `npm run dev`, or `npm start`
|
||||
- ❌ DO NOT attempt to start, stop, or restart the dev server
|
||||
- ❌ DO NOT kill processes on port 3000
|
||||
- ❌ DO NOT use background Bash processes for the dev server
|
||||
- ✅ Make code changes and let the user restart the server when needed
|
||||
- ✅ You may run other commands like `npm run type-check`, `npm run lint`, etc.
|
||||
|
||||
The user will manually start/restart the dev server after you make changes.
|
||||
**The user runs the dev server themselves.** The user will manually start/restart the dev server after you make changes.
|
||||
|
||||
## Details
|
||||
|
||||
|
||||
@@ -168,7 +168,5 @@
|
||||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
"enabledMcpjsonServers": ["sqlite"]
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"next": "^14.2.32",
|
||||
"next-auth": "5.0.0-beta.29",
|
||||
"next-intl": "^4.4.0",
|
||||
"openscad-wasm-prebuilt": "^1.2.0",
|
||||
"python-bridge": "^1.1.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
47
apps/web/public/3d-models/abacus-inline.scad
Normal file
47
apps/web/public/3d-models/abacus-inline.scad
Normal file
@@ -0,0 +1,47 @@
|
||||
// Inline version of abacus.scad that doesn't require BOSL2
|
||||
// This version uses a hardcoded bounding box size instead of the bounding_box() function
|
||||
|
||||
// ---- USER CUSTOMIZABLE PARAMETERS ----
|
||||
// These can be overridden via command line: -D 'columns=7' etc.
|
||||
columns = 13; // Total number of columns (1-13, mirrored book design)
|
||||
scale_factor = 1.5; // Overall size scale (preserves aspect ratio)
|
||||
// -----------------------------------------
|
||||
|
||||
stl_path = "/3d-models/simplified.abacus.stl";
|
||||
|
||||
// Known bounding box dimensions of the simplified.abacus.stl file
|
||||
// These were measured from the original file
|
||||
bbox_size = [186, 60, 120]; // [width, depth, height] in STL units
|
||||
|
||||
// Calculate parameters based on column count
|
||||
// The full STL has 13 columns. We want columns/2 per side (mirrored).
|
||||
total_columns_in_stl = 13;
|
||||
columns_per_side = columns / 2;
|
||||
width_scale = columns_per_side / total_columns_in_stl;
|
||||
|
||||
// Column spacing: distance between mirrored halves
|
||||
units_per_column = bbox_size[0] / total_columns_in_stl; // ~14.3 units per column
|
||||
column_spacing = columns_per_side * units_per_column;
|
||||
|
||||
// --- actual model ---
|
||||
module imported() {
|
||||
import(stl_path, convexity = 10);
|
||||
}
|
||||
|
||||
// Create a bounding box manually instead of using BOSL2's bounding_box()
|
||||
module bounding_box_manual() {
|
||||
translate([-bbox_size[0]/2, -bbox_size[1]/2, -bbox_size[2]/2])
|
||||
cube(bbox_size);
|
||||
}
|
||||
|
||||
module half_abacus() {
|
||||
intersection() {
|
||||
scale([width_scale, 1, 1]) bounding_box_manual();
|
||||
imported();
|
||||
}
|
||||
}
|
||||
|
||||
scale([scale_factor, scale_factor, scale_factor]) {
|
||||
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
|
||||
half_abacus();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export async function POST(request: NextRequest) {
|
||||
const calendarSvg = generateCalendarComposite({
|
||||
month,
|
||||
year,
|
||||
renderToString: renderToStaticMarkup
|
||||
renderToString: renderToStaticMarkup,
|
||||
})
|
||||
if (!calendarSvg || calendarSvg.trim().length === 0) {
|
||||
throw new Error('Generated empty composite calendar SVG')
|
||||
@@ -130,7 +130,7 @@ export async function POST(request: NextRequest) {
|
||||
{
|
||||
error: 'Failed to generate calendar',
|
||||
message: errorMessage,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: errorStack })
|
||||
...(process.env.NODE_ENV === 'development' && { stack: errorStack }),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function POST(request: NextRequest) {
|
||||
const calendarSvg = generateCalendarComposite({
|
||||
month,
|
||||
year,
|
||||
renderToString: renderToStaticMarkup
|
||||
renderToString: renderToStaticMarkup,
|
||||
})
|
||||
writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { css } from '../../../../styled-system/css'
|
||||
|
||||
export default function ThreeDPrintPage() {
|
||||
// New unified parameter system
|
||||
const [columns, setColumns] = useState(13)
|
||||
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')
|
||||
|
||||
@@ -10,7 +10,11 @@ interface CalendarPreviewProps {
|
||||
previewSvg: string | null
|
||||
}
|
||||
|
||||
async function fetchTypstPreview(month: number, year: number, format: string): Promise<string | null> {
|
||||
async function fetchTypstPreview(
|
||||
month: number,
|
||||
year: number,
|
||||
format: string
|
||||
): Promise<string | null> {
|
||||
const response = await fetch('/api/create/calendar/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function CalendarCreatorPage() {
|
||||
const data = await response.json()
|
||||
|
||||
// Convert base64 PDF to blob and trigger download
|
||||
const pdfBytes = Uint8Array.from(atob(data.pdf), c => c.charCodeAt(0))
|
||||
const pdfBytes = Uint8Array.from(atob(data.pdf), (c) => c.charCodeAt(0))
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
@@ -54,7 +54,9 @@ export default function CalendarCreatorPage() {
|
||||
document.body.removeChild(a)
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error)
|
||||
alert(`Failed to generate calendar: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
alert(
|
||||
`Failed to generate calendar: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
@@ -64,12 +66,12 @@ export default function CalendarCreatorPage() {
|
||||
<PageWithNav navTitle="Create" navEmoji="📅">
|
||||
<div
|
||||
data-component="calendar-creator"
|
||||
className={css({
|
||||
className={`with-fixed-nav ${css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gray.900',
|
||||
color: 'white',
|
||||
padding: '2rem',
|
||||
})}
|
||||
})}`}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
/* Import Panda CSS generated styles */
|
||||
@import "../../styled-system/styles.css";
|
||||
|
||||
/* Layout variables */
|
||||
:root {
|
||||
/* Navigation bar heights - used by both the nav itself and content padding */
|
||||
--app-nav-height-full: 72px;
|
||||
--app-nav-height-minimal: 92px;
|
||||
}
|
||||
|
||||
/* Utility class for pages with fixed nav */
|
||||
.with-fixed-nav {
|
||||
padding-top: var(--app-nav-height, 80px);
|
||||
}
|
||||
|
||||
/* Custom global styles */
|
||||
body {
|
||||
font-family:
|
||||
|
||||
@@ -245,7 +245,7 @@ export function ReadingNumbersGuide() {
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
p: '2',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -257,27 +257,19 @@ export function ReadingNumbersGuide() {
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.600',
|
||||
mb: '3',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{example.num}
|
||||
</div>
|
||||
|
||||
{/* Aspect ratio container for soroban - roughly 1:3 ratio */}
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
aspectRatio: '1/2.8',
|
||||
maxW: '120px',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
flex: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
my: '2',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
@@ -286,7 +278,7 @@ export function ReadingNumbersGuide() {
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={0.8}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
@@ -299,7 +291,7 @@ export function ReadingNumbersGuide() {
|
||||
color: 'gray.600',
|
||||
lineHeight: 'tight',
|
||||
textAlign: 'center',
|
||||
mt: 'auto',
|
||||
mt: '2',
|
||||
})}
|
||||
>
|
||||
{t(`singleDigits.examples.${example.descKey}`)}
|
||||
@@ -469,7 +461,7 @@ export function ReadingNumbersGuide() {
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.300',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
p: '2',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -481,27 +473,19 @@ export function ReadingNumbersGuide() {
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
mb: '3',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{example.num}
|
||||
</div>
|
||||
|
||||
{/* Larger container for multi-digit numbers */}
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
maxW: '180px',
|
||||
bg: 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
flex: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
my: '2',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
@@ -510,7 +494,7 @@ export function ReadingNumbersGuide() {
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={0.9}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
@@ -523,6 +507,7 @@ export function ReadingNumbersGuide() {
|
||||
color: 'blue.700',
|
||||
lineHeight: 'relaxed',
|
||||
textAlign: 'center',
|
||||
mt: '2',
|
||||
})}
|
||||
>
|
||||
{t(`multiDigit.examples.${example.descKey}`)}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function GuidePage() {
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle={t('navTitle')} navEmoji="📖">
|
||||
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
|
||||
<div className={`with-fixed-nav ${css({ minHeight: '100vh', bg: 'gray.50' })}`}>
|
||||
{/* Hero Section */}
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -14,8 +14,8 @@ export default function TestStaticAbacusPage() {
|
||||
<div style={{ padding: '40px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '10px' }}>AbacusStatic Test (Server Component)</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: '30px' }}>
|
||||
This page is a React Server Component - no "use client" directive!
|
||||
All abacus displays below are rendered on the server with zero client-side JavaScript.
|
||||
This page is a React Server Component - no "use client" directive! All abacus
|
||||
displays below are rendered on the server with zero client-side JavaScript.
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -39,26 +39,20 @@ export default function TestStaticAbacusPage() {
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<AbacusStatic
|
||||
value={num}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
compact
|
||||
scaleFactor={0.9}
|
||||
/>
|
||||
<span style={{ fontSize: '20px', fontWeight: 'bold', color: '#475569' }}>
|
||||
{num}
|
||||
</span>
|
||||
<AbacusStatic value={num} columns="auto" hideInactiveBeads compact scaleFactor={0.9} />
|
||||
<span style={{ fontSize: '20px', fontWeight: 'bold', color: '#475569' }}>{num}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '40px', padding: '20px', background: '#f0fdf4', borderRadius: '8px' }}>
|
||||
<div
|
||||
style={{ marginTop: '40px', padding: '20px', background: '#f0fdf4', borderRadius: '8px' }}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, color: '#166534' }}>✅ Success!</h2>
|
||||
<p style={{ color: '#15803d' }}>
|
||||
If you can see the abacus displays above, then AbacusStatic is working correctly
|
||||
in React Server Components. Check the page source - you'll see pure HTML/SVG with
|
||||
no client-side hydration markers!
|
||||
If you can see the abacus displays above, then AbacusStatic is working correctly in React
|
||||
Server Components. Check the page source - you'll see pure HTML/SVG with no
|
||||
client-side hydration markers!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { OrbitControls, Stage } from '@react-three/drei'
|
||||
import { Canvas, useLoader } from '@react-three/fiber'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
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'
|
||||
@@ -30,68 +30,86 @@ 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(() => {
|
||||
let mounted = true
|
||||
const worker = new Worker(new URL('../../workers/openscad.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
|
||||
const generatePreview = async () => {
|
||||
setIsGenerating(true)
|
||||
setError(null)
|
||||
worker.onmessage = (event: MessageEvent) => {
|
||||
const { data } = event
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/abacus/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ columns, scaleFactor }),
|
||||
})
|
||||
switch (data.type) {
|
||||
case 'ready':
|
||||
console.log('[STLPreview] Worker ready')
|
||||
break
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate preview')
|
||||
}
|
||||
case 'result': {
|
||||
// Create blob from STL data
|
||||
const blob = new Blob([data.stl], { type: 'application/octet-stream' })
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
// Convert response to blob and create object URL
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (mounted) {
|
||||
// Revoke old URL if it exists
|
||||
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
setPreviewUrl(objectUrl)
|
||||
} else {
|
||||
// Component unmounted, clean up the URL
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to generate preview'
|
||||
|
||||
// Check if this is an OpenSCAD not found error
|
||||
if (
|
||||
errorMessage.includes('openscad: command not found') ||
|
||||
errorMessage.includes('Command failed: openscad')
|
||||
) {
|
||||
setError('OpenSCAD not installed (preview only available in production/Docker)')
|
||||
// Fallback to showing the base STL
|
||||
setPreviewUrl('/3d-models/simplified.abacus.stl')
|
||||
} else {
|
||||
setError(errorMessage)
|
||||
}
|
||||
console.error('Preview generation error:', err)
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce: Wait 1 second after parameters change before regenerating
|
||||
const timeoutId = setTimeout(generatePreview, 1000)
|
||||
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 () => {
|
||||
mounted = false
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [columns, scaleFactor])
|
||||
|
||||
@@ -495,6 +495,10 @@ function MinimalNav({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
pointerEvents: 'none',
|
||||
// Set active nav height for content to use
|
||||
['--app-nav-height' as any]: 'var(--app-nav-height-minimal)',
|
||||
// Use the variable for min-height to ensure consistency
|
||||
minHeight: 'var(--app-nav-height-minimal)',
|
||||
}}
|
||||
>
|
||||
{/* Hamburger Menu - positioned absolutely on left */}
|
||||
@@ -568,6 +572,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const isHomePage = pathname === '/'
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
|
||||
// Try to get home hero context (if on homepage)
|
||||
@@ -579,7 +584,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const subtitle = homeHero?.subtitle || fallbackSubtitle
|
||||
|
||||
// Show branding unless we're on homepage with visible hero
|
||||
const showBranding = !homeHero || !homeHero.isHeroVisible
|
||||
const showBranding = !isHomePage || !homeHero || !homeHero.isHeroVisible
|
||||
|
||||
// Auto-detect variant based on context
|
||||
// Only arcade pages (not /games) should use minimal nav
|
||||
@@ -600,12 +605,18 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if we should use transparent styling (when hero is visible)
|
||||
const isTransparent = homeHero?.isHeroVisible
|
||||
// Check if we should use transparent styling (when hero is visible on home page)
|
||||
const isTransparent = isHomePage && homeHero?.isHeroVisible
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<header
|
||||
style={{
|
||||
// Set active nav height for content to use
|
||||
['--app-nav-height' as any]: 'var(--app-nav-height-full)',
|
||||
// Use the variable for min-height to ensure consistency
|
||||
minHeight: 'var(--app-nav-height-full)',
|
||||
}}
|
||||
className={css({
|
||||
bg: isTransparent ? 'transparent' : 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: isTransparent ? 'none' : 'blur(12px)',
|
||||
|
||||
@@ -66,32 +66,6 @@ export function InteractiveAbacus({
|
||||
const beadPosition = beadElement.getAttribute('data-bead-position')
|
||||
const isActive = beadElement.getAttribute('data-bead-active') === '1'
|
||||
|
||||
console.log('Bead clicked:', {
|
||||
beadType,
|
||||
beadColumn,
|
||||
beadPosition,
|
||||
isActive,
|
||||
})
|
||||
console.log('Current value before click:', currentValue)
|
||||
|
||||
if (beadType === 'earth') {
|
||||
const position = parseInt(beadPosition || '0', 10)
|
||||
const placeValue = beadColumn
|
||||
const columnPower = 10 ** placeValue
|
||||
const currentDigit = Math.floor(currentValue / columnPower) % 10
|
||||
const heavenContribution = Math.floor(currentDigit / 5) * 5
|
||||
const earthContribution = currentDigit % 5
|
||||
console.log('Earth bead analysis:', {
|
||||
position,
|
||||
beadColumn,
|
||||
placeValue,
|
||||
columnPower,
|
||||
currentDigit,
|
||||
heavenContribution,
|
||||
earthContribution,
|
||||
})
|
||||
}
|
||||
|
||||
if (beadType === 'heaven') {
|
||||
// Toggle heaven bead (worth 5)
|
||||
// Now using place-value based column numbering: 0=ones, 1=tens, 2=hundreds
|
||||
@@ -140,13 +114,6 @@ export function InteractiveAbacus({
|
||||
newEarthContribution = position + 1
|
||||
}
|
||||
|
||||
console.log('Earth bead calculation:', {
|
||||
position,
|
||||
isActive,
|
||||
currentEarthContribution: earthContribution,
|
||||
newEarthContribution,
|
||||
})
|
||||
|
||||
// Calculate the new digit for this column
|
||||
const newDigit = heavenContribution + newEarthContribution
|
||||
|
||||
|
||||
@@ -226,7 +226,6 @@ export function MyAbacus() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Keyframes for animations */}
|
||||
|
||||
@@ -53,49 +53,28 @@ export function HomeHeroProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Load from sessionStorage after mount (client-only, no hydration mismatch)
|
||||
useEffect(() => {
|
||||
console.log('[HeroAbacus] Loading from sessionStorage...')
|
||||
isLoadingFromStorage.current = true // Block saves during load
|
||||
|
||||
const saved = sessionStorage.getItem('heroAbacusValue')
|
||||
console.log('[HeroAbacus] Saved value from storage:', saved)
|
||||
|
||||
if (saved) {
|
||||
const parsedValue = parseInt(saved, 10)
|
||||
console.log('[HeroAbacus] Parsed value:', parsedValue)
|
||||
if (!Number.isNaN(parsedValue)) {
|
||||
console.log('[HeroAbacus] Setting abacus value to:', parsedValue)
|
||||
setAbacusValue(parsedValue)
|
||||
}
|
||||
} else {
|
||||
console.log('[HeroAbacus] No saved value found, staying at 0')
|
||||
}
|
||||
|
||||
// Use setTimeout to ensure the value has been set before we allow saves
|
||||
setTimeout(() => {
|
||||
isLoadingFromStorage.current = false
|
||||
setIsAbacusLoaded(true)
|
||||
console.log('[HeroAbacus] Load complete, allowing saves now and fading in')
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
// Persist value to sessionStorage when it changes (but skip during load)
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[HeroAbacus] Save effect triggered. Value:',
|
||||
abacusValue,
|
||||
'isLoadingFromStorage:',
|
||||
isLoadingFromStorage.current
|
||||
)
|
||||
|
||||
if (!isLoadingFromStorage.current) {
|
||||
console.log('[HeroAbacus] Saving to sessionStorage:', abacusValue)
|
||||
sessionStorage.setItem('heroAbacusValue', abacusValue.toString())
|
||||
console.log(
|
||||
'[HeroAbacus] Saved successfully. Storage now contains:',
|
||||
sessionStorage.getItem('heroAbacusValue')
|
||||
)
|
||||
} else {
|
||||
console.log('[HeroAbacus] Skipping save (currently loading from storage)')
|
||||
}
|
||||
}, [abacusValue])
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* This prevents multi-page overflow - one image scales to fit
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import type React from 'react'
|
||||
import { AbacusStatic, calculateAbacusDimensions } from '@soroban/abacus-react/static'
|
||||
|
||||
interface CalendarCompositeOptions {
|
||||
@@ -13,8 +13,18 @@ interface CalendarCompositeOptions {
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
@@ -33,84 +43,86 @@ export function generateCalendarComposite(options: CalendarCompositeOptions): st
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
// Layout constants for US Letter aspect ratio (8.5 x 11)
|
||||
const WIDTH = 850
|
||||
const HEIGHT = 1100
|
||||
const MARGIN = 50
|
||||
const CONTENT_WIDTH = WIDTH - MARGIN * 2
|
||||
const CONTENT_HEIGHT = HEIGHT - MARGIN * 2
|
||||
// Layout constants for US Letter aspect ratio (8.5 x 11)
|
||||
const WIDTH = 850
|
||||
const HEIGHT = 1100
|
||||
const MARGIN = 50
|
||||
const CONTENT_WIDTH = WIDTH - MARGIN * 2
|
||||
const CONTENT_HEIGHT = HEIGHT - MARGIN * 2
|
||||
|
||||
// Abacus natural size is 120x230 at scale=1
|
||||
const ABACUS_NATURAL_WIDTH = 120
|
||||
const ABACUS_NATURAL_HEIGHT = 230
|
||||
// Abacus natural size is 120x230 at scale=1
|
||||
const ABACUS_NATURAL_WIDTH = 120
|
||||
const ABACUS_NATURAL_HEIGHT = 230
|
||||
|
||||
// Calculate how many columns needed for year
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
// Calculate how many columns needed for year
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
|
||||
// Year abacus dimensions (calculate first to determine header height)
|
||||
// Use the shared dimension calculator so we stay in sync with AbacusStatic
|
||||
const { width: yearAbacusActualWidth, height: yearAbacusActualHeight } = calculateAbacusDimensions({
|
||||
columns: yearColumns,
|
||||
showNumbers: false,
|
||||
columnLabels: [],
|
||||
})
|
||||
// Year abacus dimensions (calculate first to determine header height)
|
||||
// Use the shared dimension calculator so we stay in sync with AbacusStatic
|
||||
const { width: yearAbacusActualWidth, height: yearAbacusActualHeight } =
|
||||
calculateAbacusDimensions({
|
||||
columns: yearColumns,
|
||||
showNumbers: false,
|
||||
columnLabels: [],
|
||||
})
|
||||
|
||||
const yearAbacusDisplayWidth = WIDTH * 0.15 // Display size on page
|
||||
const yearAbacusDisplayHeight = (yearAbacusActualHeight / yearAbacusActualWidth) * yearAbacusDisplayWidth
|
||||
const yearAbacusDisplayWidth = WIDTH * 0.15 // Display size on page
|
||||
const yearAbacusDisplayHeight =
|
||||
(yearAbacusActualHeight / yearAbacusActualWidth) * yearAbacusDisplayWidth
|
||||
|
||||
// Header - sized to fit month name + year abacus
|
||||
const MONTH_NAME_HEIGHT = 40
|
||||
const HEADER_HEIGHT = MONTH_NAME_HEIGHT + yearAbacusDisplayHeight + 20 // 20px spacing
|
||||
const TITLE_Y = MARGIN + 35
|
||||
const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2
|
||||
const yearAbacusY = TITLE_Y + 10
|
||||
// Header - sized to fit month name + year abacus
|
||||
const MONTH_NAME_HEIGHT = 40
|
||||
const HEADER_HEIGHT = MONTH_NAME_HEIGHT + yearAbacusDisplayHeight + 20 // 20px spacing
|
||||
const TITLE_Y = MARGIN + 35
|
||||
const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2
|
||||
const yearAbacusY = TITLE_Y + 10
|
||||
|
||||
// Calendar grid
|
||||
const GRID_START_Y = MARGIN + HEADER_HEIGHT
|
||||
const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT
|
||||
const WEEKDAY_ROW_HEIGHT = 25
|
||||
const DAY_GRID_HEIGHT = GRID_HEIGHT - WEEKDAY_ROW_HEIGHT
|
||||
// Calendar grid
|
||||
const GRID_START_Y = MARGIN + HEADER_HEIGHT
|
||||
const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT
|
||||
const WEEKDAY_ROW_HEIGHT = 25
|
||||
const DAY_GRID_HEIGHT = GRID_HEIGHT - WEEKDAY_ROW_HEIGHT
|
||||
|
||||
// 7 columns, up to 6 rows (35 cells max = 5 empty + 30 days worst case)
|
||||
const CELL_WIDTH = CONTENT_WIDTH / 7
|
||||
const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6
|
||||
// 7 columns, up to 6 rows (35 cells max = 5 empty + 30 days worst case)
|
||||
const CELL_WIDTH = CONTENT_WIDTH / 7
|
||||
const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6
|
||||
|
||||
// Day abacus sizing - fit in cell with padding
|
||||
const CELL_PADDING = 5
|
||||
// Day abacus sizing - fit in cell with padding
|
||||
const CELL_PADDING = 5
|
||||
|
||||
// Calculate max scale to fit in cell
|
||||
const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / ABACUS_NATURAL_WIDTH
|
||||
const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / ABACUS_NATURAL_HEIGHT
|
||||
const ABACUS_SCALE = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.9 // 90% to leave breathing room
|
||||
// Calculate max scale to fit in cell
|
||||
const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / ABACUS_NATURAL_WIDTH
|
||||
const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / ABACUS_NATURAL_HEIGHT
|
||||
const ABACUS_SCALE = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.9 // 90% to leave breathing room
|
||||
|
||||
const SCALED_ABACUS_WIDTH = ABACUS_NATURAL_WIDTH * ABACUS_SCALE
|
||||
const SCALED_ABACUS_HEIGHT = ABACUS_NATURAL_HEIGHT * ABACUS_SCALE
|
||||
const SCALED_ABACUS_WIDTH = ABACUS_NATURAL_WIDTH * ABACUS_SCALE
|
||||
const SCALED_ABACUS_HEIGHT = ABACUS_NATURAL_HEIGHT * ABACUS_SCALE
|
||||
|
||||
// Generate calendar grid
|
||||
const calendarCells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
calendarCells.push(null)
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarCells.push(day)
|
||||
}
|
||||
// Generate calendar grid
|
||||
const calendarCells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
calendarCells.push(null)
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarCells.push(day)
|
||||
}
|
||||
|
||||
// Render individual abacus SVGs as complete SVG elements
|
||||
function renderAbacusSVG(value: number, columns: number, scale: number): string {
|
||||
return renderToString(
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={scale}
|
||||
showNumbers={false}
|
||||
frameVisible={true}
|
||||
compact={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Render individual abacus SVGs as complete SVG elements
|
||||
function renderAbacusSVG(value: number, columns: number, scale: number): string {
|
||||
return renderToString(
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={scale}
|
||||
showNumbers={false}
|
||||
frameVisible={true}
|
||||
compact={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Main composite SVG
|
||||
const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
|
||||
// Main composite SVG
|
||||
const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
|
||||
<!-- Background -->
|
||||
<rect width="${WIDTH}" height="${HEIGHT}" fill="white"/>
|
||||
|
||||
@@ -130,11 +142,13 @@ const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" h
|
||||
})()}
|
||||
|
||||
<!-- Weekday Headers -->
|
||||
${WEEKDAYS.map((day, i) => `
|
||||
${WEEKDAYS.map(
|
||||
(day, i) => `
|
||||
<text x="${MARGIN + i * CELL_WIDTH + CELL_WIDTH / 2}" y="${GRID_START_Y + 18}"
|
||||
text-anchor="middle" font-family="Arial" font-size="14" font-weight="bold" fill="#555">
|
||||
${day}
|
||||
</text>`).join('')}
|
||||
</text>`
|
||||
).join('')}
|
||||
|
||||
<!-- Separator line under weekdays -->
|
||||
<line x1="${MARGIN}" y1="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
|
||||
@@ -142,45 +156,49 @@ const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" h
|
||||
stroke="#333" stroke-width="2"/>
|
||||
|
||||
<!-- Calendar Grid Cells -->
|
||||
${calendarCells.map((day, index) => {
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const cellX = MARGIN + col * CELL_WIDTH
|
||||
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
${calendarCells
|
||||
.map((day, index) => {
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const cellX = MARGIN + col * CELL_WIDTH
|
||||
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
|
||||
return `
|
||||
return `
|
||||
<rect x="${cellX}" y="${cellY}" width="${CELL_WIDTH}" height="${DAY_CELL_HEIGHT}"
|
||||
fill="none" stroke="#333" stroke-width="2"/>`
|
||||
}).join('')}
|
||||
})
|
||||
.join('')}
|
||||
|
||||
<!-- Calendar Day Abaci -->
|
||||
${calendarCells.map((day, index) => {
|
||||
if (day === null) return ''
|
||||
${calendarCells
|
||||
.map((day, index) => {
|
||||
if (day === null) return ''
|
||||
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const cellX = MARGIN + col * CELL_WIDTH
|
||||
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const cellX = MARGIN + col * CELL_WIDTH
|
||||
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
|
||||
// Center abacus in cell
|
||||
const abacusCenterX = cellX + CELL_WIDTH / 2
|
||||
const abacusCenterY = cellY + DAY_CELL_HEIGHT / 2
|
||||
// Center abacus in cell
|
||||
const abacusCenterX = cellX + CELL_WIDTH / 2
|
||||
const abacusCenterY = cellY + DAY_CELL_HEIGHT / 2
|
||||
|
||||
// Offset to top-left corner of abacus (accounting for scaled size)
|
||||
const abacusX = abacusCenterX - SCALED_ABACUS_WIDTH / 2
|
||||
const abacusY = abacusCenterY - SCALED_ABACUS_HEIGHT / 2
|
||||
// Offset to top-left corner of abacus (accounting for scaled size)
|
||||
const abacusX = abacusCenterX - SCALED_ABACUS_WIDTH / 2
|
||||
const abacusY = abacusCenterY - SCALED_ABACUS_HEIGHT / 2
|
||||
|
||||
// Render at scale=1 and let the nested SVG handle scaling via viewBox
|
||||
const abacusSVG = renderAbacusSVG(day, 2, 1)
|
||||
const svgContent = abacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
// Render at scale=1 and let the nested SVG handle scaling via viewBox
|
||||
const abacusSVG = renderAbacusSVG(day, 2, 1)
|
||||
const svgContent = abacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
|
||||
return `
|
||||
return `
|
||||
<!-- Day ${day} (row ${row}, col ${col}) -->
|
||||
<svg x="${abacusX}" y="${abacusY}" width="${SCALED_ABACUS_WIDTH}" height="${SCALED_ABACUS_HEIGHT}"
|
||||
viewBox="0 0 ${ABACUS_NATURAL_WIDTH} ${ABACUS_NATURAL_HEIGHT}">
|
||||
${svgContent}
|
||||
</svg>`
|
||||
}).join('')}
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
|
||||
return compositeSVG
|
||||
|
||||
206
apps/web/src/workers/openscad.worker.ts
Normal file
206
apps/web/src/workers/openscad.worker.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { createOpenSCAD } from 'openscad-wasm-prebuilt'
|
||||
|
||||
declare const self: DedicatedWorkerGlobalScope
|
||||
|
||||
let openscad: Awaited<ReturnType<typeof createOpenSCAD>> | null = null
|
||||
let simplifiedStlData: ArrayBuffer | null = null
|
||||
let isInitializing = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
// Message types
|
||||
interface RenderRequest {
|
||||
type: 'render'
|
||||
columns: number
|
||||
scaleFactor: number
|
||||
}
|
||||
|
||||
interface InitRequest {
|
||||
type: 'init'
|
||||
}
|
||||
|
||||
type WorkerRequest = RenderRequest | InitRequest
|
||||
|
||||
// Initialize OpenSCAD instance and load base STL file
|
||||
async function initialize() {
|
||||
if (openscad) return // Already initialized
|
||||
if (isInitializing) return initPromise // Already initializing, return existing promise
|
||||
|
||||
isInitializing = true
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
console.log('[OpenSCAD Worker] Initializing...')
|
||||
|
||||
// Create OpenSCAD instance
|
||||
openscad = await createOpenSCAD()
|
||||
console.log('[OpenSCAD Worker] OpenSCAD WASM loaded')
|
||||
|
||||
// Fetch the simplified STL file once
|
||||
const stlResponse = await fetch('/3d-models/simplified.abacus.stl')
|
||||
if (!stlResponse.ok) {
|
||||
throw new Error(`Failed to fetch STL: ${stlResponse.statusText}`)
|
||||
}
|
||||
simplifiedStlData = await stlResponse.arrayBuffer()
|
||||
console.log('[OpenSCAD Worker] Simplified STL loaded', simplifiedStlData.byteLength, 'bytes')
|
||||
|
||||
self.postMessage({ type: 'ready' })
|
||||
} catch (error) {
|
||||
console.error('[OpenSCAD Worker] Initialization failed:', error)
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Initialization failed',
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
isInitializing = false
|
||||
}
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
async function render(columns: number, scaleFactor: number) {
|
||||
// Wait for initialization if not ready
|
||||
if (!openscad || !simplifiedStlData) {
|
||||
await initialize()
|
||||
}
|
||||
|
||||
if (!openscad || !simplifiedStlData) {
|
||||
throw new Error('Worker not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[OpenSCAD Worker] Rendering with columns=${columns}, scaleFactor=${scaleFactor}`)
|
||||
|
||||
// Get low-level instance for filesystem access
|
||||
const instance = openscad.getInstance()
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
try {
|
||||
instance.FS.mkdir('/3d-models')
|
||||
console.log('[OpenSCAD Worker] Created /3d-models directory')
|
||||
} catch (e: any) {
|
||||
// Check if it's EEXIST (directory already exists) - errno 20
|
||||
if (e.errno === 20) {
|
||||
console.log('[OpenSCAD Worker] /3d-models directory already exists')
|
||||
} else {
|
||||
console.error('[OpenSCAD Worker] Failed to create directory:', e)
|
||||
throw new Error(`Failed to create /3d-models directory: ${e.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Write STL file
|
||||
instance.FS.writeFile('/3d-models/simplified.abacus.stl', new Uint8Array(simplifiedStlData))
|
||||
console.log('[OpenSCAD Worker] Wrote simplified STL to filesystem')
|
||||
|
||||
// Generate the SCAD code with parameters
|
||||
const scadCode = `
|
||||
// Inline version of abacus.scad that doesn't require BOSL2
|
||||
columns = ${columns};
|
||||
scale_factor = ${scaleFactor};
|
||||
|
||||
stl_path = "/3d-models/simplified.abacus.stl";
|
||||
|
||||
// Known bounding box dimensions
|
||||
bbox_size = [186, 60, 120];
|
||||
|
||||
// Calculate parameters
|
||||
total_columns_in_stl = 13;
|
||||
columns_per_side = columns / 2;
|
||||
width_scale = columns_per_side / total_columns_in_stl;
|
||||
|
||||
units_per_column = bbox_size[0] / total_columns_in_stl;
|
||||
column_spacing = columns_per_side * units_per_column;
|
||||
|
||||
// Model modules
|
||||
module imported() {
|
||||
import(stl_path, convexity = 10);
|
||||
}
|
||||
|
||||
module bounding_box_manual() {
|
||||
translate([-bbox_size[0]/2, -bbox_size[1]/2, -bbox_size[2]/2])
|
||||
cube(bbox_size);
|
||||
}
|
||||
|
||||
module half_abacus() {
|
||||
intersection() {
|
||||
scale([width_scale, 1, 1]) bounding_box_manual();
|
||||
imported();
|
||||
}
|
||||
}
|
||||
|
||||
scale([scale_factor, scale_factor, scale_factor]) {
|
||||
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
|
||||
half_abacus();
|
||||
}
|
||||
`
|
||||
|
||||
// Use high-level renderToStl API
|
||||
console.log('[OpenSCAD Worker] Calling renderToStl...')
|
||||
const stlBuffer = await openscad.renderToStl(scadCode)
|
||||
console.log('[OpenSCAD Worker] Rendering complete:', stlBuffer.byteLength, 'bytes')
|
||||
|
||||
// Send the result back
|
||||
self.postMessage({
|
||||
type: 'result',
|
||||
stl: stlBuffer,
|
||||
}, [stlBuffer]) // Transfer ownership of the buffer
|
||||
|
||||
// Clean up STL file
|
||||
try {
|
||||
instance.FS.unlink('/3d-models/simplified.abacus.stl')
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OpenSCAD Worker] Rendering failed:', error)
|
||||
|
||||
// Try to get more error details
|
||||
let errorMessage = 'Rendering failed'
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message
|
||||
console.error('[OpenSCAD Worker] Error stack:', error.stack)
|
||||
}
|
||||
|
||||
// Check if it's an Emscripten FS error
|
||||
if (error && typeof error === 'object' && 'errno' in error) {
|
||||
console.error('[OpenSCAD Worker] FS errno:', (error as any).errno)
|
||||
console.error('[OpenSCAD Worker] FS error details:', error)
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Message handler
|
||||
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||
const { data } = event
|
||||
|
||||
try {
|
||||
switch (data.type) {
|
||||
case 'init':
|
||||
await initialize()
|
||||
break
|
||||
|
||||
case 'render':
|
||||
await render(data.columns, data.scaleFactor)
|
||||
break
|
||||
|
||||
default:
|
||||
console.error('[OpenSCAD Worker] Unknown message type:', data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OpenSCAD Worker] Message handler error:', error)
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize on worker start
|
||||
initialize()
|
||||
@@ -17,7 +17,18 @@
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm --filter @soroban/web build:*)",
|
||||
"Bash(pnpm tsc:*)",
|
||||
"Bash(AbacusReact.tsx)"
|
||||
"Bash(AbacusReact.tsx)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(pnpm dev)",
|
||||
"Bash(npx biome:*)",
|
||||
"Bash(pnpm --filter @soroban/web tsc:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(gh api:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:schroer.ca)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(npm search:*)",
|
||||
"Bash(pnpm add:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
# [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.8.3...abacus-react-v2.9.0) (2025-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** upgrade OpenSCAD to 2024.11 to fix CGAL intersection bug ([e1bcd24](https://github.com/antialias/soroban-abacus-flashcards/commit/e1bcd241691050fa05cd49e14c288b4b070a7d17))
|
||||
* **guide:** increase abacus sizes - they were too small ([1074624](https://github.com/antialias/soroban-abacus-flashcards/commit/1074624b2fbce1d1d887dbd6326cf22eeb31dcec))
|
||||
* **guide:** make abacus sizes consistent and add nav spacing ([bea4842](https://github.com/antialias/soroban-abacus-flashcards/commit/bea4842a29aa86ca4261b4ddd6150bacc8babc46))
|
||||
* **guide:** remove inner containers and tighten margins ([7e54c6f](https://github.com/antialias/soroban-abacus-flashcards/commit/7e54c6f4fc5bc4daa6088eb3381d860a495776f2))
|
||||
* **layout:** add systematic spacing for fixed nav bar ([4559fb1](https://github.com/antialias/soroban-abacus-flashcards/commit/4559fb121d0df954ebaf33616a5262c7ca633c6e))
|
||||
* **layout:** remove wrapper, use utility class for nav spacing ([247c3d9](https://github.com/antialias/soroban-abacus-flashcards/commit/247c3d9874303f83641e599724a485eea8d5604a))
|
||||
* **nav:** restrict transparent hero styling to home page only ([fab227d](https://github.com/antialias/soroban-abacus-flashcards/commit/fab227d6862672e8250b1c169b302fbae23ce4d2))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **3d-abacus:** change default columns from 13 to 4 ([cd15c70](https://github.com/antialias/soroban-abacus-flashcards/commit/cd15c70a25c597c17ee5d2f816b1c85ba8ce4ce9))
|
||||
* add client-side OpenSCAD WASM support for 3D preview ([eaaf17c](https://github.com/antialias/soroban-abacus-flashcards/commit/eaaf17cd4c675bfd40e0573b9c99f0c733d926aa))
|
||||
|
||||
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.8.2...abacus-react-v2.8.3) (2025-11-05)
|
||||
|
||||
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -188,6 +188,9 @@ importers:
|
||||
next-intl:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0(next@14.2.33(@babel/core@7.28.4)(@playwright/test@1.56.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
|
||||
openscad-wasm-prebuilt:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
python-bridge:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
@@ -7484,6 +7487,9 @@ packages:
|
||||
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
openscad-wasm-prebuilt@1.2.0:
|
||||
resolution: {integrity: sha512-JfXJlopHVHvsYiVYUKYDXkg8yOkABm8zclo8zPrSRBeeICR+fitesO6NRwBdAzSdbtOYh6Scmuz/XLyhllbzYA==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -17737,6 +17743,8 @@ snapshots:
|
||||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openscad-wasm-prebuilt@1.2.0: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
||||
Reference in New Issue
Block a user