@jgengine/editor 0.19.0 → 0.22.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 +364 -0
- package/dist/ClipPreviewLayer.d.ts +2 -3
- package/dist/ClipPreviewLayer.js +10 -28
- package/dist/CreatorApp.d.ts +12 -0
- package/dist/CreatorApp.js +107 -0
- package/dist/DebugDraw.js +3 -1
- package/dist/EditorApp.d.ts +14 -2
- package/dist/EditorApp.js +29 -14
- package/dist/EditorChrome.d.ts +9 -1
- package/dist/EditorChrome.js +54 -35
- package/dist/InspectorPanel.d.ts +3 -1
- package/dist/InspectorPanel.js +12 -8
- package/dist/MaterialAssetEditor.d.ts +13 -0
- package/dist/MaterialAssetEditor.js +109 -0
- package/dist/MaterialPreview.d.ts +16 -0
- package/dist/MaterialPreview.js +41 -0
- package/dist/MaterialsWorkspacePanel.d.ts +14 -2
- package/dist/MaterialsWorkspacePanel.js +78 -4
- package/dist/RuntimePlayBridge.js +1 -6
- package/dist/SelectionGizmo.js +23 -14
- package/dist/SimulationInspector.d.ts +9 -0
- package/dist/SimulationInspector.js +47 -0
- package/dist/TerrainSculpt.js +12 -59
- package/dist/agent/toolBridge.js +2 -0
- package/dist/authoringPlacement.d.ts +21 -0
- package/dist/authoringPlacement.js +34 -0
- package/dist/handlers/context.d.ts +1 -0
- package/dist/handlers/document.d.ts +1 -1
- package/dist/handlers/document.js +29 -1
- package/dist/handlers/hierarchy.d.ts +1 -1
- package/dist/handlers/hierarchy.js +4 -0
- package/dist/handlers/runtime.js +20 -17
- package/dist/handlers/terrain.js +52 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/materialControls.d.ts +32 -0
- package/dist/materialControls.js +79 -0
- package/dist/mcp/fieldSpec.d.ts +1 -1
- package/dist/mcp/fieldSpec.js +11 -2
- package/dist/mcp/importOptionalGameModule.d.ts +2 -0
- package/dist/mcp/importOptionalGameModule.js +13 -0
- package/dist/mcp/loadGameCatalogs.js +5 -12
- package/dist/mcp/rpcRequest.d.ts +3 -3
- package/dist/mcp/rpcRequest.js +10 -4
- package/dist/mcp/tools.js +10 -1
- package/dist/modelAnimationAuthoring.d.ts +27 -7
- package/dist/modelAnimationAuthoring.js +69 -72
- package/dist/session.d.ts +62 -0
- package/dist/session.js +56 -7
- package/dist/shell/AnimationGraphPanel.js +18 -10
- package/dist/shell/PlayModeBar.d.ts +2 -1
- package/dist/shell/PlayModeBar.js +2 -2
- package/dist/shell/SceneToolbar.d.ts +2 -1
- package/dist/shell/SceneToolbar.js +5 -4
- package/dist/shell/TopAppBar.d.ts +2 -1
- package/dist/shell/TopAppBar.js +2 -2
- package/dist/shell/animGraphPreview.d.ts +2 -0
- package/dist/shell/animGraphPreview.js +5 -2
- package/dist/shell/clipPreview.d.ts +1 -0
- package/dist/uiStore.d.ts +1 -10
- package/dist/uiStore.js +1 -13
- package/dist/useDocumentSave.d.ts +20 -0
- package/dist/useDocumentSave.js +33 -0
- package/package.json +6 -5
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +23 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-editor/SKILL.md +9 -1
- package/skills/jgengine-editor/api.md +111 -16
- package/skills/jgengine-editor/capabilities.md +40 -0
- package/skills/jgengine-editor/reference.md +149 -1
- package/skills/jgengine-editor/references/live-sync.md +15 -0
package/dist/InspectorPanel.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { SimulationInspector } from "./SimulationInspector.js";
|
|
2
3
|
import { useState } from "react";
|
|
3
4
|
import { editorParentOf, findEditorNote, findEditorPath, isEditorObjectLocked, } from "@jgengine/core/editor/index";
|
|
4
5
|
import { readVegetationSettings, vegetationFootprint } from "@jgengine/core/world/vegetation";
|
|
@@ -11,7 +12,7 @@ import { canAuthorTrigger, clearMaterialAssignmentPatch, clearTriggerInstallPatc
|
|
|
11
12
|
import { SchemaInspector } from "./SchemaInspector.js";
|
|
12
13
|
import { TriggerInspector } from "./TriggerInspector.js";
|
|
13
14
|
import { describeClipRoles } from "./AssetBrowser.js";
|
|
14
|
-
import { animationMode, animationMetaPatch, LOCOMOTION_ROLES, ONE_SHOT_EVENTS,
|
|
15
|
+
import { animationMode, animationMetaPatch, LOCOMOTION_ROLES, ONE_SHOT_EVENTS, readAnimationSettingResult, setAnimationMode, setLocomotionClip, setLocomotionNumber, setOneShotClip, setPlaybackBoolean, setPlaybackClip, setPlaybackNumber, setPlaybackClock, setAutomaticRoles, } from "./modelAnimationAuthoring.js";
|
|
15
16
|
import { listParentCandidates } from "./parentCandidates.js";
|
|
16
17
|
import { TERRAIN_MATERIALS } from "./uiStore.js";
|
|
17
18
|
import { shallowArrayEqual, useStoreSelector } from "./useStoreSelector.js";
|
|
@@ -127,23 +128,25 @@ function ComponentAddBar({ target, meta, onMeta, api, selectionIds, }) {
|
|
|
127
128
|
}
|
|
128
129
|
/** A labeled clip dropdown (never free-text) for a role/event; "— none —" clears the binding. */
|
|
129
130
|
function ClipSelectRow({ label, clips, value, onChange, }) {
|
|
130
|
-
return (_jsx(FieldRow, { label: label, children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: value ?? "", "aria-label": `${label} clip`, onChange: (event) => onChange(event.target.value === "" ? null : event.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), clips.map((clip) => (_jsx("option", { value: clip, children: clip }, clip)))] }) }));
|
|
131
|
+
return (_jsx(FieldRow, { label: label, children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: value ?? "", "aria-label": `${label} clip`, onChange: (event) => onChange(event.target.value === "" ? null : event.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), value !== null && !clips.includes(value) ? _jsxs("option", { value: value, children: [value, " (missing)"] }) : null, clips.map((clip) => (_jsx("option", { value: clip, children: clip }, clip)))] }) }));
|
|
131
132
|
}
|
|
132
133
|
const ANIMATION_MODE_HINT = {
|
|
133
134
|
default: "Inherits the catalog default: a rigged asset auto-animates from its clip roles.",
|
|
134
135
|
auto: "Forces derivation of idle/walk/run + hit/death/attack from this rig's clip names.",
|
|
135
136
|
none: "Renders the bind pose — no clip playback.",
|
|
136
|
-
custom: "Author explicit role
|
|
137
|
+
custom: "Author explicit playback or role bindings below; states and graphs take priority over a single clip.",
|
|
137
138
|
};
|
|
138
139
|
/**
|
|
139
140
|
* Inspector section authoring a placement's `ModelConfig.animation` for a rigged catalog asset.
|
|
140
141
|
* Persists to `marker.meta.animation` through the passed `onChange` (an undoable `setMarker` dispatch).
|
|
141
142
|
* All clip picks are dropdowns over the asset's real clip list — never free-text.
|
|
142
143
|
*/
|
|
143
|
-
function ModelAnimationSection({ clips, setting, onChange, }) {
|
|
144
|
+
function ModelAnimationSection({ clips, setting, diagnostics, onChange, }) {
|
|
144
145
|
const mode = animationMode(setting);
|
|
145
146
|
const config = mode === "custom" && typeof setting === "object" ? setting : null;
|
|
146
|
-
|
|
147
|
+
if (diagnostics.length > 0)
|
|
148
|
+
return (_jsxs("div", { className: "space-y-2 text-[10px]", children: [_jsxs("div", { role: "alert", "aria-label": "Animation diagnostics", className: "space-y-1 text-amber-200", children: [_jsx("p", { children: "The saved animation override is invalid. Playback edits are unavailable until it is repaired or replaced." }), diagnostics.map((entry) => _jsxs("div", { children: [_jsxs("div", { className: "break-all", children: [entry.path, ": ", entry.message] }), _jsx("div", { children: entry.repair })] }, `${entry.path}:${entry.message}`))] }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { children: "Replace the invalid animation override" }), _jsxs("select", { className: `h-6.5 w-full px-1.5 ${INPUT_CLS}`, value: "", "aria-label": "Replace invalid animation override", onChange: (event) => onChange(setAnimationMode(undefined, event.target.value, clips)), children: [_jsx("option", { value: "", disabled: true, children: "Choose an explicit replacement" }), _jsx("option", { value: "default", children: "Remove override (inherit catalog)" }), _jsx("option", { value: "auto", children: "Replace with Auto" }), _jsx("option", { value: "none", children: "Replace with None (bind pose)" }), _jsx("option", { value: "custom", disabled: clips.length === 0, children: "Replace with Custom (derive from this rig)" })] })] }), _jsx("p", { className: "text-neutral-500", children: "Replacement discards the invalid override and can be undone. Raw metadata remains available below." })] }));
|
|
149
|
+
return (_jsxs("div", { className: "space-y-2.5", children: [_jsxs("div", { className: "text-[10px] text-neutral-500", children: [clips.length, " clips \u00B7 ", describeClipRoles(clips)] }), _jsx(FieldRow, { label: "Mode", children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: mode, "aria-label": "Animation mode", onChange: (event) => onChange(setAnimationMode(setting, event.target.value, clips)), children: [_jsx("option", { value: "default", children: "Default (catalog)" }), _jsx("option", { value: "auto", children: "Auto (derive)" }), _jsx("option", { value: "none", children: "None (bind pose)" }), _jsx("option", { value: "custom", children: "Custom" })] }) }), config === null ? (_jsx("div", { className: "text-[10px] leading-relaxed text-neutral-600", children: ANIMATION_MODE_HINT[mode] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "Playback" }), _jsx(FieldRow, { label: "Clock", title: "Real time preserves playback speed; game time follows this character's simulation speed and time scale. Unbound previews use real time.", children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, "aria-label": "Animation clock", value: config.clock ?? "real", onChange: (event) => onChange(setPlaybackClock(setting, event.target.value)), children: [_jsx("option", { value: "real", children: "Real time (default)" }), _jsx("option", { value: "game", children: "Game time" })] }) }), _jsx(FieldRow, { label: "Derive roles", title: "Infer roles from the loaded rig. Explicit mappings and graphs replace inferred fields whole.", children: _jsx("input", { type: "checkbox", "aria-label": "Derive loaded clip roles", checked: config.auto === true, onChange: (event) => onChange(setAutomaticRoles(setting, event.target.checked)) }) }), _jsx(ClipSelectRow, { label: "Single clip", clips: clips, value: config.clip ?? null, onChange: (clip) => onChange(setPlaybackClip(setting, clip)) }), _jsx("div", { className: "text-[10px] text-neutral-600", children: "Picking a single clip replaces derived roles, locomotion states and a stored graph." }), _jsx(FieldRow, { label: "Rate", title: "Playback multiplier; zero holds playback.", children: _jsx(AxisNumberField, { label: "rate", step: 0.1, value: config.timeScale ?? 1, onCommit: (value) => onChange(setPlaybackNumber(setting, "timeScale", value)) }) }), _jsx(FieldRow, { label: "Hold pose", title: "Stops playback at the current pose. Single clips also support an authored seek time.", children: _jsx("input", { type: "checkbox", "aria-label": "Hold animation pose", checked: config.paused === true, onChange: (event) => onChange(setPlaybackBoolean(setting, "paused", event.target.checked)) }) }), config.states === undefined && config.graph === undefined ? (_jsxs(_Fragment, { children: [_jsx(FieldRow, { label: "Time", title: "Start or hold the selected clip at this time in seconds.", children: _jsx(AxisNumberField, { label: "time", step: 0.05, value: config.time ?? 0, onCommit: (value) => onChange(setPlaybackNumber(setting, "time", value)) }) }), _jsx(FieldRow, { label: "Loop", children: _jsx("input", { type: "checkbox", "aria-label": "Loop animation clip", checked: config.loop !== false, onChange: (event) => onChange(setPlaybackBoolean(setting, "loop", event.target.checked)) }) })] })) : null] }), _jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "Locomotion states" }), config.graph === undefined && config.states !== undefined && (config.states.idle === undefined || config.states.idle.trim().length === 0) ? (_jsx("div", { role: "status", className: "text-[10px] text-amber-300", children: "Choose an idle clip to enable locomotion. An explicitly selected single clip remains active until then." })) : null, config.graph === undefined && config.states?.idle !== undefined && config.states.idle.trim().length > 0 && (config.states.walk === undefined || config.states.walk.trim().length === 0) ? (_jsx("div", { role: "status", className: "text-[10px] text-amber-300", children: "Walk is unset: idle continues while moving. Pick a walk clip to animate movement." })) : null, LOCOMOTION_ROLES.map((role) => (_jsx(ClipSelectRow, { label: role, clips: clips, value: config.states?.[role] ?? null, onChange: (clip) => onChange(setLocomotionClip(setting, role, clip, clips)) }, role))), _jsx(FieldRow, { label: "walkSpeed", title: "Speed (u/s) above which the entity counts as moving (walk).", children: _jsx(AxisNumberField, { label: "u/s", step: 0.1, value: config.states?.walkSpeed ?? 0.5, onCommit: (value) => onChange(setLocomotionNumber(setting, "walkSpeed", Math.max(0, value), clips)) }) }), _jsx(FieldRow, { label: "runSpeed", title: "Speed above which the run clip plays (when a run clip is set).", children: _jsx(AxisNumberField, { label: "u/s", step: 0.5, value: config.states?.runSpeed ?? 6, onCommit: (value) => onChange(setLocomotionNumber(setting, "runSpeed", Math.max(0, value), clips)) }) }), _jsx(FieldRow, { label: "fadeSec", title: "Crossfade duration (s) when the locomotion state changes.", children: _jsx(AxisNumberField, { label: "s", step: 0.05, value: config.states?.fadeSec ?? 0.2, onCommit: (value) => onChange(setLocomotionNumber(setting, "fadeSec", Math.max(0, value), clips)) }) })] }), _jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "One-shot events" }), _jsx("div", { className: "text-[10px] text-neutral-600", children: "hit / death auto-fire on damage/death; others via playEntityAnimation." }), ONE_SHOT_EVENTS.map((event) => (_jsx(FieldRow, { label: event, title: "Choose one clip or Ctrl/Cmd-click multiple variants. Clear selection to remove the binding.", children: _jsxs("select", { multiple: true, size: 3, className: `w-full min-w-0 px-1.5 ${INPUT_CLS}`, "aria-label": `${event} clip variants`, value: typeof config.oneShots?.[event] === "string" ? [config.oneShots[event]] : [...(config.oneShots?.[event] ?? [])], onChange: (change) => onChange(setOneShotClip(setting, event, Array.from(change.target.selectedOptions, (option) => option.value))), children: [clips.map((clip) => _jsx("option", { value: clip, children: clip }, clip)), (typeof config.oneShots?.[event] === "string" ? [config.oneShots[event]] : [...(config.oneShots?.[event] ?? [])]).filter((clip) => !clips.includes(clip)).map((clip) => _jsxs("option", { value: clip, children: [clip, " (missing)"] }, clip))] }) }, event)))] })] }))] }));
|
|
147
150
|
}
|
|
148
151
|
function Section({ id, title, icon, sections, children, trailing, }) {
|
|
149
152
|
return (_jsx(CollapsibleSection, { title: title, ...(icon === undefined ? {} : { icon }), open: sections.collapsed[id] !== true, onToggle: () => sections.toggle(id), ...(trailing === undefined ? {} : { trailing }), children: children }));
|
|
@@ -165,7 +168,7 @@ function MaterialsTab({ api, selection, meta, }) {
|
|
|
165
168
|
* `pathPoint` slice, so UI-only churn never rerenders it on `EditorChrome`'s render tick.
|
|
166
169
|
* @internal — mounted by `EditorChrome` inside the right dock.
|
|
167
170
|
*/
|
|
168
|
-
export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, }) {
|
|
171
|
+
export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, creatorPolicy, }) {
|
|
169
172
|
const document = useStoreSelector(session, (state) => state.document);
|
|
170
173
|
const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
|
|
171
174
|
const pathPoint = useStoreSelector(ui, (state) => state.pathPoint);
|
|
@@ -240,12 +243,13 @@ export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelec
|
|
|
240
243
|
const markerCatalog = markerCatalogId(marker);
|
|
241
244
|
const markerRigAsset = markerCatalog === null ? undefined : assets?.find((entry) => entry.id === markerCatalog);
|
|
242
245
|
const markerRigClips = markerRigAsset?.clips !== undefined && markerRigAsset.clips.length > 0 ? markerRigAsset.clips : null;
|
|
246
|
+
const animation = readAnimationSettingResult(document, marker.id);
|
|
243
247
|
const hasTrigger = hasAuthoredTrigger(marker.meta);
|
|
244
248
|
const hasMaterial = hasMaterialAssignment(marker.meta);
|
|
245
249
|
const hasGenerator = typeof marker.meta?.["assetId"] === "string" && getAssetGenerator(marker.meta["assetId"]) !== undefined;
|
|
246
250
|
const hasKindParams = isSceneKind(marker.kind);
|
|
247
251
|
const componentCards = (_jsxs(_Fragment, { children: [_jsx(ComponentAddBar, { target: "marker", meta: marker.meta, onMeta: onMeta, api: api, selectionIds: [marker.id] }), hasKindParams ? (_jsx(Section, { id: "kindParams", title: "Kind parameters", icon: "settings", sections: sections, children: _jsx(KindInspector, { object: markerObject(marker), meta: marker.meta, onMeta: onMeta }) })) : null, hasTrigger ? (_jsx(Section, { id: "trigger", title: "Trigger", icon: "target", sections: sections, trailing: _jsx(SectionAction, { label: "Remove trigger component", onClick: () => onMeta(clearTriggerInstallPatch(), "component:remove-trigger"), children: "remove" }), children: _jsx(TriggerInspector, { target: "marker", meta: marker.meta, onMeta: onMeta }) })) : null, hasMaterial ? (_jsx(Section, { id: "material", title: "Material", icon: "sphere", sections: sections, trailing: _jsx(SectionAction, { label: "Remove material assignment", onClick: () => onMeta(clearMaterialAssignmentPatch(), "component:remove-material"), children: "remove" }), children: _jsxs("div", { className: "text-[11px] text-neutral-300", children: ["materialId: ", _jsx("span", { className: NUMERIC, children: String(marker.meta?.["materialId"]) })] }) })) : null, hasGenerator ? (_jsx(Section, { id: "generator", title: "Generator", icon: "cube", sections: sections, children: _jsx(GeneratorInspector, { meta: marker.meta, onMeta: onMeta }) })) : null, !hasKindParams && !hasTrigger && !hasMaterial && !hasGenerator && !canAuthorTrigger("marker") ? (_jsx(EmptyState, { icon: "settings", title: "No components", description: "This marker has no optional components. Games that register trigger actions enable Add trigger here." })) : null] }));
|
|
248
|
-
body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: marker.kind, id: marker.id, label: marker.label ?? "", placeholder: marker.id, locked: isEditorObjectLocked(document, marker.id), onLabel: (value) => session.dispatch({ type: "setMarker", id: marker.id, patch: { label: value } }, { coalesce: `label:${marker.id}` }) }), activeTab === "inspector" ? (_jsxs(_Fragment, { children: [_jsx(Section, { id: "transform", title: "Transform", icon: "move", sections: sections, trailing: _jsx(SectionAction, { label: "Reset position to origin", onClick: () => session.dispatch({ type: "setTransform", id: marker.id, position: { x: 0, y: 0, z: 0 } }), children: "reset" }), children: _jsxs("div", { className: "space-y-1.5", children: [_jsx(FieldRow, { label: "Position", children: ["x", "y", "z"].map((axis) => (_jsx(AxisNumberField, { axis: axis, label: axis, step: 0.5, value: marker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, position: { ...marker.position, [axis]: value } }, { coalesce: `pos:${axis}:${marker.id}` }) }, axis))) }), _jsx(FieldRow, { label: "Rotation", children: _jsx(AxisNumberField, { axis: "y", label: "y\u00B0", step: 5, precision: 1, value: Math.round((((marker.rotationY ?? 0) * 180) / Math.PI) * 10) / 10, onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${marker.id}` }) }) })] }) }), _jsx(Section, { id: "appearance", title: "Appearance", icon: "sphere", sections: sections, children: _jsx(KindColorFields, { kind: marker.kind, color: marker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: marker.id, patch: { kind } }, { coalesce: `kind:${marker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: marker.id, patch: { color } }, { coalesce: `color:${marker.id}` }) }) }), _jsx(Section, { id: "placement", title: "Placement", icon: "pin", sections: sections, children: _jsxs("div", { className: "space-y-2", children: [_jsx(ClearanceField, { meta: marker.meta, onMeta: onMeta }), _jsx(ParentField, { session: session, id: marker.id })] }) }), markerRigClips !== null ? (_jsx(Section, { id: "modelAnimation", title: "Animation", icon: "film", sections: sections, children: _jsx(ModelAnimationSection, { clips: markerRigClips, setting:
|
|
252
|
+
body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: marker.kind, id: marker.id, label: marker.label ?? "", placeholder: marker.id, locked: isEditorObjectLocked(document, marker.id), onLabel: (value) => session.dispatch({ type: "setMarker", id: marker.id, patch: { label: value } }, { coalesce: `label:${marker.id}` }) }), activeTab === "inspector" ? (_jsxs(_Fragment, { children: [_jsx(Section, { id: "transform", title: "Transform", icon: "move", sections: sections, trailing: _jsx(SectionAction, { label: "Reset position to origin", onClick: () => session.dispatch({ type: "setTransform", id: marker.id, position: { x: 0, y: 0, z: 0 } }), children: "reset" }), children: _jsxs("div", { className: "space-y-1.5", children: [_jsx(FieldRow, { label: "Position", children: ["x", "y", "z"].map((axis) => (_jsx(AxisNumberField, { axis: axis, label: axis, step: 0.5, value: marker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, position: { ...marker.position, [axis]: value } }, { coalesce: `pos:${axis}:${marker.id}` }) }, axis))) }), _jsx(FieldRow, { label: "Rotation", children: _jsx(AxisNumberField, { axis: "y", label: "y\u00B0", step: 5, precision: 1, value: Math.round((((marker.rotationY ?? 0) * 180) / Math.PI) * 10) / 10, onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${marker.id}` }) }) })] }) }), _jsx(Section, { id: "appearance", title: "Appearance", icon: "sphere", sections: sections, children: _jsx(KindColorFields, { kind: marker.kind, color: marker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: marker.id, patch: { kind } }, { coalesce: `kind:${marker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: marker.id, patch: { color } }, { coalesce: `color:${marker.id}` }) }) }), _jsx(Section, { id: "placement", title: "Placement", icon: "pin", sections: sections, children: _jsxs("div", { className: "space-y-2", children: [_jsx(ClearanceField, { meta: marker.meta, onMeta: onMeta }), _jsx(ParentField, { session: session, id: marker.id })] }) }), markerRigClips !== null || typeof animation.setting === "object" || animation.diagnostics.length > 0 ? (_jsx(Section, { id: "modelAnimation", title: "Animation", icon: "film", sections: sections, children: _jsx(ModelAnimationSection, { clips: markerRigClips ?? [], setting: animation.setting, diagnostics: animation.diagnostics, onChange: (next) => onMeta(animationMetaPatch(next), "animation") }) })) : null, marker.meta !== undefined ? (_jsx(Section, { id: "meta", title: "Raw metadata", icon: "script", sections: sections, children: _jsx("pre", { className: "max-h-48 overflow-auto rounded-[5px] border border-white/[0.06] bg-black/40 p-2 text-[10px] text-neutral-400", children: JSON.stringify(marker.meta, null, 2) }) })) : null] })) : (componentCards)] }));
|
|
249
253
|
}
|
|
250
254
|
else if (selectedVolume !== undefined) {
|
|
251
255
|
const volume = selectedVolume;
|
|
@@ -288,7 +292,7 @@ export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelec
|
|
|
288
292
|
body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: "prop", id: liveObject.instanceId, label: liveObject.catalogId, placeholder: liveObject.instanceId, locked: false }), _jsx(Section, { id: "live", title: "Live object", icon: "cube", sections: sections, children: _jsxs("div", { className: `space-y-1 text-[11px] text-neutral-400 ${NUMERIC}`, children: [_jsxs("div", { children: ["x ", liveObject.position[0].toFixed(1), " \u00B7 y ", liveObject.position[1].toFixed(1), " \u00B7 z ", liveObject.position[2].toFixed(1)] }), _jsx("div", { className: "text-[10px] text-neutral-600", children: "Read-only live world object \u2014 edit its source data to move it permanently." })] }) })] }));
|
|
289
293
|
}
|
|
290
294
|
else {
|
|
291
|
-
body = (_jsx(EmptyState, { icon: "cursor", title: "Nothing selected", description: "
|
|
295
|
+
body = (creatorPolicy !== undefined && !(creatorPolicy.maxSimulationParticles || creatorPolicy.maxFireCells || creatorPolicy.maxPopulation || creatorPolicy.maxForceFields) ? _jsx(EmptyState, { icon: "cursor", title: "Nothing selected", description: "Select an object to edit it, or use Add to place approved content." }) : _jsx(SimulationInspector, { session: session, creatorPolicy: creatorPolicy }));
|
|
292
296
|
}
|
|
293
297
|
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [_jsx(PanelTabs, { ariaLabel: "Inspector tabs", active: activeTab, onSelect: selectTab, tabs: [
|
|
294
298
|
{ id: "inspector", label: "Inspector" },
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type MaterialAsset } from "@jgengine/core/material/materialAsset";
|
|
3
|
+
/** A callback-driven physical material editor; the host owns persistence, undo and preview rendering. */
|
|
4
|
+
export interface MaterialAssetEditorProps {
|
|
5
|
+
asset: MaterialAsset;
|
|
6
|
+
onChange(asset: MaterialAsset, coalesce?: string): void;
|
|
7
|
+
preview?: (asset: MaterialAsset, mode: "neutral" | "game") => ReactNode;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Edits sparse reusable material assets without replacing untouched imported material values.
|
|
11
|
+
* @internal Mounted by the material workspace.
|
|
12
|
+
*/
|
|
13
|
+
export declare function MaterialAssetEditor({ asset, onChange, preview }: MaterialAssetEditorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { MATERIAL_TEXTURE_SEMANTICS, validateMaterialAsset, } from "@jgengine/core/material/materialAsset";
|
|
4
|
+
import { advancedMaterialControls, materialAuthoringNotes, materialControlGroups, prepareMaterialAuthoringEdit } from "./materialControls.js";
|
|
5
|
+
import { CONTROL, FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
|
|
6
|
+
/**
|
|
7
|
+
* Edits sparse reusable material assets without replacing untouched imported material values.
|
|
8
|
+
* @internal Mounted by the material workspace.
|
|
9
|
+
*/
|
|
10
|
+
export function MaterialAssetEditor({ asset, onChange, preview }) {
|
|
11
|
+
const [previewMode, setPreviewMode] = useState("neutral");
|
|
12
|
+
const [mapRole, setMapRole] = useState("color");
|
|
13
|
+
const [mapUrl, setMapUrl] = useState("");
|
|
14
|
+
const diagnostics = validateMaterialAsset(asset);
|
|
15
|
+
const commit = (next, coalesce) => onChange(prepareMaterialAuthoringEdit(next), coalesce);
|
|
16
|
+
const patchSurface = (patch, field) => {
|
|
17
|
+
const surface = { ...asset.surface, ...patch };
|
|
18
|
+
for (const key of Object.keys(surface)) {
|
|
19
|
+
if (surface[key] === undefined)
|
|
20
|
+
delete surface[key];
|
|
21
|
+
}
|
|
22
|
+
commit({ ...asset, surface }, `material:${asset.id}:${field}`);
|
|
23
|
+
};
|
|
24
|
+
const patchTexture = (role, texture) => {
|
|
25
|
+
const textures = { ...asset.textures };
|
|
26
|
+
if (texture === undefined)
|
|
27
|
+
delete textures[role];
|
|
28
|
+
else
|
|
29
|
+
textures[role] = texture;
|
|
30
|
+
commit({ ...asset, textures }, `material:${asset.id}:texture:${role}`);
|
|
31
|
+
};
|
|
32
|
+
const renderControl = (control) => (_jsx(SurfaceField, { control: control, value: asset.surface[control.key], onChange: (value) => patchSurface({ [control.key]: value }, control.key) }, control.key));
|
|
33
|
+
return (_jsxs("div", { className: "space-y-3 p-2 text-[11px] text-neutral-300", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Asset name" }), _jsx("input", { "aria-label": "Material asset name", className: `w-full px-2 py-1 ${INPUT_CLS}`, defaultValue: asset.name, onBlur: (event) => { const name = event.target.value.trim(); if (name && name !== asset.name)
|
|
34
|
+
commit({ ...asset, name }); } }, `${asset.id}:${asset.name}`)] }), _jsxs("div", { className: "break-all text-[10px] text-neutral-500", children: ["Stable ID: ", asset.id, " \u00B7 saved with the scene document"] }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Surface family" }), _jsx("select", { "aria-label": "Material family", className: `px-2 py-1 ${INPUT_CLS}`, value: asset.family, onChange: (event) => commit({ ...asset, family: event.target.value }), children: ["standard", "fabric", "hair", "glass", "metal", "skin", "stone", "plastic"].map((family) => _jsx("option", { value: family, children: family }, family)) })] }), _jsx("p", { className: "text-[10px] leading-relaxed text-neutral-500", children: "Empty fields inherit the imported surface. A displayed placeholder is the renderer default, which may differ from your asset. Reset removes only this override." }), preview ? _jsxs("section", { className: "space-y-2", "aria-label": "Material preview", children: [_jsx("div", { className: "flex gap-1", role: "group", "aria-label": "Preview lighting", children: ["neutral", "game"].map((mode) => _jsx("button", { type: "button", "aria-pressed": previewMode === mode, className: `${CONTROL} ${FOCUS_RING} flex-1 px-2 py-1`, onClick: () => setPreviewMode(mode), children: mode === "neutral" ? "Neutral light" : "Document light" }, mode)) }), preview(asset, previewMode)] }) : null, materialControlGroups(asset.family).map((group) => _jsxs("section", { className: "space-y-2 border-t border-white/[0.07] pt-2", children: [_jsx("div", { className: MICRO_LABEL, children: group.label }), _jsx("p", { className: "text-[10px] leading-relaxed text-neutral-500", children: group.hint }), group.controls.map(renderControl), group.label === "Coverage" ? _jsx(CoverageFields, { surface: asset.surface, onPatch: patchSurface }) : null, asset.family === "fabric" && group.label === "Detail" ? _jsx(FabricFields, { asset: asset, onChange: commit }) : null, asset.family === "hair" && group.label === "Strand highlights" ? _jsx(HairFields, { asset: asset, onChange: commit }) : null, asset.family === "hair" && group.label === "Backlighting" ? _jsx(HairBacklightFields, { asset: asset, onChange: commit }) : null] }, group.label)), _jsxs("details", { className: "border-t border-white/[0.07] pt-2", children: [_jsxs("summary", { className: `cursor-pointer py-1 ${FOCUS_RING}`, children: ["Texture maps (", Object.keys(asset.textures ?? {}).length, ")"] }), _jsxs("div", { className: "space-y-2 pt-2", children: [_jsx("p", { className: "text-[10px] text-neutral-500", children: "Use an existing runtime URL. Color and emissive use sRGB; numeric maps use linear data and the shader's packed channel." }), _jsxs("label", { className: "block", children: [_jsx("span", { className: MICRO_LABEL, children: "Map role" }), _jsx("select", { "aria-label": "New texture role", className: `mt-1 w-full px-2 py-1 ${INPUT_CLS}`, value: mapRole, onChange: (event) => setMapRole(event.target.value), children: Object.keys(MATERIAL_TEXTURE_SEMANTICS).map((role) => _jsx("option", { value: role, children: role }, role)) })] }), _jsx("input", { "aria-label": "New texture URL", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: mapUrl, onChange: (event) => setMapUrl(event.target.value), placeholder: "/textures/my-surface.png" }), _jsx("button", { type: "button", className: `${CONTROL} ${FOCUS_RING} px-2 py-1`, disabled: !mapUrl.trim(), onClick: () => { if (!mapUrl.trim())
|
|
35
|
+
return; patchTexture(mapRole, { url: mapUrl.trim(), ...MATERIAL_TEXTURE_SEMANTICS[mapRole] }); setMapUrl(""); }, children: "Assign map" }), Object.entries(asset.textures ?? {}).map(([role, texture]) => _jsx(TextureFields, { role: role, texture: texture, onChange: (next) => patchTexture(role, next) }, role))] })] }), _jsxs("details", { className: "border-t border-white/[0.07] pt-2", children: [_jsx("summary", { className: `cursor-pointer py-1 ${FOCUS_RING}`, children: "Advanced physical settings" }), _jsxs("div", { className: "space-y-2 pt-2", children: [advancedMaterialControls.map(renderControl), _jsx(PairField, { label: "Normal strength XY", value: asset.surface.normalScale, fallback: [1, 1], onChange: (normalScale) => patchSurface({ normalScale }, "normalScale") }), _jsx(PairField, { label: "Film thickness range (nm)", value: asset.surface.iridescenceThicknessRange, fallback: [100, 400], onChange: (iridescenceThicknessRange) => patchSurface({ iridescenceThicknessRange }, "iridescenceThicknessRange") }), _jsx(CoverageFields, { surface: asset.surface, onPatch: patchSurface }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: ["Declared capabilities: ", asset.capabilities.join(", ") || "none", ". Expensive lobes render only when enabled."] })] })] }), _jsxs("details", { className: "border-t border-white/[0.07] pt-2", children: [_jsx("summary", { className: `cursor-pointer py-1 ${FOCUS_RING}`, children: "Authorship and provenance" }), _jsx("div", { className: "space-y-2 pt-2", children: _jsx(ProvenanceFields, { label: "Material", provenance: asset.provenance, onChange: (provenance) => commit({ ...asset, provenance }) }, JSON.stringify(asset.provenance)) })] }), materialAuthoringNotes(asset).map((note) => _jsx("p", { className: "text-[10px] leading-relaxed text-amber-200/80", children: note }, note)), diagnostics.length ? _jsx("ul", { className: "space-y-1 text-[10px]", "aria-label": "Material diagnostics", children: diagnostics.map((entry) => _jsxs("li", { className: entry.severity === "error" ? "text-rose-300" : "text-amber-200/80", children: [entry.path, ": ", entry.message] }, `${entry.code}:${entry.path}`)) }) : null] }));
|
|
36
|
+
}
|
|
37
|
+
function SurfaceField({ control, value, onChange }) {
|
|
38
|
+
return _jsxs("label", { className: "block space-y-1", title: control.hint, children: [_jsxs("span", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: control.label }), value === undefined ? _jsx("span", { className: "text-[9px] text-neutral-600", children: "Inherited" }) : _jsx("button", { type: "button", className: `${FOCUS_RING} text-[9px] text-neutral-400 hover:text-neutral-100`, onClick: () => onChange(undefined), children: "Reset" })] }), control.kind === "color" ? _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { "aria-label": control.label, type: "color", value: typeof value === "string" ? value : String(control.fallback), onChange: (event) => onChange(event.target.value) }), _jsx("span", { className: "text-[10px] text-neutral-500", children: typeof value === "string" ? value : "Imported color" })] }) : _jsx("input", { "aria-label": control.label, type: "number", min: control.min, max: control.max, step: control.step ?? 0.01, value: typeof value === "number" ? value : "", placeholder: String(control.fallback), className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => { if (!event.target.value)
|
|
39
|
+
onChange(undefined);
|
|
40
|
+
else {
|
|
41
|
+
const next = Number(event.target.value);
|
|
42
|
+
if (Number.isFinite(next))
|
|
43
|
+
onChange(next);
|
|
44
|
+
} } })] });
|
|
45
|
+
}
|
|
46
|
+
function CoverageFields({ surface, onPatch }) {
|
|
47
|
+
return _jsxs("div", { className: "space-y-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Alpha behavior" }), _jsxs("select", { "aria-label": "Alpha behavior", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: surface.alphaMode ?? "", onChange: (event) => onPatch({ alphaMode: (event.target.value || undefined) }, "alphaMode"), children: [_jsx("option", { value: "", children: "Imported behavior" }), _jsx("option", { value: "opaque", children: "Opaque" }), _jsx("option", { value: "mask", children: "Mask / cutout" }), _jsx("option", { value: "blend", children: "Alpha blend" })] })] }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Surface sides" }), _jsxs("select", { "aria-label": "Surface sides", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: surface.doubleSided === undefined ? "" : String(surface.doubleSided), onChange: (event) => onPatch({ doubleSided: event.target.value === "" ? undefined : event.target.value === "true" }, "doubleSided"), children: [_jsx("option", { value: "", children: "Imported sides" }), _jsx("option", { value: "false", children: "Front face" }), _jsx("option", { value: "true", children: "Both sides" })] })] })] });
|
|
48
|
+
}
|
|
49
|
+
function PairField({ label, value, fallback, onChange }) {
|
|
50
|
+
return _jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO_LABEL, children: label }), value ? _jsx("button", { type: "button", className: `${FOCUS_RING} text-[9px]`, onClick: () => onChange(undefined), children: "Reset" }) : _jsx("span", { className: "text-[9px] text-neutral-600", children: "Inherited" })] }), _jsx("div", { className: "grid grid-cols-2 gap-1", children: [0, 1].map((index) => _jsx("input", { "aria-label": `${label} ${index + 1}`, type: "number", step: "0.01", className: `min-w-0 px-2 py-1 ${INPUT_CLS}`, value: value?.[index] ?? "", placeholder: String(fallback[index]), onChange: (event) => { const next = Number(event.target.value); if (!event.target.value || !Number.isFinite(next))
|
|
51
|
+
return; const pair = [...(value ?? fallback)]; pair[index] = next; onChange(pair); } }, index)) })] });
|
|
52
|
+
}
|
|
53
|
+
function TextureFields({ role, texture, onChange }) {
|
|
54
|
+
const patch = (partial) => {
|
|
55
|
+
const next = { ...texture, ...partial };
|
|
56
|
+
if (next.transform) {
|
|
57
|
+
next.transform = { ...next.transform };
|
|
58
|
+
for (const key of Object.keys(next.transform)) {
|
|
59
|
+
if (next.transform[key] === undefined)
|
|
60
|
+
delete next.transform[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
onChange(next);
|
|
64
|
+
};
|
|
65
|
+
return _jsxs("details", { className: "rounded border border-white/[0.08] p-2", open: true, children: [_jsxs("summary", { className: `cursor-pointer ${FOCUS_RING}`, children: [role, " \u00B7 ", texture.colorSpace, " \u00B7 ", texture.channel ?? MATERIAL_TEXTURE_SEMANTICS[role].channel] }), _jsxs("div", { className: "space-y-2 pt-2", children: [_jsx("input", { "aria-label": `${role} texture URL`, className: `w-full px-2 py-1 ${INPUT_CLS}`, defaultValue: texture.url, onBlur: (event) => { if (event.target.value.trim() && event.target.value.trim() !== texture.url)
|
|
66
|
+
patch({ url: event.target.value.trim() }); } }, texture.url), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "UV set" }), _jsx("select", { "aria-label": `${role} UV set`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.uvSet ?? 0, onChange: (event) => patch({ uvSet: Number(event.target.value) }), children: [0, 1, 2, 3].map((uv) => _jsx("option", { value: uv, children: uv }, uv)) })] }), _jsx(PairField, { label: `${role} UV scale`, value: texture.transform?.scale, fallback: [1, 1], onChange: (scale) => patch({ transform: { ...texture.transform, scale } }) }), _jsx(PairField, { label: `${role} UV offset`, value: texture.transform?.offset, fallback: [0, 0], onChange: (offset) => patch({ transform: { ...texture.transform, offset } }) }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "UV rotation (radians)" }), _jsx("input", { "aria-label": `${role} UV rotation`, type: "number", step: "0.01", value: texture.transform?.rotation ?? 0, className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => { const rotation = Number(event.target.value); if (Number.isFinite(rotation))
|
|
67
|
+
patch({ transform: { ...texture.transform, rotation } }); } })] }), _jsx(PairField, { label: `${role} physical size (metres)`, value: texture.physicalSize, fallback: [1, 1], onChange: (physicalSize) => patch({ physicalSize }) }), role === "normal" || role === "clearcoatNormal" ? _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Normal convention" }), _jsxs("select", { "aria-label": `${role} normal convention`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.normalConvention ?? "opengl", onChange: (event) => patch({ normalConvention: event.target.value }), children: [_jsx("option", { value: "opengl", children: "OpenGL (+Y)" }), _jsx("option", { value: "directx", children: "DirectX (-Y)" })] })] }) : null, ["wrapS", "wrapT"].map((axis) => _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: axis }), _jsxs("select", { "aria-label": `${role} ${axis}`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.sampler?.[axis] ?? "repeat", onChange: (event) => patch({ sampler: { ...texture.sampler, [axis]: event.target.value } }), children: [_jsx("option", { value: "repeat", children: "Repeat" }), _jsx("option", { value: "clamp", children: "Clamp" }), _jsx("option", { value: "mirror", children: "Mirror" })] })] }, axis)), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Compression" }), _jsx("select", { "aria-label": `${role} compression`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.compression ?? "none", onChange: (event) => patch({ compression: event.target.value }), children: ["none", "ktx2", "basisu", "webp"].map((compression) => _jsx("option", { value: compression, children: compression }, compression)) })] }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Minification filter" }), _jsx("select", { "aria-label": `${role} minification filter`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.sampler?.minFilter ?? "linear-mipmap-linear", onChange: (event) => patch({ sampler: { ...texture.sampler, minFilter: event.target.value } }), children: ["nearest", "linear", "nearest-mipmap-nearest", "nearest-mipmap-linear", "linear-mipmap-nearest", "linear-mipmap-linear"].map((filter) => _jsx("option", { value: filter, children: filter }, filter)) })] }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Magnification filter" }), _jsxs("select", { "aria-label": `${role} magnification filter`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.sampler?.magFilter ?? "linear", onChange: (event) => patch({ sampler: { ...texture.sampler, magFilter: event.target.value } }), children: [_jsx("option", { value: "nearest", children: "Nearest" }), _jsx("option", { value: "linear", children: "Linear" })] })] }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Texture anisotropic filtering" }), _jsx("input", { "aria-label": `${role} texture anisotropy`, type: "number", min: "1", step: "1", value: texture.sampler?.anisotropy ?? 1, className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => { const anisotropy = Number(event.target.value); if (Number.isFinite(anisotropy) && anisotropy >= 1)
|
|
68
|
+
patch({ sampler: { ...texture.sampler, anisotropy } }); } })] }), _jsx("p", { className: "text-[10px] text-neutral-500", children: "Compression describes the supplied file; this editor does not transcode it. Physical size records authored metres; UV scale controls sampling." }), _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[10px] ${FOCUS_RING}`, children: "Texture attribution" }), _jsx("div", { className: "pt-2", children: _jsx(ProvenanceFields, { label: `${role} texture`, provenance: texture.provenance, onChange: (provenance) => patch({ provenance }) }, JSON.stringify(texture.provenance)) })] }), _jsx("button", { type: "button", className: `${CONTROL} ${FOCUS_RING} px-2 py-1 text-rose-200`, onClick: () => onChange(undefined), children: "Remove map override" })] })] });
|
|
69
|
+
}
|
|
70
|
+
function FabricFields({ asset, onChange }) {
|
|
71
|
+
const fabric = asset.fabric;
|
|
72
|
+
return _jsxs("div", { className: "space-y-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Fabric construction" }), _jsxs("select", { "aria-label": "Fabric construction", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: fabric?.construction ?? "", onChange: (event) => { const construction = event.target.value; if (!construction) {
|
|
73
|
+
const next = { ...asset };
|
|
74
|
+
delete next.fabric;
|
|
75
|
+
onChange(next);
|
|
76
|
+
}
|
|
77
|
+
else
|
|
78
|
+
onChange({ ...asset, fabric: construction === "fuzzy" ? { construction: "fuzzy" } : { ...fabric, construction: "woven" } }); }, children: [_jsx("option", { value: "", children: "No appearance adapter" }), _jsx("option", { value: "fuzzy", children: "Fuzzy / grazing sheen" }), _jsx("option", { value: "woven", children: "Woven / directional threads" })] })] }), fabric?.construction === "woven" ? _jsxs(_Fragment, { children: [[
|
|
79
|
+
{ key: "weaveDirection", label: "Weave direction (radians)", fallback: 0, min: -Math.PI, max: Math.PI },
|
|
80
|
+
{ key: "threadScale", label: "Thread cycles per UV", fallback: 80, min: 0.01 },
|
|
81
|
+
{ key: "normalStrength", label: "Thread normal response", fallback: 0.12, min: 0, max: 1 },
|
|
82
|
+
{ key: "variation", label: "Roughness variation", fallback: 0.05, min: 0, max: 1 },
|
|
83
|
+
].map((field) => _jsx(SurfaceField, { control: { ...field, kind: "number", step: 0.01 }, value: fabric[field.key], onChange: (value) => { const next = { ...fabric, [field.key]: value }; if (value === undefined)
|
|
84
|
+
delete next[field.key]; onChange({ ...asset, fabric: next }, `material:${asset.id}:fabric:${field.key}`); } }, field.key)), _jsx("p", { className: "text-[10px] text-neutral-500", children: "Procedural threads modify normals and roughness on UV-mapped geometry. Explicit physical highlight fields override adapter defaults." })] }) : null] });
|
|
85
|
+
}
|
|
86
|
+
function HairFields({ asset, onChange }) {
|
|
87
|
+
const hair = asset.hair;
|
|
88
|
+
return _jsxs("div", { className: "space-y-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Existing geometry" }), _jsxs("select", { "aria-label": "Hair geometry kind", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: hair?.geometry ?? "", onChange: (event) => { const geometry = event.target.value; if (!geometry) {
|
|
89
|
+
const next = { ...asset };
|
|
90
|
+
delete next.hair;
|
|
91
|
+
onChange(next);
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
onChange({ ...asset, hair: geometry === "strands" ? { geometry: "strands", ...(hair?.strandDirection === undefined ? {} : { strandDirection: hair.strandDirection }) } : { ...hair, geometry: "cards" } }); }, children: [_jsx("option", { value: "", children: "No appearance adapter" }), _jsx("option", { value: "cards", children: "Hair cards" }), _jsx("option", { value: "strands", children: "Strand mesh" })] })] }), hair ? _jsx(SurfaceField, { control: { label: "Strand direction (radians)", kind: "number", fallback: 0, min: -Math.PI, max: Math.PI, step: 0.01 }, value: hair.strandDirection, onChange: (value) => { const next = { ...hair, strandDirection: typeof value === "number" ? value : undefined }; if (value === undefined)
|
|
95
|
+
delete next.strandDirection; onChange({ ...asset, hair: next }, `material:${asset.id}:hair:direction`); } }) : null] });
|
|
96
|
+
}
|
|
97
|
+
function HairBacklightFields({ asset, onChange }) {
|
|
98
|
+
const hair = asset.hair;
|
|
99
|
+
if (hair?.geometry !== "cards")
|
|
100
|
+
return _jsx("p", { className: "text-[10px] text-neutral-500", children: "Backlighting is supported by the hair-card adapter. Select existing card geometry under Strand highlights." });
|
|
101
|
+
return _jsxs("div", { className: "space-y-2", children: [_jsx(SurfaceField, { control: { label: "Card backlight strength", kind: "number", fallback: 0.25, min: 0, max: 1, step: 0.01 }, value: hair.backlightStrength, onChange: (value) => { const next = { ...hair, backlightStrength: typeof value === "number" ? value : undefined }; if (value === undefined)
|
|
102
|
+
delete next.backlightStrength; onChange({ ...asset, hair: next }, `material:${asset.id}:hair:backlight`); } }), _jsx(SurfaceField, { control: { label: "Card backlight color", kind: "color", fallback: asset.surface.color ?? "#ffffff" }, value: hair.backlightColor, onChange: (value) => { const next = { ...hair, backlightColor: typeof value === "string" ? value : undefined }; if (value === undefined)
|
|
103
|
+
delete next.backlightColor; onChange({ ...asset, hair: next }, `material:${asset.id}:hair:backlightColor`); } })] });
|
|
104
|
+
}
|
|
105
|
+
function ProvenanceFields({ label, provenance, onChange }) {
|
|
106
|
+
const [draft, setDraft] = useState({ author: provenance?.author ?? "", license: provenance?.license ?? "", sourceUrl: provenance?.sourceUrl ?? "", description: provenance?.description ?? "" });
|
|
107
|
+
const validSource = !draft.sourceUrl.trim() || /^https?:\/\//i.test(draft.sourceUrl.trim());
|
|
108
|
+
return _jsxs("div", { className: "space-y-2", children: [["author", "license", "sourceUrl", "description"].map((field) => _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: field === "sourceUrl" ? "Source URL" : field === "description" ? "Notes" : field }), _jsx("input", { "aria-label": `${label} ${field}`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: draft[field], onChange: (event) => setDraft({ ...draft, [field]: event.target.value }) })] }, field)), !validSource ? _jsx("p", { className: "text-[10px] text-rose-300", children: "Use an HTTP or HTTPS source URL." }) : null, _jsx("button", { type: "button", className: `${CONTROL} ${FOCUS_RING} px-2 py-1`, disabled: !draft.author.trim() || !draft.license.trim() || !validSource, onClick: () => onChange({ author: draft.author.trim(), license: draft.license.trim(), ...(draft.sourceUrl.trim() ? { sourceUrl: draft.sourceUrl.trim() } : {}), ...(draft.description.trim() ? { description: draft.description.trim() } : {}) }), children: "Apply attribution" }), provenance?.sourceUrl && /^https?:\/\//i.test(provenance.sourceUrl) ? _jsx("a", { href: provenance.sourceUrl, target: "_blank", rel: "noreferrer", className: `ml-2 break-all text-[10px] text-cyan-300 underline ${FOCUS_RING}`, children: "View source" }) : null] });
|
|
109
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
2
|
+
import { type ModelMaterialSlotInfo } from "@jgengine/shell/render/materialAsset";
|
|
3
|
+
import type { MaterialAsset } from "@jgengine/core/material/materialAsset";
|
|
4
|
+
import type { EditorEnvironment } from "@jgengine/core/editor/types";
|
|
5
|
+
/**
|
|
6
|
+
* Material preview lighting uses an isolated renderer; document settings never modify the game.
|
|
7
|
+
* @internal Mounted by the material workspace.
|
|
8
|
+
*/
|
|
9
|
+
export declare function MaterialPreview({ asset, mode, environment, model, onSlots, onError }: {
|
|
10
|
+
asset?: MaterialAsset;
|
|
11
|
+
mode: "neutral" | "game";
|
|
12
|
+
environment?: EditorEnvironment;
|
|
13
|
+
model?: ModelConfig;
|
|
14
|
+
onSlots?: (slots: ModelMaterialSlotInfo[]) => void;
|
|
15
|
+
onError?: (message: string | null) => void;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Canvas, useLoader } from "@react-three/fiber";
|
|
3
|
+
import { Component, Suspense, useEffect, useState } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { inspectModelMaterialSlots } from "@jgengine/shell/render/materialAsset";
|
|
6
|
+
import { sharedGltfLoader } from "@jgengine/shell/render/modelLoad";
|
|
7
|
+
import { EntityModel } from "@jgengine/shell/render/SceneModels";
|
|
8
|
+
import { DEFAULT_SUN_ELEVATION_DEG, sunDirectionFromBearing } from "@jgengine/shell/environment/daylightCycle";
|
|
9
|
+
import { MaterialAssetSurface } from "@jgengine/shell/render/materialAsset";
|
|
10
|
+
import { EnvironmentLighting } from "@jgengine/shell/render/EnvironmentLighting";
|
|
11
|
+
import { INPUT_CLS } from "./shell/theme.js";
|
|
12
|
+
function SlotProbe({ model, onSlots }) {
|
|
13
|
+
const gltf = useLoader(sharedGltfLoader, model.url);
|
|
14
|
+
useEffect(() => { onSlots?.(inspectModelMaterialSlots(gltf.scene)); }, [gltf.scene, onSlots]);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function PreviewReady({ onError }) {
|
|
18
|
+
useEffect(() => { onError?.(null); }, [onError]);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
class PreviewBoundary extends Component {
|
|
22
|
+
state = { failed: false };
|
|
23
|
+
static getDerivedStateFromError() { return { failed: true }; }
|
|
24
|
+
componentDidCatch(error) { this.props.onError?.(error instanceof Error ? error.message : String(error)); }
|
|
25
|
+
render() { return this.state.failed ? null : this.props.children; }
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Material preview lighting uses an isolated renderer; document settings never modify the game.
|
|
29
|
+
* @internal Mounted by the material workspace.
|
|
30
|
+
*/
|
|
31
|
+
export function MaterialPreview({ asset, mode, environment, model, onSlots, onError }) {
|
|
32
|
+
const [view, setView] = useState("front");
|
|
33
|
+
const [orientation, setOrientation] = useState(0);
|
|
34
|
+
const game = mode === "game";
|
|
35
|
+
const sky = game ? environment?.zenithColor ?? "#87b5e0" : "#eeeeee";
|
|
36
|
+
const ground = game ? environment?.horizonColor ?? "#3d4a38" : "#666666";
|
|
37
|
+
const sceneLight = sunDirectionFromBearing(environment?.sunAzimuth ?? 0, environment?.sunElevation ?? DEFAULT_SUN_ELEVATION_DEG).map((axis) => axis * 5);
|
|
38
|
+
const light = game ? sceneLight : view === "back" ? [0, 1, -4] : view === "grazing" ? [4, 0.5, 0.2] : [2, 3, 4];
|
|
39
|
+
const flat = asset?.family === "hair" || asset?.family === "fabric";
|
|
40
|
+
return _jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-1 text-[10px]", children: [!game ? _jsxs("select", { "aria-label": "Material lighting view", value: view, onChange: (event) => setView(event.target.value), className: INPUT_CLS, children: [_jsx("option", { value: "front", children: "Front light" }), _jsx("option", { value: "grazing", children: "Grazing light" }), _jsx("option", { value: "back", children: "Back light" })] }) : _jsx("span", { children: "Document sun direction" }), _jsxs("label", { children: [model ? "Model yaw" : "Surface orientation", _jsx("input", { "aria-label": "Preview orientation", style: { width: 100 }, type: "range", min: 0, max: 360, value: orientation, onChange: (event) => setOrientation(Number(event.target.value)) })] })] }), _jsx("div", { style: { height: 210 }, "data-jg-material-preview": "", children: _jsxs(Canvas, { dpr: 1, camera: { position: [0, 0.35, 3.6], fov: 38 }, shadows: true, gl: { antialias: true, toneMapping: THREE.ACESFilmicToneMapping, toneMappingExposure: 1, outputColorSpace: THREE.SRGBColorSpace }, children: [_jsx("color", { attach: "background", args: ["#24272c"] }), _jsx("ambientLight", { intensity: game ? environment?.ambientIntensity ?? 0.2 : 0.12 }), _jsx("directionalLight", { position: light, intensity: game ? environment?.sunIntensity ?? 3 : 3, castShadow: true, "shadow-bias": -0.0004, "shadow-normalBias": 0.02 }), _jsx(PreviewBoundary, { onError: onError, children: _jsxs(Suspense, { fallback: null, children: [_jsx(EnvironmentLighting, { ...(game && environment?.source !== undefined ? { source: environment.source } : {}), intensity: 0.65, skyColor: sky, groundColor: ground, sunDirection: light }), model ? _jsxs("group", { "rotation-y": orientation * Math.PI / 180, "position-y": -0.85, children: [_jsx(SlotProbe, { model: model, onSlots: onSlots }), _jsx(EntityModel, { model: model })] }) : asset ? _jsxs("mesh", { rotation: [0, 0, orientation * Math.PI / 180], castShadow: true, receiveShadow: true, children: [flat ? _jsx("planeGeometry", { args: [2.1, 1.7, 24, 20] }) : _jsx("sphereGeometry", { args: [0.8, 64, 32] }), _jsx(MaterialAssetSurface, { asset: asset })] }) : null, _jsxs("mesh", { position: [0, -0.95, 0], rotation: [-Math.PI / 2, 0, 0], receiveShadow: true, children: [_jsx("planeGeometry", { args: [7, 7] }), _jsx("meshStandardMaterial", { color: "#393b3f", roughness: 0.95 })] }), _jsx(PreviewReady, { onError: onError })] }) }, JSON.stringify([asset, model]))] }, `${view}:${asset?.id ?? model?.url ?? "empty"}`) }), _jsxs("p", { className: "text-[9px] text-neutral-500", children: [model ? "Selected model displays its saved slot assignments. Choosing another library asset changes controls; assign it to a slot to change this model." : "Parameter sample; imported values require a selected model.", " ", game ? "Document environment and fixed sun; game lighting may add other lights or motion." : "Neutral front, grazing or back light.", " ACES, exposure 1. Geometry, tangents and UVs determine the response."] })] });
|
|
41
|
+
}
|
|
@@ -1,12 +1,24 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { EditorDocument, EditorSession } from "@jgengine/core/editor/index";
|
|
3
|
+
import { type MaterialAsset, type MaterialAssignment } from "@jgengine/core/material/materialAsset";
|
|
2
4
|
import type { EditorHostApi } from "./session.js";
|
|
5
|
+
/** Read the selected model's first usable assignment for workspace initialization. @internal */
|
|
6
|
+
export declare function initialMaterialAssignment(document: Pick<EditorDocument, "markers" | "materialAssets">, selection: readonly string[]): MaterialAssignment | null;
|
|
3
7
|
/**
|
|
4
8
|
* Materials workspace home panel: browse every placeable's real `meta.materialId` from the live
|
|
5
9
|
* document, filter by assignment/palette id/text, select into the hierarchy, and assign/clear via
|
|
6
10
|
* the existing `assign_material` / `batch_set_properties` RPC seams. No fake thumbnails.
|
|
7
11
|
* @internal — mounted by `EditorChrome` when the materials workspace is active.
|
|
8
12
|
*/
|
|
9
|
-
export declare function MaterialsWorkspacePanel({ session, api, }: {
|
|
13
|
+
export declare function MaterialsWorkspacePanel({ session, api, preview, onSave, materialSlots, previewError, }: {
|
|
10
14
|
session: EditorSession;
|
|
11
15
|
api: EditorHostApi;
|
|
16
|
+
preview?: (asset: MaterialAsset | undefined, mode: "neutral" | "game") => ReactNode;
|
|
17
|
+
onSave?: () => void;
|
|
18
|
+
materialSlots?: readonly {
|
|
19
|
+
mesh: string;
|
|
20
|
+
slot: string;
|
|
21
|
+
slotIndex: number;
|
|
22
|
+
}[];
|
|
23
|
+
previewError?: string | null;
|
|
12
24
|
}): import("react").JSX.Element;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo, useState } from "react";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { createMaterialTemplate, parseMaterialAssignments } from "@jgengine/core/material/materialAsset";
|
|
3
4
|
import { clearMaterialAssignmentPatch } from "./authoredComponentMeta.js";
|
|
5
|
+
import { MaterialAssetEditor } from "./MaterialAssetEditor.js";
|
|
4
6
|
import { filterMaterialAssignments, listMaterialAssignments, summarizeMaterialUsage, } from "./materialAssignments.js";
|
|
5
7
|
import { TERRAIN_MATERIALS } from "./uiStore.js";
|
|
6
8
|
import { FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
|
|
@@ -17,17 +19,69 @@ function materialColor(materialId) {
|
|
|
17
19
|
function materialLabel(materialId) {
|
|
18
20
|
return TERRAIN_MATERIALS.find((material) => material.id === materialId)?.label ?? materialId;
|
|
19
21
|
}
|
|
22
|
+
/** Read the selected model's first usable assignment for workspace initialization. @internal */
|
|
23
|
+
export function initialMaterialAssignment(document, selection) {
|
|
24
|
+
const marker = document.markers.find((item) => selection.includes(item.id));
|
|
25
|
+
const value = marker?.meta?.materialAssignments;
|
|
26
|
+
if (!Array.isArray(value))
|
|
27
|
+
return null;
|
|
28
|
+
for (const item of value) {
|
|
29
|
+
try {
|
|
30
|
+
const assignment = parseMaterialAssignments([item])[0];
|
|
31
|
+
if (document.materialAssets?.some((asset) => asset.id === assignment.materialId))
|
|
32
|
+
return assignment;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
20
40
|
/**
|
|
21
41
|
* Materials workspace home panel: browse every placeable's real `meta.materialId` from the live
|
|
22
42
|
* document, filter by assignment/palette id/text, select into the hierarchy, and assign/clear via
|
|
23
43
|
* the existing `assign_material` / `batch_set_properties` RPC seams. No fake thumbnails.
|
|
24
44
|
* @internal — mounted by `EditorChrome` when the materials workspace is active.
|
|
25
45
|
*/
|
|
26
|
-
export function MaterialsWorkspacePanel({ session, api, }) {
|
|
46
|
+
export function MaterialsWorkspacePanel({ session, api, preview, onSave, materialSlots, previewError, }) {
|
|
27
47
|
const document = useStoreSelector(session, (state) => state.document);
|
|
28
48
|
const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
|
|
29
49
|
const [query, setQuery] = useState("");
|
|
30
50
|
const [filter, setFilter] = useState("all");
|
|
51
|
+
const [tab, setTab] = useState("assets");
|
|
52
|
+
const [initialAssignment] = useState(() => { const state = session.getState(); return initialMaterialAssignment(state.document, state.selection); });
|
|
53
|
+
const [activeAssetId, setActiveAssetId] = useState(initialAssignment?.materialId ?? null);
|
|
54
|
+
const [template, setTemplate] = useState("wool");
|
|
55
|
+
const [newId, setNewId] = useState("");
|
|
56
|
+
const [mesh, setMesh] = useState(initialAssignment?.selector.mesh ?? "");
|
|
57
|
+
const [slot, setSlot] = useState(initialAssignment?.selector.slot ?? "");
|
|
58
|
+
const [slotIndex, setSlotIndex] = useState(initialAssignment?.selector.slotIndex === undefined ? "" : String(initialAssignment.selector.slotIndex));
|
|
59
|
+
const [error, setError] = useState(null);
|
|
60
|
+
const selectionKey = selection.join("\0");
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const state = session.getState();
|
|
63
|
+
const assignment = initialMaterialAssignment(state.document, state.selection);
|
|
64
|
+
if (assignment)
|
|
65
|
+
setActiveAssetId(assignment.materialId);
|
|
66
|
+
setMesh(assignment?.selector.mesh ?? "");
|
|
67
|
+
setSlot(assignment?.selector.slot ?? "");
|
|
68
|
+
setSlotIndex(assignment?.selector.slotIndex === undefined ? "" : String(assignment.selector.slotIndex));
|
|
69
|
+
}, [selectionKey, session]);
|
|
70
|
+
const assets = document.materialAssets ?? [];
|
|
71
|
+
const activeAsset = assets.find((asset) => asset.id === activeAssetId) ?? assets[0];
|
|
72
|
+
const assignmentErrors = [];
|
|
73
|
+
const selectedAssignments = document.markers.filter((marker) => selection.includes(marker.id)).flatMap((marker) => {
|
|
74
|
+
const value = marker.meta?.materialAssignments;
|
|
75
|
+
if (!Array.isArray(value))
|
|
76
|
+
return [];
|
|
77
|
+
try {
|
|
78
|
+
return parseMaterialAssignments(value).map((assignment) => ({ ...assignment, objectId: marker.id, objectName: marker.label ?? marker.id }));
|
|
79
|
+
}
|
|
80
|
+
catch (failure) {
|
|
81
|
+
assignmentErrors.push(`${marker.id}: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
});
|
|
31
85
|
const rows = useMemo(() => listMaterialAssignments(document), [document]);
|
|
32
86
|
const filtered = useMemo(() => filterMaterialAssignments(rows, query, filter), [rows, query, filter]);
|
|
33
87
|
const usage = useMemo(() => summarizeMaterialUsage(rows), [rows]);
|
|
@@ -59,7 +113,27 @@ export function MaterialsWorkspacePanel({ session, api, }) {
|
|
|
59
113
|
return filter.materialId === candidate.materialId;
|
|
60
114
|
return filter === candidate;
|
|
61
115
|
};
|
|
62
|
-
|
|
116
|
+
const tabs = _jsx("div", { className: "flex shrink-0 gap-1 border-b border-white/[0.06] p-2", role: "tablist", "aria-label": "Material workspace views", children: ["assets", "assignments"].map((view) => _jsx("button", { type: "button", role: "tab", "aria-selected": tab === view, className: `${CHIP} flex-1 ${tab === view ? CHIP_ACTIVE : CHIP_IDLE}`, onClick: () => setTab(view), children: view === "assets" ? "Surface assets" : "Legacy assignments" }, view)) });
|
|
117
|
+
if (tab === "assets")
|
|
118
|
+
return _jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [tabs, _jsxs("div", { className: "min-h-0 flex-1 overflow-auto", children: [_jsxs("div", { className: "space-y-2 border-b border-white/[0.06] p-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Reusable material asset" }), _jsx("select", { "aria-label": "Choose material asset", value: activeAsset?.id ?? "", className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => setActiveAssetId(event.target.value), children: assets.length ? assets.map((asset) => _jsx("option", { value: asset.id, children: asset.name }, asset.id)) : _jsx("option", { value: "", children: "No authored materials" }) })] }), activeAsset ? _jsx("button", { type: "button", className: `${CHIP} border-rose-400/25 text-rose-200`, onClick: () => { const result = api.handle({ method: "remove_material_asset", id: activeAsset.id }); setError(result.ok ? null : result.error ?? "Material operation failed."); }, children: "Remove unused asset" }) : null, _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[11px] text-neutral-400 ${FOCUS_RING}`, children: "Create from an editable starting point" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsx("select", { "aria-label": "Material starting point", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: template, onChange: (event) => setTemplate(event.target.value), children: ["wool", "cotton", "silk", "brushed-metal", "glass", "coated-plastic", "stone", "hair-cards"].map((entry) => _jsx("option", { value: entry, children: entry }, entry)) }), _jsx("input", { "aria-label": "New material stable ID", value: newId, onChange: (event) => setNewId(event.target.value), placeholder: "game/my-original-surface", className: `w-full px-2 py-1 ${INPUT_CLS}` }), _jsx("button", { type: "button", disabled: !newId.trim() || assets.some((asset) => asset.id === newId.trim()), className: `${CHIP} ${CHIP_IDLE}`, onClick: () => { const asset = createMaterialTemplate(template, newId.trim()); const result = api.handle({ method: "upsert_material_asset", asset }); if (!result.ok)
|
|
119
|
+
setError(result.error ?? "Material operation failed.");
|
|
120
|
+
else {
|
|
121
|
+
setActiveAssetId(asset.id);
|
|
122
|
+
setNewId("");
|
|
123
|
+
setError(null);
|
|
124
|
+
} }, children: "Create asset" }), _jsx("p", { className: "text-[10px] text-neutral-500", children: "Starting points contain authored response parameters; your game supplies original textures, geometry and art direction." })] })] }), activeAsset ? _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[11px] text-neutral-400 ${FOCUS_RING}`, children: "Assign to selected material slot" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsx("p", { className: "text-[10px] text-neutral-500", children: "Choose a loaded material slot. Assignments preserve other slots on the model." }), materialSlots?.length ? _jsxs("select", { "aria-label": "Choose imported material slot", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: String(materialSlots.findIndex((entry) => entry.mesh === mesh && entry.slot === slot && String(entry.slotIndex) === slotIndex)), onChange: (event) => { const entry = materialSlots[Number(event.target.value)]; if (!entry)
|
|
125
|
+
return; setMesh(entry.mesh); setSlot(entry.slot); setSlotIndex(String(entry.slotIndex)); }, children: [_jsx("option", { value: "-1", children: "Choose a slot\u2026" }), materialSlots.map((entry, index) => _jsxs("option", { value: String(index), children: [entry.mesh || "Unnamed mesh", " / ", entry.slot || `slot ${entry.slotIndex}`] }, `${entry.mesh}:${entry.slotIndex}:${index}`))] }) : _jsx("p", { className: "text-[10px] text-neutral-500", children: "Select a model to inspect its imported slots, or enter an exact selector below." }), _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[10px] text-neutral-400 ${FOCUS_RING}`, children: "Advanced exact selector" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsx("input", { "aria-label": "Assignment mesh name", placeholder: "Mesh name (optional)", value: mesh, onChange: (event) => setMesh(event.target.value), className: `w-full px-2 py-1 ${INPUT_CLS}` }), _jsx("input", { "aria-label": "Assignment material slot name", placeholder: "Material slot name (optional)", value: slot, onChange: (event) => setSlot(event.target.value), className: `w-full px-2 py-1 ${INPUT_CLS}` }), _jsx("input", { "aria-label": "Assignment material slot index", type: "number", min: "0", step: "1", placeholder: "Slot index (optional)", value: slotIndex, onChange: (event) => setSlotIndex(event.target.value), className: `w-full px-2 py-1 ${INPUT_CLS}` })] })] }), _jsxs("button", { type: "button", className: `${CHIP} ${CHIP_IDLE}`, disabled: selection.length === 0 || (!mesh.trim() && !slot.trim() && !slotIndex), onClick: () => {
|
|
126
|
+
const selector = {};
|
|
127
|
+
if (mesh.trim())
|
|
128
|
+
selector.mesh = mesh.trim();
|
|
129
|
+
if (slot.trim())
|
|
130
|
+
selector.slot = slot.trim();
|
|
131
|
+
if (slotIndex)
|
|
132
|
+
selector.slotIndex = Number(slotIndex);
|
|
133
|
+
const result = api.handle({ method: "assign_material_asset", ids: [...selection], materialId: activeAsset.id, selector });
|
|
134
|
+
setError(result.ok ? null : result.error ?? "Material operation failed.");
|
|
135
|
+
}, children: ["Assign to selection (", selection.length, ")"] }), selectedAssignments.map((assignment, index) => _jsxs("button", { type: "button", className: `${CHIP} ${CHIP_IDLE} block w-full text-left`, onClick: () => { setActiveAssetId(assignment.materialId); setMesh(assignment.selector.mesh ?? ""); setSlot(assignment.selector.slot ?? ""); setSlotIndex(assignment.selector.slotIndex === undefined ? "" : String(assignment.selector.slotIndex)); }, children: [assignment.objectName, ": ", assignment.selector.mesh ?? "any mesh", " / ", assignment.selector.slot ?? `slot ${assignment.selector.slotIndex ?? "any"}`, " \u2192 ", assignment.materialId] }, `${assignment.objectId}:${index}`)), selectedAssignments.length ? _jsx("button", { type: "button", className: `${CHIP} border-rose-400/25 text-rose-200`, onClick: () => { const result = api.handle({ method: "clear_material_assets", ids: [...selection] }); setError(result.ok ? null : result.error ?? "Material operation failed."); }, children: "Restore selected objects' imported materials" }) : null] })] }) : null, _jsxs("div", { className: "flex gap-1", children: [_jsx("button", { type: "button", className: `${CHIP} ${CHIP_IDLE}`, onClick: () => api.handle({ method: "undo" }), children: "Undo" }), _jsx("button", { type: "button", className: `${CHIP} ${CHIP_IDLE}`, onClick: () => api.handle({ method: "redo" }), children: "Redo" }), onSave ? _jsx("button", { type: "button", className: `${CHIP} ${CHIP_IDLE}`, onClick: onSave, children: "Save scene" }) : null] }), error ? _jsx("p", { role: "alert", className: "text-[10px] text-rose-300", children: error }) : null, previewError ? _jsxs("p", { role: "alert", className: "text-[10px] text-rose-300", children: ["Preview: ", previewError] }) : null, assignmentErrors.map((message) => _jsx("p", { role: "alert", className: "text-[10px] text-rose-300", children: message }, message))] }), activeAsset ? _jsx(MaterialAssetEditor, { asset: activeAsset, preview: preview, onChange: (asset, coalesce) => { const result = api.handle({ method: "upsert_material_asset", asset, coalesce }); setError(result.ok ? null : result.error ?? "Material operation failed."); } }, activeAsset.id) : _jsxs(_Fragment, { children: [_jsx("div", { className: "p-2", children: preview?.(undefined, "neutral") }), _jsx(EmptyState, { icon: "sphere", title: "No material assets", description: "Create an editable starting point, then assign it to a named mesh or material slot. Existing imported materials remain intact." })] })] })] });
|
|
136
|
+
return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [tabs, _jsxs("div", { className: "space-y-2 border-b border-white/[0.06] p-2", children: [_jsx("div", { className: MICRO_LABEL, children: "Materials" }), _jsx("p", { className: "text-[10px] leading-snug text-neutral-500", children: "Document material assignments \u2014 select a row to inspect, assign from the palette, or clear." }), _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Filter objects\u2026", "aria-label": "Filter material assignments", className: `h-7 w-full px-2 ${INPUT_CLS}` }), _jsx("div", { className: "flex flex-wrap gap-1", role: "group", "aria-label": "Assignment filters", children: [
|
|
63
137
|
{ id: "all", label: `All (${rows.length})` },
|
|
64
138
|
{ id: "assigned", label: `Assigned (${assignedCount})` },
|
|
65
139
|
{ id: "unassigned", label: `None (${rows.length - assignedCount})` },
|
|
@@ -65,15 +65,10 @@ export function RuntimePlayPublisher({ api }) {
|
|
|
65
65
|
api.setPlayControl({ paused: true, pendingSteps: play.pendingSteps - 1 });
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
+
// Freeze before the next simulation frame; positive priorities would take over canvas rendering.
|
|
68
69
|
if (!ctx.time.isPaused())
|
|
69
70
|
ctx.time.pause();
|
|
70
71
|
}, -1);
|
|
71
|
-
useFrame(() => {
|
|
72
|
-
const play = api.getPlayControl();
|
|
73
|
-
if (play.paused && play.pendingSteps === 0 && !ctx.time.isPaused()) {
|
|
74
|
-
ctx.time.pause();
|
|
75
|
-
}
|
|
76
|
-
}, 1);
|
|
77
72
|
return null;
|
|
78
73
|
}
|
|
79
74
|
/**
|