babylon.quarks-editor 0.20.0 → 0.23.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to the `babylon.quarks-editor` package are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project follows the `quarks.core` 0.x version line.
6
6
 
7
+ ## [0.23.0] — 2026-10-18
8
+
9
+ ### Added
10
+
11
+ - The projects page shows the host's `backLink` (the same one the editor viewport shows), so a
12
+ workspace embedded in a site can be left for the hosting page without the browser's back button.
13
+
14
+ ### Changed
15
+
16
+ - Version aligned with babylon.quarks 0.23.0, whose simulation now runs on store columns; the
17
+ editor's preview and gallery get the same speed-up with no editor changes.
18
+
19
+ ## [0.22.0] — 2026-10-17
20
+
21
+ ### Added
22
+
23
+ - Renderer module: a "Lighting" selector for mesh particles (`fixed` / `unlit` / `scene`) and
24
+ rim (Fresnel) controls — colour, power, strength, add or mix.
25
+ - Width over Trail module: an "Absolute width" checkbox for `WidthOverLength`'s `absolute`
26
+ flag (the curve as world-unit widths, the pre-0.22 reading).
27
+
28
+ ## [0.21.0] — 2026-10-16
29
+
30
+ ### Changed
31
+
32
+ - Asset folder import inlines external textures and geometries through
33
+ `QuarksLoader.resolveLinkedResources` and a File-backed `QuarksResourceResolver`, matching the
34
+ runtime loader path instead of a private `resolveExternalGeometries` helper.
35
+
7
36
  ## [0.20.0] — 2026-10-16
8
37
 
9
38
  ### Added
@@ -87,31 +87,53 @@ class ImportedFileIndex {
87
87
  }
88
88
  }
89
89
  /**
90
- * Inlines what an "assets as files" export put in separate files: image urls become data URIs
91
- * and external geometries are read into the JSON, so the effect loads (and later exports) as a
90
+ * File-backed {@link QuarksResourceResolver}: relative image urls become data URIs and geometry
91
+ * urls load from the imported file set. Collects missing references into `missing`.
92
+ */
93
+ function createFileResolver(effectPath, index, missing) {
94
+ return {
95
+ async resolve(url, kind, baseUrl) {
96
+ if (kind === 'image') {
97
+ if (isExternalUrl(url)) {
98
+ return url;
99
+ }
100
+ const file = index.find(baseUrl || effectPath, url);
101
+ if (!file) {
102
+ missing.push(url);
103
+ return url;
104
+ }
105
+ return index.read(file, readAsDataUrl);
106
+ }
107
+ // Geometry (and other kinds): stable path key for fetchJson.
108
+ const file = index.find(baseUrl || effectPath, url);
109
+ if (!file) {
110
+ missing.push(url);
111
+ throw new Error('not among the imported files');
112
+ }
113
+ return relativePathOf(file);
114
+ },
115
+ fetchJson(resolvedUrl) {
116
+ // resolvedUrl is relativePathOf(file) from resolve(); look up by path (empty base).
117
+ const file = index.find('', resolvedUrl);
118
+ if (!file) {
119
+ throw new Error(`missing file "${resolvedUrl}"`);
120
+ }
121
+ return index.read(file, async (f) => JSON.parse(await f.text()));
122
+ },
123
+ };
124
+ }
125
+ /**
126
+ * Inlines what an "assets as files" export put in separate files via
127
+ * {@link QuarksLoader.resolveLinkedResources}, so the effect loads (and later exports) as a
92
128
  * self-contained effect. Returns the references that could not be found among the files.
93
129
  */
94
130
  async function inlineExternalFiles(json, effectPath, index) {
95
131
  const missing = [];
96
- for (const image of Array.isArray(json.images) ? json.images : []) {
97
- const url = image?.url;
98
- if (typeof url !== 'string' || isExternalUrl(url))
99
- continue;
100
- const file = index.find(effectPath, url);
101
- if (file) {
102
- image.url = await index.read(file, readAsDataUrl);
103
- }
104
- else {
105
- missing.push(url);
106
- }
107
- }
108
- await QuarksLoader.resolveExternalGeometries(json, async (url) => {
109
- const file = index.find(effectPath, url);
110
- if (!file) {
111
- missing.push(url);
112
- throw new Error('not among the imported files');
113
- }
114
- return index.read(file, async (f) => JSON.parse(await f.text()));
132
+ // baseUrl is the effect file path so resolveRelativePath can walk from its folder; the
133
+ // File resolver ignores HTTP join and looks up files by path/name instead.
134
+ await QuarksLoader.resolveLinkedResources(json, {
135
+ baseUrl: effectPath,
136
+ resolver: createFileResolver(effectPath, index, missing),
115
137
  });
116
138
  return missing;
117
139
  }
@@ -42,7 +42,7 @@ export function EditorWorkspace(props) {
42
42
  }, children: "Opening project\u2026" }));
43
43
  }
44
44
  if (!project) {
45
- return (_jsx(ProjectsPage, { store: store, title: hostProps.title, onOpen: (meta) => {
45
+ return (_jsx(ProjectsPage, { store: store, title: hostProps.title, backLink: hostProps.backLink, onOpen: (meta) => {
46
46
  setProject(meta);
47
47
  onProjectChange?.(meta.id);
48
48
  } }));
@@ -4,6 +4,11 @@ export interface ProjectsPageProps {
4
4
  store: ProjectStore;
5
5
  onOpen: (project: ProjectMeta) => void;
6
6
  title?: string;
7
+ /** Optional link back to the page that hosts the editor (e.g. a gallery), shown above the title. */
8
+ backLink?: {
9
+ href: string;
10
+ label: string;
11
+ };
7
12
  }
8
13
  /** Start page: the projects saved in the store, plus storage usage. */
9
14
  export declare function ProjectsPage(props: ProjectsPageProps): React.ReactElement;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { FolderIcon, PlusIcon } from '@heroicons/react/24/solid';
2
+ import { ArrowLeftIcon, FolderIcon, PlusIcon } from '@heroicons/react/24/solid';
3
3
  import { useCallback, useEffect, useState } from 'react';
4
4
  import { formatBytes } from '../core/projectStore';
5
5
  import { ChoiceDialog } from './ChoiceDialog';
@@ -31,7 +31,7 @@ function StorageBar(props) {
31
31
  }
32
32
  /** Start page: the projects saved in the store, plus storage usage. */
33
33
  export function ProjectsPage(props) {
34
- const { store, onOpen, title = 'Effect editor' } = props;
34
+ const { store, onOpen, title = 'Effect editor', backLink } = props;
35
35
  const [projects, setProjects] = useState(null);
36
36
  const [estimate, setEstimate] = useState(null);
37
37
  const [dialog, setDialog] = useState(null);
@@ -84,7 +84,20 @@ export function ProjectsPage(props) {
84
84
  display: 'flex',
85
85
  flexDirection: 'column',
86
86
  gap: 18,
87
- }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 12 }, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 22, fontWeight: 700 }, children: title }), _jsx("div", { style: { fontSize: 13, color: theme.textDim, marginTop: 4 }, children: "Projects keep their assets and scene between sessions." })] }), _jsx("span", { style: { flex: 1 } }), _jsxs("button", { className: "qe-hover", style: {
87
+ }, children: [backLink && (_jsxs("a", { className: "qe-hover", href: backLink.href, style: {
88
+ alignSelf: 'flex-start',
89
+ display: 'inline-flex',
90
+ alignItems: 'center',
91
+ gap: 6,
92
+ background: theme.panelBg,
93
+ border: `1px solid ${theme.border}`,
94
+ borderRadius: 8,
95
+ padding: '6px 12px',
96
+ fontSize: 13,
97
+ fontWeight: 500,
98
+ color: theme.text,
99
+ textDecoration: 'none',
100
+ }, children: [_jsx(ArrowLeftIcon, { style: iconStyle(14) }), backLink.label.replace(/^←\s*/, '')] })), _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 12 }, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 22, fontWeight: 700 }, children: title }), _jsx("div", { style: { fontSize: 13, color: theme.textDim, marginTop: 4 }, children: "Projects keep their assets and scene between sessions." })] }), _jsx("span", { style: { flex: 1 } }), _jsxs("button", { className: "qe-hover", style: {
88
101
  ...buttonStyle,
89
102
  display: 'inline-flex',
90
103
  alignItems: 'center',
@@ -3,7 +3,7 @@ import { ApplyCollision, ApplyForce, ChangeEmitDirection, ColorBySpeed, Constant
3
3
  import { buildGradient, DEFAULT_GRADIENT_STOPS, findBehavior, readGradientStops } from '../core/colors';
4
4
  import { buildCurve, buildScalar, readPieces, readScalar } from '../core/values';
5
5
  import { CurveEditor } from './CurveEditor';
6
- import { NumberField, Row, SelectField } from './fields';
6
+ import { CheckboxField, NumberField, Row, SelectField } from './fields';
7
7
  import { GradientEditor } from './GradientEditor';
8
8
  import { ModuleSection } from './ModuleSection';
9
9
  import { ValueField } from './ValueField';
@@ -93,7 +93,7 @@ export function OrbitOverLifeModule({ binding }) {
93
93
  return (_jsx(BehaviorModule, { binding: binding, title: "Orbit over Lifetime", type: "OrbitOverLife", create: () => new OrbitOverLife(new ConstantValue(Math.PI), new Vector3(0, 1, 0)), children: (behavior) => (_jsx(ValueField, { label: "Orbit speed", generator: behavior.orbitSpeed, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.orbitSpeed = g)) })) }));
94
94
  }
95
95
  export function WidthOverTrailModule({ binding }) {
96
- return (_jsx(BehaviorModule, { binding: binding, title: "Width over Trail", type: "WidthOverLength", create: () => new WidthOverLength(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [1, 0.75, 0.4, 0] })), children: (behavior) => (_jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(behavior.width), maxValue: 2, onChange: (pieces) => binding.apply(() => (behavior.width = buildCurve(pieces))) }) })) }));
96
+ return (_jsx(BehaviorModule, { binding: binding, title: "Width over Trail", type: "WidthOverLength", create: () => new WidthOverLength(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [1, 0.75, 0.4, 0] })), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(behavior.width), maxValue: 2, onChange: (pieces) => binding.apply(() => (behavior.width = buildCurve(pieces))) }) }), _jsx(Row, { label: "Absolute width", children: _jsx(CheckboxField, { value: behavior.absolute, onChange: (v) => binding.apply(() => (behavior.absolute = v)) }) })] })) }));
97
97
  }
98
98
  export function TextureSheetModule({ binding }) {
99
99
  const system = binding.system;
@@ -485,6 +485,24 @@ export function RendererModule({ binding, textureOptions, geometryOptions, resol
485
485
  }
486
486
  });
487
487
  } }), _jsx(MeshPreview, { positions: system.instancingGeometry, indices: system.getRendererSettings().instancingIndices }), preset === 'custom' && (_jsxs("span", { style: { fontSize: 10.5, color: '#9eb9ff' }, children: [vertexCount, " verts / ", triangleCount, " tris \u2014 picking a preset replaces this mesh"] }))] }) }));
488
+ })(), system.renderMode === RenderMode.Mesh && (_jsx(Row, { label: "Lighting", children: _jsx(SelectField, { value: system.lighting, options: [
489
+ { value: 'fixed', label: 'Fixed light' },
490
+ { value: 'scene', label: 'Scene light' },
491
+ { value: 'unlit', label: 'Unlit' },
492
+ ], onChange: (v) => binding.apply((s) => (s.lighting = v)) }) })), system.renderMode === RenderMode.Mesh && (_jsx(Row, { label: "Rim", children: _jsx(CheckboxField, { value: !!system.rim, onChange: (v) => binding.apply((s) => (s.rim = v ? {} : undefined)) }) })), system.renderMode === RenderMode.Mesh &&
493
+ system.rim &&
494
+ (() => {
495
+ const rim = system.rim;
496
+ const patchRim = (patch) => binding.apply((s) => (s.rim = { ...s.rim, ...patch }));
497
+ const setChannel = (channel, value) => {
498
+ const color = [...rim.color];
499
+ color[channel] = value;
500
+ patchRim({ color });
501
+ };
502
+ return (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Rim mode", children: _jsx(SelectField, { value: rim.mode, options: [
503
+ { value: 'add', label: 'Add (glow)' },
504
+ { value: 'mix', label: 'Mix (outline)' },
505
+ ], onChange: (mode) => patchRim({ mode }) }) }), _jsx(Row, { label: "Rim color", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: [0, 1, 2].map((channel) => (_jsx(NumberField, { value: rim.color[channel], min: 0, step: 0.05, onChange: (v) => setChannel(channel, v) }, channel))) }) }), _jsx(Row, { label: "Rim power", children: _jsx(NumberField, { value: rim.power, min: 0.01, step: 0.25, onChange: (power) => patchRim({ power }) }) }), _jsx(Row, { label: "Rim strength", children: _jsx(NumberField, { value: rim.strength, min: 0, step: 0.05, onChange: (strength) => patchRim({ strength }) }) })] }));
488
506
  })(), _jsx(Row, { label: "Render order", children: _jsx(NumberField, { value: system.renderOrder, step: 1, onChange: (v) => binding.apply((s) => (s.renderOrder = Math.round(v))) }) }), _jsx(Row, { label: "Soft particles", children: _jsx(CheckboxField, { value: system.softParticles, onChange: (v) => binding.apply((s) => (s.softParticles = v)) }) }), system.softParticles && (_jsx(Row, { label: "Fade near/far", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: system.softNearFade, min: 0, onChange: (v) => binding.apply((s) => (s.softNearFade = v)) }), _jsx(NumberField, { value: system.softFarFade, min: 0, onChange: (v) => binding.apply((s) => (s.softFarFade = v)) })] }) }))] }));
489
507
  }
490
508
  function removeBehavior(system, type) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks-editor",
3
- "version": "0.20.0",
3
+ "version": "0.23.0",
4
4
  "description": "Shuriken-style effect editor for babylon.quarks: headless edit-model core + React module-stack UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,3 +1,4 @@
1
+ import type {QuarksResourceKind, QuarksResourceResolver} from 'babylon.quarks';
1
2
  import {QuarksLoader} from 'babylon.quarks';
2
3
 
3
4
  /**
@@ -124,33 +125,55 @@ class ImportedFileIndex {
124
125
  }
125
126
 
126
127
  /**
127
- * Inlines what an "assets as files" export put in separate files: image urls become data URIs
128
- * and external geometries are read into the JSON, so the effect loads (and later exports) as a
128
+ * File-backed {@link QuarksResourceResolver}: relative image urls become data URIs and geometry
129
+ * urls load from the imported file set. Collects missing references into `missing`.
130
+ */
131
+ function createFileResolver(effectPath: string, index: ImportedFileIndex, missing: string[]): QuarksResourceResolver {
132
+ return {
133
+ async resolve(url: string, kind: QuarksResourceKind, baseUrl: string): Promise<string> {
134
+ if (kind === 'image') {
135
+ if (isExternalUrl(url)) {
136
+ return url;
137
+ }
138
+ const file = index.find(baseUrl || effectPath, url);
139
+ if (!file) {
140
+ missing.push(url);
141
+ return url;
142
+ }
143
+ return index.read(file, readAsDataUrl);
144
+ }
145
+ // Geometry (and other kinds): stable path key for fetchJson.
146
+ const file = index.find(baseUrl || effectPath, url);
147
+ if (!file) {
148
+ missing.push(url);
149
+ throw new Error('not among the imported files');
150
+ }
151
+ return relativePathOf(file);
152
+ },
153
+ fetchJson(resolvedUrl: string): Promise<unknown> {
154
+ // resolvedUrl is relativePathOf(file) from resolve(); look up by path (empty base).
155
+ const file = index.find('', resolvedUrl);
156
+ if (!file) {
157
+ throw new Error(`missing file "${resolvedUrl}"`);
158
+ }
159
+ return index.read(file, async (f) => JSON.parse(await f.text()));
160
+ },
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Inlines what an "assets as files" export put in separate files via
166
+ * {@link QuarksLoader.resolveLinkedResources}, so the effect loads (and later exports) as a
129
167
  * self-contained effect. Returns the references that could not be found among the files.
130
168
  */
131
169
  async function inlineExternalFiles(json: any, effectPath: string, index: ImportedFileIndex): Promise<string[]> {
132
170
  const missing: string[] = [];
133
-
134
- for (const image of Array.isArray(json.images) ? json.images : []) {
135
- const url = image?.url;
136
- if (typeof url !== 'string' || isExternalUrl(url)) continue;
137
- const file = index.find(effectPath, url);
138
- if (file) {
139
- image.url = await index.read(file, readAsDataUrl);
140
- } else {
141
- missing.push(url);
142
- }
143
- }
144
-
145
- await QuarksLoader.resolveExternalGeometries(json, async (url) => {
146
- const file = index.find(effectPath, url);
147
- if (!file) {
148
- missing.push(url);
149
- throw new Error('not among the imported files');
150
- }
151
- return index.read(file, async (f) => JSON.parse(await f.text()));
171
+ // baseUrl is the effect file path so resolveRelativePath can walk from its folder; the
172
+ // File resolver ignores HTTP join and looks up files by path/name instead.
173
+ await QuarksLoader.resolveLinkedResources(json, {
174
+ baseUrl: effectPath,
175
+ resolver: createFileResolver(effectPath, index, missing),
152
176
  });
153
-
154
177
  return missing;
155
178
  }
156
179
 
@@ -63,6 +63,7 @@ export function EditorWorkspace(props: EditorWorkspaceProps): React.ReactElement
63
63
  <ProjectsPage
64
64
  store={store}
65
65
  title={hostProps.title}
66
+ backLink={hostProps.backLink}
66
67
  onOpen={(meta) => {
67
68
  setProject(meta);
68
69
  onProjectChange?.(meta.id);
@@ -1,4 +1,4 @@
1
- import {FolderIcon, PlusIcon} from '@heroicons/react/24/solid';
1
+ import {ArrowLeftIcon, FolderIcon, PlusIcon} from '@heroicons/react/24/solid';
2
2
  import React, {useCallback, useEffect, useState} from 'react';
3
3
  import {formatBytes, type ProjectMeta, type ProjectStore, type StorageEstimate} from '../core/projectStore';
4
4
  import {ChoiceDialog} from './ChoiceDialog';
@@ -11,6 +11,8 @@ export interface ProjectsPageProps {
11
11
  store: ProjectStore;
12
12
  onOpen: (project: ProjectMeta) => void;
13
13
  title?: string;
14
+ /** Optional link back to the page that hosts the editor (e.g. a gallery), shown above the title. */
15
+ backLink?: {href: string; label: string};
14
16
  }
15
17
 
16
18
  type Dialog =
@@ -79,7 +81,7 @@ function StorageBar(props: {estimate: StorageEstimate | null; onPersist: () => v
79
81
 
80
82
  /** Start page: the projects saved in the store, plus storage usage. */
81
83
  export function ProjectsPage(props: ProjectsPageProps): React.ReactElement {
82
- const {store, onOpen, title = 'Effect editor'} = props;
84
+ const {store, onOpen, title = 'Effect editor', backLink} = props;
83
85
  const [projects, setProjects] = useState<ProjectMeta[] | null>(null);
84
86
  const [estimate, setEstimate] = useState<StorageEstimate | null>(null);
85
87
  const [dialog, setDialog] = useState<Dialog>(null);
@@ -145,6 +147,29 @@ export function ProjectsPage(props: ProjectsPageProps): React.ReactElement {
145
147
  gap: 18,
146
148
  }}
147
149
  >
150
+ {backLink && (
151
+ <a
152
+ className="qe-hover"
153
+ href={backLink.href}
154
+ style={{
155
+ alignSelf: 'flex-start',
156
+ display: 'inline-flex',
157
+ alignItems: 'center',
158
+ gap: 6,
159
+ background: theme.panelBg,
160
+ border: `1px solid ${theme.border}`,
161
+ borderRadius: 8,
162
+ padding: '6px 12px',
163
+ fontSize: 13,
164
+ fontWeight: 500,
165
+ color: theme.text,
166
+ textDecoration: 'none',
167
+ }}
168
+ >
169
+ <ArrowLeftIcon style={iconStyle(14)} />
170
+ {backLink.label.replace(/^←\s*/, '')}
171
+ </a>
172
+ )}
148
173
  <div style={{display: 'flex', alignItems: 'center', gap: 12}}>
149
174
  <div>
150
175
  <div style={{fontSize: 22, fontWeight: 700}}>{title}</div>
@@ -28,7 +28,7 @@ import {EffectBinding} from '../core/binding';
28
28
  import {buildGradient, DEFAULT_GRADIENT_STOPS, findBehavior, readGradientStops} from '../core/colors';
29
29
  import {buildCurve, buildScalar, readPieces, readScalar} from '../core/values';
30
30
  import {CurveEditor} from './CurveEditor';
31
- import {NumberField, Row, SelectField} from './fields';
31
+ import {CheckboxField, NumberField, Row, SelectField} from './fields';
32
32
  import {GradientEditor} from './GradientEditor';
33
33
  import {ModuleSection} from './ModuleSection';
34
34
  import {ValueField} from './ValueField';
@@ -614,13 +614,22 @@ export function WidthOverTrailModule({binding}: ModuleProps) {
614
614
  }
615
615
  >
616
616
  {(behavior) => (
617
- <div style={{marginTop: 6}}>
618
- <CurveEditor
619
- pieces={readPieces(behavior.width as never)}
620
- maxValue={2}
621
- onChange={(pieces) => binding.apply(() => (behavior.width = buildCurve(pieces) as never))}
622
- />
623
- </div>
617
+ <>
618
+ <div style={{marginTop: 6}}>
619
+ <CurveEditor
620
+ pieces={readPieces(behavior.width as never)}
621
+ maxValue={2}
622
+ onChange={(pieces) => binding.apply(() => (behavior.width = buildCurve(pieces) as never))}
623
+ />
624
+ </div>
625
+ {/* Off: the curve multiplies the particle size. On: it is the width in world units (files from 0.21 and earlier). */}
626
+ <Row label="Absolute width">
627
+ <CheckboxField
628
+ value={behavior.absolute}
629
+ onChange={(v) => binding.apply(() => (behavior.absolute = v))}
630
+ />
631
+ </Row>
632
+ </>
624
633
  )}
625
634
  </BehaviorModule>
626
635
  );
@@ -1,7 +1,7 @@
1
1
  import {Texture} from '@babylonjs/core/Materials/Textures/texture';
2
2
  import {Mesh} from '@babylonjs/core/Meshes/mesh';
3
3
  import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid';
4
- import type {Behavior, ParticleSystem} from 'babylon.quarks';
4
+ import type {Behavior, MeshLighting, MeshRim, ParticleSystem} from 'babylon.quarks';
5
5
  import {
6
6
  ColorOverLife,
7
7
  ConstantColor,
@@ -1122,6 +1122,81 @@ export function RendererModule({
1122
1122
  </Row>
1123
1123
  );
1124
1124
  })()}
1125
+ {system.renderMode === RenderMode.Mesh && (
1126
+ <Row label="Lighting">
1127
+ <SelectField<MeshLighting>
1128
+ value={system.lighting}
1129
+ options={[
1130
+ {value: 'fixed', label: 'Fixed light'},
1131
+ {value: 'scene', label: 'Scene light'},
1132
+ {value: 'unlit', label: 'Unlit'},
1133
+ ]}
1134
+ onChange={(v) => binding.apply((s) => (s.lighting = v))}
1135
+ />
1136
+ </Row>
1137
+ )}
1138
+ {system.renderMode === RenderMode.Mesh && (
1139
+ <Row label="Rim">
1140
+ <CheckboxField
1141
+ value={!!system.rim}
1142
+ onChange={(v) => binding.apply((s) => (s.rim = v ? {} : undefined))}
1143
+ />
1144
+ </Row>
1145
+ )}
1146
+ {system.renderMode === RenderMode.Mesh &&
1147
+ system.rim &&
1148
+ (() => {
1149
+ const rim = system.rim;
1150
+ const patchRim = (patch: Partial<MeshRim>) => binding.apply((s) => (s.rim = {...s.rim, ...patch}));
1151
+ const setChannel = (channel: number, value: number) => {
1152
+ const color: [number, number, number] = [...rim.color];
1153
+ color[channel] = value;
1154
+ patchRim({color});
1155
+ };
1156
+ return (
1157
+ <>
1158
+ <Row label="Rim mode">
1159
+ <SelectField<MeshRim['mode']>
1160
+ value={rim.mode}
1161
+ options={[
1162
+ {value: 'add', label: 'Add (glow)'},
1163
+ {value: 'mix', label: 'Mix (outline)'},
1164
+ ]}
1165
+ onChange={(mode) => patchRim({mode})}
1166
+ />
1167
+ </Row>
1168
+ <Row label="Rim color">
1169
+ <div style={{display: 'flex', gap: 6}}>
1170
+ {[0, 1, 2].map((channel) => (
1171
+ <NumberField
1172
+ key={channel}
1173
+ value={rim.color[channel]}
1174
+ min={0}
1175
+ step={0.05}
1176
+ onChange={(v) => setChannel(channel, v)}
1177
+ />
1178
+ ))}
1179
+ </div>
1180
+ </Row>
1181
+ <Row label="Rim power">
1182
+ <NumberField
1183
+ value={rim.power}
1184
+ min={0.01}
1185
+ step={0.25}
1186
+ onChange={(power) => patchRim({power})}
1187
+ />
1188
+ </Row>
1189
+ <Row label="Rim strength">
1190
+ <NumberField
1191
+ value={rim.strength}
1192
+ min={0}
1193
+ step={0.05}
1194
+ onChange={(strength) => patchRim({strength})}
1195
+ />
1196
+ </Row>
1197
+ </>
1198
+ );
1199
+ })()}
1125
1200
  <Row label="Render order">
1126
1201
  <NumberField
1127
1202
  value={system.renderOrder}