diff --git a/src/ol/style/expressions.js b/src/ol/style/expressions.js index 1dbeeb45ae..b3d2961bb0 100644 --- a/src/ol/style/expressions.js +++ b/src/ol/style/expressions.js @@ -3,7 +3,7 @@ * @module ol/style/expressions */ -import {isStringColor} from '../color.js'; +import {asArray, isStringColor} from '../color.js'; /** * Base type used for literal style parameters; can be a number literal or the output of an operator, @@ -112,6 +112,88 @@ export function getValueType(value) { return operator.getReturnType(valueArr.slice(1)); } +/** + * Context available during the parsing of an expression. + * @typedef {Object} ParsingContext + * @property {boolean} inFragmentShader If false, means the expression output should be made for a vertex shader + */ + +/** + * Will return the number as a float with a dot separator, which is required by GLSL. + * @param {number} v Numerical value. + * @returns {string} The value as string. + */ +export function numberToGlsl(v) { + const s = v.toString(); + return s.indexOf('.') === -1 ? s + '.0' : s; +} + +/** + * Will return the number array as a float with a dot separator, concatenated with ', '. + * @param {Array} array Numerical values array. + * @returns {string} The array as a vector, e. g.: `vec3(1.0, 2.0, 3.0)`. + */ +export function arrayToGlsl(array) { + if (array.length < 2 || array.length > 4) { + throw new Error('`formatArray` can only output `vec2`, `vec3` or `vec4` arrays.'); + } + return `vec${array.length}(${array.map(numberToGlsl).join(', ')})`; +} + +/** + * Will normalize and converts to string a `vec4` color array compatible with GLSL. + * @param {string|import("../color.js").Color} color Color either in string format or [r, g, b, a] array format, + * with RGB components in the 0..255 range and the alpha component in the 0..1 range. + * Note that the final array will always have 4 components. + * @returns {string} The color expressed in the `vec4(1.0, 1.0, 1.0, 1.0)` form. + */ +export function colorToGlsl(color) { + const array = asArray(color).slice(); + if (array.length < 4) { + array.push(1); + } + return arrayToGlsl( + array.map(function(c, i) { + return i < 3 ? c / 255 : c; + }) + ); +} + +/** + * Recursively parses a style expression and outputs a GLSL-compatible string. Takes in a parsing context that + * will be read and modified during the parsing operation. + * @param {ParsingContext} context Parsing context + * @param {ExpressionValue} value Value + * @param {ValueTypes} [typeHint] Hint for the expected final type + * @returns {string} GLSL-compatible output + */ +export function expressionToGlsl(context, value, typeHint) { + // operator + if (Array.isArray(value) && typeof value[0] === 'string') { + const operator = Operators[value[0]]; + if (operator === undefined) { + throw new Error(`Unrecognized expression operator: ${JSON.stringify(value)}`); + } + return operator.toGlsl(context, value.slice(1)); + } else if ((getValueType(value) & ValueTypes.NUMBER) > 0) { + return numberToGlsl(/** @type {number} */(value)); + } else if ((getValueType(value) & ValueTypes.BOOLEAN) > 0) { + return value.toString(); + } else if ( + ((getValueType(value) & ValueTypes.STRING) > 0) && + (typeHint === undefined || typeHint == ValueTypes.STRING) + ) { + return value.toString(); + } else if ( + ((getValueType(value) & ValueTypes.COLOR) > 0) && + (typeHint === undefined || typeHint == ValueTypes.COLOR) + ) { + return colorToGlsl(/** @type {number[]|string} */(value)); + } else if ((getValueType(value) & ValueTypes.NUMBER_ARRAY) > 0) { + return arrayToGlsl(/** @type {number[]} */(value)); + } +} + function assertNumber(value) { if (!(getValueType(value) & ValueTypes.NUMBER)) { throw new Error(`A numeric value was expected, got ${JSON.stringify(value)} instead`); @@ -138,19 +220,13 @@ function assertArgsCount(args, count) { } } -/** - * Context available during the parsing of an expression. - * @typedef {Object} ParsingContext - * @property {boolean} inFragmentShader If false, means the expression output should be made for a vertex shader - */ - /** * An operator declaration must contain two methods: `getReturnType` which returns a type based on * the operator arguments, and `toGlsl` which returns a GLSL-compatible string. * Note: both methods can process arguments recursively. * @typedef {Object} Operator - * @property {function(...ExpressionValue): ValueTypes|number} getReturnType Returns one or several types - * @property {function(ParsingContext, ...ExpressionValue): string} toGlsl Returns a GLSL-compatible string + * @property {function(Array): ValueTypes|number} getReturnType Returns one or several types + * @property {function(ParsingContext, Array): string} toGlsl Returns a GLSL-compatible string */ /** @@ -159,218 +235,225 @@ function assertArgsCount(args, count) { */ export const Operators = { 'get': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.ANY; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { const prefix = context.inFragmentShader ? 'v_' : 'a_'; assertArgsCount(args, 1); assertString(args[0]); - return prefix + args[0]; + return prefix + expressionToGlsl(context, args[0]); } }, 'var': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.ANY; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 1); assertString(args[0]); - return `u_${args[0]}`; + return `u_${expressionToGlsl(context, args[0])}`; } }, 'time': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 0); return 'u_time'; } }, '*': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} * ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} * ${expressionToGlsl(context, args[1])})`; } }, '/': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} / ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} / ${expressionToGlsl(context, args[1])})`; } }, '+': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} + ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} + ${expressionToGlsl(context, args[1])})`; } }, '-': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} - ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} - ${expressionToGlsl(context, args[1])})`; } }, 'clamp': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 3); assertNumber(args[0]); assertNumber(args[1]); assertNumber(args[2]); - return `clamp(${args[0]}, ${args[1]}, ${args[2]})`; + const min = expressionToGlsl(context, args[1]); + const max = expressionToGlsl(context, args[2]); + return `clamp(${expressionToGlsl(context, args[0])}, ${min}, ${max})`; } }, 'stretch': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 5); assertNumber(args[0]); assertNumber(args[1]); assertNumber(args[2]); assertNumber(args[3]); assertNumber(args[4]); - const low1 = args[1]; - const high1 = args[2]; - const low2 = args[3]; - const high2 = args[4]; - return `((clamp(${args[0]}, ${low1}, ${high1}) - ${low1}) * ((${high2} - ${low2}) / (${high1} - ${low1})) + ${low2})`; + const low1 = expressionToGlsl(context, args[1]); + const high1 = expressionToGlsl(context, args[2]); + const low2 = expressionToGlsl(context, args[3]); + const high2 = expressionToGlsl(context, args[4]); + return `((clamp(${expressionToGlsl(context, args[0])}, ${low1}, ${high1}) - ${low1}) * ((${high2} - ${low2}) / (${high1} - ${low1})) + ${low2})`; } }, 'mod': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `mod(${args[0]}, ${args[1]})`; + return `mod(${expressionToGlsl(context, args[0])}, ${expressionToGlsl(context, args[1])})`; } }, 'pow': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.NUMBER; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `pow(${args[0]}, ${args[1]})`; + return `pow(${expressionToGlsl(context, args[0])}, ${expressionToGlsl(context, args[1])})`; } }, '>': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.BOOLEAN; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} > ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} > ${expressionToGlsl(context, args[1])})`; } }, '>=': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.BOOLEAN; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} >= ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} >= ${expressionToGlsl(context, args[1])})`; } }, '<': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.BOOLEAN; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} < ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} < ${expressionToGlsl(context, args[1])})`; } }, '<=': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.BOOLEAN; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} <= ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} <= ${expressionToGlsl(context, args[1])})`; } }, '==': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.BOOLEAN; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 2); assertNumber(args[0]); assertNumber(args[1]); - return `(${args[0]} == ${args[1]})`; + return `(${expressionToGlsl(context, args[0])} == ${expressionToGlsl(context, args[1])})`; } }, '!': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.BOOLEAN; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 1); assertBoolean(args[0]); - return `(!${args[0]})`; + return `(!${expressionToGlsl(context, args[0])})`; } }, 'between': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.BOOLEAN; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 3); assertNumber(args[0]); assertNumber(args[1]); assertNumber(args[2]); - return `(${args[0]} >= ${args[1]} && ${args[0]} <= ${args[2]})`; + const min = expressionToGlsl(context, args[1]); + const max = expressionToGlsl(context, args[2]); + const value = expressionToGlsl(context, args[0]); + return `(${value} >= ${min} && ${value} <= ${max})`; } }, 'interpolate': { - getReturnType: function(...args) { + getReturnType: function(args) { return ValueTypes.COLOR; }, - toGlsl: function(context, ...args) { + toGlsl: function(context, args) { assertArgsCount(args, 3); assertNumber(args[0]); assertColor(args[1]); assertColor(args[2]); - return `mix(${args[1]}, ${args[2]}, ${args[0]})`; + const start = expressionToGlsl(context, args[1], ValueTypes.COLOR); + const end = expressionToGlsl(context, args[2], ValueTypes.COLOR); + return `mix(${start}, ${end}, ${expressionToGlsl(context, args[0])})`; } } }; diff --git a/test/spec/ol/style/expressions.test.js b/test/spec/ol/style/expressions.test.js index 9f6f864367..1c430d307f 100644 --- a/test/spec/ol/style/expressions.test.js +++ b/test/spec/ol/style/expressions.test.js @@ -1,8 +1,58 @@ -import {getValueType, ValueTypes} from '../../../../src/ol/style/expressions.js'; +import { + arrayToGlsl, colorToGlsl, + expressionToGlsl, + getValueType, + numberToGlsl, + ValueTypes +} from '../../../../src/ol/style/expressions.js'; describe('ol.style.expressions', function() { + describe('numberToGlsl', function() { + it('does a simple transform when a fraction is present', function() { + expect(numberToGlsl(1.3456)).to.eql('1.3456'); + }); + it('adds a fraction separator when missing', function() { + expect(numberToGlsl(1)).to.eql('1.0'); + expect(numberToGlsl(2.0)).to.eql('2.0'); + }); + }); + + describe('arrayToGlsl', function() { + it('outputs numbers with dot separators', function() { + expect(arrayToGlsl([1, 0, 3.45, 0.8888])).to.eql('vec4(1.0, 0.0, 3.45, 0.8888)'); + expect(arrayToGlsl([3, 4])).to.eql('vec2(3.0, 4.0)'); + }); + it('throws on invalid lengths', function() { + let thrown = false; + try { + arrayToGlsl([3]); + } catch (e) { + thrown = true; + } + try { + arrayToGlsl([3, 2, 1, 0, -1]); + } catch (e) { + thrown = true; + } + expect(thrown).to.be(true); + }); + }); + + describe('colorToGlsl', function() { + it('normalizes color and outputs numbers with dot separators', function() { + expect(colorToGlsl([100, 0, 255])).to.eql('vec4(0.39215686274509803, 0.0, 1.0, 1.0)'); + expect(colorToGlsl([100, 0, 255, 1])).to.eql('vec4(0.39215686274509803, 0.0, 1.0, 1.0)'); + }); + it('handles colors in string format', function() { + expect(colorToGlsl('red')).to.eql('vec4(1.0, 0.0, 0.0, 1.0)'); + expect(colorToGlsl('#00ff99')).to.eql('vec4(0.0, 1.0, 0.6, 1.0)'); + expect(colorToGlsl('rgb(100, 0, 255)')).to.eql('vec4(0.39215686274509803, 0.0, 1.0, 1.0)'); + expect(colorToGlsl('rgba(100, 0, 255, 0.3)')).to.eql('vec4(0.39215686274509803, 0.0, 1.0, 0.3)'); + }); + }); + describe('getValueType', function() { it('correctly analyzes a literal value', function() { @@ -60,4 +110,33 @@ describe('ol.style.expressions', function() { }); + describe('expressionToGlsl', function() { + let context; + + beforeEach(function() { + context = {}; + }); + + it('correctly converts expressions to GLSL', function() { + expect(expressionToGlsl(context, ['get', 'myAttr'])).to.eql('a_myAttr'); + expect(expressionToGlsl(context, ['var', 'myValue'])).to.eql('u_myValue'); + expect(expressionToGlsl(context, ['time'])).to.eql('u_time'); + expect(expressionToGlsl(context, ['+', ['*', ['get', 'size'], 0.001], 12])).to.eql('((a_size * 0.001) + 12.0)'); + expect(expressionToGlsl(context, ['/', ['-', ['get', 'size'], 20], 100])).to.eql('((a_size - 20.0) / 100.0)'); + expect(expressionToGlsl(context, ['clamp', ['get', 'attr2'], ['get', 'attr3'], 20])).to.eql('clamp(a_attr2, a_attr3, 20.0)'); + expect(expressionToGlsl(context, ['stretch', ['get', 'size'], 10, 100, 4, 8])).to.eql('((clamp(a_size, 10.0, 100.0) - 10.0) * ((8.0 - 4.0) / (100.0 - 10.0)) + 4.0)'); + expect(expressionToGlsl(context, ['pow', ['mod', ['time'], 10], 2])).to.eql('pow(mod(u_time, 10.0), 2.0)'); + expect(expressionToGlsl(context, ['>', 10, ['get', 'attr4']])).to.eql('(10.0 > a_attr4)'); + expect(expressionToGlsl(context, ['>=', 10, ['get', 'attr4']])).to.eql('(10.0 >= a_attr4)'); + expect(expressionToGlsl(context, ['<', 10, ['get', 'attr4']])).to.eql('(10.0 < a_attr4)'); + expect(expressionToGlsl(context, ['<=', 10, ['get', 'attr4']])).to.eql('(10.0 <= a_attr4)'); + expect(expressionToGlsl(context, ['==', 10, ['get', 'attr4']])).to.eql('(10.0 == a_attr4)'); + expect(expressionToGlsl(context, ['between', ['get', 'attr4'], -4.0, 5.0])).to.eql('(a_attr4 >= -4.0 && a_attr4 <= 5.0)'); + expect(expressionToGlsl(context, ['!', ['get', 'attr4']])).to.eql('(!a_attr4)'); + expect(expressionToGlsl(context, ['interpolate', ['get', 'attr4'], [255, 255, 255, 1], 'transparent'])).to.eql( + 'mix(vec4(1.0, 1.0, 1.0, 1.0), vec4(0.0, 0.0, 0.0, 0.0), a_attr4)'); + }); + + }); + });