onejs-sl 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@
13
13
  * wants that should ask it.
14
14
  */
15
15
 
16
- import { INPUTS } from "../ir"
16
+ import { SOURCE_INPUTS } from "../ir"
17
17
  import { BUILTINS } from "./builtins"
18
18
  import { lex } from "./lexer"
19
19
  import { PRELUDE_NAMES } from "./prelude-source"
@@ -21,7 +21,9 @@ import { KEYWORD_SET, TYPE_SET } from "./words"
21
21
 
22
22
  export type SLTokenClass =
23
23
  | "keyword" | "type" | "builtin" | "input" | "prelude"
24
- | "number" | "hex" | "comment" | "punct"
24
+ | "number" | "hex" | "string" | "comment" | "punct"
25
+ /** An attribute's name, as in `[Range(0, 1)]`. */
26
+ | "attribute"
25
27
  /** A component after a dot, as in `c.rgb`. */
26
28
  | "member"
27
29
  /** Any other name: a uniform, a local, a function the file declares. */
@@ -40,7 +42,7 @@ export interface SLClassifiedToken {
40
42
  length: number
41
43
  }
42
44
 
43
- const INPUT_NAMES: ReadonlySet<string> = new Set(Object.keys(INPUTS))
45
+ const INPUT_NAMES: ReadonlySet<string> = new Set(Object.keys(SOURCE_INPUTS))
44
46
 
45
47
  /** The tokens of `source`, comments included, in order, each with its class. */
46
48
  export function classify(source: string): SLClassifiedToken[] {
@@ -54,6 +56,8 @@ export function classify(source: string): SLClassifiedToken[] {
54
56
  let kind: SLTokenClass
55
57
  if (t.kind === "ident") {
56
58
  if (before === ".") kind = beforeThat === "sdf" ? "builtin" : "member"
59
+ // There are no arrays, so a name straight after "[" is an attribute's.
60
+ else if (before === "[") kind = "attribute"
57
61
  else if (KEYWORD_SET.has(t.text)) kind = "keyword"
58
62
  else if (TYPE_SET.has(t.text)) kind = "type"
59
63
  else if (INPUT_NAMES.has(t.text)) kind = "input"
package/src/lang/glsl.ts CHANGED
@@ -107,6 +107,83 @@ const MISSING: Record<string, string> = {
107
107
  dFdx: "there are no derivatives", dFdy: "there are no derivatives", fwidth: "there are no derivatives",
108
108
  }
109
109
 
110
+ /**
111
+ * Uniforms other hosts declare for what is an input here: the Book of Shaders'
112
+ * and glslCanvas's `u_time`, glslsandbox's `time`, and Shadertoy's own when a
113
+ * tool declares them. The declaration goes and every use reads the input, so a
114
+ * pasted shader does not arrive frozen on a uniform nothing sets.
115
+ */
116
+ const HOST_UNIFORMS: Record<string, string> = {
117
+ u_time: "time", u_resolution: "resolution", time: "time", resolution: "resolution",
118
+ iTime: "time", iGlobalTime: "time", iResolution: "resolution", iMouse: "mouse",
119
+ }
120
+
121
+ const QUALIFIERS = new Set(["highp", "mediump", "lowp"])
122
+ /** Words that take a bracket without being called. */
123
+ const CONTROL = new Set(["if", "for", "while", "switch", "return"])
124
+
125
+ /**
126
+ * Whether a `mat2(...)`, given its four arguments as words, is a rotation, and
127
+ * which way: +1 for `mat2(c, s, -s, c)`, the matrix R(a), and -1 for
128
+ * `mat2(c, -s, s, c)`, which is R(-a) since GLSL fills a matrix by columns.
129
+ * `cos` and `sin` are the words that stand for the cosine and sine.
130
+ */
131
+ function rotationSign(matArgs: string[], cos: string[], sin: string[]): 1 | -1 | null {
132
+ const kind = (w: string) => cos.includes(w) ? "C" : sin.includes(w) ? "S"
133
+ : w.startsWith("- ") && sin.includes(w.slice(2)) ? "-S" : "?"
134
+ const k = matArgs.map(kind).join(",")
135
+ return k === "C,S,-S,C" ? 1 : k === "C,-S,S,C" ? -1 : null
136
+ }
137
+
138
+ /** Words split at the commas outside any bracket. */
139
+ function splitTop(words: string[]): string[] {
140
+ const out: string[] = []
141
+ let depth = 0
142
+ let cur: string[] = []
143
+ for (const w of words) {
144
+ if (w === "(" || w === "[") depth++
145
+ else if (w === ")" || w === "]") depth--
146
+ if (w === "," && depth === 0) { out.push(cur.join(" ")); cur = [] } else cur.push(w)
147
+ }
148
+ out.push(cur.join(" "))
149
+ return out
150
+ }
151
+
152
+ /** The arguments of `mat2 ( ... )` as words, when `words` is exactly that, in any number of brackets. */
153
+ function mat2Args(words: string[]): string[] | null {
154
+ while (words[0] === "(" && words[words.length - 1] === ")") words = words.slice(1, -1)
155
+ if (words[0] !== "mat2" || words[1] !== "(" || words[words.length - 1] !== ")") return null
156
+ const inner = words.slice(2, -1)
157
+ let depth = 0
158
+ for (const w of inner) { if (w === "(") depth++; else if (w === ")" && --depth < 0) return null }
159
+ return splitTop(inner)
160
+ }
161
+
162
+ /**
163
+ * The sign of a function body that returns a rotation of its parameter:
164
+ * `return mat2(cos(a), -sin(a), sin(a), cos(a));`, or the same after
165
+ * `float c = cos(a), s = sin(a);`. Null for anything else.
166
+ */
167
+ function rotationBody(words: string[], param: string): 1 | -1 | null {
168
+ const cos = [`cos ( ${param} )`]
169
+ const sin = [`sin ( ${param} )`]
170
+ const statements = words.join(" ").split(/\s*;\s*/).filter((x) => x !== "")
171
+ const last = statements.pop()
172
+ const binding = new RegExp(`^(\\w+) = (cos|sin) \\( ${param} \\)$`)
173
+ for (const st of statements) {
174
+ const parts = splitTop(st.replace(/^float /, "").split(" "))
175
+ for (const part of parts) {
176
+ const m = binding.exec(part)
177
+ if (m === null) return null
178
+ if (m[2] === "cos") cos.push(m[1]!)
179
+ else sin.push(m[1]!)
180
+ }
181
+ }
182
+ if (last === undefined || !last.startsWith("return ")) return null
183
+ const a = mat2Args(last.slice("return ".length).split(" "))
184
+ return a === null || a.length !== 4 ? null : rotationSign(a, cos, sin)
185
+ }
186
+
110
187
  /** Names a pasted value or function cannot keep, because the language already means something by them. */
111
188
  function taken(name: string, isFunction: boolean): boolean {
112
189
  if (name in INPUTS || (SL_KEYWORDS as readonly string[]).includes(name) || (SL_TYPES as readonly string[]).includes(name)) return true
@@ -141,6 +218,61 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
141
218
  }
142
219
  return toks.length
143
220
  }
221
+ /** The index of the bracket opening the one at `close`, or -1. */
222
+ const opening = (close: number): number => {
223
+ const [o, c] = toks[close]!.text === ")" ? ["(", ")"] : ["[", "]"]
224
+ let depth = 0
225
+ for (let i = close; i >= 0; i--) {
226
+ if (toks[i]!.kind !== "punct") continue
227
+ if (toks[i]!.text === c) depth++
228
+ else if (toks[i]!.text === o && --depth === 0) return i
229
+ }
230
+ return -1
231
+ }
232
+ /** Words for tokens `from` to `to`: no layout, no comments, one space between. */
233
+ const words = (from: number, to: number) =>
234
+ toks.slice(from, to).filter((t) => t.kind !== "ws" && t.kind !== "comment").map((t) => t.text)
235
+ /**
236
+ * The last token of the operand starting at `i`: a name, a number, a call
237
+ * or a bracket, then any members and indexes. -1 when it is none of those.
238
+ */
239
+ const operandAfter = (i: number): number => {
240
+ const t = toks[i]
241
+ let j: number
242
+ if (t === undefined) return -1
243
+ if (t.kind === "number") j = i
244
+ else if (is(i, "(")) j = closing(i)
245
+ else if (t.kind === "ident") { const n = skip(i + 1); j = is(n, "(") ? closing(n) : i }
246
+ else return -1
247
+ for (;;) {
248
+ if (j >= toks.length) return -1
249
+ const n = skip(j + 1)
250
+ if (is(n, ".") && toks[skip(n + 1)]?.kind === "ident") j = skip(n + 1)
251
+ else if (is(n, "[")) j = closing(n)
252
+ else return j
253
+ }
254
+ }
255
+ /** The first token of the operand ending at `j`, the same shapes read backwards. -1 when there is none. */
256
+ const operandBefore = (j: number): number => {
257
+ let i = j
258
+ for (;;) {
259
+ if (is(i, "]")) { const o = opening(i); if (o < 0) return -1; i = back(o - 1); continue }
260
+ let start: number
261
+ if (is(i, ")")) {
262
+ const o = opening(i)
263
+ if (o < 0) return -1
264
+ const p = back(o - 1)
265
+ start = toks[p]?.kind === "ident" && !CONTROL.has(toks[p]!.text) ? p : o
266
+ } else if (toks[i]?.kind === "ident" || toks[i]?.kind === "number") {
267
+ start = i
268
+ } else {
269
+ return -1
270
+ }
271
+ const p = back(start - 1)
272
+ if (!is(p, ".")) return start
273
+ i = back(p - 1)
274
+ }
275
+ }
144
276
  /** A call's arguments, as token ranges, for the call whose "(" is at `open`. */
145
277
  const args = (open: number, close: number): Array<[number, number]> => {
146
278
  const out: Array<[number, number]> = []
@@ -173,6 +305,142 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
173
305
  if (taken(name, isFunction)) clashes.set(name, name + "_")
174
306
  }
175
307
 
308
+ // ---------- what other hosts declare ----------
309
+
310
+ /**
311
+ * Token ranges written as something else, keyed by their first token: a
312
+ * declaration that goes, a rotation that becomes a call. They nest, and
313
+ * never cross.
314
+ */
315
+ const spans = new Map<number, { end: number; render: (inEntry: boolean) => string }>()
316
+ const crosses = (start: number, end: number) =>
317
+ [...spans].some(([s, v]) => (s < start && start <= v.end && v.end < end) || (start < s && s <= end && end < v.end))
318
+ /** A range and the whitespace after it, gone. */
319
+ const drop = (start: number, end: number) => {
320
+ spans.set(start, { end: toks[end + 1]?.kind === "ws" ? end + 1 : end, render: () => "" })
321
+ }
322
+
323
+ const hostUniforms = new Set<string>()
324
+ for (let i = 0; i < toks.length; i++) {
325
+ if (!is(i, "uniform")) continue
326
+ let t = skip(i + 1)
327
+ while (QUALIFIERS.has(toks[t]?.text ?? "")) t = skip(t + 1)
328
+ const n = skip(t + 1)
329
+ const name = toks[n]?.text ?? ""
330
+ const semi = skip(n + 1)
331
+ if (toks[n]?.kind !== "ident" || HOST_UNIFORMS[name] === undefined || !is(semi, ";")) continue
332
+ hostUniforms.add(name)
333
+ // Its name is the input, not a clash with it.
334
+ clashes.delete(name)
335
+ const input = HOST_UNIFORMS[name]!
336
+ noteOnce("host:" + name, toks[i]!.line, `the uniform ${name} is the input ${input}, so its declaration is dropped`)
337
+ drop(i, semi)
338
+ }
339
+
340
+ // ---------- rotations ----------
341
+
342
+ /**
343
+ * There are no matrices, and the matrix a shader almost always has is a
344
+ * rotation: `mat2 rot(float a)` returning `mat2(c, -s, s, c)`, the same as
345
+ * a macro, or written in place. Each use that multiplies a vector becomes
346
+ * `rotate(p, angle)`, the angle's sign set by the matrix and by which side
347
+ * the vector is on, since GLSL's `p * M` is M transposed times p. A
348
+ * function or macro whose every use is converted goes. Anything else keeps
349
+ * its `mat2`, which `errors` then points at, with a note naming rotate().
350
+ */
351
+ const rotations = new Map<string, { sign: 1 | -1; def: [number, number]; line: number; uses: number; converted: number }>()
352
+ for (let i = 0; i < toks.length; i++) {
353
+ const t = toks[i]!
354
+ if (t.kind === "directive") {
355
+ const m = /^#\s*define\s+([A-Za-z_]\w*)\(\s*([A-Za-z_]\w*)\s*\)(.*)$/s.exec(t.text)
356
+ if (m === null) continue
357
+ const body = tokenize(m[3]!.replace(/\\\n/g, " ")).filter((x) => x.kind !== "ws" && x.kind !== "comment").map((x) => x.text)
358
+ const a = mat2Args(body)
359
+ const sign = a === null || a.length !== 4 ? null : rotationSign(a, [`cos ( ${m[2]} )`], [`sin ( ${m[2]} )`])
360
+ if (sign !== null) rotations.set(m[1]!, { sign, def: [i, i], line: t.line, uses: 0, converted: 0 })
361
+ continue
362
+ }
363
+ if (!is(i, "mat2")) continue
364
+ const n = skip(i + 1)
365
+ const open = skip(n + 1)
366
+ if (toks[n]?.kind !== "ident" || !is(open, "(")) continue
367
+ const close = closing(open)
368
+ const params = words(open + 1, close).filter((w) => w !== "in")
369
+ const body = skip(close + 1)
370
+ if (params.length !== 2 || params[0] !== "float" || !is(body, "{")) continue
371
+ const end = closing(body)
372
+ const sign = rotationBody(words(body + 1, end), params[1]!)
373
+ if (sign !== null) rotations.set(toks[n]!.text, { sign, def: [i, end], line: t.line, uses: 0, converted: 0 })
374
+ }
375
+ // A file with a rotate of its own would have the calls written here call it.
376
+ const ownRotate = toks.some((t, i) => t.text === "rotate" && t.kind === "ident" && GLSL_TYPES.has(toks[back(i - 1)]?.text ?? "") &&
377
+ !(rotations.has("rotate") && toks[back(i - 1)]!.text === "mat2"))
378
+
379
+ /** The angle, negated when `sign` is -1, as text. */
380
+ const angle = (range: [number, number], sign: 1 | -1, inEntry: boolean) => {
381
+ const a = emit(range[0], range[1], inEntry).trim()
382
+ if (sign === 1) return a
383
+ if (/^-[\w.]+$/.test(a)) return a.slice(1)
384
+ return /^[\w.]+$/.test(a) ? `-${a}` : `-(${a})`
385
+ }
386
+ for (let i = 0; i < toks.length && !ownRotate; i++) {
387
+ if (toks[i]!.kind !== "ident" || is(back(i - 1), ".")) continue
388
+ const open = skip(i + 1)
389
+ if (!is(open, "(")) continue
390
+ const close = closing(open)
391
+ const a = args(open, close)
392
+ const fn = rotations.get(toks[i]!.text)
393
+ let sign: 1 | -1 | null = null
394
+ let range: [number, number] | null = null
395
+ if (fn !== undefined && (i < fn.def[0] || i > fn.def[1])) {
396
+ fn.uses++
397
+ if (a.length === 1) { sign = fn.sign; range = a[0]! }
398
+ } else if (is(i, "mat2") && a.length === 4) {
399
+ // In place: the angle is whatever cos() is given.
400
+ const first = skip(a[0]![0])
401
+ const inner = skip(first + 1)
402
+ if (is(first, "cos") && is(inner, "(") && closing(inner) === back(a[0]![1] - 1)) {
403
+ const x = words(inner + 1, closing(inner)).join(" ")
404
+ sign = rotationSign(a.map(([x0, x1]) => words(x0, x1).join(" ")), [`cos ( ${x} )`], [`sin ( ${x} )`])
405
+ range = [inner + 1, closing(inner)]
406
+ }
407
+ }
408
+ if (sign === null || range === null) continue
409
+ const r = range
410
+ const s = sign
411
+ const before = back(i - 1)
412
+ const after = skip(close + 1)
413
+ let span: [number, number, (inEntry: boolean) => string] | null = null
414
+ if (is(before, "=") && is(before - 1, "*") && is(after, ";")) {
415
+ // p *= M is p = p * M.
416
+ const lhsEnd = back(before - 2)
417
+ const lhs = operandBefore(lhsEnd)
418
+ if (lhs >= 0) span = [lhs, close, (e) => { const p = emit(lhs, lhsEnd + 1, e).trim(); return `${p} = rotate(${p}, ${angle(r, -s as 1 | -1, e)})` }]
419
+ } else if (is(after, "*") && !is(after + 1, "=") && !["/", "%", "*"].includes(toks[before]?.text ?? "")) {
420
+ // M * p.
421
+ const x = skip(after + 1)
422
+ const xEnd = operandAfter(x)
423
+ if (xEnd >= 0) span = [i, xEnd, (e) => `rotate(${emit(x, xEnd + 1, e).trim()}, ${angle(r, s, e)})`]
424
+ } else if (is(before, "*") && !is(after, "*")) {
425
+ // p * M.
426
+ const xEnd = back(before - 1)
427
+ const x = operandBefore(xEnd)
428
+ if (x >= 0 && !["/", "%"].includes(toks[back(x - 1)]?.text ?? "")) {
429
+ span = [x, close, (e) => `rotate(${emit(x, xEnd + 1, e).trim()}, ${angle(r, -s as 1 | -1, e)})`]
430
+ }
431
+ }
432
+ if (span === null || crosses(span[0], span[1]) || spans.has(span[0])) continue
433
+ const [start, end, render] = span
434
+ spans.set(start, { end, render })
435
+ noteOnce("rotation", toks[i]!.line, "a mat2 rotation is rotate(p, angle) here, since there are no matrices; the angle's sign follows which side p was multiplied on")
436
+ if (fn !== undefined) fn.converted++
437
+ }
438
+ for (const [name, fn] of rotations) {
439
+ if (ownRotate || fn.converted !== fn.uses) continue
440
+ drop(fn.def[0], fn.def[1])
441
+ note(fn.line, `${name} built a rotation matrix, and each use of it is rotate() now, so it is removed`)
442
+ }
443
+
176
444
  // ---------- the entry point ----------
177
445
 
178
446
  let colour = "fragColor"
@@ -231,6 +499,8 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
231
499
  let out = ""
232
500
  for (let i = from; i < to; i++) {
233
501
  const t = toks[i]!
502
+ const span = spans.get(i)
503
+ if (span !== undefined && span.end < to) { out += span.render(inEntry); i = span.end; continue }
234
504
  if (t.kind === "directive") { out += directive(t); continue }
235
505
  if (t.kind === "number") { out += t.text.replace(/[uU]$/, ""); continue }
236
506
  if (t.kind !== "ident") { out += t.text; continue }
@@ -286,6 +556,16 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
286
556
  out += to2
287
557
  continue
288
558
  }
559
+ if (hostUniforms.has(name) && (name === "u_time" || name === "u_resolution")) { out += HOST_UNIFORMS[name]; continue }
560
+ if (name === "mat2") {
561
+ noteOnce("mat2", t.line, "there are no matrices here; a mat2 rotation is rotate(p, angle): p *= mat2(c, -s, s, c) is rotate(p, a), and mat2(c, -s, s, c) * p is rotate(p, -a)")
562
+ out += name
563
+ continue
564
+ }
565
+ const unconverted = rotations.get(name)
566
+ if (unconverted !== undefined && called && (i < unconverted.def[0] || i > unconverted.def[1])) {
567
+ noteOnce("rot:" + t.line + name, t.line, `${name} builds a rotation matrix, and this use of it was not converted; write it as rotate(p, angle)`)
568
+ }
289
569
  if (/^iChannel\d$/.test(name)) { textures.add(name); out += name; continue }
290
570
  if (name === "iMouse") {
291
571
  mouse = true
package/src/lang/lexer.ts CHANGED
@@ -13,7 +13,8 @@
13
13
 
14
14
  import { SLError } from "../ir"
15
15
 
16
- export type TokenKind = "ident" | "number" | "hex" | "punct" | "eof"
16
+ /** `string` only ever appears in an attribute: `[Label("Glow colour")]`. */
17
+ export type TokenKind = "ident" | "number" | "hex" | "string" | "punct" | "eof"
17
18
 
18
19
  export interface Pos {
19
20
  /** 1 based, the way every editor counts. */
@@ -30,6 +31,8 @@ export interface Token extends Pos {
30
31
  text: string
31
32
  /** Numbers only: the parsed value. */
32
33
  value?: number
34
+ /** Strings only: the text between the quotes, with `\"` and `\\` read. */
35
+ str?: string
33
36
  }
34
37
 
35
38
  /**
@@ -206,6 +209,25 @@ export function lex(source: string, file: string, keep: boolean): Token[] | Lexe
206
209
  continue
207
210
  }
208
211
 
212
+ if (c === "\"") {
213
+ const start = here()
214
+ let j = i + 1
215
+ let str = ""
216
+ while (j < source.length && source[j] !== "\"" && source[j] !== "\n") {
217
+ if (source[j] === "\\" && (source[j + 1] === "\"" || source[j + 1] === "\\")) j++
218
+ str += source[j]
219
+ j++
220
+ }
221
+ if (source[j] !== "\"") {
222
+ fail("this string is never closed; it ends at the end of its line", start, j - i)
223
+ } else {
224
+ push({ kind: "string", text: source.slice(i, j + 1), str, ...start }, j + 1)
225
+ j++
226
+ }
227
+ i = j
228
+ continue
229
+ }
230
+
209
231
  if (isIdentStart(c)) {
210
232
  const start = here()
211
233
  let j = i
package/src/lang/lower.ts CHANGED
@@ -33,11 +33,12 @@
33
33
  * than an optimisation.
34
34
  */
35
35
 
36
- import { INPUTS, type Program, type SLType } from "../ir"
36
+ import { controlProblem, DERIVED_INPUTS, INPUTS, type Program, type SLType, type UniformControl } from "../ir"
37
37
  import type { SlSdfKind } from "../shapes"
38
38
  import * as sl from "../sl"
39
39
  import type { Num, Val } from "../sl"
40
40
  import { TYPE_WIDTH, type Expr, type FuncDecl, type Stmt, type TypeName } from "./ast"
41
+ import { readAttributes } from "./attributes"
41
42
  import { BUILTINS } from "./builtins"
42
43
  import type { Checked } from "./check"
43
44
  import { SLParseError, type Pos } from "./lexer"
@@ -161,6 +162,17 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
161
162
  for (const [name, width] of Object.entries(INPUTS)) {
162
163
  global.declare(name, { width: width as SLType, value: (inputs as never)[name] })
163
164
  }
165
+ // Read through, so a program that never names one records nothing
166
+ // for it. The setter only serves a refused assignment, which the
167
+ // checker has already reported and lowering carries on past.
168
+ for (const [name, width] of Object.entries(DERIVED_INPUTS)) {
169
+ let value: LV | undefined
170
+ global.declare(name, {
171
+ width: width as SLType,
172
+ get value() { return value ??= (inputs as never)[name] },
173
+ set value(v: LV) { value = v },
174
+ })
175
+ }
164
176
 
165
177
  // Uniforms and textures take their slots in DECLARATION order, before
166
178
  // anything is lowered, so a slot is a property of the file rather than
@@ -177,9 +189,20 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
177
189
  // that, `#ff8040` and a uniform defaulting to `#ff8040` would be two
178
190
  // different colours in one file. `sl.uniform.colour` also marks
179
191
  // the declaration, for a host's colour picker.
180
- const value = colour && u.init?.k === "hex"
181
- ? at(u.pos, () => sl.uniform.colour(u.name, (u.init as { hex: string }).hex, width as 3 | 4) as unknown as Val)
182
- : at(u.pos, () => declareUniform(u.name, u.type, components))
192
+ // What the attributes say the control is, and whether the default
193
+ // fits it, marked on the attribute that does not fit.
194
+ const read = readAttributes(u)
195
+ const problem = recover(() => {
196
+ const p = controlProblem(width as SLType, components, read.control)
197
+ const attr = p === null ? undefined : read.from[p.field]
198
+ if (p !== null) fail(inFileWords(p.message), attr?.pos ?? u.pos, attr?.length ?? 1)
199
+ return false
200
+ }, () => true)
201
+ const control = problem ? {} : read.control
202
+ // `[Color]` says the same as a hex default, for a default written as numbers.
203
+ const value = (colour && u.init?.k === "hex") || read.colour
204
+ ? at(u.pos, () => sl.uniform.colour(u.name, components, width as 3 | 4, control) as unknown as Val)
205
+ : at(u.pos, () => declareUniform(u.name, u.type, components, control))
183
206
  global.declare(u.name, { width, value })
184
207
  }
185
208
 
@@ -217,12 +240,12 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
217
240
 
218
241
  // MARK: declarations
219
242
 
220
- function declareUniform(name: string, type: TypeName, c: number[]): Val {
243
+ function declareUniform(name: string, type: TypeName, c: number[], control: UniformControl): Val {
221
244
  switch (type) {
222
- case "float": return sl.uniform.float(name, c[0]!) as unknown as Val
223
- case "float2": return sl.uniform.vec2(name, [c[0]!, c[1]!]) as unknown as Val
224
- case "float3": return sl.uniform.vec3(name, [c[0]!, c[1]!, c[2]!]) as unknown as Val
225
- case "float4": return sl.uniform.vec4(name, [c[0]!, c[1]!, c[2]!, c[3]!]) as unknown as Val
245
+ case "float": return sl.uniform.float(name, c[0]!, control) as unknown as Val
246
+ case "float2": return sl.uniform.vec2(name, [c[0]!, c[1]!], control) as unknown as Val
247
+ case "float3": return sl.uniform.vec3(name, [c[0]!, c[1]!, c[2]!], control) as unknown as Val
248
+ case "float4": return sl.uniform.vec4(name, [c[0]!, c[1]!, c[2]!, c[3]!], control) as unknown as Val
226
249
  }
227
250
  }
228
251
 
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import {
15
- type AssignOp, type BinaryOp, type Expr, type FuncDecl, type Param, type Stmt,
15
+ type AssignOp, type Attribute, type AttributeArg, type BinaryOp, type Expr, type FuncDecl, type Param, type Stmt,
16
16
  type TextureDecl, type TypeName, type UniformDecl, type Unit,
17
17
  } from "./ast"
18
18
  import { SLParseError, tokenize, type Pos, type SLFix, type Token } from "./lexer"
@@ -199,7 +199,18 @@ class Parser {
199
199
  private parseDeclaration(unit: Unit): void {
200
200
  const t = this.peek()
201
201
 
202
- if (t.text === "uniform") { unit.uniforms.push(this.parseUniform()); return }
202
+ if (t.text === "[") {
203
+ const attrs = this.parseAttributes()
204
+ if (!this.at("uniform")) {
205
+ this.fail(
206
+ `an attribute belongs on a uniform, as in \`[Range(0, 1)] uniform float amount = 0.5;\`, ` +
207
+ `and this one is followed by ${describe(this.peek())}`, t,
208
+ )
209
+ }
210
+ unit.uniforms.push(this.parseUniform(attrs))
211
+ return
212
+ }
213
+ if (t.text === "uniform") { unit.uniforms.push(this.parseUniform([])); return }
203
214
  if (t.text === "texture2D") { unit.textures.push(this.parseTexture()); return }
204
215
  if (t.text === "const") { unit.consts.push(this.parseConst()); return }
205
216
  if (t.kind === "ident") {
@@ -214,14 +225,45 @@ class Parser {
214
225
  )
215
226
  }
216
227
 
217
- private parseUniform(): UniformDecl {
228
+ private parseUniform(attrs: Attribute[]): UniformDecl {
218
229
  const kw = this.next()
219
230
  const type = this.expectType("a type after uniform")
220
231
  const name = this.expectName("a uniform name")
221
232
  let init: Expr | null = null
222
233
  if (this.eat("=")) init = this.parseExpr()
223
234
  this.expect(";", "after a uniform declaration")
224
- return { name: name.text, type, init, pos: kw }
235
+ return { name: name.text, type, init, attrs, pos: kw }
236
+ }
237
+
238
+ /** `[Name]` or `[Name(arg, ...)]`, one or more. Shape only: the checker says what each means. */
239
+ private parseAttributes(): Attribute[] {
240
+ const out: Attribute[] = []
241
+ while (this.eat("[")) {
242
+ const name = this.expectIdent("an attribute's name, such as Range")
243
+ const args: AttributeArg[] = []
244
+ if (this.eat("(")) {
245
+ if (!this.at(")")) {
246
+ do args.push(this.parseAttributeArg())
247
+ while (this.eat(","))
248
+ }
249
+ this.expect(")", "to close the attribute's arguments")
250
+ }
251
+ this.expect("]", "to close the attribute")
252
+ out.push({ name: name.text, args, pos: name, length: name.text.length })
253
+ }
254
+ return out
255
+ }
256
+
257
+ private parseAttributeArg(): AttributeArg {
258
+ const t = this.next()
259
+ if (t.kind === "string") return { k: "str", text: t.str!, pos: t, length: t.text.length }
260
+ if (t.kind === "ident") return { k: "ident", name: t.text, pos: t, length: t.text.length }
261
+ if (t.kind === "number") return { k: "num", value: t.value!, pos: t, length: t.text.length }
262
+ if (t.text === "-" && this.peek().kind === "number") {
263
+ const n = this.next()
264
+ return { k: "num", value: -n.value!, pos: t, length: n.offset + n.text.length - t.offset }
265
+ }
266
+ this.fail(`expected a number, a word or a "string" as an attribute's argument, got ${describe(t)}`, t)
225
267
  }
226
268
 
227
269
  private parseTexture(): TextureDecl {
@@ -512,6 +554,9 @@ class Parser {
512
554
  if (t.kind === "number") return { k: "num", value: t.value!, pos: t }
513
555
  if (t.kind === "hex") return { k: "hex", hex: t.text, pos: t }
514
556
  if (t.kind === "ident") return { k: "ident", name: t.text, pos: t }
557
+ if (t.kind === "string") {
558
+ this.fail(`a string is only an attribute's argument, as in [Label("Glow colour")]; a value is a number`, t)
559
+ }
515
560
  if (t.text === "(") {
516
561
  const inner = this.parseExpr()
517
562
  this.expect(")", "to close a group")
package/src/params.ts CHANGED
@@ -20,7 +20,8 @@ import { BUILTINS } from "./lang/builtins"
20
20
  import { LIB_FUNCTIONS, SDF_CALLS } from "./lib/table"
21
21
  import { LIB_PARAM_NAMES } from "./lib/names"
22
22
  import { SL_SDF_PARAMS, SL_SDF_SHAPES, type SlSdfKind } from "./shapes"
23
- import type { InputName } from "./ir"
23
+ import type { SourceInputName } from "./ir"
24
+ import type { AttributeName } from "./lang/attributes"
24
25
 
25
26
  /** One function of the helper library, as its source declares it. */
26
27
  export interface LibSignature {
@@ -164,15 +165,31 @@ export const BUILTIN_DOCS: Readonly<Record<string, string>> = {
164
165
  ramp: "t mapped through evenly spaced colour stops, from the first at 0 to the last at 1.",
165
166
  }
166
167
 
168
+ /**
169
+ * One line on each attribute, for the same tooltips: what the control is, and
170
+ * how it is written.
171
+ */
172
+ export const ATTRIBUTE_DOCS: Readonly<Record<AttributeName, string>> = {
173
+ Range: "A slider from the first number to the second: [Range(0, 2)]. A third number is its step. A float only.",
174
+ Toggle: "A checkbox. The value is 0 or 1. A float only.",
175
+ Enum: "A dropdown of names: [Enum(Soft, Hard, Glow)]. The value is the chosen name's index, from 0. A float only.",
176
+ Header: "A heading above this control, starting a group: [Header(\"Shape\")].",
177
+ Label: "The name the control shows, in place of the uniform's: [Label(\"Glow colour\")].",
178
+ Color: "A colour picker, the value a colour as written, the same as a hex default. A float3 or a float4.",
179
+ Hide: "No control: the host sets this uniform from code.",
180
+ }
181
+
167
182
  /**
168
183
  * One line on each input, for the same tooltips. In the words of no one host:
169
184
  * the output is a UI element in OneJS and an image on a Magerie board, and the
170
185
  * clock is whichever one the host runs.
171
186
  */
172
- export const INPUT_DOCS: Readonly<Record<InputName, string>> = {
173
- uv: "Where this pixel is in the output, 0 to 1 on each axis, with y up.",
174
- fragCoord: "Where this pixel is in the output, in pixels, with y up.",
187
+ export const INPUT_DOCS: Readonly<Record<SourceInputName, string>> = {
188
+ uv: "The centre of this pixel in the output, 0 to 1 on each axis, with y up.",
189
+ fragCoord: "The centre of this pixel in the output, in pixels, with y up: the first column is at 0.5.",
175
190
  resolution: "The output's size in pixels.",
176
191
  time: "The clock, in seconds.",
177
192
  aspect: "The output's width divided by its height.",
193
+ texel: "One pixel, in uv: 1 / resolution. Step by it to read a neighbouring pixel.",
194
+ centered: "uv with 0 at the centre and x scaled by aspect, so a circle drawn in it stays round.",
178
195
  }
package/src/serial.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * older one is migrated to this one and rehashed under it.
10
10
  */
11
11
  import {
12
- INPUTS, SLError, SL_IR_VERSION, TYPE, hashProgram,
12
+ INPUTS, SLError, SL_IR_VERSION, TYPE, controlOf, controlProblem, hashProgram,
13
13
  type NodeRef, type Program, type SLNode, type SLType, type TextureDecl, type UniformDecl,
14
14
  } from "./ir"
15
15
  import { SL_ARITY, SL_NAME } from "./ops"
@@ -125,7 +125,27 @@ function uniform(u: unknown, i: number): UniformDecl {
125
125
  }
126
126
  const out: UniformDecl = { name: x.name!, type: x.type as SLType, value: x.value!.slice() }
127
127
  if (x.colour === true) out.colour = true
128
- return out
128
+ // Its control: shapes checked here, sense checked by the one check the
129
+ // compiler uses, so a hand edited file cannot give a host a range it would
130
+ // choke on.
131
+ const r = x.range as { min?: unknown; max?: unknown; step?: unknown } | undefined
132
+ if (r !== undefined && (typeof r !== "object" || r === null || typeof r.min !== "number" || typeof r.max !== "number" ||
133
+ (r.step !== undefined && typeof r.step !== "number"))) {
134
+ fail(`uniform ${i}'s range needs a min and a max, and a step if any, as numbers`)
135
+ }
136
+ if (x.options !== undefined && (!Array.isArray(x.options) || !x.options.every((o) => typeof o === "string"))) {
137
+ fail(`uniform ${i}'s options must be a list of names`)
138
+ }
139
+ for (const f of ["header", "label"] as const) {
140
+ if (x[f] !== undefined && typeof x[f] !== "string") fail(`uniform ${i}'s ${f} must be text`)
141
+ }
142
+ for (const f of ["toggle", "hide"] as const) {
143
+ if (x[f] !== undefined && x[f] !== true) fail(`uniform ${i}'s ${f} can only be true`)
144
+ }
145
+ const control = controlOf(x)
146
+ const problem = controlProblem(out.type, out.value, control)
147
+ if (problem !== null) fail(`uniform ${i}: ${problem.message}`)
148
+ return Object.assign(out, control)
129
149
  }
130
150
 
131
151
  function texture(t: unknown, i: number): TextureDecl {