import AFRAME from 'aframe' const THREE = AFRAME.THREE export const ComponentName = 'text-cell' const loadBMFont = require('load-bmfont') const FONT_BASE_URL = 'https://cdn.aframe.io/fonts/' const FONTS = { aileronsemibold: FONT_BASE_URL + 'Aileron-Semibold.fnt', dejavu: FONT_BASE_URL + 'DejaVu-sdf.fnt', exo2bold: FONT_BASE_URL + 'Exo2Bold.fnt', exo2semibold: FONT_BASE_URL + 'Exo2SemiBold.fnt', kelsonsans: FONT_BASE_URL + 'KelsonSans.fnt', monoid: FONT_BASE_URL + 'Monoid.fnt', mozillavr: FONT_BASE_URL + 'mozillavr.fnt', roboto: FONT_BASE_URL + 'Roboto-msdf.json', sourcecodepro: FONT_BASE_URL + 'SourceCodePro.fnt' } const DEFAULT_FONT = 'roboto' export interface SystemData { } export const SystemSchema: AFRAME.Schema = { } interface position { x: number y: number z: number } export interface SystemProps { loadFont: (src: string, yOffset: number) => Promise, computeFontWidthFactor: (font: any) => number, computeWidth: (wrapPixels: number, wrapCount: number, widthFactor: number) => number, comparePositions(posA: position, posB: position) } export const SystemDef: AFRAME.SystemDefinition = { schema: SystemSchema, data: { } as SystemData, init () { const oldUpdateLayout = (AFRAME.components.text.Component.prototype as any).updateLayout; (AFRAME.components.text.Component.prototype as any).updateLayout = function() { oldUpdateLayout.call(this) this.el.emit('textlayoutchanged', { name: this.attrName, id: this.id }) } }, play() { }, pause() { }, loadFont (src: string, yOffset: number) { return new Promise((resolve, reject) => { loadBMFont(src, (err, font) => { if (err) { console.error('Error loading font', src) reject(err) return } // Fix negative Y offsets for Roboto MSDF font from tool. Experimentally determined. if (src.indexOf('/Roboto-msdf.json') >= 0) { yOffset = 30 } if (yOffset) { font.chars.map((ch) => { ch.yoffset += yOffset }) } resolve(font) }) }) }, computeFontWidthFactor (font) { let sum = 0 let digitsum = 0 let digits = 0 font.chars.map((ch) => { sum += ch.xadvance if (ch.id >= 48 && ch.id <= 57) { digits++ digitsum += ch.xadvance } }) return digits ? digitsum / digits : sum / font.chars.length }, computeWidth (wrapPixels, wrapCount, widthFactor) { return wrapPixels || ((0.5 + wrapCount) * widthFactor) }, comparePositions(posA: position, posB: position) { return posA.x === posB.x && posA.y === posB.y && posA.z === posB.z } } export interface Data { [key: string]: any, id: string width: number height: number text: string color: string fontsize: number wrapcount: number wrappixels: number nobr: boolean border: boolean bordersize: number borderColor: string wrapfit: boolean // justifycontent: string anchor: string align: string baseline: string } export const ComponentSchema: AFRAME.MultiPropertySchema = { id: { type: 'string', default: '' }, width: { type: 'number', default: 0.6 }, height: { type: 'number', default: 0.6 }, text: { type: 'string', default: 'text' }, color: { default: '#FFF' }, fontsize: { type: 'number', default: 1 }, wrapcount: { type: 'number', default: 20 }, wrappixels: { type: 'number', default: 0 }, nobr: { type: 'boolean', default: false }, border: { type: 'boolean', default: true }, bordersize: { type: 'number', default: 0.05 }, borderColor: { default: '#484848' }, wrapfit: { type: 'boolean', default: false }, // justifycontent: { type: 'string', default: 'flexStart' }, anchor: { type: 'string', default: 'center' }, align: { type: 'string', default: 'center' }, baseline: { type: 'string', default: 'center' } } interface offsetInterface { x?: number y?: number z?: number } export interface Props { initTextCell: () => void, addHandlers: () => void, removeHandlers: () => void, clippingSetUp: boolean, worldPosition: any, lineHeight: number, widthFactor: number, textRenderWidth: number, textScale: number, textHeight: number, createText: (opts: createTextOpts) => void, setUpTextHandler: (id: string, offset: offsetInterface) => void, textLayoutChangedHandler: (evt: any) => void, setUpClipping: (id: string) => void, updateClipping: (id: string) => void } interface createTextOpts { id?: string fontsize?: number width?: number wrapcount?: number text?: string height?: number color?: number align?: string baseline?: string } export const Component: AFRAME.ComponentDefinition = { schema: ComponentSchema, data: { } as Data, clippingSetUp: false, worldPosition: {}, lineHeight: 0, widthFactor: 0, textRenderWidth: 0, textScale: 0, textHeight: 0, init () { if (this.el.sceneEl?.hasLoaded) this.initTextCell() else this.el.sceneEl?.addEventListener('loaded', this.initTextCell.bind(this)) }, play() { this.addHandlers() }, pause() { this.removeHandlers() }, update() { const self = this const data = self.data const textName = data.id !== '' ? `text__${data.id}` : 'text' self.el.removeAttribute(textName) const font = self.data.font || DEFAULT_FONT this.system.loadFont(FONTS[font]).then( (result) => { self.lineHeight = result.common.lineHeight self.widthFactor = this.system.computeFontWidthFactor(result) self.textRenderWidth = this.system.computeWidth(self.data.wrappixels, self.data.wrapcount, self.widthFactor) self.textScale = self.data.width / self.textRenderWidth self.textHeight = self.lineHeight * self.textScale * self.data.fontsize self.el.addEventListener('textlayoutchanged', self.textLayoutChangedHandler.bind(self), { once: true }) self.createText({ id: data.id, text: data.text, width: data.width, height: data.height, color: data.color }) } ) }, tick() { const position = new THREE.Vector3() this.el.object3D.getWorldPosition(position) if (!!this.worldPosition && !this.system.comparePositions(this.worldPosition, position)) { this.updateClipping(this.data.id) this.worldPosition = position } }, initTextCell() { this.worldPosition = this.el.object3D.getWorldPosition() }, createText(opts: createTextOpts) { const self = this const data = self.data const id = opts.id || '' const textName = id !== '' ? `text__${id}` : 'text' const fontSize = opts.fontsize || data.fontsize const width = opts.width || data.width let wrapCount = opts.wrapcount || data.wrapcount if (data.wrapfit) { wrapCount = (width / fontSize) * (20 / 0.3) } const wrapSize = (wrapCount) / 20 const widthScale = 0.3 / (width) const textWidth = width * fontSize * widthScale * wrapSize const xOffset = (textWidth - width) / 2 const text = opts.text || data.text const height = opts.height || data.height const color = opts.color || data.color const align = opts.align || data.align const baseline = opts.baseline || data.baseline self.el.setAttribute(textName, { // id: id, value: text, width: textWidth, height: height, wrapCount: wrapCount, xOffset: xOffset, color: color, anchor: data.anchor, align: align, baseline: baseline }) const textObj = self.el.getObject3D(textName) this.textObj = textObj }, setUpTextHandler(id: string, offset: offsetInterface) { const self = this const object3DName = id !== '' ? `text__${id}` : 'text' const textObj = self.el.getObject3D(object3DName) const lineHeight = textObj.geometry.layout._lineHeight const linesTotal = textObj.geometry.layout._linesTotal const textScale = textObj.scale.x const halfLines = linesTotal % 2 === 0 ? linesTotal / 2 : (linesTotal) / 2 - 1 let offsetY = self.data.height / 2 if (linesTotal > 1) { offsetY -= halfLines * (lineHeight * textScale) } else { offsetY -= (lineHeight * textScale) / 2 } textObj.translateY(offsetY) if (offset.x) textObj.translateX(offset.x) if (offset.y) textObj.translateY(offset.y) if (offset.z) textObj.translateZ(offset.z) self.setUpClipping(id) }, setUpClipping(id: string) { const self = this const el = self.el if (self.clippingSetUp) { return } const object3DName = id !== '' ? `text__${id}` : 'text' const textObj = self.el.getObject3D(object3DName) const lineHeight = textObj.geometry.layout._lineHeight // const linesTotal = textObj.geometry.layout._linesTotal const textScale = textObj.scale.x const offsetY = 0 const renderer = el.sceneEl.renderer renderer.localClippingEnabled = true el.sceneEl.object3D.updateMatrixWorld() const pos = new THREE.Vector3() el.object3D.getWorldPosition(pos) const posy = pos.y const height = textObj.geometry.layout._opt.height const normalBot = new THREE.Vector3(0, 1, 0) let constantBot = -(posy + self.data.height / 2 + offsetY - height) if (self.data.nobr) { const lineRemainder = (height) % (lineHeight * textScale) constantBot -= lineRemainder } const clippingPlaneBot = new THREE.Plane(normalBot, constantBot) self.clippingPlaneBot = clippingPlaneBot const normalTop = new THREE.Vector3(0, -1, 0) const constantTop = posy + self.data.height / 2 + offsetY const clippingPlaneTop = new THREE.Plane(normalTop, constantTop) self.clippingPlaneTop = clippingPlaneTop const mat = textObj.material mat.clipping = true mat.clippingPlanes = [clippingPlaneBot, clippingPlaneTop] mat.vertexShader = 'precision highp float;\n' + '#include \n' + mat.vertexShader.replace(/(void main.*)/, '$1\n #include ') .replace(/}$/, ' #include \n #include \n}') mat.fragmentShader = 'precision highp float;\n' + '#include \n' + mat.fragmentShader.replace(/(void main.*)/, '$1\n #include ') // set isMeshBasicMaterial so that the WebGLRenderer updates opacity uniform during animations mat.isMeshBasicMaterial = true mat.needsUpdate = true self.clippingSetUp = true }, updateClipping(id: string) { const self = this const el = self.el if (!self.clippingPlaneTop) { return } const object3DName = id !== '' ? `text__${id}` : 'text' const textObj = self.el.getObject3D(object3DName) if (!textObj) { return } const lineHeight = textObj.geometry.layout._lineHeight // const linesTotal = textObj.geometry.layout._linesTotal const textScale = textObj.scale.x const offsetY = 0 el.sceneEl.object3D.updateMatrixWorld() const pos = new THREE.Vector3() el.object3D.getWorldPosition(pos) const posy = pos.y const height = textObj.geometry.layout._opt.height let constantBot = -(posy + self.data.height / 2 + offsetY - height) self.clippingPlaneBot.constant = constantBot if (self.data.nobr) { const lineRemainder = (height) % (lineHeight * textScale) constantBot -= lineRemainder } const constantTop = posy + self.data.height / 2 + offsetY self.clippingPlaneTop.constant = constantTop const mat = textObj.material mat.needsUpdate = true }, textLayoutChangedHandler(evt) { this.setUpTextHandler(evt.detail.id || '', {}) }, addHandlers: function() { this.el.removeEventListener('textlayoutchanged', this.textLayoutChangedHandler.bind(this)) }, removeHandlers: function() { this.el.removeEventListener('textlayoutchanged', this.textLayoutChangedHandler.bind(this)) } } export const Primitive: AFRAME.PrimitiveDefinition = { defaultComponents: { ComponentName: {} }, deprecated: false, mappings: { id: ComponentName + '.id', value: ComponentName + '.text', width: ComponentName + '.width', height: ComponentName + '.height', fontsize: ComponentName + '.fontsize', wrapcount: ComponentName + '.wrapcount', nobr: ComponentName + '.nobr', color: ComponentName + '.color', font: ComponentName + '.font', wrapfit: ComponentName + '.wrapfit', lines: ComponentName + '.lines', anchor: ComponentName + '.anchor', align: ComponentName + '.align', baseline: ComponentName + '.baseline' } } const ComponentSystem = { name: ComponentName, system: SystemDef, component: Component, primitive: Primitive } export default ComponentSystem