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

12
Needle/SampleScene/.gitignore vendored Normal file
View 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

View 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 Logo](data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDE4IDEwIiB3aWR0aD0iMTgiIGhlaWdodD0iMTAiPgoJPGRlZnM+CgkJPGxpbmVhckdyYWRpZW50IGlkPSJnMSIgeDI9IjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KC4wNDUsLTguNjgsMS4xMzUsLjAwNiw5LjU0OSw5Ljg0NCkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM2MmQzOTkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTEiIHN0b3AtY29sb3I9IiNhY2Q4NDIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuOSIgc3RvcC1jb2xvcj0iI2Q3ZGIwYSIvPgoJCQk8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNkN2RiMGEiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iZzIiIHgyPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgtMC4wODUsLTguNjM2LDEuMTI0LC0wLjAxMSw4LjQ4Niw5LjUyOSkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMwYmEzOTgiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNSIgc3RvcC1jb2xvcj0iIzRjYTM1MiIvPgoJCQk8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM3NmEzMGEiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iZzMiIHgyPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgtMC4xMDEsLTMuNjIxLDEuMDI4LC0wLjAyOSw2LjcyNCw4LjEwNSkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMzNmEzODIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuMTkiIHN0b3AtY29sb3I9IiMzNmEzODIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTQiIHN0b3AtY29sb3I9IiM0OWE0NTkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNzZhMzBiIi8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc0IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjExNiwtMy4zMjMsMS45MTgsLjA2Nyw1LjYxNyw4LjE2MikiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMyNjc4ODAiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTEiIHN0b3AtY29sb3I9IiM0NTdhNWMiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNzE3NTE2Ii8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc1IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjczOCwtMy44MzMsMS4xMDEsLjIxMiwxMS45Nyw3LjIxNCkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNiMGQ5MzkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZWFkYjA0Ii8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc2IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4zOTMsLTMuNDQ4LDEuODU3LC43NSwxMC40OTYsNi44MjYpIj4KCQkJPHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNzRhZjUyIi8+CgkJCTxzdG9wIG9mZnNldD0iLjE3IiBzdG9wLWNvbG9yPSIjNzRhZjUyIi8+CgkJCTxzdG9wIG9mZnNldD0iLjQ4IiBzdG9wLWNvbG9yPSIjOTliZTMyIi8+CgkJCTxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2MwYzQwYSIvPgoJCTwvbGluZWFyR3JhZGllbnQ+Cgk8L2RlZnM+Cgk8c3R5bGU+CgkJLnMwIHsgZmlsbDogdXJsKCNnMSkgfSAKCQkuczEgeyBmaWxsOiB1cmwoI2cyKSB9IAoJCS5zMiB7IGZpbGw6IHVybCgjZzMpIH0gCgkJLnMzIHsgZmlsbDogdXJsKCNnNCkgfSAKCQkuczQgeyBmaWxsOiAjOTljYzMzIH0gCgkJLnM1IHsgZmlsbDogdXJsKCNnNSkgfSAKCQkuczYgeyBmaWxsOiB1cmwoI2c2KSB9IAoJCS5zNyB7IGZpbGw6ICNmZmUxMTMgfSAKCQkuczggeyBmaWxsOiAjZjNlNjAwIH0gCgk8L3N0eWxlPgoJPGc+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMCIgZD0ibTkgMS45N3Y4LjAzbDAuODMtMC43IDAuMzYtOC4zM3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMSIgZD0ibTkgMS45NywtMS4xOS0xIDAuMzUgOC4zMyAwLjg0IDAuN3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMiIgZD0ibTYuMTIgNS41OGwwLjQ2IDIuNjIgMC42Ni0wLjgtMC4xMy0zLjAxeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InMzIiBkPSJtNi4xMiA1LjU4bC0xLjM1LTAuNzcgMC45MSAyLjg3IDAuOSAwLjUyeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM0IiBkPSJtNy4xMSA0LjM5bC0xLjM0LTAuNzctMSAxLjE5IDEuMzUgMC43N3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzNSIgZD0ibTExLjk2IDQuMTlsLTAuNTQgMy4wMSAwLjgzLTAuNDggMS4wNS0zLjMxeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM2IiBkPSJtMTEuOTYgNC4xOWwtMS0xLjE5LTAuMTUgMy40NiAwLjYxIDAuNzR6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBjbGFzcz0iczciIGQ9Im0xMy4zIDMuNDFsLTEtMS4xOC0xLjM0IDAuNzcgMSAxLjE5eiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM4IiBkPSJtMTAuMTkgMC45N2wtMS4xOS0wLjk3LTEuMTkgMC45NyAxLjE5IDF6Ii8+CgkJPC9nPgoJPC9nPgo8L3N2Zz4=) _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 Logo](data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDE4IDEwIiB3aWR0aD0iMTgiIGhlaWdodD0iMTAiPgoJPGRlZnM+CgkJPGxpbmVhckdyYWRpZW50IGlkPSJnMSIgeDI9IjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KC4wNDUsLTguNjgsMS4xMzUsLjAwNiw5LjU0OSw5Ljg0NCkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM2MmQzOTkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTEiIHN0b3AtY29sb3I9IiNhY2Q4NDIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuOSIgc3RvcC1jb2xvcj0iI2Q3ZGIwYSIvPgoJCQk8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNkN2RiMGEiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iZzIiIHgyPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgtMC4wODUsLTguNjM2LDEuMTI0LC0wLjAxMSw4LjQ4Niw5LjUyOSkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMwYmEzOTgiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNSIgc3RvcC1jb2xvcj0iIzRjYTM1MiIvPgoJCQk8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM3NmEzMGEiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iZzMiIHgyPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgtMC4xMDEsLTMuNjIxLDEuMDI4LC0wLjAyOSw2LjcyNCw4LjEwNSkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMzNmEzODIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuMTkiIHN0b3AtY29sb3I9IiMzNmEzODIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTQiIHN0b3AtY29sb3I9IiM0OWE0NTkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNzZhMzBiIi8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc0IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjExNiwtMy4zMjMsMS45MTgsLjA2Nyw1LjYxNyw4LjE2MikiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMyNjc4ODAiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTEiIHN0b3AtY29sb3I9IiM0NTdhNWMiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNzE3NTE2Ii8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc1IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjczOCwtMy44MzMsMS4xMDEsLjIxMiwxMS45Nyw3LjIxNCkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNiMGQ5MzkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZWFkYjA0Ii8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc2IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4zOTMsLTMuNDQ4LDEuODU3LC43NSwxMC40OTYsNi44MjYpIj4KCQkJPHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNzRhZjUyIi8+CgkJCTxzdG9wIG9mZnNldD0iLjE3IiBzdG9wLWNvbG9yPSIjNzRhZjUyIi8+CgkJCTxzdG9wIG9mZnNldD0iLjQ4IiBzdG9wLWNvbG9yPSIjOTliZTMyIi8+CgkJCTxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2MwYzQwYSIvPgoJCTwvbGluZWFyR3JhZGllbnQ+Cgk8L2RlZnM+Cgk8c3R5bGU+CgkJLnMwIHsgZmlsbDogdXJsKCNnMSkgfSAKCQkuczEgeyBmaWxsOiB1cmwoI2cyKSB9IAoJCS5zMiB7IGZpbGw6IHVybCgjZzMpIH0gCgkJLnMzIHsgZmlsbDogdXJsKCNnNCkgfSAKCQkuczQgeyBmaWxsOiAjOTljYzMzIH0gCgkJLnM1IHsgZmlsbDogdXJsKCNnNSkgfSAKCQkuczYgeyBmaWxsOiB1cmwoI2c2KSB9IAoJCS5zNyB7IGZpbGw6ICNmZmUxMTMgfSAKCQkuczggeyBmaWxsOiAjZjNlNjAwIH0gCgk8L3N0eWxlPgoJPGc+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMCIgZD0ibTkgMS45N3Y4LjAzbDAuODMtMC43IDAuMzYtOC4zM3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMSIgZD0ibTkgMS45NywtMS4xOS0xIDAuMzUgOC4zMyAwLjg0IDAuN3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMiIgZD0ibTYuMTIgNS41OGwwLjQ2IDIuNjIgMC42Ni0wLjgtMC4xMy0zLjAxeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InMzIiBkPSJtNi4xMiA1LjU4bC0xLjM1LTAuNzcgMC45MSAyLjg3IDAuOSAwLjUyeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM0IiBkPSJtNy4xMSA0LjM5bC0xLjM0LTAuNzctMSAxLjE5IDEuMzUgMC43N3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzNSIgZD0ibTExLjk2IDQuMTlsLTAuNTQgMy4wMSAwLjgzLTAuNDggMS4wNS0zLjMxeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM2IiBkPSJtMTEuOTYgNC4xOWwtMS0xLjE5LTAuMTUgMy40NiAwLjYxIDAuNzR6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBjbGFzcz0iczciIGQ9Im0xMy4zIDMuNDFsLTEtMS4xOC0xLjM0IDAuNzcgMSAxLjE5eiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM4IiBkPSJtMTAuMTkgMC45N2wtMS4xOS0wLjk3LTEuMTkgMC45NyAxLjE5IDF6Ii8+CgkJPC9nPgoJPC9nPgo8L3N2Zz4=) _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 Logo](data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDE4IDEwIiB3aWR0aD0iMTgiIGhlaWdodD0iMTAiPgoJPGRlZnM+CgkJPGxpbmVhckdyYWRpZW50IGlkPSJnMSIgeDI9IjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KC4wNDUsLTguNjgsMS4xMzUsLjAwNiw5LjU0OSw5Ljg0NCkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM2MmQzOTkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTEiIHN0b3AtY29sb3I9IiNhY2Q4NDIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuOSIgc3RvcC1jb2xvcj0iI2Q3ZGIwYSIvPgoJCQk8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNkN2RiMGEiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iZzIiIHgyPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgtMC4wODUsLTguNjM2LDEuMTI0LC0wLjAxMSw4LjQ4Niw5LjUyOSkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMwYmEzOTgiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNSIgc3RvcC1jb2xvcj0iIzRjYTM1MiIvPgoJCQk8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM3NmEzMGEiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iZzMiIHgyPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgtMC4xMDEsLTMuNjIxLDEuMDI4LC0wLjAyOSw2LjcyNCw4LjEwNSkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMzNmEzODIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuMTkiIHN0b3AtY29sb3I9IiMzNmEzODIiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTQiIHN0b3AtY29sb3I9IiM0OWE0NTkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNzZhMzBiIi8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc0IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjExNiwtMy4zMjMsMS45MTgsLjA2Nyw1LjYxNyw4LjE2MikiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMyNjc4ODAiLz4KCQkJPHN0b3Agb2Zmc2V0PSIuNTEiIHN0b3AtY29sb3I9IiM0NTdhNWMiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNzE3NTE2Ii8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc1IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjczOCwtMy44MzMsMS4xMDEsLjIxMiwxMS45Nyw3LjIxNCkiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNiMGQ5MzkiLz4KCQkJPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZWFkYjA0Ii8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8bGluZWFyR3JhZGllbnQgaWQ9Imc2IiB4Mj0iMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMS4zOTMsLTMuNDQ4LDEuODU3LC43NSwxMC40OTYsNi44MjYpIj4KCQkJPHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNzRhZjUyIi8+CgkJCTxzdG9wIG9mZnNldD0iLjE3IiBzdG9wLWNvbG9yPSIjNzRhZjUyIi8+CgkJCTxzdG9wIG9mZnNldD0iLjQ4IiBzdG9wLWNvbG9yPSIjOTliZTMyIi8+CgkJCTxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2MwYzQwYSIvPgoJCTwvbGluZWFyR3JhZGllbnQ+Cgk8L2RlZnM+Cgk8c3R5bGU+CgkJLnMwIHsgZmlsbDogdXJsKCNnMSkgfSAKCQkuczEgeyBmaWxsOiB1cmwoI2cyKSB9IAoJCS5zMiB7IGZpbGw6IHVybCgjZzMpIH0gCgkJLnMzIHsgZmlsbDogdXJsKCNnNCkgfSAKCQkuczQgeyBmaWxsOiAjOTljYzMzIH0gCgkJLnM1IHsgZmlsbDogdXJsKCNnNSkgfSAKCQkuczYgeyBmaWxsOiB1cmwoI2c2KSB9IAoJCS5zNyB7IGZpbGw6ICNmZmUxMTMgfSAKCQkuczggeyBmaWxsOiAjZjNlNjAwIH0gCgk8L3N0eWxlPgoJPGc+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMCIgZD0ibTkgMS45N3Y4LjAzbDAuODMtMC43IDAuMzYtOC4zM3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMSIgZD0ibTkgMS45NywtMS4xOS0xIDAuMzUgOC4zMyAwLjg0IDAuN3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzMiIgZD0ibTYuMTIgNS41OGwwLjQ2IDIuNjIgMC42Ni0wLjgtMC4xMy0zLjAxeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InMzIiBkPSJtNi4xMiA1LjU4bC0xLjM1LTAuNzcgMC45MSAyLjg3IDAuOSAwLjUyeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM0IiBkPSJtNy4xMSA0LjM5bC0xLjM0LTAuNzctMSAxLjE5IDEuMzUgMC43N3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGNsYXNzPSJzNSIgZD0ibTExLjk2IDQuMTlsLTAuNTQgMy4wMSAwLjgzLTAuNDggMS4wNS0zLjMxeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM2IiBkPSJtMTEuOTYgNC4xOWwtMS0xLjE5LTAuMTUgMy40NiAwLjYxIDAuNzR6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBjbGFzcz0iczciIGQ9Im0xMy4zIDMuNDFsLTEtMS4xOC0xLjM0IDAuNzcgMSAxLjE5eiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggY2xhc3M9InM4IiBkPSJtMTAuMTkgMC45N2wtMS4xOS0wLjk3LTEuMTkgMC45NyAxLjE5IDF6Ii8+CgkJPC9nPgoJPC9nPgo8L3N2Zz4=) _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"
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

View File

@@ -0,0 +1,7 @@
{
"baseUrl": null,
"buildDirectory": "dist",
"assetsDirectory": "assets",
"scriptsDirectory": "src/scripts",
"codegenDirectory": "src/generated"
}

5404
Needle/SampleScene/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

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 (single GLB, Gitea-style) — 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 = "Sample 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,339 @@
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]);
}
getDishSlotCount(): number {
return this.getValidDishIndices().length;
}
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;
}

View 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"]
}

View 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,
}
}
});

View 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
}
}
}