Compare commits

...

2 Commits

Author SHA1 Message Date
pelpanagiotis
beb8261554 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>
2026-06-23 20:42:20 +03:00
pelpanagiotis
d632c583d4 Add deployment-note.txt on new branch 2026-04-27 23:41:24 +03:00
38 changed files with 856 additions and 61 deletions

View File

@@ -53,6 +53,7 @@
<Compile Include="Assets\TutorialInfo\Scripts\Readme.cs" />
<Compile Include="Assets\Needle\Components.codegen\PostProcessingVolumeController.cs" />
<Compile Include="Assets\Needle\Components.codegen\ARObjectController.cs" />
<Compile Include="Assets\Needle\Components.codegen\DishIngredientsTap.cs" />
</ItemGroup>
<ItemGroup>
<Reference Include="UnityEngine">

View File

@@ -0,0 +1,14 @@
// NEEDLE_CODEGEN_START
// auto generated code - do not edit directly
#pragma warning disable
namespace Needle.Typescript.GeneratedComponents
{
public partial class DishIngredientsTap : UnityEngine.MonoBehaviour
{
public void onPointerClick(UnityEngine.EventSystems.PointerEventData @_args) {}
}
}
// NEEDLE_CODEGEN_END

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 53682507d1bf626e741afef604c0b9f0

View File

@@ -7,16 +7,12 @@ namespace Needle.Typescript.GeneratedComponents
{
public partial class MenuController : UnityEngine.MonoBehaviour
{
public bool @isMobile = false;
public bool @isDesktop = false;
public bool @isXR = false;
public UnityEngine.Transform[] @dishes;
public UnityEngine.Transform @webXROrigin;
[UnityEngine.Tooltip("Local-space vertical bob amplitude (meters). Set to 0 to disable.")]
[UnityEngine.Tooltip("Local-space vertical bob amplitude (meters). Set to 0 to disable. Peak-to-peak motion is 2× this value; 0.004 ≈ 8 mm total — subtle but visible.")]
public float @dishBobAmplitude = 0.004f;
[UnityEngine.Tooltip("Bob angular speed (radians per second).")]
public float @dishBobSpeed = 1f;
public float @selectedDishIndex = 0f;
public void OnEnable() {}
public void onEnterXR(object @args) {}
public void onLeaveXR(object @_args) {}
@@ -26,7 +22,9 @@ namespace Needle.Typescript.GeneratedComponents
public void setupMobileControls() {}
public void setupDesktopControls() {}
public void createMenuMobileControls() {}
public void prepareForAR() {}
public float getDishSlotCount() { return default; }
public void registerDishRoot(UnityEngine.Transform @dish) {}
public string getPickerLabel() { return default; }
public void selectPreviousDish() {}
public void selectNextDish() {}

Binary file not shown.

View File

@@ -0,0 +1,73 @@
fileFormatVersion: 2
guid: 776567fc7344c7b408f89845795466e3
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: 804e1ce4c496647cfa3f1a1134187c71, type: 3}
_removeEmptyRootObjects: 1
_scaleFactor: 1
_deduplicateResources: 0
_deduplicatedStatistics:
meshCountBefore: 0
meshCountAfter: 0
textureCountBefore: 0
textureCountAfter: 0
_maximumLod: 300
_readWriteEnabled: 1
_generateColliders: 0
_addColliders: 0
_swapUvs: 0
_generateLightmapUVs: 0
_importBlendShapeNames: 1
_blendShapeFrameWeight:
_option: 0
_multiplier: 1
_importNormals: 0
_importTangents: 0
_importCamera: 2
_importAnimations: 2
_mecanimHumanoidFlip: 0
_addAnimatorComponent: 0
_animationLoopTime: 1
_animationLoopPose: 0
_importMaterials: 1
_enableGpuInstancing: 0
_texturesReadWriteEnabled: 1
_generateMipMaps: 1
_useSceneNameIdentifier: 1
_textureCompression: -50
_gltfAsset: '<b>Generator: </b>Sketchfab-16.95.0
<b>Version: </b>2.0
<b>Extras:
</b>
'
optimizeGameObjects: 0
materials:
- {instanceID: 0}
textures:
- {instanceID: 0}
- {instanceID: 0}
hasSceneData: 1
hasAnimationData: 0
hasMaterialData: 1
hasTextureData: 1
animations: []
_extensions: []
_textures:
- texture: {instanceID: 0}
shouldBeLinear: 0
shouldBeNormalMap: 0
- texture: {instanceID: 0}
shouldBeLinear: 1
shouldBeNormalMap: 1
_mainAssetIdentifier: scenes/Sketchfab_Scene
_importPlugins: []

View File

@@ -571,6 +571,84 @@ Transform:
m_CorrespondingSourceObject: {fileID: 8897093224900880590, guid: 82b3eac92005fee47a7a02b0e77a8231, type: 3}
m_PrefabInstance: {fileID: 1684256003}
m_PrefabAsset: {fileID: 0}
--- !u!1001 &1784117311
PrefabInstance:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Modification:
serializedVersion: 3
m_TransformParent: {fileID: 1792418156}
m_Modifications:
- target: {fileID: 8128668236925612103, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_Name
value: quistibi_scan
objectReference: {fileID: 0}
- target: {fileID: 8128668236925612103, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_IsActive
value: 1
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalScale.x
value: 1.1699198
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalScale.y
value: 1.1699204
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalScale.z
value: 1.1699204
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalPosition.x
value: 0.476
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalPosition.y
value: -0.002
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalPosition.z
value: 0.464
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalRotation.w
value: 0.7071068
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalRotation.x
value: -0.7071068
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalRotation.y
value: -0
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalRotation.z
value: -0
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalEulerAnglesHint.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
m_AddedComponents: []
m_SourcePrefab: {fileID: -5645937942226959685, guid: 776567fc7344c7b408f89845795466e3, type: 3}
--- !u!4 &1784117312 stripped
Transform:
m_CorrespondingSourceObject: {fileID: 8897093224900880590, guid: 776567fc7344c7b408f89845795466e3, type: 3}
m_PrefabInstance: {fileID: 1784117311}
m_PrefabAsset: {fileID: 0}
--- !u!1 &1792418155
GameObject:
m_ObjectHideFlags: 0
@@ -602,6 +680,7 @@ Transform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1684256004}
- {fileID: 1784117312}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1792418157
@@ -621,7 +700,10 @@ MonoBehaviour:
isXR: 0
dishes:
- {fileID: 1684256004}
- {fileID: 1784117312}
webXROrigin: {fileID: 0}
dishBobAmplitude: 0.004
dishBobSpeed: 1
selectedDishIndex: 0
--- !u!1660057539 &9223372036854775807
SceneRoots:

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,9 +10,13 @@ onStart((context) => {
let webxr = context.scene.getComponentInChildren(WebXR);
if (!webxr) {
webxr = context.scene.addComponent(WebXR);
}
// 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;

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,9 +10,13 @@ onStart((context) => {
let webxr = context.scene.getComponentInChildren(WebXR);
if (!webxr) {
webxr = context.scene.addComponent(WebXR);
}
// 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;

View File

@@ -1,5 +1,6 @@
{
"dependencies": {
"com.needle.engine-exporter": "5.0.7",
"com.unity.ai.navigation": "2.0.12",
"com.unity.collab-proxy": "2.12.4",
"com.unity.ide.rider": "3.0.39",
@@ -11,6 +12,7 @@
"com.unity.timeline": "1.8.12",
"com.unity.ugui": "2.0.0",
"com.unity.visualscripting": "1.9.11",
"org.khronos.unitygltf": "2.19.5",
"com.unity.modules.accessibility": "1.0.0",
"com.unity.modules.adaptiveperformance": "1.0.0",
"com.unity.modules.ai": "1.0.0",
@@ -44,8 +46,7 @@
"com.unity.modules.video": "1.0.0",
"com.unity.modules.vr": "1.0.0",
"com.unity.modules.wind": "1.0.0",
"com.unity.modules.xr": "1.0.0",
"com.needle.engine-exporter": "5.0.3"
"com.unity.modules.xr": "1.0.0"
},
"scopedRegistries": [
{

View File

@@ -1,11 +1,11 @@
{
"dependencies": {
"com.needle.engine-exporter": {
"version": "5.0.3",
"version": "5.0.7",
"depth": 0,
"source": "registry",
"dependencies": {
"org.khronos.unitygltf": "2.19.2",
"org.khronos.unitygltf": "2.19.5",
"com.unity.modules.animation": "1.0.0",
"com.unity.modules.unitywebrequest": "1.0.0",
"com.unity.modules.uielements": "1.0.0",
@@ -69,7 +69,7 @@
},
"com.unity.collections": {
"version": "6.4.0",
"depth": 2,
"depth": 1,
"source": "builtin",
"dependencies": {
"com.unity.burst": "1.8.23",
@@ -114,7 +114,7 @@
},
"com.unity.mathematics": {
"version": "1.3.3",
"depth": 2,
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
@@ -139,7 +139,7 @@
},
"com.unity.nuget.mono-cecil": {
"version": "1.11.6",
"depth": 3,
"depth": 2,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
@@ -217,7 +217,7 @@
},
"com.unity.test-framework.performance": {
"version": "3.4.0",
"depth": 3,
"depth": 2,
"source": "registry",
"dependencies": {
"com.unity.test-framework": "1.1.33",
@@ -257,8 +257,8 @@
"url": "https://packages.unity.com"
},
"org.khronos.unitygltf": {
"version": "2.19.2",
"depth": 1,
"version": "2.19.5",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.nuget.newtonsoft-json": "2.0.0",

1
deployment-note.txt Normal file
View File

@@ -0,0 +1 @@
This file was added on a new branch for testing branch creation and pull request flow.