threepipe
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

tpImageInputGenerator.ts 17KB

11 месяцев назад
11 месяцев назад
11 месяцев назад
11 месяцев назад
11 месяцев назад
11 месяцев назад
11 месяцев назад
11 месяцев назад
11 месяцев назад
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import {
  2. CustomContextMenu,
  3. DataTexture,
  4. EXRExporter2,
  5. FloatType,
  6. generateUUID,
  7. getOrCall,
  8. HalfFloatType,
  9. imageBitmapToBase64,
  10. ImportResultExtras,
  11. ITexture,
  12. LinearSRGBColorSpace,
  13. makeTextSvg,
  14. RepeatWrapping,
  15. SRGBColorSpace,
  16. Texture,
  17. textureToDataUrl,
  18. ThreeViewer,
  19. upgradeTexture,
  20. WebGLRenderTarget,
  21. SVGTextureLoader,
  22. uploadFile,
  23. } from 'threepipe'
  24. import type {UiObjectConfig} from 'uiconfig.js'
  25. import {TweakpaneUiPlugin} from './TweakpaneUiPlugin'
  26. export const makeTextSvg2 = (text: string): string => {
  27. return `data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Ctext style='font: 8px "Roboto Mono", "Source Code Pro", Menlo, Courier, monospace; fill: white;' x='9' y='18'%3E${text}%3C/text%3E%3C/svg%3E%0A`
  28. }
  29. const staticData = {
  30. placeholderVal: 'placeholder',
  31. // renderTarImage: makeColorSvg('ffffff'),
  32. renderTarImage: makeTextSvg('Render Target'),
  33. renderTarImage2: makeTextSvg2('...'),
  34. dataTexImage: makeTextSvg('Data Texture'),
  35. lutCubeTexImage: makeTextSvg('CUBE Texture'),
  36. compressedTexImage: makeTextSvg('Compressed Texture'),
  37. textureMap: {} as any,
  38. imageMap: {} as any,
  39. tempMap: {} as any,
  40. }
  41. const allowedImageExtensions = ['.jpg', '.png', '.svg', '.hdr', '.ktx2',
  42. '.exr', /* '.mp4', '.ogg', '.mov',*/ '.jpeg',
  43. '.bmp', '.gif', '.webp', '.cube', '.ktx2', '.avif', '.ico', '.tiff'] // todo update blueprint editor with this list
  44. function proxyGetValue(cc: any, viewer: ThreeViewer, config: UiObjectConfig) {
  45. if (cc?.get) cc = cc.get()
  46. let ret = staticData.placeholderVal
  47. if (!cc) return ret
  48. if (cc.isCompressedTexture && !cc.image.tp_src) {
  49. cc.image.tp_src = staticData.compressedTexImage
  50. }
  51. // todo: video is not playing
  52. // if (cc.isVideoTexture && !cc.image.tp_src) {
  53. // cc.image.tp_src = dataTexImage
  54. // }
  55. if (cc.isTexture) {
  56. // console.warn('here')
  57. // todo: use textureToCanvas for data texture
  58. if (cc.image && !cc.image.tp_src && !cc.tp_src) {
  59. if (cc.isRenderTargetTexture) {
  60. if (cc._target) {
  61. // doing in the timeout so it doesnt hang when opening a folder which does deep refresh
  62. // if (!config._lastRtRefresh || Date.now() - config._lastRtRefresh > 5000) { // 5000 should be significantly more than 500 + 100 below
  63. setTimeout(() => {
  64. if (!cc._target) return
  65. // here we are not doing cc.image.tp_src because cc.image can be shared across multiple textures in MRT
  66. const dataUrl = viewer.renderManager.renderTargetToDataUrl(cc._target, undefined, undefined, Array.isArray(cc._target.texture) ? cc._target.texture.indexOf(cc) : undefined)
  67. cc.tp_src = dataUrl
  68. setTimeout(()=>cc.tp_src && delete cc.tp_src, 1000) // clear after 1 second so it refreshes on next render
  69. config.uiRefresh?.(false, 'postFrame')
  70. }, 200)
  71. cc.tp_src = staticData.renderTarImage2
  72. // }
  73. // config._lastRtRefresh = Date.now()
  74. }
  75. } else if (cc.image instanceof ImageBitmap || cc.image instanceof HTMLImageElement || cc.image instanceof HTMLVideoElement) { // todo: support playback in video
  76. cc.image.tp_src = imageBitmapToBase64(cc.image, 160)
  77. } else {
  78. cc.image.tp_src = textureToDataUrl(cc, 160, false, 'image/png', 90) // this supports DataTexture also
  79. }
  80. if (!cc.image.tp_src && !cc.tp_src) {
  81. if (cc.isRenderTargetTexture) cc.image.tp_src = staticData.renderTarImage
  82. else if (cc.isDataTexture) cc.image.tp_src = staticData.dataTexImage
  83. }
  84. }
  85. if (cc.image) {
  86. const uid = cc.image.tp_src_uuid as string
  87. ret = uid ? staticData.imageMap[uid] : undefined
  88. if (!ret) ret = cc.image.tp_src || cc.image.src
  89. }
  90. if (cc.tp_src) ret = cc.tp_src
  91. } else if (typeof cc === 'string') {
  92. ret = cc
  93. } else if (cc.domainMin) { // for lut CUBE files.
  94. // ret = cc.texture
  95. const image = cc.texture.image
  96. if (image) {
  97. // todo this will always show placeholder, we need to snapshot data texture
  98. if (!image.tp_src) {
  99. image.tp_src = staticData.lutCubeTexImage
  100. }
  101. const uid = image.tp_src_uuid as string
  102. ret = uid ? staticData.imageMap[uid] : undefined
  103. if (!ret) ret = image.tp_src || image.src
  104. }
  105. } else if (cc) {
  106. console.error('unknown value', cc)
  107. }
  108. if (!ret) ret = staticData.placeholderVal
  109. if (cc.image && !cc.image.tp_src_uuid) {
  110. const uuid = generateUUID()
  111. cc.image.tp_src_uuid = uuid
  112. staticData.tempMap[ret] = uuid
  113. }
  114. ret = staticData.imageMap[ret] ?? ret // Note: this will be a bottleneck if the length of src is too long.
  115. return ret
  116. }
  117. const setterTex = (v1: any, config: UiObjectConfig, renderer: TweakpaneUiPlugin)=>{
  118. if (v1 && v1.isTexture) {
  119. if (!v1.isDataTexture) {
  120. const key = renderer.methods.getBinding(config)[1] + ''
  121. const isLinear = ['normalMap', 'aoMap', 'emissiveMap', 'roughnessMap', 'metalnessMap', 'displacementMap', 'bumpMap', 'alphaMap'].includes(key)
  122. v1.colorSpace = isLinear ? LinearSRGBColorSpace : SRGBColorSpace
  123. v1.wrapS = RepeatWrapping
  124. v1.wrapT = RepeatWrapping
  125. v1.flipY = config.__proxy.value_?.flipY ?? true // todo: figure out flipY
  126. } else {
  127. v1.needsUpdate = true
  128. }
  129. if (v1.image) {
  130. if (!v1.image.id?.length) v1.image.id = generateUUID()
  131. if (!staticData.textureMap[v1.image.id]) staticData.textureMap[v1.image.id] = v1
  132. }
  133. }
  134. config.__proxy.value_ = v1
  135. renderer.methods.setValue(config, v1, {last: true}, false, true)
  136. config.uiRefresh?.(false, 'postFrame')
  137. }
  138. function setterFile(viewer: ThreeViewer, file: File, path: string | undefined, config: UiObjectConfig, renderer: TweakpaneUiPlugin) {
  139. path = path || file.webkitRelativePath || file.name
  140. viewer.assetManager.importer.importSingle<ITexture>({file, path: path}).then(texture => {
  141. if (!texture) {
  142. console.warn('Failed to load texture', file)
  143. return
  144. }
  145. const ext = path?.split('?')?.[0]?.split('.').pop() ?? ''
  146. if ((texture as any).userData) { // todo why is this required?
  147. if (!(texture as any).userData.mimeType)
  148. (texture as any).userData.mimeType = 'image/' + (['jpg', 'jpeg'].includes(ext) ? 'jpeg' : 'png')
  149. }
  150. setterTex(texture, config, renderer)
  151. })
  152. }
  153. function proxySetValue(v: any, cc: any, config: UiObjectConfig, viewer: ThreeViewer, renderer: TweakpaneUiPlugin) {
  154. if (typeof v === 'string') {
  155. if (typeof cc === 'string') setterTex(v, config, renderer)
  156. return
  157. }
  158. v = v || staticData.placeholderVal
  159. if ((v as any).isPlaceholder || v === staticData.placeholderVal) {
  160. if (cc) setterTex(typeof cc === 'string' ? '' : null, config, renderer)
  161. return
  162. }
  163. let iMapKey = v.tp_src_uuid
  164. if (!iMapKey) {
  165. iMapKey = v.src ?? v.tp_src
  166. iMapKey = staticData.tempMap[iMapKey] ?? iMapKey
  167. delete staticData.tempMap[iMapKey]
  168. v.tp_src_uuid = iMapKey
  169. }
  170. if (iMapKey)
  171. staticData.imageMap[iMapKey] = v
  172. // todo: dispose textures if not used.
  173. if (typeof cc === 'string') {
  174. setterTex(iMapKey, config, renderer)
  175. return
  176. }
  177. if (cc === v || cc && (
  178. cc.image === v
  179. || cc.image?.src === v.src
  180. || cc.image?.tp_src === v.tp_src && v.tp_src != null
  181. || cc.image?.tp_src === v.src && v.src != null
  182. || cc.image?.src === v.tp_src && v.tp_src != null
  183. )) return
  184. if (v instanceof File) { // v.src must be from createObjectURL.
  185. setterFile(viewer, v, (v as any).src, config, renderer)
  186. } else if (v.isTexture) {
  187. setterTex(v, config, renderer)
  188. } else { // HTMLImageElement, ImageBitmap, HTMLVideoElement
  189. let tex: ITexture = staticData.textureMap[v.id] || staticData.textureMap[v.src] || staticData.textureMap[v.tp_src]
  190. if (tex) {
  191. setterTex(tex, config, renderer)
  192. return
  193. }
  194. if (SVGTextureLoader.USE_CANVAS_TEXTURE && (v.src?.endsWith('.svg') || v.src?.startsWith('data:image/svg'))) {
  195. // due to windows bug which cannot load svg files in webgl without a width and height
  196. const canvas = document.createElement('canvas')
  197. SVGTextureLoader.CopyImageToCanvas(canvas, v)
  198. v = canvas
  199. }
  200. tex = new Texture(v)
  201. upgradeTexture.call(tex)
  202. tex.assetType = 'texture'
  203. tex.needsUpdate = true
  204. // set userData.mimeType for GLTFExporter
  205. const ext = v.src?.split('?')?.[0]?.split('.').pop() ?? ''
  206. if (!tex.userData.mimeType)
  207. tex.userData.mimeType = 'image/' + (['jpg', 'jpeg'].includes(ext) ? 'jpeg' : 'png')
  208. setterTex(tex, config, renderer)
  209. // todo: make normal maps jpeg always? jpg is lossy
  210. }
  211. }
  212. function removeImage(config: UiObjectConfig, renderer: TweakpaneUiPlugin) {
  213. const vc = config.uiRef.controller_.valueController as any
  214. vc.value.setRawValue('')
  215. const isStr = typeof config.__proxy.value_ === 'string'
  216. setterTex(isStr ? '' : null, config, renderer)
  217. }
  218. function downloadImage(config: UiObjectConfig, _: TweakpaneUiPlugin, viewer: ThreeViewer) {
  219. CustomContextMenu.Remove()
  220. const tex: ITexture&Partial<ImportResultExtras> = config.__proxy.value_
  221. if (!tex) return
  222. const vcv = tex.image ?? config.uiRef.controller_.valueController.value.rawValue
  223. if (tex.__rootBlob && !tex.__rootBlob.objectUrl) tex.__rootBlob.objectUrl = URL.createObjectURL(tex.__rootBlob)
  224. let src = tex.__rootBlob ? tex.__rootBlob.objectUrl : tex.userData.rootPath || vcv?.src
  225. if (src && src.startsWith('blob:')) src = ''
  226. let revokeSrc = false
  227. // HTML image/video/bitmap
  228. if (vcv && (vcv instanceof ImageBitmap || vcv instanceof HTMLImageElement || vcv instanceof HTMLVideoElement) && !src)
  229. src = imageBitmapToBase64(vcv)
  230. let name = tex.__rootBlob ? tex.__rootBlob.name || 'image.' + (tex.__rootBlob.ext || 'png') : null
  231. // Render target texture
  232. if (!src && tex.isRenderTargetTexture) {
  233. const target1 = tex._target
  234. if (target1?.isWebGLRenderTarget) {
  235. const val = viewer.renderManager.exportRenderTarget(target1 as WebGLRenderTarget)
  236. if (!val) {
  237. console.error('cannot export render target', vcv, tex, target1, config)
  238. return
  239. }
  240. name = 'renderTarget.' + (val.ext || 'png')
  241. src = URL.createObjectURL(val)
  242. revokeSrc = true
  243. } else {
  244. console.error('Render target not supported', vcv, tex, target1, config)
  245. return
  246. }
  247. }
  248. // data texture
  249. if (!src && tex.isDataTexture) {
  250. if (tex.type !== HalfFloatType && tex.type !== FloatType) {
  251. // todo: use textureToCanvas for data texture
  252. console.error('Only Float and HalfFloat Data texture export is supported', vcv, tex, config)
  253. return
  254. }
  255. // todo: use viewer.export directly (check threepipe Readme)
  256. const buffer = new EXRExporter2().parse(undefined as any, tex as DataTexture&ITexture)
  257. const val: Blob|undefined = new Blob([buffer], {type: 'image/x-exr'})
  258. if (!val) {
  259. console.error('cannot export data texture', vcv, tex, config)
  260. return
  261. }
  262. name = 'dataTexture.exr'
  263. src = URL.createObjectURL(val)
  264. }
  265. if (!src) {
  266. console.error('cannot export image', vcv, tex, config)
  267. return
  268. }
  269. const link = document.createElement('a')
  270. document.body.appendChild(link)
  271. link.style.display = 'none'
  272. link.href = src
  273. link.download = name || (src.startsWith('data:') ? 'image.png' : src.split('/').pop() ?? 'image.png')
  274. link.target = '_blank'
  275. link.click()
  276. if (revokeSrc) setTimeout(()=>{
  277. document.body.removeChild(link)
  278. URL.revokeObjectURL(src)
  279. }, 1000)
  280. }
  281. async function imageFromUrl(renderer: TweakpaneUiPlugin, config: UiObjectConfig, viewer: ThreeViewer) {
  282. // let url: string|null = navigator.clipboard ? await navigator.clipboard.readText() : ''
  283. let url: string | null = ''
  284. // if (!url || !url.startsWith('http') && !url.startsWith('data:image')) {
  285. // url = ''
  286. // }
  287. url = await renderer.prompt('Load texture: Enter Image/Texture URL', url, true)
  288. if (!url || !url.startsWith('http') && !url.startsWith('data:image')) {
  289. if (url !== null) await renderer.alert('Loading Image: Invalid URL')
  290. return
  291. } else {
  292. url = url.trim()
  293. }
  294. const last = config.__proxy.value_
  295. const isStr = typeof last === 'string'
  296. if (isStr) {
  297. setterTex(url, config, renderer)
  298. } else { // texture
  299. viewer.assetManager.importer.importSingle<ITexture>(url).then(texture => {
  300. if (!texture) {
  301. console.warn('Failed to load texture', url)
  302. return
  303. }
  304. setterTex(texture, config, renderer)
  305. })
  306. }
  307. }
  308. async function imageFromFile(renderer: TweakpaneUiPlugin, config: UiObjectConfig, viewer: ThreeViewer, inp: HTMLInputElement, params: any) {
  309. const last = config.__proxy.value_
  310. const isStr = typeof last === 'string'
  311. if (isStr) {
  312. inp.click()
  313. return
  314. }
  315. const files = await uploadFile(false, false, params.extensions?.map((ext: string) => `image/${ext.replace(/^\./, '')}`).join(', ') ?? 'image/*')
  316. if (!files.length) return
  317. const file = files[0]
  318. setterFile(viewer, file, undefined, config, renderer)
  319. }
  320. export const tpImageInputGenerator: (viewer: ThreeViewer) => (parent: any, config: UiObjectConfig, renderer: TweakpaneUiPlugin, params?: any) => any = (viewer: ThreeViewer) => (parent: any /* FolderApi */, config: UiObjectConfig, renderer: TweakpaneUiPlugin, params?: any) => {
  321. // if (config.value !== undefined) throw 'Not supported yet'
  322. if (!config.__proxy) {
  323. config.__proxy = {
  324. listedOnChange: false,
  325. }
  326. Object.defineProperty(config.__proxy, 'value', {
  327. get: () => {
  328. try {
  329. config.__proxy.value_ = renderer.methods.getRawValue(config) // sending undefined to disable comparison for undo etc
  330. const ret = proxyGetValue(config.__proxy.value_, viewer, config) as any
  331. if (typeof ret !== 'string' && !ret.id?.length) ret.id = generateUUID()
  332. const id = typeof ret === 'string' ? ret : ret.id ?? ret
  333. if (!staticData.textureMap[id]) staticData.textureMap[id] = config.__proxy.value_
  334. return ret
  335. } catch (e) {
  336. console.error('uiconfig-tweakpane - ImageInput Unknown error', e)
  337. return staticData.placeholderVal
  338. }
  339. },
  340. set: (v: any) => {
  341. if (getOrCall(config.readOnly)) return
  342. config.__proxy.value_ = renderer.methods.getRawValue(config) // current value
  343. proxySetValue(v, config.__proxy.value_, config, viewer, renderer)
  344. },
  345. })
  346. }
  347. config.__proxy.value_ = renderer.methods.getRawValue(config)
  348. params = params ?? {}
  349. params.extensions = allowedImageExtensions
  350. if (typeof params.imageFit === 'undefined') params.imageFit = 'contain'
  351. if (typeof params.clickCallback === 'undefined') params.clickCallback = (ev: MouseEvent, inp: HTMLInputElement) => {
  352. const target = ev?.target as HTMLElement
  353. const rect = target?.getBoundingClientRect()
  354. if (!rect) {
  355. inp.click()
  356. return
  357. }
  358. const cv = config.uiRef.controller_.valueController.value.rawValue
  359. const isPlaceholder = cv === staticData.placeholderVal || cv?.isPlaceholder
  360. const items: any = isPlaceholder ? {} : {
  361. ['download image']: () => downloadImage(config, renderer, viewer),
  362. }
  363. const readOnly = getOrCall(config.readOnly)
  364. if (!isPlaceholder && !readOnly) Object.assign(items, {
  365. ['remove image']: () => removeImage(config, renderer),
  366. })
  367. if (!readOnly) Object.assign(items, {
  368. ['set/replace image']: async() => imageFromFile(renderer, config, viewer, inp, params),
  369. ['from url']: async() => imageFromUrl(renderer, config, viewer),
  370. })
  371. const menu = CustomContextMenu.Create({
  372. ...items,
  373. 'cancel': () => {return},
  374. }, 2, rect.height + 8, false, true)
  375. target.parentElement?.appendChild(menu)
  376. if (rect.y > document.body.clientHeight * 0.7) {
  377. menu.style.top = 'auto'
  378. menu.style.bottom = rect.height + 8 + 'px'
  379. }
  380. config.uiRefresh?.(false, 'postFrame')
  381. }
  382. params.view = 'input-image'
  383. return renderer.typeGenerators.input(parent, config, renderer, params)
  384. }