Implement M2 deterministic botanical SVG renderer and visual studies
16
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Plant Laboratory / Botanica
|
||||
|
||||
M0 provides a running React shell, Fastify API, PostgreSQL schema and repeatable bootstrap. Genetics and game workflows follow in M1–M4.
|
||||
M0–M2 provide a running React laboratory, deterministic genetics, illustrated SVG renderer, Fastify API, PostgreSQL schema and repeatable bootstrap. Persistent plant creation and breeding follow in M3.
|
||||
|
||||
## Run
|
||||
|
||||
|
|
@ -34,8 +34,18 @@ docker compose run --rm bootstrap node dist/migrate.js
|
|||
docker compose run --rm bootstrap node dist/seed.js
|
||||
```
|
||||
|
||||
Migrations are checked-in SQL tracked in `schema_migrations`; seeds never overwrite existing records. `.env` is ignored by Git and Docker build contexts. Production containers run as non-root. The API returns safe uniform errors and health responds 503 if PostgreSQL is unavailable. Relative browser API URLs avoid container hostname leakage. `PUBLIC_BASE_URL` is validated and derived from origin plus port for localhost, or just origin behind a production reverse proxy.
|
||||
Migrations are checked-in SQL tracked in `schema_migrations`; player seeds preserve existing records; the catalog seed synchronizes the current catalog definition without changing plants or ancestry. `.env` is ignored by Git and Docker build contexts. Production containers run as non-root. The API returns safe uniform errors and health responds 503 if PostgreSQL is unavailable. Relative browser API URLs avoid container hostname leakage. `PUBLIC_BASE_URL` is validated and derived from origin plus port for localhost, or just origin behind a production reverse proxy.
|
||||
|
||||
Routes: `/` redirects to `/lab`; `/lab`, `/breed`, `/gallery`, `/about` currently show clearly labeled milestone empty states. Original shell illustration is decorative; the deterministic species renderer is M2. No authentication: this sandbox is intended for local use.
|
||||
Routes: `/` redirects to `/lab`, which contains curated rendering studies, four stages, cosmetic seed and manual golden-vein preview, derived trait inspection, and SVG/PNG export with warm or transparent background. `/gallery` is a proof atlas of 12 systematic variants plus a 100-seed rendering demo. `/breed` remains a clearly labeled M3 empty state; `/about` explains the fictional genetics model. These studies are not saved plants. No authentication: this sandbox is intended for local use.
|
||||
|
||||
Read [the implementation plan](docs/IMPLEMENTATION_PLAN.md) and [progress log](docs/PROGRESS.md) for acceptance gates.
|
||||
|
||||
|
||||
## Domain and artwork
|
||||
|
||||
Genetics v1 uses fixed-locus diploid genomes, explicit dominance, seeded Mulberry32 inheritance, mutation logs and derived rarity. The catalog and phenotype preview APIs are available under `/api/v1`; see `docs/API_AND_DATA.md` for request examples. Pure packages use built JS exports; root build/typecheck/test/dev scripts first build package dependencies in order.
|
||||
|
||||
Velvetleaf is composed from 15 original handcrafted SVG primitives and six curated palettes. `npm run assets:write` regenerates standalone primitive sources from the typed paths; tests reject source drift. `assets/README.md` documents CC0 artwork provenance. Geometry is deterministic and all exported assets are self-contained.
|
||||
|
||||
For optional browser acceptance QA, install/provide Playwright and Chrome, then run `node scripts/verify-m2.cjs` against the running Compose app. `PLAYWRIGHT_MODULE` may point to an available Playwright package and `BROWSER_EXECUTABLE` to a browser binary. The script checks desktop/mobile overflow, 12/100 rendering studies, namespace collisions, seed invariance, real downloads, PNG alpha/background and pixel equivalence to the live preview. Screenshots and measurements are written to `artifacts/m2`.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
"drizzle-orm": "^0.45.4",
|
||||
"pg": "^8.16.3",
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"@botanica/shared": "0.0.0"
|
||||
"@botanica/shared": "0.0.0",
|
||||
"@botanica/renderer": "0.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.5",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { registerPreviewRoute } from "./preview-route.js";
|
||||
import { ZodError } from "zod";
|
||||
import { GeneticsError } from "@botanica/genetics";
|
||||
import { registerGeneticsRoutes } from "./genetics-routes.js";
|
||||
|
|
@ -21,6 +22,7 @@ export function createApp(
|
|||
}
|
||||
});
|
||||
registerGeneticsRoutes(app);
|
||||
registerPreviewRoute(app);
|
||||
app.setNotFoundHandler((_request, reply) =>
|
||||
reply
|
||||
.code(404)
|
||||
|
|
@ -28,9 +30,7 @@ export function createApp(
|
|||
);
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof ZodError)
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Request validation failed.",
|
||||
|
|
|
|||
41
apps/server/src/preview-route.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { it, expect } from "vitest";
|
||||
import { exampleGenome, resolvePhenotype } from "@botanica/genetics";
|
||||
import { layoutPlant, renderSvg } from "@botanica/renderer";
|
||||
import { createApp } from "./app.js";
|
||||
it("returns the same safe serialized SVG as the client renderer and rejects invalid preview inputs", async () => {
|
||||
const app = createApp(async () => {});
|
||||
try {
|
||||
const payload = {
|
||||
speciesId: "velvetleaf",
|
||||
genome: exampleGenome,
|
||||
visualSeed: 91,
|
||||
growthStage: "flowering",
|
||||
background: "warm",
|
||||
};
|
||||
const result = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/preview",
|
||||
payload,
|
||||
});
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.json().svg).toBe(
|
||||
renderSvg(
|
||||
layoutPlant(
|
||||
"velvetleaf",
|
||||
resolvePhenotype("velvetleaf", exampleGenome),
|
||||
91,
|
||||
"flowering",
|
||||
),
|
||||
{ namespace: "preview", background: "warm" },
|
||||
),
|
||||
);
|
||||
const bad = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/preview",
|
||||
payload: { ...payload, visualSeed: "<script>" },
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
35
apps/server/src/preview-route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from "zod";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { resolvePhenotype } from "@botanica/genetics";
|
||||
import { resolveRequestSchema } from "@botanica/shared";
|
||||
import { layoutPlant, renderSvg, stages } from "@botanica/renderer";
|
||||
const previewSchema = resolveRequestSchema.extend({
|
||||
visualSeed: z.number().int().min(-2147483648).max(2147483647),
|
||||
growthStage: z.enum(stages).default("mature"),
|
||||
rendererVersion: z.literal(1).default(1),
|
||||
background: z.enum(["transparent", "warm"]).default("transparent"),
|
||||
});
|
||||
export function registerPreviewRoute(app: FastifyInstance) {
|
||||
app.post("/api/v1/preview", async (request) => {
|
||||
const input = previewSchema.parse(request.body),
|
||||
phenotype = resolvePhenotype(
|
||||
input.speciesId,
|
||||
input.genome,
|
||||
input.mutations,
|
||||
input.catalogVersion,
|
||||
);
|
||||
return {
|
||||
rendererVersion: 1,
|
||||
svg: renderSvg(
|
||||
layoutPlant(
|
||||
input.speciesId,
|
||||
phenotype,
|
||||
input.visualSeed,
|
||||
input.growthStage,
|
||||
input.rendererVersion,
|
||||
),
|
||||
{ namespace: "preview", background: input.background },
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -17,7 +17,10 @@
|
|||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^0.577.0"
|
||||
"lucide-react": "^0.577.0",
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"@botanica/renderer": "0.0.0",
|
||||
"@botanica/shared": "0.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.3.5",
|
||||
|
|
|
|||
43
apps/web/src/components/PlantPreview.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { memo, useId, useMemo } from "react";
|
||||
import {
|
||||
layoutPlant,
|
||||
renderSvg,
|
||||
type GrowthStage,
|
||||
type Background,
|
||||
} from "@botanica/renderer";
|
||||
import type { Phenotype } from "@botanica/genetics";
|
||||
export interface PlantPreviewProps {
|
||||
phenotype: Phenotype;
|
||||
visualSeed: number;
|
||||
stage: GrowthStage;
|
||||
background?: Background;
|
||||
size?: 64 | 128 | 512;
|
||||
label?: string;
|
||||
}
|
||||
export const PlantPreview = memo(function PlantPreview({
|
||||
phenotype,
|
||||
visualSeed,
|
||||
stage,
|
||||
background = "transparent",
|
||||
size = 512,
|
||||
label = "Velvetleaf plant",
|
||||
}: PlantPreviewProps) {
|
||||
const id = "plant" + useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const svg = useMemo(
|
||||
() =>
|
||||
renderSvg(layoutPlant("velvetleaf", phenotype, visualSeed, stage), {
|
||||
namespace: id,
|
||||
background,
|
||||
}),
|
||||
[phenotype, visualSeed, stage, id, background],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={"plant-preview size-" + size}
|
||||
style={{ width: size, maxWidth: "100%" }}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
61
apps/web/src/lib/exportPlant.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
layoutPlant,
|
||||
renderSvg,
|
||||
type Background,
|
||||
type GrowthStage,
|
||||
} from "@botanica/renderer";
|
||||
import type { Phenotype } from "@botanica/genetics";
|
||||
function download(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob),
|
||||
anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
export async function exportPlant(
|
||||
format: "svg" | "png",
|
||||
phenotype: Phenotype,
|
||||
visualSeed: number,
|
||||
stage: GrowthStage,
|
||||
background: Background,
|
||||
) {
|
||||
const svg = renderSvg(
|
||||
layoutPlant("velvetleaf", phenotype, visualSeed, stage),
|
||||
{ namespace: "export", background },
|
||||
),
|
||||
blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }),
|
||||
filename = "velvetleaf-" + stage + "-" + visualSeed;
|
||||
if (format === "svg") {
|
||||
download(blob, filename + ".svg");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () =>
|
||||
reject(new Error("Could not load the SVG for PNG export."));
|
||||
image.src = url;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("PNG export is unavailable in this browser.");
|
||||
context.drawImage(image, 0, 0, 512, 512);
|
||||
const png = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(result) =>
|
||||
result ? resolve(result) : reject(new Error("Could not create PNG.")),
|
||||
"image/png",
|
||||
),
|
||||
);
|
||||
download(png, filename + ".png");
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
65
apps/web/src/lib/studies.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
exampleGenome,
|
||||
resolvePhenotype,
|
||||
type Genome,
|
||||
} from "@botanica/genetics";
|
||||
export const studies = (["O", "L", "S"] as const).flatMap((shape) =>
|
||||
(["G", "P"] as const).flatMap((pigment) =>
|
||||
(["S", "V"] as const).map((pattern) => {
|
||||
const genome: Genome = {
|
||||
...exampleGenome,
|
||||
leafShape: [shape, shape],
|
||||
pigment: [pigment, pigment],
|
||||
leafPattern: [pattern, pattern],
|
||||
};
|
||||
const phenotype = resolvePhenotype("velvetleaf", genome);
|
||||
return {
|
||||
id: shape + pigment + pattern,
|
||||
name: [
|
||||
phenotype.leafShape,
|
||||
phenotype.pigment,
|
||||
phenotype.leafPattern,
|
||||
].join(" · "),
|
||||
genome,
|
||||
phenotype,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
export const smokeStudies = Array.from({ length: 100 }, (_, i) => {
|
||||
const sample = studies[i % studies.length];
|
||||
const genome: Genome = {
|
||||
...sample.genome,
|
||||
pigment: (
|
||||
[
|
||||
["G", "G"],
|
||||
["P", "P"],
|
||||
["R", "R"],
|
||||
["Y", "Y"],
|
||||
] as const
|
||||
)[i % 4] as Genome["pigment"],
|
||||
leafPattern: (
|
||||
[
|
||||
["S", "S"],
|
||||
["V", "V"],
|
||||
["M", "M"],
|
||||
] as const
|
||||
)[i % 3] as Genome["leafPattern"],
|
||||
growthHabit: (
|
||||
[
|
||||
["B", "B"],
|
||||
["C", "C"],
|
||||
["T", "T"],
|
||||
] as const
|
||||
)[Math.floor(i / 3) % 3] as Genome["growthHabit"],
|
||||
};
|
||||
return {
|
||||
id: i,
|
||||
phenotype: resolvePhenotype(
|
||||
"velvetleaf",
|
||||
genome,
|
||||
i % 11 === 0 ? ["golden_veins"] : [],
|
||||
),
|
||||
visualSeed: i * 7919,
|
||||
};
|
||||
});
|
||||
18
apps/web/src/lib/studyStore.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { create } from "zustand";
|
||||
import type { Background, GrowthStage } from "@botanica/renderer";
|
||||
interface StudyState {
|
||||
studyId: string;
|
||||
stage: GrowthStage;
|
||||
visualSeed: number;
|
||||
goldenVeins: boolean;
|
||||
background: Background;
|
||||
set: (value: Partial<Omit<StudyState, "set">>) => void;
|
||||
}
|
||||
export const useStudy = create<StudyState>((set) => ({
|
||||
studyId: "LGS",
|
||||
stage: "flowering",
|
||||
visualSeed: 8155,
|
||||
goldenVeins: false,
|
||||
background: "warm",
|
||||
set: (value) => set(value),
|
||||
}));
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { RenderLab } from "./pages/RenderLab";
|
||||
import { RenderGallery } from "./pages/RenderGallery";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router";
|
||||
|
|
@ -185,7 +187,7 @@ function Page({ page }: { page: keyof typeof pages }) {
|
|||
{page === "lab" ? "The workbench" : p.label}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="badge">M0 · Foundations</span>
|
||||
<span className="badge">M2 · Foundations</span>
|
||||
</div>
|
||||
<div className="empty">
|
||||
<span className="icon">
|
||||
|
|
@ -233,7 +235,15 @@ function App() {
|
|||
<Route
|
||||
key={key}
|
||||
path={`/${key}`}
|
||||
element={<Page page={key as keyof typeof pages} />}
|
||||
element={
|
||||
key === "lab" ? (
|
||||
<RenderLab />
|
||||
) : key === "gallery" ? (
|
||||
<RenderGallery />
|
||||
) : (
|
||||
<Page page={key as keyof typeof pages} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
|
|
|
|||
83
apps/web/src/pages/RenderGallery.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { useState, useTransition } from "react";
|
||||
import { PlantPreview } from "../components/PlantPreview";
|
||||
import { studies, smokeStudies } from "../lib/studies";
|
||||
export function RenderGallery() {
|
||||
const [smoke, setSmoke] = useState(false),
|
||||
[pending, startTransition] = useTransition();
|
||||
return (
|
||||
<>
|
||||
<section className="lab-heading">
|
||||
<div>
|
||||
<p className="eyebrow">The velvetleaf field atlas</p>
|
||||
<h1>Variations on a leaf.</h1>
|
||||
<p>
|
||||
Twelve controlled studies: three silhouettes, two pigments, two
|
||||
patterns. These are render proofs, not saved plants.
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge">Renderer v1</span>
|
||||
</section>
|
||||
<section className="proof-toolbar">
|
||||
<h2>{smoke ? "100-seed render study" : "The systematic twelve"}</h2>
|
||||
<button
|
||||
aria-pressed={smoke}
|
||||
onClick={() => startTransition(() => setSmoke(!smoke))}
|
||||
>
|
||||
{smoke ? "Show 12 studies" : "Show 100-seed study"}
|
||||
</button>
|
||||
<span role="status">
|
||||
{pending
|
||||
? "Rendering…"
|
||||
: smoke
|
||||
? "100 deterministic plants"
|
||||
: "12 systematic variations"}
|
||||
</span>
|
||||
</section>
|
||||
<div className={"proof-grid" + (smoke ? " smoke-grid" : "")}>
|
||||
{smoke
|
||||
? smokeStudies.map((s) => (
|
||||
<article className="plant-card" key={s.id}>
|
||||
<PlantPreview
|
||||
phenotype={s.phenotype}
|
||||
visualSeed={s.visualSeed}
|
||||
stage="flowering"
|
||||
size={128}
|
||||
/>
|
||||
<h3>Study {String(s.id + 1).padStart(3, "0")}</h3>
|
||||
<p>
|
||||
{s.phenotype.pigment} · {s.phenotype.leafPattern}
|
||||
</p>
|
||||
<small>Seed {s.visualSeed}</small>
|
||||
</article>
|
||||
))
|
||||
: studies.map((s, i) => (
|
||||
<article className="plant-card" key={s.id}>
|
||||
<div className="plate-number">
|
||||
{String(i + 1).padStart(2, "0")} / VELVETLEAF
|
||||
</div>
|
||||
<PlantPreview
|
||||
phenotype={s.phenotype}
|
||||
visualSeed={8155}
|
||||
stage="mature"
|
||||
size={512}
|
||||
/>
|
||||
<h3>{s.phenotype.leafShape} velvetleaf</h3>
|
||||
<p>
|
||||
{s.phenotype.pigment} · {s.phenotype.leafPattern}
|
||||
</p>
|
||||
<div className="card-footer">
|
||||
<span>Catalog v1 · Seed 8155</span>
|
||||
<PlantPreview
|
||||
phenotype={s.phenotype}
|
||||
visualSeed={8155}
|
||||
stage="mature"
|
||||
size={64}
|
||||
label={"64 pixel " + s.name}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
235
apps/web/src/pages/RenderLab.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Download, Leaf } from "lucide-react";
|
||||
import { classifyRarity, resolvePhenotype } from "@botanica/genetics";
|
||||
import { stages, type GrowthStage, type Background } from "@botanica/renderer";
|
||||
import { PlantPreview } from "../components/PlantPreview";
|
||||
import { studies } from "../lib/studies";
|
||||
import { useStudy } from "../lib/studyStore";
|
||||
import { exportPlant } from "../lib/exportPlant";
|
||||
export function RenderLab() {
|
||||
const state = useStudy(),
|
||||
study = studies.find((s) => s.id === state.studyId) ?? studies[0];
|
||||
const phenotype = useMemo(
|
||||
() =>
|
||||
resolvePhenotype(
|
||||
"velvetleaf",
|
||||
study.genome,
|
||||
state.goldenVeins ? ["golden_veins"] : [],
|
||||
),
|
||||
[study, state.goldenVeins],
|
||||
);
|
||||
const rarity = classifyRarity(phenotype);
|
||||
const [message, setMessage] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
async function save(format: "svg" | "png") {
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
try {
|
||||
await exportPlant(
|
||||
format,
|
||||
phenotype,
|
||||
state.visualSeed,
|
||||
state.stage,
|
||||
state.background,
|
||||
);
|
||||
setMessage(format.toUpperCase() + " exported.");
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Export failed. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<section className="lab-heading">
|
||||
<div>
|
||||
<p className="eyebrow">The illustrated laboratory</p>
|
||||
<h1>A study in possibility.</h1>
|
||||
<p>
|
||||
One species, many expressions. Explore the illustrated character of
|
||||
velvetleaf.
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge">M2 · Vector studies</span>
|
||||
</section>
|
||||
<section
|
||||
className="render-workbench"
|
||||
aria-label="Plant rendering workbench"
|
||||
>
|
||||
<div className="specimen">
|
||||
<div className="specimen-top">
|
||||
<span>VELVETLEAF</span>
|
||||
<span>Plate 001 / Renderer v1</span>
|
||||
</div>
|
||||
<PlantPreview
|
||||
phenotype={phenotype}
|
||||
visualSeed={state.visualSeed}
|
||||
stage={state.stage}
|
||||
background={state.background}
|
||||
/>
|
||||
<div className="specimen-bottom">
|
||||
<div>
|
||||
<h2>Velvetleaf</h2>
|
||||
<p>
|
||||
{phenotype.leafShape} foliage · {phenotype.growthHabit} habit
|
||||
</p>
|
||||
</div>
|
||||
<PlantPreview
|
||||
phenotype={phenotype}
|
||||
visualSeed={state.visualSeed}
|
||||
stage={state.stage}
|
||||
size={64}
|
||||
label="64 pixel icon preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="study-panel" aria-labelledby="study-heading">
|
||||
<div className="panel-title">
|
||||
<Leaf size={20} />
|
||||
<h2 id="study-heading">Specimen study</h2>
|
||||
</div>
|
||||
<p className="panel-note">
|
||||
Curated render presets. Diploid editing and saved plants arrive in
|
||||
M3.
|
||||
</p>
|
||||
<label>
|
||||
Botanical study
|
||||
<select
|
||||
value={state.studyId}
|
||||
onChange={(e) => state.set({ studyId: e.target.value })}
|
||||
>
|
||||
{studies.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="control-pair">
|
||||
<label>
|
||||
Growth stage
|
||||
<select
|
||||
value={state.stage}
|
||||
onChange={(e) =>
|
||||
state.set({ stage: e.target.value as GrowthStage })
|
||||
}
|
||||
>
|
||||
{stages.map((s) => (
|
||||
<option key={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Visual seed
|
||||
<input
|
||||
type="number"
|
||||
min="-2147483648"
|
||||
max="2147483647"
|
||||
step="1"
|
||||
value={state.visualSeed}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (
|
||||
Number.isInteger(value) &&
|
||||
value >= -2147483648 &&
|
||||
value <= 2147483647
|
||||
)
|
||||
state.set({ visualSeed: value });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.goldenVeins}
|
||||
onChange={(e) => state.set({ goldenVeins: e.target.checked })}
|
||||
/>
|
||||
Golden veins · manual preview
|
||||
</label>
|
||||
<label>
|
||||
Preview & export background
|
||||
<select
|
||||
value={state.background}
|
||||
onChange={(e) =>
|
||||
state.set({ background: e.target.value as Background })
|
||||
}
|
||||
>
|
||||
<option value="warm">Warm ivory</option>
|
||||
<option value="transparent">Transparent</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="trait-summary">
|
||||
<span className="eyebrow">Resolved phenotype</span>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Pigment</dt>
|
||||
<dd>{phenotype.pigment}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Pattern</dt>
|
||||
<dd>{phenotype.leafPattern}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Flowers</dt>
|
||||
<dd>
|
||||
{phenotype.flowerColor} · {phenotype.flowerDensity}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Derived rarity</dt>
|
||||
<dd>
|
||||
{rarity.tier} · {rarity.score}/100
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="rarity-note">
|
||||
{rarity.reasons
|
||||
.map((r) => r.label + " +" + r.points)
|
||||
.join("; ") || "No unusual traits in this preset."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="export-actions">
|
||||
<button onClick={() => void save("svg")} disabled={busy}>
|
||||
<Download size={15} />
|
||||
Export SVG
|
||||
</button>
|
||||
<button onClick={() => void save("png")} disabled={busy}>
|
||||
<Download size={15} />
|
||||
Export PNG
|
||||
</button>
|
||||
</div>
|
||||
<p role="status" className="export-status">
|
||||
{message}
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section className="stage-strip" aria-labelledby="stages-heading">
|
||||
<div>
|
||||
<p className="eyebrow">From first leaf to flower</p>
|
||||
<h2 id="stages-heading">Four chapters of growth</h2>
|
||||
<p>
|
||||
Growth changes the illustration. Hereditary traits stay the same.
|
||||
</p>
|
||||
</div>
|
||||
<div className="stage-previews">
|
||||
{stages.map((stage) => (
|
||||
<div key={stage}>
|
||||
<PlantPreview
|
||||
phenotype={phenotype}
|
||||
visualSeed={state.visualSeed}
|
||||
stage={stage}
|
||||
size={128}
|
||||
/>
|
||||
<span>{stage}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -297,3 +297,400 @@ footer {
|
|||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
/* Illustrated laboratory and renderer proof atlas */
|
||||
.lab-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 48px 0 30px;
|
||||
}
|
||||
.lab-heading .eyebrow {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.lab-heading h1 {
|
||||
font-size: clamp(38px, 4vw, 54px);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.lab-heading p:not(.eyebrow) {
|
||||
color: #5d7062;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
max-width: 620px;
|
||||
margin: 0;
|
||||
}
|
||||
.render-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
|
||||
border: 1px solid #d8ded2;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #fffcf4;
|
||||
}
|
||||
.specimen {
|
||||
background: #f6f3e8;
|
||||
padding: 25px 30px 15px;
|
||||
min-width: 0;
|
||||
}
|
||||
.specimen-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.8px;
|
||||
color: #64765b;
|
||||
}
|
||||
.plant-preview {
|
||||
line-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.plant-preview svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.specimen > .plant-preview {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.specimen-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid #d8ded2;
|
||||
gap: 16px;
|
||||
padding-top: 13px;
|
||||
}
|
||||
.specimen-bottom h2 {
|
||||
font-style: italic;
|
||||
font-size: 25px;
|
||||
}
|
||||
.specimen-bottom p {
|
||||
font-size: 11px;
|
||||
color: #637565;
|
||||
margin-top: 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.study-panel {
|
||||
padding: 29px 30px;
|
||||
border-left: 1px solid #d8ded2;
|
||||
min-width: 0;
|
||||
}
|
||||
.panel-title {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.panel-title h2 {
|
||||
font-size: 25px;
|
||||
}
|
||||
.panel-note {
|
||||
font-size: 12px;
|
||||
color: #637565;
|
||||
line-height: 1.7;
|
||||
margin: 14px 0 21px;
|
||||
}
|
||||
.study-panel label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #4c654f;
|
||||
margin: 15px 0;
|
||||
}
|
||||
select,
|
||||
input[type="number"] {
|
||||
font:
|
||||
12px Arial,
|
||||
sans-serif;
|
||||
color: #20392d;
|
||||
border: 1px solid #cbd3c3;
|
||||
border-radius: 6px;
|
||||
background: #fffcf4;
|
||||
padding: 11px 9px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 39px;
|
||||
}
|
||||
select:focus-visible,
|
||||
input:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 3px solid #b76f52;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.control-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.study-panel .checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
margin: 14px 0 22px;
|
||||
gap: 10px;
|
||||
}
|
||||
.checkbox input {
|
||||
accent-color: #32684a;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.trait-summary {
|
||||
border-top: 1px solid #d8ded2;
|
||||
margin-top: 24px;
|
||||
padding-top: 21px;
|
||||
}
|
||||
.trait-summary .eyebrow {
|
||||
font-size: 9px;
|
||||
}
|
||||
.trait-summary dl {
|
||||
margin: 13px 0;
|
||||
}
|
||||
.trait-summary dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
margin: 11px 0;
|
||||
}
|
||||
.trait-summary dt {
|
||||
color: #637565;
|
||||
}
|
||||
.trait-summary dd {
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
text-align: right;
|
||||
}
|
||||
.rarity-note {
|
||||
font-size: 10px;
|
||||
color: #637565;
|
||||
line-height: 1.6;
|
||||
margin: 12px 0 17px;
|
||||
}
|
||||
.export-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: #32684a;
|
||||
color: #fffaf0;
|
||||
border: 1px solid #32684a;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
padding: 11px 14px;
|
||||
cursor: pointer;
|
||||
min-height: 39px;
|
||||
}
|
||||
button:hover {
|
||||
background: #254f39;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.export-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
.export-actions button:last-child {
|
||||
background: transparent;
|
||||
color: #32684a;
|
||||
}
|
||||
.export-status {
|
||||
font-size: 11px;
|
||||
color: #32684a;
|
||||
min-height: 17px;
|
||||
margin: 11px 0 0;
|
||||
}
|
||||
.stage-strip {
|
||||
margin-top: 34px;
|
||||
padding: 30px 0;
|
||||
border-top: 1px solid #d8ded2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.7fr;
|
||||
gap: 25px;
|
||||
align-items: center;
|
||||
}
|
||||
.stage-strip .eyebrow {
|
||||
margin-bottom: 12px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.stage-strip h2 {
|
||||
font-size: 25px;
|
||||
}
|
||||
.stage-strip p:not(.eyebrow) {
|
||||
font-size: 12px;
|
||||
color: #637565;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.stage-previews {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stage-previews > div {
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.stage-previews .plant-preview {
|
||||
margin: auto;
|
||||
}
|
||||
.stage-previews span {
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
color: #637565;
|
||||
}
|
||||
.proof-toolbar {
|
||||
border-top: 1px solid #d8ded2;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.proof-toolbar h2 {
|
||||
font-size: 24px;
|
||||
margin-right: auto;
|
||||
}
|
||||
.proof-toolbar [role="status"] {
|
||||
font-size: 11px;
|
||||
color: #637565;
|
||||
}
|
||||
.proof-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.plant-card {
|
||||
background: #fffcf4;
|
||||
border: 1px solid #d8ded2;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.plate-number {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.7px;
|
||||
color: #6a7c70;
|
||||
}
|
||||
.plant-card > .plant-preview {
|
||||
margin: 4px auto;
|
||||
}
|
||||
.plant-card h3 {
|
||||
font:
|
||||
22px Georgia,
|
||||
serif;
|
||||
margin: 10px 0 9px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.plant-card p {
|
||||
font-size: 11px;
|
||||
color: #637565;
|
||||
text-transform: capitalize;
|
||||
margin: 0;
|
||||
}
|
||||
.card-footer {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid #e0e4d8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 9px;
|
||||
color: #637565;
|
||||
}
|
||||
.smoke-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 13px;
|
||||
}
|
||||
.smoke-grid .plant-card {
|
||||
padding: 15px;
|
||||
}
|
||||
.smoke-grid h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.smoke-grid small {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: #6a7c70;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.study-panel {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
.specimen {
|
||||
padding: 22px 20px 15px;
|
||||
}
|
||||
.stage-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.smoke-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.lab-heading {
|
||||
padding-top: 32px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
.lab-heading h1 {
|
||||
font-size: 42px;
|
||||
}
|
||||
.render-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.study-panel {
|
||||
border-left: 0;
|
||||
border-top: 1px solid #d8ded2;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.specimen {
|
||||
padding: 20px 14px 12px;
|
||||
}
|
||||
.specimen-top {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.stage-previews {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.proof-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.plant-card {
|
||||
padding: 12px;
|
||||
}
|
||||
.plant-card h3 {
|
||||
font-size: 17px;
|
||||
}
|
||||
.plate-number {
|
||||
font-size: 7px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
.card-footer {
|
||||
font-size: 8px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
.smoke-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.smoke-grid .plant-card {
|
||||
padding: 12px;
|
||||
}
|
||||
.proof-toolbar {
|
||||
gap: 14px;
|
||||
}
|
||||
.proof-toolbar h2 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
artifacts/m2/atlas-desktop.png
Normal file
|
After Width: | Height: | Size: 495 KiB |
BIN
artifacts/m2/atlas-mobile.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
73
artifacts/m2/browser-checks.json
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"routes": [
|
||||
{
|
||||
"route": "/lab",
|
||||
"width": 1440,
|
||||
"scrollWidth": 1440
|
||||
},
|
||||
{
|
||||
"route": "/gallery",
|
||||
"width": 1440,
|
||||
"scrollWidth": 1440
|
||||
},
|
||||
{
|
||||
"route": "/lab",
|
||||
"width": 360,
|
||||
"scrollWidth": 360
|
||||
},
|
||||
{
|
||||
"route": "/gallery",
|
||||
"width": 360,
|
||||
"scrollWidth": 360
|
||||
},
|
||||
{
|
||||
"route": "/breed",
|
||||
"width": 360,
|
||||
"scrollWidth": 360
|
||||
},
|
||||
{
|
||||
"route": "/about",
|
||||
"width": 360,
|
||||
"scrollWidth": 360
|
||||
}
|
||||
],
|
||||
"exports": [
|
||||
{
|
||||
"background": "warm",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"corner": [
|
||||
246,
|
||||
243,
|
||||
232,
|
||||
255
|
||||
],
|
||||
"pngToSvgMeanDifference": 0,
|
||||
"pngToPreviewMeanDifference": 0
|
||||
},
|
||||
{
|
||||
"background": "transparent",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"corner": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"pngToSvgMeanDifference": 0,
|
||||
"pngToPreviewMeanDifference": 0
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"performance": {
|
||||
"cards": 100,
|
||||
"elapsedMs": 250.20000004768372,
|
||||
"maxFrameGapMs": 66.80000000000001,
|
||||
"longTasksMs": [
|
||||
108
|
||||
],
|
||||
"uniqueIds": 1302,
|
||||
"ids": 1302
|
||||
}
|
||||
}
|
||||
BIN
artifacts/m2/export-transparent.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
1
artifacts/m2/export-transparent.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
artifacts/m2/export-warm.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
1
artifacts/m2/export-warm.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
artifacts/m2/hero-512.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
artifacts/m2/icon-64.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
artifacts/m2/lab-desktop.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
artifacts/m2/lab-mobile.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
artifacts/m2/smoke-desktop.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
|
|
@ -1,2 +1,9 @@
|
|||
# Artwork
|
||||
The botanical shell study in apps/web/src/main.tsx is original SVG artwork created for Botanica by Codex (2026-10-28), provided under the repository terms. No external assets are used. This is decorative M0 art; species primitives and the genotype renderer belong to M2.
|
||||
# Original botanical artwork
|
||||
|
||||
The velvetleaf vector artwork was authored for Botanica by Codex on 2026-10-28. All SVG primitive artwork in `assets/species/velvetleaf` and its hand-authored path definitions in `packages/renderer/src/primitives.ts` are dedicated to the public domain under CC0-1.0. No scraped, stock, generated raster, commercial or externally hosted artwork is used.
|
||||
|
||||
The 15 primitives comprise three leaf silhouettes with layered shading, highlights and vein paths; three branch grammars; a stem and flowering stalk; four bloom variants; two pots; and a ground shadow. `manifest.json` declares asset IDs, normalized anchors, canonical bounds, palette slots and renderer version. The renderer uses the same typed path definitions, rather than parsing or injecting arbitrary XML. Committed standalone SVG sources are regenerated with `npm run assets:write`; `npm run test` checks that they remain in sync. Metadata anchors are normalized to bounds; renderer anchors use canonical pixel coordinates.
|
||||
|
||||
Six curated leaf palettes are defined in `packages/renderer/src/palettes.ts`. Pattern overlays use leaf-specific clipping; golden veins change only the vein paint. Exported SVGs have no external references or fonts. Pots and the shadow use their own fixed material colors.
|
||||
|
||||
The earlier decorative shell study in `apps/web/src/main.tsx` is also original project artwork under CC0-1.0. It remains on informational shell pages; the laboratory and render atlas use the procedural renderer.
|
||||
|
|
|
|||
1
assets/species/velvetleaf/bloom_cup_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><title>velvetleaf/bloom_cup_v1</title><path data-palette="flower" d="M25 43 C5 37 3 20 10 11 Q20 9 25 20 Q29 6 39 10 C50 23 42 37 25 43Z" fill="#d797a1" stroke="#264a38" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 255 B |
1
assets/species/velvetleaf/bloom_double_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><title>velvetleaf/bloom_double_v1</title><path data-palette="flower" d="M25 9 Q35 0 39 12 Q52 12 44 26 Q53 38 39 40 Q35 53 25 43 Q12 52 10 39 Q-1 33 8 24 Q0 11 14 11 Q18 0 25 9Z" fill="#d797a1" stroke="#264a38" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 296 B |
1
assets/species/velvetleaf/bloom_round_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><title>velvetleaf/bloom_round_v1</title><path data-palette="flower" d="M25 25 C5 28 3 10 15 9 Q22 8 25 20 C22 0 40 0 41 12 Q42 21 29 24 C49 16 56 31 43 37 Q33 40 28 28 C38 46 22 55 16 43 Q12 35 22 28 C6 42 -2 27 10 22 Q17 19 25 25Z" fill="#d797a1" stroke="#264a38" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
1
assets/species/velvetleaf/bloom_star_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><title>velvetleaf/bloom_star_v1</title><path data-palette="flower" d="M25 3 L31 18 L47 14 L37 28 L47 42 L30 37 L24 49 L20 35 L3 39 L13 25 L3 12 L20 17Z" fill="#d797a1" stroke="#264a38" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 270 B |
1
assets/species/velvetleaf/branch_bushy_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>velvetleaf/branch_bushy_v1</title><path data-palette="leafShade" d="M256 394 Q246 282 260 164 M252 354 Q212 350 188 319 M254 331 Q296 327 325 299 M252 297 Q223 284 207 254 M255 268 Q293 253 311 222 M256 232 Q233 204 239 179 M258 202 Q277 178 278 156" fill="none" stroke="#264a38" stroke-width="4"/></svg>
|
||||
|
After Width: | Height: | Size: 374 B |
1
assets/species/velvetleaf/branch_compact_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>velvetleaf/branch_compact_v1</title><path data-palette="leafShade" d="M256 394 Q249 310 252 195 M253 348 Q219 344 211 325 M254 326 Q289 322 299 304 M253 295 Q226 286 223 268 M253 270 Q279 257 285 242 M252 242 Q234 223 239 212" fill="none" stroke="#264a38" stroke-width="4"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
1
assets/species/velvetleaf/branch_trailing_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>velvetleaf/branch_trailing_v1</title><path data-palette="leafShade" d="M256 394 Q249 298 259 211 M253 348 C219 297 163 318 170 378 M254 347 C304 291 346 322 342 380 M255 300 Q222 275 209 263 M256 271 Q291 255 303 243 M257 239 L241 212" fill="none" stroke="#264a38" stroke-width="4"/></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
1
assets/species/velvetleaf/flower_stalk_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -100 20 100"><title>velvetleaf/flower_stalk_v1</title><path data-palette="leafShade" d="M0 0 Q-8-50 0-100" fill="none" stroke="#264a38" stroke-width="4"/></svg>
|
||||
|
After Width: | Height: | Size: 214 B |
1
assets/species/velvetleaf/leaf_lobed_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 140"><title>velvetleaf/leaf_lobed_v1</title><path data-palette="leafBase" d="M50 130 Q33 123 29 112 L14 112 L17 98 Q5 93 7 80 L14 75 L6 61 L19 57 L16 43 L29 43 L27 29 L40 31 L50 7 L60 31 L73 29 L71 43 L84 43 L81 57 L94 61 L86 75 Q97 91 83 98 L86 112 L71 112 Q67 123 50 130Z" fill="#537b49" stroke="#264a38" stroke-width="1"/><path data-palette="leafShade" d="M50 130 Q61 93 57 44 L50 7 L60 31 L73 29 L71 43 L84 43 L81 57 L94 61 L86 75 Q97 91 83 98 L86 112 L71 112 Q67 123 50 130Z" fill="#264a38" opacity=".2"/><path data-palette="variegation" d="M23 79 L29 66 L35 83 L30 99Z" fill="#e7dfad" opacity=".35"/><path data-palette="vein" d="M50 127 L50 19 M50 113 L22 105 M50 96 L17 83 M50 78 L20 62 M50 60 L30 43 M50 113 L78 105 M50 96 L83 83 M50 78 L80 62 M50 60 L70 43" stroke="#b3c88a" stroke-width="1.25" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 881 B |
1
assets/species/velvetleaf/leaf_oval_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 140"><title>velvetleaf/leaf_oval_v1</title><path data-palette="leafBase" d="M50 128 C34 118 19 109 13 91 C5 69 13 39 27 23 Q40 9 50 7 Q75 17 87 46 C101 80 82 113 50 128Z" fill="#537b49" stroke="#264a38" stroke-width="1"/><path data-palette="leafShade" d="M50 128 C71 99 77 69 65 24 Q91 48 89 73 Q91 104 50 128Z" fill="#264a38" opacity=".2"/><path data-palette="variegation" d="M25 72 Q22 47 42 28 Q30 47 32 67Z" fill="#e7dfad" opacity=".35"/><path data-palette="vein" d="M50 126 Q45 70 50 16 M48 109 Q30 95 23 78 M47 91 Q28 74 26 53 M47 70 L34 37 M49 105 Q70 89 78 70 M48 82 Q66 65 69 44 M49 58 L58 29" stroke="#b3c88a" stroke-width="1.25" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 717 B |
1
assets/species/velvetleaf/leaf_pointed_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 140"><title>velvetleaf/leaf_pointed_v1</title><path data-palette="leafBase" d="M50 131 C24 111 14 88 22 64 Q31 36 50 4 Q66 34 78 64 C90 95 69 119 50 131Z" fill="#537b49" stroke="#264a38" stroke-width="1"/><path data-palette="leafShade" d="M50 131 Q66 97 60 58 L50 4 Q66 34 78 64 C90 95 69 119 50 131Z" fill="#264a38" opacity=".2"/><path data-palette="variegation" d="M30 83 Q28 61 45 33 Q35 58 37 77Z" fill="#e7dfad" opacity=".35"/><path data-palette="vein" d="M50 127 Q45 71 50 15 M48 109 L30 87 M47 91 L32 65 M47 71 L39 43 M49 107 L70 83 M48 87 L68 62 M49 65 L59 39" stroke="#b3c88a" stroke-width="1.25" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 683 B |
283
assets/species/velvetleaf/manifest.json
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
[
|
||||
{
|
||||
"assetId": "velvetleaf/leaf_oval_v1",
|
||||
"category": "leaf",
|
||||
"anchor": [
|
||||
0.5,
|
||||
0.9142857142857143
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
140
|
||||
],
|
||||
"paletteSlots": [
|
||||
"leafBase",
|
||||
"leafShade",
|
||||
"vein",
|
||||
"variegation"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/leaf_lobed_v1",
|
||||
"category": "leaf",
|
||||
"anchor": [
|
||||
0.5,
|
||||
0.9142857142857143
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
140
|
||||
],
|
||||
"paletteSlots": [
|
||||
"leafBase",
|
||||
"leafShade",
|
||||
"vein",
|
||||
"variegation"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/leaf_pointed_v1",
|
||||
"category": "leaf",
|
||||
"anchor": [
|
||||
0.5,
|
||||
0.9142857142857143
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
140
|
||||
],
|
||||
"paletteSlots": [
|
||||
"leafBase",
|
||||
"leafShade",
|
||||
"vein",
|
||||
"variegation"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/branch_compact_v1",
|
||||
"category": "branch",
|
||||
"anchor": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
],
|
||||
"paletteSlots": [
|
||||
"leafShade",
|
||||
"vein"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/branch_bushy_v1",
|
||||
"category": "branch",
|
||||
"anchor": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
],
|
||||
"paletteSlots": [
|
||||
"leafShade",
|
||||
"vein"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/branch_trailing_v1",
|
||||
"category": "branch",
|
||||
"anchor": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
],
|
||||
"paletteSlots": [
|
||||
"leafShade",
|
||||
"vein"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/stem_v1",
|
||||
"category": "stem",
|
||||
"anchor": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
],
|
||||
"paletteSlots": [
|
||||
"leafShade",
|
||||
"vein"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/flower_stalk_v1",
|
||||
"category": "stem",
|
||||
"anchor": [
|
||||
0.5,
|
||||
1
|
||||
],
|
||||
"bounds": [
|
||||
-10,
|
||||
-100,
|
||||
20,
|
||||
100
|
||||
],
|
||||
"paletteSlots": [
|
||||
"leafShade"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/bloom_round_v1",
|
||||
"category": "bloom",
|
||||
"anchor": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50
|
||||
],
|
||||
"paletteSlots": [
|
||||
"flower",
|
||||
"variegation"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/bloom_star_v1",
|
||||
"category": "bloom",
|
||||
"anchor": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50
|
||||
],
|
||||
"paletteSlots": [
|
||||
"flower",
|
||||
"variegation"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/bloom_cup_v1",
|
||||
"category": "bloom",
|
||||
"anchor": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50
|
||||
],
|
||||
"paletteSlots": [
|
||||
"flower",
|
||||
"variegation"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/bloom_double_v1",
|
||||
"category": "bloom",
|
||||
"anchor": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50
|
||||
],
|
||||
"paletteSlots": [
|
||||
"flower",
|
||||
"variegation"
|
||||
],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/pot_terracotta_v1",
|
||||
"category": "pot",
|
||||
"anchor": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
],
|
||||
"paletteSlots": [],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/pot_ceramic_v1",
|
||||
"category": "pot",
|
||||
"anchor": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
],
|
||||
"paletteSlots": [],
|
||||
"rendererVersion": 1
|
||||
},
|
||||
{
|
||||
"assetId": "velvetleaf/shadow_v1",
|
||||
"category": "shadow",
|
||||
"anchor": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"bounds": [
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
],
|
||||
"paletteSlots": [],
|
||||
"rendererVersion": 1
|
||||
}
|
||||
]
|
||||
1
assets/species/velvetleaf/pot_ceramic_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>velvetleaf/pot_ceramic_v1</title><path data-palette="pot" d="M195 390 Q188 458 215 472 Q256 485 297 472 Q322 457 317 390Z" fill="#b77b5d" stroke="#264a38" stroke-width="1"/><path data-palette="leafShade" d="M292 398 Q300 448 281 477 Q308 474 314 452 Q321 433 317 390Z" fill="#264a38" opacity=".2"/><path data-palette="variegation" d="M209 408 Q205 441 218 459 L230 463 Q219 436 224 409Z" fill="#e7dfad" opacity=".35"/></svg>
|
||||
|
After Width: | Height: | Size: 494 B |
1
assets/species/velvetleaf/pot_terracotta_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>velvetleaf/pot_terracotta_v1</title><path data-palette="pot" d="M195 389 L207 468 Q256 487 305 468 L317 389Z" fill="#b77b5d" stroke="#264a38" stroke-width="1"/><path data-palette="leafShade" d="M285 396 L278 477 Q297 474 305 468 L317 389Z" fill="#264a38" opacity=".2"/><path data-palette="variegation" d="M208 404 L218 461 Q230 466 240 466 L231 405Z" fill="#e7dfad" opacity=".35"/></svg>
|
||||
|
After Width: | Height: | Size: 457 B |
1
assets/species/velvetleaf/shadow_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>velvetleaf/shadow_v1</title><path data-palette="pot" d="M156 479 C156 462 356 462 356 479 C356 495 156 495 156 479Z" fill="#b77b5d" stroke="#264a38" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 243 B |
1
assets/species/velvetleaf/stem_v1.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>velvetleaf/stem_v1</title><path data-palette="leafShade" d="M256 395 Q251 368 253 342" fill="none" stroke="#264a38" stroke-width="4"/></svg>
|
||||
|
After Width: | Height: | Size: 210 B |
|
|
@ -55,3 +55,5 @@ Indices: plants(owner_id,created_at DESC), plants(species_id,rarity_tier), breed
|
|||
## M1 implemented examples
|
||||
|
||||
`GET /api/v1/catalog/species` returns `{catalogVersion:1,species:[...]}` with dominance, phenotype names, compatibility and mutation/rarity policy data. `POST /api/v1/phenotype/resolve` accepts `{speciesId:"velvetleaf",genome:<the six diploid locus pairs>,mutations:[],catalogVersion:1}` and returns `{phenotype,rarity}`. Unknown client fields (including rarity claims) and invalid alleles return a uniform 400 error. Shared request schemas/DTOs are in `packages/shared`; no breeding endpoint exists before M3.
|
||||
|
||||
`POST /api/v1/preview` accepts the resolve fields plus signed-int32 `visualSeed`, `growthStage` (`seedling`, `juvenile`, `mature`, `flowering`), `rendererVersion:1` and `background` (`transparent`, `warm`). It validates genomes server-side and returns `{rendererVersion:1,svg:"<svg ...>...</svg>"}` using the same pure renderer as the browser. It does not persist a plant or accept arbitrary SVG/scene/palette input.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ Later replace `PUBLIC_DOMAIN` with a true HTTPS origin and configure TLS at the
|
|||
|
||||
## Implemented M0 bootstrap
|
||||
|
||||
Compose publishes web on loopback (`127.0.0.1:${HOST_PORT}:8080`), with nginx running as an unprivileged user. API and DB have no published ports. The `bootstrap` one-shot service uses the API image to run `node dist/migrate.js` then `node dist/seed.js`; API waits for successful completion. A PostgreSQL transaction advisory lock serializes migration execution. A migration tracking table prevents repeat schema application. Seeds use conflict-safe inserts and preserve existing records.
|
||||
Compose publishes web on loopback (`127.0.0.1:${HOST_PORT}:8080`), with nginx running as an unprivileged user. API and DB have no published ports. The `bootstrap` one-shot service uses the API image to run `node dist/migrate.js` then `node dist/seed.js`; API waits for successful completion. A PostgreSQL transaction advisory lock serializes migration execution. A migration tracking table prevents repeat schema application. Player seeds use conflict-safe inserts and preserve existing records. The M1 catalog seed synchronizes the current versioned definition; it does not rewrite plant genomes or ancestry.
|
||||
|
||||
For explicit reruns use `docker compose run --rm bootstrap node dist/migrate.js` or `docker compose run --rm bootstrap node dist/seed.js`. Runtime images contain compiled JavaScript; root npm migration/seed scripts use TypeScript tooling for host development with an explicit `DATABASE_URL`. Compose production mode overrides the starter development value without modifying `.env`.
|
||||
|
||||
|
|
|
|||
|
|
@ -47,3 +47,13 @@ Each path has canonical coordinates and must be paintable using declared palette
|
|||
- 100 small previews render without unacceptable jank on a normal desktop browser.
|
||||
- Exported SVG opens in a standard SVG viewer without external assets; PNG export has correct alpha/background as selected.
|
||||
- All graphics are original vector assets stored in the repository; document creator and licensing in `assets/README.md`.
|
||||
|
||||
## Renderer v1 implemented grammar
|
||||
|
||||
`layoutPlant` returns a validated scene of known primitive IDs, numeric transforms, palette slots and ordered layers. Mulberry32 starts afresh from the signed cosmetic seed. It jitters leaf anchor position by at most four canonical pixels, angle by 4.5 degrees and scale by five percent. Species/habit anchors and silhouettes remain fixed. Seed parity selects one of two pot materials. Stage scales are 0.42 / 0.69 / 1 / 1, with 2 / 4 / 6 / 6 leaves. Only flowering produces blooms; low/medium/high density yields 2/4/6 flowers. Genotype, resolved phenotype and rarity are unchanged by growth stage or cosmetic seed.
|
||||
|
||||
Render SVG strings use the fixed `0 0 512 512` viewBox, finite validated transforms, strict hex palettes, known asset IDs and validated per-instance namespaces. `PlantPreview` supplies a React instance namespace to prevent clipping/gradient ID collisions; changing namespaces does not alter geometry. It memoizes render inputs, so workbench changes do not redraw unrelated gallery plants. Raw SVG injection is confined to this adapter and receives only the serializer's validated output.
|
||||
|
||||
The `/gallery` M2 proof atlas contains exactly 12 systematic shape/pigment/pattern studies and an explicit 100-seed smoke view. These are deterministic visual studies, not database records or breeding outcomes. `/lab` provides curated studies, growth stages, visual seed, clearly labeled manual golden-vein preview, 64px and 128px comparison views, and matching SVG/PNG exports. The full diploid editor remains M3.
|
||||
|
||||
The export background selector controls the preview and both exports together. SVG downloads are self-contained; PNG rasterizes that identical SVG at 512×512 via a temporary Blob URL. Object URLs are revoked, failures are reported, and transparent export retains alpha. Primitive source provenance and synchronization are documented in `assets/README.md`.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
- [x] M0 — workspaces, Compose, healthcheck, migrations, responsive shell.
|
||||
- [x] M1 — genetics contracts, deterministic PRNG, pure engine + tests.
|
||||
- [ ] M2 — procedural illustrated SVG renderer, exports, visual QA.
|
||||
- [x] M2 — procedural illustrated SVG renderer, exports, visual QA.
|
||||
- [ ] M3 — lab editor, server persistence, breeding flow.
|
||||
- [ ] M4 — gallery, provenance, UX polish, full acceptance.
|
||||
|
||||
|
|
@ -47,3 +47,26 @@ Gate complete before starting M2. Implemented versioned catalog/Zod schemas, pur
|
|||
Verified `npm run test` (10 passed), `npm run typecheck`, `npm run build`, `docker compose config --quiet`, `docker compose up --build -d`, `docker compose ps`, and proxied HTTP health/catalog/resolve requests. 10,000 mutation-free PG crosses: PP=2499 (24.99%), PG=5041 (50.41%), GG=2460 (24.60%), all within ±2 percentage points. Exact replay tested in a separate Node process. Tests cover all dominance pairs, invalid/missing/extra alleles, unknown versions/species, compatibility/hybrid requirements, mutation probabilities 0/1, valid replacements and rarity reasons. Live invalid-allele POST returned 400; valid POST returned resolved phenotype and rarity.
|
||||
|
||||
A mutation-boundary test exposed coupling between mutation draw consumption and cosmetic seed generation; cosmetic child seeds now use a separately salted stream, documented in GENETICS_SPEC. No UI changes at M1: existing shell remains available at http://localhost:8155. Persistent breeding remains M3. Continuing to M2 under the user's instruction.
|
||||
|
||||
|
||||
## M2 — 2026-10-28
|
||||
|
||||
Gate complete. Added 15 original CC0 botanical SVG primitives (three silhouettes, three branch grammars, soil stem and flower stalk, four blooms, two pots, shadow) with asset manifest and synchronized standalone sources. Added six curated palettes, deterministic species-specific layout, four stages, clipped variegation/marbling, restrained golden veins, stable per-instance SVG namespaces and strict scene/serializer validation. Geometry and domain packages remain independent of React, DOM and persistence.
|
||||
|
||||
Added memoized PlantPreview adapter, a usable `/lab` visual-study workbench (curated presets, seed, stage, clearly labeled manual mutation, derived phenotype/rarity, 64px icon, four 128px stage views), SVG/PNG exports, `/gallery` 12-variation atlas and 100-seed smoke study. These are explicit non-persistent render proofs; the full diploid editor and saved breeding remain M3. Added validated `/api/v1/preview`, using the same pure renderer as the client. Updated asset provenance, genetics/graphics/API documentation and README.
|
||||
|
||||
### Checks actually run
|
||||
|
||||
- `npm run test`: 16 tests passed across five suites; 16 asset files (15 SVG sources + manifest) verified in sync. Includes M1 regressions, exact render replay, cosmetic seed invariance, 3,600 shape/habit/stage/seed layout bounds, stage/density behavior, distinct 12-way output, clipped patterns, golden-vein geometry invariance, malicious namespace/asset/palette/NaN rejection and API/client SVG equivalence.
|
||||
- `npm run typecheck`: passed all workspaces.
|
||||
- `npm run build`: passed packages, API and Vite production bundle (about 129 kB gzip JS).
|
||||
- `docker compose config --quiet`, `docker compose up --build -d`, `docker compose ps`: passed; API/DB healthy, web available at http://localhost:8155, only loopback web port published.
|
||||
- `curl.exe -fsS http://localhost:8155/health`: 200 JSON with connected database. Live proxied preview POST returned 200 and a self-contained renderer-v1 SVG. Sandbox database remains one player and one species.
|
||||
- `node scripts/verify-m2.cjs` with bundled Playwright and Chrome: passed desktop 1440px and mobile 360px checks, no horizontal overflow or JavaScript page errors. Verified seed edits leave the phenotype/rarity summary unchanged, exact 12/100 card counts and unique SVG IDs (1302/1302 in the smoke view).
|
||||
- Actual UI downloads for warm/transparent SVG and PNG decode successfully. PNG dimensions are 512×512. All four downloads rasterize identically to the live SVG preview: mean pixel difference 0. Warm corner RGBA=246,243,232,255; transparent corner=0,0,0,0.
|
||||
- 100-plant transition measured about 250 ms including browser interaction and two frames; largest observed frame gap about 67 ms, one 108 ms long task. This is a local headless desktop measurement, not a cross-device performance guarantee. The UI remained usable and toggled back successfully.
|
||||
- Visually inspected desktop workbench, complete systematic atlas, mobile workbench, 512px hero, 64px icon and 100-seed overview. Original bloom stems looked detached during initial QA; added explicit connecting stalk geometry and repeated tests/build/browser/export checks after fixing it. No obvious leaf/pot clipping.
|
||||
|
||||
Screenshots, real exported SVG/PNG files and structured measurements are in `artifacts/m2/`. The optional browser script is checked in and documents its external Playwright/browser requirements in README.
|
||||
|
||||
No known M2 blockers. Local sandbox only; persistent plant creation, full genome editor, saved breeding and lineage navigation are intentionally not implemented before M3. `.env` values remain unchanged and excluded from Git. Stop at M2 as requested; next milestone is M3.
|
||||
|
|
|
|||
10
package-lock.json
generated
|
|
@ -23,6 +23,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"@botanica/renderer": "0.0.0",
|
||||
"@botanica/shared": "0.0.0",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
|
|
@ -38,6 +39,9 @@
|
|||
"name": "@botanica/web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"@botanica/renderer": "0.0.0",
|
||||
"@botanica/shared": "0.0.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -3886,7 +3890,11 @@
|
|||
},
|
||||
"packages/renderer": {
|
||||
"name": "@botanica/renderer",
|
||||
"version": "0.0.0"
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"zod": "^4.6.7"
|
||||
}
|
||||
},
|
||||
"packages/rules": {
|
||||
"name": "@botanica/rules",
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@
|
|||
"dev": "concurrently \"npm run dev -w @botanica/server\" \"npm run dev -w @botanica/web\"",
|
||||
"build": "npm run build:packages && npm run build -w @botanica/server && npm run build -w @botanica/web",
|
||||
"typecheck": "npm run build:packages && npm run typecheck --workspaces --if-present",
|
||||
"test": "vitest run",
|
||||
"test": "vitest run && node scripts/write-assets.mjs --check",
|
||||
"db:migrate": "npm run db:migrate -w @botanica/server",
|
||||
"db:seed": "npm run db:seed -w @botanica/server",
|
||||
"build:packages": "npm run build -w @botanica/rules && npm run build -w @botanica/genetics && npm run build -w @botanica/shared && npm run build -w @botanica/renderer",
|
||||
"pretest": "npm run build:packages",
|
||||
"predev": "npm run build:packages"
|
||||
"predev": "npm run build:packages",
|
||||
"assets:write": "npm run build:packages && node scripts/write-assets.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.9.3",
|
||||
|
|
|
|||
|
|
@ -12,5 +12,9 @@
|
|||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"zod": "^4.6.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
export {};
|
||||
export * from "./types.js";
|
||||
export * from "./layout.js";
|
||||
export * from "./renderSvg.js";
|
||||
export * from "./palettes.js";
|
||||
export * from "./primitives.js";
|
||||
|
|
|
|||
174
packages/renderer/src/layout.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import {
|
||||
createRng,
|
||||
getSpecies,
|
||||
phenotypeSchema,
|
||||
seedSchema,
|
||||
type Phenotype,
|
||||
} from "@botanica/genetics";
|
||||
import { paletteFor } from "./palettes.js";
|
||||
import {
|
||||
sceneSchema,
|
||||
stages,
|
||||
type GrowthStage,
|
||||
type PlantScene,
|
||||
type SceneNode,
|
||||
type AssetId,
|
||||
} from "./types.js";
|
||||
const anchors = {
|
||||
compact: [
|
||||
[211, 325, -58, 0.82],
|
||||
[299, 304, 54, 0.85],
|
||||
[223, 268, -45, 0.82],
|
||||
[285, 242, 45, 0.78],
|
||||
[239, 212, -28, 0.74],
|
||||
[252, 195, 12, 0.77],
|
||||
],
|
||||
bushy: [
|
||||
[188, 319, -58, 0.94],
|
||||
[325, 299, 58, 0.93],
|
||||
[207, 254, -48, 0.94],
|
||||
[311, 222, 48, 0.89],
|
||||
[239, 179, -28, 0.82],
|
||||
[278, 156, 26, 0.78],
|
||||
],
|
||||
trailing: [
|
||||
[170, 378, -128, 0.83],
|
||||
[342, 380, 128, 0.83],
|
||||
[209, 263, -54, 0.94],
|
||||
[303, 243, 51, 0.93],
|
||||
[241, 212, -25, 0.83],
|
||||
[259, 211, 25, 0.83],
|
||||
],
|
||||
} as const;
|
||||
const round = (v: number) => Math.round(v * 1000) / 1000;
|
||||
export function layoutPlant(
|
||||
speciesId: string,
|
||||
phenotype: Phenotype,
|
||||
visualSeed: number,
|
||||
growthStage: GrowthStage = "mature",
|
||||
rendererVersion: number = 1,
|
||||
): PlantScene {
|
||||
getSpecies(speciesId, phenotype.catalogVersion);
|
||||
const p = phenotypeSchema.parse(phenotype);
|
||||
seedSchema.parse(visualSeed);
|
||||
if (
|
||||
p.speciesId !== speciesId ||
|
||||
rendererVersion !== 1 ||
|
||||
!stages.includes(growthStage)
|
||||
)
|
||||
throw new Error("Unsupported renderer inputs.");
|
||||
const rng = createRng(visualSeed),
|
||||
palette = paletteFor(p),
|
||||
nodes: SceneNode[] = [];
|
||||
const add = (
|
||||
assetId: AssetId,
|
||||
layer: number,
|
||||
x = 0,
|
||||
y = 0,
|
||||
rotation = 0,
|
||||
scale = 1,
|
||||
pattern: SceneNode["pattern"] = "solid",
|
||||
) =>
|
||||
nodes.push({
|
||||
assetId,
|
||||
layer,
|
||||
x: round(x),
|
||||
y: round(y),
|
||||
rotation: round(rotation),
|
||||
scale: round(scale),
|
||||
palette: { ...palette },
|
||||
pattern,
|
||||
});
|
||||
add("shadow", 0);
|
||||
add((visualSeed & 1) === 0 ? "pot_terracotta" : "pot_ceramic", 1);
|
||||
const stageScale = {
|
||||
seedling: 0.42,
|
||||
juvenile: 0.69,
|
||||
mature: 1,
|
||||
flowering: 1,
|
||||
}[growthStage];
|
||||
const active =
|
||||
growthStage === "seedling"
|
||||
? anchors.compact.slice(-2)
|
||||
: growthStage === "juvenile"
|
||||
? anchors[p.growthHabit].slice(-4)
|
||||
: anchors[p.growthHabit];
|
||||
const positions = active.map(([x, y, r, s], i) => ({
|
||||
x: 256 + (x - 256) * stageScale + (rng() - 0.5) * 8 * stageScale,
|
||||
y: 389 + (y - 389) * stageScale + (rng() - 0.5) * 8 * stageScale,
|
||||
r: r + (rng() - 0.5) * 9,
|
||||
s: s * stageScale * (0.95 + rng() * 0.1),
|
||||
rear: i % 2 === 0,
|
||||
}));
|
||||
for (const leaf of positions.filter((v) => v.rear))
|
||||
add(
|
||||
("leaf_" + p.leafShape) as AssetId,
|
||||
2,
|
||||
leaf.x,
|
||||
leaf.y,
|
||||
leaf.r,
|
||||
leaf.s,
|
||||
p.leafPattern,
|
||||
);
|
||||
// Stage scaling is around the soil line. Branch primitives share the canonical 512-space.
|
||||
add(
|
||||
("branch_" +
|
||||
(growthStage === "seedling" ? "compact" : p.growthHabit)) as AssetId,
|
||||
3,
|
||||
256 * (1 - stageScale),
|
||||
389 * (1 - stageScale),
|
||||
0,
|
||||
stageScale,
|
||||
);
|
||||
add("stem", 3);
|
||||
for (const leaf of positions.filter((v) => !v.rear))
|
||||
add(
|
||||
("leaf_" + p.leafShape) as AssetId,
|
||||
4,
|
||||
leaf.x,
|
||||
leaf.y,
|
||||
leaf.r,
|
||||
leaf.s,
|
||||
p.leafPattern,
|
||||
);
|
||||
if (growthStage === "flowering") {
|
||||
const count = { low: 2, medium: 4, high: 6 }[p.flowerDensity];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = 230 + (i % 2 ? 1 : -1) * (18 + Math.floor(i / 2) * 19),
|
||||
y = 130 + Math.floor(i / 2) * 35;
|
||||
const flowerX = x + (rng() - 0.5) * 9,
|
||||
flowerY = y + (rng() - 0.5) * 7;
|
||||
const rotation = (rng() - 0.5) * 30,
|
||||
scale = 0.65 + rng() * 0.15;
|
||||
const dx = flowerX - 256,
|
||||
dy = flowerY - 240;
|
||||
add(
|
||||
"flower_stalk",
|
||||
3,
|
||||
256,
|
||||
240,
|
||||
(Math.atan2(dx, -dy) * 180) / Math.PI,
|
||||
Math.hypot(dx, dy) / 100,
|
||||
);
|
||||
add(
|
||||
(["bloom_round", "bloom_star", "bloom_cup", "bloom_double"] as const)[
|
||||
i % 4
|
||||
],
|
||||
6,
|
||||
flowerX,
|
||||
flowerY,
|
||||
rotation,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
add((visualSeed & 1) === 0 ? "pot_terracotta" : "pot_ceramic", 8);
|
||||
return sceneSchema.parse({
|
||||
speciesId,
|
||||
rendererVersion,
|
||||
visualSeed,
|
||||
growthStage,
|
||||
phenotype: p,
|
||||
nodes: nodes.sort((a, b) => a.layer - b.layer),
|
||||
});
|
||||
}
|
||||
66
packages/renderer/src/palettes.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { Phenotype } from "@botanica/genetics";
|
||||
import type { Palette } from "./types.js";
|
||||
export const palettes = {
|
||||
fern: {
|
||||
leafBase: "#537b49",
|
||||
leafShade: "#264a38",
|
||||
vein: "#b3c88a",
|
||||
variegation: "#e7dfad",
|
||||
},
|
||||
sage: {
|
||||
leafBase: "#698966",
|
||||
leafShade: "#315747",
|
||||
vein: "#bccc9f",
|
||||
variegation: "#eee6bb",
|
||||
},
|
||||
plum: {
|
||||
leafBase: "#865273",
|
||||
leafShade: "#422d4e",
|
||||
vein: "#ce96ad",
|
||||
variegation: "#e7cfb5",
|
||||
},
|
||||
aubergine: {
|
||||
leafBase: "#8c5977",
|
||||
leafShade: "#462f49",
|
||||
vein: "#d6a1b7",
|
||||
variegation: "#ebdca5",
|
||||
},
|
||||
copper: {
|
||||
leafBase: "#b56b58",
|
||||
leafShade: "#6c3c44",
|
||||
vein: "#e7b28c",
|
||||
variegation: "#f1d7a0",
|
||||
},
|
||||
citron: {
|
||||
leafBase: "#b7b95d",
|
||||
leafShade: "#657242",
|
||||
vein: "#f0df9b",
|
||||
variegation: "#faf0c3",
|
||||
},
|
||||
} as const;
|
||||
export function paletteFor(p: Phenotype): Palette {
|
||||
const name =
|
||||
p.pigment === "green"
|
||||
? p.leafPattern === "solid"
|
||||
? "fern"
|
||||
: "sage"
|
||||
: p.pigment === "purple"
|
||||
? p.leafPattern === "solid"
|
||||
? "plum"
|
||||
: "aubergine"
|
||||
: p.pigment === "red"
|
||||
? "copper"
|
||||
: "citron";
|
||||
return {
|
||||
...palettes[name],
|
||||
vein: p.mutations.includes("golden_veins")
|
||||
? "#e4b84e"
|
||||
: palettes[name].vein,
|
||||
flower: {
|
||||
white: "#f3e9cf",
|
||||
pink: "#d797a1",
|
||||
blue: "#899dc4",
|
||||
red: "#bf605e",
|
||||
}[p.flowerColor],
|
||||
};
|
||||
}
|
||||
151
packages/renderer/src/primitives.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import type { AssetId } from "./types.js";
|
||||
export interface Primitive {
|
||||
assetId: string;
|
||||
category: "leaf" | "branch" | "stem" | "bloom" | "pot" | "shadow";
|
||||
anchor: readonly [number, number];
|
||||
bounds: readonly [number, number, number, number];
|
||||
paletteSlots: readonly string[];
|
||||
rendererVersion: 1;
|
||||
outline: string;
|
||||
shade?: string;
|
||||
highlight?: string;
|
||||
veins?: string;
|
||||
}
|
||||
const leaf = (
|
||||
id: string,
|
||||
outline: string,
|
||||
shade: string,
|
||||
veins: string,
|
||||
highlight: string,
|
||||
): Primitive => ({
|
||||
assetId: "velvetleaf/" + id + "_v1",
|
||||
category: "leaf",
|
||||
anchor: [50, 128],
|
||||
bounds: [0, 0, 100, 140],
|
||||
paletteSlots: ["leafBase", "leafShade", "vein", "variegation"],
|
||||
rendererVersion: 1,
|
||||
outline,
|
||||
shade,
|
||||
veins,
|
||||
highlight,
|
||||
});
|
||||
const branch = (id: string, outline: string): Primitive => ({
|
||||
assetId: "velvetleaf/" + id + "_v1",
|
||||
category: "branch",
|
||||
anchor: [0, 0],
|
||||
bounds: [0, 0, 512, 512],
|
||||
paletteSlots: ["leafShade", "vein"],
|
||||
rendererVersion: 1,
|
||||
outline,
|
||||
});
|
||||
const bloom = (id: string, outline: string): Primitive => ({
|
||||
assetId: "velvetleaf/" + id + "_v1",
|
||||
category: "bloom",
|
||||
anchor: [25, 25],
|
||||
bounds: [0, 0, 50, 50],
|
||||
paletteSlots: ["flower", "variegation"],
|
||||
rendererVersion: 1,
|
||||
outline,
|
||||
});
|
||||
const pot = (
|
||||
id: string,
|
||||
outline: string,
|
||||
shade: string,
|
||||
highlight: string,
|
||||
): Primitive => ({
|
||||
assetId: "velvetleaf/" + id + "_v1",
|
||||
category: "pot",
|
||||
anchor: [0, 0],
|
||||
bounds: [0, 0, 512, 512],
|
||||
paletteSlots: [],
|
||||
rendererVersion: 1,
|
||||
outline,
|
||||
shade,
|
||||
highlight,
|
||||
});
|
||||
export const primitives: Record<AssetId, Primitive> = {
|
||||
leaf_oval: leaf(
|
||||
"leaf_oval",
|
||||
"M50 128 C34 118 19 109 13 91 C5 69 13 39 27 23 Q40 9 50 7 Q75 17 87 46 C101 80 82 113 50 128Z",
|
||||
"M50 128 C71 99 77 69 65 24 Q91 48 89 73 Q91 104 50 128Z",
|
||||
"M50 126 Q45 70 50 16 M48 109 Q30 95 23 78 M47 91 Q28 74 26 53 M47 70 L34 37 M49 105 Q70 89 78 70 M48 82 Q66 65 69 44 M49 58 L58 29",
|
||||
"M25 72 Q22 47 42 28 Q30 47 32 67Z",
|
||||
),
|
||||
leaf_lobed: leaf(
|
||||
"leaf_lobed",
|
||||
"M50 130 Q33 123 29 112 L14 112 L17 98 Q5 93 7 80 L14 75 L6 61 L19 57 L16 43 L29 43 L27 29 L40 31 L50 7 L60 31 L73 29 L71 43 L84 43 L81 57 L94 61 L86 75 Q97 91 83 98 L86 112 L71 112 Q67 123 50 130Z",
|
||||
"M50 130 Q61 93 57 44 L50 7 L60 31 L73 29 L71 43 L84 43 L81 57 L94 61 L86 75 Q97 91 83 98 L86 112 L71 112 Q67 123 50 130Z",
|
||||
"M50 127 L50 19 M50 113 L22 105 M50 96 L17 83 M50 78 L20 62 M50 60 L30 43 M50 113 L78 105 M50 96 L83 83 M50 78 L80 62 M50 60 L70 43",
|
||||
"M23 79 L29 66 L35 83 L30 99Z",
|
||||
),
|
||||
leaf_pointed: leaf(
|
||||
"leaf_pointed",
|
||||
"M50 131 C24 111 14 88 22 64 Q31 36 50 4 Q66 34 78 64 C90 95 69 119 50 131Z",
|
||||
"M50 131 Q66 97 60 58 L50 4 Q66 34 78 64 C90 95 69 119 50 131Z",
|
||||
"M50 127 Q45 71 50 15 M48 109 L30 87 M47 91 L32 65 M47 71 L39 43 M49 107 L70 83 M48 87 L68 62 M49 65 L59 39",
|
||||
"M30 83 Q28 61 45 33 Q35 58 37 77Z",
|
||||
),
|
||||
branch_compact: branch(
|
||||
"branch_compact",
|
||||
"M256 394 Q249 310 252 195 M253 348 Q219 344 211 325 M254 326 Q289 322 299 304 M253 295 Q226 286 223 268 M253 270 Q279 257 285 242 M252 242 Q234 223 239 212",
|
||||
),
|
||||
branch_bushy: branch(
|
||||
"branch_bushy",
|
||||
"M256 394 Q246 282 260 164 M252 354 Q212 350 188 319 M254 331 Q296 327 325 299 M252 297 Q223 284 207 254 M255 268 Q293 253 311 222 M256 232 Q233 204 239 179 M258 202 Q277 178 278 156",
|
||||
),
|
||||
branch_trailing: branch(
|
||||
"branch_trailing",
|
||||
"M256 394 Q249 298 259 211 M253 348 C219 297 163 318 170 378 M254 347 C304 291 346 322 342 380 M255 300 Q222 275 209 263 M256 271 Q291 255 303 243 M257 239 L241 212",
|
||||
),
|
||||
stem: { ...branch("stem", "M256 395 Q251 368 253 342"), category: "stem" },
|
||||
flower_stalk: {
|
||||
assetId: "velvetleaf/flower_stalk_v1",
|
||||
category: "stem",
|
||||
anchor: [0, 0],
|
||||
bounds: [-10, -100, 20, 100],
|
||||
paletteSlots: ["leafShade"],
|
||||
rendererVersion: 1,
|
||||
outline: "M0 0 Q-8-50 0-100",
|
||||
},
|
||||
bloom_round: bloom(
|
||||
"bloom_round",
|
||||
"M25 25 C5 28 3 10 15 9 Q22 8 25 20 C22 0 40 0 41 12 Q42 21 29 24 C49 16 56 31 43 37 Q33 40 28 28 C38 46 22 55 16 43 Q12 35 22 28 C6 42 -2 27 10 22 Q17 19 25 25Z",
|
||||
),
|
||||
bloom_star: bloom(
|
||||
"bloom_star",
|
||||
"M25 3 L31 18 L47 14 L37 28 L47 42 L30 37 L24 49 L20 35 L3 39 L13 25 L3 12 L20 17Z",
|
||||
),
|
||||
bloom_cup: bloom(
|
||||
"bloom_cup",
|
||||
"M25 43 C5 37 3 20 10 11 Q20 9 25 20 Q29 6 39 10 C50 23 42 37 25 43Z",
|
||||
),
|
||||
bloom_double: bloom(
|
||||
"bloom_double",
|
||||
"M25 9 Q35 0 39 12 Q52 12 44 26 Q53 38 39 40 Q35 53 25 43 Q12 52 10 39 Q-1 33 8 24 Q0 11 14 11 Q18 0 25 9Z",
|
||||
),
|
||||
pot_terracotta: pot(
|
||||
"pot_terracotta",
|
||||
"M195 389 L207 468 Q256 487 305 468 L317 389Z",
|
||||
"M285 396 L278 477 Q297 474 305 468 L317 389Z",
|
||||
"M208 404 L218 461 Q230 466 240 466 L231 405Z",
|
||||
),
|
||||
pot_ceramic: pot(
|
||||
"pot_ceramic",
|
||||
"M195 390 Q188 458 215 472 Q256 485 297 472 Q322 457 317 390Z",
|
||||
"M292 398 Q300 448 281 477 Q308 474 314 452 Q321 433 317 390Z",
|
||||
"M209 408 Q205 441 218 459 L230 463 Q219 436 224 409Z",
|
||||
),
|
||||
shadow: {
|
||||
assetId: "velvetleaf/shadow_v1",
|
||||
category: "shadow",
|
||||
anchor: [0, 0],
|
||||
bounds: [0, 0, 512, 512],
|
||||
paletteSlots: [],
|
||||
rendererVersion: 1,
|
||||
outline: "M156 479 C156 462 356 462 356 479 C356 495 156 495 156 479Z",
|
||||
},
|
||||
};
|
||||
export const variegationPath =
|
||||
"M50 8 Q28 41 35 68 Q41 90 22 109 L42 125 Q61 96 50 71 Q41 44 62 22Z M77 45 Q65 65 73 87 L88 74Z";
|
||||
export const marblePath =
|
||||
"M9 54 Q32 26 53 54 T92 62 L93 78 Q66 87 49 69 T10 76Z M14 96 Q32 78 50 99 T83 101 L70 115 Q48 111 38 105 T22 113Z M34 28 Q49 15 67 30 L65 37 Q48 28 38 41Z";
|
||||
172
packages/renderer/src/renderSvg.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { z } from "zod";
|
||||
import { primitives, variegationPath, marblePath } from "./primitives.js";
|
||||
import { sceneSchema, type PlantScene, type Background } from "./types.js";
|
||||
const optionsSchema = z.strictObject({
|
||||
namespace: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z][a-zA-Z0-9_-]{0,79}$/)
|
||||
.default("plant"),
|
||||
background: z.enum(["transparent", "warm"]).default("transparent"),
|
||||
});
|
||||
export function renderSvg(
|
||||
input: PlantScene,
|
||||
options: { namespace?: string; background?: Background } = {},
|
||||
): string {
|
||||
const scene = sceneSchema.parse(input),
|
||||
{ namespace: ns, background } = optionsSchema.parse(options);
|
||||
const defs: string[] = [],
|
||||
content: string[] = [];
|
||||
if (background === "warm")
|
||||
content.push('<path fill="#f6f3e8" d="M0 0H512V512H0Z"/>');
|
||||
scene.nodes.forEach((node, i) => {
|
||||
const asset = primitives[node.assetId],
|
||||
p = node.palette,
|
||||
id = ns + "-" + i;
|
||||
if (asset.category === "leaf") {
|
||||
defs.push(
|
||||
'<clipPath id="' +
|
||||
id +
|
||||
'-clip"><path d="' +
|
||||
asset.outline +
|
||||
'"/></clipPath><linearGradient id="' +
|
||||
id +
|
||||
'-fill" x1="0" y1="0" x2="1" y2="1"><stop stop-color="' +
|
||||
p.leafBase +
|
||||
'"/><stop offset="1" stop-color="' +
|
||||
p.leafShade +
|
||||
'"/></linearGradient>',
|
||||
);
|
||||
const pattern =
|
||||
node.pattern === "solid"
|
||||
? ""
|
||||
: '<path d="' +
|
||||
(node.pattern === "variegated" ? variegationPath : marblePath) +
|
||||
'" fill="' +
|
||||
p.variegation +
|
||||
'" opacity="' +
|
||||
(node.pattern === "variegated" ? ".92" : ".72") +
|
||||
'" clip-path="url(#' +
|
||||
id +
|
||||
'-clip)"/>';
|
||||
content.push(
|
||||
'<g data-asset="' +
|
||||
node.assetId +
|
||||
'" transform="translate(' +
|
||||
node.x +
|
||||
" " +
|
||||
node.y +
|
||||
") rotate(" +
|
||||
node.rotation +
|
||||
") scale(" +
|
||||
node.scale +
|
||||
') translate(-50 -128)"><path d="' +
|
||||
asset.outline +
|
||||
'" fill="url(#' +
|
||||
id +
|
||||
'-fill)" stroke="' +
|
||||
p.leafShade +
|
||||
'" stroke-width="1.1"/>' +
|
||||
pattern +
|
||||
'<path d="' +
|
||||
asset.shade +
|
||||
'" fill="' +
|
||||
p.leafShade +
|
||||
'" opacity=".18"/><path d="' +
|
||||
asset.highlight +
|
||||
'" fill="' +
|
||||
p.variegation +
|
||||
'" opacity=".17"/><path d="' +
|
||||
asset.veins +
|
||||
'" fill="none" stroke="' +
|
||||
p.vein +
|
||||
'" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" opacity=".9"/></g>',
|
||||
);
|
||||
} else if (asset.category === "branch" || asset.category === "stem")
|
||||
content.push(
|
||||
'<path data-asset="' +
|
||||
node.assetId +
|
||||
'" d="' +
|
||||
asset.outline +
|
||||
'" transform="translate(' +
|
||||
node.x +
|
||||
" " +
|
||||
node.y +
|
||||
") rotate(" +
|
||||
node.rotation +
|
||||
") scale(" +
|
||||
node.scale +
|
||||
')" fill="none" stroke="#4a6648" stroke-width="' +
|
||||
(node.assetId === "flower_stalk"
|
||||
? 2
|
||||
: asset.category === "stem"
|
||||
? 6
|
||||
: 4) +
|
||||
'" stroke-linecap="round"/>',
|
||||
);
|
||||
else if (asset.category === "bloom")
|
||||
content.push(
|
||||
'<g data-asset="' +
|
||||
node.assetId +
|
||||
'" transform="translate(' +
|
||||
node.x +
|
||||
" " +
|
||||
node.y +
|
||||
") rotate(" +
|
||||
node.rotation +
|
||||
") scale(" +
|
||||
node.scale +
|
||||
') translate(-25 -25)"><path d="' +
|
||||
asset.outline +
|
||||
'" fill="' +
|
||||
p.flower +
|
||||
'" stroke="#695e72" stroke-width=".7"/><path d="M25 15L25 36M15 22L34 29M17 32L33 19" stroke="#fff5de" opacity=".45" stroke-width="2"/><circle cx="25" cy="25" r="5" fill="#c7a15b"/><circle cx="24" cy="24" r="1.7" fill="#f5dc93"/></g>',
|
||||
);
|
||||
else if (asset.category === "shadow")
|
||||
content.push(
|
||||
'<path d="' + asset.outline + '" fill="#324534" opacity=".1"/>',
|
||||
);
|
||||
else if (asset.category === "pot") {
|
||||
const ceramic = node.assetId === "pot_ceramic",
|
||||
base = ceramic ? "#d8d8bf" : "#b77b5d",
|
||||
shade = ceramic ? "#b5bba2" : "#935f4c",
|
||||
light = ceramic ? "#f4ecd7" : "#dda582";
|
||||
if (node.layer === 1)
|
||||
content.push(
|
||||
'<path d="' +
|
||||
asset.outline +
|
||||
'" fill="' +
|
||||
base +
|
||||
'"/><path d="' +
|
||||
asset.shade +
|
||||
'" fill="' +
|
||||
shade +
|
||||
'"/><path d="' +
|
||||
asset.highlight +
|
||||
'" fill="' +
|
||||
light +
|
||||
'" opacity=".55"/><path d="M211 445Q256 459 303 445" stroke="' +
|
||||
shade +
|
||||
'" opacity=".28" fill="none"/><ellipse cx="256" cy="389" rx="61" ry="12" fill="#66523d"/><ellipse cx="256" cy="389" rx="51" ry="7" fill="#493f31"/>',
|
||||
);
|
||||
else
|
||||
content.push(
|
||||
'<path d="M194 387 Q256 404 318 387 L317 401 Q256 418 195 401Z" fill="' +
|
||||
base +
|
||||
'"/><path d="M196 388Q256 405 316 388" fill="none" stroke="' +
|
||||
light +
|
||||
'" stroke-width="3"/>',
|
||||
);
|
||||
}
|
||||
});
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-labelledby="' +
|
||||
ns +
|
||||
'-title"><title id="' +
|
||||
ns +
|
||||
'-title">Velvetleaf botanical illustration</title><defs>' +
|
||||
defs.join("") +
|
||||
"</defs>" +
|
||||
content.join("") +
|
||||
"</svg>"
|
||||
);
|
||||
}
|
||||
155
packages/renderer/src/renderer.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
exampleGenome,
|
||||
resolvePhenotype,
|
||||
type Phenotype,
|
||||
} from "@botanica/genetics";
|
||||
import {
|
||||
layoutPlant,
|
||||
renderSvg,
|
||||
stages,
|
||||
primitives,
|
||||
type PlantScene,
|
||||
} from "./index.js";
|
||||
const phenotype = resolvePhenotype("velvetleaf", exampleGenome);
|
||||
describe("renderer v1", () => {
|
||||
it("replays geometry and SVG exactly while visual seeds only affect cosmetics", () => {
|
||||
const scene = layoutPlant("velvetleaf", phenotype, 12, "flowering");
|
||||
expect(layoutPlant("velvetleaf", phenotype, 12, "flowering")).toEqual(
|
||||
scene,
|
||||
);
|
||||
expect(renderSvg(scene)).toBe(
|
||||
renderSvg(layoutPlant("velvetleaf", phenotype, 12, "flowering")),
|
||||
);
|
||||
const other = layoutPlant("velvetleaf", phenotype, 13, "flowering");
|
||||
expect(other.phenotype).toEqual(scene.phenotype);
|
||||
expect(other.nodes).not.toEqual(scene.nodes);
|
||||
expect(renderSvg(scene, { namespace: "a" })).toContain('id="a-');
|
||||
expect(renderSvg(scene, { namespace: "b" })).not.toContain('id="a-');
|
||||
});
|
||||
it("keeps all leaves, pots and flowers inside the viewBox over varied shapes, habits, stages and seeds", () => {
|
||||
for (const shape of ["oval", "lobed", "pointed"] as const)
|
||||
for (const habit of ["compact", "bushy", "trailing"] as const)
|
||||
for (const stage of stages)
|
||||
for (let seed = 0; seed < 100; seed++) {
|
||||
const scene = layoutPlant(
|
||||
"velvetleaf",
|
||||
{ ...phenotype, leafShape: shape, growthHabit: habit },
|
||||
seed,
|
||||
stage,
|
||||
);
|
||||
for (const node of scene.nodes) {
|
||||
const asset = primitives[node.assetId];
|
||||
if (!["leaf", "bloom"].includes(asset.category)) continue;
|
||||
const [bx, by, w, h] = asset.bounds,
|
||||
[ax, ay] = asset.anchor;
|
||||
for (const [x, y] of [
|
||||
[bx, by],
|
||||
[bx + w, by],
|
||||
[bx, by + h],
|
||||
[bx + w, by + h],
|
||||
]) {
|
||||
const radians = (node.rotation * Math.PI) / 180,
|
||||
dx = (x - ax) * node.scale,
|
||||
dy = (y - ay) * node.scale;
|
||||
const px =
|
||||
node.x + dx * Math.cos(radians) - dy * Math.sin(radians),
|
||||
py = node.y + dx * Math.sin(radians) + dy * Math.cos(radians);
|
||||
expect(px).toBeGreaterThan(10);
|
||||
expect(px).toBeLessThan(502);
|
||||
expect(py).toBeGreaterThan(10);
|
||||
expect(py).toBeLessThan(502);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
it("shows blooms only when flowering and respects density and stage counts", () => {
|
||||
for (const stage of stages) {
|
||||
const scene = layoutPlant("velvetleaf", phenotype, 1, stage);
|
||||
expect(
|
||||
scene.nodes.filter((n) => n.assetId.startsWith("bloom")).length,
|
||||
).toBe(stage === "flowering" ? 6 : 0);
|
||||
expect(
|
||||
scene.nodes.filter((n) => n.assetId.startsWith("leaf")).length,
|
||||
).toBe(stage === "seedling" ? 2 : stage === "juvenile" ? 4 : 6);
|
||||
}
|
||||
for (const [flowerDensity, count] of [
|
||||
["low", 2],
|
||||
["medium", 4],
|
||||
["high", 6],
|
||||
] as const)
|
||||
expect(
|
||||
layoutPlant(
|
||||
"velvetleaf",
|
||||
{ ...phenotype, flowerDensity },
|
||||
0,
|
||||
"flowering",
|
||||
).nodes.filter((n) => n.assetId.startsWith("bloom")),
|
||||
).toHaveLength(count);
|
||||
});
|
||||
it("renders distinct silhouettes, pigments and clipped patterns, with golden veins affecting veins alone", () => {
|
||||
const outputs = new Set<string>();
|
||||
for (const leafShape of ["oval", "lobed", "pointed"] as const)
|
||||
for (const pigment of ["green", "purple"] as const)
|
||||
for (const leafPattern of ["solid", "variegated"] as const)
|
||||
outputs.add(
|
||||
renderSvg(
|
||||
layoutPlant(
|
||||
"velvetleaf",
|
||||
{ ...phenotype, leafShape, pigment, leafPattern },
|
||||
2,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(outputs.size).toBe(12);
|
||||
const a = layoutPlant("velvetleaf", phenotype, 1),
|
||||
b = layoutPlant(
|
||||
"velvetleaf",
|
||||
{ ...phenotype, mutations: ["golden_veins"] },
|
||||
1,
|
||||
);
|
||||
expect(a.nodes.map(({ palette, ...node }) => node)).toEqual(
|
||||
b.nodes.map(({ palette, ...node }) => node),
|
||||
);
|
||||
expect(b.nodes[0].palette.vein).toBe("#e4b84e");
|
||||
expect(
|
||||
renderSvg(
|
||||
layoutPlant("velvetleaf", { ...phenotype, leafPattern: "marbled" }, 1),
|
||||
),
|
||||
).toContain('clip-path="url(#');
|
||||
});
|
||||
it("rejects XML injection, unknown assets, non-finite geometry and unsafe palettes", () => {
|
||||
const scene = layoutPlant("velvetleaf", phenotype, 1);
|
||||
expect(() => renderSvg(scene, { namespace: '"><script/>' })).toThrow();
|
||||
for (const bad of [
|
||||
{ ...scene, nodes: [{ ...scene.nodes[0], assetId: "<script>" }] },
|
||||
{ ...scene, nodes: [{ ...scene.nodes[0], x: NaN }] },
|
||||
{
|
||||
...scene,
|
||||
nodes: [
|
||||
{
|
||||
...scene.nodes[0],
|
||||
palette: {
|
||||
...scene.nodes[0].palette,
|
||||
leafBase: "url(https://evil)",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(() => renderSvg(bad as unknown as PlantScene)).toThrow();
|
||||
expect(() =>
|
||||
layoutPlant("velvetleaf", phenotype, 1, "mature", 2),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
layoutPlant(
|
||||
"velvetleaf",
|
||||
{ ...phenotype, leafShape: "<script>" } as unknown as Phenotype,
|
||||
1,
|
||||
),
|
||||
).toThrow();
|
||||
const svg = renderSvg(scene, { background: "warm" });
|
||||
expect(svg).toContain('viewBox="0 0 512 512"');
|
||||
expect(svg).not.toMatch(/<script|foreignObject|<image|href=/);
|
||||
});
|
||||
});
|
||||
51
packages/renderer/src/types.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { z } from "zod";
|
||||
import { phenotypeSchema } from "@botanica/genetics";
|
||||
export const stages = ["seedling", "juvenile", "mature", "flowering"] as const;
|
||||
export type GrowthStage = (typeof stages)[number];
|
||||
export const assetIds = [
|
||||
"leaf_oval",
|
||||
"leaf_lobed",
|
||||
"leaf_pointed",
|
||||
"branch_compact",
|
||||
"branch_bushy",
|
||||
"branch_trailing",
|
||||
"stem",
|
||||
"flower_stalk",
|
||||
"bloom_round",
|
||||
"bloom_star",
|
||||
"bloom_cup",
|
||||
"bloom_double",
|
||||
"pot_terracotta",
|
||||
"pot_ceramic",
|
||||
"shadow",
|
||||
] as const;
|
||||
export type AssetId = (typeof assetIds)[number];
|
||||
export const paletteSchema = z.strictObject({
|
||||
leafBase: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
leafShade: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
vein: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
variegation: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
flower: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
});
|
||||
export type Palette = z.infer<typeof paletteSchema>;
|
||||
export const sceneNodeSchema = z.strictObject({
|
||||
layer: z.number().int().min(0).max(8),
|
||||
assetId: z.enum(assetIds),
|
||||
x: z.number().finite().min(-100).max(612),
|
||||
y: z.number().finite().min(-100).max(612),
|
||||
rotation: z.number().finite().min(-180).max(180),
|
||||
scale: z.number().finite().min(0.01).max(2),
|
||||
palette: paletteSchema,
|
||||
pattern: z.enum(["solid", "variegated", "marbled"]).default("solid"),
|
||||
});
|
||||
export type SceneNode = z.infer<typeof sceneNodeSchema>;
|
||||
export const sceneSchema = z.strictObject({
|
||||
speciesId: z.literal("velvetleaf"),
|
||||
rendererVersion: z.literal(1),
|
||||
visualSeed: z.number().int().min(-2147483648).max(2147483647),
|
||||
growthStage: z.enum(stages),
|
||||
phenotype: phenotypeSchema,
|
||||
nodes: z.array(sceneNodeSchema).max(80),
|
||||
});
|
||||
export type PlantScene = z.infer<typeof sceneSchema>;
|
||||
export type Background = "transparent" | "warm";
|
||||
216
scripts/verify-m2.cjs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
const fs = require("node:fs"),
|
||||
path = require("node:path");
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || "playwright");
|
||||
const directory = "artifacts/m2";
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.BROWSER_EXECUTABLE
|
||||
? { executablePath: process.env.BROWSER_EXECUTABLE }
|
||||
: {}),
|
||||
});
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1440, height: 1100 },
|
||||
reducedMotion: "reduce",
|
||||
acceptDownloads: true,
|
||||
});
|
||||
const report = { routes: [], exports: [], errors: [] };
|
||||
page.on("pageerror", (e) => report.errors.push(e.message));
|
||||
async function checkWidth(route, width) {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await page.goto("http://localhost:8155" + route);
|
||||
await page
|
||||
.getByRole("status")
|
||||
.filter({ hasText: "Laboratory online" })
|
||||
.waitFor();
|
||||
const size = await page.evaluate(() => ({
|
||||
width: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
if (size.width !== size.scrollWidth)
|
||||
throw Error("Horizontal overflow " + route + " " + width);
|
||||
report.routes.push({ route, ...size });
|
||||
}
|
||||
await checkWidth("/lab", 1440);
|
||||
await page.getByLabel("Golden veins · manual preview").check();
|
||||
const before = await page.locator(".trait-summary").innerText();
|
||||
await page.getByLabel("Visual seed", { exact: true }).fill("9876");
|
||||
if ((await page.locator(".trait-summary").innerText()) !== before)
|
||||
throw Error("Seed changed phenotype");
|
||||
await page.getByLabel("Visual seed", { exact: true }).fill("8155");
|
||||
await page.getByLabel("Golden veins · manual preview").uncheck();
|
||||
await page.screenshot({
|
||||
path: directory + "/lab-desktop.png",
|
||||
fullPage: true,
|
||||
});
|
||||
for (const background of ["warm", "transparent"]) {
|
||||
await page
|
||||
.getByLabel("Preview & export background")
|
||||
.selectOption(background);
|
||||
for (const format of ["svg", "png"]) {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page
|
||||
.getByRole("button", {
|
||||
name: "Export " + format.toUpperCase(),
|
||||
exact: true,
|
||||
})
|
||||
.click(),
|
||||
]);
|
||||
if (await download.failure()) throw Error("Download failed");
|
||||
await download.saveAs(directory + "/export-" + background + "." + format);
|
||||
}
|
||||
const svg = fs.readFileSync(
|
||||
directory + "/export-" + background + ".svg",
|
||||
"utf8",
|
||||
);
|
||||
if (
|
||||
!svg.includes('viewBox="0 0 512 512"') ||
|
||||
/<script|foreignObject|<image|href=/.test(svg)
|
||||
)
|
||||
throw Error("Unsafe SVG export");
|
||||
const png = fs
|
||||
.readFileSync(directory + "/export-" + background + ".png")
|
||||
.toString("base64");
|
||||
const comparison = await page.evaluate(
|
||||
async ({ svg, png }) => {
|
||||
async function raster(url) {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const c = canvas.getContext("2d");
|
||||
c.drawImage(image, 0, 0, 512, 512);
|
||||
return {
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
data: c.getImageData(0, 0, 512, 512).data,
|
||||
};
|
||||
}
|
||||
const a = await raster("data:image/png;base64," + png),
|
||||
b = await raster(
|
||||
"data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg),
|
||||
);
|
||||
const preview = document.querySelector(
|
||||
".specimen>.plant-preview svg",
|
||||
).outerHTML;
|
||||
const c = await raster(
|
||||
"data:image/svg+xml;charset=utf-8," + encodeURIComponent(preview),
|
||||
);
|
||||
let pngDifference = 0,
|
||||
previewDifference = 0;
|
||||
for (let i = 0; i < a.data.length; i++) {
|
||||
pngDifference += Math.abs(a.data[i] - b.data[i]);
|
||||
previewDifference += Math.abs(a.data[i] - c.data[i]);
|
||||
}
|
||||
return {
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
corner: Array.from(a.data.slice(0, 4)),
|
||||
pngToSvgMeanDifference: pngDifference / a.data.length,
|
||||
pngToPreviewMeanDifference: previewDifference / a.data.length,
|
||||
};
|
||||
},
|
||||
{ svg, png },
|
||||
);
|
||||
if (
|
||||
comparison.width !== 512 ||
|
||||
comparison.height !== 512 ||
|
||||
comparison.pngToSvgMeanDifference > 0.1 ||
|
||||
comparison.pngToPreviewMeanDifference > 0.1
|
||||
)
|
||||
throw Error("Export mismatch " + JSON.stringify(comparison));
|
||||
if (
|
||||
background === "transparent"
|
||||
? comparison.corner[3] !== 0
|
||||
: comparison.corner.join(",") !== "246,243,232,255"
|
||||
)
|
||||
throw Error("Wrong background alpha/color");
|
||||
report.exports.push({ background, ...comparison });
|
||||
}
|
||||
await page.getByLabel("Preview & export background").selectOption("warm");
|
||||
await page
|
||||
.locator(".specimen>.plant-preview")
|
||||
.screenshot({ path: directory + "/hero-512.png" });
|
||||
await page
|
||||
.locator(".specimen-bottom .plant-preview")
|
||||
.screenshot({ path: directory + "/icon-64.png" });
|
||||
await checkWidth("/gallery", 1440);
|
||||
if ((await page.locator(".plant-card").count()) !== 12)
|
||||
throw Error("Expected 12 studies");
|
||||
await page.screenshot({
|
||||
path: directory + "/atlas-desktop.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.__qa = {
|
||||
gaps: [],
|
||||
tasks: [],
|
||||
start: performance.now(),
|
||||
last: performance.now(),
|
||||
};
|
||||
new PerformanceObserver((list) =>
|
||||
window.__qa.tasks.push(...list.getEntries().map((e) => e.duration)),
|
||||
).observe({ type: "longtask", buffered: false });
|
||||
const frame = (now) => {
|
||||
window.__qa.gaps.push(now - window.__qa.last);
|
||||
window.__qa.last = now;
|
||||
if (now - window.__qa.start < 3000) requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "Show 100-seed study", exact: true })
|
||||
.click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll(".plant-card").length === 100,
|
||||
);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
|
||||
);
|
||||
report.performance = await page.evaluate(() => ({
|
||||
cards: document.querySelectorAll(".plant-card").length,
|
||||
elapsedMs: performance.now() - window.__qa.start,
|
||||
maxFrameGapMs: Math.max(...window.__qa.gaps),
|
||||
longTasksMs: window.__qa.tasks,
|
||||
uniqueIds: new Set(
|
||||
Array.from(document.querySelectorAll("[id]")).map((e) => e.id),
|
||||
).size,
|
||||
ids: document.querySelectorAll("[id]").length,
|
||||
}));
|
||||
if (report.performance.ids !== report.performance.uniqueIds)
|
||||
throw Error("SVG instance ID collision");
|
||||
await page.screenshot({ path: directory + "/smoke-desktop.png" });
|
||||
await page
|
||||
.getByRole("button", { name: "Show 12 studies", exact: true })
|
||||
.click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll(".plant-card").length === 12,
|
||||
);
|
||||
for (const route of ["/lab", "/gallery", "/breed", "/about"]) {
|
||||
await checkWidth(route, 360);
|
||||
if (["/lab", "/gallery"].includes(route))
|
||||
await page.screenshot({
|
||||
path:
|
||||
directory +
|
||||
"/" +
|
||||
(route === "/lab" ? "lab" : "atlas") +
|
||||
"-mobile.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
if (report.errors.length) throw Error(report.errors.join("\n"));
|
||||
fs.writeFileSync(
|
||||
directory + "/browser-checks.json",
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
await browser.close();
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
76
scripts/write-assets.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { primitives } from "@botanica/renderer";
|
||||
const directory = "assets/species/velvetleaf";
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const files = {};
|
||||
const manifest = [];
|
||||
for (const [id, p] of Object.entries(primitives)) {
|
||||
const [x, y, w, h] = p.bounds;
|
||||
manifest.push({
|
||||
assetId: p.assetId,
|
||||
category: p.category,
|
||||
anchor: [(p.anchor[0] - x) / w, (p.anchor[1] - y) / h],
|
||||
bounds: p.bounds,
|
||||
paletteSlots: p.paletteSlots,
|
||||
rendererVersion: p.rendererVersion,
|
||||
});
|
||||
const leaf = p.category === "leaf",
|
||||
line = ["branch", "stem"].includes(p.category);
|
||||
const paths =
|
||||
'<path data-palette="' +
|
||||
(leaf
|
||||
? "leafBase"
|
||||
: line
|
||||
? "leafShade"
|
||||
: p.category === "bloom"
|
||||
? "flower"
|
||||
: "pot") +
|
||||
'" d="' +
|
||||
p.outline +
|
||||
'" fill="' +
|
||||
(line
|
||||
? "none"
|
||||
: leaf
|
||||
? "#537b49"
|
||||
: p.category === "bloom"
|
||||
? "#d797a1"
|
||||
: "#b77b5d") +
|
||||
'" stroke="#264a38" stroke-width="' +
|
||||
(line ? 4 : 1) +
|
||||
'"/>' +
|
||||
(p.shade
|
||||
? '<path data-palette="leafShade" d="' +
|
||||
p.shade +
|
||||
'" fill="#264a38" opacity=".2"/>'
|
||||
: "") +
|
||||
(p.highlight
|
||||
? '<path data-palette="variegation" d="' +
|
||||
p.highlight +
|
||||
'" fill="#e7dfad" opacity=".35"/>'
|
||||
: "") +
|
||||
(p.veins
|
||||
? '<path data-palette="vein" d="' +
|
||||
p.veins +
|
||||
'" stroke="#b3c88a" stroke-width="1.25" fill="none"/>'
|
||||
: "");
|
||||
files[directory + "/" + id + "_v1.svg"] =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="' +
|
||||
[x, y, w, h].join(" ") +
|
||||
'"><title>' +
|
||||
p.assetId +
|
||||
"</title>" +
|
||||
paths +
|
||||
"</svg>\n";
|
||||
}
|
||||
files[directory + "/manifest.json"] = JSON.stringify(manifest, null, 2) + "\n";
|
||||
for (const [file, value] of Object.entries(files)) {
|
||||
if (process.argv.includes("--check")) {
|
||||
if (readFileSync(file, "utf8").replaceAll("\r\n", "\n") !== value)
|
||||
throw new Error("Asset source drift: " + file);
|
||||
} else writeFileSync(file, value);
|
||||
}
|
||||
console.log(
|
||||
Object.keys(files).length +
|
||||
" asset files " +
|
||||
(process.argv.includes("--check") ? "verified" : "written"),
|
||||
);
|
||||