From 1babafb82f31e357e1780732dd02f7f2d238b37b Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:50:55 +0200 Subject: [PATCH] feat(minerals): interactive 3D crystal view + fix runaway image loop + gem-page polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 — killed a runaway 404 image-retry loop (missing /crystals/placeholder.svg + unguarded onerror in 3 components caused 26k-57k failed requests / hung pages; guarded onerror, added a real placeholder, backfilled 8 model-less expression SVGs, taught check-svgs to validate expression ids); wired the interactive Crystal3DViewer into the mineral hero as SVG-default + SVG↔3D toggle (new MineralCrystalViewer island, WebGL-gated, reduced-motion aware, lazy glTF parse, SVG fallback for the 8 model-less stones) — and fixed the underlying data bug where getMineralWithModels read model_gltf from the empty base `minerals` table instead of the populated mineral_expressions, so the 3D data now actually reaches the page; sidebar counterpart/related names wrap to 2 lines instead of mid-word truncation; consolidated the 5 tag-list cards into one Characteristics card; CDL notation wraps instead of overflowing; de-duplicated PropertyTable onto the shared ui/Table component with semantic dl/dt/dd markup (which also fixed a latent bug that silently dropped legitimately-zero birefringence/dispersion rows); labelled the chemistry formula; hardened the nested-parens family-name regex; added aria-pressed to the shared ViewerToggle; removed the dead prerender npm script. 507 tests, browser-verified in both themes (3D toggle renders, P0 loop gone). --- package.json | 1 - .../garnet-topped-doublet-doublet.svg | 6 + public/crystals/glass-simulant-faceted.svg | 6 + public/crystals/opal-doublet-cabochon.svg | 6 + public/crystals/opal-triplet-cabochon.svg | 6 + public/crystals/placeholder.svg | 6 + public/crystals/soude-emerald-doublet.svg | 6 + public/crystals/synthetic-coral-cabochon.svg | 6 + .../synthetic-lapis-gilson-cabochon.svg | 6 + .../synthetic-opal-gilson-cabochon.svg | 11 ++ scripts/audit-crystal-svgs.mjs | 40 ++++- scripts/generate-missing-crystals.mjs | 121 +++++++++++++++ src/components/crystal/ViewerToggle.tsx | 4 + .../minerals/CounterpartsSection.astro | 14 +- .../minerals/CrystalStructureCard.astro | 2 +- .../minerals/MineralCrystalViewer.test.tsx | 86 +++++++++++ .../minerals/MineralCrystalViewer.tsx | 146 ++++++++++++++++++ src/components/minerals/MineralHero.astro | 31 ++-- src/components/minerals/MineralTagsCard.astro | 69 +++++++++ src/components/minerals/PropertyTable.astro | 36 ----- src/components/minerals/RelatedMinerals.astro | 6 +- src/components/minerals/TagList.astro | 27 +++- src/components/ui/Table.tsx | 10 +- src/lib/db-server.ts | 17 +- src/pages/minerals/[slug].astro | 12 +- 25 files changed, 581 insertions(+), 100 deletions(-) create mode 100644 public/crystals/garnet-topped-doublet-doublet.svg create mode 100644 public/crystals/glass-simulant-faceted.svg create mode 100644 public/crystals/opal-doublet-cabochon.svg create mode 100644 public/crystals/opal-triplet-cabochon.svg create mode 100644 public/crystals/placeholder.svg create mode 100644 public/crystals/soude-emerald-doublet.svg create mode 100644 public/crystals/synthetic-coral-cabochon.svg create mode 100644 public/crystals/synthetic-lapis-gilson-cabochon.svg create mode 100644 public/crystals/synthetic-opal-gilson-cabochon.svg create mode 100644 scripts/generate-missing-crystals.mjs create mode 100644 src/components/minerals/MineralCrystalViewer.test.tsx create mode 100644 src/components/minerals/MineralCrystalViewer.tsx create mode 100644 src/components/minerals/MineralTagsCard.astro delete mode 100644 src/components/minerals/PropertyTable.astro diff --git a/package.json b/package.json index fdae88c..5eb698e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "build": "astro build", "preview": "astro preview", "copy-db": "node scripts/copy-database.js", - "prerender": "node scripts/prerender-crystals.js", "check-svgs": "node scripts/audit-crystal-svgs.mjs", "prebuild": "npm run sync && npm run copy-db && npm run check-svgs", "generate-og": "npm run build && node scripts/generate-og-image.js", diff --git a/public/crystals/garnet-topped-doublet-doublet.svg b/public/crystals/garnet-topped-doublet-doublet.svg new file mode 100644 index 0000000..445a23c --- /dev/null +++ b/public/crystals/garnet-topped-doublet-doublet.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/crystals/glass-simulant-faceted.svg b/public/crystals/glass-simulant-faceted.svg new file mode 100644 index 0000000..2889122 --- /dev/null +++ b/public/crystals/glass-simulant-faceted.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/crystals/opal-doublet-cabochon.svg b/public/crystals/opal-doublet-cabochon.svg new file mode 100644 index 0000000..445a23c --- /dev/null +++ b/public/crystals/opal-doublet-cabochon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/crystals/opal-triplet-cabochon.svg b/public/crystals/opal-triplet-cabochon.svg new file mode 100644 index 0000000..445a23c --- /dev/null +++ b/public/crystals/opal-triplet-cabochon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/crystals/placeholder.svg b/public/crystals/placeholder.svg new file mode 100644 index 0000000..d5931ce --- /dev/null +++ b/public/crystals/placeholder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/crystals/soude-emerald-doublet.svg b/public/crystals/soude-emerald-doublet.svg new file mode 100644 index 0000000..445a23c --- /dev/null +++ b/public/crystals/soude-emerald-doublet.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/crystals/synthetic-coral-cabochon.svg b/public/crystals/synthetic-coral-cabochon.svg new file mode 100644 index 0000000..2889122 --- /dev/null +++ b/public/crystals/synthetic-coral-cabochon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/crystals/synthetic-lapis-gilson-cabochon.svg b/public/crystals/synthetic-lapis-gilson-cabochon.svg new file mode 100644 index 0000000..2889122 --- /dev/null +++ b/public/crystals/synthetic-lapis-gilson-cabochon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/crystals/synthetic-opal-gilson-cabochon.svg b/public/crystals/synthetic-opal-gilson-cabochon.svg new file mode 100644 index 0000000..cb362eb --- /dev/null +++ b/public/crystals/synthetic-opal-gilson-cabochon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/audit-crystal-svgs.mjs b/scripts/audit-crystal-svgs.mjs index 9b0e143..96bab5f 100644 --- a/scripts/audit-crystal-svgs.mjs +++ b/scripts/audit-crystal-svgs.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node /** - * Verify that every mineral_families row has a corresponding SVG in public/crystals/. + * Verify that every mineral_families row AND every mineral_expressions row + * has a corresponding SVG — either a pre-generated `model_svg` in the + * database (expressions only) or a file in public/crystals/. * Exits non-zero if any are missing. */ import { existsSync, readFileSync } from 'node:fs'; @@ -20,16 +22,38 @@ if (!existsSync(DB_PATH)) { const SQL = await initSqlJs(); const db = new SQL.Database(readFileSync(DB_PATH)); -const result = db.exec('SELECT id FROM mineral_families ORDER BY id'); -const ids = result[0].values.map((row) => row[0]); -const missing = ids.filter((id) => !existsSync(join(CRYSTALS_DIR, `${id}.svg`))); +// 1. Family-level check (existing behaviour). +const familyResult = db.exec('SELECT id FROM mineral_families ORDER BY id'); +const familyIds = familyResult.length ? familyResult[0].values.map((row) => row[0]) : []; +const missingFamilies = familyIds.filter((id) => !existsSync(join(CRYSTALS_DIR, `${id}.svg`))); -if (missing.length === 0) { - console.log(`OK: all ${ids.length} mineral families have SVGs in public/crystals/`); +// 2. Expression-level check: a gap only counts if there is NEITHER an inline +// `model_svg` in the database NOR a file in public/crystals/. +const expressionResult = db.exec('SELECT id, model_svg FROM mineral_expressions ORDER BY id'); +const expressionRows = expressionResult.length ? expressionResult[0].values : []; +const missingExpressions = expressionRows + .filter(([, modelSvg]) => !(typeof modelSvg === 'string' && modelSvg.trim().length > 0)) + .map(([id]) => id) + .filter((id) => !existsSync(join(CRYSTALS_DIR, `${id}.svg`))); + +if (missingFamilies.length === 0 && missingExpressions.length === 0) { + console.log( + `OK: all ${familyIds.length} mineral families and ${expressionRows.length} mineral expressions have SVGs (file or model_svg) available` + ); process.exit(0); } -console.error(`MISSING ${missing.length}/${ids.length} SVGs:`); -for (const id of missing) console.error(` - ${id}`); +if (missingFamilies.length > 0) { + console.error(`MISSING ${missingFamilies.length}/${familyIds.length} family SVGs:`); + for (const id of missingFamilies) console.error(` - ${id}`); +} + +if (missingExpressions.length > 0) { + console.error( + `MISSING ${missingExpressions.length}/${expressionRows.length} expression SVGs (no model_svg and no file):` + ); + for (const id of missingExpressions) console.error(` - ${id}`); +} + process.exit(1); diff --git a/scripts/generate-missing-crystals.mjs b/scripts/generate-missing-crystals.mjs new file mode 100644 index 0000000..5117239 --- /dev/null +++ b/scripts/generate-missing-crystals.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * Generate placeholder SVGs for any mineral_expressions row that has neither + * a pre-generated `model_svg` in the database nor an existing file in + * public/crystals/. + * + * Strategy (in priority order), per missing expression: + * 1. Skip if public/crystals/{expression_id}.svg already exists (idempotent). + * 2. Copy the family-level placeholder public/crystals/{family_id}.svg if + * it exists — this is the common case, since most expression-level + * gaps are for materials whose family already has a generic outline. + * 3. Otherwise, emit an origin-appropriate template (composite/simulant/ + * default) so the page never 404s on a broken reference. + * + * Usage: node scripts/generate-missing-crystals.mjs + */ +import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import initSqlJs from 'sql.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const CRYSTALS_DIR = join(ROOT, 'public', 'crystals'); + +const require = createRequire(import.meta.url); + +function resolveDbPath() { + try { + const mineralData = require('@gemmology/mineral-data'); + if (mineralData?.dbPath && existsSync(mineralData.dbPath)) { + return mineralData.dbPath; + } + } catch { + // fall through to direct node_modules path + } + const fallback = join(ROOT, 'node_modules', '@gemmology', 'mineral-data', 'minerals.db'); + if (existsSync(fallback)) return fallback; + throw new Error( + `Could not locate @gemmology/mineral-data database. Run npm install first.` + ); +} + +// Origin-appropriate templates, mirroring the existing family-level placeholder +// conventions in public/crystals/ (viewBox 0 0 200 200, width/height 568.8pt). +const TEMPLATES = { + // Doublets/triplets/assembled stones: gray ellipse ("cabochon" silhouette), + // matching e.g. public/crystals/opal-doublet.svg, garnet-topped-doublet.svg. + composite: ` + + + + + +`, + // Simulants (glass, gilson-process, etc.): pale-blue faceted outline, + // matching e.g. public/crystals/glass-simulant.svg, synthetic-coral.svg. + simulant: ` + + + + + +`, + // Last-resort neutral-gray fallback (same content as public/crystals/placeholder.svg). + default: ` + + + + + +`, +}; + +const DB_PATH = resolveDbPath(); +const SQL = await initSqlJs(); +const db = new SQL.Database(readFileSync(DB_PATH)); + +const expressionsResult = db.exec( + "SELECT id, family_id, model_svg FROM mineral_expressions ORDER BY id" +); +const expressions = expressionsResult.length + ? expressionsResult[0].values.map(([id, family_id, model_svg]) => ({ id, family_id, model_svg })) + : []; + +const familyOriginResult = db.exec('SELECT id, origin FROM mineral_families'); +const originByFamily = new Map( + familyOriginResult.length ? familyOriginResult[0].values.map(([id, origin]) => [id, origin]) : [] +); + +let written = 0; +let skipped = 0; + +for (const expr of expressions) { + const hasModelSvg = typeof expr.model_svg === 'string' && expr.model_svg.trim().length > 0; + if (hasModelSvg) continue; + + const targetPath = join(CRYSTALS_DIR, `${expr.id}.svg`); + if (existsSync(targetPath)) { + skipped++; + continue; + } + + const familySvgPath = join(CRYSTALS_DIR, `${expr.family_id}.svg`); + if (existsSync(familySvgPath)) { + copyFileSync(familySvgPath, targetPath); + console.log(`wrote ${expr.id}.svg (copied from family placeholder ${expr.family_id}.svg)`); + written++; + continue; + } + + const origin = originByFamily.get(expr.family_id); + const template = TEMPLATES[origin] ?? TEMPLATES.default; + const templateName = TEMPLATES[origin] ? origin : 'default'; + writeFileSync(targetPath, template); + console.log(`wrote ${expr.id}.svg (${templateName} template, origin=${origin ?? 'unknown'})`); + written++; +} + +console.log(`\nDone. Wrote ${written} new SVG(s), skipped ${skipped} already-present file(s).`); diff --git a/src/components/crystal/ViewerToggle.tsx b/src/components/crystal/ViewerToggle.tsx index af0b5b0..9393aff 100644 --- a/src/components/crystal/ViewerToggle.tsx +++ b/src/components/crystal/ViewerToggle.tsx @@ -22,6 +22,8 @@ export function ViewerToggle({