Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/troika-three-text/images/screenshot6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
690 changes: 354 additions & 336 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"author": "Jason Johnston <jason@lojjic.com>",
"license": "MIT",
"devDependencies": {
"@ampproject/rollup-plugin-closure-compiler": "^0.26.0",
"@ampproject/rollup-plugin-closure-compiler": "^0.27.0",
"@babel/core": "^7.12.16",
"@babel/preset-env": "^7.12.16",
"@babel/preset-react": "^7.12.13",
Expand Down
2 changes: 1 addition & 1 deletion packages/troika-3d-text/src/facade/Text3DFacade.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const TEXT_MESH_PROPS = [
'whiteSpace',
'material',
'color',
'colorRanges',
'styleRanges',
'fillOpacity',
'outlineOpacity',
'outlineColor',
Expand Down
1 change: 0 additions & 1 deletion packages/troika-3d-ui/src/facade/UIBlock3DFacade.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,6 @@ class UIBlock3DFacadeBase extends Group3DFacade {
textChild.whiteSpace = getInheritable(this, 'whiteSpace')
textChild.overflowWrap = getInheritable(this, 'overflowWrap')
textChild.color = getInheritable(this, 'color')
textChild.colorRanges = this.colorRanges
textChild.outlineWidth = this.textOutlineWidth || 0
textChild.outlineColor = this.textOutlineColor
textChild.outlineOpacity = this.textOutlineOpacity
Expand Down
68 changes: 55 additions & 13 deletions packages/troika-examples/text/TextExample.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export const FONTS = {
'Snowburst One': 'https://fonts.gstatic.com/s/snowburstone/v5/MQpS-WezKdujBsXY3B7I-UT7SZieOA.woff',
'Syncopate': 'https://fonts.gstatic.com/s/syncopate/v9/pe0sMIuPIYBCpEV5eFdCBfe5.woff',
'Wallpoet': 'https://fonts.gstatic.com/s/wallpoet/v9/f0X10em2_8RnXVVdUObp58I.woff',
'Sirin Stencil': 'https://fonts.gstatic.com/s/sirinstencil/v6/mem4YaWwznmLx-lzGfN7MdRyRc9MAQ.woff'
'Sirin Stencil': 'https://fonts.gstatic.com/s/sirinstencil/v6/mem4YaWwznmLx-lzGfN7MdRyRc9MAQ.woff',
// https://www.cdnfonts.com/caxton-bk-bt.font
'Caxton': 'https://fonts.cdnfonts.com/s/13390/CAXTON~2.woff',
'Caxton Bold': 'https://fonts.cdnfonts.com/s/13390/CAXTON~7.woff',
'Caxton Italic': 'https://fonts.cdnfonts.com/s/13390/CAXTON~3.woff',
'Caxton Bold Italic': 'https://fonts.cdnfonts.com/s/13390/CAXTON~6.woff'
}

const CUSTOM_LBL = '(Custom...)'
Expand Down Expand Up @@ -71,6 +76,8 @@ November 19, 1863`,

'Emoji': 'Examples of emoji are 😂, 😃, 🧘🏻‍♂️, 🌍, 🌦️, 🥖, 🚗, 📱, 🎉, ❤️, ✅, and 🏁.',

'Rich Text': 'This is a Rich Text example with color, font (Bold Italic), size and vertical-align variations. Font fallbacks work! 😉',

// TODO fix in XR:
[CUSTOM_LBL]: 'Edit me!'
}
Expand Down Expand Up @@ -132,6 +139,7 @@ class TextExample extends React.Component {
outlineOpacity: 1,
outlineBlur: 0,
curveRadius: 0,
styleRanges: {},
fog: false,
animTextColor: true,
animTilt: true,
Expand All @@ -140,7 +148,6 @@ class TextExample extends React.Component {
useTexture: false,
shadows: false,
selectable: false,
colorRanges: false,
sdfGlyphSize: 6,
debugSDF: false
}
Expand All @@ -150,6 +157,50 @@ class TextExample extends React.Component {
newState.textScale = 0.5
newState.maxWidth = 2.5
}
if (newState.text === 'Rich Text' && newState.text !== this.state.text) {
newState.font = 'Caxton';
newState.fontSize = 0.17;
newState.color = 0x997700;
// EXAMPLE styleRanges test cases for TEXTS['Rich Text']
newState.styleRanges = {
// All styles: Color + Font + Size + vAlign
10: { color: 0xe0ce09, font: FONTS['Caxton Bold Italic'], size: 0.27, valign: .06 },
19: null, // reset all/any styles to default

// Color
33: { color: 0xEA3323 },
34: { color: 0xEF8632 },
35: { color: 0xFFFF54 },
36: { color: 0x54B951 },
37: { color: 0x2B66F6 },
38: { color: null },

// Font
40: { font: FONTS['Orbitron'] },
45: { font: null},
46: { font: FONTS['Caxton Bold'] },
51: { font: FONTS['Caxton Italic'] },
57: { font: null },

// Size
60: { size: 0.28 },
64: { size: null },

// vAlign
69: { valign: -.03, size: 0.1 },
77: { valign: null, size: null },
78: { valign: .05, size: 0.1 },
83: { valign: null, size: null },

// Font on character requiring fallback
117: { color: 0xe0ce09, font: FONTS['Caxton Italic'] },
118: { color: null, font: null },
}
} else if (newState.text && newState.text !== 'Rich Text') {
// switching away from Rich Text — clear style ranges
newState.styleRanges = {};
}

this.setState(newState)
}
}
Expand Down Expand Up @@ -232,8 +283,9 @@ class TextExample extends React.Component {
strokeWidth: state.strokeWidth,
strokeColor: state.strokeColor,
curveRadius: state.curveRadius,
styleRanges: state.styleRanges,
material: material,
color: 0xffffff,
color: state.color || 0xffffff,
scaleX: state.textScale || 1,
scaleY: state.textScale || 1,
scaleZ: state.textScale || 1,
Expand All @@ -247,15 +299,6 @@ class TextExample extends React.Component {
// onMouseMove: e => {
// this.setState({hoverPoint: e.intersection.point})
// },
colorRanges: state.colorRanges ? TEXTS[state.text].split('').reduce((out, char, i) => {
if (i === 0 || /\s/.test(char)) {
out[i] = (Math.floor(Math.pow(Math.sin(i), 2) * 256) << 16)
| (Math.floor(Math.pow(Math.sin(i + 1), 2) * 256) << 8)
| (Math.floor(Math.pow(Math.sin(i + 2), 2) * 256))
//out[i] = '#' + new Color(out[i]).getHexString()
}
return out
}, {}) : null,
transition: {
scaleX: true,
scaleY: true,
Expand Down Expand Up @@ -334,7 +377,6 @@ class TextExample extends React.Component {
{type: 'boolean', path: "animRotate", label: "Rotate"},
{type: 'boolean', path: "fog", label: "Fog"},
{type: 'boolean', path: "shadows", label: "Shadows"},
{type: 'boolean', path: "colorRanges", label: "colorRanges (WIP)"},
{type: 'boolean', path: "selectable", label: "Selectable (WIP)"},
{type: 'number', path: "fontSize", label: "fontSize", min: 0.01, max: 0.2, step: 0.01},
{type: 'number', path: "textScale", label: "scale", min: 0.1, max: 10, step: 0.1},
Expand Down
40 changes: 40 additions & 0 deletions packages/troika-three-text/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Once the SDFs are generated, it assembles a geometry that positions all the glyp

![Unicode coverage](../../docs/troika-three-text/images/screenshot5.png)

![Rich text](../../docs/troika-three-text/images/screenshot6.png)

## Installation

Get it from [NPM](https://www.npmjs.com/package/troika-three-text):
Expand Down Expand Up @@ -289,6 +291,44 @@ The width can be specified as either an absolute number in local units, or as a

Default: `0`

### `styleRanges`

Enables rich-text rendering by applying per-character style overrides to a range of characters. Each key in `styleRanges` `Array` is the starting character index in the `text` string at which a set of styles becomes active; those styles remain active until the next key. The value of the keys is a style range object.

Supported style properties in each style range object are: `{ color, font, size, valign }`
* `color: number | string` - Overrides the text `color` for this range. Accepts any [`THREE.Color#set`](https://threejs.org/docs/#api/en/math/Color.set).
* `font: string` - URL of a font file to use for this range. If the font does not contain a glyph for a character, the system falls back to the global `font` or unicode fallback fonts.
* `size: number` - Overrides `fontSize` for this range, in the same local world units.
* `valign: number` - Vertical offset applied to glyphs in this range, in local world units. Positive moves glyphs up; negative moves them down. Useful for superscripts and subscripts.

Setting a property to `null` resets it to the global default at that character index. Setting the entire value to `null` resets all style properties at once.

```js
myText.text = 'Hello rich-text world!'
myText.fontSize = 0.1
myText.color = 0xffffff
myText.styleRanges = {
// At character 6 (start of 'rich-text'), apply a color and size
6: { color: 0xff6600, size: 0.15 },
// At character 15 (end of 'rich-text'), reset color size
15: { color: null, size: null },
// At character 16 (start of 'world'), apply font, size and valign
16: { font: '/fonts/MyBoldFont.woff', valign: 0.04, size: 0.07 },
// At character 15 (end of 'world'), reset all styles to the defaults
21: null,
}
myText.sync()
```

To clear all style ranges, set styleRanges to `null` or `{}`:

```js
myText.styleRanges = null
myText.sync()
```

Default: `null`

### `textAlign`

The horizontal alignment of each line of text within the overall text bounding box. Can be one of `'left'`, `'right'`, `'center'`, or `'justify'`.
Expand Down
125 changes: 84 additions & 41 deletions packages/troika-three-text/src/FontResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { defineWorkerModule } from "troika-worker-utils";

/**
* @typedef {string | {src:string, label?:string, unicodeRange?:string, lang?:string}} UserFont
* @typedef {{[start]: {font: UserFont, color: number|string}}} UserStyle
*/

/**
* @typedef {ClientOptions} FontResolverOptions
* @property {Array<UserFont>|UserFont} [fonts]
* @property {UserStyles} [styleRanges]
* @property {'normal'|'italic'} [style]
* @property {'normal'|'bold'|number} [style]
* @property {string} [unicodeFontsURL]
Expand Down Expand Up @@ -109,6 +111,7 @@ export function createFontResolver(fontParser, unicodeFontResolverClient) {
fonts: userFonts = [],
style = 'normal',
weight = 'normal',
styleRanges,
unicodeFontsURL
} = {}) {
const charResolutions = new Uint8Array(text.length);
Expand Down Expand Up @@ -145,47 +148,83 @@ export function createFontResolver(fontParser, unicodeFontResolverClient) {
// Carry previous character's result forward if:
// - it resolved to a font that also covers this character
// - this character is whitespace
// - there is no styleRanges[].font instruction for this character index
if (
(prevCharResult === RESOLVED && fontResolutions[charResolutions[i - 1]].supportsCodePoint(codePoint)) ||
(i > 0 && /\s/.test(text[i]))
(
(prevCharResult === RESOLVED && fontResolutions[charResolutions[i - 1]].supportsCodePoint(codePoint)) ||
(i > 0 && /\s/.test(text[i]))
) &&
!(styleRanges && styleRanges[i] && !!styleRanges[i].font)
) {
charResolutions[i] = charResolutions[i - 1]
// Carry resolved font forward
charResolutions[i] = charResolutions[i - 1];
if (prevCharResult === NEEDS_FALLBACK) {
fallbackRanges[fallbackRanges.length - 1][1] = i
}
} else {
for (let j = charResolutions[i], jLen = userFonts.length; j <= jLen; j++) {
if (j === jLen) {
// none of the user fonts matched; needs fallback
const range = prevCharResult === NEEDS_FALLBACK ?
fallbackRanges[fallbackRanges.length - 1] :
(fallbackRanges[fallbackRanges.length] = [i, i])
range[1] = i;
prevCharResult = NEEDS_FALLBACK;
} else {
charResolutions[i] = j;
const { src, unicodeRange } = userFonts[j];
// filter by optional explicit unicode ranges
if (!unicodeRange || isCodeInRanges(codePoint, unicodeRange)) {
const fontObj = parsedFonts[src];
// font not yet loaded, load it and resume
if (!fontObj) {
loadFont(src, () => {
resolveUserFonts(i);
});
return;
}
// if the font actually contains a glyph for this char, lock it in
if (fontObj.supportsCodePoint(codePoint)) {
let fontIndex = fontIndices.get(fontObj);
if (typeof fontIndex !== 'number') {
fontIndex = fontResolutions.length;
fontResolutions.push(fontObj);
fontIndices.set(fontObj, fontIndex);
// Support styleRanges[].font at this index
let resolvedByStyleFont = false;
if ((!!styleRanges && !!styleRanges[i] && !!styleRanges[i].font)) {
const fontObj = parsedFonts[styleRanges[i].font];
if (!fontObj) {
loadFont(styleRanges[i].font, () => {
resolveUserFonts(i);
});
return;
}
if (fontObj.supportsCodePoint(codePoint)) {
let fontIndex = fontIndices.get(fontObj);
if (typeof fontIndex !== 'number') {
fontIndex = fontResolutions.length;
fontResolutions.push(fontObj);
fontIndices.set(fontObj, fontIndex);
}
// Set this character font index
charResolutions[i] = fontIndex;
prevCharResult = RESOLVED;
resolvedByStyleFont = true;
}
// if the styleRanges[].font doesn't cover this character, fall through to fallback
}
if (!resolvedByStyleFont) {
// Support fallback font
for (let j = charResolutions[i], jLen = userFonts.length; j <= jLen; j++) {
if (j === jLen) {
// none of the user fonts matched; needs fallback
const range = prevCharResult === NEEDS_FALLBACK ?
fallbackRanges[(fallbackRanges.length || 1) - 1]:
(fallbackRanges[fallbackRanges.length] = [i, i])
range[1] = i;
prevCharResult = NEEDS_FALLBACK;
} else {
// Check each character to see if this userFont supports it
charResolutions[i] = j;
// Find default font
const { src, unicodeRange } = userFonts.find(f => f.label === 'default');
// Filter by optional explicit unicode ranges
if (src && (!unicodeRange || isCodeInRanges(codePoint, unicodeRange))) {
const fontObj = parsedFonts[src];
// font not yet loaded, load it and resume
if (!fontObj) {
loadFont(src, () => {
resolveUserFonts(i);
});
return;
}
// if the font actually contains a glyph for this char, lock it in
if (fontObj.supportsCodePoint(codePoint)) {
let fontIndex = fontIndices.get(fontObj);
// Set new font index
if (typeof fontIndex !== 'number') {
fontIndex = fontResolutions.length;
fontResolutions.push(fontObj);
fontIndices.set(fontObj, fontIndex);
}
// set this character font index
charResolutions[i] = fontIndex;
prevCharResult = RESOLVED;
break;
}
charResolutions[i] = fontIndex;
prevCharResult = RESOLVED;
break;
}
}
}
Expand Down Expand Up @@ -229,14 +268,18 @@ export function createFontResolver(fontParser, unicodeFontResolverClient) {

// Load and parse the fallback fonts - avoiding Promise here to prevent polyfills in the worker
let loadedCount = 0;
fontUrls.forEach((url, i) => {
loadFont(url, fontObj => {
fontResolutions[i + fontIndexOffset] = fontObj
if (++loadedCount === fontUrls.length) {
allDone();
}
if (!fontUrls.length) {
allDone();
} else {
fontUrls.forEach((url, i) => {
loadFont(url, fontObj => {
fontResolutions[i + fontIndexOffset] = fontObj
if (++loadedCount === fontUrls.length) {
allDone();
}
})
})
})
}
});
} else {
allDone();
Expand Down
12 changes: 11 additions & 1 deletion packages/troika-three-text/src/GlyphsGeometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,19 @@ class GlyphsGeometry extends InstancedBufferGeometry {
// If length isn't changing, just update the attribute's array data
if (attr && attr.array.length === newArray.length) {
attr.array.set(newArray)
// Compatibility shim: Three.js <r156 renderers expect updateRange (singular object),
// while newer Three.js BufferAttribute uses updateRanges (array). If this attribute
// was created by a newer Three.js but the renderer is older, add the legacy property.
if (attr.updateRange === undefined) {
attr.updateRange = { offset: 0, count: -1 }
}
Comment thread
phinity marked this conversation as resolved.
attr.needsUpdate = true
} else {
this.setAttribute(attrName, new InstancedBufferAttribute(newArray, itemSize))
const newAttr = new InstancedBufferAttribute(newArray, itemSize)
if (newAttr.updateRange === undefined) {
newAttr.updateRange = { offset: 0, count: -1 }
}
this.setAttribute(attrName, newAttr)
// If the new attribute has a different size, we also have to (as of r117) manually clear the
// internal cached max instance count. See https://github.com/mrdoob/three.js/issues/19706
// It's unclear if this is a threejs bug or a truly unsupported scenario; discussion in
Expand Down
Loading
Loading