threepipe
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

tpImageInputGenerator.ts 17KB

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