Refactoring round 9 covers everything since round 8 (14972d0): 62 commits with about 29 000 new lines in src, api, tools and tests. Behaviour is unchanged. The one fix is in a check: e2e 04 counted 25 asset types, and the shader function from #303 makes 26, so the run ofa526e1bfailed there. Duplicate code (jscpd over src, 50 tokens, without the two licence texts: 35 clones / 289 lines before, 29 / 232 after): - ShaderPicture (new, viewport/shaderPicture.ts) is the picture a camera's shader and a particle field's shader are laid over. It keeps the render texture while its size holds and frees the replaced one once the shader holds the new one. CameraShade and ParticleField.shadeField drew it the same way, line for line. - CameraShade builds its mesh on the first draw. Before, every redraw of a view without a camera shader built a mesh and threw it away. - emptyHost in inspector/host.ts builds the empty host the Inspector, the Variables tab and the Tilemap panel start from. - paddedQuad(null) is the plain unit quad. symbols.ts and shaderPasses.ts each wrote it out by hand. - In graph/nested.ts, calls of shader functions and On behavior nodes are followed by one walk, `reaches`, over ids `namedBy` a node type. Dead code: - ShaderEditor.colorFieldOf and the getter CurveCanvas.snapping had no reader. - 22 values used only in their own file lose their `export`. Types and the node catalogues of shaders and particles stay exported. - Kept on purpose: PhysicsSystem.holdsJoint is read by tests only, but it is their one view of whether a joint holds. - A pass with stricter lint rules over every file changed since round 8 found no bug. PixelArtEditor.started is now readonly. Comments and docs: - Ten comments told history ("since #190", "used to", "any more"). They now describe what is. The bundle is md5-identical before and after, and the four e2e files minify to the same bytes. - README: joint and shader function in the asset list, joints on the Physics row, what the Shaders page covers, two CI runners. - e2e 04 keeps the number of asset types once, as ASSET_TYPE_COUNT. - screenshot.png in the README is a new picture of the editor (3840x2160 instead of 1825x1140), taken by the user. Tests: - Gate on melektaus-atx: 229 files, 3514 tests, CHECK EXIT 0 (+3 for emptyHost, +1 for the unit quad). - e2e 1-4, 31, 63 on melektaus-atx: ALL PASSED, 195 checks. That is the + menu of 04, the field shader of 31 and the camera shader of 63, both through ShaderPicture. - No new e2e check, so the sum stays 1728. - Counter-checks, each put back and compared with cmp: reaches that never goes on fails 1 test, listensTo reading the wrong parameter fails 1, emptyHost dropping what a panel hands in fails 1, folding without a relayout fails 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
504 lines
17 KiB
TypeScript
504 lines
17 KiB
TypeScript
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||
import { type PropertyBag, textOf } from '../model/Properties';
|
||
import { propertyNodeParams } from '../properties/declaredRows';
|
||
import { flatDef } from '../properties/PropertySchema';
|
||
import { PARTICLE_PROPERTY, PROPERTY_NAME_PARAM, propertyNameOf } from '../properties/particles';
|
||
import { mixColor } from '../util/color';
|
||
|
||
/** What one particle is, at one moment: everything a graph may read about it. */
|
||
export interface ParticleEnv {
|
||
/** 0 at birth, 1 at the end of its lifetime. */
|
||
age: number;
|
||
/** The same, in seconds – what the formulas of a fall are written in. */
|
||
ageSeconds: number;
|
||
/** Seconds since the source started emitting; the same for every particle alive. */
|
||
time: number;
|
||
/** Which particle this is, counting up from the first the source ever emitted. */
|
||
index: number;
|
||
/** Fixed for the particle's whole life: what makes two of them differ. */
|
||
seed: number;
|
||
/** The source's own Color row, which is what an unconnected colour output reads. */
|
||
color: number;
|
||
/** The values of the graph's Property nodes, in the order the compiler declared them. */
|
||
params: readonly number[];
|
||
}
|
||
|
||
/**
|
||
* One output of one node, as a function of the particle. `locals` holds the outputs already
|
||
* computed this evaluation, so a node feeding two others is evaluated once (see `compile.ts`).
|
||
*/
|
||
export type ParticleExpr = (env: ParticleEnv, locals: Float64Array) => number;
|
||
|
||
/** What a node may need beyond its inputs: which node it is, and where its parameter's value lands. */
|
||
export interface ParticleEmitContext {
|
||
nodeId: string;
|
||
/** The slot in `env.params` this graph's parameter of that name was given. */
|
||
paramIndex(name: string): number;
|
||
}
|
||
|
||
/**
|
||
* A node of a particle graph. It never runs as a behavior does: `emit` builds the function each of
|
||
* its outputs is. Every value is a number – a colour is a packed 0xRRGGBB one, as everywhere else
|
||
* in the project.
|
||
*/
|
||
export interface ParticleNodeDef extends NodeDef {
|
||
category: 'particles';
|
||
emit(
|
||
inputs: Record<string, ParticleExpr>,
|
||
params: PropertyBag,
|
||
ctx: ParticleEmitContext,
|
||
): Record<string, ParticleExpr>;
|
||
/** What an input with nothing to type on reads while unconnected; without it, zero. */
|
||
unfed?: Record<string, ParticleExpr>;
|
||
}
|
||
|
||
export function isParticleNodeDef(def: NodeDef): def is ParticleNodeDef {
|
||
return def.category === 'particles' && 'emit' in def;
|
||
}
|
||
|
||
export const PARTICLE_OUTPUT = 'ParticleOutput';
|
||
export const PARTICLE_RANDOM = 'ParticleRandom';
|
||
export const PARTICLE_TIME = 'ParticleTime';
|
||
|
||
/** The seven things a particle is drawn with – every output of the Output node. */
|
||
export const PARTICLE_OUTPUTS = ['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha'] as const;
|
||
|
||
export type ParticleOutputKey = (typeof PARTICLE_OUTPUTS)[number];
|
||
|
||
const DEG = Math.PI / 180;
|
||
const NUMBER_STEP = 0.1;
|
||
|
||
/**
|
||
* A particle's own random number, 0..1: fixed for its whole life, and different per node, so the
|
||
* direction and the speed of one particle are not the same draw. Mixing `salt` in is what separates
|
||
* the nodes; the node's id is hashed into it, so an edit elsewhere in the graph does not reshuffle it.
|
||
*/
|
||
function random01(seed: number, salt: number): number {
|
||
let h = (seed ^ salt) >>> 0;
|
||
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
|
||
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
|
||
h = (h ^ (h >>> 16)) >>> 0;
|
||
return h / 0x100000000;
|
||
}
|
||
|
||
export function hashId(id: string): number {
|
||
let h = 0x811c9dc5;
|
||
for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 0x01000193) >>> 0;
|
||
return h >>> 0;
|
||
}
|
||
|
||
const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' });
|
||
|
||
/** A number input with an inline value, so a constant needs no node of its own. */
|
||
const numberIn = (key: string, value: number, step = NUMBER_STEP): PortDef => ({
|
||
key,
|
||
label: key,
|
||
type: 'number',
|
||
default: value,
|
||
field: { key, label: key, kind: 'number', default: value, step },
|
||
});
|
||
|
||
/** A number input carrying a colour: the field is a swatch, the value a packed 0xRRGGBB. */
|
||
const colorIn = (key: string, value = 0xffffff): PortDef => ({
|
||
key,
|
||
label: key,
|
||
type: 'number',
|
||
default: value,
|
||
field: flatDef(key, 'color', { label: key, value }),
|
||
});
|
||
|
||
const constant =
|
||
(value: number): ParticleExpr =>
|
||
() =>
|
||
value;
|
||
|
||
function source(type: string, label: string, key: string, description: string, read: ParticleExpr) {
|
||
const def: ParticleNodeDef = {
|
||
type,
|
||
label,
|
||
category: 'particles',
|
||
description,
|
||
inputs: [],
|
||
outputs: [numberOut(key)],
|
||
params: [],
|
||
emit: () => ({ [key]: read }),
|
||
};
|
||
return def;
|
||
}
|
||
|
||
export const ParticleAge = source(
|
||
'ParticleAge',
|
||
'Age',
|
||
'age',
|
||
'How far through its life this particle is: 0 when it is born, 1 when it dies.',
|
||
(env) => env.age,
|
||
);
|
||
|
||
export const ParticleAgeSeconds = source(
|
||
'ParticleAgeSeconds',
|
||
'Age in seconds',
|
||
'seconds',
|
||
'How long this particle has been alive, in seconds – the t of a formula of motion.',
|
||
(env) => env.ageSeconds,
|
||
);
|
||
|
||
export const ParticleTimeNode = source(
|
||
PARTICLE_TIME,
|
||
'Time',
|
||
'seconds',
|
||
'Seconds since the source started emitting. The same for every particle alive, so it drifts them together.',
|
||
(env) => env.time,
|
||
);
|
||
|
||
export const ParticleIndex = source(
|
||
'ParticleIndex',
|
||
'Index',
|
||
'index',
|
||
'Which particle this is, counting up from the first one the source emitted: a fan or a ring instead of a scatter.',
|
||
(env) => env.index,
|
||
);
|
||
|
||
/** Two Randoms give one particle two different draws – that is what the node id in the salt is for. */
|
||
export const ParticleRandom: ParticleNodeDef = {
|
||
type: PARTICLE_RANDOM,
|
||
label: 'Random',
|
||
category: 'particles',
|
||
description:
|
||
'A number drawn once per particle and kept for its whole life. Two Random nodes draw differently, so direction and speed can scatter apart.',
|
||
inputs: [numberIn('min', 0), numberIn('max', 1)],
|
||
outputs: [numberOut('value')],
|
||
params: [],
|
||
emit: ({ min, max }, _params, ctx) => {
|
||
const salt = hashId(ctx.nodeId);
|
||
return {
|
||
value: (env, locals) => {
|
||
const lo = min!(env, locals);
|
||
return lo + random01(env.seed, salt) * (max!(env, locals) - lo);
|
||
},
|
||
};
|
||
},
|
||
};
|
||
|
||
/**
|
||
* The offset gravity has added by now: ½·g·t². Written as a node because a formula, unlike a
|
||
* simulation, has to say the whole fall rather than the force behind it.
|
||
*/
|
||
export const ParticleGravity: ParticleNodeDef = {
|
||
type: 'ParticleGravity',
|
||
label: 'Gravity',
|
||
category: 'particles',
|
||
description:
|
||
'How far a steady pull has moved the particle by now, down being positive: ½ × strength × age². Add it to the offset.',
|
||
inputs: [numberIn('strength', 800, 10)],
|
||
outputs: [numberOut('x'), numberOut('y')],
|
||
params: [],
|
||
emit: ({ strength }) => ({
|
||
x: constant(0),
|
||
y: (env, locals) => 0.5 * strength!(env, locals) * env.ageSeconds * env.ageSeconds,
|
||
}),
|
||
};
|
||
|
||
/** The offset a constant speed has added by now: v₀·t, aimed by an angle in degrees. */
|
||
export const ParticleStartVelocity: ParticleNodeDef = {
|
||
type: 'ParticleStartVelocity',
|
||
label: 'Start velocity',
|
||
category: 'particles',
|
||
description:
|
||
'How far the particle has flown at a steady speed: angle in degrees (0 is right, 90 is down), speed in pixels per second.',
|
||
inputs: [numberIn('angle', 0, 1), numberIn('speed', 200, 10)],
|
||
outputs: [numberOut('x'), numberOut('y')],
|
||
params: [],
|
||
emit: ({ angle, speed }) => ({
|
||
x: (env, locals) => Math.cos(angle!(env, locals) * DEG) * speed!(env, locals) * env.ageSeconds,
|
||
y: (env, locals) => Math.sin(angle!(env, locals) * DEG) * speed!(env, locals) * env.ageSeconds,
|
||
}),
|
||
};
|
||
|
||
export const ParticleNumber: ParticleNodeDef = {
|
||
type: 'ParticleNumber',
|
||
label: 'Number',
|
||
category: 'particles',
|
||
description: 'A constant number.',
|
||
inputs: [],
|
||
outputs: [numberOut('value')],
|
||
params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: NUMBER_STEP }],
|
||
emit: (_inputs, params) => ({ value: constant(Number(params.value ?? 0) || 0) }),
|
||
};
|
||
|
||
export const ParticleColor: ParticleNodeDef = {
|
||
type: 'ParticleColor',
|
||
label: 'Color',
|
||
category: 'particles',
|
||
description: 'A constant color.',
|
||
inputs: [],
|
||
outputs: [numberOut('color')],
|
||
params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })],
|
||
emit: (_inputs, params) => ({ color: constant(Number(params.value ?? 0xffffff) || 0) }),
|
||
};
|
||
|
||
/** `name` is a row on every source placed from this asset, and a value here. */
|
||
export const ParticleProperty: ParticleNodeDef = {
|
||
type: PARTICLE_PROPERTY,
|
||
label: 'Property',
|
||
category: 'particles',
|
||
description: 'A number the source sets: a property row on the placed source, and a value here.',
|
||
inputs: [],
|
||
outputs: [numberOut('value')],
|
||
params: propertyNodeParams(NUMBER_STEP),
|
||
derivePorts: ({ node }) => ({ label: propertyNameOf(node) || ParticleProperty.label }),
|
||
emit: (_inputs, params, ctx) => {
|
||
// The slot is bound once, here: reading it per particle would cost a lookup by name each time.
|
||
const slot = ctx.paramIndex(paramNameOf(params));
|
||
return { value: slot < 0 ? constant(0) : (env) => env.params[slot] ?? 0 };
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Where the particle is, how big, how turned and what colour – all of it relative to the source.
|
||
* An unconnected input leaves that part of the particle as it would be without a graph.
|
||
*/
|
||
export const ParticleOutput: ParticleNodeDef = {
|
||
type: PARTICLE_OUTPUT,
|
||
label: 'Output',
|
||
category: 'particles',
|
||
description:
|
||
'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, color tints it and alpha fades it.',
|
||
inputs: [
|
||
{ key: 'x', label: 'x', type: 'number', inline: false },
|
||
{ key: 'y', label: 'y', type: 'number', inline: false },
|
||
{ key: 'scaleX', label: 'scaleX', type: 'number', inline: false },
|
||
{ key: 'scaleY', label: 'scaleY', type: 'number', inline: false },
|
||
{ key: 'rotation', label: 'rotation', type: 'number', inline: false },
|
||
{ key: 'color', label: 'color', type: 'number', inline: false },
|
||
{ key: 'alpha', label: 'alpha', type: 'number', inline: false },
|
||
],
|
||
outputs: [],
|
||
params: [],
|
||
unfed: {
|
||
x: constant(0),
|
||
y: constant(0),
|
||
scaleX: constant(1),
|
||
scaleY: constant(1),
|
||
rotation: constant(0),
|
||
color: (env) => env.color,
|
||
alpha: constant(1),
|
||
},
|
||
emit: () => ({}),
|
||
};
|
||
|
||
function arithmetic(
|
||
type: string,
|
||
label: string,
|
||
description: string,
|
||
bDefault: number,
|
||
op: (a: number, b: number) => number,
|
||
): ParticleNodeDef {
|
||
return {
|
||
type,
|
||
label,
|
||
category: 'particles',
|
||
description,
|
||
inputs: [numberIn('a', bDefault === 1 ? 1 : 0), numberIn('b', bDefault)],
|
||
outputs: [numberOut('result')],
|
||
params: [],
|
||
emit: ({ a, b }) => ({ result: (env, locals) => op(a!(env, locals), b!(env, locals)) }),
|
||
};
|
||
}
|
||
|
||
export const ParticleAdd = arithmetic('ParticleAdd', 'Add', 'a + b.', 0, (a, b) => a + b);
|
||
export const ParticleSubtract = arithmetic('ParticleSubtract', 'Subtract', 'a − b.', 0, (a, b) => a - b);
|
||
export const ParticleMultiply = arithmetic('ParticleMultiply', 'Multiply', 'a × b.', 1, (a, b) => a * b);
|
||
/** Division by zero would give Infinity, which draws as nothing at all; it reads as 0 instead. */
|
||
export const ParticleDivide = arithmetic('ParticleDivide', 'Divide', 'a ÷ b; ÷ 0 is 0.', 1, (a, b) =>
|
||
b === 0 ? 0 : a / b,
|
||
);
|
||
|
||
function unary(type: string, label: string, description: string, fn: (v: number) => number) {
|
||
const def: ParticleNodeDef = {
|
||
type,
|
||
label,
|
||
category: 'particles',
|
||
description,
|
||
inputs: [numberIn('value', 0)],
|
||
outputs: [numberOut('result')],
|
||
params: [],
|
||
emit: ({ value }) => ({ result: (env, locals) => fn(value!(env, locals)) }),
|
||
};
|
||
return def;
|
||
}
|
||
|
||
export const ParticleSin = unary(
|
||
'ParticleSin',
|
||
'Sin',
|
||
'The sine of an angle in degrees: a wave between −1 and 1, one full turn every 360.',
|
||
(v) => Math.sin(v * DEG),
|
||
);
|
||
export const ParticleCos = unary(
|
||
'ParticleCos',
|
||
'Cos',
|
||
'The cosine of an angle in degrees: the same wave as Sin, a quarter turn ahead.',
|
||
(v) => Math.cos(v * DEG),
|
||
);
|
||
export const ParticleSqrt = unary(
|
||
'ParticleSqrt',
|
||
'Sqrt',
|
||
'The square root: rises fast at first and then slows down, which is how smoke drifts up. Below 0 it is 0.',
|
||
(v) => (v > 0 ? Math.sqrt(v) : 0),
|
||
);
|
||
export const ParticleAbs = unary(
|
||
'ParticleAbs',
|
||
'Abs',
|
||
'The distance from 0, sign dropped: −0.3 becomes 0.3.',
|
||
Math.abs,
|
||
);
|
||
export const ParticleFloor = unary(
|
||
'ParticleFloor',
|
||
'Floor',
|
||
'Rounds down to a whole number: 2.75 becomes 2.',
|
||
Math.floor,
|
||
);
|
||
export const ParticleFract = unary(
|
||
'ParticleFract',
|
||
'Fract',
|
||
'The part after the decimal point: a rising value becomes a sawtooth from 0 to 1.',
|
||
(v) => v - Math.floor(v),
|
||
);
|
||
|
||
export const ParticleMin: ParticleNodeDef = {
|
||
type: 'ParticleMin',
|
||
label: 'Min',
|
||
category: 'particles',
|
||
description: 'The smaller of two numbers.',
|
||
inputs: [numberIn('a', 0), numberIn('b', 0)],
|
||
outputs: [numberOut('result')],
|
||
params: [],
|
||
emit: ({ a, b }) => ({ result: (env, locals) => Math.min(a!(env, locals), b!(env, locals)) }),
|
||
};
|
||
|
||
export const ParticleMax: ParticleNodeDef = {
|
||
type: 'ParticleMax',
|
||
label: 'Max',
|
||
category: 'particles',
|
||
description: 'The larger of two numbers.',
|
||
inputs: [numberIn('a', 0), numberIn('b', 0)],
|
||
outputs: [numberOut('result')],
|
||
params: [],
|
||
emit: ({ a, b }) => ({ result: (env, locals) => Math.max(a!(env, locals), b!(env, locals)) }),
|
||
};
|
||
|
||
export const ParticleClamp: ParticleNodeDef = {
|
||
type: 'ParticleClamp',
|
||
label: 'Clamp',
|
||
category: 'particles',
|
||
description: 'Holds a value between min and max: below min it is min, above max it is max.',
|
||
inputs: [numberIn('value', 0), numberIn('min', 0), numberIn('max', 1)],
|
||
outputs: [numberOut('result')],
|
||
params: [],
|
||
emit: ({ value, min, max }) => ({
|
||
result: (env, locals) => Math.min(Math.max(value!(env, locals), min!(env, locals)), max!(env, locals)),
|
||
}),
|
||
};
|
||
|
||
export const ParticleMix: ParticleNodeDef = {
|
||
type: 'ParticleMix',
|
||
label: 'Mix',
|
||
category: 'particles',
|
||
description: 'Blends from a to b: t = 0 is a, t = 1 is b, beyond that it carries on.',
|
||
inputs: [numberIn('a', 0), numberIn('b', 1), numberIn('t', 0.5)],
|
||
outputs: [numberOut('result')],
|
||
params: [],
|
||
emit: ({ a, b, t }) => ({
|
||
result: (env, locals) => {
|
||
const from = a!(env, locals);
|
||
return from + (b!(env, locals) - from) * t!(env, locals);
|
||
},
|
||
}),
|
||
};
|
||
|
||
/**
|
||
* Colours are packed numbers, so blending them is a channel-by-channel job a plain Mix cannot do –
|
||
* mixing 0xff0000 and 0x0000ff as numbers would run through green.
|
||
*/
|
||
export const ParticleMixColor: ParticleNodeDef = {
|
||
type: 'ParticleMixColor',
|
||
label: 'Mix color',
|
||
category: 'particles',
|
||
description: 'Blends two colors channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.',
|
||
inputs: [colorIn('a', 0xffffff), colorIn('b', 0x000000), numberIn('t', 0.5)],
|
||
outputs: [numberOut('color')],
|
||
params: [],
|
||
emit: ({ a, b, t }) => ({
|
||
color: (env, locals) => mixColor(a!(env, locals), b!(env, locals), t!(env, locals)),
|
||
}),
|
||
};
|
||
|
||
export const ParticleStep: ParticleNodeDef = {
|
||
type: 'ParticleStep',
|
||
label: 'Step',
|
||
category: 'particles',
|
||
description: '0 while value is below edge, 1 from edge on: a hard cut.',
|
||
inputs: [numberIn('edge', 0.5), numberIn('value', 0)],
|
||
outputs: [numberOut('result')],
|
||
params: [],
|
||
emit: ({ edge, value }) => ({
|
||
result: (env, locals) => (value!(env, locals) < edge!(env, locals) ? 0 : 1),
|
||
}),
|
||
};
|
||
|
||
/** Negative bases have no real power, so the value is held at 0 there, as the shader's Pow does. */
|
||
export const ParticlePow: ParticleNodeDef = {
|
||
type: 'ParticlePow',
|
||
label: 'Pow',
|
||
category: 'particles',
|
||
description:
|
||
'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0.',
|
||
inputs: [numberIn('value', 0), numberIn('exponent', 2)],
|
||
outputs: [numberOut('result')],
|
||
params: [],
|
||
emit: ({ value, exponent }) => ({
|
||
result: (env, locals) => Math.pow(Math.max(value!(env, locals), 0), exponent!(env, locals)),
|
||
}),
|
||
};
|
||
|
||
/** Every node a particle graph offers. Registered by `graph/nodes`, offered by no behavior palette. */
|
||
export const PARTICLE_NODES: readonly ParticleNodeDef[] = [
|
||
ParticleAge,
|
||
ParticleAgeSeconds,
|
||
ParticleTimeNode,
|
||
ParticleIndex,
|
||
ParticleRandom,
|
||
ParticleGravity,
|
||
ParticleStartVelocity,
|
||
ParticleOutput,
|
||
ParticleProperty,
|
||
ParticleNumber,
|
||
ParticleColor,
|
||
ParticleAdd,
|
||
ParticleSubtract,
|
||
ParticleMultiply,
|
||
ParticleDivide,
|
||
ParticleMin,
|
||
ParticleMax,
|
||
ParticleClamp,
|
||
ParticleMix,
|
||
ParticleMixColor,
|
||
ParticleSin,
|
||
ParticleCos,
|
||
ParticleSqrt,
|
||
ParticleAbs,
|
||
ParticleFloor,
|
||
ParticleFract,
|
||
ParticleStep,
|
||
ParticlePow,
|
||
];
|
||
|
||
/** The name a Property node placed from the palette gets: `value`, then `value2`, `value3` … */
|
||
export function nextParticlePropertyName(taken: ReadonlySet<string>): string {
|
||
if (!taken.has('value')) return 'value';
|
||
for (let n = 2; ; n++) if (!taken.has(`value${n}`)) return `value${n}`;
|
||
}
|
||
|
||
function paramNameOf(params: PropertyBag): string {
|
||
return textOf(params[PROPERTY_NAME_PARAM] ?? '');
|
||
}
|