rspace-online/lib/folk-blender.ts

445 lines
9.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 380px;
min-height: 480px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #ea580c, #f59e0b);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
.prompt-area {
padding: 12px;
border-bottom: 1px solid #e2e8f0;
}
.prompt-input {
width: 100%;
padding: 10px 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
}
.prompt-input:focus {
border-color: #ea580c;
}
.controls {
display: flex;
gap: 8px;
margin-top: 8px;
}
.generate-btn {
flex: 1;
padding: 8px 16px;
background: linear-gradient(135deg, #ea580c, #f59e0b);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.generate-btn:hover {
opacity: 0.9;
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tabs {
display: flex;
border-bottom: 1px solid #e2e8f0;
}
.tab {
flex: 1;
padding: 8px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: #64748b;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: #ea580c;
}
.tab.active {
color: #ea580c;
border-bottom-color: #ea580c;
}
.preview-area {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.render-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
overflow: hidden;
}
.render-preview img {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.code-area {
flex: 1;
overflow: auto;
padding: 12px;
}
.code-area pre {
margin: 0;
padding: 12px;
background: #1e293b;
color: #e2e8f0;
border-radius: 6px;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.download-row {
display: flex;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
}
.download-btn {
padding: 6px 12px;
background: #334155;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.download-btn:hover {
background: #475569;
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
text-align: center;
gap: 8px;
}
.placeholder-icon {
font-size: 48px;
opacity: 0.5;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #ea580c;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
margin: 12px;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-blender": FolkBlender;
}
}
export class FolkBlender extends FolkShape {
static override tagName = "folk-blender";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#isLoading = false;
#error: string | null = null;
#renderUrl: string | null = null;
#script: string | null = null;
#blendUrl: string | null = null;
#activeTab: "preview" | "code" = "preview";
#promptInput: HTMLTextAreaElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#previewArea: HTMLElement | null = null;
#codeArea: HTMLElement | null = null;
#downloadRow: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🧊</span>
<span>3D Blender</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Describe the 3D scene to generate...\ne.g. 'A low-poly fox sitting on a tree stump'" rows="3"></textarea>
<div class="controls">
<button class="generate-btn">🧊 Generate 3D</button>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="preview">🖼️ Render</button>
<button class="tab" data-tab="code">📜 Script</button>
</div>
<div class="preview-area">
<div class="render-preview">
<div class="placeholder">
<span class="placeholder-icon">🧊</span>
<span>Describe a 3D scene and click Generate</span>
</div>
</div>
<div class="code-area" style="display:none">
<pre>// Blender Python script will appear here</pre>
</div>
</div>
<div class="download-row" style="display:none">
<a class="download-btn blend-dl" href="#" download>⬇️ .blend</a>
</div>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#generateBtn = wrapper.querySelector(".generate-btn");
this.#previewArea = wrapper.querySelector(".render-preview");
this.#codeArea = wrapper.querySelector(".code-area");
this.#downloadRow = wrapper.querySelector(".download-row");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Tab switching
wrapper.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", (e) => {
e.stopPropagation();
const tabName = (tab as HTMLElement).dataset.tab as "preview" | "code";
this.#activeTab = tabName;
wrapper.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
if (this.#previewArea) this.#previewArea.style.display = tabName === "preview" ? "flex" : "none";
if (this.#codeArea) this.#codeArea.style.display = tabName === "code" ? "block" : "none";
});
});
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#generate();
});
this.#promptInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.#generate();
}
});
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
async #generate() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || this.#isLoading) return;
this.#isLoading = true;
this.#error = null;
if (this.#generateBtn) this.#generateBtn.disabled = true;
if (this.#previewArea) {
this.#previewArea.innerHTML = '<div class="loading"><div class="spinner"></div><span>Generating 3D scene...</span></div>';
}
try {
const res = await fetch("/api/blender-gen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || "Generation failed");
}
const data = await res.json();
this.#renderUrl = data.render_url || null;
this.#script = data.script || null;
this.#blendUrl = data.blend_url || null;
this.#renderResult();
} catch (err) {
this.#error = err instanceof Error ? err.message : "Generation failed";
if (this.#previewArea) {
this.#previewArea.innerHTML = `<div class="error">${this.#escapeHtml(this.#error)}</div>`;
}
} finally {
this.#isLoading = false;
if (this.#generateBtn) this.#generateBtn.disabled = false;
}
}
#renderResult() {
if (this.#previewArea) {
if (this.#renderUrl) {
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#renderUrl)}" alt="3D Render" />`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">✅</span><span>Script generated (see Script tab)</span></div>';
}
}
if (this.#codeArea && this.#script) {
this.#codeArea.innerHTML = `<pre>${this.#escapeHtml(this.#script)}</pre>`;
}
if (this.#downloadRow && this.#blendUrl) {
this.#downloadRow.style.display = "flex";
const blendLink = this.#downloadRow.querySelector(".blend-dl") as HTMLAnchorElement;
if (blendLink) blendLink.href = this.#blendUrl;
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-blender",
renderUrl: this.#renderUrl,
script: this.#script,
blendUrl: this.#blendUrl,
};
}
}