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:
12
Needle/MenuScene/.gitignore
vendored
Normal file
12
Needle/MenuScene/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
**/node_modules
|
||||
assets/
|
||||
src/generated/
|
||||
dist/
|
||||
include/draco/
|
||||
include/ktx2/
|
||||
include/three/
|
||||
include/console/
|
||||
include/three-mesh-ui-assets/
|
||||
include/fonts/
|
||||
build.log
|
||||
.DS_Store
|
||||
611
Needle/MenuScene/.vscode/custom-elements.json
vendored
Normal file
611
Needle/MenuScene/.vscode/custom-elements.json
vendored
Normal file
@@ -0,0 +1,611 @@
|
||||
{
|
||||
"version": 1.1,
|
||||
"tags": [
|
||||
{
|
||||
"name": "needle-menu",
|
||||
"description": "`The <needle-menu>` web component. A lightweight cross-platform menu that contains built-in functionality and can be extended.\n\nThis element is intended as an internal UI primitive for hosting application\nmenus and buttons. Use the higher-level `NeedleMenu` API from the engine\ncode to manipulate it programmatically.\n\n _Needle Engine_",
|
||||
"attributes": [],
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "needle-button",
|
||||
"description": "A <needle-button> can be used to add VR, AR or Quicklook buttons to your website without having to write code.\n\n _Needle Engine_",
|
||||
"attributes": [
|
||||
{
|
||||
"name": "ar",
|
||||
"description": "Create an AR button\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vr",
|
||||
"description": "Create a VR button\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "quicklook",
|
||||
"description": "Create a QuickLook button for iOS\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "unstyled",
|
||||
"description": "When present, the component won't add its default styles\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "needle-engine",
|
||||
"description": "The <needle-engine> web component creates and manages a Needle Engine context for rendering a 3D scene using threejs. The context is created when the `src` attribute is set and disposed when the element is removed from the DOM; set the `keep-alive` attribute to `true` to prevent cleanup. The context is accessible as `document.querySelector('needle-engine').context`. See https://engine.needle.tools/docs/reference/needle-engine-attributes\n\n _Needle Engine_",
|
||||
"attributes": [
|
||||
{
|
||||
"name": "src",
|
||||
"description": "Change which model gets loaded. This will trigger a reload of the scene.\n@example src=\"path/to/scene.glb\"\n@example src=\"[./path/scene1.glb, myOtherScene.gltf]\"\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "hash",
|
||||
"description": "Optional. String attached to the context for caching/identification.\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "camera-controls",
|
||||
"description": "If set to false the camera controls are disabled. Default is true.\n@example <needle-engine camera-controls=\"false\"></needle-engine>\n@example <needle-engine camera-controls=\"true\"></needle-engine>\n@example <needle-engine camera-controls></needle-engine>\n@example <needle-engine></needle-engine>\n@returns {boolean | null} if the attribute is not set it returns null\n\n",
|
||||
"values": [
|
||||
{
|
||||
"name": "true"
|
||||
},
|
||||
{
|
||||
"name": "false"
|
||||
},
|
||||
{
|
||||
"name": "none"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dracoDecoderPath",
|
||||
"description": "Override the default draco decoder path location.\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dracoDecoderType",
|
||||
"description": "Override the default draco library type.\n\n",
|
||||
"values": [
|
||||
{
|
||||
"name": "wasm"
|
||||
},
|
||||
{
|
||||
"name": "js"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ktx2DecoderPath",
|
||||
"description": "Override the default KTX2 transcoder/decoder path\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tone-mapping",
|
||||
"description": "Tonemapping mode\n\n",
|
||||
"values": [
|
||||
{
|
||||
"name": "none"
|
||||
},
|
||||
{
|
||||
"name": "linear"
|
||||
},
|
||||
{
|
||||
"name": "neutral"
|
||||
},
|
||||
{
|
||||
"name": "agx"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tone-mapping-exposure",
|
||||
"description": "Exposure multiplier for tonemapping\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "background-blurriness",
|
||||
"description": "Blurs the background image. Strength between 0 (sharp) and 1 (fully blurred).\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "background-color",
|
||||
"description": "CSS background color value to be used if no skybox or background image is provided.\n@example \"background-color='#ff0000'\" will set the background color to red.\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "environment-intensity",
|
||||
"description": "Intensity of environment lighting\n\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "keep-alive",
|
||||
"description": "Prevent Needle Engine context from being disposed when the element is removed from the DOM\n\n",
|
||||
"valueSet": "b",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "background-image",
|
||||
"description": "URL to .exr, .hdr, .png, .jpg to be used as skybox. Can also be a magic name for built-in skyboxes, or a FastHDR URL from cdn.needle.tools.",
|
||||
"values": [
|
||||
{
|
||||
"name": "studio",
|
||||
"description": "Neutral studio lighting (magic name)"
|
||||
},
|
||||
{
|
||||
"name": "blurred-skybox",
|
||||
"description": "Soft blurred environment (magic name)"
|
||||
},
|
||||
{
|
||||
"name": "quicklook",
|
||||
"description": "Apple QuickLook Object Mode (magic name)"
|
||||
},
|
||||
{
|
||||
"name": "quicklook-ar",
|
||||
"description": "Apple QuickLook AR Mode (magic name)"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/studio_small_09_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Studio Small - neutral product lighting"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/photo_studio_01_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Photo Studio - professional lighting"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/brown_photostudio_02_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Brown Photo Studio - warm tones"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/venice_sunset_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Venice Sunset - golden hour"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/spruit_sunrise_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Spruit Sunrise - morning light"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/meadow_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Meadow - outdoor natural"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/canary_wharf_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Canary Wharf - urban daylight"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/shanghai_bund_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Shanghai Bund - city night"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/cayley_interior_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Cayley Interior - indoor ambient"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/fireplace_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Fireplace - warm interior"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/the_sky_is_on_fire_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: The Sky is on Fire - dramatic sunset"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/dikhololo_night_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Dikhololo Night - night sky"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
},
|
||||
{
|
||||
"name": "FastHDR Library",
|
||||
"url": "https://cloud.needle.tools/hdris"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "environment-image",
|
||||
"description": "URL to .exr, .hdr, .png, .jpg to be used for lighting. Can also be a magic name for built-in environments, or a FastHDR URL from cdn.needle.tools.",
|
||||
"values": [
|
||||
{
|
||||
"name": "studio",
|
||||
"description": "Neutral studio lighting (magic name)"
|
||||
},
|
||||
{
|
||||
"name": "blurred-skybox",
|
||||
"description": "Soft blurred environment (magic name)"
|
||||
},
|
||||
{
|
||||
"name": "quicklook",
|
||||
"description": "Apple QuickLook Object Mode (magic name)"
|
||||
},
|
||||
{
|
||||
"name": "quicklook-ar",
|
||||
"description": "Apple QuickLook AR Mode (magic name)"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/studio_small_09_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Studio Small - neutral product lighting"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/photo_studio_01_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Photo Studio - professional lighting"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/brown_photostudio_02_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Brown Photo Studio - warm tones"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/venice_sunset_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Venice Sunset - golden hour"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/spruit_sunrise_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Spruit Sunrise - morning light"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/meadow_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Meadow - outdoor natural"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/canary_wharf_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Canary Wharf - urban daylight"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/shanghai_bund_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Shanghai Bund - city night"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/cayley_interior_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Cayley Interior - indoor ambient"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/fireplace_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Fireplace - warm interior"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/the_sky_is_on_fire_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: The Sky is on Fire - dramatic sunset"
|
||||
},
|
||||
{
|
||||
"name": "https://cdn.needle.tools/static/hdris/dikhololo_night_2k.pmrem.ktx2",
|
||||
"description": "FastHDR: Dikhololo Night - night sky"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
},
|
||||
{
|
||||
"name": "FastHDR Library",
|
||||
"url": "https://cloud.needle.tools/hdris"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "background-intensity",
|
||||
"description": "Intensity multiplier for the background image.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "transparent",
|
||||
"description": "Enable/disable renderer canvas transparency.",
|
||||
"valueSet": "b",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "contact-shadows",
|
||||
"description": "Enable/disable contact shadows in the rendered scene",
|
||||
"valueSet": "c",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "focus-rect",
|
||||
"description": "Defines a CSS selector or HTMLElement where the camera should be focused on. Content will be fit into this element.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "clickthrough",
|
||||
"description": "Allow pointer events to pass through transparent parts of the content to the underlying DOM elements.",
|
||||
"valueSet": "b",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "auto-fit",
|
||||
"description": "Automatically fits the model into the camera view on load.",
|
||||
"valueSet": "b",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "auto-rotate",
|
||||
"description": "Automatically rotates the model until a user interacts with the scene.",
|
||||
"valueSet": "b",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "autoplay",
|
||||
"description": "Play animations automatically on scene load",
|
||||
"valueSet": "b",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onloadstart",
|
||||
"description": "Emitted when loading begins for the scene. The event is cancelable — calling `preventDefault()` will stop the default loading UI behavior, so apps can implement custom loading flows.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onprogress",
|
||||
"description": "Emitted repeatedly while loading resources. Use the event detail to show progress.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onloadfinished",
|
||||
"description": "Emitted when scene loading has finished.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onxr-session-ended",
|
||||
"description": "Emitted when an XR session ends.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onenter-ar",
|
||||
"description": "Emitted when entering an AR session.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onexit-ar",
|
||||
"description": "Emitted when exiting an AR session.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onenter-vr",
|
||||
"description": "Emitted when entering a VR session.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onexit-vr",
|
||||
"description": "Emitted when exiting a VR session.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onready",
|
||||
"description": "Emitted when the engine has rendered its first frame and is ready.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onxr-session-started",
|
||||
"description": "Emitted when an XR session is started. You can do additional setup here.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"name": "Needle Engine reference",
|
||||
"url": "https://engine.needle.tools/docs/reference/needle-engine-attributes.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"globalAttributes": [],
|
||||
"valueSets": [
|
||||
{
|
||||
"name": "b",
|
||||
"values": [
|
||||
{
|
||||
"name": "true"
|
||||
},
|
||||
{
|
||||
"name": "false"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "c",
|
||||
"values": [
|
||||
{
|
||||
"name": "true"
|
||||
},
|
||||
{
|
||||
"name": "1"
|
||||
},
|
||||
{
|
||||
"name": "false"
|
||||
},
|
||||
{
|
||||
"name": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Needle/MenuScene/favicon.ico
Normal file
BIN
Needle/MenuScene/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
Needle/MenuScene/include/poster.webp
LFS
Normal file
BIN
Needle/MenuScene/include/poster.webp
LFS
Normal file
Binary file not shown.
35
Needle/MenuScene/index.html
Normal file
35
Needle/MenuScene/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, viewport-fit=cover">
|
||||
|
||||
<title>Made with Needle</title>
|
||||
|
||||
<meta property="og:title" content="Made with Needle" />
|
||||
<meta name="description" content="🌵 Made with Needle Engine">
|
||||
<meta property="og:description" content="🌵 Made with Needle Engine" />
|
||||
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="url" content="https://needle.tools">
|
||||
|
||||
<link rel="stylesheet" href="./src/styles/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="module" src="./src/generated/gen.js"></script>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
<needle-engine>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
7
Needle/MenuScene/needle.config.json
Normal file
7
Needle/MenuScene/needle.config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"baseUrl": null,
|
||||
"buildDirectory": "dist",
|
||||
"assetsDirectory": "assets",
|
||||
"scriptsDirectory": "src/scripts",
|
||||
"codegenDirectory": "src/generated"
|
||||
}
|
||||
4858
Needle/MenuScene/package-lock.json
generated
Normal file
4858
Needle/MenuScene/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Needle/MenuScene/package.json
Normal file
24
Needle/MenuScene/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "my-needle-engine-project",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"start": "npm run dev",
|
||||
"build": "vite build -- --production",
|
||||
"build:production": "npm run build",
|
||||
"build:dev": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@needle-tools/engine": "5.0.3",
|
||||
"three": "npm:@needle-tools/three@0.169.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@needle-tools/helper": "2.0.0",
|
||||
"@types/three": "0.169.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.2.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-compression2": "^2.5.2"
|
||||
}
|
||||
}
|
||||
117
Needle/MenuScene/src/assetPicker.ts
Normal file
117
Needle/MenuScene/src/assetPicker.ts
Normal 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();
|
||||
20
Needle/MenuScene/src/enableXR.ts
Normal file
20
Needle/MenuScene/src/enableXR.ts
Normal 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;
|
||||
});
|
||||
3
Needle/MenuScene/src/main.ts
Normal file
3
Needle/MenuScene/src/main.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import("@needle-tools/engine") /* async import of needle engine */;
|
||||
import "./enableXR";
|
||||
import "./assetPicker";
|
||||
74
Needle/MenuScene/src/scripts/ARObjectController.ts
Normal file
74
Needle/MenuScene/src/scripts/ARObjectController.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
341
Needle/MenuScene/src/scripts/MenuController.ts
Normal file
341
Needle/MenuScene/src/scripts/MenuController.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
Needle/MenuScene/src/scripts/MyComponent.ts
Normal file
30
Needle/MenuScene/src/scripts/MyComponent.ts
Normal 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);
|
||||
// }
|
||||
|
||||
// }
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
7
Needle/MenuScene/src/scripts/Readme.md
Normal file
7
Needle/MenuScene/src/scripts/Readme.md
Normal 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
|
||||
90
Needle/MenuScene/src/styles/style.css
Normal file
90
Needle/MenuScene/src/styles/style.css
Normal 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 Engine’s 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;
|
||||
}
|
||||
21
Needle/MenuScene/tsconfig.json
Normal file
21
Needle/MenuScene/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitAny": false,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./src"]
|
||||
}
|
||||
30
Needle/MenuScene/vite.config.js
Normal file
30
Needle/MenuScene/vite.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import viteCompression from 'vite-plugin-compression2';
|
||||
import basicSsl from '@vitejs/plugin-basic-ssl'
|
||||
|
||||
export default defineConfig(async ({ command }) => {
|
||||
|
||||
const { needlePlugins, useGzip, loadConfig } = await import("@needle-tools/engine/plugins/vite/index.js");
|
||||
const needleConfig = await loadConfig();
|
||||
|
||||
return {
|
||||
base: "./",
|
||||
plugins: [
|
||||
basicSsl(),
|
||||
useGzip(needleConfig) ? viteCompression({ ddeleteOriginalAssets: true, algorithms: ['gzip']}) : null,
|
||||
needlePlugins(command, needleConfig),
|
||||
],
|
||||
server: {
|
||||
https: true,
|
||||
proxy: { // workaround: specifying a proxy skips HTTP2 which is currently problematic in Vite since it causes session memory timeouts.
|
||||
'https://localhost:3000': 'https://localhost:3000'
|
||||
},
|
||||
strictPort: true,
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
18
Needle/MenuScene/workspace.code-workspace
Normal file
18
Needle/MenuScene/workspace.code-workspace
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".",
|
||||
},
|
||||
{
|
||||
"name": "Needle Engine",
|
||||
"path": "./node_modules/@needle-tools/engine",
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"window.title": "Template - ${rootName}${separator}${activeEditorShort}",
|
||||
"files.exclude": {
|
||||
"**/.DS_Store": true,
|
||||
"**/*.meta": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user