lgimgui 0.1.0

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.
@@ -0,0 +1,77 @@
1
+ function mixColor(a, b, t) {
2
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
3
+ }
4
+
5
+ export const ACCENT = [0.17, 0.44, 1, 1]
6
+
7
+ const LIGHT = {
8
+ fg: [0.06, 0.06, 0.08, 0.94],
9
+ fg2: [0.06, 0.06, 0.08, 0.58],
10
+ fg3: [0.06, 0.06, 0.08, 0.34],
11
+ hover: [0.06, 0.06, 0.08, 0.07],
12
+ press: [0.06, 0.06, 0.08, 0.13],
13
+ selected: [0.06, 0.06, 0.08, 0.1],
14
+ track: [0.06, 0.06, 0.08, 0.14],
15
+ separator: [0.06, 0.06, 0.08, 0.1],
16
+ knob: [1, 1, 1, 1],
17
+ field: [1, 1, 1, 0.62],
18
+ onAccent: [1, 1, 1, 1],
19
+ accent: ACCENT,
20
+ accentPress: [0.13, 0.36, 0.88, 1],
21
+ switchOn: [0.2, 0.78, 0.35, 1],
22
+ focusRing: [0.17, 0.44, 1, 0.7],
23
+ dim: [0, 0, 0, 0.22],
24
+ }
25
+
26
+ const DARK = {
27
+ fg: [1, 1, 1, 0.96],
28
+ fg2: [1, 1, 1, 0.62],
29
+ fg3: [1, 1, 1, 0.36],
30
+ hover: [1, 1, 1, 0.1],
31
+ press: [1, 1, 1, 0.18],
32
+ selected: [1, 1, 1, 0.14],
33
+ track: [1, 1, 1, 0.22],
34
+ separator: [1, 1, 1, 0.14],
35
+ knob: [1, 1, 1, 1],
36
+ field: [1, 1, 1, 0.14],
37
+ onAccent: [1, 1, 1, 1],
38
+ accent: [0.35, 0.58, 1, 1],
39
+ accentPress: [0.28, 0.5, 0.92, 1],
40
+ switchOn: [0.25, 0.82, 0.4, 1],
41
+ focusRing: [0.45, 0.65, 1, 0.8],
42
+ dim: [0, 0, 0, 0.3],
43
+ }
44
+
45
+ export function resolveTheme(dark, settings) {
46
+ const theme = {}
47
+ for (const key of Object.keys(LIGHT)) theme[key] = mixColor(LIGHT[key], DARK[key], dark)
48
+ theme.dark = dark
49
+ if (settings?.increaseContrast) {
50
+ const strong = dark > 0.5
51
+ theme.fg = strong ? [1, 1, 1, 1] : [0, 0, 0, 1]
52
+ theme.fg2 = strong ? [1, 1, 1, 0.85] : [0, 0, 0, 0.85]
53
+ theme.fg3 = strong ? [1, 1, 1, 0.7] : [0, 0, 0, 0.7]
54
+ theme.track = strong ? [1, 1, 1, 0.4] : [0, 0, 0, 0.35]
55
+ theme.separator = strong ? [1, 1, 1, 0.5] : [0, 0, 0, 0.5]
56
+ theme.hover = strong ? [1, 1, 1, 0.2] : [0, 0, 0, 0.12]
57
+ theme.press = strong ? [1, 1, 1, 0.32] : [0, 0, 0, 0.22]
58
+ theme.border = strong ? [1, 1, 1, 0.9] : [0, 0, 0, 0.9]
59
+ }
60
+ return theme
61
+ }
62
+
63
+ export function withAlpha(color, alpha) {
64
+ return [color[0], color[1], color[2], color[3] * alpha]
65
+ }
66
+
67
+ export function tintFamily(color, backdropLuminance) {
68
+ const lift = (0.5 - backdropLuminance) * 0.18
69
+ return [
70
+ Math.min(1, Math.max(0, color[0] + lift)),
71
+ Math.min(1, Math.max(0, color[1] + lift)),
72
+ Math.min(1, Math.max(0, color[2] + lift)),
73
+ color[3],
74
+ ]
75
+ }
76
+
77
+ export const CONTENT_THEME = resolveTheme(0, null)
package/src/index.js ADDED
@@ -0,0 +1,221 @@
1
+ import { createRenderer } from './render/renderer.js'
2
+ import { createInput } from './core/input.js'
3
+ import { createStateStore } from './core/state.js'
4
+ import { createIdScope } from './core/ids.js'
5
+ import { createSpringClock, springs, springForDuration } from './core/spring.js'
6
+ import { createFrameDriver } from './core/frame.js'
7
+ import { createGlass } from './widgets/glass.js'
8
+ import { createPrimitives } from './widgets/primitives.js'
9
+ import { createControls } from './widgets/controls.js'
10
+ import { createComposite } from './widgets/composite.js'
11
+ import { createDesktop } from './widgets/desktop.js'
12
+ import { clonePresets, ROLE_PRESETS, ROLES, serializeMaterials, parseMaterials, assignMaterials } from './material/presets.js'
13
+ import { MATERIAL_PARAMS, SHAPE_PARAMS, MATERIAL_GROUPS, defaultMaterial, normalizeMaterial } from './material/params.js'
14
+ import { mixMaterial, unfocusedMaterial, darkMaterial } from './material/material.js'
15
+ import { ACCENT } from './core/theme.js'
16
+
17
+ export { ROLE_PRESETS, ROLES, serializeMaterials, parseMaterials, unfocusedMaterial, darkMaterial, MATERIAL_PARAMS, SHAPE_PARAMS, MATERIAL_GROUPS, defaultMaterial, normalizeMaterial, mixMaterial, springs, springForDuration, ACCENT }
18
+
19
+ export function createUI(canvas, options = {}) {
20
+ const renderer = createRenderer(canvas)
21
+ const input = createInput(canvas)
22
+ const state = createStateStore()
23
+ const ids = createIdScope()
24
+ const clock = createSpringClock()
25
+ const driver = createFrameDriver({ renderer, input, state, ids, clock, probe: renderer.probe })
26
+
27
+ const settings = { reduceTransparency: false, increaseContrast: false, reduceMotion: false, appearance: 'auto', ...(options.settings ?? {}) }
28
+ const materials = options.materials ? assignMaterials(clonePresets(), parseMaterials(options.materials)) : clonePresets()
29
+ const focus = { id: null, keyboard: false }
30
+ let focusables = []
31
+ let lastTime = 0
32
+ let running = false
33
+ let dirty = true
34
+ let wakeAt = 0
35
+ let continuous = false
36
+ let frameCallback = null
37
+ let cursor = null
38
+ let stableCollects = 2
39
+ let requested = true
40
+
41
+ function interaction(id, props = {}) {
42
+ const f = driver.frame
43
+ const disabled = !!props.disabled
44
+ if (props.focusable !== false && !disabled) driver.registerFocusable(id)
45
+ const focused = focus.id === id
46
+ let clicked = !disabled && f.clicked.has(id)
47
+ if (focused && focus.keyboard && !disabled && (f.keysPressed.has('Enter') || f.keysPressed.has('Space'))) clicked = true
48
+ return {
49
+ hot: !disabled && f.hot === id,
50
+ active: !disabled && f.active === id,
51
+ pressed: !disabled && f.pressed.has(id),
52
+ released: f.released.has(id),
53
+ clicked,
54
+ focused,
55
+ keyboard: focus.keyboard,
56
+ disabled,
57
+ }
58
+ }
59
+
60
+ function insideGlass() {
61
+ let node = driver.current()
62
+ while (node) {
63
+ if (node.kind === 'glass') return true
64
+ node = node.parent
65
+ }
66
+ return false
67
+ }
68
+
69
+ function updateFocus(frame) {
70
+ if (frame.pressedAnywhere) {
71
+ focus.keyboard = false
72
+ const target = frame.hotEntry?.id ?? null
73
+ focus.id = target && focusables.includes(target) ? target : null
74
+ }
75
+ if (frame.keysPressed.has('Tab') && focusables.length > 0) {
76
+ const index = focusables.indexOf(focus.id)
77
+ const direction = frame.keysDown.has('Shift') ? -1 : 1
78
+ const next = index < 0 ? (direction > 0 ? 0 : focusables.length - 1) : (index + direction + focusables.length) % focusables.length
79
+ focus.id = focusables[next]
80
+ focus.keyboard = true
81
+ }
82
+ if (frame.keysPressed.has('Escape') && focus.keyboard) focus.id = null
83
+ }
84
+
85
+ const core = {
86
+ renderer,
87
+ input,
88
+ state,
89
+ ids,
90
+ clock,
91
+ driver,
92
+ settings,
93
+ materials,
94
+ focus,
95
+ interaction,
96
+ insideGlass,
97
+ toolbarDepth: 0,
98
+ capsuleDepth: 0,
99
+ setCursor(name) {
100
+ cursor = name
101
+ },
102
+ invalidateSoon(ms) {
103
+ const at = performance.now() + ms
104
+ if (!wakeAt || at < wakeAt) wakeAt = at
105
+ },
106
+ }
107
+
108
+ const glassWidget = createGlass(core)
109
+ const primitives = createPrimitives(core)
110
+ const controls = createControls(core, primitives, glassWidget)
111
+ const composite = createComposite(core, primitives, controls, glassWidget)
112
+
113
+ function currentSize() {
114
+ const bounds = canvas.getBoundingClientRect()
115
+ return { w: Math.max(1, bounds.width), h: Math.max(1, bounds.height), dpr: Math.min(options.maxDpr ?? 3, window.devicePixelRatio || 1) }
116
+ }
117
+
118
+ function frame(fn, time = performance.now()) {
119
+ const size = currentSize()
120
+ renderer.resize(size.w, size.h, size.dpr)
121
+ const seconds = lastTime ? (time - lastTime) / 1000 : 1 / 60
122
+ lastTime = time
123
+ clock.reduceMotion = settings.reduceMotion
124
+ const frameInput = driver.begin(size.w, size.h, seconds)
125
+ updateFocus(frameInput)
126
+ const probes = renderer.probe.poll()
127
+ cursor = null
128
+ fn(ui)
129
+ const result = driver.end({ clearColor: options.clearColor })
130
+ canvas.style.cursor = cursor ?? 'default'
131
+ focusables = driver.focusOrder
132
+ const active = result.animating || !frameInput.idle || probes.changed || requested
133
+ requested = false
134
+ if (active) stableCollects = 0
135
+ else if (probes.collected > 0) stableCollects += 1
136
+ dirty = active
137
+ if (active || stableCollects < 2) core.invalidateSoon(40)
138
+ return result
139
+ }
140
+
141
+ function tick(time) {
142
+ if (!running) return
143
+ const wakeDue = wakeAt && time >= wakeAt
144
+ const needsFrame = dirty || continuous || input.pending() || wakeDue
145
+ if (needsFrame) {
146
+ if (wakeDue) wakeAt = 0
147
+ frame(frameCallback, time)
148
+ }
149
+ requestAnimationFrame(tick)
150
+ }
151
+
152
+ function start(fn) {
153
+ frameCallback = fn
154
+ if (running) return
155
+ running = true
156
+ input.attach()
157
+ dirty = true
158
+ const observer = new ResizeObserver(() => {
159
+ dirty = true
160
+ })
161
+ observer.observe(canvas)
162
+ window.addEventListener('resize', invalidate)
163
+ requestAnimationFrame(tick)
164
+ }
165
+
166
+ function stop() {
167
+ running = false
168
+ input.detach()
169
+ window.removeEventListener('resize', invalidate)
170
+ }
171
+
172
+ function invalidate() {
173
+ dirty = true
174
+ requested = true
175
+ }
176
+
177
+ const ui = {
178
+ frame,
179
+ start,
180
+ stop,
181
+ invalidate,
182
+ settings,
183
+ materials,
184
+ focus,
185
+ renderer,
186
+ state,
187
+ springs,
188
+ ...primitives,
189
+ ...controls,
190
+ ...composite,
191
+ glass: glassWidget.glass,
192
+ desktop: (opts) => createDesktop(ui, opts),
193
+ debug: { driver, core },
194
+ exportMaterials: () => serializeMaterials(materials),
195
+ importMaterials: (data) => {
196
+ assignMaterials(materials, parseMaterials(data))
197
+ invalidate()
198
+ },
199
+ setCursor: (name) => core.setCursor(name),
200
+ loadImage: (source, opts) => renderer.loadImage(source, opts),
201
+ updateImage: (image, source) => renderer.updateImage(image, source),
202
+ destroyImage: (image) => renderer.destroyImage(image),
203
+ get input() {
204
+ return driver.frame
205
+ },
206
+ get viewport() {
207
+ return driver.viewport
208
+ },
209
+ get continuous() {
210
+ return continuous
211
+ },
212
+ set continuous(value) {
213
+ continuous = value
214
+ },
215
+ get time() {
216
+ return lastTime
217
+ },
218
+ }
219
+
220
+ return ui
221
+ }
@@ -0,0 +1,72 @@
1
+ const DARK_BELOW = 0.4
2
+ const LIGHT_ABOVE = 0.52
3
+
4
+ export function analyzeBackdrop(probe, rect) {
5
+ if (!probe || probe.width === 0) return { luminance: 0.75, detail: 0.2, samples: 0 }
6
+ const x0 = Math.max(0, Math.floor(rect.x * probe.scaleX))
7
+ const y0 = Math.max(0, Math.floor(rect.y * probe.scaleY))
8
+ const x1 = Math.min(probe.width - 1, Math.ceil((rect.x + rect.w) * probe.scaleX))
9
+ const y1 = Math.min(probe.height - 1, Math.ceil((rect.y + rect.h) * probe.scaleY))
10
+ if (x1 < x0 || y1 < y0) return { luminance: 0.75, detail: 0.2, samples: 0 }
11
+
12
+ let sum = 0
13
+ let gradient = 0
14
+ let count = 0
15
+ let edges = 0
16
+ const data = probe.data
17
+ const stride = probe.width * 4
18
+ for (let y = y0; y <= y1; y++) {
19
+ const row = probe.height - 1 - y
20
+ for (let x = x0; x <= x1; x++) {
21
+ const i = row * stride + x * 4
22
+ const l = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255
23
+ sum += l
24
+ count += 1
25
+ if (x < x1) {
26
+ const j = i + 4
27
+ const lr = (0.2126 * data[j] + 0.7152 * data[j + 1] + 0.0722 * data[j + 2]) / 255
28
+ gradient += Math.abs(lr - l)
29
+ edges += 1
30
+ }
31
+ if (y < y1) {
32
+ const j = i - stride
33
+ const ld = (0.2126 * data[j] + 0.7152 * data[j + 1] + 0.0722 * data[j + 2]) / 255
34
+ gradient += Math.abs(ld - l)
35
+ edges += 1
36
+ }
37
+ }
38
+ }
39
+ const luminance = count ? sum / count : 0.75
40
+ const detail = edges ? Math.min(1, (gradient / edges) / 0.12) : 0
41
+ return { luminance, detail, samples: count }
42
+ }
43
+
44
+ export function resolvePolarity(data, analysis, surface, clock) {
45
+ if (surface === 'large') {
46
+ data.polarity = 'light'
47
+ return clock.animate(data, 'dark', 0, { stiffness: 120, damping: 20 })
48
+ }
49
+ if (data.polarity == null) data.polarity = analysis.luminance < (DARK_BELOW + LIGHT_ABOVE) / 2 ? 'dark' : 'light'
50
+ if (analysis.samples > 0) {
51
+ if (data.polarity === 'light' && analysis.luminance < DARK_BELOW) data.polarity = 'dark'
52
+ else if (data.polarity === 'dark' && analysis.luminance > LIGHT_ABOVE) data.polarity = 'light'
53
+ }
54
+ return clock.animate(data, 'dark', data.polarity === 'dark' ? 1 : 0, { stiffness: 120, damping: 20 })
55
+ }
56
+
57
+ export function adaptiveScales(analysis) {
58
+ const detail = analysis.detail
59
+ const bright = Math.max(0, (analysis.luminance - 0.6) / 0.4)
60
+ const shadow = 0.55 + detail * 0.95 - bright * (1 - detail) * 0.3
61
+ const tint = 0.85 + detail * 0.4
62
+ return { shadow: Math.max(0.2, shadow), tint: Math.min(1.3, tint) }
63
+ }
64
+
65
+ export function resolveBorderFade(data, analysis, clock) {
66
+ if (data.borderPolarity == null) data.borderPolarity = analysis.samples > 0 && analysis.luminance < (DARK_BELOW + LIGHT_ABOVE) / 2 ? 'dark' : 'light'
67
+ if (analysis.samples > 0) {
68
+ if (data.borderPolarity === 'light' && analysis.luminance < DARK_BELOW) data.borderPolarity = 'dark'
69
+ else if (data.borderPolarity === 'dark' && analysis.luminance > LIGHT_ABOVE) data.borderPolarity = 'light'
70
+ }
71
+ return clock.animate(data, 'borderDark', data.borderPolarity === 'dark' ? 1 : 0, { stiffness: 260, damping: 30 })
72
+ }
@@ -0,0 +1,131 @@
1
+ import { MATERIAL_PARAMS } from './params.js'
2
+
3
+ function lerp(a, b, t) {
4
+ return a + (b - a) * t
5
+ }
6
+
7
+ export function mixMaterial(a, b, t) {
8
+ if (t <= 0) return a
9
+ if (t >= 1) return b
10
+ const out = {}
11
+ for (const param of MATERIAL_PARAMS) {
12
+ const key = param.key
13
+ if (param.type === 'boolean') out[key] = t < 0.5 ? a[key] : b[key]
14
+ else if (param.type === 'color') {
15
+ out[key] = {
16
+ r: lerp(a[key].r, b[key].r, t),
17
+ g: lerp(a[key].g, b[key].g, t),
18
+ b: lerp(a[key].b, b[key].b, t),
19
+ a: lerp(a[key].a, b[key].a, t),
20
+ }
21
+ } else if (param.type === 'vector') out[key] = { x: lerp(a[key].x, b[key].x, t), y: lerp(a[key].y, b[key].y, t) }
22
+ else out[key] = lerp(a[key], b[key], t)
23
+ }
24
+ return out
25
+ }
26
+
27
+ export function unfocusedMaterial(material) {
28
+ const grey = (value) => value * 0.6 + 222 * 0.4
29
+ return {
30
+ ...material,
31
+ tint: {
32
+ r: grey(material.tint.r),
33
+ g: grey(material.tint.g),
34
+ b: grey(material.tint.b),
35
+ a: material.tint.a,
36
+ },
37
+ refDispersion: material.refDispersion * 0.3,
38
+ refFresnelFactor: 0,
39
+ glareFactor: 0,
40
+ shadowFactor: 0,
41
+ }
42
+ }
43
+
44
+ export function darkMaterial(material) {
45
+ return {
46
+ ...material,
47
+ tint: { r: 28, g: 28, b: 34, a: Math.max(0.38, material.tint.a * 0.85) },
48
+ refFresnelFactor: material.refFresnelFactor * 0.8,
49
+ glareFactor: material.glareFactor * 0.75,
50
+ shadowFactor: material.shadowFactor * 1.1,
51
+ }
52
+ }
53
+
54
+ export function lenslessMaterial(material) {
55
+ return {
56
+ ...material,
57
+ refFactor: 1,
58
+ refDispersion: 0,
59
+ refFresnelFactor: 0,
60
+ glareFactor: 0,
61
+ blurRadius: 1,
62
+ tint: { ...material.tint, a: 0 },
63
+ shadowFactor: 0,
64
+ }
65
+ }
66
+
67
+ export function thickenedMaterial(material, amount) {
68
+ if (amount <= 0) return material
69
+ return {
70
+ ...material,
71
+ refThickness: material.refThickness * (1 + amount * 0.15),
72
+ blurRadius: Math.min(200, material.blurRadius * (1 + amount * 0.8) + amount * 12),
73
+ shadowExpand: material.shadowExpand * (1 + amount * 0.8),
74
+ shadowFactor: Math.min(100, material.shadowFactor * (1 + amount * 0.6)),
75
+ refFresnelFactor: material.refFresnelFactor * (1 + amount * 0.2),
76
+ glareFactor: material.glareFactor * (1 - amount * 0.2),
77
+ }
78
+ }
79
+
80
+ export function accessibleMaterial(material, settings, dark, keepTint = false) {
81
+ let out = material
82
+ if (settings.reduceTransparency && !keepTint) {
83
+ const base = dark ? 34 : 246
84
+ out = {
85
+ ...out,
86
+ blurRadius: Math.max(out.blurRadius, 140),
87
+ refDispersion: 0,
88
+ tint: { r: base, g: base, b: base + (dark ? 4 : 2), a: Math.max(out.tint.a, 0.82) },
89
+ }
90
+ }
91
+ if (settings.increaseContrast) {
92
+ const base = dark ? 0 : 255
93
+ out = {
94
+ ...out,
95
+ blurRadius: Math.max(out.blurRadius, 160),
96
+ refDispersion: 0,
97
+ glareFactor: 0,
98
+ refFresnelFactor: 0,
99
+ tint: keepTint ? { ...out.tint, a: 0.96 } : { r: base, g: base, b: base, a: 0.96 },
100
+ }
101
+ }
102
+ return out
103
+ }
104
+
105
+ export function materialUniforms(material) {
106
+ return {
107
+ u_refThickness: material.refThickness,
108
+ u_refFactor: material.refFactor,
109
+ u_refDispersion: material.refDispersion,
110
+ u_refFresnelRange: material.refFresnelRange,
111
+ u_refFresnelHardness: material.refFresnelHardness / 100,
112
+ u_refFresnelFactor: material.refFresnelFactor / 100,
113
+ u_glareRange: material.glareRange,
114
+ u_glareHardness: material.glareHardness / 100,
115
+ u_glareConvergence: material.glareConvergence / 100,
116
+ u_glareOppositeFactor: material.glareOppositeFactor / 100,
117
+ u_glareFactor: material.glareFactor / 100,
118
+ u_glareAngle: (material.glareAngle * Math.PI) / 180,
119
+ u_blurRadius: material.blurRadius,
120
+ u_blurEdge: material.blurEdge ? 1 : 0,
121
+ u_tint: [material.tint.r / 255, material.tint.g / 255, material.tint.b / 255, material.tint.a],
122
+ }
123
+ }
124
+
125
+ export function shadowUniforms(material) {
126
+ return {
127
+ u_shadowExpand: material.shadowExpand,
128
+ u_shadowFactor: material.shadowFactor / 100,
129
+ u_shadowPosition: [-material.shadowPosition.x, -material.shadowPosition.y],
130
+ }
131
+ }
@@ -0,0 +1,49 @@
1
+ function safeAsin(x) {
2
+ return Math.asin(Math.max(-1, Math.min(1, x)))
3
+ }
4
+
5
+ export function edgeFactor(inwardDistance, thickness, refFactor) {
6
+ if (inwardDistance >= thickness) return 0
7
+ const ratio = 1 - inwardDistance / thickness
8
+ const thetaI = safeAsin(ratio * ratio)
9
+ const thetaT = safeAsin((1 / refFactor) * Math.sin(thetaI))
10
+ return -Math.tan(thetaT - thetaI)
11
+ }
12
+
13
+ export function displacement(inwardDistance, thickness, refFactor) {
14
+ return edgeFactor(inwardDistance, thickness, refFactor) * 70.710678
15
+ }
16
+
17
+ export function superellipseCorner(px, py, radius, n) {
18
+ const x = Math.abs(px)
19
+ const y = Math.abs(py)
20
+ return Math.pow(Math.pow(x, n) + Math.pow(y, n), 1 / n) - radius
21
+ }
22
+
23
+ export function roundedRectSDF(px, py, shape, radius, roundness) {
24
+ const hw = shape.w / 2
25
+ const hh = shape.h / 2
26
+ const qx = px - (shape.x + hw)
27
+ const qy = py - (shape.y + hh)
28
+ const cr = Math.max(0.5, Math.min(radius, Math.min(hw, hh)))
29
+ const n = cr >= Math.min(hw, hh) - 0.5 ? 2 : roundness
30
+ const dx = Math.abs(qx) - hw
31
+ const dy = Math.abs(qy) - hh
32
+ if (dx > -cr && dy > -cr) {
33
+ const cx = Math.sign(qx) * (hw - cr)
34
+ const cy = Math.sign(qy) * (hh - cr)
35
+ return superellipseCorner(qx - cx, qy - cy, cr, n)
36
+ }
37
+ return Math.min(Math.max(dx, dy), 0) + Math.hypot(Math.max(dx, 0), Math.max(dy, 0))
38
+ }
39
+
40
+ export function smin(a, b, k) {
41
+ if (k <= 0) return Math.min(a, b)
42
+ const h = Math.max(0, Math.min(1, 0.5 + (0.5 * (b - a)) / k))
43
+ return b + (a - b) * h - k * h * (1 - h)
44
+ }
45
+
46
+ export function edgeProfile(sdf, range, hardness) {
47
+ const safeRange = Math.max(range, 0.001)
48
+ return Math.max(0, Math.min(1, Math.pow(Math.max(1 + (sdf / 1500) * Math.pow(500 / safeRange, 2) + hardness, 0), 5)))
49
+ }
@@ -0,0 +1,83 @@
1
+ export const MATERIAL_PARAMS = [
2
+ { key: 'cornerRadius', label: 'Corner radius', group: 'shape', min: 0, max: 60, step: 1, value: 12 },
3
+ { key: 'cornerRoundness', label: 'Corner roundness', group: 'shape', min: 2, max: 7, step: 0.01, value: 5 },
4
+ { key: 'refThickness', label: 'Thickness', group: 'refraction', min: 1, max: 80, step: 0.01, value: 20 },
5
+ { key: 'refFactor', label: 'Refraction', group: 'refraction', min: 1, max: 4, step: 0.01, value: 1.4 },
6
+ { key: 'refDispersion', label: 'Dispersion', group: 'refraction', min: 0, max: 50, step: 0.01, value: 7 },
7
+ { key: 'refFresnelRange', label: 'Fresnel range', group: 'fresnel', min: 0, max: 100, step: 0.01, value: 30 },
8
+ { key: 'refFresnelHardness', label: 'Fresnel hardness', group: 'fresnel', min: 0, max: 100, step: 0.01, value: 20 },
9
+ { key: 'refFresnelFactor', label: 'Fresnel', group: 'fresnel', min: 0, max: 100, step: 0.01, value: 20 },
10
+ { key: 'glareRange', label: 'Glare range', group: 'glare', min: 0, max: 100, step: 0.01, value: 30 },
11
+ { key: 'glareHardness', label: 'Glare hardness', group: 'glare', min: 0, max: 100, step: 0.01, value: 20 },
12
+ { key: 'glareFactor', label: 'Glare', group: 'glare', min: 0, max: 120, step: 0.01, value: 90 },
13
+ { key: 'glareConvergence', label: 'Glare convergence', group: 'glare', min: 0, max: 100, step: 0.01, value: 50 },
14
+ { key: 'glareOppositeFactor', label: 'Opposite glare', group: 'glare', min: 0, max: 100, step: 0.01, value: 80 },
15
+ { key: 'glareAngle', label: 'Glare angle', group: 'glare', min: -180, max: 180, step: 0.01, value: -45 },
16
+ { key: 'blurRadius', label: 'Blur', group: 'blur', min: 1, max: 200, step: 1, value: 1 },
17
+ { key: 'blurEdge', label: 'Blur edge', group: 'blur', type: 'boolean', value: true },
18
+ { key: 'tint', label: 'Tint', group: 'tint', type: 'color', value: { r: 255, g: 255, b: 255, a: 0 } },
19
+ { key: 'shadowExpand', label: 'Shadow expand', group: 'shadow', min: 2, max: 100, step: 0.01, value: 25 },
20
+ { key: 'shadowFactor', label: 'Shadow', group: 'shadow', min: 0, max: 100, step: 0.01, value: 15 },
21
+ { key: 'shadowPosition', label: 'Shadow offset', group: 'shadow', type: 'vector', min: -50, max: 50, step: 0.1, value: { x: 0, y: -10 } },
22
+ { key: 'borderWidth', label: 'Border width', group: 'border', min: 0, max: 4, step: 0.1, value: 0 },
23
+ { key: 'borderColor', label: 'Border', group: 'border', type: 'color', value: { r: 255, g: 255, b: 255, a: 0.4 } },
24
+ { key: 'borderAdapt', label: 'Fade over dark', group: 'border', type: 'boolean', value: false },
25
+ { key: 'adaptTint', label: 'Tint response', group: 'adapt', min: 0, max: 100, step: 1, value: 100 },
26
+ { key: 'adaptShadow', label: 'Shadow response', group: 'adapt', min: 0, max: 100, step: 1, value: 100 },
27
+ ]
28
+
29
+ export const SHAPE_PARAMS = [
30
+ { key: 'shapeWidth', label: 'Width', min: 20, max: 800, step: 1, value: 200 },
31
+ { key: 'shapeHeight', label: 'Height', min: 20, max: 800, step: 1, value: 200 },
32
+ { key: 'shapeRadius', label: 'Radius', min: 1, max: 100, step: 0.1, value: 80 },
33
+ { key: 'shapeRoundness', label: 'Roundness', min: 2, max: 7, step: 0.01, value: 5 },
34
+ { key: 'mergeRate', label: 'Merge rate', min: 0, max: 0.3, step: 0.01, value: 0.05 },
35
+ { key: 'springSizeFactor', label: 'Spring size', min: 0, max: 50, step: 0.01, value: 10 },
36
+ ]
37
+
38
+ export const MATERIAL_GROUPS = [
39
+ { key: 'shape', label: 'Shape' },
40
+ { key: 'refraction', label: 'Refraction' },
41
+ { key: 'fresnel', label: 'Fresnel' },
42
+ { key: 'glare', label: 'Glare' },
43
+ { key: 'blur', label: 'Blur' },
44
+ { key: 'tint', label: 'Tint' },
45
+ { key: 'shadow', label: 'Shadow' },
46
+ { key: 'border', label: 'Border' },
47
+ { key: 'adapt', label: 'Adapt' },
48
+ ]
49
+
50
+ export function defaultMaterial() {
51
+ const material = {}
52
+ for (const param of MATERIAL_PARAMS) material[param.key] = structuredClone(param.value)
53
+ return material
54
+ }
55
+
56
+ export function clampParam(param, value) {
57
+ if (param.type === 'boolean') return Boolean(value)
58
+ if (param.type === 'color') {
59
+ return {
60
+ r: Math.min(255, Math.max(0, value.r)),
61
+ g: Math.min(255, Math.max(0, value.g)),
62
+ b: Math.min(255, Math.max(0, value.b)),
63
+ a: Math.min(1, Math.max(0, value.a)),
64
+ }
65
+ }
66
+ if (param.type === 'vector') {
67
+ return {
68
+ x: Math.min(param.max, Math.max(param.min, value.x)),
69
+ y: Math.min(param.max, Math.max(param.min, value.y)),
70
+ }
71
+ }
72
+ return Math.min(param.max, Math.max(param.min, value))
73
+ }
74
+
75
+ export function normalizeMaterial(input, defaults) {
76
+ const base = defaults ? structuredClone(defaults) : defaultMaterial()
77
+ for (const param of MATERIAL_PARAMS) if (base[param.key] === undefined) base[param.key] = structuredClone(param.value)
78
+ if (!input) return base
79
+ for (const param of MATERIAL_PARAMS) {
80
+ if (input[param.key] !== undefined) base[param.key] = clampParam(param, input[param.key])
81
+ }
82
+ return base
83
+ }