rspace-online/lib/folk-freecad.ts

379 lines
8.3 KiB
TypeScript
Raw 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: 360px;
min-height: 440px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #0891b2, #06b6d4);
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: #0891b2;
}
.controls {
display: flex;
gap: 8px;
margin-top: 8px;
}
.generate-btn {
flex: 1;
padding: 8px 16px;
background: linear-gradient(135deg, #0891b2, #06b6d4);
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;
}
.preview-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
overflow: hidden;
}
.preview-area img {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.export-row {
display: flex;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
}
.export-btn {
padding: 6px 12px;
background: #334155;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.export-btn:hover {
background: #475569;
}
.export-btn.primary {
background: #0891b2;
}
.export-btn.primary:hover {
background: #0e7490;
}
.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: #0891b2;
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-freecad": FolkFreeCAD;
}
}
export class FolkFreeCAD extends FolkShape {
static override tagName = "folk-freecad";
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;
#previewUrl: string | null = null;
#stepUrl: string | null = null;
#stlUrl: string | null = null;
#promptInput: HTMLTextAreaElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#previewArea: HTMLElement | null = null;
#exportRow: 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>FreeCAD</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 parametric part to generate...\ne.g. 'Enclosure box 80x50x30mm with 3mm walls, ventilation slots, and M3 screw bosses'" rows="3"></textarea>
<div class="controls">
<button class="generate-btn">📐 Generate CAD</button>
</div>
</div>
<div class="preview-area">
<div class="placeholder">
<span class="placeholder-icon">📐</span>
<span>Describe a part and click Generate</span>
</div>
</div>
<div class="export-row" style="display:none">
<a class="export-btn primary step-dl" href="#" download>⬇️ STEP</a>
<a class="export-btn stl-dl" href="#" download>⬇️ STL</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(".preview-area");
this.#exportRow = wrapper.querySelector(".export-row");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
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 CAD model...</span></div>';
}
try {
const res = await fetch("/api/freecad/generate", {
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.#previewUrl = data.preview_url || null;
this.#stepUrl = data.step_url || null;
this.#stlUrl = data.stl_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.#previewUrl) {
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#previewUrl)}" alt="CAD Preview" />`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">\u2705</span><span>Model generated! Download files below.</span></div>';
}
}
if (this.#exportRow && (this.#stepUrl || this.#stlUrl)) {
this.#exportRow.style.display = "flex";
const stepLink = this.#exportRow.querySelector(".step-dl") as HTMLAnchorElement;
const stlLink = this.#exportRow.querySelector(".stl-dl") as HTMLAnchorElement;
if (stepLink && this.#stepUrl) {
stepLink.href = this.#stepUrl;
stepLink.style.display = "";
}
if (stlLink && this.#stlUrl) {
stlLink.href = this.#stlUrl;
stlLink.style.display = "";
}
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-freecad",
previewUrl: this.#previewUrl,
stepUrl: this.#stepUrl,
stlUrl: this.#stlUrl,
};
}
}