diff --git a/.dev.vars.example b/.dev.vars.example
new file mode 100644
index 0000000..a9a4592
--- /dev/null
+++ b/.dev.vars.example
@@ -0,0 +1,6 @@
+# Copy to .dev.vars for local Worker development. Never use production values here.
+AUTH_USERNAME=local-maker
+AUTH_PASSWORD=replace-with-a-local-only-password
+SESSION_SECRET=replace-with-at-least-32-random-characters
+# Optional: AI remains safely disabled when this is blank or omitted.
+OPENAI_API_KEY=
diff --git a/.gitignore b/.gitignore
index 8ddb7bd..6e28452 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,5 @@ dist/
*.tsbuildinfo
.DS_Store
Thumbs.db
+__pycache__/
+*.pyc
diff --git a/IMPLEMENTATION_ROADMAP.md b/IMPLEMENTATION_ROADMAP.md
new file mode 100644
index 0000000..19af66d
--- /dev/null
+++ b/IMPLEMENTATION_ROADMAP.md
@@ -0,0 +1,171 @@
+# PrintPath Implementation Roadmap
+
+Last updated: 2026-08-24
+
+## 2026-08-24 — Vase estimate + Cozy Harvest phone stand
+
+- Calculated the Porcelain Reed STL's solid volume at approximately 216.5 cm³, or about 275 g of PETG; added a clearly labeled 9–11 hour planning range pending the final Bambu Studio slice.
+- Added Cozy Harvest Dock as phone-stand version 5 without replacing Tidepool Buddy, Capiz Cove, or the two earlier saved directions.
+- Kept the farming-game influence at the level of original pixel geometry: terraced field, structural barn support, stone-bridge cable lip, and replaceable crop tiles; no copied Stardew Valley sprites, logo, characters, or map.
+- Expanded the phone selector to three featured studies with responsive three-, two-, and one-column layouts.
+
+## 2026-08-24 — Porcelain Reed vase + Evergarden bouquet
+
+- Added a third saved vase version without replacing Petal Twist or Leaf Bloom.
+- Translated the supplied white-vase reference into original printable geometry: 245 mm tall, 36 fine vertical ribs, a softly planted base, a clean round rim, 2.4 mm walls, and a 3.2 mm floor.
+- Added the approved vase STL and a white-PETG print guide; the body remains capable of holding real flowers after a leak test.
+- Added a coordinated printable-flower study with three interchangeable head silhouettes, universal keyed stems, leaves, snap-fit color centers, and a removable arrangement grid.
+- Kept the bouquet labeled as design review until its joints and seven-stem composition have fit-tested STLs.
+
+## 2026-08-24 — Capiz Cove shell study
+
+- Added Capiz Cove as version 4 inside the existing phone-stand project rather than replacing Tidepool Buddy.
+- Chose a Filipino-first visual language: white pearlescent capiz-pane rhythm, dark-blue scallop reef structure, and a light-blue measured phone cradle.
+- Added a compact two-version selector so Tidepool Buddy and Capiz Cove can be compared and reopened independently; the earlier forest and fern directions remain recorded in project history.
+- Avoided reproducing Niʻihau shell lei patterns. Hawaiian shell lei remain a living, specific practice; Capiz Cove keeps only a broad shoreline mood while sourcing its cultural design reference from documented Filipino capiz art and architecture.
+
+## 2026-08-24 — Tidepool Buddy phone stand
+
+- Advanced the saved phone-stand project to a third direction: an original ocean-themed Tidepool Buddy with a manta-like base, curling wave support, and removable sea-foam accents.
+- Locked the visual palette to light blue, dark blue, and white as three separately printable parts, keeping the design practical for a single-nozzle P1S without requiring AMS.
+- Preserved the earlier forest-guardian and fern-cradle directions as project history rather than replacing them.
+- CAD remains gated on the exact phone and case envelope, camera bump, viewing orientation, and charging method; the first print is a lip-depth and viewing-angle coupon.
+
+## 2026-08-19 — Bathroom additions
+
+- Added a priority Sink Rack Drain Bridge concept: removable PETG catch adapter plus measured drain channel so the rack can sit level. CAD remains gated on the rack outlet, sink reach, and available fall; the first artifact is an outlet-and-slope coupon.
+- Added two low-clutter bathroom concepts: a ventilated Retainer Drying Dock with washable insert, and a Low-Profile Shower Tool Rail with replaceable open-draining clips.
+- Project counts now reflect 17 top-level projects: 1 completed, 4 active, and 12 concepts.
+- Next physical input: one straight side photo of the rack and sink, plus the outlet size, horizontal reach to the inside sink edge, and vertical drop.
+
+This is the living implementation record for PrintPath. Update it whenever a feature ships, a workflow decision changes, or a new project exposes friction.
+
+## Product principles
+
+1. Capture intent before dimensions.
+2. Prefer deterministic generators and checks for supported templates.
+3. Use agents only when ambiguity, novelty, or assembly risk justifies them.
+4. Keep Bambu Studio as the final slice-and-print safety boundary.
+5. Maintain one shared application database when cloud sync is introduced.
+6. Show actual generated geometry in project imagery whenever possible.
+
+## Shipped
+
+| Area | Capability | Status |
+| --- | --- | --- |
+| Intake | Intent, dimension meaning, fit, Gridfinity relationship, obstacles, and one-part gates | Shipped |
+| Printer context | Bambu Lab P1S, nozzle, plate, material, build-volume, wall, and margin checks | Shipped |
+| Orchestration | Deterministic, guided, and independently reviewed routes | Shipped |
+| Local control tower | IndexedDB projects, drafts, immutable checkpoints, approval, and handoff state | Shipped |
+| Bambu handoff | Pairing-protected local bridge, approved-design gate, STL generation, and Studio launch | Shipped |
+| Gridfinity gap tray | One 254.4 × 39.4 × 49.4 mm part for a 255 × 40 × 50 mm envelope | Completed |
+| Project presentation | Clean actual-STL product renders, readable project typography, and labeled sharing covers | Shipped |
+| Information architecture | Maker-focused navigation, room/use template categories, and one unified process page | Shipped |
+| Branding | PrintPath cube favicon for browser tabs and bookmarks | Shipped |
+| Vase project | Visible saved-version history for Petal Twist v1 and Leaf Bloom v2; both manifold and downloadable | Design review |
+| Project controls | Working status filters and persistent version selection | Shipped |
+| 7 Wonders Duel organizer | Official inventory research, 196 × 196 × 42 mm reference envelope, four-module layout v1, and measurement gate | Layout v1 |
+| Cozy Stickerville organizer | Official inventory map, P1S-aware modular concept, campaign-state workflow, and measurement packet | Research mapped |
+| Magnetic hex token pods | Generated single, split, and triple tray STLs, 6 mm magnet-fit coupon, replaceable cups, two-pod connection-test plate, and staged print guide | Printable alpha |
+| Vase inspiration | Four original form directions informed by spiral, fan, cloud-stack, and folded-organic references on Thangs | Saved study |
+| Deployment | GitHub workflow and Cloudflare Workers static hosting | Shipped |
+| Public access | Browse-only showcase with protected project authoring | Shipped |
+| AI cost boundary | Signed Maker Mode sessions, server-only OpenAI key, body/token caps, and rate limits | Shipped |
+| Print planning facts | Readable plate, print-job, component, material, and time estimates on every project card | Shipped |
+| Honest concept estimates | Geometry-backed projects show one planning estimate; project cards and workbench metrics show `Pending CAD` until volume exists | Shipped |
+| Cozy source dimensions | Published 305 × 225 × 51 mm box and 181 unsleeved 63 × 88 mm cards mapped; three physical fit checks remain | Shipped |
+| Responsive 7 Wonders pipeline | One full-image organizer composition maps published box/card data and reduces the fit gate to three physical checks | Shipped |
+| Navigation and library cleanup | Five top-level projects, nested drafts excluded from the count, simplified Overview, and a minimal category-based template list | Shipped |
+| Responsive project cards | Card columns follow available workspace width; headings, print facts, and next steps wrap safely on compact desktop and phone layouts | Shipped |
+| Organizer presentation standard | 7 Wonders, Cozy Stickerville, and magnetic-token workbenches use one full-image 16:9 layout with contain-fit artwork on desktop and mobile | Shipped |
+| Project workspace cleanup | Removed the duplicate thumbnail gallery; active projects use full workbench rows and completed prints have a dedicated archive section | Shipped |
+| Project master-detail workspace | Completed-first project index, most-recent marker, minimal clickable cards, and one selected detail workbench | Shipped |
+| New concept queue | Pottery signature stamps, perfume Lazy Susan, three saved phone-stand directions, ceramic cabinet risers, and a film-camera toolkit with measurement gates | Outlined |
+| Board-game and apartment concepts | Token upgrade studio, five-plate magnetic 3D island scaffold, official-CAD Steam Controller dock, and four-part quiet utility kit | Outlined |
+| Draft geometry renders | Product-style mesh previews for Tidepool Buddy, Capiz Cove, Cozy Harvest, and the Evergarden bouquet, with clearly labeled pre-CAD plate, time, and material ranges | Shipped |
+
+## Next implementation candidates
+
+### Project history and learning
+
+- Add a post-print outcome: `perfect`, `too tight`, `too loose`, `warped`, or `failed`.
+- Store the actual print date, filament, slicer estimate, and user notes.
+- Let a completed project create a revised version without losing the approved artifact.
+- Add artifact download links and checksums to the project detail page.
+
+### Central data control tower
+
+- Implement one Convex adapter for projects, versions, approvals, printer profiles, and artifact metadata.
+- Keep IndexedDB as the offline cache/outbox after Convex becomes authoritative.
+- Reuse Maker Mode identity when cross-device sync is enabled; do not create a second login system.
+- Do not create a parallel Cloudflare project database.
+
+### Modeling accuracy
+
+- Add photo-guided measurement capture and annotated reference images.
+- Add multi-location measurements for long, tapered, or non-square openings.
+- Create reusable test coupons before long prints.
+- Validate generated mesh bounds against the approved design packet automatically.
+- Add an optional orbitable STL viewer; keep static covers for fast project browsing.
+
+### Bambu workflow
+
+- Record handoff receipts and attach them to the matching project version.
+- Test Bambu Studio CLI-based 3MF creation behind an experimental flag.
+- Preserve manual slice review before any print action.
+- Improve phone reprint guidance as supported Bambu workflows evolve.
+
+### Template pipeline
+
+- Promote templates only after they have a deterministic generator and validation tests.
+- Start compound projects with an inventory, plate plan, test-fit stop, and assembly order.
+- Create cover renders from the generated artifact rather than illustrative placeholders.
+
+## Suggested next projects
+
+1. **Drawer divider end-cap** — a small, quick print that tests snug-versus-sliding fit.
+2. **Board-game token tray** — one compartment with a pour corner; introduces curved access without assembly.
+3. **Sleeved-card holder** — tests usable-inside dimensions and finger clearance.
+4. **Plant drip tray** — tests PETG defaults, water containment, and exact outside sizing.
+5. **Two-part board-game insert** — first compound workflow with a plate and fit-check plan.
+
+## Deferred
+
+- Additional printer models beyond the P1S
+- Automatic printing without a human Bambu Studio review
+- Public community publishing and template marketplace
+- Multi-user live collaboration
+
+## Decision log
+
+- **2026-08-13:** Keep project data local-first and prepare a single Convex adapter rather than adding D1.
+- **2026-08-13:** Treat the 255 × 40 × 50 mm dimensions as a maximum available envelope.
+- **2026-08-13:** Apply 0.3 mm clearance per horizontal side and 0.6 mm top clearance to the drawer gap tray.
+- **2026-08-13:** Describe the tray as adjacent to Gridfinity, not Gridfinity-compatible.
+- **2026-08-13:** Use the generated STL as the source for the project cover render.
+- **2026-08-13:** Remove Control Tower and Bambu Handoff from primary navigation; explain both as project memory and print handoff within one process page.
+- **2026-08-13:** Organize the design library by Kitchen, Bathroom, Bedroom, Board Games, Plants, and Desk & Workshop while keeping Gridfinity as a featured system.
+- **2026-08-13:** Keep the water-holding vase seam-free at 245 mm tall; use PETG, a 2.4 mm wall, a 3.2 mm floor, and require a physical leak test.
+- **2026-08-13:** Use text-free actual-STL renders inside the interface and reserve labeled covers for sharing or export.
+- **2026-08-13:** Treat vase redesigns as versions of one project; keep every artifact available and let one version be explicitly selected.
+- **2026-08-13:** Never remove a prior design from the visible project record; experiments remain previewable and downloadable even when another version is selected.
+- **2026-08-13:** Project filters must change the visible project set, and selecting a saved version must update the active preview and artifact links.
+- **2026-08-13:** Start the 7 Wonders Duel organizer with four lift-out modules; require actual box, sleeve, expansion, and storage-orientation answers before CAD.
+- **2026-08-13:** Keep the showcase public while requiring Maker Mode for project authoring and every paid AI request.
+- **2026-08-13:** Keep OpenAI credentials server-only; cap, rate-limit, and fail closed on all AI intake requests.
+- **2026-08-14:** Treat 196 × 196 × 42 mm as a 7 Wonders Duel reference insert envelope, not a guaranteed fit; require the user's box, sleeve, and deck measurements before CAD.
+- **2026-08-14:** Make Cozy Stickerville modular across staged P1S plates because its published 225 × 300 × 53 mm outer box exceeds one P1S plate direction.
+- **2026-08-14:** De-risk the magnetic token system with a three-size magnet coupon and separate glue-on cups before integrating magnets into full trays.
+- **2026-08-14:** Use Thangs references only as form vocabulary; preserve original PrintPath geometry and the PETG water-holding requirements.
+- **2026-08-14:** Show plate count, print jobs, components, material, and time together; label unsliced numbers as estimates and keep Bambu Studio as the final authority.
+- **2026-08-14:** Require a two-pod magnetic connection test before batch-printing token storage; one pod alone cannot validate polarity or holding strength.
+- **2026-08-14:** Keep organizer artwork in one 16:9 contain-fit presentation instead of maintaining crop-prone project-specific mobile compositions.
+- **2026-08-14:** Use full project records for active work and a separate completed archive instead of repeating every project in a thumbnail gallery and a detail row.
+- **2026-08-14:** Put completed work first, keep the vase first among active projects as the most recent, and render full diagrams and print facts only for the selected project.
+- **2026-08-15:** Keep new ideas as five top-level projects; the creature and nature phone stands remain saved directions inside one project. A third ocean direction was added on 2026-08-24.
+- **2026-08-15:** Require test coupons before clay-stamp relief, Lazy Susan bearing fit, phone lip angle, or any lens-cap contact becomes full printable geometry.
+- **2026-08-17:** Treat game-specific token replacements as kits inside one reusable studio rather than separate top-level projects for every game.
+- **2026-08-17:** Stage the magnetic 19-hex island across five planning plates; keep board magnets permanently captured and prefer ferrous inserts over second magnets in small player pieces.
+- **2026-08-17:** Use Valve's official Steam Controller and Puck CAD plus keep-out drawings as the fit authority before creating the holder geometry.
+- **2026-08-17:** Group small apartment optimizations into one quiet utility family whose pieces consume no new floor area and share a restrained visual language.
+- **2026-08-26:** Distinguish approved-STL renders from geometry-draft renders; show conservative planning ranges for draft projects and keep Bambu Studio as the source of final print time and filament usage.
diff --git a/README.md b/README.md
index 07adaae..37b30b0 100644
--- a/README.md
+++ b/README.md
@@ -7,14 +7,71 @@ PrintPath is an open-source, visual workflow for turning an everyday 3D-printing
## Current prototype
- Guided four-step design intake
+- Plain-language “Make a Design” starting point
+- Public read-only showcase with a protected Maker Mode for authoring
+- Server-side OpenAI Responses API intake with signed sessions and per-user/IP rate limits
+- P1S-specific 256 × 256 × 256 mm machine profile
+- Explicit build-plate and nozzle awareness
- Live dimension visualization
-- Bambu Lab printer and material profiles
+- Custom-fit Gridfinity layouts, edge fillers, and fractional bins
+- Filterable templates for board games, bathrooms, bedrooms, plants, and everyday objects
+- Prefilled dimensions, materials, part counts, and assembly assumptions for every template
+- Local, pairing-code-protected handoff into Bambu Studio without sharing Bambu credentials
+- Printable generators for an exact-dimension open tray and a one-compartment Gridfinity gap tray
+- A printable magnetic hex token-pod alpha with single, split, and triple wells, a 6 mm magnet-fit coupon, and replaceable magnet cups
+- Measured-layout packets for 7 Wonders Duel and Cozy Stickerville compound organizers
+- A saved vase inspiration study that translates form ideas into original, water-capable PrintPath directions
+- Multi-part plate, checkpoint, and assembly planning
- Build-volume, wall-thickness, and clearance checks
- Automatic local saving
+- Structured intent, dimension-meaning, fit, Gridfinity-relationship, and one-part gates
+- IndexedDB control tower with immutable design checkpoints and a future Convex adapter boundary
+- Cost-aware orchestration routes for deterministic, guided, and independently reviewed designs
+- Explicit design approval and date-based print titles before artifact generation
- Portable JSON project-spec export
-- Responsive interface ready for Cloudflare Workers static assets
+- Responsive interface on Cloudflare Workers with selective `/api/*` Worker routing
-The visual preview is intentionally labeled as a concept. It does not yet produce printable geometry.
+Most visual previews are intentionally labeled as concepts and do not yet produce printable geometry. The magnetic hex token-pod project is an explicit alpha: print its fit coupon before committing to trays or magnets.
+
+The **Exact-fit open tray** and **Custom drawer grid** workflows are exceptions. PrintPath Bridge can generate their STLs locally and open them in Bambu Studio for human slicing and print review. The drawer workflow can produce one continuous gap-filler compartment beside an existing Gridfinity layout. It rotates long parts diagonally for P1S plate margin and does not falsely claim standard baseplate compatibility.
+
+## Safe Bambu Studio handoff
+
+The local bridge keeps Bambu authentication inside Bambu Studio and never starts a print automatically.
+
+1. Open **Bambu handoff** in PrintPath, download the Windows bridge ZIP, and extract it on the computer running Bambu Studio. Node.js is currently required.
+2. Double-click `bridge/start-bridge.cmd` (or run `npm run bridge` from a repository clone).
+3. Keep the bridge window open and copy its one-time pairing code into PrintPath.
+4. Open the **Exact-fit open tray** or **Custom drawer grid** template, review its dimensions, and choose the Bambu handoff action.
+5. Approve the frozen intent statement and short print title.
+6. Inspect the model, plate, filament, orientation, supports, and sliced preview in Bambu Studio before pressing Print.
+
+The bridge listens only on `127.0.0.1`, restricts web origins, and stores generated files under `Documents/PrintPath Exports`. See [bridge/README.md](bridge/README.md) for the security boundary and current limitations.
+
+## Data strategy
+
+PrintPath uses one storage interface. IndexedDB is the active local-first implementation; a future Convex adapter can become the shared source of truth for projects, versions, profiles, approvals, and artifact metadata. Cloudflare hosts the application assets and stateless authentication/AI boundary, but does not hold a second project database.
+
+## Maker Mode and AI safety
+
+The public site can browse projects, versions, templates, the process, and downloadable showcase artifacts. Starting or editing a project opens Maker Mode sign-in. The AI design-intake button is additionally protected at the Worker endpoint, so changing browser code cannot bypass authentication.
+
+- Credentials and the OpenAI key are Cloudflare secrets, never Vite variables or committed files.
+- Sessions are signed, expire after eight hours, and use an `HttpOnly; Secure; SameSite=Strict` cookie.
+- Login attempts are limited to 8 per minute per IP; AI intake is limited to 6 per minute per signed-in maker/IP.
+- Each AI request is capped at 2,000 input characters and 700 output tokens, uses `store: false`, and fails closed when no API key is installed.
+- The existing measurement, geometry, project, download, and Bambu handoff flows remain deterministic and do not call OpenAI.
+
+Configure production secrets through interactive prompts so values do not enter shell history:
+
+```powershell
+npx wrangler secret put AUTH_USERNAME
+npx wrangler secret put AUTH_PASSWORD
+npx wrangler secret put SESSION_SECRET
+npx wrangler secret put OPENAI_API_KEY
+```
+
+The OpenAI key is optional. Without it, Maker Mode can still unlock protected project tools and the AI endpoint returns a safe “not configured” response. For local development, copy `.dev.vars.example` to `.dev.vars` and use non-production values; `.dev.vars` is ignored by Git.
## Run locally
@@ -31,7 +88,7 @@ npm run deploy:dry
npm run deploy
```
-Deployment uses Cloudflare Workers static assets as configured in `wrangler.jsonc`.
+Run `npm run typegen` after changing Worker bindings. Deployment uses selective Worker-first routing for `/api/*`; hashed assets remain on Cloudflare’s static-asset path.
## Roadmap
diff --git a/bridge/README.md b/bridge/README.md
new file mode 100644
index 0000000..0a6b71a
--- /dev/null
+++ b/bridge/README.md
@@ -0,0 +1,40 @@
+# PrintPath Bridge
+
+PrintPath Bridge is a deliberately small local service that moves reviewed PrintPath designs into Bambu Studio without receiving Bambu Lab credentials.
+
+## Safety boundary
+
+- Listens only on `127.0.0.1:32145`.
+- Accepts the hosted PrintPath origin and local development origins only.
+- Requires a locally generated pairing code for file creation. The website keeps it in browser session storage by default.
+- Requires a frozen PrintPath intent approval and passing readiness checks.
+- Writes files to `Documents/PrintPath Exports`.
+- Opens the model in Bambu Studio, but never slices or starts a print automatically.
+- Does not read Bambu Studio sessions, passwords, cookies, or printer access codes.
+
+## Start on Windows
+
+Double-click `start-bridge.cmd` or run:
+
+```powershell
+npm run bridge
+```
+
+Keep the terminal window open and enter its pairing code in PrintPath. The code is created once and stored under `%LOCALAPPDATA%\PrintPath\Bridge`.
+
+If Bambu Studio uses a nonstandard installation folder, set `PRINTPATH_BAMBU_STUDIO_PATH` to the full `bambu-studio.exe` path before starting the bridge. Without it, the bridge asks Windows to open the STL with its registered application.
+
+## Current geometry support
+
+Version 0.4 includes two local, parametric generators:
+
+- `open-tray` creates one exact-dimension, square-corner tray STL.
+- `gridfinity-gap-tray` creates one watertight, uninterrupted compartment for a narrow leftover zone beside an existing Gridfinity layout.
+
+For a 255 × 40 × 50 mm maximum drawer envelope, the gap-tray generator creates one part with one compartment. With the default 0.3 mm-per-side and 0.6 mm top allowances, its printed outside is 254.4 × 39.4 × 49.4 mm and its interior is 249.6 × 34.6 × 47 mm with 2.4 mm walls and floor.
+
+The STL is pre-rotated 45 degrees, giving it an approximately 207.75 × 207.75 mm plate footprint instead of placing the long edge against the P1S boundary. It is deliberately described as a custom companion to Gridfinity, not a standard baseplate-compatible bin. A standard bin footprint is approximately 41.5 mm wide, so it cannot fit inside the measured 40 mm zone.
+
+## What to inspect in Bambu Studio
+
+The bridge opens the diagonally arranged STL. Before printing, confirm that the part is flat on the plate, its full footprint is inside the printable boundary, the plate and filament profiles match the P1S setup, the sliced preview has continuous walls and floor, and the estimated material is acceptable. The bridge never presses Print.
diff --git a/bridge/server.mjs b/bridge/server.mjs
new file mode 100644
index 0000000..3baab54
--- /dev/null
+++ b/bridge/server.mjs
@@ -0,0 +1,257 @@
+import { createServer } from "node:http";
+import { randomBytes } from "node:crypto";
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { spawn } from "node:child_process";
+import { createGridfinityGapTrayStl, createOpenTrayStl, slugify } from "./stl.mjs";
+
+const HOST = "127.0.0.1";
+const PORT = Number(process.env.PRINTPATH_BRIDGE_PORT || 32145);
+const VERSION = "0.4.0";
+const MAX_BODY_BYTES = 64 * 1024;
+
+const localDataRoot = process.env.LOCALAPPDATA || join(homedir(), ".printpath");
+const bridgeDataRoot = process.env.PRINTPATH_BRIDGE_DATA_DIR || join(localDataRoot, "PrintPath", "Bridge");
+const oneDriveDocuments = process.env.OneDrive ? join(process.env.OneDrive, "Documents") : "";
+const defaultDocuments = existsSync(oneDriveDocuments) ? oneDriveDocuments : join(homedir(), "Documents");
+const exportRoot = process.env.PRINTPATH_BRIDGE_EXPORT_DIR || join(defaultDocuments, "PrintPath Exports");
+const tokenPath = join(bridgeDataRoot, "pairing-token.txt");
+
+mkdirSync(bridgeDataRoot, { recursive: true });
+mkdirSync(exportRoot, { recursive: true });
+
+function formatToken(raw) {
+ return `PP-${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`.toUpperCase();
+}
+
+function loadOrCreateToken() {
+ if (existsSync(tokenPath)) return readFileSync(tokenPath, "utf8").trim();
+ const token = formatToken(randomBytes(6).toString("hex"));
+ writeFileSync(tokenPath, `${token}\n`, { encoding: "utf8", mode: 0o600 });
+ return token;
+}
+
+const pairingToken = loadOrCreateToken();
+
+function isAllowedOrigin(origin) {
+ if (!origin) return true;
+ if (origin === "https://printpath.willymlee.workers.dev") return true;
+ try {
+ const url = new URL(origin);
+ return url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname);
+ } catch {
+ return false;
+ }
+}
+
+function isAllowedHost(hostHeader = "") {
+ const hostname = hostHeader.toLowerCase().split(":")[0];
+ return hostname === "127.0.0.1" || hostname === "localhost";
+}
+
+function responseHeaders(origin) {
+ return {
+ "Access-Control-Allow-Origin": origin || "*",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, X-PrintPath-Token",
+ "Access-Control-Allow-Private-Network": "true",
+ "Cache-Control": "no-store",
+ "Content-Type": "application/json; charset=utf-8",
+ "Vary": "Origin",
+ };
+}
+
+function sendJson(response, status, payload, origin) {
+ response.writeHead(status, responseHeaders(origin));
+ response.end(JSON.stringify(payload));
+}
+
+function readJson(request) {
+ return new Promise((resolve, reject) => {
+ let body = "";
+ request.setEncoding("utf8");
+ request.on("data", (chunk) => {
+ body += chunk;
+ if (body.length > MAX_BODY_BYTES) reject(new Error("Request is too large."));
+ });
+ request.on("end", () => {
+ try {
+ resolve(JSON.parse(body));
+ } catch {
+ reject(new Error("Request body must be valid JSON."));
+ }
+ });
+ request.on("error", reject);
+ });
+}
+
+function finiteNumber(value, label, minimum, maximum) {
+ if (!Number.isFinite(value) || value < minimum || value > maximum) {
+ throw new Error(`${label} must be between ${minimum} and ${maximum} mm.`);
+ }
+ return value;
+}
+
+function validateProject(project) {
+ if (!project || typeof project !== "object") throw new Error("A project is required.");
+ const name = typeof project.name === "string" ? project.name.trim().slice(0, 120) : "";
+ if (!name) throw new Error("Give the project a name before handing it off.");
+
+ return {
+ ...project,
+ name,
+ description: typeof project.description === "string" ? project.description.slice(0, 2000) : "",
+ width: finiteNumber(project.width, "Width", 2, 256),
+ depth: finiteNumber(project.depth, "Depth", 2, 256),
+ height: finiteNumber(project.height, "Height", 2, 256),
+ wall: finiteNumber(project.wall, "Wall thickness", 0.4, 20),
+ floorThickness: finiteNumber(project.floorThickness ?? project.wall, "Floor thickness", 0.4, 20),
+ clearance: finiteNumber(project.clearance ?? 0.3, "Clearance", 0, 5),
+ verticalClearance: finiteNumber(project.verticalClearance ?? 0.6, "Vertical clearance", 0, 10),
+ dimensionIntent: ["available-envelope", "finished-outside", "usable-inside"].includes(project.dimensionIntent) ? project.dimensionIntent : "available-envelope",
+ printTitle: typeof project.printTitle === "string" ? project.printTitle.trim().slice(0, 64) : name,
+ printer: "Bambu Lab P1S",
+ };
+}
+
+function findBambuStudio() {
+ const candidates = [
+ process.env.PRINTPATH_BAMBU_STUDIO_PATH,
+ process.env["ProgramFiles"] && join(process.env["ProgramFiles"], "Bambu Studio", "bambu-studio.exe"),
+ process.env["ProgramFiles(x86)"] && join(process.env["ProgramFiles(x86)"], "Bambu Studio", "bambu-studio.exe"),
+ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Programs", "Bambu Studio", "bambu-studio.exe"),
+ ].filter(Boolean);
+ return candidates.find((candidate) => existsSync(candidate));
+}
+
+function openArtifact(artifactPath) {
+ if (process.env.PRINTPATH_BRIDGE_NO_OPEN === "1") return "test mode (saved only)";
+ if (process.platform !== "win32") return "saved-only";
+ const studio = findBambuStudio();
+ const child = studio
+ ? spawn(studio, [artifactPath], { detached: true, stdio: "ignore" })
+ : spawn("explorer.exe", [artifactPath], { detached: true, stdio: "ignore" });
+ child.unref();
+ return studio ? "Bambu Studio" : "Windows file association";
+}
+
+async function handleHandoff(request, response, origin) {
+ if (request.headers["x-printpath-token"] !== pairingToken) {
+ sendJson(response, 401, { ok: false, error: "The PrintPath pairing code is missing or incorrect." }, origin);
+ return;
+ }
+
+ const payload = await readJson(request);
+ const project = validateProject(payload.project);
+ if (!["open-tray", "gridfinity-gap-tray", "gridfinity-pitch-strip"].includes(project.geometryKind)) {
+ sendJson(response, 422, { ok: false, error: "This design does not have a safe geometry generator yet." }, origin);
+ return;
+ }
+ if (!project.intentConfirmedAt) {
+ sendJson(response, 422, { ok: false, error: "Approve the design intent in PrintPath before creating a printable artifact." }, origin);
+ return;
+ }
+ if (project.geometryKind === "gridfinity-gap-tray" && (project.objectIntent !== "one-compartment" || project.gridRelationship !== "adjacent-only" || project.partCount !== 1)) {
+ sendJson(response, 422, { ok: false, error: "The gap-tray generator requires one continuous compartment beside Gridfinity and exactly one part." }, origin);
+ return;
+ }
+ const readiness = Array.isArray(payload.readiness) ? payload.readiness : [];
+ if (!readiness.length || readiness.some((check) => check?.ok !== true)) {
+ sendJson(response, 422, { ok: false, error: "Every PrintPath readiness check must pass before handoff." }, origin);
+ return;
+ }
+
+ const slug = slugify(project.printTitle || project.name);
+ const projectRoot = join(exportRoot, `${slug}-${new Date().toISOString().replace(/[:.]/g, "-")}`);
+ mkdirSync(projectRoot, { recursive: true });
+ const manifestPath = join(projectRoot, `${slug}.printpath.json`);
+ let modelPath;
+ let files;
+ let generationPlan;
+ if (project.geometryKind === "open-tray") {
+ modelPath = join(projectRoot, `${slug}.stl`);
+ writeFileSync(modelPath, createOpenTrayStl(project), "utf8");
+ files = [{ type: "model/stl", name: `${slug}.stl`, role: "print" }];
+ } else {
+ const generated = createGridfinityGapTrayStl(project);
+ modelPath = join(projectRoot, `${slug}-p1s-diagonal.stl`);
+ writeFileSync(modelPath, generated.model, "utf8");
+ files = [{ type: "model/stl", name: `${slug}-p1s-diagonal.stl`, role: "single compartment · 45 degree P1S orientation" }];
+ generationPlan = generated.plan;
+ }
+
+ writeFileSync(manifestPath, JSON.stringify({
+ format: "printpath-handoff",
+ version: 2,
+ createdAt: new Date().toISOString(),
+ projectId: typeof payload.projectId === "string" ? payload.projectId : undefined,
+ safety: "Review geometry, orientation, filament, plate, supports, and sliced preview in Bambu Studio before printing.",
+ project,
+ approval: { confirmedAt: project.intentConfirmedAt, printTitle: project.printTitle },
+ orchestration: payload.orchestration && typeof payload.orchestration === "object" ? payload.orchestration : undefined,
+ readiness,
+ generationPlan,
+ files,
+ }, null, 2), "utf8");
+
+ const openedWith = openArtifact(modelPath);
+ sendJson(response, 200, {
+ ok: true,
+ modelGenerated: true,
+ fileName: modelPath.split(/[\\/]/).pop(),
+ outputDirectory: projectRoot,
+ openedWith,
+ files,
+ generationPlan,
+ nextStep: "Review and slice in Bambu Studio. PrintPath never starts the print automatically.",
+ }, origin);
+}
+
+const server = createServer(async (request, response) => {
+ const origin = request.headers.origin;
+ if (!isAllowedHost(request.headers.host) || !isAllowedOrigin(origin)) {
+ sendJson(response, 403, { ok: false, error: "Request origin is not allowed." }, origin);
+ return;
+ }
+
+ if (request.method === "OPTIONS") {
+ response.writeHead(204, responseHeaders(origin));
+ response.end();
+ return;
+ }
+
+ if (request.method === "GET" && request.url === "/health") {
+ sendJson(response, 200, {
+ ok: true,
+ service: "PrintPath Bridge",
+ version: VERSION,
+ paired: request.headers["x-printpath-token"] === pairingToken,
+ capabilities: ["open-tray-stl", "gridfinity-gap-tray-stl", "diagonal-p1s-layout", "approved-design-gate", "versioned-manifest", "bambu-studio-handoff"],
+ bambuStudio: {
+ detected: Boolean(findBambuStudio()),
+ launchMethod: findBambuStudio() ? "direct" : "windows-file-association",
+ },
+ }, origin);
+ return;
+ }
+
+ if (request.method === "POST" && request.url === "/handoff") {
+ try {
+ await handleHandoff(request, response, origin);
+ } catch (error) {
+ sendJson(response, 400, { ok: false, error: error instanceof Error ? error.message : "Handoff failed." }, origin);
+ }
+ return;
+ }
+
+ sendJson(response, 404, { ok: false, error: "Not found." }, origin);
+});
+
+server.listen(PORT, HOST, () => {
+ console.log(`\nPrintPath Bridge ${VERSION}`);
+ console.log(`Listening only on http://${HOST}:${PORT}`);
+ console.log(`Pairing code: ${pairingToken}`);
+ console.log(`Exports: ${exportRoot}`);
+ console.log("Keep this window open while using PrintPath. Press Ctrl+C to stop.\n");
+});
diff --git a/bridge/server.test.mjs b/bridge/server.test.mjs
new file mode 100644
index 0000000..0101926
--- /dev/null
+++ b/bridge/server.test.mjs
@@ -0,0 +1,102 @@
+import assert from "node:assert/strict";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawn } from "node:child_process";
+import test from "node:test";
+
+const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url)));
+
+async function waitForFile(path, attempts = 100) {
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ if (existsSync(path)) return;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ throw new Error(`Timed out waiting for ${path}`);
+}
+
+test("bridge writes the one-compartment Gridfinity gap-tray handoff", async () => {
+ const testRoot = mkdtempSync(join(tmpdir(), "printpath-bridge-"));
+ const dataRoot = join(testRoot, "data");
+ const exportRoot = join(testRoot, "exports");
+ const port = 32146;
+ let serverOutput = "";
+ const child = spawn(process.execPath, ["bridge/server.mjs"], {
+ cwd: repositoryRoot,
+ windowsHide: true,
+ env: {
+ ...process.env,
+ PRINTPATH_BRIDGE_PORT: String(port),
+ PRINTPATH_BRIDGE_DATA_DIR: dataRoot,
+ PRINTPATH_BRIDGE_EXPORT_DIR: exportRoot,
+ PRINTPATH_BRIDGE_NO_OPEN: "1",
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ child.stdout.on("data", (chunk) => { serverOutput += chunk; });
+ child.stderr.on("data", (chunk) => { serverOutput += chunk; });
+
+ try {
+ const tokenPath = join(dataRoot, "pairing-token.txt");
+ await waitForFile(tokenPath);
+ const token = readFileSync(tokenPath, "utf8").trim();
+ const project = {
+ name: "255mm one-compartment Gridfinity gap tray",
+ description: "One continuous compartment for a 255 by 40 by 50 millimeter gap beside an existing Gridfinity layout.",
+ width: 255,
+ depth: 40,
+ height: 50,
+ wall: 2.4,
+ floorThickness: 2.4,
+ clearance: 0.35,
+ verticalClearance: 0.6,
+ cornerRadius: 0,
+ printer: "Bambu Lab P1S",
+ plate: "Textured PEI Plate",
+ nozzle: 0.4,
+ material: "PLA",
+ layerHeight: 0.2,
+ strength: "Balanced",
+ partCount: 1,
+ assemblyMethod: "Single print",
+ designSystem: "gridfinity",
+ gridfinityMode: "gap-tray",
+ geometryKind: "gridfinity-gap-tray",
+ objectIntent: "one-compartment",
+ dimensionIntent: "available-envelope",
+ gridRelationship: "adjacent-only",
+ onePartRequired: true,
+ intentConfirmedAt: "2026-08-13T12:00:00.000Z",
+ printTitle: "Drawer Gap Tray · Aug 2026",
+ };
+ const response = await fetch(`http://127.0.0.1:${port}/handoff`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-PrintPath-Token": token },
+ body: JSON.stringify({ projectId: "test-project", project, readiness: [{ label: "Design approval", ok: true }] }),
+ });
+ assert.equal(response.status, 200, serverOutput);
+ const result = await response.json();
+ assert.equal(result.ok, true);
+ assert.equal(result.openedWith, "test mode (saved only)");
+ assert.equal(result.generationPlan.compartmentCount, 1);
+ assert.equal(result.generationPlan.partCount, 1);
+ assert.equal(result.generationPlan.plateRotationDegrees, 45);
+ assert.deepEqual(result.generationPlan.outerDimensions, { length: 254.3, width: 39.3, height: 49.4 });
+ assert.deepEqual(result.generationPlan.plateBounds, { width: 207.61, depth: 207.61, height: 49.4 });
+ assert.deepEqual(result.files.map((file) => file.name), [
+ "drawer-gap-tray-aug-2026-p1s-diagonal.stl",
+ ]);
+ for (const file of result.files) assert.equal(existsSync(join(result.outputDirectory, file.name)), true);
+ const manifest = JSON.parse(readFileSync(join(result.outputDirectory, "drawer-gap-tray-aug-2026.printpath.json"), "utf8"));
+ assert.equal(manifest.project.geometryKind, "gridfinity-gap-tray");
+ assert.equal(manifest.projectId, "test-project");
+ assert.equal(manifest.approval.printTitle, "Drawer Gap Tray · Aug 2026");
+ assert.equal(manifest.generationPlan.compartmentCount, 1);
+ assert.equal(manifest.generationPlan.compatibility.standardBaseplateCompatible, false);
+ } finally {
+ child.kill();
+ await new Promise((resolve) => child.once("exit", resolve));
+ rmSync(testRoot, { recursive: true, force: true });
+ }
+});
diff --git a/bridge/start-bridge.cmd b/bridge/start-bridge.cmd
new file mode 100644
index 0000000..6411416
--- /dev/null
+++ b/bridge/start-bridge.cmd
@@ -0,0 +1,9 @@
+@echo off
+title PrintPath Bridge
+cd /d "%~dp0.."
+node bridge\server.mjs
+if errorlevel 1 (
+ echo.
+ echo PrintPath Bridge could not start. Make sure Node.js is installed.
+ pause
+)
diff --git a/bridge/stl.mjs b/bridge/stl.mjs
new file mode 100644
index 0000000..c4e6246
--- /dev/null
+++ b/bridge/stl.mjs
@@ -0,0 +1,147 @@
+function normal(a, b, c) {
+ const ux = b[0] - a[0];
+ const uy = b[1] - a[1];
+ const uz = b[2] - a[2];
+ const vx = c[0] - a[0];
+ const vy = c[1] - a[1];
+ const vz = c[2] - a[2];
+ const nx = uy * vz - uz * vy;
+ const ny = uz * vx - ux * vz;
+ const nz = ux * vy - uy * vx;
+ const length = Math.hypot(nx, ny, nz) || 1;
+ return [nx / length, ny / length, nz / length];
+}
+function facet(a, b, c) {
+ const n = normal(a, b, c);
+ return [
+ ` facet normal ${n.join(" ")}`,
+ " outer loop",
+ ` vertex ${a.join(" ")}`,
+ ` vertex ${b.join(" ")}`,
+ ` vertex ${c.join(" ")}`,
+ " endloop",
+ " endfacet",
+ ].join("\n");
+}
+
+function quad(triangles, a, b, c, d) {
+ triangles.push([a, b, c], [a, c, d]);
+}
+
+function createOpenTrayTriangles({ width, depth, height, wall, floorThickness = wall }) {
+ const base = Math.min(Math.max(floorThickness, 1.2), height - 0.8);
+ if (width <= wall * 2 + 1 || depth <= wall * 2 + 1 || height <= base) {
+ throw new Error("The tray dimensions do not leave enough interior space.");
+ }
+
+ const A = [0, 0, 0];
+ const B = [width, 0, 0];
+ const C = [width, depth, 0];
+ const D = [0, depth, 0];
+ const E = [0, 0, height];
+ const F = [width, 0, height];
+ const G = [width, depth, height];
+ const H = [0, depth, height];
+
+ const a = [wall, wall, base];
+ const b = [width - wall, wall, base];
+ const c = [width - wall, depth - wall, base];
+ const d = [wall, depth - wall, base];
+ const e = [wall, wall, height];
+ const f = [width - wall, wall, height];
+ const g = [width - wall, depth - wall, height];
+ const h = [wall, depth - wall, height];
+
+ const triangles = [];
+ quad(triangles, A, D, C, B);
+ quad(triangles, A, B, F, E);
+ quad(triangles, B, C, G, F);
+ quad(triangles, C, D, H, G);
+ quad(triangles, D, A, E, H);
+
+ quad(triangles, E, F, f, e);
+ quad(triangles, F, G, g, f);
+ quad(triangles, G, H, h, g);
+ quad(triangles, H, E, e, h);
+
+ quad(triangles, a, e, f, b);
+ quad(triangles, b, f, g, c);
+ quad(triangles, c, g, h, d);
+ quad(triangles, d, h, e, a);
+ quad(triangles, a, b, c, d);
+
+ return { triangles, base };
+}
+
+function trianglesToStl(name, triangles) {
+ return `solid ${name}\n${triangles.map(([v1, v2, v3]) => facet(v1, v2, v3)).join("\n")}\nendsolid ${name}\n`;
+}
+
+export function createOpenTrayStl(dimensions) {
+ return trianglesToStl("printpath_open_tray", createOpenTrayTriangles(dimensions).triangles);
+}
+
+export function createGridfinityGapTrayStl({ width, depth, height, wall, floorThickness = wall, clearance = 0.3, verticalClearance = 0.6, dimensionIntent = "available-envelope" }) {
+ const longAxis = Math.max(width, depth);
+ const shortAxis = Math.min(width, depth);
+ const standardBinFootprint = 41.5;
+ if (shortAxis >= standardBinFootprint) throw new Error("This generator is only for a narrow gap beside a standard Gridfinity layout.");
+ const partLength = Number((dimensionIntent === "available-envelope" ? longAxis - clearance * 2 : dimensionIntent === "usable-inside" ? longAxis + wall * 2 : longAxis).toFixed(3));
+ const partWidth = Number((dimensionIntent === "available-envelope" ? shortAxis - clearance * 2 : dimensionIntent === "usable-inside" ? shortAxis + wall * 2 : shortAxis).toFixed(3));
+ const partHeight = Number((dimensionIntent === "available-envelope" ? height - verticalClearance : dimensionIntent === "usable-inside" ? height + floorThickness : height).toFixed(3));
+ const { triangles, base } = createOpenTrayTriangles({ width: partLength, depth: partWidth, height: partHeight, wall, floorThickness });
+ const angleDegrees = 45;
+ const angle = angleDegrees * Math.PI / 180;
+ const cosine = Math.cos(angle);
+ const sine = Math.sin(angle);
+ const rotate = ([x, y, z]) => [x * cosine - y * sine, x * sine + y * cosine, z];
+ const rotated = triangles.map((triangle) => triangle.map(rotate));
+ const allVertices = rotated.flat();
+ const minX = Math.min(...allVertices.map((vertex) => vertex[0]));
+ const minY = Math.min(...allVertices.map((vertex) => vertex[1]));
+ const translate = ([x, y, z]) => [Number((x - minX).toFixed(6)), Number((y - minY).toFixed(6)), z];
+ const plateTriangles = rotated.map((triangle) => triangle.map(translate));
+ const plateSide = Number(((partLength + partWidth) / Math.sqrt(2)).toFixed(2));
+ if (plateSide > 250 || partHeight > 256) throw new Error("The single compartment does not leave a safe P1S plate margin, even when rotated diagonally.");
+
+ return {
+ plan: {
+ standardBinFootprint,
+ requestedLength: longAxis,
+ requestedWidth: shortAxis,
+ dimensionIntent,
+ fitClearancePerSide: clearance,
+ verticalClearance,
+ compartmentCount: 1,
+ partCount: 1,
+ measuredEnvelope: { length: longAxis, width: shortAxis, height },
+ outerDimensions: { length: partLength, width: partWidth, height: partHeight },
+ interiorDimensions: {
+ length: Number((partLength - wall * 2).toFixed(2)),
+ width: Number((partWidth - wall * 2).toFixed(2)),
+ height: Number((partHeight - base).toFixed(2)),
+ },
+ plateRotationDegrees: angleDegrees,
+ height: partHeight,
+ plateBounds: { width: plateSide, depth: plateSide, height: partHeight },
+ compatibility: {
+ adjacentToGridfinity: true,
+ pitchAligned: false,
+ standardBaseplateCompatible: false,
+ note: "A custom one-compartment gap filler beside the existing Gridfinity layout; not a standard Gridfinity bin or baseplate part.",
+ },
+ },
+ model: trianglesToStl("printpath_gridfinity_gap_tray_p1s_diagonal", plateTriangles),
+ };
+}
+
+// Keeps projects saved by Bridge 0.2 working while generating the corrected one-compartment design.
+export const createGridfinityPitchStripStls = createGridfinityGapTrayStl;
+
+export function slugify(value) {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "")
+ .slice(0, 80) || "printpath-design";
+}
diff --git a/bridge/stl.test.mjs b/bridge/stl.test.mjs
new file mode 100644
index 0000000..a939acb
--- /dev/null
+++ b/bridge/stl.test.mjs
@@ -0,0 +1,69 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createGridfinityGapTrayStl, createOpenTrayStl, slugify } from "./stl.mjs";
+
+function stlVertices(stl) {
+ return [...stl.matchAll(/vertex\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)/g)].map((match) => match.slice(1).map(Number));
+}
+
+function stlBounds(stl) {
+ const vertices = stlVertices(stl);
+ return [0, 1, 2].map((axis) => {
+ const values = vertices.map((vertex) => vertex[axis]);
+ return [Math.min(...values), Math.max(...values)];
+ });
+}
+
+function assertWatertight(stl) {
+ const vertices = stlVertices(stl);
+ const edges = new Map();
+ const key = (a, b) => [a.join(","), b.join(",")].sort().join("|");
+ for (let index = 0; index < vertices.length; index += 3) {
+ for (const [a, b] of [[0, 1], [1, 2], [2, 0]]) {
+ const edge = key(vertices[index + a], vertices[index + b]);
+ edges.set(edge, (edges.get(edge) || 0) + 1);
+ }
+ }
+ assert.ok([...edges.values()].every((count) => count === 2), "every mesh edge should be shared by exactly two triangles");
+}
+
+test("creates a closed open-tray STL with 28 facets", () => {
+ const stl = createOpenTrayStl({ width: 100, depth: 70, height: 24, wall: 2.4 });
+ assert.match(stl, /^solid printpath_open_tray/);
+ assert.equal((stl.match(/facet normal/g) || []).length, 28);
+ assert.match(stl, /vertex 97\.6 67\.6 2\.4/);
+});
+test("rejects a tray without usable interior space", () => {
+ assert.throws(
+ () => createOpenTrayStl({ width: 5, depth: 5, height: 3, wall: 2.4 }),
+ /do not leave enough interior space/,
+ );
+});
+
+test("creates one watertight gap tray with fit clearance and a diagonal P1S orientation", () => {
+ const result = createGridfinityGapTrayStl({ width: 255, depth: 40, height: 50, wall: 2.4, clearance: 0.3 });
+ assert.equal(result.plan.compartmentCount, 1);
+ assert.equal(result.plan.partCount, 1);
+ assert.deepEqual(result.plan.measuredEnvelope, { length: 255, width: 40, height: 50 });
+ assert.deepEqual(result.plan.outerDimensions, { length: 254.4, width: 39.4, height: 49.4 });
+ assert.deepEqual(result.plan.interiorDimensions, { length: 249.6, width: 34.6, height: 47 });
+ assert.equal(result.plan.plateRotationDegrees, 45);
+ assert.deepEqual(result.plan.plateBounds, { width: 207.75, depth: 207.75, height: 49.4 });
+ const bounds = stlBounds(result.model);
+ assert.ok(Math.abs(bounds[0][0]) < 0.000001 && Math.abs(bounds[0][1] - 207.747972) < 0.000001);
+ assert.ok(Math.abs(bounds[1][0]) < 0.000001 && Math.abs(bounds[1][1] - 207.747972) < 0.000001);
+ assert.deepEqual(bounds[2], [0, 49.4]);
+ assertWatertight(result.model);
+});
+
+test("rejects the gap-tray generator when a standard Gridfinity bin already fits", () => {
+ assert.throws(
+ () => createGridfinityGapTrayStl({ width: 255, depth: 42, height: 50, wall: 2.4 }),
+ /only for a narrow gap/,
+ );
+});
+
+test("creates safe file slugs", () => {
+ assert.equal(slugify("William's Drawer / Tray"), "william-s-drawer-tray");
+ assert.equal(slugify("***"), "printpath-design");
+});
diff --git a/index.html b/index.html
index ab23fc3..b2b59df 100644
--- a/index.html
+++ b/index.html
@@ -8,6 +8,7 @@
content="PrintPath turns an idea and a few measurements into a print-ready 3D design brief."
/>
+
PrintPath — From idea to printed part
diff --git a/package-lock.json b/package-lock.json
index 56e947e..dd9d1c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,11 +15,12 @@
"vite": "latest"
},
"devDependencies": {
- "@cloudflare/workers-types": "latest",
+ "@cloudflare/workers-types": "^5.20260813.1",
+ "@types/node": "^26.2.0",
"@types/react": "latest",
"@types/react-dom": "latest",
"typescript": "latest",
- "wrangler": "latest"
+ "wrangler": "^4.123.0"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
@@ -49,9 +50,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-64": {
- "version": "1.20260804.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260804.1.tgz",
- "integrity": "sha512-191/PPEFicRK2wK69eXzSjnLgHL79k7zR2VUpyIr8rhFgGns1b5bTHgnBuUrgU2LPbEtwbE5eL2hJ0uhLAFEow==",
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260811.1.tgz",
+ "integrity": "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ==",
"cpu": [
"x64"
],
@@ -66,9 +67,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-arm64": {
- "version": "1.20260804.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260804.1.tgz",
- "integrity": "sha512-aI2cAFLsrNkSz3kLSQrgrO9ICTfY7jb2h0jgaWDE9mQLQQDfpXeYrKWt07tTgMmeNP79o9w3NZe3aqtt8mTGHQ==",
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260811.1.tgz",
+ "integrity": "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ==",
"cpu": [
"arm64"
],
@@ -83,9 +84,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-64": {
- "version": "1.20260804.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260804.1.tgz",
- "integrity": "sha512-KBCjxBIlN2jucfQGaTK4EgmPsWzQgYR/zYhPfi3mkWwdoTyG1dgrt2aizKps/SYse85ci/SOxojKk0/K7vstPw==",
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260811.1.tgz",
+ "integrity": "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg==",
"cpu": [
"x64"
],
@@ -100,9 +101,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-arm64": {
- "version": "1.20260804.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260804.1.tgz",
- "integrity": "sha512-7lswfarBZ7xkHpTFrNb74ExwOltIalVaASc+GOYfCNtnwFxK/JZSVByfm/hnZEBtrHU7fMY2z7dYT64rwS0pHg==",
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260811.1.tgz",
+ "integrity": "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow==",
"cpu": [
"arm64"
],
@@ -117,9 +118,9 @@
}
},
"node_modules/@cloudflare/workerd-windows-64": {
- "version": "1.20260804.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260804.1.tgz",
- "integrity": "sha512-GOgRWYtxISN5rAWfx3K7zubt3xEn0/ZPFrbcLL+GwT4ouCKyNoHqfT1cPNB6Nx7t8BHEjnuTG7gkHO4Wd5ORdg==",
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260811.1.tgz",
+ "integrity": "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw==",
"cpu": [
"x64"
],
@@ -134,9 +135,9 @@
}
},
"node_modules/@cloudflare/workers-types": {
- "version": "5.20260810.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260810.1.tgz",
- "integrity": "sha512-aD0hEU6HaiG4wy4hDoPoLXMH963nHD4MsIY566pGkWO2dL/yeYEiMN7SeJ24t+wBmLDnh16yQ7IPos5opGkIoA==",
+ "version": "5.20260813.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260813.1.tgz",
+ "integrity": "sha512-RQNfm7xD10hNHEQZFxQPmyGMJ9+aDGPcdFZ0x1LtmjRoLFcgZkGvfaqJAbOMQBAUFSESO3bYJS4p9mOLv28Ihg==",
"dev": true,
"license": "MIT OR Apache-2.0"
},
@@ -1489,6 +1490,16 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/@types/node": {
+ "version": "26.2.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -2275,16 +2286,16 @@
}
},
"node_modules/miniflare": {
- "version": "5.20260804.0-alpha",
- "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260804.0-alpha.tgz",
- "integrity": "sha512-UDegnTukIpJCI/lNyQXU7B8kYFLcMZihlxqqOocnc62tV8AO6XofmFmZm3ZLDXTWZpkZnLLJ2sKCSlTIEXO3dQ==",
+ "version": "5.20260811.1-alpha",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260811.1-alpha.tgz",
+ "integrity": "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "0.8.1",
"sharp": "0.35.2",
"undici": "7.29.0",
- "workerd": "1.20260804.1",
+ "workerd": "1.20260811.1",
"ws": "8.21.0",
"youch": "4.1.0-beta.10"
},
@@ -2578,6 +2589,13 @@
"node": ">=20.18.1"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/unenv": {
"version": "2.0.0-rc.24",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
@@ -2666,9 +2684,9 @@
}
},
"node_modules/workerd": {
- "version": "1.20260804.1",
- "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260804.1.tgz",
- "integrity": "sha512-b0P38g5/ssemwWxd/mafNYggEZ0ere7PCiUH6RCHkgqjRhhXTP45nDiL2L3iIvi8uF2IjNrGpVgMXEunCaeY/w==",
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260811.1.tgz",
+ "integrity": "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -2679,17 +2697,17 @@
"node": ">=16"
},
"optionalDependencies": {
- "@cloudflare/workerd-darwin-64": "1.20260804.1",
- "@cloudflare/workerd-darwin-arm64": "1.20260804.1",
- "@cloudflare/workerd-linux-64": "1.20260804.1",
- "@cloudflare/workerd-linux-arm64": "1.20260804.1",
- "@cloudflare/workerd-windows-64": "1.20260804.1"
+ "@cloudflare/workerd-darwin-64": "1.20260811.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260811.1",
+ "@cloudflare/workerd-linux-64": "1.20260811.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260811.1",
+ "@cloudflare/workerd-windows-64": "1.20260811.1"
}
},
"node_modules/wrangler": {
- "version": "4.120.1",
- "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.120.1.tgz",
- "integrity": "sha512-rQJ38rY02XiVITbSBWHC7oap3M2evcXgsC4CdlIX4WQENUZMMnue9j2vXO4aIi39KR+jDH+UHK8JNqp1+UjkRQ==",
+ "version": "4.123.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.123.0.tgz",
+ "integrity": "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
@@ -2697,10 +2715,10 @@
"@cloudflare/unenv-preset": "2.16.1",
"blake3-wasm": "2.1.5",
"esbuild": "0.28.1",
- "miniflare": "5.20260804.0-alpha",
+ "miniflare": "5.20260811.1-alpha",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
- "workerd": "1.20260804.1"
+ "workerd": "1.20260811.1"
},
"bin": {
"cf-wrangler": "bin/cf-wrangler.js",
@@ -2714,7 +2732,7 @@
"fsevents": "2.3.3"
},
"peerDependencies": {
- "@cloudflare/workers-types": "^5.20260804.1"
+ "@cloudflare/workers-types": "^5.20260811.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
diff --git a/package.json b/package.json
index b59fbca..f1eb01c 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,10 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
+ "bridge": "node bridge/server.mjs",
+ "test:bridge": "node --test bridge/*.test.mjs",
"typecheck": "tsc -b",
+ "typegen": "wrangler types",
"deploy": "npm run build && wrangler deploy",
"deploy:dry": "npm run build && wrangler deploy --dry-run"
},
@@ -19,10 +22,11 @@
"vite": "latest"
},
"devDependencies": {
- "@cloudflare/workers-types": "latest",
+ "@cloudflare/workers-types": "^5.20260813.1",
+ "@types/node": "^26.2.0",
"@types/react": "latest",
"@types/react-dom": "latest",
"typescript": "latest",
- "wrangler": "latest"
+ "wrangler": "^4.123.0"
}
}
diff --git a/public/downloads/printpath-bridge-windows.zip b/public/downloads/printpath-bridge-windows.zip
new file mode 100644
index 0000000..915ebe3
Binary files /dev/null and b/public/downloads/printpath-bridge-windows.zip differ
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..697d1a7
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/projects/apartment-quiet-utility-concept.svg b/public/projects/apartment-quiet-utility-concept.svg
new file mode 100644
index 0000000..45ab717
--- /dev/null
+++ b/public/projects/apartment-quiet-utility-concept.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/projects/board-game-token-upgrades-concept.svg b/public/projects/board-game-token-upgrades-concept.svg
new file mode 100644
index 0000000..b1f68b3
--- /dev/null
+++ b/public/projects/board-game-token-upgrades-concept.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/projects/capiz-cove-phone-stand-concept.svg b/public/projects/capiz-cove-phone-stand-concept.svg
new file mode 100644
index 0000000..1c3c4fd
--- /dev/null
+++ b/public/projects/capiz-cove-phone-stand-concept.svg
@@ -0,0 +1,44 @@
+
diff --git a/public/projects/capiz-cove-product-draft.png b/public/projects/capiz-cove-product-draft.png
new file mode 100644
index 0000000..8d8d0db
Binary files /dev/null and b/public/projects/capiz-cove-product-draft.png differ
diff --git a/public/projects/ceramic-cabinet-risers-concept.svg b/public/projects/ceramic-cabinet-risers-concept.svg
new file mode 100644
index 0000000..e8b237e
--- /dev/null
+++ b/public/projects/ceramic-cabinet-risers-concept.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/projects/cozy-harvest-phone-stand-concept.svg b/public/projects/cozy-harvest-phone-stand-concept.svg
new file mode 100644
index 0000000..5604a0a
--- /dev/null
+++ b/public/projects/cozy-harvest-phone-stand-concept.svg
@@ -0,0 +1,42 @@
+
diff --git a/public/projects/cozy-harvest-product-draft.png b/public/projects/cozy-harvest-product-draft.png
new file mode 100644
index 0000000..da952d4
Binary files /dev/null and b/public/projects/cozy-harvest-product-draft.png differ
diff --git a/public/projects/cozy-stickerville-organizer-concept.svg b/public/projects/cozy-stickerville-organizer-concept.svg
new file mode 100644
index 0000000..b215467
--- /dev/null
+++ b/public/projects/cozy-stickerville-organizer-concept.svg
@@ -0,0 +1,97 @@
+
diff --git a/public/projects/cozy-stickerville-organizer-v1-aug-2026.md b/public/projects/cozy-stickerville-organizer-v1-aug-2026.md
new file mode 100644
index 0000000..37d50df
--- /dev/null
+++ b/public/projects/cozy-stickerville-organizer-v1-aug-2026.md
@@ -0,0 +1,37 @@
+# Cozy Stickerville Organizer · Campaign layout v1 · Aug 2026
+
+## Goal
+
+A campaign-friendly insert that separates archived cards from the active year, brings the four resource types to the table in lift-out bowls, and preserves the supplied save-game box.
+
+## Verified inventory
+
+The official contents include a sticker book, storybook, die, 120 Event cards arranged as ten 12-card year decks, 60 Catalog cards, a reference card, save-game box, map, and tokens for 12 Food, 12 Wood, 12 Gold, and 6 Ore.
+
+Sources: [official contents](https://www.asmodee.ca/en/product/cozy-stickerville/), [official rulebook](https://cdn.svc.asmodee.net/production-asmodeeca/uploads/2026/01/Cozy_Stickerville_Rulebook_English.pdf), [published box dimensions](https://www.hugendubel.de/de/spielware/corey_konieczka-unexpected_games_cozy_stickerville-52767357-produkt-details.html), and [card dimensions](https://fundas.online/juegos/cozy-stickerville).
+
+The published outer box is approximately **305 × 225 × 51 mm**. The game has **181 cards at 63 × 88 mm** unsleeved. Because one box axis exceeds the P1S plate, the insert must be modular even before the internal dimensions are known.
+
+## Original module plan
+
+| Module | Purpose | Design feature |
+| --- | --- | --- |
+| Year Library | Ten Event decks | Numbered dividers and one active-year pull tab |
+| Catalog Library | Cards 1–60 in order | Wide finger ramp; never requires shuffling |
+| Resource Village | Wood, Food, Gold, Ore, and die | Four removable bowls that become table supply trays |
+| Save-Game Dock | Supplied save box and current campaign state | Full-height finger access without compressing cards |
+| Flat archive layer | Map, sticker book, storybook, and reference | Continuous support ledge to prevent bowing |
+
+The Year Library and Catalog Library form Plate A. The Resource Village and Save-Game Dock form Plate B. Small campaign dividers can fill unused plate space.
+
+## Required measurements before printable CAD
+
+1. Inside box width, depth, and usable height with all books and the map stacked as intended.
+2. Outside size of the supplied save-game box.
+3. Thickness of the map/book stack and whether the box will be stored vertically.
+
+## First prototype
+
+The first printable part should be the four-bowl Resource Village, because its fit depends mainly on the internal box envelope and token volume—not card sleeves or campaign state. The card libraries follow after a single width gauge is approved.
+
+This packet is an original PrintPath layout and does not copy the geometry of existing commercial or community inserts.
diff --git a/public/projects/drawer-gap-tray-aug-2026-cover.png b/public/projects/drawer-gap-tray-aug-2026-cover.png
new file mode 100644
index 0000000..be476cf
Binary files /dev/null and b/public/projects/drawer-gap-tray-aug-2026-cover.png differ
diff --git a/public/projects/drawer-gap-tray-aug-2026-product.png b/public/projects/drawer-gap-tray-aug-2026-product.png
new file mode 100644
index 0000000..a7f06a4
Binary files /dev/null and b/public/projects/drawer-gap-tray-aug-2026-product.png differ
diff --git a/public/projects/evergarden-bouquet-product-draft.png b/public/projects/evergarden-bouquet-product-draft.png
new file mode 100644
index 0000000..6cdcc94
Binary files /dev/null and b/public/projects/evergarden-bouquet-product-draft.png differ
diff --git a/public/projects/film-camera-tools-concept.svg b/public/projects/film-camera-tools-concept.svg
new file mode 100644
index 0000000..804508d
--- /dev/null
+++ b/public/projects/film-camera-tools-concept.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/projects/leaf-bloom-vase-aug-2026-cover.png b/public/projects/leaf-bloom-vase-aug-2026-cover.png
new file mode 100644
index 0000000..a68987c
Binary files /dev/null and b/public/projects/leaf-bloom-vase-aug-2026-cover.png differ
diff --git a/public/projects/leaf-bloom-vase-aug-2026-print-guide.md b/public/projects/leaf-bloom-vase-aug-2026-print-guide.md
new file mode 100644
index 0000000..a5d1025
--- /dev/null
+++ b/public/projects/leaf-bloom-vase-aug-2026-print-guide.md
@@ -0,0 +1,28 @@
+# Leaf Bloom Vase · Aug 2026
+
+## Design intent
+
+A one-piece botanical vase for fuller arrangements with leafy stems. Seven broad folds twist gently into a flared, scalloped opening while the low, wide base keeps the 245 mm form stable.
+
+## Bambu Lab P1S setup
+
+- Material: PETG
+- Nozzle: 0.4 mm
+- Plate: Textured PEI Plate
+- Layer height: 0.20 mm
+- Walls: 5 perimeters
+- Top layers: 0
+- Bottom layers: at least 7
+- Infill: 12–15% gyroid
+- Supports: off
+- Brim: 6–8 mm recommended
+- Seam: rear or aligned, not random
+
+## Before adding flowers
+
+1. Slice upright and confirm the model stays inside the P1S build volume.
+2. Inspect the first two layers before leaving the print unattended.
+3. Fill the finished vase with water over a sink and leave it on a dry paper towel for at least four hours.
+4. If moisture appears, dry it fully and seal the inside with a PETG-compatible waterproof coating before use.
+
+The STL is manifold and designed as one continuous body, but a watertight mesh does not guarantee a watertight FDM print.
diff --git a/public/projects/leaf-bloom-vase-aug-2026-product.png b/public/projects/leaf-bloom-vase-aug-2026-product.png
new file mode 100644
index 0000000..7ec6170
Binary files /dev/null and b/public/projects/leaf-bloom-vase-aug-2026-product.png differ
diff --git a/public/projects/leaf-bloom-vase-aug-2026.stl b/public/projects/leaf-bloom-vase-aug-2026.stl
new file mode 100644
index 0000000..7e631fe
Binary files /dev/null and b/public/projects/leaf-bloom-vase-aug-2026.stl differ
diff --git a/public/projects/magnetic-3d-island-board-concept.svg b/public/projects/magnetic-3d-island-board-concept.svg
new file mode 100644
index 0000000..2bdb808
--- /dev/null
+++ b/public/projects/magnetic-3d-island-board-concept.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/projects/magnetic-hex-token-magnet-cup-6x2-aug-2026.stl b/public/projects/magnetic-hex-token-magnet-cup-6x2-aug-2026.stl
new file mode 100644
index 0000000..b6c2339
Binary files /dev/null and b/public/projects/magnetic-hex-token-magnet-cup-6x2-aug-2026.stl differ
diff --git a/public/projects/magnetic-hex-token-system-aug-2026-print-guide.md b/public/projects/magnetic-hex-token-system-aug-2026-print-guide.md
new file mode 100644
index 0000000..577740e
--- /dev/null
+++ b/public/projects/magnetic-hex-token-system-aug-2026-print-guide.md
@@ -0,0 +1,57 @@
+# Magnetic Hex Token System · Alpha print guide · Aug 2026
+
+## Included prototypes
+
+- **Single well:** one open token bowl.
+- **Split well:** two resource zones.
+- **Triple well:** three wedge-like resource zones.
+- **6 × 2 mm magnet coupon:** three pocket sizes from tight to loose.
+- **Two-pod starter plate:** two single-well pods and twelve glue-on cups, arranged to prove connection strength before a batch print.
+
+Each tray is approximately **96 × 83.1 × 22 mm** and fits comfortably on the Bambu Lab P1S. Six separate glue-on cups accept nominal 6 × 2 mm disc magnets. This alpha keeps the connectors replaceable and lets the tray itself print as a clean support-free shell.
+
+The starter plate is approximately **200 × 113.4 × 22 mm**, requires **12 magnets**, and is planned at roughly **80 g PLA / 5–6 hours** before Bambu Studio slicing. Print it only after the fit coupon succeeds.
+
+## Print the coupon first
+
+Print the 45 × 18 mm coupon before any full tray. From left to right, the circular wells are:
+
+1. 6.20 mm diameter — tight.
+2. 6.35 mm diameter — nominal.
+3. 6.50 mm diameter — loose/glue fit.
+
+Use the well that holds your actual magnets securely without splitting the print. The alpha tray uses the nominal 6.30 mm pocket.
+
+## Prove one connection before batching
+
+1. Print the magnet coupon and choose the fit that works with your actual magnets.
+2. Print the two-pod starter plate: two single-well pods plus twelve cups.
+3. Assemble only one facing pair of cups first and verify attraction, holding strength, and easy separation.
+4. Mark the remaining alternating faces `A` and `B`, then complete the two-pod test.
+5. Only after that test passes, print the single-, split-, or triple-well variants needed for a larger set.
+
+## P1S starting profile
+
+- Material: PLA for the first fit test; PETG is optional for tougher table use.
+- Nozzle: 0.4 mm.
+- Layer height: 0.20 mm.
+- Walls: 3.
+- Top/bottom layers: 4.
+- Infill: 12–15% gyroid.
+- Supports: off.
+- Plate: Textured PEI.
+- Orientation: tray floor flat on the plate.
+
+## Connector assembly and magnet polarity
+
+Print six magnet cups per tray with their flat backs on the plate. Dry-fit the magnets, then mark alternating tray faces `A` and `B` with a paint pen. Glue A-face cups with one pole outward and B-face cups with the opposite pole outward. Rotate neighboring trays so an A face meets a B face.
+
+Dry-fit every magnet before using a tiny amount of cyanoacrylate or two-part epoxy. Do not force a magnet into a pocket that failed the coupon test.
+
+## Safety and alpha limits
+
+- Small magnets are a serious ingestion hazard. Keep them away from children and pets.
+- Keep magnets away from sensitive medical devices and electronics.
+- This alpha uses glue-on side cups so the tray remains easy to print and polarity mistakes can be corrected before final assembly.
+- Perform a two-tray connection test before printing a large set.
+- The trays are not food-safe containers.
diff --git a/public/projects/magnetic-hex-token-system-concept.svg b/public/projects/magnetic-hex-token-system-concept.svg
new file mode 100644
index 0000000..a67ec8d
--- /dev/null
+++ b/public/projects/magnetic-hex-token-system-concept.svg
@@ -0,0 +1,35 @@
+
diff --git a/public/projects/magnetic-hex-token-tray-fit-coupon-aug-2026.stl b/public/projects/magnetic-hex-token-tray-fit-coupon-aug-2026.stl
new file mode 100644
index 0000000..78cb7fe
Binary files /dev/null and b/public/projects/magnetic-hex-token-tray-fit-coupon-aug-2026.stl differ
diff --git a/public/projects/magnetic-hex-token-tray-single-aug-2026-product.png b/public/projects/magnetic-hex-token-tray-single-aug-2026-product.png
new file mode 100644
index 0000000..443bff3
Binary files /dev/null and b/public/projects/magnetic-hex-token-tray-single-aug-2026-product.png differ
diff --git a/public/projects/magnetic-hex-token-tray-single-aug-2026.stl b/public/projects/magnetic-hex-token-tray-single-aug-2026.stl
new file mode 100644
index 0000000..0db0f09
Binary files /dev/null and b/public/projects/magnetic-hex-token-tray-single-aug-2026.stl differ
diff --git a/public/projects/magnetic-hex-token-tray-split-aug-2026.stl b/public/projects/magnetic-hex-token-tray-split-aug-2026.stl
new file mode 100644
index 0000000..885ade4
Binary files /dev/null and b/public/projects/magnetic-hex-token-tray-split-aug-2026.stl differ
diff --git a/public/projects/magnetic-hex-token-tray-triple-aug-2026-product.png b/public/projects/magnetic-hex-token-tray-triple-aug-2026-product.png
new file mode 100644
index 0000000..5830b1b
Binary files /dev/null and b/public/projects/magnetic-hex-token-tray-triple-aug-2026-product.png differ
diff --git a/public/projects/magnetic-hex-token-tray-triple-aug-2026.stl b/public/projects/magnetic-hex-token-tray-triple-aug-2026.stl
new file mode 100644
index 0000000..ed7650e
Binary files /dev/null and b/public/projects/magnetic-hex-token-tray-triple-aug-2026.stl differ
diff --git a/public/projects/magnetic-hex-token-two-pod-starter-kit-aug-2026-product.png b/public/projects/magnetic-hex-token-two-pod-starter-kit-aug-2026-product.png
new file mode 100644
index 0000000..5bacb63
Binary files /dev/null and b/public/projects/magnetic-hex-token-two-pod-starter-kit-aug-2026-product.png differ
diff --git a/public/projects/magnetic-hex-token-two-pod-starter-kit-aug-2026.stl b/public/projects/magnetic-hex-token-two-pod-starter-kit-aug-2026.stl
new file mode 100644
index 0000000..ef85552
Binary files /dev/null and b/public/projects/magnetic-hex-token-two-pod-starter-kit-aug-2026.stl differ
diff --git a/public/projects/ocean-nature-phone-stand-concept.svg b/public/projects/ocean-nature-phone-stand-concept.svg
new file mode 100644
index 0000000..91e2318
--- /dev/null
+++ b/public/projects/ocean-nature-phone-stand-concept.svg
@@ -0,0 +1,45 @@
+
diff --git a/public/projects/perfume-lazy-susan-concept.svg b/public/projects/perfume-lazy-susan-concept.svg
new file mode 100644
index 0000000..2316a84
--- /dev/null
+++ b/public/projects/perfume-lazy-susan-concept.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/projects/petal-twist-vase-aug-2026-cover.png b/public/projects/petal-twist-vase-aug-2026-cover.png
new file mode 100644
index 0000000..aba483e
Binary files /dev/null and b/public/projects/petal-twist-vase-aug-2026-cover.png differ
diff --git a/public/projects/petal-twist-vase-aug-2026-print-guide.md b/public/projects/petal-twist-vase-aug-2026-print-guide.md
new file mode 100644
index 0000000..efc61e4
--- /dev/null
+++ b/public/projects/petal-twist-vase-aug-2026-print-guide.md
@@ -0,0 +1,40 @@
+# Petal Twist Vase — draft print guide
+
+- Status: design review
+- Printer: Bambu Lab P1S
+- Nozzle: 0.4 mm
+- Material: PETG
+- Finished size: 122.6 × 122.6 × 245 mm
+
+## Why it fits
+
+- The vase is 245 mm tall, leaving 11 mm beneath the P1S's 256 mm Z limit.
+- Its 122.6 mm footprint leaves ample XY space for a brim.
+- It prints upright as one continuous part, so there is no assembled seam below the waterline.
+
+## Starting slice
+
+- Use a dry PETG profile from the filament manufacturer.
+- 0.20 mm layer height.
+- Six wall loops; the modeled wall is 2.4 mm.
+- Keep the modeled 3.2 mm solid floor.
+- Add a 5–6 mm outer brim for tall-part confidence.
+- No supports and no vase/spiral mode.
+- Place the seam on the least-visible back flute and inspect that seam carefully in Preview.
+
+## Required checks before printing
+
+1. Confirm the model is upright and reports 245 mm tall.
+2. Confirm the P1S, 0.4 mm nozzle, selected PETG, and intended plate.
+3. Slice and inspect the entire floor, all six wall paths, the vertical seam, and the scalloped rim.
+4. Make sure there is no out-of-bounds warning and the brim remains inside the plate.
+
+## Water test
+
+Mesh validation proves that the digital surface is closed; it cannot guarantee that an FDM print will be leak-free.
+
+1. Let the print cool completely.
+2. Put it on a dry paper towel and fill the bottom 25 mm for 30 minutes.
+3. If dry, fill it normally and leave it on a fresh towel for 12 hours.
+4. Check the bottom and vertical seam. Do not place it on finished furniture until it passes.
+5. If it seeps, use a removable liner or seal the interior with a coating suitable for the intended use.
diff --git a/public/projects/petal-twist-vase-aug-2026-product.png b/public/projects/petal-twist-vase-aug-2026-product.png
new file mode 100644
index 0000000..c2a1749
Binary files /dev/null and b/public/projects/petal-twist-vase-aug-2026-product.png differ
diff --git a/public/projects/petal-twist-vase-aug-2026.stl b/public/projects/petal-twist-vase-aug-2026.stl
new file mode 100644
index 0000000..d8a6b42
Binary files /dev/null and b/public/projects/petal-twist-vase-aug-2026.stl differ
diff --git a/public/projects/phone-stand-pair-concept.svg b/public/projects/phone-stand-pair-concept.svg
new file mode 100644
index 0000000..e3383d3
--- /dev/null
+++ b/public/projects/phone-stand-pair-concept.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/projects/porcelain-reed-bouquet-concept.svg b/public/projects/porcelain-reed-bouquet-concept.svg
new file mode 100644
index 0000000..29b6c71
--- /dev/null
+++ b/public/projects/porcelain-reed-bouquet-concept.svg
@@ -0,0 +1,62 @@
+
diff --git a/public/projects/porcelain-reed-vase-aug-2026-print-guide.md b/public/projects/porcelain-reed-vase-aug-2026-print-guide.md
new file mode 100644
index 0000000..ab1e3ca
--- /dev/null
+++ b/public/projects/porcelain-reed-vase-aug-2026-print-guide.md
@@ -0,0 +1,39 @@
+# Porcelain Reed Vase — Print Approach
+
+## Approved geometry
+
+- Envelope: approximately **116.7 × 116.7 × 245 mm**
+- Body: one watertight STL with 36 fine vertical ribs
+- Wall: **2.4 mm** radial thickness
+- Floor: **3.2 mm**
+- Opening: approximately **91 mm inner diameter**
+- Printer: Bambu Lab P1S, 0.4 mm nozzle, Textured PEI Plate
+
+## Suggested first print
+
+- Material: white PETG for the water-capable body
+- Layer height: 0.20 mm
+- Walls: 4
+- Top shell layers: 0
+- Bottom shell layers: 6
+- Infill: 10–15% gyroid; the hollow body dominates
+- Supports: off
+- Brim: 6–8 mm if the plate is not freshly cleaned
+- Seam: align to rear
+
+## Planning estimate
+
+- Geometry volume: approximately **216.5 cm³**
+- Material: approximately **275 g PETG** at 1.27 g/cm³
+- Time budget: approximately **9–11 hours** at 0.20 mm with four walls
+
+Treat the time as a planning range. Bambu Studio's sliced estimate is authoritative for the selected filament profile, speeds, seam placement, and acceleration.
+
+## Before flowers or water
+
+1. Confirm the model reports **245 mm** tall and remains inside the P1S build volume.
+2. Inspect the sliced base for a continuous floor and the rim for an uninterrupted loop.
+3. For real flowers, fill the cooled vase over a sink and leave it on a dry towel for 12 hours.
+4. The Evergarden printed bouquet does not require water; use the removable stem grid to space its stems.
+
+The bouquet parts and stem grid are still in design review. This file approves the vase body only.
diff --git a/public/projects/porcelain-reed-vase-aug-2026-product.png b/public/projects/porcelain-reed-vase-aug-2026-product.png
new file mode 100644
index 0000000..4bf1b5a
Binary files /dev/null and b/public/projects/porcelain-reed-vase-aug-2026-product.png differ
diff --git a/public/projects/porcelain-reed-vase-aug-2026.stl b/public/projects/porcelain-reed-vase-aug-2026.stl
new file mode 100644
index 0000000..c587563
Binary files /dev/null and b/public/projects/porcelain-reed-vase-aug-2026.stl differ
diff --git a/public/projects/pottery-signature-stamps-concept.svg b/public/projects/pottery-signature-stamps-concept.svg
new file mode 100644
index 0000000..d654ab9
--- /dev/null
+++ b/public/projects/pottery-signature-stamps-concept.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/projects/retainer-drying-dock-concept.svg b/public/projects/retainer-drying-dock-concept.svg
new file mode 100644
index 0000000..eb32600
--- /dev/null
+++ b/public/projects/retainer-drying-dock-concept.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/projects/seven-wonders-duel-organizer-concept-mobile.svg b/public/projects/seven-wonders-duel-organizer-concept-mobile.svg
new file mode 100644
index 0000000..eb1dfb3
--- /dev/null
+++ b/public/projects/seven-wonders-duel-organizer-concept-mobile.svg
@@ -0,0 +1,34 @@
+
diff --git a/public/projects/seven-wonders-duel-organizer-concept.svg b/public/projects/seven-wonders-duel-organizer-concept.svg
new file mode 100644
index 0000000..e4425af
--- /dev/null
+++ b/public/projects/seven-wonders-duel-organizer-concept.svg
@@ -0,0 +1,45 @@
+
diff --git a/public/projects/seven-wonders-duel-organizer-v1-aug-2026.md b/public/projects/seven-wonders-duel-organizer-v1-aug-2026.md
new file mode 100644
index 0000000..5791f93
--- /dev/null
+++ b/public/projects/seven-wonders-duel-organizer-v1-aug-2026.md
@@ -0,0 +1,43 @@
+# 7 Wonders Duel Organizer · Layout v1 · Aug 2026
+
+## Goal
+
+A lift-out, base-game organizer that shortens setup, keeps loose pieces contained during vertical storage, and leaves the board and rules supported as a flat top layer.
+
+## Verified inventory
+
+The official game lists 23 Age I cards, 23 Age II cards, 20 Age III cards, 7 Guild cards, 12 Wonder cards, 10 Progress tokens, 4 Military tokens, a Conflict pawn, and 31 coins. The existing insert ecosystem confirms that large cards, Age decks, coins, progress tokens, military pieces, and boards benefit from separate zones.
+
+Sources: [official component list](https://sevenwondersduel.com/), [published 208 × 206 × 58 mm box listing](https://www.asmodee.co.uk/products/asm7du-en01-7-wonders-duel), [73 small cards at 44 × 68 mm and 12 Wonder cards at 65 × 100 mm](https://www.rykergames.com/products/7-wonders-duel-card-sleeve-kit), and [196 × 196 × 42 mm reference insert envelope](https://thedicetroyers.com/wp-content/uploads/2021/04/Instructions-7-Wonders-Duel-min.pdf).
+
+Published boxes vary slightly by printing, with common listings around **205–208 mm square** and **50–58 mm deep**. These are outer dimensions, not a printable interior claim.
+
+## Reference layout—not yet a fit claim
+
+The first layout tiles a **196 × 196 mm reference envelope** with four removable modules and a 2 mm service gap between rows and columns:
+
+| Module | Reference outside size | Job |
+| --- | --- | --- |
+| Age card bank | 121.5 × 117.5 × 28 mm | Two broad wells with labeled dividers for the three Ages and Guild cards |
+| Wonder cradle | 72.5 × 117.5 × 28 mm | Large-card well, score pad access, and a thumb ramp |
+| Coin bank | 121.5 × 76.5 × 28 mm | Three scoopable value wells; lifts directly to the table |
+| Token caddy | 72.5 × 76.5 × 28 mm | Progress, Military, and Conflict-piece zones |
+
+All four modules fit geometrically on one P1S plate. The recommended first run uses two plates: card modules first, then loose-piece modules after the card fit is confirmed. This limits wasted material if sleeve clearance is wrong.
+
+## Required measurements before printable CAD
+
+1. Inside box width, depth, and usable height beneath the closed board, rules, and score pad.
+2. Unsleeved or sleeve brand, plus whether the layout is base-game only or reserves expansion space.
+3. Board/rule stack thickness and whether the box is normally stored flat or vertically.
+
+## Print sequence
+
+1. Print one small card-width gauge.
+2. Confirm the gauge with the thickest sleeved deck.
+3. Print the Age card bank and Wonder cradle on Plate A.
+4. Test the board-and-rule top layer and close the lid.
+5. Print the coin bank and token caddy on Plate B.
+6. Perform a gentle vertical-storage shake test before calling the layout complete.
+
+No STL is released from this reference packet until the five measurements above are confirmed.
diff --git a/public/projects/shower-tool-rail-concept.svg b/public/projects/shower-tool-rail-concept.svg
new file mode 100644
index 0000000..1673a6e
--- /dev/null
+++ b/public/projects/shower-tool-rail-concept.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/projects/sink-rack-drain-bridge-concept.svg b/public/projects/sink-rack-drain-bridge-concept.svg
new file mode 100644
index 0000000..fdd3a8f
--- /dev/null
+++ b/public/projects/sink-rack-drain-bridge-concept.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/projects/steam-controller-dock-concept.svg b/public/projects/steam-controller-dock-concept.svg
new file mode 100644
index 0000000..ca6ec94
--- /dev/null
+++ b/public/projects/steam-controller-dock-concept.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/projects/tidepool-buddy-product-draft.png b/public/projects/tidepool-buddy-product-draft.png
new file mode 100644
index 0000000..9892246
Binary files /dev/null and b/public/projects/tidepool-buddy-product-draft.png differ
diff --git a/scripts/generate_hex_token_system.py b/scripts/generate_hex_token_system.py
new file mode 100644
index 0000000..2bbc85e
--- /dev/null
+++ b/scripts/generate_hex_token_system.py
@@ -0,0 +1,269 @@
+"""Generate PrintPath's support-free magnetic hex token tray prototype."""
+
+from __future__ import annotations
+
+import math
+import struct
+from collections import Counter
+from pathlib import Path
+
+Vec3 = tuple[float, float, float]
+Triangle = tuple[Vec3, Vec3, Vec3]
+
+OUTER_RADIUS = 48.0
+HEIGHT = 22.0
+WALL = 2.4
+FLOOR = 2.4
+MAGNET_RADIUS = 3.15
+MAGNET_DEPTH = 2.4
+POCKET_DEPTH = 4.0
+POCKET_FLOOR = POCKET_DEPTH - MAGNET_DEPTH
+CYLINDER_SEGMENTS = 32
+
+
+def add_quad(triangles: list[Triangle], a: Vec3, b: Vec3, c: Vec3, d: Vec3) -> None:
+ triangles.extend(((a, b, c), (a, c, d)))
+
+
+def ring(radius: float, z: float) -> list[Vec3]:
+ return [
+ (radius * math.cos(math.radians(index * 60)), radius * math.sin(math.radians(index * 60)), z)
+ for index in range(6)
+ ]
+
+
+def add_hex_cup(triangles: list[Triangle]) -> float:
+ outer_apothem = OUTER_RADIUS * math.cos(math.radians(30))
+ inner_apothem = outer_apothem - WALL
+ inner_radius = inner_apothem / math.cos(math.radians(30))
+ outer_bottom = ring(OUTER_RADIUS, 0.0)
+ outer_top = ring(OUTER_RADIUS, HEIGHT)
+ inner_floor = ring(inner_radius, FLOOR)
+ inner_top = ring(inner_radius, HEIGHT)
+ bottom_center = (0.0, 0.0, 0.0)
+ floor_center = (0.0, 0.0, FLOOR)
+
+ for index in range(6):
+ following = (index + 1) % 6
+ triangles.append((bottom_center, outer_bottom[following], outer_bottom[index]))
+ add_quad(triangles, outer_bottom[index], outer_bottom[following], outer_top[following], outer_top[index])
+ add_quad(triangles, inner_floor[index], inner_top[index], inner_top[following], inner_floor[following])
+ triangles.append((floor_center, inner_floor[index], inner_floor[following]))
+ add_quad(triangles, outer_top[index], outer_top[following], inner_top[following], inner_top[index])
+ return inner_apothem
+
+
+def add_oriented_box(
+ triangles: list[Triangle],
+ *,
+ length: float,
+ width: float,
+ height: float,
+ angle_degrees: float,
+ center_x: float = 0.0,
+ center_y: float = 0.0,
+ bottom: float = FLOOR - 0.2,
+) -> None:
+ angle = math.radians(angle_degrees)
+ forward = (math.cos(angle), math.sin(angle))
+ side = (-forward[1], forward[0])
+
+ def point(along: float, across: float, z: float) -> Vec3:
+ return (
+ center_x + forward[0] * along + side[0] * across,
+ center_y + forward[1] * along + side[1] * across,
+ z,
+ )
+
+ low = bottom
+ high = bottom + height
+ half_length = length / 2
+ half_width = width / 2
+ a = point(-half_length, -half_width, low)
+ b = point(half_length, -half_width, low)
+ c = point(half_length, half_width, low)
+ d = point(-half_length, half_width, low)
+ e = point(-half_length, -half_width, high)
+ f = point(half_length, -half_width, high)
+ g = point(half_length, half_width, high)
+ h = point(-half_length, half_width, high)
+ add_quad(triangles, a, d, c, b)
+ add_quad(triangles, a, b, f, e)
+ add_quad(triangles, b, c, g, f)
+ add_quad(triangles, c, d, h, g)
+ add_quad(triangles, d, a, e, h)
+ add_quad(triangles, e, f, g, h)
+
+
+def generate_tray(variant: str) -> list[Triangle]:
+ triangles: list[Triangle] = []
+ inner_apothem = add_hex_cup(triangles)
+ divider_height = HEIGHT - FLOOR - 5.0
+ if variant == "split":
+ add_oriented_box(triangles, length=inner_apothem * 2 + 1.0, width=2.2, height=divider_height, angle_degrees=0)
+ elif variant == "triple":
+ arm_length = inner_apothem + 1.0
+ for angle in (0.0, 120.0, 240.0):
+ radians = math.radians(angle)
+ add_oriented_box(
+ triangles,
+ length=arm_length,
+ width=2.2,
+ height=divider_height,
+ angle_degrees=angle,
+ center_x=math.cos(radians) * arm_length / 2,
+ center_y=math.sin(radians) * arm_length / 2,
+ )
+ elif variant != "single":
+ raise ValueError(f"Unknown tray variant: {variant}")
+ return triangles
+
+
+def generate_magnet_cup(radius: float = MAGNET_RADIUS) -> list[Triangle]:
+ triangles: list[Triangle] = []
+ outer_radius = radius + 1.55
+ outer_bottom = [
+ (outer_radius * math.cos(2 * math.pi * index / CYLINDER_SEGMENTS), outer_radius * math.sin(2 * math.pi * index / CYLINDER_SEGMENTS), 0.0)
+ for index in range(CYLINDER_SEGMENTS)
+ ]
+ outer_top = [(x, y, POCKET_DEPTH) for x, y, _ in outer_bottom]
+ inner_floor = [
+ (radius * math.cos(2 * math.pi * index / CYLINDER_SEGMENTS), radius * math.sin(2 * math.pi * index / CYLINDER_SEGMENTS), POCKET_FLOOR)
+ for index in range(CYLINDER_SEGMENTS)
+ ]
+ inner_top = [(x, y, POCKET_DEPTH) for x, y, _ in inner_floor]
+ outer_center = (0.0, 0.0, 0.0)
+ floor_center = (0.0, 0.0, POCKET_FLOOR)
+ for index in range(CYLINDER_SEGMENTS):
+ following = (index + 1) % CYLINDER_SEGMENTS
+ add_quad(triangles, outer_bottom[index], outer_bottom[following], outer_top[following], outer_top[index])
+ add_quad(triangles, inner_floor[index], inner_top[index], inner_top[following], inner_floor[following])
+ add_quad(triangles, outer_top[index], outer_top[following], inner_top[following], inner_top[index])
+ triangles.append((outer_center, outer_bottom[following], outer_bottom[index]))
+ triangles.append((floor_center, inner_floor[index], inner_floor[following]))
+ return triangles
+
+
+def add_vertical_cup(triangles: list[Triangle], center_x: float, radius: float) -> None:
+ outer_radius = radius + 1.5
+ outer_bottom = [
+ (center_x + outer_radius * math.cos(2 * math.pi * index / CYLINDER_SEGMENTS), 9.0 + outer_radius * math.sin(2 * math.pi * index / CYLINDER_SEGMENTS), 1.8)
+ for index in range(CYLINDER_SEGMENTS)
+ ]
+ outer_top = [(x, y, 5.6) for x, y, _ in outer_bottom]
+ inner_floor = [
+ (center_x + radius * math.cos(2 * math.pi * index / CYLINDER_SEGMENTS), 9.0 + radius * math.sin(2 * math.pi * index / CYLINDER_SEGMENTS), 3.2)
+ for index in range(CYLINDER_SEGMENTS)
+ ]
+ inner_top = [(x, y, 5.6) for x, y, _ in inner_floor]
+ outer_center = (center_x, 9.0, 1.8)
+ floor_center = (center_x, 9.0, 3.2)
+ for index in range(CYLINDER_SEGMENTS):
+ following = (index + 1) % CYLINDER_SEGMENTS
+ add_quad(triangles, outer_bottom[index], outer_bottom[following], outer_top[following], outer_top[index])
+ add_quad(triangles, inner_floor[index], inner_top[index], inner_top[following], inner_floor[following])
+ add_quad(triangles, outer_top[index], outer_top[following], inner_top[following], inner_top[index])
+ triangles.append((outer_center, outer_bottom[following], outer_bottom[index]))
+ triangles.append((floor_center, inner_floor[index], inner_floor[following]))
+
+
+def generate_coupon() -> list[Triangle]:
+ triangles: list[Triangle] = []
+ add_oriented_box(triangles, length=45.0, width=18.0, height=2.0, angle_degrees=0, center_x=22.5, center_y=9.0, bottom=0.0)
+ for center_x, radius in zip((8.0, 22.5, 37.0), (3.10, 3.175, 3.25)):
+ add_vertical_cup(triangles, center_x, radius)
+ return triangles
+
+
+def translated(triangles: list[Triangle], offset_x: float, offset_y: float, offset_z: float = 0.0) -> list[Triangle]:
+ return [
+ tuple((x + offset_x, y + offset_y, z + offset_z) for x, y, z in triangle)
+ for triangle in triangles
+ ]
+
+
+def generate_two_pod_starter_kit() -> list[Triangle]:
+ """Lay out two pods and twelve cups as a connection-test set on one P1S plate."""
+ triangles: list[Triangle] = []
+ triangles.extend(translated(generate_tray("single"), -52.0, 0.0))
+ triangles.extend(translated(generate_tray("single"), 52.0, 0.0))
+ cup = generate_magnet_cup()
+ cup_x_positions = (-37.5, -22.5, -7.5, 7.5, 22.5, 37.5)
+ for y in (-52.0, 52.0):
+ for x in cup_x_positions:
+ triangles.extend(translated(cup, x, y))
+ return triangles
+
+
+def normal(triangle: Triangle) -> Vec3:
+ a, b, c = triangle
+ ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
+ ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
+ cross = (
+ ab[1] * ac[2] - ab[2] * ac[1],
+ ab[2] * ac[0] - ab[0] * ac[2],
+ ab[0] * ac[1] - ab[1] * ac[0],
+ )
+ length = math.sqrt(sum(component * component for component in cross))
+ return tuple(component / length for component in cross) if length else (0.0, 0.0, 0.0)
+
+
+def write_binary_stl(path: Path, triangles: list[Triangle], label: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("wb") as handle:
+ handle.write(label.encode("ascii")[:80].ljust(80, b"\0"))
+ handle.write(struct.pack(" dict[str, object]:
+ def key(vertex: Vec3) -> tuple[int, int, int]:
+ return tuple(round(component * 1_000_000) for component in vertex)
+
+ edges: Counter[tuple[tuple[int, int, int], tuple[int, int, int]]] = Counter()
+ points: list[Vec3] = []
+ for triangle in triangles:
+ points.extend(triangle)
+ keys = [key(point) for point in triangle]
+ for first, second in ((0, 1), (1, 2), (2, 0)):
+ edges[tuple(sorted((keys[first], keys[second])))] += 1
+ bad_edges = [edge for edge, count in edges.items() if count != 2]
+ bounds_min = tuple(min(point[axis] for point in points) for axis in range(3))
+ bounds_max = tuple(max(point[axis] for point in points) for axis in range(3))
+ dimensions = tuple(bounds_max[axis] - bounds_min[axis] for axis in range(3))
+ if bad_edges:
+ raise ValueError(f"Mesh contains {len(bad_edges)} non-manifold shell edges.")
+ if max(dimensions) > 256:
+ raise ValueError(f"Mesh exceeds the P1S build volume: {dimensions}")
+ return {"triangles": len(triangles), "dimensions": dimensions, "watertight_shells": True}
+
+
+if __name__ == "__main__":
+ project_dir = Path(__file__).resolve().parents[1] / "public" / "projects"
+ outputs = {
+ "single": project_dir / "magnetic-hex-token-tray-single-aug-2026.stl",
+ "split": project_dir / "magnetic-hex-token-tray-split-aug-2026.stl",
+ "triple": project_dir / "magnetic-hex-token-tray-triple-aug-2026.stl",
+ }
+ for variant, output in outputs.items():
+ mesh = generate_tray(variant)
+ report = validate(mesh)
+ write_binary_stl(output, mesh, f"PrintPath magnetic hex tray {variant}")
+ print(f"{variant}: {report['dimensions']} mm · {report['triangles']} triangles · watertight shells")
+ coupon = generate_coupon()
+ coupon_report = validate(coupon)
+ coupon_path = project_dir / "magnetic-hex-token-tray-fit-coupon-aug-2026.stl"
+ write_binary_stl(coupon_path, coupon, "PrintPath 6x2 magnet fit coupon")
+ print(f"coupon: {coupon_report['dimensions']} mm · {coupon_report['triangles']} triangles · watertight shells")
+ magnet_cup = generate_magnet_cup()
+ magnet_cup_report = validate(magnet_cup)
+ magnet_cup_path = project_dir / "magnetic-hex-token-magnet-cup-6x2-aug-2026.stl"
+ write_binary_stl(magnet_cup_path, magnet_cup, "PrintPath 6x2 glue-on magnet cup")
+ print(f"magnet cup: {magnet_cup_report['dimensions']} mm · {magnet_cup_report['triangles']} triangles · watertight shell")
+ starter_kit = generate_two_pod_starter_kit()
+ starter_kit_report = validate(starter_kit)
+ starter_kit_path = project_dir / "magnetic-hex-token-two-pod-starter-kit-aug-2026.stl"
+ write_binary_stl(starter_kit_path, starter_kit, "PrintPath two-pod magnetic connection starter kit")
+ print(f"starter kit: {starter_kit_report['dimensions']} mm · {starter_kit_report['triangles']} triangles · watertight shells")
diff --git a/scripts/generate_petal_vase.py b/scripts/generate_petal_vase.py
new file mode 100644
index 0000000..d64eb40
--- /dev/null
+++ b/scripts/generate_petal_vase.py
@@ -0,0 +1,189 @@
+"""Generate PrintPath's single-piece, water-capable vase variants."""
+
+from __future__ import annotations
+
+import argparse
+import math
+import struct
+from collections import Counter
+from pathlib import Path
+
+Vec3 = tuple[float, float, float]
+Triangle = tuple[Vec3, Vec3, Vec3]
+
+HEIGHT = 245.0
+WALL = 2.4
+FLOOR = 3.2
+FLUTES = 8
+REED_FLUTES = 36
+ANGULAR_SEGMENTS = 128
+VERTICAL_RINGS = 80
+
+
+def smoothstep(value: float) -> float:
+ value = max(0.0, min(1.0, value))
+ return value * value * (3.0 - 2.0 * value)
+
+
+def interpolate_profile(t: float, values: list[tuple[float, float]]) -> float:
+ for (left_t, left_value), (right_t, right_value) in zip(values, values[1:]):
+ if t <= right_t:
+ blend = smoothstep((t - left_t) / (right_t - left_t))
+ return left_value + (right_value - left_value) * blend
+ return values[-1][1]
+
+
+def outer_radius(z: float, theta: float) -> float:
+ t = max(0.0, min(1.0, z / HEIGHT))
+ core = interpolate_profile(t, [(0.0, 51.0), (0.13, 57.0), (0.35, 55.0), (0.62, 47.0), (0.82, 39.0), (1.0, 41.0)])
+ amplitude = interpolate_profile(t, [(0.0, 3.8), (0.32, 5.2), (0.75, 3.0), (1.0, 4.2)])
+ twist = math.radians(13.0) * t
+ return core + amplitude * math.cos(FLUTES * (theta + twist))
+
+
+def leaf_bloom_outer_radius(z: float, theta: float) -> float:
+ """A broad seven-leaf body with a narrower waist and bouquet-friendly flare."""
+ t = max(0.0, min(1.0, z / HEIGHT))
+ core = interpolate_profile(t, [(0.0, 53.0), (0.12, 58.0), (0.28, 56.0), (0.52, 48.0), (0.72, 42.0), (0.84, 46.0), (0.93, 52.0), (1.0, 55.0)])
+ amplitude = interpolate_profile(t, [(0.0, 2.2), (0.18, 4.8), (0.50, 7.0), (0.72, 5.0), (1.0, 6.2)])
+ twist = math.radians(34.0) * smoothstep(t)
+ phase = 7.0 * (theta + twist)
+ leaf_fold = 0.64 * math.cos(phase) + 0.36 * math.cos(2.0 * phase)
+ return core + amplitude * leaf_fold
+
+
+def porcelain_reed_outer_radius(z: float, theta: float) -> float:
+ """A calm, fine-fluted body with a planted foot and clean circular rim."""
+ t = max(0.0, min(1.0, z / HEIGHT))
+ core = interpolate_profile(t, [(0.0, 53.0), (0.08, 57.0), (0.22, 56.0), (0.55, 52.5), (0.84, 49.5), (1.0, 48.0)])
+ amplitude = interpolate_profile(t, [(0.0, 0.9), (0.10, 1.35), (0.62, 1.15), (1.0, 0.85)])
+ phase = REED_FLUTES * theta
+ fine_rib = 0.78 * math.cos(phase) + 0.22 * math.cos(2.0 * phase)
+ return core + amplitude * fine_rib
+
+
+def vertex(z: float, theta: float, inward: float = 0.0, style: str = "petal-twist") -> Vec3:
+ radius_function = {
+ "petal-twist": outer_radius,
+ "leaf-bloom": leaf_bloom_outer_radius,
+ "porcelain-reed": porcelain_reed_outer_radius,
+ }[style]
+ radius = radius_function(z, theta) - inward
+ return (radius * math.cos(theta), radius * math.sin(theta), z)
+
+
+def top_vertex(theta: float, inward: float = 0.0, style: str = "petal-twist") -> Vec3:
+ if style == "leaf-bloom":
+ # A deeper seven-leaf crown opens the silhouette without exceeding 245 mm.
+ phase = 7.0 * (theta + math.radians(34.0))
+ z = HEIGHT - 4.0 + 4.0 * math.cos(phase)
+ elif style == "petal-twist":
+ # Eight shallow petals: the highest points remain at the approved 245 mm bound.
+ z = HEIGHT - 2.5 + 2.5 * math.cos(FLUTES * theta)
+ else:
+ z = HEIGHT
+ return vertex(HEIGHT, theta, inward, style)[:2] + (z,)
+
+
+def add_side(triangles: list[Triangle], lower: list[Vec3], upper: list[Vec3], inward: bool = False) -> None:
+ count = len(lower)
+ for index in range(count):
+ following = (index + 1) % count
+ a, b = lower[index], lower[following]
+ c, d = upper[index], upper[following]
+ if inward:
+ triangles.extend(((a, d, b), (a, c, d)))
+ else:
+ triangles.extend(((a, b, d), (a, d, c)))
+
+
+def generate(style: str = "petal-twist") -> list[Triangle]:
+ angular_segments = 216 if style == "porcelain-reed" else 168 if style == "leaf-bloom" else ANGULAR_SEGMENTS
+ angles = [2.0 * math.pi * index / angular_segments for index in range(angular_segments)]
+ regular_z = [(HEIGHT - 8.0) * index / (VERTICAL_RINGS - 1) for index in range(VERTICAL_RINGS)]
+ outer_rings = [[vertex(z, angle, style=style) for angle in angles] for z in regular_z]
+ outer_rings.append([top_vertex(angle, style=style) for angle in angles])
+
+ inner_z = [FLOOR] + [z for z in regular_z if z > FLOOR]
+ inner_rings = [[vertex(z, angle, WALL, style) for angle in angles] for z in inner_z]
+ inner_rings.append([top_vertex(angle, WALL, style) for angle in angles])
+
+ triangles: list[Triangle] = []
+ for lower, upper in zip(outer_rings, outer_rings[1:]):
+ add_side(triangles, lower, upper)
+ for lower, upper in zip(inner_rings, inner_rings[1:]):
+ add_side(triangles, lower, upper, inward=True)
+
+ bottom_center = (0.0, 0.0, 0.0)
+ floor_center = (0.0, 0.0, FLOOR)
+ for index in range(angular_segments):
+ following = (index + 1) % angular_segments
+ triangles.append((bottom_center, outer_rings[0][following], outer_rings[0][index]))
+ triangles.append((floor_center, inner_rings[0][index], inner_rings[0][following]))
+
+ inner = inner_rings[-1]
+ outer = outer_rings[-1]
+ triangles.append((inner[index], outer[index], outer[following]))
+ triangles.append((inner[index], outer[following], inner[following]))
+ return triangles
+
+
+def normal(triangle: Triangle) -> Vec3:
+ a, b, c = triangle
+ ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
+ ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
+ cross = (ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0])
+ length = math.sqrt(sum(component * component for component in cross))
+ return tuple(component / length for component in cross) if length else (0.0, 0.0, 0.0)
+
+
+def write_binary_stl(path: Path, triangles: list[Triangle], style: str = "petal-twist") -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("wb") as handle:
+ labels = {
+ "petal-twist": b"PrintPath Petal Twist Vase",
+ "leaf-bloom": b"PrintPath Leaf Bloom Vase",
+ "porcelain-reed": b"PrintPath Porcelain Reed Vase",
+ }
+ label = labels[style]
+ handle.write(label.ljust(80, b"\0"))
+ handle.write(struct.pack(" dict[str, object]:
+ def key(vertex_value: Vec3) -> tuple[int, int, int]:
+ return tuple(round(component * 1_000_000) for component in vertex_value)
+
+ edges: Counter[tuple[tuple[int, int, int], tuple[int, int, int]]] = Counter()
+ points: list[Vec3] = []
+ for triangle in triangles:
+ points.extend(triangle)
+ keys = [key(point) for point in triangle]
+ for first, second in ((0, 1), (1, 2), (2, 0)):
+ edges[tuple(sorted((keys[first], keys[second])))] += 1
+ bad_edges = [edge for edge, count in edges.items() if count != 2]
+ bounds_min = tuple(min(point[axis] for point in points) for axis in range(3))
+ bounds_max = tuple(max(point[axis] for point in points) for axis in range(3))
+ dimensions = tuple(bounds_max[axis] - bounds_min[axis] for axis in range(3))
+ if bad_edges:
+ raise ValueError(f"Mesh is not watertight: {len(bad_edges)} edges do not have exactly two faces.")
+ if dimensions[2] > 245.001 or max(dimensions[:2]) > 130:
+ raise ValueError(f"Unexpected vase bounds: {dimensions}")
+ return {"triangles": len(triangles), "dimensions": dimensions, "bounds_min": bounds_min, "bounds_max": bounds_max, "watertight": True}
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("output", type=Path)
+ parser.add_argument("--style", choices=("petal-twist", "leaf-bloom", "porcelain-reed"), default="petal-twist")
+ args = parser.parse_args()
+ mesh = generate(args.style)
+ report = validate(mesh)
+ write_binary_stl(args.output, mesh, args.style)
+ print(f"Wrote {args.output}")
+ print(f"Triangles: {report['triangles']}")
+ print("Bounds: " + " × ".join(f"{value:.2f}" for value in report["dimensions"]) + " mm")
+ print("Watertight: yes")
diff --git a/scripts/render_design_drafts.py b/scripts/render_design_drafts.py
new file mode 100644
index 0000000..793f589
--- /dev/null
+++ b/scripts/render_design_drafts.py
@@ -0,0 +1,255 @@
+"""Render deterministic geometry drafts for PrintPath concept projects.
+
+These are product-style previews of real triangle meshes built in memory. They are
+not print-approved STLs: phone fit dimensions and connector coupons remain gates.
+"""
+
+from __future__ import annotations
+
+import math
+import struct
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+from PIL import Image, ImageDraw, ImageFilter
+
+Vec3 = tuple[float, float, float]
+
+
+@dataclass
+class Part:
+ triangles: np.ndarray
+ color: tuple[int, int, int]
+ name: str
+
+
+def mesh(triangles: list[tuple[Vec3, Vec3, Vec3]]) -> np.ndarray:
+ return np.asarray(triangles, dtype=float)
+
+
+def box(size: Vec3, center: Vec3) -> np.ndarray:
+ sx, sy, sz = (value / 2 for value in size)
+ cx, cy, cz = center
+ v = np.array([
+ [cx - sx, cy - sy, cz - sz], [cx + sx, cy - sy, cz - sz],
+ [cx + sx, cy + sy, cz - sz], [cx - sx, cy + sy, cz - sz],
+ [cx - sx, cy - sy, cz + sz], [cx + sx, cy - sy, cz + sz],
+ [cx + sx, cy + sy, cz + sz], [cx - sx, cy + sy, cz + sz],
+ ])
+ faces = [(0, 2, 1), (0, 3, 2), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4),
+ (1, 2, 6), (1, 6, 5), (2, 3, 7), (2, 7, 6), (3, 0, 4), (3, 4, 7)]
+ return v[np.asarray(faces)]
+
+
+def prism(points: list[tuple[float, float]], z0: float, z1: float) -> np.ndarray:
+ vertices = [(x, y, z0) for x, y in points] + [(x, y, z1) for x, y in points]
+ count = len(points)
+ triangles: list[tuple[Vec3, Vec3, Vec3]] = []
+ for index in range(1, count - 1):
+ triangles.append((vertices[0], vertices[index + 1], vertices[index]))
+ triangles.append((vertices[count], vertices[count + index], vertices[count + index + 1]))
+ for index in range(count):
+ following = (index + 1) % count
+ triangles.extend(((vertices[index], vertices[following], vertices[count + following]),
+ (vertices[index], vertices[count + following], vertices[count + index])))
+ return mesh(triangles)
+
+
+def cylinder(radius: float, height: float, center: Vec3, segments: int = 24) -> np.ndarray:
+ cx, cy, cz = center
+ z0, z1 = cz - height / 2, cz + height / 2
+ lower = [(cx + radius * math.cos(2 * math.pi * i / segments), cy + radius * math.sin(2 * math.pi * i / segments), z0) for i in range(segments)]
+ upper = [(x, y, z1) for x, y, _ in lower]
+ triangles: list[tuple[Vec3, Vec3, Vec3]] = []
+ for index in range(segments):
+ following = (index + 1) % segments
+ triangles.extend(((lower[index], lower[following], upper[following]), (lower[index], upper[following], upper[index])))
+ triangles.append(((cx, cy, z0), lower[following], lower[index]))
+ triangles.append(((cx, cy, z1), upper[index], upper[following]))
+ return mesh(triangles)
+
+
+def ellipsoid(radii: Vec3, center: Vec3, segments: int = 28, rings: int = 14) -> np.ndarray:
+ rx, ry, rz = radii
+ cx, cy, cz = center
+ vertices = []
+ for ring in range(rings + 1):
+ phi = math.pi * ring / rings
+ for segment in range(segments):
+ theta = 2 * math.pi * segment / segments
+ vertices.append((cx + rx * math.sin(phi) * math.cos(theta), cy + ry * math.sin(phi) * math.sin(theta), cz + rz * math.cos(phi)))
+ triangles: list[tuple[Vec3, Vec3, Vec3]] = []
+ for ring in range(rings):
+ for segment in range(segments):
+ following = (segment + 1) % segments
+ a = ring * segments + segment
+ b = ring * segments + following
+ c = (ring + 1) * segments + following
+ d = (ring + 1) * segments + segment
+ triangles.extend(((vertices[a], vertices[b], vertices[c]), (vertices[a], vertices[c], vertices[d])))
+ return mesh(triangles)
+
+
+def rotate_x(triangles: np.ndarray, degrees: float, origin: Vec3 = (0, 0, 0)) -> np.ndarray:
+ angle = math.radians(degrees)
+ rotation = np.array([[1, 0, 0], [0, math.cos(angle), -math.sin(angle)], [0, math.sin(angle), math.cos(angle)]])
+ return (triangles - np.asarray(origin)) @ rotation.T + np.asarray(origin)
+
+
+def rotate_z(triangles: np.ndarray, degrees: float) -> np.ndarray:
+ angle = math.radians(degrees)
+ rotation = np.array([[math.cos(angle), -math.sin(angle), 0], [math.sin(angle), math.cos(angle), 0], [0, 0, 1]])
+ return triangles @ rotation.T
+
+
+def translate(triangles: np.ndarray, offset: Vec3) -> np.ndarray:
+ return triangles + np.asarray(offset)
+
+
+def load_stl(path: Path) -> np.ndarray:
+ data = path.read_bytes()
+ count = struct.unpack(" list[Part]:
+ bars = [box((6, 10, 164), (-38, 3, 91)), box((6, 10, 164), (38, 3, 91)),
+ box((70, 10, 6), (0, 3, 12)), box((70, 10, 6), (0, 3, 170))]
+ return [Part(rotate_x(bar, -12, (0, 3, 12)), (48, 63, 65), "assumed phone envelope") for bar in bars]
+
+
+def tidepool() -> list[Part]:
+ parts = [Part(ellipsoid((68, 46, 8), (0, 2, 8)), (31, 77, 112), "manta base")]
+ for index, (y, z, angle) in enumerate([((24), 36, -10), (28, 57, -4), (31, 78, 4), (34, 97, 12)]):
+ arc = rotate_x(box((88 - index * 10, 11, 23), (0, y, z)), angle, (0, y, z))
+ parts.append(Part(arc, (94, 183, 204), "wave support"))
+ parts.append(Part(box((88, 18, 12), (0, -25, 18)), (110, 198, 218), "phone lip"))
+ for x in (-44, -22, 22, 44):
+ parts.append(Part(ellipsoid((10, 8, 5), (x, -32, 27)), (244, 246, 239), "foam accent"))
+ return parts + phone_parts()
+
+
+def capiz() -> list[Part]:
+ fan = [(-70, -26), (-56, 28), (-34, 52), (0, 62), (34, 52), (56, 28), (70, -26)]
+ parts = [Part(prism(fan, 0, 14), (28, 66, 102), "scallop reef")]
+ for index, x in enumerate((-48, -24, 0, 24, 48)):
+ pane = ellipsoid((25, 4, 45 + (2 - abs(index - 2)) * 5), (x, 30, 72 + (2 - abs(index - 2)) * 3))
+ parts.append(Part(pane, (234, 240, 233), "capiz pane"))
+ parts.append(Part(box((92, 20, 15), (0, -25, 19)), (104, 191, 205), "tide cradle"))
+ return parts + phone_parts()
+
+
+def cozy() -> list[Part]:
+ parts = [Part(box((132, 92, 14), (0, 2, 7)), (48, 105, 58), "field base"),
+ Part(box((112, 72, 10), (0, 3, 18)), (80, 142, 72), "upper terrace"),
+ Part(box((88, 13, 105), (0, 30, 72)), (239, 215, 165), "barn support")]
+ roof = prism([(-58, 25), (0, 62), (58, 25), (46, 17), (0, 46), (-46, 17)], 119, 133)
+ parts.append(Part(roof, (177, 74, 59), "barn roof"))
+ parts.append(Part(box((90, 21, 14), (0, -25, 24)), (168, 169, 157), "stone bridge lip"))
+ parts.append(Part(box((18, 27, 6), (0, -28, 16)), (84, 145, 164), "cable channel"))
+ for x, color in [(-35, (226, 187, 73)), (0, (105, 165, 81)), (35, (206, 103, 82))]:
+ parts.append(Part(box((18, 18, 8), (x, -1, 29)), color, "crop tile"))
+ return parts + phone_parts()
+
+
+def evergarden(vase_path: Path) -> list[Part]:
+ vase = load_stl(vase_path)
+ vase[:, :, 2] *= 0.72
+ parts = [Part(vase, (235, 235, 229), "Porcelain Reed vase")]
+ positions = [(-45, 2, 218), (-28, 6, 244), (-8, 8, 226), (14, 4, 250), (34, 6, 220), (48, 3, 240), (4, 7, 270)]
+ colors = [(100, 181, 202), (244, 241, 231), (40, 77, 112), (100, 181, 202), (244, 241, 231), (100, 181, 202), (40, 77, 112)]
+ for index, (x, y, z) in enumerate(positions):
+ parts.append(Part(cylinder(2.2, z - 120, (x, y, 120 + (z - 120) / 2), 14), (82, 137, 92), "stem"))
+ petal_count = 6 if index % 3 == 0 else 8 if index % 3 == 1 else 5
+ for petal in range(petal_count):
+ angle = 360 * petal / petal_count
+ petal_mesh = ellipsoid((8, 3, 16), (0, 0, 0), 18, 10)
+ petal_mesh = rotate_x(petal_mesh, 82)
+ petal_mesh = rotate_z(petal_mesh, angle)
+ petal_mesh = translate(petal_mesh, (x + 12 * math.cos(math.radians(angle)), y, z + 12 * math.sin(math.radians(angle))))
+ parts.append(Part(petal_mesh, colors[index], "flower petal"))
+ parts.append(Part(ellipsoid((7, 4, 7), (x, y - 3, z)), (225, 184, 74), "flower center"))
+ if index < 5:
+ leaf = rotate_z(ellipsoid((14, 3, 28), (x + (12 if index % 2 else -12), y, 165 + index * 6), 18, 10), 28 if index % 2 else -28)
+ parts.append(Part(leaf, (104, 163, 112), "leaf"))
+ return parts
+
+
+def camera_basis(azimuth: float, elevation: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ azimuth, elevation = math.radians(azimuth), math.radians(elevation)
+ view = np.array([math.cos(elevation) * math.cos(azimuth), math.cos(elevation) * math.sin(azimuth), math.sin(elevation)])
+ right = np.cross(view, np.array([0.0, 0.0, 1.0])); right /= np.linalg.norm(right)
+ up = np.cross(right, view); up /= np.linalg.norm(up)
+ return right, up, view
+
+
+def shade(color: tuple[int, int, int], amount: float) -> tuple[int, int, int, int]:
+ amount = max(0.42, min(1.18, amount))
+ return tuple(max(0, min(255, round(channel * amount))) for channel in color) + (255,)
+
+
+def render(parts: list[Part], output: Path) -> None:
+ scale = 2
+ image = Image.new("RGB", (1200 * scale, 675 * scale), "#f6f7f1")
+ draw = ImageDraw.Draw(image, "RGBA")
+ horizon, bottom = 248 * scale, 650 * scale
+ vanish = (650 * scale, horizon)
+ for x in range(-300, 1600, 85):
+ draw.line((vanish[0], vanish[1], x * scale, bottom), fill=(107, 125, 116, 22), width=2)
+ for index in range(12):
+ t = index / 11
+ y = horizon + (t ** 1.8) * (bottom - horizon)
+ draw.line((50 * scale, y, 1160 * scale, y), fill=(107, 125, 116, 18 + index * 2), width=2)
+ shadow = Image.new("RGBA", image.size, (0, 0, 0, 0))
+ ImageDraw.Draw(shadow).ellipse((320 * scale, 500 * scale, 900 * scale, 620 * scale), fill=(21, 54, 44, 65))
+ image = Image.alpha_composite(image.convert("RGBA"), shadow.filter(ImageFilter.GaussianBlur(28 * scale)))
+ draw = ImageDraw.Draw(image, "RGBA")
+
+ all_points = np.concatenate([part.triangles.reshape((-1, 3)) for part in parts])
+ center = (all_points.min(axis=0) + all_points.max(axis=0)) / 2
+ prepared = [rotate_z(part.triangles - center, -17) for part in parts]
+ right, up, view = camera_basis(-58, 29)
+ flat_all = []
+ for triangles in prepared:
+ flat_all.append(np.stack((triangles @ right, triangles @ up), axis=-1))
+ flat = np.concatenate([projected.reshape((-1, 2)) for projected in flat_all])
+ span = flat.max(axis=0) - flat.min(axis=0)
+ object_scale = min(820 * scale / span[0], 465 * scale / span[1])
+ records = []
+ light = np.array([-0.35, -0.5, 0.79]); light /= np.linalg.norm(light)
+ for part, triangles, projected in zip(parts, prepared, flat_all):
+ projected = projected * object_scale
+ projected[..., 0] += 600 * scale
+ projected[..., 1] = 390 * scale - projected[..., 1]
+ edges_a, edges_b = triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]
+ normals = np.cross(edges_a, edges_b)
+ lengths = np.linalg.norm(normals, axis=1)
+ normals = normals / np.where(lengths[:, None] == 0, 1, lengths[:, None])
+ intensity = 0.62 + np.maximum(0, normals @ light) * 0.48
+ depth = (triangles @ view).mean(axis=1)
+ for index in range(len(triangles)):
+ records.append((depth[index], projected[index], shade(part.color, float(intensity[index]))))
+ for _, polygon, color in sorted(records, key=lambda record: record[0]):
+ draw.polygon([tuple(point) for point in polygon], fill=color)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ image.convert("RGB").resize((1200, 675), Image.Resampling.LANCZOS).save(output, quality=94)
+
+
+def main() -> None:
+ root = Path(__file__).resolve().parents[1]
+ projects = root / "public" / "projects"
+ scenes = {
+ "tidepool-buddy-product-draft.png": tidepool(),
+ "capiz-cove-product-draft.png": capiz(),
+ "cozy-harvest-product-draft.png": cozy(),
+ "evergarden-bouquet-product-draft.png": evergarden(projects / "porcelain-reed-vase-aug-2026.stl"),
+ }
+ for filename, parts in scenes.items():
+ render(parts, projects / filename)
+ print(f"Rendered {filename}: {sum(len(part.triangles) for part in parts):,} triangles")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/render_stl_cover.py b/scripts/render_stl_cover.py
new file mode 100644
index 0000000..5424e40
--- /dev/null
+++ b/scripts/render_stl_cover.py
@@ -0,0 +1,205 @@
+"""Render an accurate STL project cover with PrintPath's MotionDeck-inspired stage."""
+
+from __future__ import annotations
+
+import argparse
+import math
+import re
+import struct
+from pathlib import Path
+
+import numpy as np
+from PIL import Image, ImageDraw, ImageFilter, ImageFont
+
+
+def load_stl(path: Path) -> np.ndarray:
+ data = path.read_bytes()
+ if len(data) >= 84:
+ triangle_count = struct.unpack(" ImageFont.FreeTypeFont:
+ name = "arialbd.ttf" if bold else "arial.ttf"
+ return ImageFont.truetype(str(Path("C:/Windows/Fonts") / name), size=size)
+
+
+def rotate_z(points: np.ndarray, degrees: float) -> np.ndarray:
+ angle = math.radians(degrees)
+ rotation = np.array([[math.cos(angle), -math.sin(angle), 0], [math.sin(angle), math.cos(angle), 0], [0, 0, 1]])
+ return points @ rotation.T
+
+
+def camera_basis(azimuth: float, elevation: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ azimuth = math.radians(azimuth)
+ elevation = math.radians(elevation)
+ view = np.array([math.cos(elevation) * math.cos(azimuth), math.cos(elevation) * math.sin(azimuth), math.sin(elevation)])
+ right = np.cross(view, np.array([0.0, 0.0, 1.0]))
+ right /= np.linalg.norm(right)
+ up = np.cross(right, view)
+ up /= np.linalg.norm(up)
+ return right, up, view
+
+
+def shade(base: tuple[int, int, int], amount: float) -> tuple[int, int, int, int]:
+ amount = max(0.42, min(1.22, amount))
+ return tuple(max(0, min(255, round(channel * amount))) for channel in base) + (255,)
+
+
+def parse_hex_color(value: str) -> tuple[int, int, int]:
+ value = value.lstrip("#")
+ if len(value) != 6:
+ raise ValueError("Colors must use six-digit hex notation, for example #ecebe5.")
+ return tuple(int(value[index:index + 2], 16) for index in (0, 2, 4))
+
+
+def rounded_label(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], text: str, fill: str, color: str) -> None:
+ draw.rounded_rectangle(box, radius=(box[3] - box[1]) // 2, fill=fill)
+ draw.text((box[0] + 20, box[1] + 11), text, font=font(20, True), fill=color)
+
+
+def render(
+ stl_path: Path,
+ output_path: Path,
+ *,
+ eyebrow: str = "DRAWER GAP TRAY",
+ title: str = "One uninterrupted compartment",
+ subtitle: str = "Designed for the narrow zone beside an existing Gridfinity layout.",
+ status: str = "ACTUAL STL · APPROVED",
+ profile: str = "P1S · ONE PART",
+ dimensions: str = "254.4 × 39.4 × 49.4 mm",
+ footer: str = "SOFT MATTE PREVIEW • AUG 2026",
+ portrait: bool = False,
+ clean: bool = False,
+ base_color: tuple[int, int, int] = (105, 207, 170),
+) -> None:
+ scale_factor = 2
+ width, height = 1200 * scale_factor, 675 * scale_factor
+ image = Image.new("RGB", (width, height), "#f6f7f1")
+ draw = ImageDraw.Draw(image, "RGBA")
+
+ # Perspective-like stage grid inspired by MotionDeck's live preview.
+ horizon = 245 * scale_factor
+ floor_bottom = 650 * scale_factor
+ vanishing = (650 * scale_factor, horizon)
+ for x in range(-300, 1600, 85):
+ draw.line((vanishing[0], vanishing[1], x * scale_factor, floor_bottom), fill=(107, 125, 116, 22), width=2)
+ for index in range(12):
+ t = index / 11
+ y = horizon + (t ** 1.8) * (floor_bottom - horizon)
+ draw.line((50 * scale_factor, y, 1160 * scale_factor, y), fill=(107, 125, 116, 18 + index * 2), width=2)
+
+ shadow = Image.new("RGBA", image.size, (0, 0, 0, 0))
+ shadow_draw = ImageDraw.Draw(shadow)
+ shadow_box = (365, 480, 835, 610) if clean and portrait else (225, 470, 985, 610) if clean else (505, 500, 985, 615) if portrait else (255, 485, 1050, 610)
+ shadow_draw.ellipse(tuple(value * scale_factor for value in shadow_box), fill=(21, 54, 44, 75))
+ shadow = shadow.filter(ImageFilter.GaussianBlur(28 * scale_factor))
+ image = Image.alpha_composite(image.convert("RGBA"), shadow)
+ draw = ImageDraw.Draw(image, "RGBA")
+
+ triangles = load_stl(stl_path)
+ centered = triangles - triangles.reshape((-1, 3)).mean(axis=0)
+ centered = rotate_z(centered, -17)
+ right, up, view = camera_basis(-58, 31)
+ projected_x = centered @ right
+ projected_y = centered @ up
+ depth = centered @ view
+ projected = np.stack((projected_x, projected_y), axis=-1)
+ flat = projected.reshape((-1, 2))
+ span = flat.max(axis=0) - flat.min(axis=0)
+ target_width, target_height = ((560, 470) if clean and portrait else (900, 410) if clean else (470, 390) if portrait else (780, 335))
+ object_scale = min(target_width * scale_factor / span[0], target_height * scale_factor / span[1])
+ projected *= object_scale
+ center_x = 600 if clean else 765 if portrait else 665
+ center_y = 390 if clean else 445 if portrait else 430
+ projected[..., 0] += center_x * scale_factor
+ projected[..., 1] = center_y * scale_factor - projected[..., 1]
+
+ edges_a = centered[:, 1] - centered[:, 0]
+ edges_b = centered[:, 2] - centered[:, 0]
+ normals = np.cross(edges_a, edges_b)
+ normal_lengths = np.linalg.norm(normals, axis=1)
+ normals = normals / np.where(normal_lengths[:, None] == 0, 1, normal_lengths[:, None])
+ light = np.array([-0.35, -0.5, 0.79])
+ light /= np.linalg.norm(light)
+ intensity = 0.62 + np.maximum(0, normals @ light) * 0.48
+ order = np.argsort(depth.mean(axis=1))
+ for index in order:
+ polygon = [tuple(point) for point in projected[index]]
+ draw.polygon(polygon, fill=shade(base_color, float(intensity[index])))
+
+ # A clean outer silhouette keeps the render soft while the shaded faces reveal the cavity.
+ points = sorted({tuple(np.round(point, 3)) for point in projected.reshape((-1, 2))})
+
+ def cross(origin: tuple[float, float], a: tuple[float, float], b: tuple[float, float]) -> float:
+ return (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * (b[0] - origin[0])
+
+ lower: list[tuple[float, float]] = []
+ for point in points:
+ while len(lower) >= 2 and cross(lower[-2], lower[-1], point) <= 0:
+ lower.pop()
+ lower.append(point)
+ upper: list[tuple[float, float]] = []
+ for point in reversed(points):
+ while len(upper) >= 2 and cross(upper[-2], upper[-1], point) <= 0:
+ upper.pop()
+ upper.append(point)
+ hull = lower[:-1] + upper[:-1]
+ outline_color = tuple(round(channel * 0.52) for channel in base_color) + (190,)
+ draw.line(hull + [hull[0]], fill=outline_color, width=2 * scale_factor, joint="curve")
+
+ if not clean:
+ # Labeled covers are retained for sharing; the website uses clean product renders.
+ draw.text((72 * scale_factor, 60 * scale_factor), eyebrow, font=font(22 * scale_factor, True), fill="#2d6e5a")
+ draw.text((72 * scale_factor, 101 * scale_factor), title, font=font(42 * scale_factor, True), fill="#132d25")
+ draw.text((74 * scale_factor, 158 * scale_factor), subtitle, font=font(21 * scale_factor), fill="#65766f")
+ rounded_label(draw, (72 * scale_factor, 202 * scale_factor, 390 * scale_factor, 254 * scale_factor), status, "#ddf4e9", "#236a55")
+ rounded_label(draw, (408 * scale_factor, 202 * scale_factor, 605 * scale_factor, 254 * scale_factor), profile, "#e8ece7", "#52635c")
+ draw.rounded_rectangle((750 * scale_factor, 565 * scale_factor, 1135 * scale_factor, 630 * scale_factor), radius=18 * scale_factor, fill="#ffffffdd", outline="#dfe7e1")
+ draw.text((780 * scale_factor, 580 * scale_factor), dimensions, font=font(24 * scale_factor, True), fill="#18372d")
+ draw.text((72 * scale_factor, 598 * scale_factor), footer, font=font(18 * scale_factor, True), fill="#71827b")
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ image.convert("RGB").resize((1200, 675), Image.Resampling.LANCZOS).save(output_path, quality=94)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("stl", type=Path)
+ parser.add_argument("output", type=Path)
+ parser.add_argument("--eyebrow", default="DRAWER GAP TRAY")
+ parser.add_argument("--title", default="One uninterrupted compartment")
+ parser.add_argument("--subtitle", default="Designed for the narrow zone beside an existing Gridfinity layout.")
+ parser.add_argument("--status", default="ACTUAL STL · APPROVED")
+ parser.add_argument("--profile", default="P1S · ONE PART")
+ parser.add_argument("--dimensions", default="254.4 × 39.4 × 49.4 mm")
+ parser.add_argument("--footer", default="SOFT MATTE PREVIEW • AUG 2026")
+ parser.add_argument("--portrait", action="store_true")
+ parser.add_argument("--clean", action="store_true")
+ parser.add_argument("--base-color", default="#69cfaa")
+ args = parser.parse_args()
+ render(
+ args.stl,
+ args.output,
+ eyebrow=args.eyebrow,
+ title=args.title,
+ subtitle=args.subtitle,
+ status=args.status,
+ profile=args.profile,
+ dimensions=args.dimensions,
+ footer=args.footer,
+ portrait=args.portrait,
+ clean=args.clean,
+ base_color=parse_hex_color(args.base_color),
+ )
diff --git a/src/App.tsx b/src/App.tsx
index 8a85b96..9acdf1f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import {
Box,
Check,
@@ -6,11 +6,17 @@ import {
CircleCheck,
Clock3,
Download,
+ ExternalLink,
FileJson,
Gauge,
+ Grid3X3,
Home,
Layers3,
Lightbulb,
+ Link2,
+ LockKeyhole,
+ LogIn,
+ LogOut,
Menu,
PackageCheck,
PencilRuler,
@@ -18,12 +24,16 @@ import {
Printer,
RotateCcw,
Ruler,
- Save,
Settings,
+ ShieldCheck,
Sparkles,
- WandSparkles,
+ Wifi,
+ WifiOff,
X,
} from "lucide-react";
+import Dashboard, { type AppPage, type StarterTemplate } from "./Dashboard";
+import { createProjectStore, type ProjectStoreSummary } from "./projectStore";
+import roadmapUrl from "../IMPLEMENTATION_ROADMAP.md?url";
type ProjectSpec = {
name: string;
@@ -36,15 +46,50 @@ type ProjectSpec = {
clearance: number;
cornerRadius: number;
printer: string;
+ plate: string;
nozzle: number;
material: string;
layerHeight: number;
strength: string;
+ partCount: number;
+ assemblyMethod: string;
+ designSystem: "custom" | "gridfinity";
+ gridfinityMode?: "full-grid" | "fractional" | "gap-tray";
+ geometryKind: "brief-only" | "open-tray" | "gridfinity-gap-tray";
+ objectIntent: "one-compartment" | "multiple-compartments" | "spacer" | "base" | "holder" | "other";
+ dimensionIntent: "available-envelope" | "finished-outside" | "usable-inside";
+ fitPreference: "loose" | "sliding" | "snug" | "press-fit";
+ gridRelationship: "connects" | "aligned" | "adjacent-only" | "none";
+ onePartRequired: boolean;
+ obstacles: string;
+ floorThickness: number;
+ verticalClearance: number;
+ supportsAllowed: boolean;
+ brimAllowed: boolean;
+ orchestrationPreference: "auto" | "lean" | "reviewed";
+ intentConfirmedAt?: string;
+ completedAt?: string;
+ printTitle?: string;
+ createdAt: string;
};
-type NumberKey = "width" | "depth" | "height" | "wall" | "clearance" | "cornerRadius" | "nozzle" | "layerHeight";
+type NumberKey = "width" | "depth" | "height" | "wall" | "floorThickness" | "clearance" | "verticalClearance" | "cornerRadius" | "nozzle" | "layerHeight" | "partCount";
+
+type AccessMode = "checking" | "public" | "maker";
+
+type SessionResponse = {
+ authenticated?: boolean;
+ displayName?: string;
+ aiConfigured?: boolean;
+ error?: string;
+};
const STORAGE_KEY = "printpath-project-v1";
+const BRIDGE_PAIRING_KEY = "printpath-bridge-pairing-v1";
+const ACTIVE_PROJECT_KEY = "printpath-active-project-v1";
+const BRIDGE_URL = "http://127.0.0.1:32145";
+const SHOWCASED_PROJECT_COUNT = 17;
+const projectStore = createProjectStore();
const starterSpec: ProjectSpec = {
name: "Under-desk headphone hanger",
@@ -56,11 +101,28 @@ const starterSpec: ProjectSpec = {
wall: 3.2,
clearance: 0.35,
cornerRadius: 6,
- printer: "Bambu Lab A1",
+ printer: "Bambu Lab P1S",
+ plate: "Textured PEI Plate",
nozzle: 0.4,
material: "PLA",
layerHeight: 0.2,
strength: "Balanced",
+ partCount: 1,
+ assemblyMethod: "Single print",
+ designSystem: "custom",
+ geometryKind: "brief-only",
+ objectIntent: "holder",
+ dimensionIntent: "available-envelope",
+ fitPreference: "sliding",
+ gridRelationship: "none",
+ onePartRequired: true,
+ obstacles: "",
+ floorThickness: 3.2,
+ verticalClearance: 0.6,
+ supportsAllowed: false,
+ brimAllowed: true,
+ orchestrationPreference: "auto",
+ createdAt: new Date().toISOString(),
};
const freshSpec: ProjectSpec = {
@@ -76,29 +138,184 @@ const steps = [
{ label: "Review", detail: "Check the brief", icon: PackageCheck },
];
-const printerVolumes: Record = {
- "Bambu Lab A1 mini": [180, 180, 180],
- "Bambu Lab A1": [256, 256, 256],
- "Bambu Lab P1S / P1P": [256, 256, 256],
- "Bambu Lab X1C": [256, 256, 256],
- "Other / not sure": [220, 220, 220],
-};
+const P1S_BUILD_VOLUME: [number, number, number] = [256, 256, 256];
+const GAP_TRAY_COMPLETED_AT = "2026-08-13T16:50:21.505Z";
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
+function normalizeKnownCompletedProject(project: ProjectSpec): ProjectSpec {
+ const isApprovedGapTray = project.geometryKind === "gridfinity-gap-tray"
+ && Math.max(project.width, project.depth) === 255
+ && Math.min(project.width, project.depth) === 40
+ && project.height === 50;
+ if (!isApprovedGapTray) return project;
+ return {
+ ...project,
+ name: "Drawer Gap Tray",
+ printTitle: project.printTitle || "Drawer Gap Tray · Aug 2026",
+ intentConfirmedAt: project.intentConfirmedAt || GAP_TRAY_COMPLETED_AT,
+ completedAt: project.completedAt || GAP_TRAY_COMPLETED_AT,
+ };
+}
+
function loadProject(): ProjectSpec {
try {
const stored = localStorage.getItem(STORAGE_KEY);
- return stored ? { ...starterSpec, ...JSON.parse(stored) } : starterSpec;
+ if (!stored) return starterSpec;
+ const parsed = JSON.parse(stored) as Partial> & { geometryKind?: string; gridfinityMode?: string };
+ const isLegacyStrip = parsed.geometryKind === "gridfinity-pitch-strip" || parsed.name === "Non-standard Gridfinity-pitch drawer strip";
+ const migrated = isLegacyStrip ? {
+ ...parsed,
+ name: "One-compartment drawer gap tray",
+ description: `One continuous custom compartment for the measured gap beside the existing Gridfinity layout. It prints diagonally as a single P1S part and is not a standard baseplate-compatible bin.`,
+ partCount: 1,
+ assemblyMethod: "Single print",
+ gridfinityMode: "gap-tray",
+ geometryKind: "gridfinity-gap-tray",
+ } : parsed;
+ const normalized = { ...starterSpec, ...migrated, printer: "Bambu Lab P1S" } as ProjectSpec;
+ if (normalized.geometryKind === "gridfinity-gap-tray") {
+ return normalizeKnownCompletedProject({
+ ...normalized,
+ name: normalized.name === "One-compartment Gridfinity gap tray" ? "One-compartment drawer gap tray" : normalized.name,
+ objectIntent: "one-compartment",
+ dimensionIntent: "available-envelope",
+ fitPreference: "sliding",
+ gridRelationship: "adjacent-only",
+ onePartRequired: true,
+ floorThickness: typeof parsed.floorThickness === "number" ? parsed.floorThickness : normalized.wall,
+ verticalClearance: normalized.verticalClearance ?? 0.6,
+ });
+ }
+ return normalized;
} catch {
return starterSpec;
}
}
+function suggestedPrintTitle(spec: ProjectSpec) {
+ const created = new Date(spec.createdAt || Date.now());
+ const month = created.toLocaleDateString("en-US", { month: "short", year: "numeric" });
+ const base = spec.geometryKind === "gridfinity-gap-tray"
+ ? "Drawer Gap Tray"
+ : spec.geometryKind === "open-tray"
+ ? "Exact-Fit Tray"
+ : spec.name || "PrintPath Design";
+ return `${base} · ${month}`;
+}
+
+function printedDimensions(spec: ProjectSpec) {
+ if (spec.dimensionIntent === "usable-inside") {
+ return {
+ width: spec.width + spec.wall * 2,
+ depth: spec.depth + spec.wall * 2,
+ height: spec.height + spec.floorThickness,
+ };
+ }
+ if (spec.dimensionIntent === "finished-outside") {
+ return { width: spec.width, depth: spec.depth, height: spec.height };
+ }
+ return {
+ width: Math.max(1, spec.width - spec.clearance * 2),
+ depth: Math.max(1, spec.depth - spec.clearance * 2),
+ height: Math.max(1, spec.height - spec.verticalClearance),
+ };
+}
+
+function orchestrationRoute(spec: ProjectSpec) {
+ if (spec.orchestrationPreference === "lean" || (spec.geometryKind !== "brief-only" && spec.partCount === 1 && spec.orchestrationPreference === "auto")) {
+ return { label: "Deterministic fast path", detail: "Known generator · automated checks · no agent fan-out", agents: 0 };
+ }
+ if (spec.partCount > 1 || spec.orchestrationPreference === "reviewed") {
+ return { label: "Plan + independent review", detail: "Geometry planner · printability reviewer", agents: 2 };
+ }
+ return { label: "Guided specification", detail: "One planning pass after required answers", agents: 1 };
+}
+
function PartPreview({ spec }: { spec: ProjectSpec }) {
const [flipped, setFlipped] = useState(false);
+ const displayOuter = printedDimensions(spec);
+
+ if (spec.geometryKind === "gridfinity-gap-tray") {
+ return (
+
+
+
+ Printable part
+ One continuous compartment
+
+
+
+
+
+
+ Printed {Number(Math.max(displayOuter.width, displayOuter.depth).toFixed(1))} × {Number(Math.min(displayOuter.width, displayOuter.depth).toFixed(1))} × {Number(displayOuter.height.toFixed(1))} mm · 45° on one P1S plate
+
+ );
+ }
+
const visualWidth = clamp(170 + (spec.width - 50) * 1.15, 165, 315);
const visualHeight = clamp(94 + (spec.height - 20) * 1.4, 96, 190);
const visualDepth = clamp(56 + (spec.depth - 30) * 0.7, 50, 98);
@@ -212,7 +429,7 @@ function PartPreview({ spec }: { spec: ProjectSpec }) {
{spec.depth} mm
- Concept visualization · printable geometry comes next
+ {spec.geometryKind === "open-tray" ? "Concept preview · exact tray STL available at Review" : "Concept visualization · printable geometry comes next"}
);
@@ -233,26 +450,103 @@ function Metric({ label, value, detail, icon: Icon }: { label: string; value: st
export default function App() {
const [spec, setSpec] = useState(loadProject);
+ const [projectId, setProjectId] = useState(() => localStorage.getItem(ACTIVE_PROJECT_KEY) || crypto.randomUUID());
+ const [storeReady, setStoreReady] = useState(false);
+ const [storeSummary, setStoreSummary] = useState({ projectCount: 0, versionCount: 0, provider: "Local control tower", syncTarget: "Convex-ready" });
+ const [currentPage, setCurrentPage] = useState("overview");
const [activeStep, setActiveStep] = useState(1);
const [mobileNav, setMobileNav] = useState(false);
const [saveState, setSaveState] = useState<"saved" | "saving">("saved");
+ const [pairingCode, setPairingCode] = useState(() => sessionStorage.getItem(BRIDGE_PAIRING_KEY) || localStorage.getItem(BRIDGE_PAIRING_KEY) || "");
+ const [bridgeState, setBridgeState] = useState<{ status: "checking" | "online" | "offline"; paired: boolean; version?: string; bambuStudioDetected?: boolean }>({ status: "checking", paired: false });
+ const [handoffState, setHandoffState] = useState<{ status: "idle" | "sending" | "success" | "error"; message?: string }>({ status: "idle" });
+ const [accessMode, setAccessMode] = useState("checking");
+ const [makerName, setMakerName] = useState("");
+ const [aiConfigured, setAiConfigured] = useState(false);
+ const [loginOpen, setLoginOpen] = useState(false);
+ const [loginUsername, setLoginUsername] = useState("");
+ const [loginPassword, setLoginPassword] = useState("");
+ const [loginError, setLoginError] = useState("");
+ const [loginSubmitting, setLoginSubmitting] = useState(false);
+ const pendingMakerAction = useRef<(() => void) | null>(null);
useEffect(() => {
+ let cancelled = false;
+ void fetch("/api/auth/session", { credentials: "same-origin", cache: "no-store" })
+ .then(async (response) => response.ok ? response.json() as Promise : undefined)
+ .then((session) => {
+ if (cancelled) return;
+ setAccessMode(session?.authenticated ? "maker" : "public");
+ setMakerName(session?.displayName || "");
+ setAiConfigured(Boolean(session?.aiConfigured));
+ })
+ .catch(() => {
+ if (!cancelled) setAccessMode("public");
+ });
+ return () => { cancelled = true; };
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ setStoreReady(false);
+ localStorage.setItem(ACTIVE_PROJECT_KEY, projectId);
+ void projectStore.load(projectId).then(async (project) => {
+ if (cancelled) return;
+ if (project) {
+ const normalizedProject = normalizeKnownCompletedProject(project.draft);
+ setSpec(normalizedProject);
+ if (normalizedProject.completedAt && !project.draft.completedAt) {
+ await projectStore.checkpoint(projectId, normalizedProject.name, normalizedProject, "completed");
+ }
+ } else {
+ await projectStore.checkpoint(projectId, spec.name, spec, spec.completedAt ? "completed" : "created");
+ }
+ if (!cancelled) {
+ setStoreSummary(await projectStore.summary());
+ setStoreReady(true);
+ }
+ }).catch(() => setStoreReady(true));
+ return () => { cancelled = true; };
+ }, [projectId]);
+
+ useEffect(() => {
+ if (!storeReady) return;
setSaveState("saving");
const timeout = window.setTimeout(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(spec));
- setSaveState("saved");
- }, 450);
+ void projectStore.saveDraft(projectId, spec.name, spec).then((summary) => {
+ setStoreSummary(summary);
+ setSaveState("saved");
+ }).catch(() => setSaveState("saved"));
+ }, 650);
return () => window.clearTimeout(timeout);
- }, [spec]);
+ }, [projectId, spec, storeReady]);
+
+ useEffect(() => {
+ void checkBridge(pairingCode);
+ }, []);
const checks = useMemo(() => {
- const volume = printerVolumes[spec.printer] ?? printerVolumes["Other / not sure"];
- return [
+ const isGridfinity = spec.designSystem === "gridfinity";
+ const shortestGridSide = Math.min(spec.width, spec.depth);
+ const longGridSide = Math.max(spec.width, spec.depth);
+ const isGapTray = spec.geometryKind === "gridfinity-gap-tray";
+ const calculatedOuter = printedDimensions(spec);
+ const printedLongSide = Math.max(calculatedOuter.width, calculatedOuter.depth);
+ const printedShortSide = Math.min(calculatedOuter.width, calculatedOuter.depth);
+ const printedHeight = calculatedOuter.height;
+ const diagonalPlateSide = Number(((printedLongSide + printedShortSide) / Math.sqrt(2)).toFixed(1));
+ const printableLongestSide = isGapTray ? diagonalPlateSide : longGridSide;
+ const baseChecks = [
{
label: "Build volume",
- detail: `${volume[0]} × ${volume[1]} × ${volume[2]} mm`,
- ok: spec.width <= volume[0] && spec.depth <= volume[1] && spec.height <= volume[2],
+ detail: isGapTray ? `${diagonalPlateSide} × ${diagonalPlateSide} × ${Number(printedHeight.toFixed(1))} mm at 45° · P1S` : `${P1S_BUILD_VOLUME[0]} × ${P1S_BUILD_VOLUME[1]} × ${P1S_BUILD_VOLUME[2]} mm · P1S`,
+ ok: printableLongestSide <= P1S_BUILD_VOLUME[0] && (isGapTray || shortestGridSide <= P1S_BUILD_VOLUME[1]) && printedHeight <= P1S_BUILD_VOLUME[2],
+ },
+ {
+ label: "Plate margin",
+ detail: isGapTray ? `${diagonalPlateSide} mm diagonal footprint · ${Number(((256 - diagonalPlateSide) / 2).toFixed(1))} mm centered edge margin` : `${printableLongestSide} mm longest printed side · 6 mm breathing room recommended`,
+ ok: printableLongestSide <= 250,
},
{
label: "Wall thickness",
@@ -260,19 +554,70 @@ export default function App() {
ok: spec.wall >= spec.nozzle * 2,
},
{
- label: "Fit allowance",
- detail: `${spec.clearance} mm clearance`,
- ok: spec.clearance >= 0.2,
+ label: isGapTray ? "Drawer fit" : "Fit allowance",
+ detail: isGapTray ? `${Number(printedLongSide.toFixed(1))} × ${Number(printedShortSide.toFixed(1))} × ${Number(printedHeight.toFixed(1))} mm · ${spec.clearance} mm side / ${spec.verticalClearance} mm top allowance` : `${spec.clearance} mm clearance`,
+ ok: spec.dimensionIntent !== "available-envelope" || (spec.clearance >= 0.2 && spec.verticalClearance >= 0.2),
+ },
+ {
+ label: "Plate & nozzle",
+ detail: `${spec.plate} · ${spec.nozzle} mm nozzle`,
+ ok: Boolean(spec.plate) && [0.2, 0.4, 0.6, 0.8].includes(spec.nozzle),
},
];
+ const relevantChecks = spec.geometryKind === "open-tray" ? baseChecks.filter((check) => check.label !== "Fit allowance") : baseChecks;
+ if (spec.partCount > 1) {
+ relevantChecks.push({
+ label: "Assembly plan",
+ detail: `${spec.partCount} parts · ${spec.assemblyMethod}`,
+ ok: spec.assemblyMethod !== "Not sure yet",
+ });
+ }
+ if (isGridfinity && spec.gridfinityMode !== "fractional") {
+ relevantChecks.push({
+ label: isGapTray ? "Gridfinity gap strategy" : "Gridfinity bin footprint",
+ detail: isGapTray ? `One custom compartment beside the grid · not a baseplate part` : shortestGridSide >= 41.5 ? `${shortestGridSide} mm shortest side · a 41.5 mm standard bin fits` : `${shortestGridSide} mm shortest side · needs at least 41.5 mm`,
+ ok: isGapTray || shortestGridSide >= 41.5,
+ });
+ }
+ if (spec.geometryKind !== "brief-only") {
+ relevantChecks.push({
+ label: "Design approval",
+ detail: spec.intentConfirmedAt ? `${spec.printTitle || suggestedPrintTitle(spec)} · intent frozen` : "Confirm the intent statement before handoff",
+ ok: Boolean(spec.intentConfirmedAt),
+ });
+ }
+ return relevantChecks;
}, [spec]);
+ const gridfinityPlan = useMemo(() => {
+ if (spec.designSystem !== "gridfinity" || spec.gridfinityMode === "fractional") return null;
+ const pitch = 42;
+ const standardBinFootprint = 41.5;
+ const columns = Math.floor(spec.width / pitch);
+ const rows = Math.floor(spec.depth / pitch);
+ const widthRemainder = Number((spec.width - columns * pitch).toFixed(1));
+ const depthRemainder = Number((spec.depth - rows * pitch).toFixed(1));
+ const fullGridFits = columns > 0 && rows > 0 && Math.min(spec.width, spec.depth) >= standardBinFootprint;
+ const longAxisCells = spec.width >= spec.depth ? columns : rows;
+ const diagonalPlateSide = Number(((Math.max(spec.width, spec.depth) + Math.min(spec.width, spec.depth) - spec.clearance * 4) / Math.sqrt(2)).toFixed(1));
+ const canGenerateGapTray = Math.min(spec.width, spec.depth) < standardBinFootprint && Math.min(spec.width, spec.depth) >= spec.wall * 2 + 1 && longAxisCells >= 1 && diagonalPlateSide <= 250;
+ return { pitch, standardBinFootprint, columns, rows, widthRemainder, depthRemainder, fullGridFits, diagonalPlateSide, canGenerateGapTray };
+ }, [spec.designSystem, spec.gridfinityMode, spec.width, spec.depth, spec.wall, spec.clearance]);
+
const readyCount = checks.filter((check) => check.ok).length;
+ const preApprovalReady = checks.filter((check) => check.label !== "Design approval").every((check) => check.ok);
+ const hasPrintableGeometry = spec.geometryKind !== "brief-only";
+ const outer = printedDimensions(spec);
+ const route = orchestrationRoute(spec);
const estimatedGrams = Math.max(4, Math.round((spec.width * spec.depth * spec.height * 0.2 * 1.24) / 1000));
const estimatedHours = Math.max(0.4, estimatedGrams / 13).toFixed(1);
function updateField(key: K, value: ProjectSpec[K]) {
- setSpec((current) => ({ ...current, [key]: value }));
+ setSpec((current) => ({
+ ...current,
+ [key]: value,
+ ...(key === "intentConfirmedAt" || key === "printTitle" || key === "completedAt" ? {} : { intentConfirmedAt: undefined, completedAt: undefined }),
+ }));
}
function updateNumber(key: NumberKey, raw: string) {
@@ -280,21 +625,344 @@ export default function App() {
if (Number.isFinite(value)) updateField(key, value);
}
+ function enableGridfinityGapTray() {
+ if (!gridfinityPlan?.canGenerateGapTray) return;
+ setSpec((current) => ({
+ ...current,
+ name: "One-compartment drawer gap tray",
+ description: `One continuous ${Math.max(current.width, current.depth)} × ${Math.min(current.width, current.depth)} × ${current.height} mm compartment for the narrow zone beside the existing Gridfinity layout. It prints diagonally as one P1S part and intentionally does not claim standard baseplate compatibility.`,
+ cornerRadius: 0,
+ partCount: 1,
+ assemblyMethod: "Single print",
+ gridfinityMode: "gap-tray",
+ geometryKind: "gridfinity-gap-tray",
+ objectIntent: "one-compartment",
+ dimensionIntent: "available-envelope",
+ fitPreference: "sliding",
+ gridRelationship: "adjacent-only",
+ onePartRequired: true,
+ floorThickness: current.wall,
+ verticalClearance: 0.6,
+ printTitle: undefined,
+ intentConfirmedAt: undefined,
+ completedAt: undefined,
+ }));
+ setHandoffState({ status: "idle" });
+ }
+
+ function requestMakerAccess(action?: () => void) {
+ pendingMakerAction.current = action || null;
+ setLoginError("");
+ setLoginPassword("");
+ setLoginOpen(true);
+ }
+
+ function requireMaker(action: () => void) {
+ if (accessMode === "maker") {
+ action();
+ return;
+ }
+ requestMakerAccess(action);
+ }
+
+ async function signIn(event: FormEvent) {
+ event.preventDefault();
+ setLoginSubmitting(true);
+ setLoginError("");
+ try {
+ const response = await fetch("/api/auth/login", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: loginUsername, password: loginPassword }),
+ });
+ const result = await response.json() as SessionResponse;
+ if (!response.ok || !result.authenticated) throw new Error(result.error || "Sign-in failed.");
+ setAccessMode("maker");
+ setMakerName(result.displayName || loginUsername);
+ setAiConfigured(Boolean(result.aiConfigured));
+ setLoginPassword("");
+ setLoginOpen(false);
+ const action = pendingMakerAction.current;
+ pendingMakerAction.current = null;
+ action?.();
+ } catch (error) {
+ setLoginError(error instanceof Error ? error.message : "Sign-in failed.");
+ } finally {
+ setLoginSubmitting(false);
+ }
+ }
+
+ async function signOut() {
+ try {
+ await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
+ } finally {
+ setAccessMode("public");
+ setMakerName("");
+ setLoginPassword("");
+ pendingMakerAction.current = null;
+ navigate("overview");
+ }
+ }
+
+ async function askPrintPathAi(idea: string): Promise {
+ const response = await fetch("/api/ai/design", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ idea }),
+ });
+ const result = await response.json() as { brief?: string; error?: string };
+ if (response.status === 401) {
+ setAccessMode("public");
+ requestMakerAccess();
+ }
+ if (!response.ok || !result.brief) throw new Error(result.error || "PrintPath AI could not complete that request.");
+ return result.brief;
+ }
+
function startFresh() {
- setSpec(freshSpec);
+ setStoreReady(false);
+ setProjectId(crypto.randomUUID());
+ setSpec({ ...freshSpec, createdAt: new Date().toISOString() });
+ setActiveStep(0);
+ setCurrentPage("workbench");
+ setMobileNav(false);
+ }
+
+ function startIdea(idea: string) {
+ setStoreReady(false);
+ setProjectId(crypto.randomUUID());
+ setSpec({ ...freshSpec, description: idea, createdAt: new Date().toISOString() });
setActiveStep(0);
+ navigate("workbench");
+ }
+
+ function navigate(page: AppPage) {
+ setCurrentPage(page);
setMobileNav(false);
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }
+
+ function useTemplate(template: StarterTemplate) {
+ const templates: Record, Partial> = {
+ "grid-fit-tile": {
+ name: "Gridfinity fit tile",
+ description: "A single 42 mm confidence tile to verify scale, first-layer grip, and grid feel before printing a larger system.",
+ category: "Container or organizer",
+ width: 42,
+ depth: 42,
+ height: 6,
+ wall: 2,
+ clearance: 0.25,
+ cornerRadius: 4,
+ designSystem: "gridfinity",
+ gridfinityMode: "full-grid",
+ },
+ "loose-tray": {
+ name: "Exact-fit open tray",
+ description: "A simple square-corner open tray sized to the measured space, with a solid base and uniform walls.",
+ category: "Container or organizer",
+ width: 120,
+ depth: 80,
+ height: 28,
+ wall: 3,
+ clearance: 0.3,
+ cornerRadius: 0,
+ geometryKind: "open-tray",
+ },
+ "utensil-tray": {
+ name: "Custom kitchen utensil tray",
+ description: "A measured set of long compartments for everyday utensils, with the number and width of lanes chosen from the actual drawer contents.",
+ category: "Container or organizer",
+ width: 280, depth: 180, height: 45, wall: 2.4, clearance: 0.5, cornerRadius: 7,
+ partCount: 2, assemblyMethod: "Placed side by side",
+ objectIntent: "multiple-compartments",
+ dimensionIntent: "available-envelope",
+ gridRelationship: "none",
+ },
+ "sink-caddy": {
+ name: "Ventilated kitchen sink caddy",
+ description: "A water-safe holder for a sponge, dish brush, and cloth with drainage, airflow, and removable wet-zone pieces for cleaning.",
+ category: "Holder or mount",
+ width: 150, depth: 75, height: 90, wall: 2.6, clearance: 0.7, cornerRadius: 9,
+ material: "PETG", partCount: 2, assemblyMethod: "Slides together",
+ objectIntent: "holder",
+ gridRelationship: "none",
+ },
+ "bag-clip": {
+ name: "Reusable kitchen bag clip set",
+ description: "A small batch of flexible snap clips sized to seal folded snack, coffee, and freezer bags without sharp corners.",
+ category: "Replacement part",
+ width: 85, depth: 14, height: 8, wall: 2.4, clearance: 0.4, cornerRadius: 4,
+ material: "PETG", partCount: 6,
+ objectIntent: "holder",
+ gridRelationship: "none",
+ },
+ "drawer-strip": {
+ name: "Custom drawer base strip",
+ description: "A measured single-row strip used to verify drawer fit before producing a complete multi-plate base.",
+ category: "Container or organizer",
+ width: 210,
+ depth: 42,
+ height: 6,
+ wall: 2,
+ clearance: 0.3,
+ cornerRadius: 4,
+ designSystem: "gridfinity",
+ gridfinityMode: "full-grid",
+ },
+ "grid-customizer": {
+ name: "Custom drawer Gridfinity layout",
+ description: "A measured Gridfinity layout that uses full 42 mm cells, centers the usable grid, and fills the leftover drawer space intentionally.",
+ category: "Container or organizer",
+ width: 210, depth: 168, height: 7, wall: 2.4, clearance: 0.3, cornerRadius: 4,
+ partCount: 2, assemblyMethod: "Placed side by side",
+ designSystem: "gridfinity",
+ gridfinityMode: "full-grid",
+ },
+ "grid-edge-filler": {
+ name: "Gridfinity edge and corner fillers",
+ description: "Measured strips and corner pieces that keep a standard Gridfinity grid centered and secure inside a drawer with awkward leftover space.",
+ category: "Container or organizer",
+ width: 42, depth: 14, height: 7, wall: 2.4, clearance: 0.3, cornerRadius: 3,
+ partCount: 4, assemblyMethod: "Slides together",
+ designSystem: "gridfinity",
+ gridfinityMode: "fractional",
+ },
+ "grid-fractional-bin": {
+ name: "Fractional-width Gridfinity bin",
+ description: "A half-width bin for objects that are smaller than one standard Gridfinity cell, with a separate test fit before the full-height print.",
+ category: "Container or organizer",
+ width: 21, depth: 42, height: 35, wall: 2, clearance: 0.3, cornerRadius: 4,
+ designSystem: "gridfinity",
+ gridfinityMode: "fractional",
+ },
+ "token-tray": {
+ name: "Pourable board game token tray",
+ description: "A rounded token well with a low pouring corner so pieces can move between the tray, table, and game box quickly.",
+ category: "Container or organizer",
+ width: 90, depth: 70, height: 22, wall: 2.4, clearance: 0.35, cornerRadius: 8,
+ },
+ "card-holder": {
+ name: "Sleeved card holder",
+ description: "A slightly leaning card well sized for a sleeved deck, with finger access and enough clearance to remove cards without binding.",
+ category: "Holder or mount",
+ width: 72, depth: 100, height: 45, wall: 2.4, clearance: 0.5, cornerRadius: 5,
+ },
+ "board-game-insert": {
+ name: "Modular board game box organizer",
+ description: "A six-part box insert that separates cards, tokens, boards, and player pieces into labeled modules with a plate-by-plate assembly plan.",
+ category: "Container or organizer",
+ width: 240, depth: 240, height: 55, wall: 2.2, clearance: 0.5, cornerRadius: 5,
+ partCount: 6, assemblyMethod: "Slides together",
+ },
+ "vanity-organizer": {
+ name: "Custom vanity drawer organizer",
+ description: "A measured compartment layout for cosmetics and daily tools that works around drawer rails, pipes, and other obstacles.",
+ category: "Container or organizer",
+ width: 220, depth: 140, height: 38, wall: 2.4, clearance: 0.5, cornerRadius: 7,
+ partCount: 2, assemblyMethod: "Slides together",
+ },
+ "toothbrush-dock": {
+ name: "Toothbrush and razor dock",
+ description: "Ventilated upright storage with removable drip cups sized to the handles you use and designed for easy cleaning.",
+ category: "Holder or mount",
+ width: 105, depth: 70, height: 95, wall: 2.6, clearance: 0.6, cornerRadius: 8,
+ material: "PETG", partCount: 2, assemblyMethod: "Slides together",
+ },
+ "nightstand-dock": {
+ name: "Nightstand charging dock",
+ description: "A phone rest with a routed charging cable, watch landing area, and pocket tray sized to the devices on the nightstand.",
+ category: "Holder or mount",
+ width: 170, depth: 110, height: 35, wall: 2.8, clearance: 0.5, cornerRadius: 9,
+ partCount: 2, assemblyMethod: "Slides together",
+ },
+ "jewelry-tray": {
+ name: "Stackable jewelry tray",
+ description: "A soft-corner compartment tray with room for rings, small pieces, and alignment features for adding another layer later.",
+ category: "Container or organizer",
+ width: 180, depth: 120, height: 25, wall: 2.2, clearance: 0.35, cornerRadius: 8,
+ },
+ "plant-drip-tray": {
+ name: "Exact-fit planter drip tray",
+ description: "A low-profile waterproof saucer matched to the planter base with a raised lip sized for routine runoff.",
+ category: "Container or organizer",
+ width: 145, depth: 145, height: 12, wall: 2.8, clearance: 1, cornerRadius: 14,
+ material: "PETG",
+ },
+ "trellis-clips": {
+ name: "Plant and trellis clips",
+ description: "A small batch of reusable clips tuned to the plant stem and trellis rod diameters without pinching new growth.",
+ category: "Holder or mount",
+ width: 22, depth: 18, height: 10, wall: 2, clearance: 0.5, cornerRadius: 4,
+ material: "PETG", partCount: 8,
+ },
+ "propagation-stand": {
+ name: "Propagation tube stand",
+ description: "A broad, water-safe holder that keeps glass propagation tubes upright and separates them for easy removal.",
+ category: "Holder or mount",
+ width: 160, depth: 80, height: 60, wall: 3, clearance: 0.7, cornerRadius: 10,
+ material: "PETG", partCount: 2, assemblyMethod: "Slides together",
+ },
+ "cable-guide": {
+ name: "Measured cable guide set",
+ description: "Snap-in cable routing matched to the cable thickness and the desk or furniture edge it mounts beneath.",
+ category: "Holder or mount",
+ width: 28, depth: 18, height: 14, wall: 2.4, clearance: 0.4, cornerRadius: 5,
+ partCount: 6,
+ },
+ "wall-hook": {
+ name: "Purpose-fit wall hook",
+ description: "A load-aware hook shaped around the exact object it holds and the fastener or mounting tape used on the wall.",
+ category: "Holder or mount",
+ width: 55, depth: 35, height: 70, wall: 3.2, clearance: 0.4, cornerRadius: 7,
+ strength: "Strong",
+ },
+ };
+ setStoreReady(false);
+ setProjectId(crypto.randomUUID());
+ setSpec(template === "blank"
+ ? { ...freshSpec, createdAt: new Date().toISOString() }
+ : { ...starterSpec, ...templates[template], floorThickness: templates[template].wall ?? starterSpec.floorThickness, createdAt: new Date().toISOString(), intentConfirmedAt: undefined, completedAt: undefined, printTitle: undefined });
+ setActiveStep(template === "blank" ? 0 : 1);
+ navigate("workbench");
+ }
+
+ async function saveCheckpoint(reason: "step-complete" | "approved" | "handoff" | "completed") {
+ const summary = await projectStore.checkpoint(projectId, spec.name, spec, reason);
+ setStoreSummary(summary);
+ }
+
+ function approveDesign() {
+ const approved = {
+ ...spec,
+ printTitle: spec.printTitle || suggestedPrintTitle(spec),
+ intentConfirmedAt: new Date().toISOString(),
+ };
+ setSpec(approved);
+ void projectStore.checkpoint(projectId, approved.name, approved, "approved").then(setStoreSummary);
+ }
+
+ function completeProject() {
+ const completed = { ...spec, completedAt: spec.completedAt || new Date().toISOString() };
+ setSpec(completed);
+ void projectStore.checkpoint(projectId, completed.name, completed, "completed").then(setStoreSummary);
+ navigate("projects");
}
function exportSpec() {
const payload = {
format: "printpath-project",
- version: 1,
+ version: 2,
units: "millimeters",
exportedAt: new Date().toISOString(),
+ projectId,
project: spec,
+ printedOutside: printedDimensions(spec),
+ orchestration: route,
readiness: checks,
- nextStep: "Generate parametric CAD from this reviewed specification.",
+ nextStep: spec.intentConfirmedAt ? "Generate the approved artifact and review its slice in Bambu Studio." : "Confirm the design intent before generating an artifact.",
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
@@ -305,6 +973,60 @@ export default function App() {
URL.revokeObjectURL(url);
}
+ async function checkBridge(token = pairingCode) {
+ setBridgeState((current) => ({ ...current, status: "checking" }));
+ const controller = new AbortController();
+ const timeout = window.setTimeout(() => controller.abort(), 1800);
+ try {
+ const response = await fetch(`${BRIDGE_URL}/health`, {
+ cache: "no-store",
+ headers: token ? { "X-PrintPath-Token": token.trim().toUpperCase() } : undefined,
+ signal: controller.signal,
+ targetAddressSpace: "loopback",
+ } as RequestInit & { targetAddressSpace: "loopback" });
+ if (!response.ok) throw new Error("Bridge did not respond.");
+ const result = await response.json() as { paired?: boolean; version?: string; bambuStudio?: { detected?: boolean } };
+ setBridgeState({ status: "online", paired: Boolean(result.paired), version: result.version, bambuStudioDetected: Boolean(result.bambuStudio?.detected) });
+ if (result.paired && token) {
+ const normalized = token.trim().toUpperCase();
+ setPairingCode(normalized);
+ sessionStorage.setItem(BRIDGE_PAIRING_KEY, normalized);
+ localStorage.removeItem(BRIDGE_PAIRING_KEY);
+ }
+ } catch {
+ setBridgeState({ status: "offline", paired: false });
+ } finally {
+ window.clearTimeout(timeout);
+ }
+ }
+
+ async function handoffToBambu() {
+ if (!bridgeState.paired || !hasPrintableGeometry) return;
+ setHandoffState({ status: "sending" });
+ try {
+ const response = await fetch(`${BRIDGE_URL}/handoff`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-PrintPath-Token": pairingCode,
+ },
+ body: JSON.stringify({ projectId, project: spec, readiness: checks, orchestration: route }),
+ targetAddressSpace: "loopback",
+ } as RequestInit & { targetAddressSpace: "loopback" });
+ const result = await response.json() as { ok?: boolean; fileName?: string; openedWith?: string; files?: Array<{ role?: string }>; error?: string };
+ if (!response.ok || !result.ok) throw new Error(result.error || "The handoff failed.");
+ const launchMessage = result.openedWith === "Bambu Studio"
+ ? `${result.fileName} opened in Bambu Studio.`
+ : `${result.fileName} was saved and sent to Windows. Choose Bambu Studio if Windows asks which app to use.`;
+ const savedParts = result.files && result.files.length > 1 ? " Parts A and B were saved beside it." : "";
+ setHandoffState({ status: "success", message: `${launchMessage}${savedParts} Review the sliced preview before printing.` });
+ void saveCheckpoint("handoff");
+ } catch (error) {
+ setHandoffState({ status: "error", message: error instanceof Error ? error.message : "The handoff failed." });
+ void checkBridge(pairingCode);
+ }
+ }
+
const stepContent = [
@@ -327,12 +1049,33 @@ export default function App() {
+
+
Intent gateAnswer these before dimensions
These choices prevent a correct measurement from becoming the wrong object.
+
+
+
+
+
+
+
+
Work routingHow much reasoning should this design use?
+
+ {([['auto','Auto'],['lean','Lean'],['reviewed','Reviewed']] as const).map(([value, label]) => )}
+
+
{route.label} · {route.detail}
+
,
Step 2 of 4
Let’s capture the fit
-
Start with the outside size. Every change updates the concept preview.
+
First identify what the numbers mean, then enter the measurements.
+
+
+ These dimensions describe
+
+ {([['available-envelope','Maximum available space','PrintPath subtracts fit clearance.'],['finished-outside','Exact finished outside','No dimensional allowance is applied.'],['usable-inside','Required usable inside','Walls and floor are added outside.']] as const).map(([value, title, detail]) => )}
+
{(["width", "depth", "height"] as NumberKey[]).map((key) => (
@@ -342,24 +1085,47 @@ export default function App() {
))}
+ {gridfinityPlan && (
+
+
+
{gridfinityPlan.fullGridFits ? "A standard Gridfinity bin fits." : "A standard 41.5 mm bin footprint does not fit across the short side."}
{spec.geometryKind === "gridfinity-gap-tray" ? "This measured zone stays independent from the grid and becomes one continuous compartment with drawer-fit clearance." : "PrintPath will show the exact grid count, leftover space, and a printable custom-gap option at Review."}
{spec.geometryKind === "gridfinity-gap-tray" ? "GRIDFINITY GAP PLAN" : "42 MM GRID CHECK"}{spec.geometryKind === "gridfinity-gap-tray" ? "One custom zone beside the existing grid" : gridfinityPlan.fullGridFits ? "Standard cells fit this footprint" : "This strip is too narrow for a standard bin"}
+ {spec.geometryKind === "gridfinity-gap-tray" ? <>
+ Measured zone{spec.width} × {spec.depth} × {spec.height} mmdrawer opening
+ Printed outside{Number(outer.width.toFixed(1))} × {Number(outer.depth.toFixed(1))} × {Number(outer.height.toFixed(1))} mm{spec.clearance} mm side · {spec.verticalClearance} mm top allowance
+ Usable interior{Number((outer.width - spec.wall * 2).toFixed(1))} × {Number((outer.depth - spec.wall * 2).toFixed(1))} × {Number((outer.height - spec.floorThickness).toFixed(1))} mmone uninterrupted compartment
+ > : <>
+ Along {spec.width} mm{gridfinityPlan.columns} cells{gridfinityPlan.columns * gridfinityPlan.pitch} mm used
+ Across {spec.depth} mm{gridfinityPlan.rows} cells{gridfinityPlan.rows * gridfinityPlan.pitch} mm used
+ Left over{gridfinityPlan.widthRemainder} × {gridfinityPlan.depthRemainder} mmwidth × depth
+ >}
+
+
+ {gridfinityPlan.fullGridFits
+ ? `Center the full cells and distribute the ${gridfinityPlan.widthRemainder} mm × ${gridfinityPlan.depthRemainder} mm remainder as intentional edge space.`
+ : `${Math.min(spec.width, spec.depth)} mm is ${Number((gridfinityPlan.standardBinFootprint - Math.min(spec.width, spec.depth)).toFixed(1))} mm narrower than a standard 41.5 mm bin footprint. Treat this measured zone as one custom gap-filler compartment beside the existing grid. Rotated 45°, its plate footprint is approximately ${gridfinityPlan.diagonalPlateSide} × ${gridfinityPlan.diagonalPlateSide} mm.`}
+
+ {gridfinityPlan.canGenerateGapTray && spec.geometryKind !== "gridfinity-gap-tray" && (
+ Build one continuous compartmentFits the {Math.max(spec.width, spec.depth)} × {Math.min(spec.width, spec.depth)} × {spec.height} mm zone · one diagonal P1S part
+ )}
+ {spec.geometryKind === "gridfinity-gap-tray" &&
One-compartment printable geometry selected
}
+
+ )}
+
Envelope
{spec.width} × {spec.depth} × {spec.height} mm
-
Construction
{spec.wall} mm walls · {spec.cornerRadius} mm corners
-
Print profile
{spec.printer} · {spec.material} · {spec.layerHeight} mm layers
+ {spec.geometryKind === "gridfinity-gap-tray" &&
Printed outside
{Number(outer.width.toFixed(1))} × {Number(outer.depth.toFixed(1))} × {Number(outer.height.toFixed(1))} mm
}
+
Construction
{spec.wall} mm walls · {spec.floorThickness} mm floor · {spec.cornerRadius} mm corners
PrintPath creates the local file. You still choose the filament, slice it, inspect the preview, and press Print in Bambu Studio.
+ {bridgeState.status === "offline" ? (
+
Install and start the local helper on this computer, then retry.
navigate("process")}>View print process void checkBridge()}>Retry connection
+ ) : !bridgeState.paired ? (
+
+
+ void checkBridge(pairingCode)}>Pair this browser
+
+ ) : (
+
Paired locally. No Bambu account credentials are stored here.
+ )}
+ {bridgeState.status === "online" && (
+
{bridgeState.bambuStudioDetected ? "Bambu Studio detected on this computer." : "Bambu Studio was not detected. Restart the helper after installing it, or choose Bambu Studio if Windows asks."}
{spec.geometryKind === "gridfinity-gap-tray" ? "Creates one STL already rotated 45° for a safe single-part P1S plate layout." : "Creates a square-corner STL from these outer dimensions and wall thickness."}
+
+ void handoffToBambu()}>
+ {handoffState.status === "sending" ? "Creating local model…" : spec.geometryKind === "gridfinity-gap-tray" ? "Create one compartment and open in Bambu Studio" : "Create STL and open in Bambu Studio"}
+
+ {handoffState.message &&
{handoffState.message}
}
+ : (
+
+
+
NEXT STEPThe fit plan is ready; printable geometry is not.
Export the reviewed specification now. PrintPath will not invent a Gridfinity base that does not fit your measured space.
The public site can browse projects and the design library. Signing in unlocks project editing and the rate-limited AI design intake.
+
+ Credentials go only to the Cloudflare Worker and are never stored in browser code.
+
+
+ )}
);
}
diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx
new file mode 100644
index 0000000..8467be3
--- /dev/null
+++ b/src/Dashboard.tsx
@@ -0,0 +1,1159 @@
+import { useMemo, useState, type ReactNode } from "react";
+import {
+ ArrowRight,
+ BadgeCheck,
+ Bath,
+ BedDouble,
+ Box,
+ Cable,
+ Check,
+ ChevronRight,
+ CircleDot,
+ Coins,
+ Component,
+ CreditCard,
+ Database,
+ Download,
+ Droplets,
+ ExternalLink,
+ Flower2,
+ Gamepad2,
+ Gem,
+ Grid3X3,
+ GitBranch,
+ HardDrive,
+ KeyRound,
+ Layers3,
+ LayoutGrid,
+ Link2,
+ LockKeyhole,
+ PackageCheck,
+ PanelsTopLeft,
+ Plus,
+ Printer,
+ Ruler,
+ ShieldCheck,
+ Sparkles,
+ Smartphone,
+ Sprout,
+ WandSparkles,
+ Wifi,
+ WifiOff,
+ Wrench,
+ Workflow,
+} from "lucide-react";
+
+export type AppPage = "overview" | "projects" | "library" | "process" | "workbench" | "settings";
+
+export type StarterTemplate =
+ | "grid-fit-tile" | "loose-tray" | "drawer-strip" | "grid-customizer" | "grid-edge-filler" | "grid-fractional-bin"
+ | "token-tray" | "card-holder" | "board-game-insert"
+ | "vanity-organizer" | "toothbrush-dock"
+ | "nightstand-dock" | "jewelry-tray"
+ | "plant-drip-tray" | "trellis-clips" | "propagation-stand"
+ | "utensil-tray" | "sink-caddy" | "bag-clip"
+ | "cable-guide" | "wall-hook" | "blank";
+
+type DashboardProps = {
+ page: Exclude;
+ activeProjectName: string;
+ nozzle: number;
+ plate: string;
+ onNavigate: (page: AppPage) => void;
+ onOpenProject: () => void;
+ onNewProject: () => void;
+ onStartIdea: (idea: string) => void;
+ onUseTemplate: (template: StarterTemplate) => void;
+ onNozzleChange: (value: number) => void;
+ onPlateChange: (value: string) => void;
+ bridgeStatus: "checking" | "online" | "offline";
+ bridgePaired: boolean;
+ bridgeVersion?: string;
+ bambuStudioDetected?: boolean;
+ pairingCode: string;
+ onPairingCodeChange: (value: string) => void;
+ onCheckBridge: () => void;
+ projectId: string;
+ projectCount: number;
+ versionCount: number;
+ storageProvider: string;
+ syncTarget: string;
+ lastSavedAt?: string;
+ orchestrationLabel: string;
+ orchestrationDetail: string;
+ orchestrationAgents: number;
+ projectApproved: boolean;
+ projectCompleted: boolean;
+ accessMode: "checking" | "public" | "maker";
+ aiConfigured: boolean;
+ onRequestAccess: () => void;
+ onAskAi: (idea: string) => Promise;
+};
+
+const plateOptions = [
+ "Textured PEI Plate",
+ "Smooth PEI / High Temp Plate",
+ "Cool Plate",
+ "Engineering Plate",
+];
+
+type VaseVersionId = "petal-twist" | "leaf-bloom" | "porcelain-reed";
+type PhoneOceanVersionId = "tidepool-buddy" | "capiz-cove" | "cozy-harvest";
+type ConceptProjectId =
+ | "pottery-stamps" | "perfume-susan" | "phone-stands" | "ceramic-risers" | "film-camera-tools"
+ | "token-upgrades" | "magnetic-island" | "steam-controller-dock" | "quiet-utility-kit"
+ | "sink-rack-drain-bridge" | "retainer-drying-dock" | "shower-tool-rail";
+type ProjectSelection = "drawer-gap" | "vase" | "seven-wonders" | "cozy" | "magnetic" | ConceptProjectId;
+
+const SELECTED_VASE_VERSION_KEY = "printpath-selected-vase-version-v2";
+const SELECTED_PHONE_VERSION_KEY = "printpath-selected-phone-ocean-version-v2";
+
+const vaseVersions = [
+ {
+ id: "petal-twist" as const,
+ version: 1,
+ name: "Petal Twist Vase",
+ image: "/projects/petal-twist-vase-aug-2026-product.png",
+ stl: "/projects/petal-twist-vase-aug-2026.stl",
+ guide: "/projects/petal-twist-vase-aug-2026-print-guide.md",
+ dimensions: "122.6 × 122.6 × 245 mm",
+ opening: "≈77 mm opening",
+ description: "The original eight-flute vase with a stable bulb-shaped base and a soft scalloped rim.",
+ historyNote: "Original eight-flute body with a compact scalloped opening and softly rounded base.",
+ facts: [["122.6 × 122.6 × 245 mm", "P1S-safe size"], ["2.4 mm", "Continuous wall"], ["PETG", "One-piece body"]],
+ },
+ {
+ id: "leaf-bloom" as const,
+ version: 2,
+ name: "Leaf Bloom Vase",
+ image: "/projects/leaf-bloom-vase-aug-2026-product.png",
+ stl: "/projects/leaf-bloom-vase-aug-2026.stl",
+ guide: "/projects/leaf-bloom-vase-aug-2026-print-guide.md",
+ dimensions: "122.9 × 122.5 × 245 mm",
+ opening: "≈105 mm opening",
+ description: "Seven leaf-like folds rise into a wider, gently flared opening for fuller greenery.",
+ historyNote: "Seven twisted botanical folds and a wider flared opening for fuller, leaf-heavy arrangements.",
+ facts: [["122.9 × 122.5 × 245 mm", "P1S-safe size"], ["≈105 mm", "Average inner opening"], ["PETG", "One-piece body"]],
+ },
+ {
+ id: "porcelain-reed" as const,
+ version: 3,
+ name: "Porcelain Reed Vase",
+ image: "/projects/porcelain-reed-vase-aug-2026-product.png",
+ stl: "/projects/porcelain-reed-vase-aug-2026.stl",
+ guide: "/projects/porcelain-reed-vase-aug-2026-print-guide.md",
+ dimensions: "116.7 × 116.7 × 245 mm",
+ opening: "≈91 mm opening",
+ description: "A quiet white vessel with 36 fine vertical ribs, a softly planted foot, and a clean round rim for real or printed flowers.",
+ historyNote: "Fine porcelain-like ribs and a restrained taper based on the new white-vase reference, paired with the modular Evergarden bouquet study.",
+ facts: [["116.7 × 116.7 × 245 mm", "P1S-safe size"], ["≈9–11 hr", "Planning estimate"], ["≈275 g PETG", "Geometry-based material"]],
+ },
+];
+
+const phoneOceanVersions = [
+ {
+ id: "tidepool-buddy" as const,
+ version: 3,
+ name: "Tidepool Buddy",
+ image: "/projects/ocean-nature-phone-stand-concept.svg",
+ render: "/projects/tidepool-buddy-product-draft.png",
+ label: "Manta + wave",
+ summary: "A broad manta-like base, curling wave support, and white foam accents in the original ocean palette.",
+ thesis: "Make the ocean shape do the structural work, then let the cute details stay light and replaceable.",
+ description: "A dark-blue manta-like base resists tipping, a light-blue curling wave supports the phone, and white snap-in foam pieces soften the silhouette. A centered cable canyon keeps wired charging open. Each color prints as a separate flat-oriented part, so AMS remains optional.",
+ facts: [["2 plates", "Estimated print plan"], ["≈4–6 hr", "Pre-CAD time"], ["≈90–120 g PLA", "Pre-CAD material"]] as Array<[string, string]>,
+ modules: [["Manta base", "Dark blue · optional hidden washer pockets"], ["Curling wave", "Light blue · structural back support"], ["Foam accents", "White · replaceable crest and front lip"], ["Cable canyon", "Centered path for wired charging"]] as Array<[string, string]>,
+ prompt: "Design version 3 of the saved phone-stand project as an original Tidepool Buddy ocean stand. Use a broad dark-blue manta-like base for stability, a light-blue curling wave as the structural phone support, and separate white snap-in sea-foam accents plus a soft front-lip shell. Preserve a centered charging-cable path, target support-free P1S printing, and keep each color as a separately printable part so AMS is optional. Confirm the exact phone and case envelope, camera bump, portrait-versus-landscape use, preferred viewing angle, and wired-versus-MagSafe charging; print a lip-depth and angle coupon first.",
+ sources: [["Wave geometry", "https://thangs.com/designer/Bamingo%20Design/3d-model/Wave%20Phone%20Stand-1564576"], ["Manta stability", "https://thangs.com/bundle/designer/Decorartor/3d-model/Manta%20Ray%20Ramp%20Phone%20Stand%20with%20Swirl%20Patterns-1528958"]] as Array<[string, string]>,
+ },
+ {
+ id: "capiz-cove" as const,
+ version: 4,
+ name: "Capiz Cove",
+ image: "/projects/capiz-cove-phone-stand-concept.svg",
+ render: "/projects/capiz-cove-product-draft.png",
+ label: "Filipino shell study",
+ summary: "A Filipino-first shell stand with pearlescent capiz-pane rhythm, a scalloped reef base, and tide-blue cradle.",
+ thesis: "Use capiz as a light-and-geometry reference—not a pasted-on symbol.",
+ description: "The phone leans against a structural fan of white, softly translucent shell panes arranged like a compact capiz window. A dark-blue scallop base supplies the load-bearing ribs, while a light-blue tide cradle and cable notch handle the actual phone fit. The shell fan, base, and cradle print separately, keeping the light blue, dark blue, and white palette practical on a single-nozzle P1S.",
+ facts: [["3 plates", "Estimated print plan"], ["≈5–7 hr", "Pre-CAD time"], ["≈105–135 g PLA", "Pre-CAD material"]] as Array<[string, string]>,
+ modules: [["Capiz fan", "White · repeating shell panes with light-catching ribs"], ["Scallop reef", "Dark blue · radial structure and anti-tip footprint"], ["Tide cradle", "Light blue · measured case lip and cable notch"], ["Fit coupon", "Two lip depths plus 55° and 65° viewing tests"]] as Array<[string, string]>,
+ prompt: "Design version 4 of the saved phone-stand project as an original Filipino-first Capiz Cove shell stand. Use a white structural fan of softly translucent capiz-pane-inspired geometry, a dark-blue scallop reef base with radial load-bearing ribs, and a separate light-blue tide cradle with a centered charging notch. Treat capiz as a documented Filipino art and architectural material reference without copying a historical window panel or claiming a traditional symbol. Keep all three colors as separately printable support-free P1S parts. Confirm the exact phone and case envelope, camera bump, portrait-versus-landscape use, preferred viewing angle, and wired-versus-MagSafe charging; print a lip-depth and angle coupon first.",
+ sources: [["National Museum capiz exhibition", "https://www.nationalmuseum.gov.ph/exhibitions/fine-arts/gallery-20/"], ["Capiz architectural precedent", "https://www.nationalmuseum.gov.ph/2023/02/16/built-tradition-of-the-aduana-building-in-cebu-city/"], ["Separate color-insert precedent", "https://makerworld.com/en/models/739533-phone-stand-01-by-teeti3d"]] as Array<[string, string]>,
+ },
+ {
+ id: "cozy-harvest" as const,
+ version: 5,
+ name: "Cozy Harvest Dock",
+ image: "/projects/cozy-harvest-phone-stand-concept.svg",
+ render: "/projects/cozy-harvest-product-draft.png",
+ label: "Pixel farm study",
+ summary: "An original cozy-farming stand with a terraced field base, structural barn roof, stone-bridge lip, and seasonal crop tiles.",
+ thesis: "Let the farm scene carry the phone instead of decorating a generic stand afterward.",
+ description: "A wide terraced field forms the anti-tip base, while a cream barn wall and clay-red roof hide the structural back support. The phone rests on a gray stone-bridge lip with a blue cable channel underneath. Small crop tiles snap into the field so the scene can change by season without reprinting the load-bearing parts. The pixel-block language evokes cozy farming games without reproducing Stardew Valley sprites, logos, or characters.",
+ facts: [["3–4 plates", "Estimated print plan"], ["≈6–8 hr", "Pre-CAD time"], ["≈120–160 g PLA", "Pre-CAD material"]] as Array<[string, string]>,
+ modules: [["Terraced field", "Forest green · wide anti-tip footprint"], ["Barn support", "Cream + clay · concealed structural back"], ["Stone bridge", "Gray lip · centered blue cable route"], ["Crop tiles", "Gold, green, or coral · replaceable pixel inserts"]] as Array<[string, string]>,
+ prompt: "Design version 5 of the saved phone-stand project as an original cozy pixel-farm stand inspired by the mood of Stardew Valley without copying its sprites, logo, characters, or map. Use a wide forest-green terraced field as the anti-tip base, a cream barn wall with a clay-red roof as the structural back support, a gray stone-bridge front lip with a centered blue charging channel, and small replaceable seasonal crop tiles. Keep the load-bearing parts support-free and separately printable on a Bambu Lab P1S so an AMS is optional. Confirm the exact phone and case envelope, camera bump, portrait-versus-landscape use, preferred viewing angle, and wired-versus-MagSafe charging; print a lip-depth and angle coupon first.",
+ sources: [["Mood reference", "https://www.stardewvalley.net/"]] as Array<[string, string]>,
+ },
+];
+
+const conceptProjects: Array<{
+ id: ConceptProjectId;
+ title: string;
+ category: string;
+ status: string;
+ image: string;
+ summary: string;
+ thesis: string;
+ description: string;
+ facts: Array<[string, string]>;
+ modules: Array<[string, string]>;
+ questions: string[];
+ prompt: string;
+ sources?: Array<[string, string]>;
+}> = [
+ {
+ id: "sink-rack-drain-bridge",
+ title: "Sink Rack Drain Bridge",
+ category: "Bathroom",
+ status: "Priority · Fit coupon first",
+ image: "/projects/sink-rack-drain-bridge-concept.svg",
+ summary: "A removable, low-profile drain extension that lets the drying rack sit level beside the sink.",
+ thesis: "Bring the water to the sink—not the rack—and keep the counter setup level.",
+ description: "The recommended direction is a two-piece PETG bridge: a shallow catch adapter clips beneath the rack's existing drain, then a measured slide-out channel reaches past the sink lip with a positive slope. A small outlet-and-slope coupon proves the clip, clearance, and drainage before the full bridge is printed. The channel lifts off for cleaning and never relies on a permanent counter adhesive.",
+ facts: [["2 parts", "Adapter + channel"], ["PETG", "Wet-area starting material"], ["Coupon first", "Clip + slope test"]],
+ modules: [["Catch adapter", "Fits below the rack outlet without lifting the rack"], ["Drain bridge", "Measured reach with ribs that preserve a steady fall"], ["Sink lip", "Wide spill-safe nose projects past the inner sink edge"], ["Clean-out stop", "Slide lock releases without tools for washing"]],
+ questions: ["Rack outlet width, depth, and underside clearance?", "Horizontal gap from outlet to the inside sink edge?", "Vertical drop available across that gap, plus a side photo of the setup?"],
+ prompt: "Design a removable two-piece PETG drain bridge for a bathroom sink drying rack so the rack can remain level. Use a fitted catch adapter beneath the existing rack drain and a slide-out channel that crosses the measured counter gap with positive fall, ends past the inside sink edge, and lifts off for cleaning. Start with a compact outlet-and-slope coupon before the full bridge. Confirm the outlet width and depth, underside clearance, horizontal reach to the inner sink edge, available vertical drop, and attachment preference from a straight side photo before CAD.",
+ sources: [["Removable swivel-spout precedent", "https://www.simplehuman.com/pages/indepth-dishrack"], ["Bambu PETG water resistance", "https://eu.store.bambulab.com/en-mt/products/petg-hf"]],
+ },
+ {
+ id: "retainer-drying-dock",
+ title: "Retainer Drying Dock",
+ category: "Bathroom",
+ status: "Ventilated case outlined",
+ image: "/projects/retainer-drying-dock-concept.svg",
+ summary: "A compact, protected home for a night guard or retainer with real airflow and a washable drip insert.",
+ thesis: "Give the dental appliance a clean visual home without sealing dampness inside it.",
+ description: "A small clamshell-style dock keeps the appliance off the counter, while offset vents allow air to pass without leaving it exposed to splashes. The curved insert lifts out for washing and the lower tray catches drips. The dock is storage only: care and cleaning still follow the appliance maker or dental provider's instructions.",
+ facts: [["3 parts", "Shell / insert / tray"], ["Ventilated", "Air path on both sides"], ["PETG", "Washable prototype"]],
+ modules: [["Vent shell", "Splash-shielded slots and a quiet hinged or lift-off lid"], ["Guard cradle", "Rounded removable insert with no sharp contact points"], ["Drip tray", "Shallow pull-out base for quick rinsing"], ["Name tile", "Optional subtle label for two-person bathrooms"]],
+ questions: ["Night guard, clear retainer, or both—and its outside envelope?", "One appliance or a paired his-and-hers dock?", "Countertop, medicine cabinet, or wall location?"],
+ prompt: "Design a compact ventilated drying dock for a measured night guard or retainer. Use a splash-shielded outer shell, removable rounded cradle, and shallow washable drip tray; keep air paths open and avoid sharp contact points. Treat it as storage only and do not make hygiene or medical claims. Confirm the appliance type and outside dimensions, one-versus-two-person capacity, and countertop, cabinet, or wall location before CAD.",
+ sources: [["AAO retainer storage guidance", "https://aaoinfo.org/resources/faqs/"], ["AAO case-care guidance", "https://aaoinfo.org/whats-trending/taking-care-of-retainers/"]],
+ },
+ {
+ id: "shower-tool-rail",
+ title: "Low-Profile Shower Tool Rail",
+ category: "Bathroom",
+ status: "Three-tool rail outlined",
+ image: "/projects/shower-tool-rail-concept.svg",
+ summary: "A narrow, removable rail for a squeegee, loofah, and scrub brush that drains instead of becoming a shelf.",
+ thesis: "Organize the wet tools already in the shower without adding another bulky caddy.",
+ description: "The rail uses one slim backplate and three lift-off tool clips rather than a basket. Drip noses hold each item away from the tile, open bottoms drain freely, and the mounting plate can be sized for bathroom-rated removable strips or screws. Each clip is independently replaceable when a handle size changes.",
+ facts: [["1 rail", "Three modular clips"], ["No shelf", "Open drainage"], ["PETG", "Wet-area prototype"]],
+ modules: [["Squeegee cradle", "Two-point support that avoids the flexible blade"], ["Loop hook", "Open hook for a loofah or wash cloth"], ["Brush clip", "Measured snap coupon before the full rail"], ["Mounting plate", "Separate adhesive-strip or screw-backed version"]],
+ questions: ["Which three shower tools and their handle or loop dimensions?", "Available tile width and grout-line locations?", "Bathroom-rated adhesive strips, screws, or an over-door mount?"],
+ prompt: "Design a low-profile modular shower-tool rail for a squeegee, loofah or wash cloth, and scrub brush. Use one narrow backplate, open-draining lift-off clips, replaceable tool modules, and no basket or shelf. Confirm each tool's handle or loop dimensions, available tile width and grout lines, and whether the mount uses bathroom-rated removable strips, screws, or an over-door adapter. Print one clip and mounting coupon before the full rail.",
+ sources: [["3M bathroom-strip conditions", "https://www.3m.com/3M/en_US/p/d/b5005604003/"], ["Squeegee storage precedent", "https://www.3m.com/3M/en_US/p/c/cleaning-supplies/equipment/squeegees/b/command/i/consumer/"]],
+ },
+ {
+ id: "pottery-stamps",
+ title: "Jessa + Willy Pottery Stamps",
+ category: "Ceramics",
+ status: "Two marks outlined",
+ image: "/projects/pottery-signature-stamps-concept.svg",
+ summary: "Two related English-name signature stamps with restrained Japanese seal composition.",
+ thesis: "One visual family, two clearly personal maker marks.",
+ description: "Jessa Parayno gets a vertical, botanical seal with a softer frame; Willy Lee gets a compact geometric seal with a calmer monogram. Both use mirrored relief, a depth-stop shoulder, and a broad thumb grip so the mark lands evenly in leather-hard clay.",
+ facts: [["2 stamps", "Separate names"], ["1 plate", "Grip + test tiles"], ["PLA first", "PETG after approval"]],
+ modules: [["Jessa seal", "Full name or JP monogram · botanical border"], ["Willy seal", "Full name or WL monogram · geometric border"], ["Depth-stop grip", "Even pressure without over-driving the mark"], ["Test matrix", "Three relief depths and two mark sizes"]],
+ questions: ["Full names, initials, or one of each?", "Preferred finished mark width: roughly 18, 22, or 26 mm?", "Is the clay usually soft, firm, or leather hard when signed?"],
+ prompt: "Design a paired pottery signature-stamp family for Jessa Parayno and Willy Lee. Use English lettering in a restrained Japanese seal-inspired composition without copying kanji or a traditional crest. Each stamp needs mirrored raised geometry, a broad ergonomic grip, a depth-stop shoulder, and a small test tile with multiple relief depths. Confirm full-name versus initials, preferred finished mark width, and clay firmness before CAD.",
+ sources: [["3D-printed clay stamp practice", "https://ceramicartsnetwork.org/daily/article/Custom-Clay-Stamps-for-the-21st-Century"], ["Reverse-image reminder", "https://ceramicartsnetwork.org/daily/article/Rolling-Rolling-Rolling-Making-Roulettes-to-Add-Texture-and-Depth-to-Your-Pottery"]],
+ },
+ {
+ id: "perfume-susan",
+ title: "Perfume Lazy Susan",
+ category: "Bedroom",
+ status: "Mechanism outlined",
+ image: "/projects/perfume-lazy-susan-concept.svg",
+ summary: "A low, quiet rotating perfume display with a removable tier and bottle-safe rail.",
+ thesis: "Make every bottle visible without turning the organizer into a bulky carousel.",
+ description: "The P1S-friendly direction uses a 220 mm base, a replaceable center bearing module, a shallow outer rail, and an optional half-moon riser for shorter bottles. The rotating mechanism remains isolated from the display tray so the top can be redesigned without rebuilding the bearing fit.",
+ facts: [["≤220 mm", "One-piece top"], ["608 bearing", "8 × 22 × 7 mm"], ["2–4 parts", "Modular assembly"]],
+ modules: [["Display platter", "Non-slip bottle zones and a low spill rail"], ["Half-moon riser", "Elevates shorter bottles without hiding tall ones"], ["Bearing cassette", "Replaceable fit around a common 608 bearing"], ["Weighted base", "Wide footprint with optional steel-washer pockets"]],
+ questions: ["Shelf or dresser maximum width and depth?", "Bottle count plus the widest and tallest bottle?", "Do you prefer one flat level or a removable rear tier?"],
+ prompt: "Design a P1S-printable lazy Susan for perfume bottles with a target maximum platter diameter of 220 mm, a low bottle-safe rail, a removable rear half-moon riser, and a replaceable bearing cassette around a measured 608 bearing. Confirm available footprint, bottle count, widest bottle, tallest bottle, and preferred one-level versus tiered layout before CAD.",
+ sources: [["608 bearing dimensions", "https://cdn.skfmediahub.skf.com/api/public/094abc84edb75bdd/pdf_preview_medium/094abc84edb75bdd_pdf_preview_medium.pdf"]],
+ },
+ {
+ id: "phone-stands",
+ title: "Ocean Nature Phone Stands",
+ category: "Desk & Bedroom",
+ status: "5 directions · 3 featured studies",
+ image: "/projects/cozy-harvest-phone-stand-concept.svg",
+ summary: "Three selectable studies—Tidepool Buddy, Filipino-first Capiz Cove, and Cozy Harvest—plus the preserved forest and fern concepts.",
+ thesis: "Choose the emotional silhouette first; the same measured phone fit can serve every saved shell.",
+ description: "The featured branch now contains two ocean directions and one cozy pixel-farm direction. Tidepool Buddy uses a manta stance and curling wave; Capiz Cove uses pearlescent shell-pane rhythm and a scalloped reef base; Cozy Harvest turns the structural support into a terraced field, barn, and cable bridge. The earlier forest guardian and fern cradle remain preserved rather than being replaced.",
+ facts: [["3 featured versions", "Selectable below"], ["5 directions", "One project history"], ["1 shared coupon", "Case + angle test"]],
+ modules: [["Tidepool Buddy", "Manta stance · curling wave · foam accents"], ["Capiz Cove", "Filipino shell panes · scallop reef · tide cradle"], ["Cozy Harvest", "Pixel field · barn support · cable bridge"], ["Shared fit core", "One phone envelope and charging route"]],
+ questions: ["Exact phone model and case thickness, including any camera bump?", "Portrait and landscape, or portrait only—and preferred viewing angle?", "Wired charging, MagSafe, or both while the phone is docked?"],
+ prompt: phoneOceanVersions[2].prompt,
+ sources: phoneOceanVersions[2].sources,
+ },
+ {
+ id: "ceramic-risers",
+ title: "Ceramic Cabinet Risers",
+ category: "Kitchen",
+ status: "Layout outlined",
+ image: "/projects/ceramic-cabinet-risers-concept.svg",
+ summary: "Modular platforms that separate plates, bowls, and serving ceramics without wasting shelf height.",
+ thesis: "Treat the cabinet as a small vertical room, not a pile of dishes.",
+ description: "The starting system uses two bridge-style risers, a narrow corner step, and optional plate dividers. Open sides keep pieces visible and easy to lift, while ribbed feet spread weight and allow each module to print flat without support.",
+ facts: [["3 modules", "Small / medium / corner"], ["1–2 plates", "Likely print plan"], ["PLA or PETG", "Dry cabinet use"]],
+ modules: [["Low bridge", "Cups or small bowls above plates"], ["Tall bridge", "Serving pieces above everyday stacks"], ["Corner step", "Uses the awkward rear cabinet zone"], ["Plate dividers", "Optional vertical lanes for platters and lids"]],
+ questions: ["Inside shelf width, depth, and clear height?", "Largest plate, bowl, and serving-piece diameters?", "Maximum stack weight and preferred shelf arrangement?"],
+ prompt: "Design a modular ceramic cabinet-riser system for a Bambu Lab P1S: low and tall bridge platforms, a rear corner step, and optional vertical plate dividers. Open sides should preserve visibility and hand access, feet should spread load, and every module should print flat without support. Confirm inside shelf dimensions, ceramic diameters, stack heights, and approximate maximum load before CAD.",
+ },
+ {
+ id: "film-camera-tools",
+ title: "Film Camera Toolkit",
+ category: "Photography",
+ status: "System outlined",
+ image: "/projects/film-camera-tools-concept.svg",
+ summary: "Measured lens caps, camera docks, display stands, and small-parts organizers as one expandable system.",
+ thesis: "Start with harmless fit coupons before anything touches vintage camera threads or finishes.",
+ description: "The first collection separates soft-contact display hardware from precision lens accessories. A snap-cap test ring verifies diameter and flex before a cap is printed; camera docks use felt or TPU contact strips; labeled organizers keep batteries, rolls, caps, and adapters together without forcing one camera system into another.",
+ facts: [["4 tool families", "Caps / docks / stands / trays"], ["Coupon first", "No forced lens fit"], ["Model-specific", "Measure each camera"]],
+ modules: [["Lens-cap coupons", "Diameter, snap flex, and tab clearance"], ["Camera dock", "Soft-contact home for one body and mounted lens"], ["Display stand", "Stable presentation with strap and lens clearance"], ["Parts organizer", "Film rolls, batteries, caps, adapters, and tools"]],
+ questions: ["Exact camera bodies and mounted lens combinations?", "Filter-thread or outside-barrel diameter for each cap?", "Display orientation, strap storage, and shelf footprint?"],
+ prompt: "Outline a modular film-camera tool system with model-specific lens-cap fit coupons, soft-contact camera docks, display stands, and labeled organizers for film rolls, batteries, caps, and adapters. Never force a printed part onto a vintage lens: confirm each camera model, mounted-lens envelope, filter-thread or outside-barrel diameter, strap arrangement, and display footprint before CAD.",
+ sources: [["Nikon cap and filter size examples", "https://www.nikonusa.com/accessories/dslr-lens-accessories"], ["Canon lens compatibility chart", "https://www.canon-europe.com/media/Lens_Accessory_Compatibility_Chart_1H17_tcm13-1169764.pdf"]],
+ },
+ {
+ id: "token-upgrades",
+ title: "Board Game Token Upgrade Studio",
+ category: "Board Games",
+ status: "Kit system outlined",
+ image: "/projects/board-game-token-upgrades-concept.svg",
+ summary: "Original replacement-token families that can be tuned to different games without copying logos or artwork.",
+ thesis: "Upgrade table feel with one reusable design language, then tailor counts and symbols per game.",
+ description: "The studio starts with four tactile token archetypes—resources, currency, status markers, and round or first-player markers. Each game receives its own inventory sheet, original geometric icon set, color plan, and storage footprint. A single sampler plate proves size, edge feel, stackability, and color contrast before a complete token batch is generated.",
+ facts: [["4 archetypes", "Reusable token grammar"], ["1 sampler", "Coupon before batches"], ["Game-specific", "Counts after inventory"]],
+ modules: [["Resource set", "Distinct silhouettes that remain readable without color"], ["Currency + score", "Stackable coins and high-value markers"], ["Status markers", "Condition, damage, charge, or action states"], ["Round marker", "Large table-visible anchor with original iconography"]],
+ questions: ["Which two games should receive the first replacement kits?", "Exact token inventory, maximum footprint, and storage-box limits?", "Single-color, manual color swaps, or AMS-ready face inlays?"],
+ prompt: "Create a board-game token replacement studio with original, logo-free iconography and four reusable token archetypes: resources, currency or score, status markers, and a large round or first-player marker. Start each game with a component inventory and storage-footprint check, then print one sampler plate to test size, edge feel, stackability, and color contrast before generating a full batch. Ask which two games come first, their exact token counts and storage limits, and whether printing is single-color, manual-swap, or AMS-ready.",
+ },
+ {
+ id: "magnetic-island",
+ title: "Magnetic 3D Island Board",
+ category: "Board Games",
+ status: "Five-plate scaffold",
+ image: "/projects/magnetic-3d-island-board-concept.svg",
+ summary: "A terrain-rich 19-hex island compatible with a standard CATAN base set, staged into smaller P1S prints.",
+ thesis: "Build a dramatic 3D board that packs flat, assembles predictably, and never depends on loose exposed magnets.",
+ description: "Nineteen original terrain tiles form the island, with six low shoreline-frame groups and keyed number-token nests. The planning split is three terrain plates, one coast-and-connector plate, and one player-piece test plate. Captured magnets connect the board; steel inserts in roads and buildings can provide gentle placement assistance without adding a second magnet to every small piece.",
+ facts: [["19 hexes", "Official base-set count"], ["5 plates", "Planning estimate"], ["Captured only", "No loose magnets"]],
+ modules: [["Terrain tiles", "Forest, field, pasture, hill, mountain, and desert forms"], ["Coast frame", "Six low-profile groups that square the assembled island"], ["Number nests", "Keyed recesses preserve random setup without magnets"], ["Piece anchors", "Steel-assisted roads, settlements, and cities where useful"]],
+ questions: ["Use your current cardboard hex footprint or a new compact tile size?", "Which expansions, if any, must fit the first version?", "Preferred magnet size and whether children ever access the game?"],
+ prompt: "Design an original magnetic 3D island board compatible with a standard 19-hex CATAN base set without copying published terrain art, logos, or sculpted assets. Stage it for a Bambu Lab P1S as three terrain plates, one coast-and-connector plate, and one player-piece test plate. Use permanently captured board magnets, a polarity jig, keyed number-token nests, and ferrous inserts rather than second magnets in small roads and buildings where practical. Confirm the user's hex footprint, expansion scope, chosen magnet dimensions, and whether children can access the game before CAD.",
+ sources: [["Official base-game component count", "https://www.catan.com/sites/default/files/2021-06/catan_base_rules_2020_200707.pdf"], ["Official 3D-edition structure", "https://www.catan.com/sites/default/files/2024-01/Rules%203D-CATAN.pdf"], ["Magnet safety", "https://www.cpsc.gov/Safety-Education/Safety-Education-Centers/Magnets"]],
+ },
+ {
+ id: "steam-controller-dock",
+ title: "Steam Controller + Puck Dock",
+ category: "Living Room",
+ status: "Official CAD located",
+ image: "/projects/steam-controller-dock-concept.svg",
+ summary: "A controller-first dock with a dedicated Puck bay, cable routing, and desk or wall mounting.",
+ thesis: "Use Valve's released shell geometry so the holder disappears around the hardware instead of guessing the fit.",
+ description: "The first direction is a soft-contact cradle that supports the controller beneath the grips, leaves buttons and status areas clear, and routes the charging lead through the rear. A replaceable base converts between tabletop, wall, and under-shelf use; the Puck gets its own ventilated bay rather than becoming loose desk clutter.",
+ facts: [["Official CAD", "STP + STL reference"], ["3 mounts", "Desk / wall / shelf"], ["2–3 parts", "Replaceable base"]],
+ modules: [["Controller cradle", "Grip support with TPU or felt contact pads"], ["Puck bay", "Ventilated home with cable and antenna keep-outs"], ["Cable spine", "Rear routing with strain-relief space"], ["Mounting base", "Interchangeable tabletop, screw, or adhesive plate"]],
+ questions: ["New 2026 Steam Controller, original 2015 model, or another gamepad?", "Tabletop, wall, under-shelf, or a convertible mount?", "Should the controller charge while stored, and where does the Puck sit?"],
+ prompt: "Design a minimal Steam Controller and Puck dock using Valve's official released CAD and engineering keep-out drawings as the fit authority. The cradle should support the controller beneath the grips with soft contact pads, preserve controls, LEDs, antennas, ventilation, and charging access, and use an interchangeable tabletop, wall, or under-shelf base. Confirm whether this is the new 2026 Steam Controller or the original 2015 model, the mounting location, charging behavior, and Puck placement before CAD.",
+ sources: [["Valve controller + Puck CAD release", "https://steamcommunity.com/groups/steam_hardware/announcements/detail/702141174212723353"], ["Official CAD repository", "https://gitlab.steamos.cloud/SteamHardware/SteamController"]],
+ },
+ {
+ id: "quiet-utility-kit",
+ title: "Apartment Quiet Utility Kit",
+ category: "Apartment",
+ status: "Four low-clutter ideas",
+ image: "/projects/apartment-quiet-utility-concept.svg",
+ summary: "A coordinated set of small organizers that use dead space and visually recede into the apartment.",
+ thesis: "Every part should remove visible clutter, occupy no new floor area, and earn its place in one daily motion.",
+ description: "The starter kit combines a shallow entry ledge, a couch-side remote rail, an under-shelf charging bridge, and a narrow cleaning-tool clip rail. Shared edge radii, hidden fasteners, and two neutral colorways keep the pieces feeling architectural rather than gadget-like. Each module remains optional and is sized only after photographing the actual location.",
+ facts: [["4 modules", "One visual family"], ["0 floor area", "Uses walls + shelves"], ["Reversible", "Low-damage mounting"]],
+ modules: [["Entry ledge", "Keys, wallet, and one mail slot without a catch-all bowl"], ["Remote rail", "Slim couch-side home for remotes and one controller"], ["Charging bridge", "Under-shelf phone shelf with hidden cable slack"], ["Tool clip rail", "Broom, duster, or vacuum tools in a narrow service zone"]],
+ questions: ["Which three items are most often left without a home?", "Where are screw holes acceptable versus removable adhesive only?", "Wall, cabinet, and furniture colors to match or intentionally contrast?"],
+ prompt: "Design a coordinated apartment quiet-utility kit that removes visible clutter without taking floor space: a shallow entry ledge, couch-side remote rail, under-shelf charging bridge, and narrow cleaning-tool clip rail. Use shared edge radii, hidden fasteners, reversible mounting options, and neutral finishes. Before CAD, ask which items lack a home, photograph and measure each exact location, identify screw-safe versus adhesive-only surfaces, and choose match-versus-contrast colors.",
+ },
+];
+
+function PageHeader({ eyebrow, title, description, action }: { eyebrow: string; title: string; description: string; action?: ReactNode }) {
+ return (
+
+