Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_GOOGLE_CLIENT_ID=432776535595-d2n7cj7jl8qbrojdpnr00eup6t8k36od.apps.googleusercontent.com
25 changes: 25 additions & 0 deletions frontend/object-box.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,31 @@

<div class="hint" id="hint">pick an object →</div>

<form class="generate" id="generate-form">
<button id="generate-mic" type="button" class="generate-mic" aria-label="Speak object name">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="3" width="6" height="12" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0" />
<path d="M12 18v3" />
</svg>
</button>
<input
id="generate-input"
type="text"
placeholder="Say or type an object (e.g. alligator)"
autocomplete="off"
/>
<button id="generate-btn" type="submit">Generate 3D</button>
</form>
<p class="generate-msg" id="generate-msg" role="status"></p>
<details class="tripo-debug" id="tripo-debug">
<summary>
Tripo response
<button type="button" id="tripo-debug-copy" class="tripo-debug-copy">copy</button>
</summary>
<pre id="tripo-debug-body">(no request yet)</pre>
</details>

<div class="corner" aria-hidden="true">
<svg viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round">
<rect x="3.5" y="3.5" width="15" height="15" rx="3" />
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/object-box/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface ObjectDef {
/** Optional builder name; defaults to `label`. Lets aliases like "kitten"
* reuse the cat builder. */
builder?: string;
/** Optional remote model url (GLB/GLTF) generated by Tripo. */
modelUrl?: string;
}

export interface LetterDef {
Expand Down
268 changes: 267 additions & 1 deletion frontend/src/object-box/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,55 @@ const hintEl = $("hint");
const greetingName = $("greeting-name");
const paperWord = $("paper-word");
const booting = $("booting");
const generateForm = $("generate-form");
const generateInput = $<HTMLInputElement>("generate-input");
const generateBtn = $<HTMLButtonElement>("generate-btn");
const generateMic = $<HTMLButtonElement>("generate-mic");
const generateMsg = $("generate-msg");
/** Toggle to true while debugging Tripo responses on-screen. */
const SHOW_TRIPO_DEBUG = false;

const tripoDebug = $<HTMLDetailsElement>("tripo-debug");
const tripoDebugBody = $<HTMLPreElement>("tripo-debug-body");
const tripoDebugCopy = $<HTMLButtonElement>("tripo-debug-copy");

if (!SHOW_TRIPO_DEBUG) {
tripoDebug.hidden = true;
}

function logTripo(label: string, payload: unknown): void {
console.log(`[tripo] ${label}`, payload);
if (!SHOW_TRIPO_DEBUG) return;
const pretty =
typeof payload === "string"
? payload
: JSON.stringify(payload, null, 2);
tripoDebugBody.textContent = `--- ${label} @ ${new Date().toISOString()} ---\n${pretty}`;
tripoDebug.open = true;
}

tripoDebugCopy.addEventListener("click", async (ev) => {
// Prevent toggling the <details> when clicking the button inside the <summary>
ev.preventDefault();
ev.stopPropagation();
const text = tripoDebugBody.textContent ?? "";
try {
await navigator.clipboard.writeText(text);
tripoDebugCopy.textContent = "copied";
tripoDebugCopy.classList.add("is-copied");
setTimeout(() => {
tripoDebugCopy.textContent = "copy";
tripoDebugCopy.classList.remove("is-copied");
}, 1400);
} catch {
// Clipboard API blocked — fall back to manual selection.
const range = document.createRange();
range.selectNodeContents(tripoDebugBody);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
});

function renderPrompt(prompt: LessonPrompt | null): void {
if (!prompt) {
Expand Down Expand Up @@ -102,7 +151,224 @@ function applyLevel(level: Level): void {
void prefetchPronunciations(level.objects.map((o) => o.label));
}

applyLevel(applyUrlOverrides(LEVEL_FOX));
let currentLevel = applyUrlOverrides(LEVEL_FOX);
applyLevel(currentLevel);

interface GenerateResp {
ok?: boolean;
label?: string;
taskId?: string | null;
modelUrl?: string | null;
degraded?: boolean;
warning?: string;
error?: string;
}

interface TaskStatusResp {
ok?: boolean;
taskId?: string;
status?: string;
progress?: number | null;
modelUrl?: string | null;
done?: boolean;
failed?: boolean;
error?: string;
}

function mergedLetterPool(level: Level, newWord: string): string | undefined {
if (typeof level.letters !== "string") return undefined;
const set = new Set<string>();
for (const c of level.letters.toLowerCase()) {
if (/[a-z]/.test(c)) set.add(c);
}
for (const c of newWord.toLowerCase()) {
if (/[a-z]/.test(c)) set.add(c);
}
return [...set].sort().join("");
}

function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

const POLL_INTERVAL_MS = 3000;
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

async function pollTaskUntilDone(taskId: string): Promise<TaskStatusResp> {
const start = Date.now();
let lastStatus: TaskStatusResp | null = null;
while (Date.now() - start < POLL_TIMEOUT_MS) {
await sleep(POLL_INTERVAL_MS);
const r = await fetch(`/api/object-box/task?id=${encodeURIComponent(taskId)}`, {
credentials: "include",
});
const status = (await r.json()) as TaskStatusResp;
lastStatus = status;
logTripo(
`task ${status.status ?? "?"} ${status.progress != null ? `${status.progress}%` : ""}`,
status,
);

if (status.progress != null) {
generateMsg.textContent = `Generating 3D object… ${status.progress}%`;
}

if (status.done && status.modelUrl) return status;
if (status.failed) return status;
}
return lastStatus ?? { ok: false, status: "timeout" };
}

async function generateObjectFromDescription(description: string): Promise<void> {
generateBtn.disabled = true;
generateMsg.textContent = "Submitting to Tripo…";
logTripo("request", { url: "/api/object-box/generate", description });

try {
const r = await fetch("/api/object-box/generate", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ description }),
});

const rawText = await r.text();
let body: GenerateResp | null = null;
try {
body = JSON.parse(rawText) as GenerateResp;
} catch {
body = null;
}
logTripo(`generate response (HTTP ${r.status})`, body ?? rawText);

if (!r.ok || !body || !body.label) {
const errMsg = body?.error ?? rawText.slice(0, 200) ?? `HTTP ${r.status}`;
throw new Error(errMsg);
}

const label = body.label;

// Case 1: degraded create — no taskId, fall back to placeholder.
let modelUrl: string | null | undefined = body.modelUrl;
if (!body.taskId) {
modelUrl = null;
} else {
// Case 2: poll the task until done or failed.
generateMsg.textContent = "Generating 3D object…";
const final = await pollTaskUntilDone(body.taskId);
if (final.done && final.modelUrl) {
modelUrl = final.modelUrl;
} else {
modelUrl = null;
}
}

const nextLetters = mergedLetterPool(currentLevel, label);
currentLevel = {
...currentLevel,
letters: nextLetters ?? currentLevel.letters,
objects: [{ label, modelUrl: modelUrl ?? undefined }, ...currentLevel.objects].slice(0, 8),
};
applyLevel(currentLevel);
lesson.reset();
renderPrompt(null);
hintEl.classList.remove("hidden");
generateMsg.textContent = modelUrl
? `Added "${label}" to the object box.`
: `Added "${label}" (placeholder). Generation didn't complete.`;
generateInput.value = "";
} catch (err) {
const msg = (err as Error).message;
generateMsg.textContent = `Could not generate object: ${msg}`;
logTripo("error", msg);
} finally {
generateBtn.disabled = false;
}
}

generateForm.addEventListener("submit", (ev) => {
ev.preventDefault();
const description = generateInput.value.trim();
if (!description) return;
void generateObjectFromDescription(description);
});

// ── Voice input via Web Speech API ────────────────────────────────────────
interface SpeechRecognitionEventLike {
results: ArrayLike<ArrayLike<{ transcript: string }>>;
}
interface SpeechRecognitionLike {
lang: string;
interimResults: boolean;
maxAlternatives: number;
continuous: boolean;
start(): void;
stop(): void;
onresult: ((ev: SpeechRecognitionEventLike) => void) | null;
onerror: ((ev: { error?: string }) => void) | null;
onend: (() => void) | null;
}

const SpeechRecognitionCtor: (new () => SpeechRecognitionLike) | undefined =
(window as unknown as { SpeechRecognition?: new () => SpeechRecognitionLike }).SpeechRecognition ??
(window as unknown as { webkitSpeechRecognition?: new () => SpeechRecognitionLike }).webkitSpeechRecognition;

if (!SpeechRecognitionCtor) {
generateMic.disabled = true;
generateMic.title = "Voice input not supported in this browser";
} else {
let recognizer: SpeechRecognitionLike | null = null;
let listening = false;

function stopListening(): void {
listening = false;
generateMic.classList.remove("is-listening");
try {
recognizer?.stop();
} catch {
// already stopped
}
}

generateMic.addEventListener("click", () => {
if (listening) {
stopListening();
return;
}

recognizer = new SpeechRecognitionCtor!();
recognizer.lang = "en-US";
recognizer.interimResults = false;
recognizer.maxAlternatives = 1;
recognizer.continuous = false;

recognizer.onresult = (ev) => {
const first = ev.results[0]?.[0]?.transcript ?? "";
const cleaned = first.trim().replace(/[.,!?]+$/g, "");
if (!cleaned) return;
generateInput.value = cleaned;
generateMsg.textContent = `Heard: "${cleaned}". Sending to Tripo…`;
void generateObjectFromDescription(cleaned);
};
recognizer.onerror = (ev) => {
generateMsg.textContent = `Voice input error: ${ev.error ?? "unknown"}`;
stopListening();
};
recognizer.onend = () => {
stopListening();
};

try {
recognizer.start();
listening = true;
generateMic.classList.add("is-listening");
generateMsg.textContent = "Listening… say an object name.";
} catch (err) {
generateMsg.textContent = `Could not start mic: ${(err as Error).message}`;
stopListening();
}
});
}

/** Cross-page hand-off to the writing pad. We slide our cream paper overlay
* in first so the screen is fully cream at the moment of navigation, then
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/object-box/scene.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PALETTE } from "./palette";
import { buildRug, RUG_DIMS } from "./rug";
import { buildTray } from "./box";
Expand Down Expand Up @@ -96,6 +97,18 @@ export interface SceneAPI {

const PITCH_MIN = 0.42;
const PITCH_MAX = 1.28;
const gltfLoader = new GLTFLoader();

function loadGltf(url: string): Promise<THREE.Group> {
return new Promise((resolve, reject) => {
gltfLoader.load(
url,
(gltf) => resolve(gltf.scene),
undefined,
(err) => reject(err),
);
});
}

export function createScene(canvas: HTMLCanvasElement, callbacks: SceneCallbacks = {}): SceneAPI {
// -------- renderer / scene / camera --------
Expand Down Expand Up @@ -332,6 +345,45 @@ export function createScene(canvas: HTMLCanvasElement, callbacks: SceneCallbacks
const idx = objectArea.cells.length;
objectArea.cells.push({ pos: homePos.clone(), occupiedBy: g });
g.userData.cellIndex = idx;

if (def.modelUrl) {
void (async () => {
try {
const model = await loadGltf(def.modelUrl!);
// If level switched while loading, discard.
if (!levelGroup.children.includes(g)) return;

for (const child of [...g.children]) g.remove(child);

model.traverse((node) => {
if ((node as THREE.Mesh).isMesh) {
const mesh = node as THREE.Mesh;
mesh.castShadow = true;
mesh.receiveShadow = true;
}
});

const box = new THREE.Box3().setFromObject(model);
const size = new THREE.Vector3();
box.getSize(size);
const maxAxis = Math.max(size.x, size.y, size.z) || 1;
const target = 0.85;
const scale = target / maxAxis;
model.scale.setScalar(scale);

const recenteredBox = new THREE.Box3().setFromObject(model);
const center = recenteredBox.getCenter(new THREE.Vector3());
model.position.sub(center);
// Lift so it sits nicely on the rug.
const floorY = recenteredBox.min.y - center.y;
model.position.y -= floorY - 0.03;

g.add(model);
} catch {
// Keep placeholder if model fails.
}
})();
}
}
}

Expand Down
Loading