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:
Binary file not shown.
BIN
Needle/MenuScene/dist/assets/MenuScene.glb
vendored
BIN
Needle/MenuScene/dist/assets/MenuScene.glb
vendored
Binary file not shown.
BIN
Needle/MenuScene/dist/include/dishes/quistibi_scan.glb
vendored
Normal file
BIN
Needle/MenuScene/dist/include/dishes/quistibi_scan.glb
vendored
Normal file
Binary file not shown.
BIN
Needle/MenuScene/dist/include/poster.webp
vendored
BIN
Needle/MenuScene/dist/include/poster.webp
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 40 KiB |
29
Needle/MenuScene/dist/index.html
vendored
29
Needle/MenuScene/dist/index.html
vendored
@@ -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>
|
||||
|
||||
BIN
Needle/MenuScene/dist/index.html.gz
vendored
BIN
Needle/MenuScene/dist/index.html.gz
vendored
Binary file not shown.
2
Needle/MenuScene/dist/needle-app.js
vendored
2
Needle/MenuScene/dist/needle-app.js
vendored
@@ -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');
|
||||
|
||||
2
Needle/MenuScene/dist/needle.buildinfo.json
vendored
2
Needle/MenuScene/dist/needle.buildinfo.json
vendored
File diff suppressed because one or more lines are too long
BIN
Needle/MenuScene/include/dishes/quistibi_scan.glb
Normal file
BIN
Needle/MenuScene/include/dishes/quistibi_scan.glb
Normal file
Binary file not shown.
Binary file not shown.
4
Needle/MenuScene/package-lock.json
generated
4
Needle/MenuScene/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
5
Needle/MenuScene/src/arIngredientsMenuButton.ts
Normal file
5
Needle/MenuScene/src/arIngredientsMenuButton.ts
Normal 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";
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
66
Needle/MenuScene/src/dishPlacement.ts
Normal file
66
Needle/MenuScene/src/dishPlacement.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
19
Needle/MenuScene/src/findMenuController.ts
Normal file
19
Needle/MenuScene/src/findMenuController.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
297
Needle/MenuScene/src/ingredientsPopup.ts
Normal file
297
Needle/MenuScene/src/ingredientsPopup.ts
Normal 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);
|
||||
});
|
||||
106
Needle/MenuScene/src/loadCatalogDishes.ts
Normal file
106
Needle/MenuScene/src/loadCatalogDishes.ts
Normal 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();
|
||||
}
|
||||
@@ -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";
|
||||
28
Needle/MenuScene/src/menuSkybox.ts
Normal file
28
Needle/MenuScene/src/menuSkybox.ts
Normal 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);
|
||||
});
|
||||
34
Needle/MenuScene/src/scripts/DishIngredientsTap.ts
Normal file
34
Needle/MenuScene/src/scripts/DishIngredientsTap.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user