Initial commit: Unity Needle AR Menu project with MenuScene and SampleScene web apps

Add root .gitignore for Unity Library/Temp/Logs, IDE folders, and node_modules.
Include Assets, Needle TypeScript (MenuController, asset picker, WebXR), and project configuration.

Made-with: Cursor
This commit is contained in:
pelpanagiotis
2026-04-19 22:41:05 +03:00
commit a7c53a08a0
139 changed files with 30122 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import { NeedleXRSession, findObjectOfType } from "@needle-tools/engine";
import type { MenuController } from "./scripts/MenuController.js";
/**
* HTML overlay: drives {@link MenuController} prev/next (same GLB as Gitea AR-Menu) — no `src` swap.
* Requires Unity export with MenuController + dish Object3Ds on the scene GLB.
*/
function whenDomReady(fn: () => void): void {
if (document.readyState !== "loading") fn();
else document.addEventListener("DOMContentLoaded", () => fn(), { once: true });
}
function initAssetPicker(): void {
const needle = document.querySelector("needle-engine");
const prev = document.querySelector<HTMLButtonElement>("#asset-picker-prev");
const next = document.querySelector<HTMLButtonElement>("#asset-picker-next");
const arBtn = document.querySelector<HTMLButtonElement>("#asset-picker-ar");
const labelEl = document.querySelector("#asset-picker-label");
const indexEl = document.querySelector("#asset-picker-index");
if (!needle || !prev || !next || !arBtn || !labelEl || !indexEl) return;
let menuController: MenuController | null = null;
let immersiveSessionActive = false;
let arSupported = false;
let arStarting = false;
const syncUi = (): void => {
if (menuController && menuController.getDishSlotCount() > 0) {
labelEl.textContent = menuController.getPickerLabel();
indexEl.textContent = "";
} else if (menuController) {
labelEl.textContent = "Menu (assign dishes in Unity)";
indexEl.textContent = "";
} else {
labelEl.textContent = "Menu scene";
indexEl.textContent = "—";
}
const canNav = menuController !== null && menuController.getDishSlotCount() > 1;
prev.disabled = !canNav;
next.disabled = !canNav;
arBtn.disabled =
!arSupported ||
arStarting ||
immersiveSessionActive;
};
const bindMenuController = async (): Promise<void> => {
try {
const ctx = await needle.getContext();
menuController = findObjectOfType(MenuController, ctx);
} catch {
menuController = null;
}
syncUi();
};
void NeedleXRSession.isARSupported().then((ok: boolean) => {
arSupported = ok;
syncUi();
});
const requestNavigate = (delta: number): void => {
if (!menuController || menuController.getDishSlotCount() <= 1) return;
if (delta < 0) menuController.selectPreviousDish();
else menuController.selectNextDish();
syncUi();
};
const startAr = async (): Promise<void> => {
if (!arSupported || arStarting || immersiveSessionActive) return;
arStarting = true;
syncUi();
try {
const ctx = await needle.getContext();
await NeedleXRSession.start("immersive-ar", undefined, ctx);
} catch (err) {
console.warn("[assetPicker] Failed to start AR session:", err);
} finally {
arStarting = false;
syncUi();
}
};
prev.addEventListener("click", () => requestNavigate(-1));
next.addEventListener("click", () => requestNavigate(1));
arBtn.addEventListener("click", () => void startAr());
needle.addEventListener("enter-ar", () => {
immersiveSessionActive = true;
syncUi();
});
needle.addEventListener("exit-ar", () => {
immersiveSessionActive = false;
syncUi();
});
needle.addEventListener("enter-vr", () => {
immersiveSessionActive = true;
syncUi();
});
needle.addEventListener("exit-vr", () => {
immersiveSessionActive = false;
syncUi();
});
needle.addEventListener("loadfinished", () => void bindMenuController());
whenDomReady(() => {
requestAnimationFrame(() => void bindMenuController());
});
}
initAssetPicker();

View File

@@ -0,0 +1,20 @@
import { onStart, WebXR } from "@needle-tools/engine";
/**
* WebXR (AR/VR) — https://engine.needle.tools/docs/how-to-guides/xr/
*
* Unity editor (parity with Gitea): use XR Flag on content so meshes stay visible in AR; author dish
* scale for real-world size. If the scene already has WebXR from export, we only tune placement.
*/
onStart((context) => {
let webxr = context.scene.getComponentInChildren(WebXR);
if (!webxr) {
webxr = context.scene.addComponent(WebXR);
webxr.createARButton = true;
webxr.createVRButton = true;
}
webxr.autoPlace = true;
webxr.autoCenter = true;
webxr.arScale = 1;
});

View File

@@ -0,0 +1,3 @@
import("@needle-tools/engine") /* async import of needle engine */;
import "./enableXR";
import "./assetPicker";

View File

@@ -0,0 +1,74 @@
import { Behaviour } from "@needle-tools/engine";
import { Vector2, Raycaster, Vector3, Plane } from "three";
export class ARObjectController extends Behaviour {
private raycaster: Raycaster = new Raycaster();
private touchPos: Vector2 = new Vector2();
private plane: Plane = new Plane(new Vector3(0, 1, 0), 0);
private initialPinchDistance: number = 0;
private initialScale: Vector3 = new Vector3();
private isScaling: boolean = false;
onEnable(): void {
const canvas = this.context.renderer.domElement;
canvas.addEventListener("touchstart", this.onTouchStart);
canvas.addEventListener("touchmove", this.onTouchMove);
canvas.addEventListener("touchend", this.onTouchEnd);
}
onDisable(): void {
const canvas = this.context.renderer.domElement;
canvas.removeEventListener("touchstart", this.onTouchStart);
canvas.removeEventListener("touchmove", this.onTouchMove);
canvas.removeEventListener("touchend", this.onTouchEnd);
}
private onTouchStart = (event: TouchEvent): void => {
if (event.touches.length === 2) {
this.isScaling = true;
const touch1 = event.touches[0];
const touch2 = event.touches[1];
this.initialPinchDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
this.initialScale.copy(this.gameObject.scale);
}
};
private onTouchMove = (event: TouchEvent): void => {
event.preventDefault();
if (this.isScaling && event.touches.length === 2) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
const scaleFactor = currentDistance / this.initialPinchDistance;
const newScale = this.initialScale.clone().multiplyScalar(scaleFactor);
this.gameObject.scale.copy(newScale);
} else if (event.touches.length === 1 && !this.isScaling) {
const touch = event.touches[0];
const canvas = this.context.renderer.domElement;
const rect = canvas.getBoundingClientRect();
this.touchPos.set(
((touch.clientX - rect.left) / rect.width) * 2 - 1,
-((touch.clientY - rect.top) / rect.height) * 2 + 1
);
this.raycaster.setFromCamera(this.touchPos, this.context.mainCamera);
const intersection = new Vector3();
if (this.raycaster.ray.intersectPlane(this.plane, intersection)) {
this.gameObject.position.copy(intersection);
}
}
};
private onTouchEnd = (event: TouchEvent): void => {
if (event.touches.length < 2) {
this.isScaling = false;
}
};
}

View File

@@ -0,0 +1,341 @@
import {
Behaviour,
DeviceUtilities,
GameObject,
serializable,
USDZExporter,
type NeedleXREventArgs,
} from "@needle-tools/engine";
import { Object3D } from "three";
const dishBaseY = new WeakMap<Object3D, number>();
/**
* Place each dish model in the same Unity scene as MenuScene, assign the roots to {@link MenuController.dishes},
* then export a single `MenuScene.glb`. Only one dish is active at a time; the HTML picker cycles entries for AR preview.
*/
export class MenuController extends Behaviour {
isMobile: boolean = false;
isDesktop: boolean = false;
isXR: boolean = false;
private dishName: string = "";
@serializable(Object3D)
dishes: Object3D[] = [];
@serializable(Object3D)
webXROrigin?: Object3D;
/** Local-space vertical bob amplitude (meters). Set to 0 to disable. */
@serializable()
dishBobAmplitude = 0.05;
/** Bob angular speed (radians per second). */
@serializable()
dishBobSpeed = 2.5;
private usdzExporter?: USDZExporter;
/** True while an immersive-ar session is active — vertical bob is paused. */
private arSessionBobPaused = false;
selectedDishIndex: number = 0;
onEnable(): void {
const params = new URLSearchParams(window.location.search);
this.dishName = params.get("dishName") ?? "";
if (this.webXROrigin) this.usdzExporter = this.webXROrigin.getComponent(USDZExporter) ?? undefined;
if (this.dishName) {
let matched = false;
this.dishes.forEach((dish, index) => {
if (!dish) return;
if (dish.name === this.dishName) {
this.selectedDishIndex = index;
matched = true;
}
});
this.dishes.forEach((dish) => {
if (!dish) return;
const on = matched && dish.name === this.dishName;
if (!on) {
this.restoreDishBaseY(dish);
}
GameObject.setActive(dish, on);
});
if (!matched) {
this.ensureOnlySelectedDishVisible();
}
} else {
this.ensureOnlySelectedDishVisible();
}
this.updateUSDZExporterTarget();
void this.checkForDeviceType().then(() => {
if (this.isMobile) {
console.log("[MenuController] isMobile");
} else if (this.isDesktop) {
this.setupDesktopControls();
} else if (this.isXR) {
// XR-specific setup if needed
}
});
this.setupMobileControls();
this.disableDoubleTapZoom();
}
onEnterXR(args: NeedleXREventArgs): void {
if (args.xr.mode === "immersive-ar") {
this.arSessionBobPaused = true;
this.snapActiveDishToBaseY();
}
}
onLeaveXR(_args: NeedleXREventArgs): void {
this.arSessionBobPaused = false;
}
update(): void {
if (this.arSessionBobPaused) return;
if (this.dishBobAmplitude <= 0) return;
const valid = this.getValidDishIndices();
if (valid.length === 0) return;
const dish = this.dishes[this.selectedDishIndex];
if (!dish) return;
let base = dishBaseY.get(dish);
if (base === undefined) {
base = dish.position.y;
dishBaseY.set(dish, base);
}
const t = this.context.time.time;
dish.position.y = base + Math.sin(t * this.dishBobSpeed) * this.dishBobAmplitude;
}
async checkForDeviceType(): Promise<void> {
const xrSupported = await this.isXRDevice();
if (xrSupported) {
this.isXR = true;
} else {
console.log("DeviceUtilities.isMobileDevice()", DeviceUtilities.isMobileDevice());
this.isMobile = DeviceUtilities.isMobileDevice();
if (!this.isMobile) {
this.isDesktop = DeviceUtilities.isDesktop();
}
}
}
async isXRDevice(): Promise<boolean> {
if (navigator.xr) {
try {
return await navigator.xr.isSessionSupported("immersive-vr");
} catch {
console.log("XR check error!");
return false;
}
}
return false;
}
setupMobileControls(): void {
if (typeof document !== "undefined" && document.querySelector("#asset-picker")) {
return;
}
this.createMenuMobileControls();
}
setupDesktopControls(): void {
// Optional: mirror mobile controls on desktop
}
createMenuMobileControls(): void {
const menuControlsContainer = document.createElement("div");
menuControlsContainer.id = "menuControlsZone";
menuControlsContainer.style.cssText = `
position: absolute;
display: flex;
justify-content: space-between;
bottom: 10%;
left: 10%;
right: 10%;
height: 150px;
`;
document.body.appendChild(menuControlsContainer);
const previousButton = document.createElement("button");
previousButton.id = "previousButton";
previousButton.style.cssText = `
height: 100px;
width: 100px;
background-color: #ffffff;
color: #111111;
border: none;
border-radius: 50px;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
`;
previousButton.setAttribute("aria-label", "Previous");
previousButton.innerHTML = `
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
previousButton.onclick = this.selectPreviousDish.bind(this);
const nextButton = document.createElement("button");
nextButton.id = "nextButton";
nextButton.style.cssText = `
height: 100px;
width: 100px;
background-color: #ffffff;
color: #111111;
border: none;
border-radius: 50px;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
`;
nextButton.setAttribute("aria-label", "Next");
nextButton.innerHTML = `
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
nextButton.onclick = this.selectNextDish.bind(this);
if (this.dishName) {
previousButton.disabled = true;
previousButton.style.display = "none";
nextButton.disabled = true;
nextButton.style.display = "none";
}
menuControlsContainer.appendChild(previousButton);
menuControlsContainer.appendChild(nextButton);
}
private disableDoubleTapZoom(): void {
let lastTouchEnd = 0;
const onTouchEnd = (event: TouchEvent): void => {
const now = Date.now();
if (now - lastTouchEnd <= 300) {
event.preventDefault();
}
lastTouchEnd = now;
};
document.addEventListener("touchend", onTouchEnd, { passive: false });
}
private getValidDishIndices(): number[] {
return this.dishes.map((dish, index) => (dish != null ? index : -1)).filter((index) => index >= 0);
}
/** Show exactly one dish: current {@link selectedDishIndex}, or the first valid slot if the index is unset. */
private ensureOnlySelectedDishVisible(): void {
const valid = this.getValidDishIndices();
if (valid.length === 0) return;
let idx = this.selectedDishIndex;
if (valid.indexOf(idx) < 0) {
idx = valid[0];
this.selectedDishIndex = idx;
}
valid.forEach((i) => {
const active = i === this.selectedDishIndex;
const d = this.dishes[i];
if (!active) {
this.restoreDishBaseY(d);
}
GameObject.setActive(this.dishes[i], active);
});
}
private restoreDishBaseY(dish: Object3D | null | undefined): void {
if (!dish) return;
const y = dishBaseY.get(dish);
if (y !== undefined) {
dish.position.y = y;
}
}
private snapActiveDishToBaseY(): void {
this.restoreDishBaseY(this.dishes[this.selectedDishIndex]);
}
/** For HTML overlay: how many dish slots are assigned in Unity. */
getDishSlotCount(): number {
return this.getValidDishIndices().length;
}
/** Label for the asset picker (object name from Unity when set, else Dish i / n). */
getPickerLabel(): string {
const valid = this.getValidDishIndices();
if (valid.length === 0) return "Menu";
const pos = Math.max(0, valid.indexOf(this.selectedDishIndex));
const dish = this.dishes[this.selectedDishIndex];
const label = dish?.name?.trim();
if (label) {
return `${label} (${pos + 1}/${valid.length})`;
}
return `Dish ${pos + 1} / ${valid.length}`;
}
selectPreviousDish(): void {
const valid = this.getValidDishIndices();
if (valid.length === 0) return;
let pos = valid.indexOf(this.selectedDishIndex);
if (pos < 0) pos = 0;
this.restoreDishBaseY(this.dishes[valid[pos]]);
GameObject.setActive(this.dishes[valid[pos]], false);
pos = (pos - 1 + valid.length) % valid.length;
this.selectedDishIndex = valid[pos];
GameObject.setActive(this.dishes[this.selectedDishIndex], true);
this.updateUSDZExporterTarget();
}
selectNextDish(): void {
const valid = this.getValidDishIndices();
if (valid.length === 0) return;
let pos = valid.indexOf(this.selectedDishIndex);
if (pos < 0) pos = 0;
this.restoreDishBaseY(this.dishes[valid[pos]]);
GameObject.setActive(this.dishes[valid[pos]], false);
pos = (pos + 1) % valid.length;
this.selectedDishIndex = valid[pos];
GameObject.setActive(this.dishes[this.selectedDishIndex], true);
this.updateUSDZExporterTarget();
}
private updateUSDZExporterTarget(): void {
const dish = this.dishes[this.selectedDishIndex];
if (this.usdzExporter && dish) {
this.usdzExporter.objectToExport = dish;
}
}
getUrlParameter(name: string): string | null {
const params = new URLSearchParams(window.location.search);
return params.get(name);
}
}

View File

@@ -0,0 +1,30 @@
// 1) Uncomment the code below to get started with your own script!
// 2) You can then return to your editor and add the 'MyComponent' component to any object in your scene.
// 3) Click Export or Play and see the effect in the browser. You've successfully added your first Needle Engine component to your 3D scene
// 4) Continue learning on https://docs.needle.tools/scripting
// import { Behaviour, Gizmos, serializable, showBalloonMessage } from "@needle-tools/engine";
// import { Object3D } from "three";
// export class MyComponent extends Behaviour {
// @serializable()
// rotationSpeed: number = 1;
// @serializable(Object3D)
// otherObject: Object3D | null = null;
// start() {
// showBalloonMessage("Hello Needle");
// console.log("Hello Needle - this component is on the " + this.gameObject.name + " object");
// }
// update(): void {
// Gizmos.DrawWireSphere(this.gameObject.worldPosition, .5, 0xddff33);
// if (this.otherObject) this.otherObject.rotateY(this.context.time.deltaTime * this.rotationSpeed);
// else this.gameObject.rotateY(this.context.time.deltaTime * this.rotationSpeed);
// }
// }

View File

@@ -0,0 +1,20 @@
import { Behaviour, BloomEffect, serializable, Volume } from "@needle-tools/engine";
export class PostProcessingVolumeController extends Behaviour {
@serializable(Volume)
volume?: Volume;
start(): void {
if (!this.volume) {
console.warn("No PostProcessVolume assigned");
return;
}
this.volume.addEffect(
new BloomEffect({
intensity: 3,
luminanceThreshold: 0.2,
})
);
}
}

View File

@@ -0,0 +1,7 @@
Project-specific typescript files go here.
Needle Engine will automatically generate matching C# "stub components" so you can attach them to objects in Unity.
If you want to reuse components between multiple projects, a great way to do so are NpmDefs reusable modules that contain both TypeScript and C# components.
Learn more about scripting on the docs:
https://docs.needle.tools/scripting

View File

@@ -0,0 +1,90 @@
html {
height: -webkit-fill-available;
}
body {
padding: 0;
margin: 0;
}
needle-engine {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Sit above Needle Engines bottom menu / controls (DOM overlay) */
#asset-picker {
position: fixed;
left: 0;
right: 0;
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
z-index: 600;
pointer-events: auto;
display: flex;
justify-content: center;
padding: 12px;
box-sizing: border-box;
}
#asset-picker .asset-picker__inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
max-width: min(100%, 36rem);
padding: 10px 14px;
border-radius: 12px;
background: rgba(15, 15, 20, 0.82);
color: #f2f2f7;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 0.95rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(8px);
}
#asset-picker button {
pointer-events: auto;
touch-action: manipulation;
cursor: pointer;
border: none;
border-radius: 8px;
padding: 8px 14px;
font: inherit;
font-weight: 600;
color: #0f0f14;
background: #e8e8ed;
}
#asset-picker button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
#asset-picker #asset-picker-ar {
background: #34c759;
color: #0a0a0c;
}
#asset-picker #asset-picker-ar:disabled {
background: #3a3a3e;
color: rgba(255, 255, 255, 0.5);
}
#asset-picker .asset-picker__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
#asset-picker .asset-picker__index {
flex-shrink: 0;
opacity: 0.85;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
}