Improve AR menu with multi-dish support, 3D ingredients, and shared sky HDRI.

Fix AR export/visibility per dish, load catalog GLBs at runtime, and replace per-dish HDR with one overcast sky environment for the whole menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pelpanagiotis
2026-06-23 20:42:20 +03:00
parent d632c583d4
commit beb8261554
37 changed files with 855 additions and 61 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -3,7 +3,7 @@
<head>
<link rel="modulepreload" as="script" href="./assets/three@0.169.19.js">
<link rel="modulepreload" as="script" href="./assets/three-examples@0.169.19.js">
<link rel="modulepreload" as="script" href="./assets/needle-engine@5.0.3.js">
<link rel="modulepreload" as="script" href="./assets/needle-engine@5.0.7.js">
<link rel="preload" as="fetch" fetchpriority="low" href="./assets/MenuScene.glb" type="model/gltf+json" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com">
@@ -20,9 +20,9 @@
<meta name="og:image:width" content="1080">
<meta name="og:image:height" content="1080">
<meta name="og:title" content="Menu Scene">
<meta name="generator" content="Unity 6000.4.3f1, Needle Engine Integration @5.0.3">
<meta name="needle-engine" content="5.0.3">
<meta name="needle:buildtime" content="2026-04-19T21:14:58.726Z">
<meta name="generator" content="Unity 6000.4.3f1, Needle Engine Integration @5.0.7">
<meta name="needle-engine" content="5.0.7">
<meta name="needle:buildtime" content="2026-06-23T10:34:28.095Z">
<meta charset="UTF-8" />
<link rel="icon" href="./assets/favicon.DtN_Fmav.ico">
@@ -37,36 +37,35 @@
<meta name="robots" content="index,follow">
<meta name="url" content="https://localhost:3000">
<script type="module" crossorigin src="./assets/index-dZODtGdh.js"></script>
<script type="module" crossorigin src="./assets/index-CCjWXS7N.js"></script>
<link rel="modulepreload" crossorigin href="./assets/rolldown-runtime.CYVBeYYp.js">
<link rel="modulepreload" crossorigin href="./assets/three@0.169.19.js">
<link rel="modulepreload" crossorigin href="./assets/materialx.CnHmG--Y.js">
<link rel="modulepreload" crossorigin href="./assets/three-examples@0.169.19.js">
<link rel="modulepreload" crossorigin href="./assets/gltf-progressive.BBJGcb3s.js">
<link rel="modulepreload" crossorigin href="./assets/three-mesh-ui.CgGzZ3ml.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine-ui.D3ZoIJG5.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine-webxr.DiiWSXeh.js">
<link rel="modulepreload" crossorigin href="./assets/three-quarks.DALdLWPD.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine-particles.Ce6eiXqY.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine-ui.B94mye74.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine-webxr.BmCsvEVe.js">
<link rel="modulepreload" crossorigin href="./assets/three-quarks.CKtEpqVy.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine-particles.BlGcRsIu.js">
<link rel="modulepreload" crossorigin href="./assets/postprocessing.Wy71DpiM.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine.extras.dI9yZHT7.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine.extras.B9pwA3ET.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine.extras.D6MD-rCg.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine.extras.CMyNTAvj.js">
<link rel="modulepreload" crossorigin href="./assets/three-mesh-bvh.B-oXEGo2.js">
<link rel="modulepreload" crossorigin href="./assets/needle-engine@5.0.3.js">
<link rel="stylesheet" crossorigin href="./assets/index.2XwOwFJz.css">
<link rel="modulepreload" crossorigin href="./assets/needle-engine@5.0.7.js">
<link rel="stylesheet" crossorigin href="./assets/index.a_pIm1gH.css">
</head>
<body>
<script>/* needle: fix for peerjs */ window.global = window; var parcelRequire;</script>
<needle-engine>
<needle-engine camera-controls="false">
<div id="asset-picker" class="ar desktop">
<div class="asset-picker__inner">
<button type="button" id="asset-picker-prev" aria-label="Previous model">Previous</button>
<span id="asset-picker-label" class="asset-picker__label"></span>
<span id="asset-picker-index" class="asset-picker__index" aria-live="polite"></span>
<button type="button" id="asset-picker-next" aria-label="Next model">Next</button>
<button type="button" id="asset-picker-ar" aria-label="Start augmented reality">View in AR</button>
</div>
</div>
</needle-engine>

Binary file not shown.

View File

@@ -53,7 +53,7 @@ if (!customElements.get(componentName)) {
while(this.basePath.endsWith('/')) {
this.basePath = this.basePath.slice(0, -1);
}
script.src = this.getAttribute('script-src') || `${this.basePath}/assets/index-dZODtGdh.js`;
script.src = this.getAttribute('script-src') || `${this.basePath}/assets/index-CCjWXS7N.js`;
this.shadowRoot.appendChild(script);
this.needleEngine = document.createElement('needle-engine');

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -8,7 +8,7 @@
"name": "my-needle-engine-project",
"version": "1.0.0",
"dependencies": {
"@needle-tools/engine": "5.0.3",
"@needle-tools/engine": "5.0.7",
"three": "npm:@needle-tools/three@0.169.19"
},
"devDependencies": {
@@ -755,7 +755,7 @@
}
},
"node_modules/@needle-tools/engine": {
"version": "5.0.3",
"version": "5.0.7",
"dependencies": {
"@dimforge/rapier3d-compat": "0.19.3",
"@needle-tools/gltf-progressive": "3.4.0-beta.3",

View File

@@ -10,7 +10,7 @@
"build:dev": "vite build"
},
"dependencies": {
"@needle-tools/engine": "5.0.3",
"@needle-tools/engine": "5.0.7",
"three": "npm:@needle-tools/three@0.169.19"
},
"devDependencies": {

View File

@@ -0,0 +1,5 @@
/**
* AR ingredients UI is initialized from `ingredientsPopup.ts` (spatial menu + dish tap).
* This file is kept so existing `main.ts` import order stays stable.
*/
import "./ingredientsPopup.js";

View File

@@ -1,4 +1,6 @@
import { NeedleXRSession, findObjectOfType } from "@needle-tools/engine";
import { NeedleXRSession } from "@needle-tools/engine";
import { findMenuController } from "./findMenuController.js";
import { whenCatalogDishesReady } from "./loadCatalogDishes.js";
import type { MenuController } from "./scripts/MenuController.js";
/**
@@ -54,7 +56,7 @@ function initAssetPicker(): void {
const bindMenuController = async (): Promise<void> => {
try {
const ctx = await needle.getContext();
menuController = findObjectOfType(MenuController, ctx);
menuController = findMenuController(ctx);
} catch {
menuController = null;
}
@@ -79,6 +81,9 @@ function initAssetPicker(): void {
syncUi();
try {
const ctx = await needle.getContext();
await whenCatalogDishesReady();
menuController = findMenuController(ctx);
menuController?.prepareForAR();
await NeedleXRSession.start("immersive-ar", undefined, ctx);
} catch (err) {
console.warn("[assetPicker] Failed to start AR session:", err);
@@ -110,6 +115,7 @@ function initAssetPicker(): void {
});
needle.addEventListener("loadfinished", () => void bindMenuController());
needle.addEventListener("menu-dishes-updated", () => void bindMenuController());
whenDomReady(() => {
requestAnimationFrame(() => void bindMenuController());

View File

@@ -1,6 +1,6 @@
import { findObjectOfType } from "@needle-tools/engine";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { Object3D } from "three";
import { findMenuController } from "./findMenuController.js";
import type { MenuController } from "./scripts/MenuController.js";
/**
@@ -26,13 +26,16 @@ function resolveIncludeUrl(relative: string): string {
return new URL(relative, new URL(normalized, window.location.href)).href;
}
async function ensureDishMeshes(menu: MenuController): Promise<void> {
export async function ensureDishMeshes(
menu: MenuController,
): Promise<void> {
const loader = new GLTFLoader();
for (const dish of menu.dishes) {
if (!dish) continue;
const rel = DISH_GLBS[dish.name];
if (!rel) continue;
if (countRenderableMeshes(dish) > 0) continue;
if (dish.children.some((child) => child.name === `${dish.name}_mesh`)) continue;
const url = resolveIncludeUrl(rel);
const gltf = await loader.loadAsync(url);
@@ -48,7 +51,7 @@ function initDishMeshFallback(): void {
const run = async (): Promise<void> => {
try {
const ctx = await needle.getContext();
const menu = findObjectOfType(MenuController, ctx);
const menu = findMenuController(ctx);
if (menu) await ensureDishMeshes(menu);
} catch (err) {
console.warn("[dishMeshFallback] Could not inject dish meshes:", err);
@@ -56,6 +59,7 @@ function initDishMeshFallback(): void {
};
needle.addEventListener("loadfinished", () => void run());
needle.addEventListener("menu-dishes-updated", () => void run());
}
initDishMeshFallback();

View File

@@ -0,0 +1,66 @@
import { Object3D, Vector3 } from "three";
/**
* Shared anchor for every dish under the Dishes object — matches exported pizza in MenuScene.glb.
* Unity local (+0.48022, 0, 0.48978) becomes glTF/Three.js X negated on export.
*/
const SHARED_DISH_POSITION = new Vector3(-0.48022, 0, 0.48978);
type DishPlacement = {
position?: Vector3;
/** Euler radians (x, y, z) on the dish wrapper. Omit for (0, 0, 0). */
rotation?: Vector3;
scale?: Vector3;
};
/**
* Per-dish scene overrides under Dishes (from MenuScene.unity).
* Sketchfab scans already bake axis conversion in the GLB — wrapper rotation stays at 0.
*/
const DISH_PLACEMENTS: Record<string, DishPlacement> = {
quistibi_scan: {
rotation: new Vector3(0, 0, 0),
scale: new Vector3(1.1699198, 1.1699204, 1.1699204),
},
};
/**
* Keep orientation from the GLB file but move scale/offset to the scene wrapper (like Unity import).
*/
function prepareImportedSceneRoot(gltfScene: Object3D): void {
gltfScene.position.set(0, 0, 0);
gltfScene.scale.set(1, 1, 1);
gltfScene.updateMatrix();
}
/**
* Wrap a catalog-loaded GLB scene with a dish root that mirrors Unity's prefab instance transform.
*/
export function wrapCatalogDishRoot(
gltfScene: Object3D,
dishName: string,
): Object3D {
const placement = DISH_PLACEMENTS[dishName];
prepareImportedSceneRoot(gltfScene);
const wrapper = new Object3D();
wrapper.name = dishName;
wrapper.position.copy(placement?.position ?? SHARED_DISH_POSITION);
const rot = placement?.rotation ?? new Vector3(0, 0, 0);
wrapper.rotation.set(rot.x, rot.y, rot.z, "XYZ");
wrapper.scale.copy(placement?.scale ?? new Vector3(1, 1, 1));
wrapper.add(gltfScene);
wrapper.updateMatrixWorld(true);
return wrapper;
}
export function ensureDishVisible(dish: Object3D, active: boolean): void {
dish.visible = active;
dish.traverse((child) => {
child.visible = active;
});
}

View File

@@ -10,10 +10,14 @@ onStart((context) => {
let webxr = context.scene.getComponentInChildren(WebXR);
if (!webxr) {
webxr = context.scene.addComponent(WebXR);
webxr.createARButton = true;
webxr.createVRButton = true;
}
// Unity-exported WebXR often serializes these off; only the `if (!webxr)` branch used to set them,
// which hid Enter AR / Enter VR / Send to Quest in NeedleMenu for exported scenes.
webxr.createARButton = true;
webxr.createVRButton = true;
webxr.createSendToQuestButton = true;
webxr.autoPlace = true;
webxr.autoCenter = true;
webxr.arScale = 1;

View File

@@ -0,0 +1,19 @@
import { findObjectOfType, TypeStore } from "@needle-tools/engine";
import type { Object3D } from "three";
import type { MenuController } from "./scripts/MenuController.js";
/**
* Resolve MenuController at runtime via TypeStore (registered in generated/register_types.ts).
* Avoids passing the class constructor from modules that may load before registration.
*/
export function findMenuController(
context: { scene: Object3D },
): MenuController | null {
const ctor = TypeStore.get("MenuController");
if (!ctor) return null;
return (
findObjectOfType(ctor, context.scene) ??
findObjectOfType(ctor, context as never)
);
}

View File

@@ -19,9 +19,9 @@ document.addEventListener("DOMContentLoaded", () =>
const needleEngine = document.querySelector("needle-engine");
if(needleEngine && needleEngine.getAttribute("src") === null)
{
needleEngine.setAttribute("hash", "1776799769658");
needleEngine.setAttribute("hash", "1782211605003");
needleEngine.setAttribute("src", JSON.stringify(needle_exported_files));
}
});
console.log("Made\ with\ ♥\ by\ 🌵\ Needle\ -\ https://needle\.tools\ —\ Version\ 5\.0\.3");
console.log("Made\ with\ ♥\ by\ 🌵\ Needle\ -\ https://needle\.tools\ —\ Version\ 5\.0\.7");

View File

@@ -5,7 +5,7 @@
"deployOnly": false,
"needleEditor": null,
"gzip": true,
"generator": "Unity 6000.4.3f1, Needle Engine Integration @5.0.3",
"generator": "Unity 6000.4.3f1, Needle Engine Integration @5.0.7",
"license": {
"team": "org_vl8u9dsjhx6b"
},

View File

@@ -3,12 +3,14 @@ import { TypeStore } from "@needle-tools/engine"
// Import types
import { ARObjectController } from "../scripts/ARObjectController.js";
import { DishIngredientsTap } from "../scripts/DishIngredientsTap.js";
import { MenuController } from "../scripts/MenuController.js";
import { PostProcessingVolumeController } from "../scripts/PostProcessingVolumeController.js";
// Register types
export function registerTypes() {
TypeStore.add("ARObjectController", ARObjectController);
TypeStore.add("DishIngredientsTap", DishIngredientsTap);
TypeStore.add("MenuController", MenuController);
TypeStore.add("PostProcessingVolumeController", PostProcessingVolumeController);
}

View File

@@ -0,0 +1,297 @@
import { GameObject, onBeforeRender, onStart } from "@needle-tools/engine";
import type { Context } from "@needle-tools/engine";
import {
Box3,
CanvasTexture,
DoubleSide,
Mesh,
MeshBasicMaterial,
Object3D,
PlaneGeometry,
Vector3,
} from "three";
import { findMenuController } from "./findMenuController.js";
import type { MenuController } from "./scripts/MenuController.js";
import { DishIngredientsTap } from "./scripts/DishIngredientsTap.js";
/** Dish root names → ingredient lines (extend when adding dishes). */
export const INGREDIENTS_BY_DISH_NAME: Record<string, string[]> = {
"cc0_-_pizza_salami": ["Tomato sauce", "Mozzarella", "Salami", "Oregano", "Olive oil"],
};
const PLANE_WORLD_WIDTH = 0.28;
const PLANE_OFFSET_Y = 0.08;
const TEX_W = 512;
const TEX_H = 384;
let arSessionActive = false;
let ingredientsMenuButton: HTMLElement | null = null;
let markerMesh: Mesh | null = null;
let markerDishName: string | null = null;
let markerDishRoot: Object3D | null = null;
const _box = new Box3();
const _anchor = new Vector3();
const _viewPos = new Vector3();
/** World position of the camera the user is viewing through (XR eye in AR, main camera otherwise). */
function getViewCameraWorldPosition(context: Context, out: Vector3): boolean {
const xrCam = context.xrCamera;
if (context.isInXR && xrCam) {
const eye = xrCam.cameras.length > 0 ? xrCam.cameras[0] : xrCam;
eye.getWorldPosition(out);
return true;
}
const cam = context.mainCamera;
if (!cam) return false;
cam.getWorldPosition(out);
return true;
}
export function isArSessionActive(context: Context): boolean {
if (arSessionActive) return true;
const xr = context.xr as { running?: boolean; mode?: string } | undefined;
return xr?.running === true && xr?.mode === "immersive-ar";
}
function roundedRect(
c: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
const rr = Math.min(r, w / 2, h / 2);
c.moveTo(x + rr, y);
c.arcTo(x + w, y, x + w, y + h, rr);
c.arcTo(x + w, y + h, x, y + h, rr);
c.arcTo(x, y + h, x, y, rr);
c.arcTo(x, y, x + w, y, rr);
c.closePath();
}
function drawIngredientsTexture(
dishName: string,
ingredients: string[],
): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = TEX_W;
canvas.height = TEX_H;
const g = canvas.getContext("2d")!;
const pad = 28;
const corner = 20;
const innerW = TEX_W - pad * 2;
const innerH = TEX_H - pad * 2;
g.fillStyle = "rgba(18, 18, 24, 0.92)";
g.beginPath();
roundedRect(g, pad, pad, innerW, innerH, corner);
g.fill();
g.strokeStyle = "rgba(255, 255, 255, 0.12)";
g.lineWidth = 2;
g.stroke();
const title = dishName.replace(/_/g, " ");
g.fillStyle = "#f2f2f7";
g.font = "bold 28px system-ui, Segoe UI, sans-serif";
g.textAlign = "left";
g.fillText("Ingredients", pad + 18, pad + 48);
g.font = "600 22px system-ui, Segoe UI, sans-serif";
g.fillStyle = "rgba(242, 242, 247, 0.92)";
g.fillText(title, pad + 18, pad + 88);
g.font = "22px system-ui, Segoe UI, sans-serif";
g.fillStyle = "rgba(242, 242, 247, 0.88)";
let y = pad + 130;
if (ingredients.length > 0) {
for (const line of ingredients) {
g.fillText(`${line}`, pad + 26, y);
y += 34;
}
} else {
g.fillStyle = "rgba(242, 242, 247, 0.7)";
g.fillText("No ingredients listed yet.", pad + 26, y);
}
return canvas;
}
function disposeMarker(): void {
if (!markerMesh) return;
markerMesh.removeFromParent();
markerMesh.geometry.dispose();
const mat = markerMesh.material as MeshBasicMaterial;
if (mat.map) mat.map.dispose();
mat.dispose();
markerMesh = null;
markerDishName = null;
markerDishRoot = null;
}
function computeAnchorAboveDish(dishRoot: Object3D, liftAboveTop: number): void {
_box.setFromObject(dishRoot);
if (_box.isEmpty()) {
dishRoot.getWorldPosition(_anchor);
_anchor.y += liftAboveTop;
} else {
_anchor.set(
(_box.min.x + _box.max.x) / 2,
_box.max.y + liftAboveTop,
(_box.min.z + _box.max.z) / 2,
);
}
}
function updateMarkerInWorld(context: Context): void {
if (!markerMesh || !markerDishRoot) return;
computeAnchorAboveDish(markerDishRoot, PLANE_OFFSET_Y);
markerMesh.position.copy(_anchor);
if (getViewCameraWorldPosition(context, _viewPos)) {
markerMesh.lookAt(_viewPos);
}
}
function resolveDishRoot(menu: MenuController, dishName: string): Object3D | null {
return menu.dishes.find((d) => d?.name === dishName) ?? null;
}
export function hideIngredientsPanel(): void {
disposeMarker();
}
export function showIngredientsPanel(context: Context, dishName: string): void {
if (!isArSessionActive(context)) return;
const menu = findMenuController(context);
if (!menu) return;
const dishRoot = resolveDishRoot(menu, dishName);
if (!dishRoot) return;
const ingredients = INGREDIENTS_BY_DISH_NAME[dishName] ?? [];
disposeMarker();
const canvas = drawIngredientsTexture(dishName, ingredients);
const map = new CanvasTexture(canvas);
map.needsUpdate = true;
const aspect = TEX_W / TEX_H;
const geo = new PlaneGeometry(PLANE_WORLD_WIDTH, PLANE_WORLD_WIDTH / aspect);
const mat = new MeshBasicMaterial({
map,
transparent: true,
side: DoubleSide,
depthWrite: false,
});
markerMesh = new Mesh(geo, mat);
markerMesh.name = "IngredientsInfoPlane";
markerMesh.renderOrder = 10;
markerMesh.frustumCulled = false;
context.scene.add(markerMesh);
markerDishName = dishName;
markerDishRoot = dishRoot;
updateMarkerInWorld(context);
}
export function toggleIngredientsPanel(context: Context, dishName: string): void {
if (!isArSessionActive(context)) return;
if (markerMesh && markerDishName === dishName) {
hideIngredientsPanel();
return;
}
showIngredientsPanel(context, dishName);
}
function showIngredientsForActiveDish(context: Context): void {
const menu = findMenuController(context);
if (!menu) return;
const dish = menu.dishes[menu.selectedDishIndex];
if (!dish) return;
toggleIngredientsPanel(context, dish.name);
}
function removeIngredientsMenuButton(): void {
ingredientsMenuButton?.remove();
ingredientsMenuButton = null;
}
function addIngredientsMenuButton(context: Context): void {
if (ingredientsMenuButton) return;
ingredientsMenuButton = context.menu.appendChild({
label: "Ingredients",
icon: "restaurant",
title: "Show ingredients above the dish in AR",
priority: 6,
onClick: () => showIngredientsForActiveDish(context),
}) as HTMLElement;
}
export function attachDishTapHandlers(menu: MenuController): void {
for (const dish of menu.dishes) {
if (!dish) continue;
if (!GameObject.getComponent(dish, DishIngredientsTap)) {
dish.addComponent(DishIngredientsTap);
}
dish.traverse((child) => {
const mesh = child as { isMesh?: boolean };
if (!mesh.isMesh) return;
if (!GameObject.getComponent(child, DishIngredientsTap)) {
child.addComponent(DishIngredientsTap);
}
});
}
}
function bindArSessionUi(context: Context): void {
const needle = document.querySelector("needle-engine");
if (!needle || needle.hasAttribute("data-ingredients-ar-bound")) return;
needle.setAttribute("data-ingredients-ar-bound", "true");
needle.addEventListener("enter-ar", () => {
arSessionActive = true;
addIngredientsMenuButton(context);
});
needle.addEventListener("exit-ar", () => {
arSessionActive = false;
hideIngredientsPanel();
removeIngredientsMenuButton();
});
}
function tryAttachTapHandlers(context: Context): void {
const menu = findMenuController(context);
if (menu) attachDishTapHandlers(menu);
}
function initIngredientsArUi(context: Context): void {
context.menu.showSpatialMenu(true);
bindArSessionUi(context);
const needle = document.querySelector("needle-engine");
needle?.addEventListener("loadfinished", () => tryAttachTapHandlers(context));
needle?.addEventListener("menu-dishes-updated", () => tryAttachTapHandlers(context));
tryAttachTapHandlers(context);
}
onStart((context) => {
initIngredientsArUi(context);
});
// After LateUpdate so the WebXR rig + xrCamera reflect the current head pose.
onBeforeRender((context) => {
if (!isArSessionActive(context) || !markerMesh) return;
updateMarkerInWorld(context);
});

View File

@@ -0,0 +1,106 @@
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { Object3D } from "three";
import { findMenuController } from "./findMenuController.js";
import { wrapCatalogDishRoot } from "./dishPlacement.js";
import { ensureDishMeshes } from "./dishMeshFallback.js";
/**
* Dish roots expected in the menu but missing from the exported MenuScene.glb.
* Keys must match Unity GameObject names. Re-export from Unity to embed meshes instead.
*/
export const CATALOG_DISH_GLBS: Record<string, string> = {
quistibi_scan: "include/dishes/quistibi_scan.glb",
};
function resolveIncludeUrl(relative: string): string {
const base = import.meta.env.BASE_URL ?? "./";
const normalized = base.endsWith("/") ? base : `${base}/`;
return new URL(relative, new URL(normalized, window.location.href)).href;
}
function notifyDishesUpdated(): void {
document.querySelector("needle-engine")?.dispatchEvent(
new CustomEvent("menu-dishes-updated"),
);
}
function findExistingDishRoot(
menu: NonNullable<ReturnType<typeof findMenuController>>,
dishName: string,
): Object3D | undefined {
const registered = menu.dishes.find((d) => d?.name === dishName);
if (registered) return registered;
return menu.gameObject.children.find((child) => child.name === dishName);
}
async function loadCatalogDishesForMenu(
menu: NonNullable<ReturnType<typeof findMenuController>>,
): Promise<number> {
const loader = new GLTFLoader();
let added = 0;
for (const [dishName, relativePath] of Object.entries(CATALOG_DISH_GLBS)) {
if (findExistingDishRoot(menu, dishName)) continue;
try {
const gltf = await loader.loadAsync(resolveIncludeUrl(relativePath));
if (findExistingDishRoot(menu, dishName)) continue;
const root = wrapCatalogDishRoot(gltf.scene, dishName);
menu.gameObject.add(root);
menu.registerDishRoot(root);
added += 1;
console.log(`[loadCatalogDishes] Loaded catalog dish: ${dishName}`);
} catch (err) {
console.warn(
`[loadCatalogDishes] Could not load "${dishName}" from ${relativePath}. ` +
"Copy the GLB to Needle/MenuScene/include/dishes/ or re-export from Unity.",
err,
);
}
}
if (added === 0 && menu.getDishSlotCount() <= 1) {
console.warn(
"[loadCatalogDishes] Menu has only one dish slot. In Unity: assign all dishes on MenuController, " +
"keep them active, then re-export MenuScene.glb.",
);
}
return added;
}
let catalogLoadPromise: Promise<void> | null = null;
function initCatalogDishLoader(): void {
const needle = document.querySelector("needle-engine");
if (!needle) return;
const run = (): Promise<void> => {
if (catalogLoadPromise) return catalogLoadPromise;
catalogLoadPromise = (async () => {
try {
const ctx = await needle.getContext();
const menu = findMenuController(ctx);
if (!menu) return;
const added = await loadCatalogDishesForMenu(menu);
await ensureDishMeshes(menu);
if (added > 0) notifyDishesUpdated();
} catch (err) {
console.warn("[loadCatalogDishes] Failed:", err);
}
})();
return catalogLoadPromise;
};
needle.addEventListener("loadfinished", () => void run());
}
initCatalogDishLoader();
/** Wait until catalog dish loading has finished (no-op if already done). */
export function whenCatalogDishesReady(): Promise<void> {
return catalogLoadPromise ?? Promise.resolve();
}

View File

@@ -1,5 +1,9 @@
import("./generated/register_types.ts");
import("@needle-tools/engine") /* async import of needle engine */;
import "./disableCameraControls";
import "./menuSkybox";
import "./dishMeshFallback";
import "./enableXR";
import "./ingredientsPopup";
import "./loadCatalogDishes";
import "./assetPicker";

View File

@@ -0,0 +1,28 @@
import { loadPMREM, onStart, type Context } from "@needle-tools/engine";
const NEEDLE_HDRI_BASE = "https://cdn.needle.tools/static/hdris";
/**
* Single outdoor sky HDRI for the whole menu (FastHDR .ktx2 on Needle CDN).
* "Overcast Soil Puresky" — soft overcast sky, neutral lighting for all dishes.
* @see https://cloud.needle.tools/hdris
*/
export const MENU_SKYBOX_URL = `${NEEDLE_HDRI_BASE}/overcast_soil_puresky_2k.pmrem.ktx2`;
let applied = false;
/** Apply shared sky background + image-based lighting once for the menu scene. */
export async function applyMenuSkybox(context: Context): Promise<void> {
if (applied) return;
const tex = await loadPMREM(MENU_SKYBOX_URL, context.renderer);
if (!tex) return;
applied = true;
context.scene.background = tex;
context.scene.environment = tex;
}
onStart((context) => {
void applyMenuSkybox(context);
});

View File

@@ -0,0 +1,34 @@
import { Behaviour } from "@needle-tools/engine";
import type { PointerEventData } from "@needle-tools/engine";
import { findMenuController } from "../findMenuController.js";
import { isArSessionActive, toggleIngredientsPanel } from "../ingredientsPopup.js";
import type { Object3D } from "three";
/**
* On mesh tap in AR, open the ingredients panel for the parent dish root.
*/
export class DishIngredientsTap extends Behaviour {
onPointerClick(_args: PointerEventData): void {
if (!isArSessionActive(this.context)) return;
const dishRoot = this.resolveDishRoot();
if (!dishRoot) return;
toggleIngredientsPanel(this.context, dishRoot.name);
}
private resolveDishRoot(): Object3D | null {
const menu = findMenuController(this.context);
if (menu) {
for (const dish of menu.dishes) {
if (!dish) continue;
let node: Object3D | null = this.gameObject;
while (node) {
if (node === dish) return dish;
node = node.parent;
}
}
}
return this.gameObject;
}
}

View File

@@ -2,11 +2,14 @@ import {
Behaviour,
DeviceUtilities,
GameObject,
findObjectOfType,
Renderer,
serializable,
USDZExporter,
type NeedleXREventArgs,
} from "@needle-tools/engine";
import { Object3D } from "three";
import { ensureDishVisible } from "../dishPlacement.js";
const dishBaseY = new WeakMap<Object3D, number>();
@@ -48,7 +51,13 @@ export class MenuController extends Behaviour {
const params = new URLSearchParams(window.location.search);
this.dishName = params.get("dishName") ?? "";
if (this.webXROrigin) this.usdzExporter = this.webXROrigin.getComponent(USDZExporter) ?? undefined;
if (this.webXROrigin) {
this.usdzExporter = this.webXROrigin.getComponent(USDZExporter) ?? undefined;
}
if (!this.usdzExporter) {
this.usdzExporter =
findObjectOfType(USDZExporter, this.context) ?? undefined;
}
if (this.dishName) {
let matched = false;
@@ -66,6 +75,7 @@ export class MenuController extends Behaviour {
this.restoreDishBaseY(dish);
}
GameObject.setActive(dish, on);
ensureDishVisible(dish, on);
});
if (!matched) {
this.ensureOnlySelectedDishVisible();
@@ -94,6 +104,7 @@ export class MenuController extends Behaviour {
if (args.xr.mode === "immersive-ar") {
this.arSessionBobPaused = true;
this.snapActiveDishToBaseY();
this.prepareForAR();
}
}
@@ -243,6 +254,22 @@ export class MenuController extends Behaviour {
document.addEventListener("touchend", onTouchEnd, { passive: false });
}
private setDishActive(dish: Object3D | null | undefined, active: boolean): void {
if (!dish) return;
GameObject.setActive(dish, active);
ensureDishVisible(dish, active);
dish.traverse((child) => {
const renderer = GameObject.getComponent(child, Renderer);
if (renderer) renderer.enabled = active;
});
}
/** Call before / during AR so only the selected dish is exported and rendered. */
prepareForAR(): void {
this.ensureOnlySelectedDishVisible();
this.updateUSDZExporterTarget();
}
private getValidDishIndices(): number[] {
return this.dishes.map((dish, index) => (dish != null ? index : -1)).filter((index) => index >= 0);
}
@@ -264,7 +291,7 @@ export class MenuController extends Behaviour {
if (!active) {
this.restoreDishBaseY(d);
}
GameObject.setActive(this.dishes[i], active);
this.setDishActive(this.dishes[i], active);
});
}
@@ -285,6 +312,15 @@ export class MenuController extends Behaviour {
return this.getValidDishIndices().length;
}
/** Register a dish root loaded at runtime (e.g. catalog GLB) and refresh visibility. */
registerDishRoot(dish: Object3D): void {
const already = this.dishes.some((d) => d === dish || d?.name === dish.name);
if (already) return;
this.dishes.push(dish);
this.ensureOnlySelectedDishVisible();
this.updateUSDZExporterTarget();
}
/** Label for the asset picker (object name from Unity when set, else Dish i / n). */
getPickerLabel(): string {
const valid = this.getValidDishIndices();
@@ -306,12 +342,12 @@ export class MenuController extends Behaviour {
if (pos < 0) pos = 0;
this.restoreDishBaseY(this.dishes[valid[pos]]);
GameObject.setActive(this.dishes[valid[pos]], false);
this.setDishActive(this.dishes[valid[pos]], false);
pos = (pos - 1 + valid.length) % valid.length;
this.selectedDishIndex = valid[pos];
GameObject.setActive(this.dishes[this.selectedDishIndex], true);
this.setDishActive(this.dishes[this.selectedDishIndex], true);
this.updateUSDZExporterTarget();
this.prepareForAR();
}
selectNextDish(): void {
@@ -322,17 +358,23 @@ export class MenuController extends Behaviour {
if (pos < 0) pos = 0;
this.restoreDishBaseY(this.dishes[valid[pos]]);
GameObject.setActive(this.dishes[valid[pos]], false);
this.setDishActive(this.dishes[valid[pos]], false);
pos = (pos + 1) % valid.length;
this.selectedDishIndex = valid[pos];
GameObject.setActive(this.dishes[this.selectedDishIndex], true);
this.setDishActive(this.dishes[this.selectedDishIndex], true);
this.updateUSDZExporterTarget();
this.prepareForAR();
}
private updateUSDZExporterTarget(): void {
const dish = this.dishes[this.selectedDishIndex];
if (this.usdzExporter && dish) {
if (!dish) return;
if (!this.usdzExporter) {
this.usdzExporter =
findObjectOfType(USDZExporter, this.context) ?? undefined;
}
if (this.usdzExporter) {
this.usdzExporter.objectToExport = dish;
}
}

View File

@@ -10,10 +10,14 @@ onStart((context) => {
let webxr = context.scene.getComponentInChildren(WebXR);
if (!webxr) {
webxr = context.scene.addComponent(WebXR);
webxr.createARButton = true;
webxr.createVRButton = true;
}
// Unity-exported WebXR often serializes these off; only the `if (!webxr)` branch used to set them,
// which hid Enter AR / Enter VR / Send to Quest in NeedleMenu for exported scenes.
webxr.createARButton = true;
webxr.createVRButton = true;
webxr.createSendToQuestButton = true;
webxr.autoPlace = true;
webxr.autoCenter = true;
webxr.arScale = 1;