Use AI to generate office 3D assets

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-06 19:25:45 +00:00
co-authored by Luke The Dev
parent 5b44a87d4b
commit f1c823bf2e
9 changed files with 816 additions and 277 deletions
+7
View File
@@ -48,3 +48,10 @@ DEBUG=true
# ELEVENLABS_API_KEY=
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
# ELEVENLABS_MODEL_ID=eleven_flash_v2_5
# Optional: AI image -> 3D reconstruction for office assets
# Uses an OpenAI-compatible chat completions endpoint with image input and
# json_schema structured output.
# OPENAI_API_KEY=
# OPENAI_BASE_URL=https://api.openai.com/v1
# OPENAI_3D_MODEL=gpt-4o-mini
+78
View File
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import {
generatePictureModelFromImage,
MAX_PICTURE_MODEL_UPLOAD_BYTES,
} from "@/lib/office/pictureModelGeneration";
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
const MULTIPART_OVERHEAD_ALLOWANCE = 1024;
const contentLengthHeader = request.headers.get("content-length");
if (contentLengthHeader !== null) {
const contentLength = Number(contentLengthHeader);
if (
!Number.isNaN(contentLength) &&
contentLength > MAX_PICTURE_MODEL_UPLOAD_BYTES + MULTIPART_OVERHEAD_ALLOWANCE
) {
return NextResponse.json(
{
error: `Image upload exceeds the ${MAX_PICTURE_MODEL_UPLOAD_BYTES} byte limit.`,
},
{ status: 413 },
);
}
}
const formData = await request.formData();
const image = formData.get("image");
if (
image === null ||
typeof image !== "object" ||
typeof (image as File).arrayBuffer !== "function"
) {
return NextResponse.json(
{ error: "image file is required." },
{ status: 400 },
);
}
const imageFile = image as File;
const arrayBuffer = await imageFile.arrayBuffer();
const byteLength = arrayBuffer.byteLength;
if (byteLength <= 0) {
return NextResponse.json({ error: "Image upload is empty." }, { status: 400 });
}
if (byteLength > MAX_PICTURE_MODEL_UPLOAD_BYTES) {
return NextResponse.json(
{
error: `Image upload exceeds the ${MAX_PICTURE_MODEL_UPLOAD_BYTES} byte limit.`,
},
{ status: 413 },
);
}
if (!imageFile.type.startsWith("image/")) {
return NextResponse.json(
{ error: "Only image uploads are supported." },
{ status: 400 },
);
}
const base64 = Buffer.from(arrayBuffer).toString("base64");
const imageDataUrl = `data:${imageFile.type};base64,${base64}`;
const result = await generatePictureModelFromImage({
imageDataUrl,
fileName: imageFile.name,
mimeType: imageFile.type,
});
return NextResponse.json(result);
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to generate the 3D model from the uploaded image.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+23 -7
View File
@@ -4996,13 +4996,29 @@ export function RetroOffice3D({
setPictureDraftStatus("processing");
setPictureDraftError(null);
try {
const asset = await createPictureAssetFromFile(file);
setPictureDraft(asset);
const previewAsset = await createPictureAssetFromFile(file);
const formData = new FormData();
formData.set("image", file);
formData.set("previewDataUrl", previewAsset.imageDataUrl);
const response = await fetch("/api/office/picture-model", {
method: "POST",
body: formData,
});
const payload = (await response.json().catch(() => null)) as
| {
asset?: PicturePropAsset;
error?: string;
}
| null;
if (!response.ok || !payload?.asset) {
throw new Error(payload?.error || "AI 3D reconstruction failed.");
}
setPictureDraft(payload.asset);
} catch (error) {
setPictureDraftError(
error instanceof Error
? error.message
: "Picture processing failed.",
: "Picture model generation failed.",
);
} finally {
setPictureDraftStatus("idle");
@@ -7130,7 +7146,7 @@ export function RetroOffice3D({
{selectedPictureAsset ? (
<div className="mt-3 border-t border-amber-900/20 pt-3">
<div className="mb-2 text-[10px] font-bold uppercase tracking-[0.22em] text-amber-500/65">
Picture to GLB
AI Model to GLB
</div>
<div className="relative h-24 w-full overflow-hidden rounded-md border border-amber-800/20">
<Image
@@ -7199,10 +7215,10 @@ export function RetroOffice3D({
</div>
<div className="mt-3 rounded-lg border border-amber-800/20 bg-[#120e08] p-3">
<div className="text-[10px] font-bold uppercase tracking-[0.22em] text-amber-500/65">
Picture Lab
Image to 3D Lab
</div>
<div className="mt-1 text-[10px] leading-relaxed text-amber-500/55">
Upload any picture, stylize it into a matte office prop, then export the same model as a GLB.
Upload a picture and let AI reconstruct it as a simplified 3D office asset, then export that model as a GLB.
</div>
{pictureDraft ? (
<div className="relative mt-3 h-24 w-full overflow-hidden rounded-md border border-amber-800/20">
@@ -7266,7 +7282,7 @@ export function RetroOffice3D({
: "border-amber-900/20 bg-[#16100a] text-amber-400/40"
}`}
>
<span>Place in Office</span>
<span>Generate and Place</span>
</button>
</div>
</div>
+276 -220
View File
@@ -3,7 +3,11 @@
import * as THREE from "three";
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
import { SCALE } from "@/features/retro-office/core/constants";
import type { PicturePropAsset } from "@/features/retro-office/core/types";
import type {
Picture3dPrimitive,
Picture3dRecipe,
PicturePropAsset,
} from "@/features/retro-office/core/types";
export const PICTURE_PROP_TYPE = "picture_prop";
@@ -12,7 +16,11 @@ const MIN_PIXEL_WIDTH = 20;
const MAX_PIXEL_WIDTH = 44;
const MIN_PIXEL_HEIGHT = 20;
const MAX_PIXEL_HEIGHT = 44;
const PICTURE_PROP_DEPTH_UNITS = 24;
const PICTURE_PROP_DEPTH_UNITS = 30;
const DEFAULT_PALETTE = {
accentColor: "#d97706",
dominantColor: "#7c5c3b",
} as const;
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
@@ -108,11 +116,7 @@ export const derivePicturePalette = (rgba: ArrayLike<number>) => {
}
if (visiblePixels === 0 || buckets.size === 0) {
return {
accentColor: "#d97706",
dominantColor: "#7c5c3b",
frameColor: "#24170d",
};
return { ...DEFAULT_PALETTE };
}
const dominantBucket =
@@ -160,7 +164,7 @@ const loadImageElement = (src: string) =>
image.src = src;
});
const renderCoverImage = (
const renderPreviewImage = (
context: CanvasRenderingContext2D,
image: CanvasImageSource,
targetWidth: number,
@@ -219,16 +223,95 @@ export const getPicturePropGlbFileName = (asset: PicturePropAsset) =>
export const resolvePicturePropFootprint = (aspectRatio: number) => {
const safeAspect = clamp(Number.isFinite(aspectRatio) ? aspectRatio : 1, 0.65, 1.9);
const widthUnits = Math.round(36 + (safeAspect - 0.65) * 16);
const widthUnits = Math.round(44 + (safeAspect - 0.65) * 18);
return {
depthUnits: PICTURE_PROP_DEPTH_UNITS,
widthUnits: clamp(widthUnits, 34, 58),
widthUnits: clamp(widthUnits, 40, 66),
};
};
export const buildFallbackGeneratedModel = (
palette: Pick<PicturePropAsset, "accentColor" | "dominantColor">,
aspectRatio: number,
): Picture3dRecipe => {
const safeAspect = clamp(aspectRatio, 0.65, 1.9);
const width = clamp(1.1 + (safeAspect - 1) * 0.22, 0.86, 1.42);
const depth = 0.72;
const towerWidth = clamp(width * 0.28, 0.22, 0.34);
const headWidth = clamp(width * 0.5, 0.32, 0.58);
const highlightWidth = clamp(width * 0.14, 0.08, 0.18);
return {
title: "AI office sculpture",
summary:
"retro office collectible with chunky low-poly forms, matte materials, soft bevels, and furniture-like proportions.",
footprintMeters: {
width,
depth,
height: 1.66,
},
primitives: [
{
kind: "box",
size: [width, 0.22, depth],
position: [0, 0.11, 0],
material: {
color: palette.accentColor,
roughness: 0.82,
},
},
{
kind: "box",
size: [width * 0.74, 0.92, depth * 0.7],
position: [0, 0.68, -0.02],
material: {
color: palette.dominantColor,
roughness: 0.76,
},
},
{
kind: "box",
size: [headWidth, 0.36, depth * 0.44],
position: [0, 1.3, 0.04],
material: {
color: mixColors(palette.dominantColor, "#f6efe1", 0.3),
roughness: 0.74,
},
},
{
kind: "box",
size: [towerWidth, 0.54, depth * 0.44],
position: [-(width * 0.22), 0.86, 0.12],
material: {
color: mixColors(palette.dominantColor, "#0f0a06", 0.72),
roughness: 0.82,
},
},
{
kind: "box",
size: [towerWidth, 0.62, depth * 0.36],
position: [width * 0.22, 0.78, -0.08],
material: {
color: mixColors(mixColors(palette.dominantColor, "#0f0a06", 0.72), palette.dominantColor, 0.24),
roughness: 0.78,
},
},
{
kind: "box",
size: [highlightWidth, 0.68, depth * 0.8],
position: [width * 0.34, 0.78, 0.05],
material: {
color: mixColors(palette.accentColor, "#f8d34d", 0.24),
roughness: 0.66,
metalness: 0.1,
},
},
],
};
};
export const createPictureAssetFromFile = async (file: File) => {
if (!file.type.startsWith("image/")) {
throw new Error("Only image uploads are supported for picture props.");
throw new Error("Only image uploads are supported for 3D generation.");
}
const objectUrl = URL.createObjectURL(file);
@@ -254,7 +337,7 @@ export const createPictureAssetFromFile = async (file: File) => {
throw new Error("Could not create an image processing context.");
}
sourceContext.imageSmoothingEnabled = true;
renderCoverImage(sourceContext, image, pixelWidth, pixelHeight);
renderPreviewImage(sourceContext, image, pixelWidth, pixelHeight);
const previewScale = Math.max(
1,
@@ -279,7 +362,6 @@ export const createPictureAssetFromFile = async (file: File) => {
const palette = derivePicturePalette(
sourceContext.getImageData(0, 0, pixelWidth, pixelHeight).data,
);
return {
...palette,
aspectRatio,
@@ -287,91 +369,146 @@ export const createPictureAssetFromFile = async (file: File) => {
imageDataUrl: previewCanvas.toDataURL("image/webp", 0.86),
pixelHeight,
pixelWidth,
provider: "preview",
model: "local-fallback",
summary:
"Local preview generated. Upload will request an AI low-poly office asset recipe.",
recipe: buildFallbackGeneratedModel(palette, aspectRatio),
} satisfies PicturePropAsset;
} finally {
URL.revokeObjectURL(objectUrl);
}
};
type PicturePropDimensions = {
artHeight: number;
artWidth: number;
backdropDepth: number;
backdropHeight: number;
backdropWidth: number;
baseDepth: number;
baseHeight: number;
baseWidth: number;
braceLength: number;
braceTilt: number;
frameDepth: number;
frameHeight: number;
frameInset: number;
frameThickness: number;
frameWidth: number;
supportHeight: number;
supportWidth: number;
const clampPrimitiveSize = (value: number, fallback: number, min: number, max: number) =>
clamp(Number.isFinite(value) ? value : fallback, min, max);
const clampPrimitivePosition = (value: number, fallback = 0, min = -1.5, max = 1.8) =>
clamp(Number.isFinite(value) ? value : fallback, min, max);
const clampPrimitiveRotation = (value: number, fallback = 0) =>
clamp(Number.isFinite(value) ? value : fallback, -Math.PI, Math.PI);
const normalizeHexColor = (value: string | undefined, fallback: string) => {
const raw = value?.trim() ?? "";
if (!/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(raw)) {
return fallback;
}
if (raw.length === 4) {
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`.toLowerCase();
}
return raw.toLowerCase();
};
export const resolvePicturePropDimensions = (params: {
aspectRatio: number;
footprintDepth: number;
footprintWidth: number;
}): PicturePropDimensions => {
const safeAspect = clamp(Number.isFinite(params.aspectRatio) ? params.aspectRatio : 1, 0.65, 1.9);
let artWidth = clamp(params.footprintWidth * 0.78, 0.48, 1.16);
let artHeight = artWidth / safeAspect;
if (artHeight > 1.08) {
artHeight = 1.08;
artWidth = artHeight * safeAspect;
export const sanitizeGeneratedPrimitive = (
primitive: Picture3dPrimitive,
palette: Pick<PicturePropAsset, "accentColor" | "dominantColor">,
index: number,
): Picture3dPrimitive => {
const fallbackColor =
index === 0
? palette.dominantColor
: index % 3 === 0
? palette.accentColor
: mixColors(palette.dominantColor, "#0f0a06", 0.72);
const material = {
color: normalizeHexColor(primitive.material?.color, fallbackColor),
metalness: clamp(primitive.material?.metalness ?? 0.08, 0, 0.35),
roughness: clamp(primitive.material?.roughness ?? 0.76, 0.4, 1),
};
const rotation = primitive.rotation
? ([
clampPrimitiveRotation(primitive.rotation[0] ?? Number.NaN),
clampPrimitiveRotation(primitive.rotation[1] ?? Number.NaN),
clampPrimitiveRotation(primitive.rotation[2] ?? Number.NaN),
] as [number, number, number])
: undefined;
const position = [
clampPrimitivePosition(primitive.position[0] ?? Number.NaN),
clampPrimitivePosition(primitive.position[1] ?? Number.NaN, 0.2, 0, 2.2),
clampPrimitivePosition(primitive.position[2] ?? Number.NaN),
] as [number, number, number];
if (primitive.kind === "cylinder") {
return {
kind: "cylinder",
height: clampPrimitiveSize(primitive.height, 0.46, 0.08, 2.2),
radiusTop: clampPrimitiveSize(primitive.radiusTop, 0.18, 0.04, 0.8),
radiusBottom: clampPrimitiveSize(primitive.radiusBottom, 0.18, 0.04, 0.8),
radialSegments: Math.round(clampPrimitiveSize(primitive.radialSegments ?? 16, 16, 8, 24)),
position,
...(rotation ? { rotation } : {}),
material,
};
}
if (artHeight < 0.56) {
artHeight = 0.56;
artWidth = artHeight * safeAspect;
if (primitive.kind === "sphere") {
return {
kind: "sphere",
radius: clampPrimitiveSize(primitive.radius, 0.24, 0.04, 0.8),
widthSegments: Math.round(clampPrimitiveSize(primitive.widthSegments ?? 16, 16, 8, 24)),
heightSegments: Math.round(clampPrimitiveSize(primitive.heightSegments ?? 16, 16, 8, 24)),
position,
...(rotation ? { rotation } : {}),
material,
};
}
const frameThickness = clamp(artWidth * 0.085, 0.045, 0.085);
const frameInset = frameThickness * 0.68;
const frameWidth = artWidth + frameThickness * 2;
const frameHeight = artHeight + frameThickness * 2;
const frameDepth = 0.08;
const baseHeight = 0.08;
const baseWidth = clamp(frameWidth * 0.72, 0.34, params.footprintWidth * 0.92);
const baseDepth = clamp(params.footprintDepth * 0.72, 0.2, 0.34);
const supportHeight = clamp(frameHeight * 0.84, 0.56, 1.08);
const supportWidth = clamp(frameWidth * 0.12, 0.05, 0.09);
return {
artHeight,
artWidth,
backdropDepth: 0.03,
backdropHeight: frameHeight * 0.96,
backdropWidth: frameWidth * 0.96,
baseDepth,
baseHeight,
baseWidth,
braceLength: clamp(frameHeight * 0.55, 0.38, 0.62),
braceTilt: 0.68,
frameDepth,
frameHeight,
frameInset,
frameThickness,
frameWidth,
supportHeight,
supportWidth,
kind: "box",
size: [
clampPrimitiveSize(primitive.size[0] ?? Number.NaN, 0.46, 0.08, 1.8),
clampPrimitiveSize(primitive.size[1] ?? Number.NaN, 0.46, 0.08, 2.2),
clampPrimitiveSize(primitive.size[2] ?? Number.NaN, 0.46, 0.08, 1.8),
],
position,
...(rotation ? { rotation } : {}),
material,
};
};
const applyPictureTextureStyle = (texture: THREE.Texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
return texture;
export const sanitizeGeneratedModel = (
model: Picture3dRecipe | null | undefined,
asset: Pick<
PicturePropAsset,
| "accentColor"
| "aspectRatio"
| "dominantColor"
| "fileName"
| "recipe"
>,
): Picture3dRecipe => {
const fallback = buildFallbackGeneratedModel(
{
accentColor: asset.accentColor,
dominantColor: asset.dominantColor,
},
asset.aspectRatio,
);
if (!model) return fallback;
const primitives: Picture3dPrimitive[] = Array.isArray(model.primitives)
? model.primitives
.slice(0, 16)
.map((primitive: Picture3dPrimitive, index: number) =>
sanitizeGeneratedPrimitive(
primitive,
{
accentColor: asset.accentColor,
dominantColor: asset.dominantColor,
},
index,
),
)
: fallback.primitives;
return {
title: model.title?.trim() || sanitizeBaseFileName(asset.fileName),
summary: model.summary?.trim() || fallback.summary,
footprintMeters: {
width: clamp(model.footprintMeters?.width ?? fallback.footprintMeters.width, 0.6, 1.8),
depth: clamp(model.footprintMeters?.depth ?? fallback.footprintMeters.depth, 0.4, 1.4),
height: clamp(model.footprintMeters?.height ?? fallback.footprintMeters.height, 0.8, 2.1),
},
primitives: primitives.length > 0 ? primitives : fallback.primitives,
};
};
export const createStyledPictureTexture = (texture: THREE.Texture) =>
applyPictureTextureStyle(texture.clone());
const createStandardMaterial = (
color: string,
overrides: Partial<THREE.MeshStandardMaterialParameters> = {},
@@ -379,135 +516,70 @@ const createStandardMaterial = (
new THREE.MeshStandardMaterial({
color,
metalness: 0.08,
roughness: 0.72,
roughness: 0.76,
...overrides,
});
type BuildPicturePropGroupParams = {
asset: PicturePropAsset;
footprintDepth: number;
footprintWidth: number;
texture: THREE.Texture;
const createPrimitiveGeometry = (primitive: Picture3dPrimitive) => {
switch (primitive.kind) {
case "sphere":
return new THREE.SphereGeometry(
clamp(primitive.radius, 0.05, 0.9),
primitive.widthSegments ?? 18,
primitive.heightSegments ?? 18,
);
case "cylinder":
return new THREE.CylinderGeometry(
clamp(primitive.radiusTop, 0.04, 0.8),
clamp(primitive.radiusBottom, 0.04, 0.8),
clamp(primitive.height, 0.08, 2.2),
primitive.radialSegments ?? 18,
);
case "box":
default:
return new THREE.BoxGeometry(
clamp(primitive.size[0], 0.08, 1.8),
clamp(primitive.size[1], 0.08, 2.2),
clamp(primitive.size[2], 0.08, 1.8),
);
}
};
export const buildPicturePropGroup = ({
asset,
footprintDepth,
footprintWidth,
texture,
}: BuildPicturePropGroupParams) => {
const dominantShadow = mixColors(asset.dominantColor, "#111827", 0.42);
const accentShadow = mixColors(asset.accentColor, "#111827", 0.28);
const dims = resolvePicturePropDimensions({
aspectRatio: asset.aspectRatio,
footprintDepth,
footprintWidth,
});
export const buildPicturePropGroup = (asset: PicturePropAsset) => {
const model = sanitizeGeneratedModel(asset.recipe, asset);
const footprint = resolvePicturePropFootprint(asset.aspectRatio);
const widthScale = (footprint.widthUnits * SCALE) / Math.max(model.footprintMeters.width, 0.1);
const depthScale = (footprint.depthUnits * SCALE) / Math.max(model.footprintMeters.depth, 0.1);
const heightScale = 1.55 / Math.max(model.footprintMeters.height, 0.1);
const uniformScale = clamp(Math.min(widthScale, depthScale, heightScale), 0.4, 1.6);
const group = new THREE.Group();
const frameCenterY = dims.baseHeight + dims.frameHeight * 0.5 + 0.18;
const applySharedFlags = (mesh: THREE.Mesh) => {
for (const primitive of model.primitives) {
const geometry = createPrimitiveGeometry(primitive);
const material = createStandardMaterial(primitive.material.color, {
metalness: primitive.material.metalness ?? 0.08,
roughness: primitive.material.roughness ?? 0.76,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(
primitive.position[0] * uniformScale,
primitive.position[1] * uniformScale,
primitive.position[2] * uniformScale,
);
mesh.rotation.set(
primitive.rotation?.[0] ?? 0,
primitive.rotation?.[1] ?? 0,
primitive.rotation?.[2] ?? 0,
);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
};
const base = applySharedFlags(
new THREE.Mesh(
new THREE.BoxGeometry(dims.baseWidth, dims.baseHeight, dims.baseDepth),
createStandardMaterial(asset.accentColor, { roughness: 0.78 }),
),
);
base.position.set(0, dims.baseHeight * 0.5, 0);
group.add(base);
const support = applySharedFlags(
new THREE.Mesh(
new THREE.BoxGeometry(dims.supportWidth, dims.supportHeight, 0.07),
createStandardMaterial(dominantShadow, { roughness: 0.82 }),
),
);
support.position.set(0, dims.baseHeight + dims.supportHeight * 0.5, -0.02);
group.add(support);
const brace = applySharedFlags(
new THREE.Mesh(
new THREE.BoxGeometry(dims.supportWidth * 0.8, dims.braceLength, 0.06),
createStandardMaterial(accentShadow, { roughness: 0.76 }),
),
);
brace.position.set(0, dims.baseHeight + dims.braceLength * 0.55, -dims.baseDepth * 0.2);
brace.rotation.x = -dims.braceTilt;
group.add(brace);
const backdrop = applySharedFlags(
new THREE.Mesh(
new THREE.BoxGeometry(
dims.backdropWidth,
dims.backdropHeight,
dims.backdropDepth,
),
createStandardMaterial(mixColors(asset.dominantColor, "#efe6d8", 0.18), {
roughness: 0.9,
}),
),
);
backdrop.position.set(0, frameCenterY, -0.01);
group.add(backdrop);
const frame = applySharedFlags(
new THREE.Mesh(
new THREE.BoxGeometry(dims.frameWidth, dims.frameHeight, dims.frameDepth),
createStandardMaterial(asset.frameColor, {
metalness: 0.12,
roughness: 0.7,
}),
),
);
frame.position.set(0, frameCenterY, 0);
group.add(frame);
const innerPanel = applySharedFlags(
new THREE.Mesh(
new THREE.BoxGeometry(
dims.frameWidth - dims.frameInset * 2,
dims.frameHeight - dims.frameInset * 2,
dims.frameDepth * 0.52,
),
createStandardMaterial(mixColors(asset.dominantColor, "#f6efe1", 0.32), {
roughness: 0.92,
}),
),
);
innerPanel.position.set(0, frameCenterY, dims.frameDepth * 0.06);
group.add(innerPanel);
const artPlane = applySharedFlags(
new THREE.Mesh(
new THREE.PlaneGeometry(dims.artWidth, dims.artHeight),
createStandardMaterial("#ffffff", {
map: texture,
metalness: 0.02,
roughness: 0.9,
side: THREE.DoubleSide,
}),
),
);
artPlane.position.set(0, frameCenterY, dims.frameDepth * 0.5 + 0.002);
group.add(artPlane);
const topCap = applySharedFlags(
new THREE.Mesh(
new THREE.BoxGeometry(dims.frameWidth * 0.18, 0.06, dims.frameDepth * 0.78),
createStandardMaterial(accentShadow, {
metalness: 0.16,
roughness: 0.62,
}),
),
);
topCap.position.set(0, frameCenterY + dims.frameHeight * 0.5 + 0.01, -0.008);
group.add(topCap);
group.add(mesh);
}
const boundingBox = new THREE.Box3().setFromObject(group);
const center = boundingBox.getCenter(new THREE.Vector3());
const minY = boundingBox.min.y;
group.position.set(-center.x, Math.max(0, -minY), -center.z);
return group;
};
@@ -529,21 +601,8 @@ export const buildPicturePropItem = (
};
};
const loadTexture = async (imageDataUrl: string) => {
const loader = new THREE.TextureLoader();
const texture = await loader.loadAsync(imageDataUrl);
return applyPictureTextureStyle(texture);
};
export const exportPictureAssetToGlb = async (asset: PicturePropAsset) => {
const footprint = resolvePicturePropFootprint(asset.aspectRatio);
const texture = await loadTexture(asset.imageDataUrl);
const group = buildPicturePropGroup({
asset,
footprintDepth: footprint.depthUnits * SCALE,
footprintWidth: footprint.widthUnits * SCALE,
texture,
});
const group = buildPicturePropGroup(asset);
const exporter = new GLTFExporter();
const binary = await new Promise<ArrayBuffer>((resolve, reject) => {
exporter.parse(
@@ -553,18 +612,15 @@ export const exportPictureAssetToGlb = async (asset: PicturePropAsset) => {
resolve(result);
return;
}
reject(new Error("Picture prop export did not return a binary GLB."));
reject(new Error("3D generation export did not return a binary GLB."));
},
(error) => {
reject(
error instanceof Error
? error
: new Error("Picture prop export failed."),
error instanceof Error ? error : new Error("3D generation export failed."),
);
},
{
binary: true,
maxTextureSize: 1024,
onlyVisible: false,
},
);
+53 -1
View File
@@ -64,15 +64,67 @@ export type RenderAgent = SceneActor & {
janitorPauseUntil?: number;
};
export type Picture3dMaterial = {
color: string;
roughness?: number;
metalness?: number;
};
export type Picture3dPrimitiveBase = {
material: Picture3dMaterial;
position: [number, number, number];
rotation?: [number, number, number];
};
export type Picture3dBoxPrimitive = Picture3dPrimitiveBase & {
kind: "box";
size: [number, number, number];
};
export type Picture3dCylinderPrimitive = Picture3dPrimitiveBase & {
kind: "cylinder";
radiusBottom: number;
radiusTop: number;
height: number;
radialSegments?: number;
};
export type Picture3dSpherePrimitive = Picture3dPrimitiveBase & {
kind: "sphere";
radius: number;
widthSegments?: number;
heightSegments?: number;
};
export type Picture3dPrimitive =
| Picture3dBoxPrimitive
| Picture3dCylinderPrimitive
| Picture3dSpherePrimitive;
export type Picture3dRecipe = {
title: string;
summary: string;
footprintMeters: {
width: number;
depth: number;
height: number;
};
primitives: Picture3dPrimitive[];
};
export type PicturePropAsset = {
fileName: string;
imageDataUrl: string;
sourceImageDataUrl?: string;
aspectRatio: number;
dominantColor: string;
accentColor: string;
frameColor: string;
pixelWidth: number;
pixelHeight: number;
provider: string;
model: string;
summary: string;
recipe: Picture3dRecipe;
};
export type FurnitureItem = {
@@ -1,6 +1,5 @@
"use client";
import { useTexture } from "@react-three/drei";
import { useEffect, useMemo } from "react";
import * as THREE from "three";
import { SCALE } from "@/features/retro-office/core/constants";
@@ -12,7 +11,6 @@ import {
import {
buildPicturePropGroup,
PICTURE_PROP_TYPE,
createStyledPictureTexture,
resolvePicturePropFootprint,
} from "@/features/retro-office/core/pictureAsset";
import type { FurnitureItem } from "@/features/retro-office/core/types";
@@ -36,9 +34,6 @@ const disposeObject3D = (object: THREE.Object3D) => {
});
};
const EMPTY_TEXTURE_DATA_URL =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==";
const applyHighlight = ({
editMode,
isHovered,
@@ -72,17 +67,10 @@ const applyHighlight = ({
const buildStyledObject = (
item: FurnitureItem,
texture: THREE.Texture,
overrideAsset = item.pictureAsset,
) => {
if (!overrideAsset) return null;
const { width, height } = getItemBaseSize(item);
return buildPicturePropGroup({
asset: overrideAsset,
footprintDepth: height * SCALE,
footprintWidth: width * SCALE,
texture: createStyledPictureTexture(texture),
});
return buildPicturePropGroup(overrideAsset);
};
export function PicturePropModel({
@@ -96,11 +84,9 @@ export function PicturePropModel({
onClick,
}: InteractiveFurnitureModelProps) {
const asset = item.pictureAsset;
const imageDataUrl = asset?.imageDataUrl ?? EMPTY_TEXTURE_DATA_URL;
const sourceTexture = useTexture(imageDataUrl);
const modelObject = useMemo(
() => buildStyledObject(item, sourceTexture),
[item, sourceTexture],
() => buildStyledObject(item),
[item],
);
const [wx, , wz] = toWorld(item.x, item.y);
const { width, height } = getItemBaseSize(item);
@@ -163,7 +149,6 @@ export function PicturePropGhost({
asset: NonNullable<FurnitureItem["pictureAsset"]>;
position: [number, number, number];
}) {
const sourceTexture = useTexture(asset.imageDataUrl);
const footprint = resolvePicturePropFootprint(asset.aspectRatio);
const ghostItem = useMemo<FurnitureItem>(
() => ({
@@ -178,8 +163,8 @@ export function PicturePropGhost({
[asset, footprint.depthUnits, footprint.widthUnits],
);
const modelObject = useMemo(
() => buildStyledObject(ghostItem, sourceTexture, asset),
[asset, ghostItem, sourceTexture],
() => buildStyledObject(ghostItem, asset),
[asset, ghostItem],
);
const pivotX = footprint.widthUnits * SCALE * 0.5;
const pivotZ = footprint.depthUnits * SCALE * 0.5;
+178
View File
@@ -0,0 +1,178 @@
import type { Picture3dRecipe } from "@/features/retro-office/core/types";
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_PICTURE_3D_MODEL = "gpt-4o-mini";
export const MAX_PICTURE_MODEL_UPLOAD_BYTES = 12 * 1024 * 1024;
type GeneratePictureModelParams = {
imageDataUrl: string;
fileName?: string;
mimeType?: string;
};
const generatedPrimitiveSchema = {
type: "object",
additionalProperties: false,
properties: {
kind: {
type: "string",
enum: ["box", "cylinder", "sphere"],
},
position: {
type: "array",
minItems: 3,
maxItems: 3,
items: { type: "number" },
},
rotation: {
type: "array",
minItems: 3,
maxItems: 3,
items: { type: "number" },
},
material: {
type: "object",
additionalProperties: false,
properties: {
color: { type: "string" },
roughness: { type: "number" },
metalness: { type: "number" },
},
required: ["color"],
},
size: {
type: "array",
minItems: 3,
maxItems: 3,
items: { type: "number" },
},
radiusTop: { type: "number" },
radiusBottom: { type: "number" },
height: { type: "number" },
radius: { type: "number" },
radialSegments: { type: "number" },
widthSegments: { type: "number" },
heightSegments: { type: "number" },
},
required: ["kind", "position", "material"],
} as const;
const generatedModelSchema = {
name: "picture_to_3d_office_asset",
schema: {
type: "object",
additionalProperties: false,
properties: {
title: { type: "string" },
summary: { type: "string" },
footprintMeters: {
type: "object",
additionalProperties: false,
properties: {
width: { type: "number" },
depth: { type: "number" },
height: { type: "number" },
},
required: ["width", "depth", "height"],
},
primitives: {
type: "array",
minItems: 3,
maxItems: 16,
items: generatedPrimitiveSchema,
},
},
required: ["title", "summary", "footprintMeters", "primitives"],
},
strict: true,
} as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const extractStructuredOutput = (payload: unknown): Picture3dRecipe | null => {
if (!isRecord(payload)) return null;
const choices = Array.isArray(payload.choices) ? payload.choices : [];
const firstChoice = choices[0];
if (!isRecord(firstChoice)) return null;
const message = isRecord(firstChoice.message) ? firstChoice.message : null;
const content = message?.content;
if (typeof content === "string" && content.trim()) {
try {
return JSON.parse(content) as Picture3dRecipe;
} catch {
return null;
}
}
const parsed = message && "parsed" in message ? message.parsed : null;
return isRecord(parsed) ? (parsed as Picture3dRecipe) : null;
};
export const generatePictureModelFromImage = async ({
imageDataUrl,
}: GeneratePictureModelParams): Promise<Picture3dRecipe> => {
const apiKey = process.env.OPENAI_API_KEY?.trim();
if (!apiKey) {
throw new Error("Missing OPENAI_API_KEY for AI 3D generation.");
}
const baseUrl =
process.env.OPENAI_BASE_URL?.trim() || DEFAULT_OPENAI_BASE_URL;
const model =
process.env.OPENAI_PICTURE_3D_MODEL?.trim() || DEFAULT_PICTURE_3D_MODEL;
const response = await fetch(
`${baseUrl.replace(/\/$/, "")}/chat/completions`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
cache: "no-store",
body: JSON.stringify({
model,
response_format: {
type: "json_schema",
json_schema: generatedModelSchema,
},
messages: [
{
role: "system",
content:
"You convert a reference image into a compact low-poly office sculpture recipe. Output only structured JSON. Use 3-16 simple primitives. Match a matte retro office furniture style with chunky shapes, no thin details, and plausible freestanding balance. Return only boxes, cylinders, and spheres.",
},
{
role: "user",
content: [
{
type: "text",
text: "Recreate the uploaded image as a stylized 3D object that feels like it belongs next to the office furniture and avatars in a retro Three.js office. Use simple primitives, strong silhouette, and no textures. Keep all dimensions normalized to roughly desk-scale collectible proportions and prefer grounded, freestanding forms.",
},
{
type: "image_url",
image_url: {
url: imageDataUrl,
},
},
],
},
],
}),
},
);
if (!response.ok) {
const detail = (await response.text().catch(() => "")).trim();
throw new Error(detail || "AI picture-to-3D generation failed.");
}
const payload = (await response.json()) as unknown;
const parsed = extractStructuredOutput(payload);
if (!parsed) {
throw new Error(
"AI picture-to-3D generation returned invalid structured output.",
);
}
return parsed;
};
+68 -29
View File
@@ -1,9 +1,56 @@
import { describe, expect, it } from "vitest";
import * as THREE from "three";
import {
buildPicturePropGroup,
buildPicturePropItem,
derivePicturePalette,
resolvePicturePropDimensions,
resolvePicturePropFootprint,
} from "@/features/retro-office/core/pictureAsset";
import type { PicturePropAsset } from "@/features/retro-office/core/types";
const demoAsset: PicturePropAsset = {
fileName: "photo.png",
imageDataUrl: "data:image/png;base64,abc",
aspectRatio: 1.1,
dominantColor: "#774433",
accentColor: "#335577",
pixelWidth: 32,
pixelHeight: 28,
provider: "openai",
model: "gpt-4o-mini",
summary: "Chunky desk collectible inspired by the uploaded reference.",
recipe: {
title: "Retro Desk Figure",
summary: "Layered low-poly character silhouette.",
footprintMeters: {
width: 0.84,
depth: 0.46,
height: 1.24,
},
primitives: [
{
kind: "box",
size: [0.78, 0.2, 0.44],
position: [0, 0.1, 0],
material: { color: "#774433", roughness: 0.82, metalness: 0.06 },
},
{
kind: "cylinder",
radiusTop: 0.14,
radiusBottom: 0.18,
height: 0.78,
position: [0, 0.59, 0],
material: { color: "#335577", roughness: 0.74, metalness: 0.08 },
},
{
kind: "sphere",
radius: 0.22,
position: [0, 1.08, 0.02],
material: { color: "#d9c4aa", roughness: 0.7, metalness: 0.04 },
},
],
},
};
describe("derivePicturePalette", () => {
it("builds stable dominant and accent colors from visible pixels", () => {
@@ -28,45 +75,37 @@ describe("derivePicturePalette", () => {
frameColor: "#3c191b",
});
});
it("falls back to the default palette for fully transparent images", () => {
const pixels = new Uint8ClampedArray([0, 0, 0, 0, 255, 255, 255, 0]);
expect(derivePicturePalette(pixels)).toEqual({
accentColor: "#d97706",
dominantColor: "#7c5c3b",
frameColor: "#24170d",
});
});
});
describe("resolvePicturePropFootprint", () => {
it("scales width with aspect ratio and clamps the result", () => {
expect(resolvePicturePropFootprint(0.4)).toEqual({
depthUnits: 24,
widthUnits: 36,
depthUnits: 30,
widthUnits: 44,
});
expect(resolvePicturePropFootprint(1.5)).toEqual({
depthUnits: 24,
widthUnits: 50,
});
expect(resolvePicturePropFootprint(3)).toEqual({
depthUnits: 24,
widthUnits: 56,
depthUnits: 30,
widthUnits: 59,
});
});
});
describe("resolvePicturePropDimensions", () => {
it("keeps portrait props within the scene height budget", () => {
const dims = resolvePicturePropDimensions({
aspectRatio: 0.68,
footprintDepth: 24 * 0.018,
footprintWidth: 34 * 0.018,
});
describe("buildPicturePropGroup", () => {
it("builds a freestanding object whose base sits on the floor", () => {
const group = buildPicturePropGroup(demoAsset);
expect(group.children.length).toBe(3);
const bounds = new THREE.Box3().setFromObject(group);
expect(bounds.min.y).toBeGreaterThanOrEqual(-0.000001);
});
});
expect(dims.artHeight).toBeLessThanOrEqual(1.08);
expect(dims.baseDepth).toBeGreaterThan(0.19);
expect(dims.frameWidth).toBeGreaterThan(dims.artWidth);
describe("buildPicturePropItem", () => {
it("stores the AI recipe on the furniture item", () => {
const item = buildPicturePropItem(demoAsset, "item-1", 100, 120);
expect(item.pictureAsset?.recipe.title).toBe("Retro Desk Figure");
expect(item.type).toBe("picture_prop");
expect(item.w).toBeGreaterThan(0);
expect(item.h).toBeGreaterThan(0);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/office/pictureModelGeneration", () => ({
MAX_PICTURE_MODEL_UPLOAD_BYTES: 8 * 1024 * 1024,
generatePictureModelFromImage: vi.fn().mockResolvedValue({
asset: {
accentColor: "#f59e0b",
aspectRatio: 1,
dominantColor: "#7c5c3b",
fileName: "demo.png",
imageDataUrl: "data:image/webp;base64,abc",
model: "gpt-4o-mini",
pixelHeight: 32,
pixelWidth: 32,
provider: "openai-compatible",
recipe: {
footprintMeters: {
depth: 0.6,
height: 1.2,
width: 0.72,
},
primitives: [
{
kind: "box",
material: {
color: "#7c5c3b",
metalness: 0.08,
roughness: 0.78,
},
position: [0, 0.4, 0],
size: [0.72, 0.8, 0.32],
},
],
summary: "Chunky desk sculpture.",
title: "Desk sculpture",
},
summary: "Chunky desk sculpture.",
},
}),
}));
const { POST } = await import("@/app/api/office/picture-model/route");
const { MAX_PICTURE_MODEL_UPLOAD_BYTES } = await import(
"@/lib/office/pictureModelGeneration"
);
function makeImageFile(byteLength: number, type = "image/png") {
return {
arrayBuffer: () => Promise.resolve(new ArrayBuffer(byteLength)),
name: "demo.png",
type,
};
}
function mockRequest(opts: {
contentLength?: string;
imageFile?: ReturnType<typeof makeImageFile> | null;
}): Request {
const headersMap = new Map<string, string>();
if (opts.contentLength !== undefined) {
headersMap.set("content-length", opts.contentLength);
}
const image = opts.imageFile ?? null;
const fakeFormData = {
get: (key: string) => (key === "image" ? image : null),
};
return {
headers: { get: (name: string) => headersMap.get(name) ?? null },
formData: () => Promise.resolve(fakeFormData),
} as unknown as Request;
}
describe("POST /api/office/picture-model", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 413 for obviously oversized uploads", async () => {
const request = mockRequest({
contentLength: String(MAX_PICTURE_MODEL_UPLOAD_BYTES + 4096),
imageFile: makeImageFile(1024),
});
const response = await POST(request);
expect(response.status).toBe(413);
});
it("returns 400 when the upload is missing", async () => {
const response = await POST(mockRequest({ imageFile: null }));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringMatching(/image file is required/i),
});
});
it("returns 400 for unsupported mime types", async () => {
const response = await POST(
mockRequest({ imageFile: makeImageFile(1024, "application/pdf") }),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringMatching(/only image uploads/i),
});
});
it("returns generated asset payload for valid uploads", async () => {
const response = await POST(
mockRequest({ imageFile: makeImageFile(2048, "image/png") }),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
asset: {
fileName: "demo.png",
pixelWidth: 32,
recipe: {
primitives: expect.any(Array),
title: "Desk sculpture",
},
},
});
});
});