diff --git a/src/pages/tools/3d-to-svg.astro b/src/pages/tools/3d-to-svg.astro index 3424193..9a45d53 100644 --- a/src/pages/tools/3d-to-svg.astro +++ b/src/pages/tools/3d-to-svg.astro @@ -8,37 +8,41 @@ import { areaViewport, attribution, button, - buttonRow, canvas, checkboxRow, colorInput, copyButton, copyIconCopied, copyIconDefault, + dropError, dropZone, fieldRow, fieldset, + headButton, ids, intro, label, legend, - loadButton, loadingOverlay, modeField, modeGroup, modeOption, number, + paneBlock, poseGrid, preview, spinner, scanFrame, + sectionHead, + sectionHeading, slider, sliderRow, + snapRow, snippetBlock, - snippetHead, - snippetHeading, + snippetCode, + snippetFrame, snippetPre, - status, + snippetSpinnerHost, viewport } from './_3d-to-svg/studio.css'; @@ -83,40 +87,63 @@ const jsonLd = {
-
- - - -
- Drop a 3D model - .glb, .obj, or .stl - +
+
+

Model

+
-
-
+
+ + +
+ Drop a 3D model + .glb, .obj, or .stl +

+ +
+
+
+
+

-
+
+
+

SVG

+ +
+ +
Pose @@ -124,25 +151,24 @@ const jsonLd = {
- +
- +
- +
- - -
+
+
@@ -168,18 +194,12 @@ const jsonLd = {
- -
- -
-

-
-
-

Exported SVG

+
+

Exported SVG

-
+
+
+
+
+
+

diff --git a/src/pages/tools/_3d-to-svg/studio.css.ts b/src/pages/tools/_3d-to-svg/studio.css.ts index 66f099b..ef2c4f6 100644 --- a/src/pages/tools/_3d-to-svg/studio.css.ts +++ b/src/pages/tools/_3d-to-svg/studio.css.ts @@ -13,8 +13,8 @@ export const ids = { viewport: 'studio-viewport', canvas: 'studio-canvas', dropZone: 'studio-drop-zone', + dropError: 'studio-drop-error', fileInput: 'studio-file-input', - status: 'studio-status', preview: 'studio-preview', x: 'studio-x', y: 'studio-y', @@ -27,11 +27,14 @@ export const ids = { color: 'studio-color', mode: 'studio-mode', download: 'studio-download', + snippetFrame: 'studio-snippet-frame', snippet: 'studio-snippet', snippetInline: 'studio-snippet-inline', + snippetSpinner: 'studio-snippet-spinner', copyInline: 'studio-copy-inline', loading: 'studio-loading', - loadButton: 'studio-load' + loadButton: 'studio-load', + live: 'studio-live' } as const; /* @@ -58,11 +61,15 @@ export const poseGrid = style({ } }); -export const areaViewport = style({gridArea: 'viewport', alignSelf: 'start'}); -export const areaPreview = style({gridArea: 'preview', alignSelf: 'start'}); +export const areaViewport = style({gridArea: 'viewport', alignSelf: 'start', minWidth: 0}); +export const areaPreview = style({gridArea: 'preview', alignSelf: 'start', minWidth: 0}); export const areaPose = style({gridArea: 'pose'}); export const areaOutput = style({gridArea: 'output'}); +// Wraps a pane's header row plus the pane itself so the header sits above the box +// (outside the canvas) instead of floating inside it. +export const paneBlock = style({minWidth: 0}); + const pane = style({ boxSizing: 'border-box', position: 'relative', @@ -189,19 +196,20 @@ export const spinner = style({ } }); -export const status = style({ - marginTop: '0.75rem', - minHeight: '1.4em', +// Error line shown inside the drop zone when a model fails to load, so the reason +// appears in context beside the prompt. Empty by default, so it costs no layout. +export const dropError = style({ + margin: 0, fontSize: '0.85rem', - color: palette.textMuted, + color: palette.linkHover, '@media': { [media.dark]: { - color: palette.textMutedDark + color: palette.linkHoverDark } }, selectors: { - '&[data-error="true"]': { - color: palette.linkHover + '&:empty': { + display: 'none' } } }); @@ -222,8 +230,7 @@ export const preview = style([ } ]); -// Flex column so the trailing button row can be pinned to the card's bottom -// (the cards are stretched to equal height by the pose grid's row). +// Flex column; the pose grid stretches both cards to equal height. export const fieldset = style({ display: 'flex', flexDirection: 'column', @@ -311,13 +318,12 @@ export const modeOption = style({ cursor: 'pointer' }); -// `marginTop: auto` drops the row to the bottom of its flex-column card so Reset -// and Download sit at the cards' lower edge regardless of content height. -export const buttonRow = style({ +// Trailing Pose row: the snap toggle on the left, Reset pushed to the right edge. +export const snapRow = style({ display: 'flex', - flexWrap: 'wrap', - gap: '0.6rem', - marginTop: 'auto' + alignItems: 'center', + justifyContent: 'space-between', + gap: '0.75rem' }); export const button = style({ @@ -382,7 +388,9 @@ export const snippetBlock = style({ minWidth: 0 }); -export const snippetHead = style({ +// Header row shared by the Model/SVG panes and the exported-SVG block: a heading +// on the left and an icon button pushed to the right. +export const sectionHead = style({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -390,18 +398,38 @@ export const snippetHead = style({ marginBottom: '0.5rem' }); -export const snippetHeading = style({ +export const sectionHeading = style({ margin: 0, fontSize: '0.95rem', fontWeight: 600 }); +// Hosts the exported SVG snippet; the spinner overlays this box while loading. +export const snippetFrame = style({ + position: 'relative' +}); + +export const snippetSpinnerHost = style({ + position: 'absolute', + inset: 0, + zIndex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', + selectors: { + '&[hidden]': { + display: 'none' + } + } +}); + // Mirrors shiki's github-light/github-dark themes so this matches the block. export const snippetPre = style({ margin: 0, padding: '1.1em 1.25em', maxWidth: '100%', - maxHeight: '15rem', + height: '15rem', overflowY: 'auto', // The exported SVG is one long line. We wrap it instead of blowing out the width. whiteSpace: 'pre-wrap', @@ -419,23 +447,13 @@ export const snippetPre = style({ backgroundColor: '#24292e', borderColor: palette.borderDark } - }, + } +}); + +export const snippetCode = style({ selectors: { - // While a model loads, centre a spinner where the SVG markup will land. - '&[data-loading="true"]': { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - minHeight: '6rem' - }, - '&[data-loading="true"]::after': { - content: '""', - width: '2.25rem', - height: '2.25rem', - borderRadius: '50%', - border: `3px solid ${palette.border}`, - borderTopColor: palette.accent, - animation: `${spin} 0.8s linear infinite` + [`${snippetFrame}[data-loading="true"] &`]: { + visibility: 'hidden' } } }); @@ -485,32 +503,30 @@ export const copyButton = style({ } }); -// Tucked into the pane's top-right corner (inside the pane, outside the dashed -// scan frame). Borderless so it doesn't compete with the frame; the accent only -// shows on hover/focus. Rounded close to the pane's 12px so the corner nests. -export const loadButton = style({ - position: 'absolute', - top: '0.3rem', - right: '0.3rem', - zIndex: 3, +// Icon + text button that lives in a pane header (Upload on Model, Download on +// SVG). The icon is vertically centred with a gap to the label. +export const headButton = style({ display: 'inline-flex', alignItems: 'center', - justifyContent: 'center', - width: '2rem', - height: '2rem', - padding: 0, + gap: '0.4rem', + flexShrink: 0, + padding: '0.35rem 0.65rem', + fontFamily: fonts.sans, + fontSize: '0.85rem', + fontWeight: 600, color: palette.textMuted, - background: 'transparent', - border: '1px solid transparent', - borderRadius: '10px', + backgroundColor: palette.surface, + border: `1px solid ${palette.border}`, + borderRadius: '8px', cursor: 'pointer', '@media': { [media.dark]: { - color: palette.textMutedDark + color: palette.textMutedDark, + backgroundColor: palette.surfaceDark, + borderColor: palette.borderDark }, [media.coarsePointer]: { - width: touchTargetMin, - height: touchTargetMin + minHeight: touchTargetMin } }, selectors: { diff --git a/src/pages/tools/_3d-to-svg/studio.ts b/src/pages/tools/_3d-to-svg/studio.ts index 8ce6305..db9d5bb 100644 --- a/src/pages/tools/_3d-to-svg/studio.ts +++ b/src/pages/tools/_3d-to-svg/studio.ts @@ -69,7 +69,8 @@ function initStudio(): void { const fileInputEl = requireElement(ids.fileInput); const loadingEl = requireElement(ids.loading); const loadButtonEl = requireElement(ids.loadButton); - const statusEl = requireElement(ids.status); + const dropErrorEl = requireElement(ids.dropError); + const liveEl = requireElement(ids.live); const previewEl = requireElement(ids.preview); const xRange = requireElement(ids.x); @@ -86,7 +87,8 @@ function initStudio(): void { const modeGroup = requireElement(ids.mode); const downloadButton = requireElement(ids.download); - const snippetPre = requireElement(ids.snippet); + const snippetFrameEl = requireElement(ids.snippetFrame); + const snippetSpinnerEl = requireElement(ids.snippetSpinner); const snippetInline = requireElement(ids.snippetInline); const copyInlineButton = requireElement(ids.copyInline); @@ -182,18 +184,27 @@ function initStudio(): void { loadingEl.dataset.hidden = 'false'; currentSvg = ''; previewEl.innerHTML = `

`; - snippetInline.textContent = ''; - snippetPre.dataset.loading = 'true'; - setStatus('', false); + snippetFrameEl.dataset.loading = 'true'; + snippetSpinnerEl.hidden = false; + // Fresh load: drop any prior error, and announce loading to screen readers + // only (sighted users have the spinner) via the visually-hidden live region. + dropErrorEl.textContent = ''; + liveEl.textContent = 'Loading model…'; } function hideLoading(): void { loadingEl.dataset.hidden = 'true'; } - /** On failure, surface the message and reveal the drop prompt so the user can retry. */ + /** + * On failure, show the reason inside the drop prompt (revealed for retry) and + * announce it through the live region. The loading announcement is replaced, so + * `hideLoading` leaves it intact. + */ function onLoadError(error: unknown): void { - setStatus(messageFrom(error), true); + const message = messageFrom(error); + dropErrorEl.textContent = message; + liveEl.textContent = message; dropZoneEl.dataset.hidden = 'false'; } @@ -209,7 +220,8 @@ function initStudio(): void { mountViewport(threeModule, geometryModule.CAMPOS); rebuildDisplay(threeModule); dropZoneEl.dataset.hidden = 'true'; - setStatus('', false); + // Clear the loading announcement now that the model is mounted. + liveEl.textContent = ''; // Every freshly loaded model starts at the default pose, then drifts. orientation = orientationFromMat3(threeModule, geometryModule.LOCKED_ORIENTATION); syncControls(); @@ -356,7 +368,13 @@ function initStudio(): void { isWorkerBusy = false; const reply = event.data; if (reply.type === 'error') { - setStatus(reply.message, true); + // SVG generation failed with a model still loaded, so show the reason where + // the icon would render (preview is aria-hidden. The live region carries it + // to screen readers) and stop the snippet spinner. + previewEl.textContent = reply.message; + snippetFrameEl.dataset.loading = 'false'; + snippetSpinnerEl.hidden = true; + liveEl.textContent = reply.message; } else if (reply.id === latestRequestId) { applySvg(reply.svg); } @@ -477,8 +495,9 @@ function initStudio(): void { } function updateSnippets(): void { - snippetPre.dataset.loading = 'false'; snippetInline.innerHTML = highlightSvg(currentSvg.trim()); + snippetFrameEl.dataset.loading = 'false'; + snippetSpinnerEl.hidden = true; } /* @@ -650,8 +669,6 @@ function initStudio(): void { viewportEl.addEventListener('pointercancel', onPointerUp); dropZoneEl.addEventListener('click', () => fileInputEl.click()); - // Sits over the viewport, so keep its click from also starting an arcball drag. - loadButtonEl.addEventListener('pointerdown', event => event.stopPropagation()); loadButtonEl.addEventListener('click', () => fileInputEl.click()); fileInputEl.addEventListener('change', () => { const file = fileInputEl.files?.[0]; @@ -707,11 +724,6 @@ function initStudio(): void { anchor.click(); URL.revokeObjectURL(url); } - - function setStatus(message: string, isError: boolean): void { - statusEl.textContent = message; - statusEl.dataset.error = String(isError); - } } /*