fix(blender,freecad): contain render in element, fix FreeCAD file serving
Blender: add wrapper with height:100%, min-height:0 for flex shrink, object-fit:contain on img — render stays within shape bounds. FreeCAD: update assembleFreecadResult to scan all tool results for file paths (.step, .stl, .png), not just execute_python_script JSON parsing. Add preview PNG rendering instruction to system prompt. Add subdirectory file serving routes for /data/files/generated/:subdir/:filename. Add STEP/STL/SVG/PDF mime types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ea537525a
commit
92629e239e
|
|
@ -49,10 +49,18 @@ const styles = css`
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100% - 36px);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +144,7 @@ const styles = css`
|
||||||
|
|
||||||
.preview-area {
|
.preview-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -148,11 +157,13 @@ const styles = css`
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.render-preview img {
|
.render-preview img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
@ -284,6 +295,7 @@ export class FolkBlender extends FolkShape {
|
||||||
const root = super.createRenderRoot();
|
const root = super.createRenderRoot();
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "wrapper";
|
||||||
wrapper.innerHTML = html`
|
wrapper.innerHTML = html`
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="header-title">
|
<span class="header-title">
|
||||||
|
|
|
||||||
|
|
@ -232,37 +232,35 @@ export function assembleFreecadResult(orch: OrchestrationResult): FreecadResult
|
||||||
let stepUrl: string | null = null;
|
let stepUrl: string | null = null;
|
||||||
let stlUrl: string | null = null;
|
let stlUrl: string | null = null;
|
||||||
|
|
||||||
|
// Scan ALL tool results for file paths (not just execute_python_script)
|
||||||
for (const entry of orch.toolCallLog) {
|
for (const entry of orch.toolCallLog) {
|
||||||
try {
|
const text = entry.result;
|
||||||
const parsed = JSON.parse(entry.result);
|
|
||||||
// FreeCAD exports via execute_python_script — look for file paths in results
|
// Extract preview image path
|
||||||
if (entry.tool === "execute_python_script" || entry.tool === "execute_script") {
|
if (!previewUrl) {
|
||||||
const text = entry.result.toLowerCase();
|
const pngPath = extractPathFromText(text, [".png", ".jpg", ".jpeg"]);
|
||||||
if (text.includes(".step") || text.includes(".stp")) {
|
if (pngPath) previewUrl = pngPath;
|
||||||
stepUrl = parsed.path || parsed.file_path || extractPathFromText(entry.result, [".step", ".stp"]);
|
}
|
||||||
}
|
|
||||||
if (text.includes(".stl")) {
|
// Extract STEP path
|
||||||
stlUrl = parsed.path || parsed.file_path || extractPathFromText(entry.result, [".stl"]);
|
if (!stepUrl) {
|
||||||
}
|
stepUrl = extractPathFromText(text, [".step", ".stp"]);
|
||||||
}
|
}
|
||||||
// save_document may also produce a path
|
|
||||||
if (entry.tool === "save_document") {
|
// Extract STL path
|
||||||
const path = parsed.path || parsed.file_path || null;
|
if (!stlUrl) {
|
||||||
if (path && (path.endsWith(".FCStd") || path.endsWith(".fcstd"))) {
|
stlUrl = extractPathFromText(text, [".stl"]);
|
||||||
// Not directly servable, but note it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Try extracting paths from raw text
|
|
||||||
stepUrl = stepUrl || extractPathFromText(entry.result, [".step", ".stp"]);
|
|
||||||
stlUrl = stlUrl || extractPathFromText(entry.result, [".stl"]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert internal paths to servable URLs
|
||||||
|
// /data/files/generated/... is served at /data/files/generated/...
|
||||||
|
const toUrl = (p: string | null) => p; // paths are already servable
|
||||||
|
|
||||||
return {
|
return {
|
||||||
previewUrl,
|
previewUrl: toUrl(previewUrl),
|
||||||
stepUrl,
|
stepUrl: toUrl(stepUrl),
|
||||||
stlUrl,
|
stlUrl: toUrl(stlUrl),
|
||||||
summary: orch.finalMessage,
|
summary: orch.finalMessage,
|
||||||
toolCallLog: orch.toolCallLog,
|
toolCallLog: orch.toolCallLog,
|
||||||
};
|
};
|
||||||
|
|
@ -314,6 +312,17 @@ Follow this workflow:
|
||||||
5. save_document to save the FreeCAD file
|
5. save_document to save the FreeCAD file
|
||||||
6. execute_python_script to export STEP: Part.export([obj], "/data/files/generated/freecad-<id>/model.step")
|
6. execute_python_script to export STEP: Part.export([obj], "/data/files/generated/freecad-<id>/model.step")
|
||||||
7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-<id>/model.stl")
|
7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-<id>/model.stl")
|
||||||
|
8. execute_python_script to render a preview PNG (do this last, ignore errors if GUI unavailable):
|
||||||
|
try:
|
||||||
|
import FreeCADGui
|
||||||
|
FreeCADGui.showMainWindow()
|
||||||
|
view = FreeCADGui.ActiveDocument.ActiveView
|
||||||
|
view.viewIsometric()
|
||||||
|
view.fitAll()
|
||||||
|
view.saveImage("/data/files/generated/freecad-<id>/preview.png", 1024, 1024, "White")
|
||||||
|
print("/data/files/generated/freecad-<id>/preview.png")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Preview rendering not available: {e}")
|
||||||
|
|
||||||
Important:
|
Important:
|
||||||
- Use /data/files/generated/freecad-${Date.now()}/ as the working directory
|
- Use /data/files/generated/freecad-${Date.now()}/ as the working directory
|
||||||
|
|
@ -321,4 +330,7 @@ Important:
|
||||||
- For complex shapes, build up from primitives with boolean operations
|
- For complex shapes, build up from primitives with boolean operations
|
||||||
- Wall thickness should be at least 1mm for 3D printing
|
- Wall thickness should be at least 1mm for 3D printing
|
||||||
- Always export both STEP (for CAD) and STL (for 3D printing)
|
- Always export both STEP (for CAD) and STL (for 3D printing)
|
||||||
|
- Try to render a preview PNG as the final step — this is displayed to the user
|
||||||
|
- When exporting files, always print() the FULL absolute path so it can be extracted from output
|
||||||
|
- Example: after Part.export(...), add print("/data/files/generated/freecad-xxx/model.step")
|
||||||
- If a tool call fails, try an alternative approach`;
|
- If a tool call fails, try an alternative approach`;
|
||||||
|
|
|
||||||
|
|
@ -198,23 +198,37 @@ app.get("/collect.js", async (c) => {
|
||||||
|
|
||||||
// ── Serve generated files from /data/files/generated/ and /api/files/generated/ ──
|
// ── Serve generated files from /data/files/generated/ and /api/files/generated/ ──
|
||||||
// The /api/ route avoids Cloudflare/Traefik redirecting /data/ paths
|
// The /api/ route avoids Cloudflare/Traefik redirecting /data/ paths
|
||||||
|
const GENERATED_MIME: Record<string, string> = {
|
||||||
|
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp",
|
||||||
|
glb: "model/gltf-binary", gltf: "model/gltf+json",
|
||||||
|
step: "application/step", stp: "application/step",
|
||||||
|
stl: "application/sla", fcstd: "application/octet-stream",
|
||||||
|
svg: "image/svg+xml", pdf: "application/pdf",
|
||||||
|
};
|
||||||
|
|
||||||
function serveGeneratedFile(c: any) {
|
function serveGeneratedFile(c: any) {
|
||||||
|
// Support both flat files and subdirectory paths (e.g. freecad-xxx/model.step)
|
||||||
const filename = c.req.param("filename");
|
const filename = c.req.param("filename");
|
||||||
if (!filename || filename.includes("..") || filename.includes("/")) {
|
const subdir = c.req.param("subdir");
|
||||||
|
const relPath = subdir ? `${subdir}/${filename}` : filename;
|
||||||
|
if (!relPath || relPath.includes("..")) {
|
||||||
return c.json({ error: "Invalid filename" }, 400);
|
return c.json({ error: "Invalid filename" }, 400);
|
||||||
}
|
}
|
||||||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||||
const filePath = resolve(dir, filename);
|
const filePath = resolve(dir, relPath);
|
||||||
|
// Ensure resolved path stays within generated dir
|
||||||
|
if (!filePath.startsWith(dir)) return c.json({ error: "Invalid path" }, 400);
|
||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
return file.exists().then((exists: boolean) => {
|
return file.exists().then((exists: boolean) => {
|
||||||
if (!exists) return c.notFound();
|
if (!exists) return c.notFound();
|
||||||
const ext = filename.split(".").pop() || "";
|
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
||||||
const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", glb: "model/gltf-binary", gltf: "model/gltf+json" };
|
return new Response(file, { headers: { "Content-Type": GENERATED_MIME[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
|
||||||
return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
app.get("/data/files/generated/:filename", serveGeneratedFile);
|
app.get("/data/files/generated/:filename", serveGeneratedFile);
|
||||||
|
app.get("/data/files/generated/:subdir/:filename", serveGeneratedFile);
|
||||||
app.get("/api/files/generated/:filename", serveGeneratedFile);
|
app.get("/api/files/generated/:filename", serveGeneratedFile);
|
||||||
|
app.get("/api/files/generated/:subdir/:filename", serveGeneratedFile);
|
||||||
|
|
||||||
// ── Link preview / unfurl API ──
|
// ── Link preview / unfurl API ──
|
||||||
const linkPreviewCache = new Map<string, { title: string; description: string; image: string | null; domain: string; fetchedAt: number }>();
|
const linkPreviewCache = new Map<string, { title: string; description: string; image: string | null; domain: string; fetchedAt: number }>();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue