CharacterKit/CharacterCreatorPanel.razor

A Blazor-style Razor UI panel for a character creator. It renders the character editor UI (tabs for presets, skin, face & body, photos, hair, clothing, shape, makeup), binds many controls to an Appearance model, and provides methods to modify appearance, cosmetics, wardrobe and preview state and to save/emit the final character.

File AccessNetworking
@using Sandbox

@using Sandbox.UI

@using System

@using System.Text.Json

@using System.Threading.Tasks

@namespace CharacterEditor

@inherits Panel

<root class="interactive creatoroverlay">

 @if(true)

 {

  <div class="creatorwindow">

   <div class="creatorheader"><div><div class="creatorbreadcrumb"><span>Character Editor</span><span class="material-icons">chevron_right</span><span>Studio</span></div><div class="creatortitle">Create your character</div></div><button class="creatorbutton quiet cancelcreation" onclick=@Back><span class="material-icons">arrow_back</span>Back</button></div>

   <div class="creatorbody">

    <div class="creatorcontrols">

     <div class="creatortabs">@foreach(var tab in Tabs){<button class=@($"creatorbutton creatortab {(Tab==tab?"chosen":"")}") onclick=@(()=>ChangeTab(tab))>@tab</button>}</div>

     <div class=@($"creatoroptions {(Tab=="Voice"?"voiceoptions":"")}")>

      <ReferenceFacePanel style=@(Tab=="Photos"?"display: flex;":"display: none;") Appearance=@Appearance Generated=@AddPhotoFace />
      @if(Tab=="Presets")

      {

       <div class="optionheading">Choose a starting look</div><div class="optiondescription">Start with a preset, then make it your own.</div><div class="presetgrid">

        @foreach(var preset in AppearanceCatalog.Presets){<button class=@($"creatorbutton presetchoice {(ChosenPreset==preset.Id?"chosen":"")}") onclick=@(()=>ApplyPreset(preset.Id))><div class="presetportrait"><CharacterCreatorPreview [email protected] Portrait=@true /></div><div class="presetlabel">@preset.Label</div></button>}

       </div>

      }

      else if(Tab=="Face & body")

      {

       @if(Appearance.BaseShape is not null)
       {
        <div class="optionheading">Blend your base face</div><div class="optiondescription">Overall sets the whole face. In regional tabs, blend that result with individual faces for that area. Refine the result in Shape; choose textures in Skin.</div>
        <div class="blendtabs">@foreach(var region in new[]{"overall"}.Concat(FaceRegions.Ids)){<button class=@($"creatorbutton blendtab blend-tab-{region} {(BlendRegion==region?"chosen":"")}") onclick=@(()=>BlendRegion=region)>@FaceRegions.Label(region)</button>}</div>
        @if(BlendRegion!="overall")
        {
         <div class="regionstatus"><span>Mix the live Overall blend with individual faces. Relative weights determine each share.</span><button class="creatorbutton quiet reset-region" onclick=@ResetRegion>Reset region</button></div>
         <div class="overallblendrow"><div class="shapelabel"><span>Overall blend</span><span>@($"{MathF.Round(OverallWeight*100)} influence · {MathF.Round(FaceRegions.OverallShare(Appearance.BaseShape,BlendRegion)*100)}% of region")</span></div><SliderControl class="shapeslider region-overall-weight" Value=@OverallWeight OnValueChanged=@SetOverallWeight Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /><div class="optiondescription">Follows changes made in the Overall tab.</div></div>
        }
        @for(var i=0;i<7;i++)
        {
         var index=i;
         <div class="shaperow"><div class="shapelabel"><span>Face @(index+1)</span><span>@BlendWeightLabel(index)</span></div><SliderControl class=@($"shapeslider head-weight-{index+1}") Value=@HeadWeight(index) OnValueChanged=@((float value)=>SetHeadWeight(index,value)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
        }
        @foreach(var face in Appearance.BaseShape.PhotoFaces)
        {
         <div class="photoblendcard">
          <div class="photoblendheader"><span class="photoblendname">@face.Name</span><button class="creatorbutton quiet delete-photo-face" tooltip="Delete this custom face from the character and all regions" onclick=@(()=>FaceRegions.RemovePhoto(Appearance.BaseShape,face))><span class="material-icons">delete_outline</span></button></div>
          <div class="shapelabel"><span>Blend influence</span><span>@($"{MathF.Round(PhotoWeight(face)*100)} influence · {MathF.Round(FaceRegions.PhotoEntryShare(Appearance.BaseShape,face,BlendRegion)*100)}% of blend")</span></div>
          <SliderControl class="shapeslider photo-face-weight" Value=@PhotoWeight(face) OnValueChanged=@((float value)=>SetPhotoWeight(face,value)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false />
          @if(BlendRegion=="overall")
          {
           <div class="shapelabel"><span>Photo detail strength</span><span>@MathF.Round(face.Strength*100)%</span></div>
           <SliderControl class="shapeslider photo-face-strength" [email protected] OnValueChanged=@((float value)=>{face.Strength=Math.Clamp(value,0,2);}) Min=@(0f) Max=@(2f) Step=@(.01f) ShowTextEntry=@false />
           <div class="optiondescription">100% is the fitted shape. Higher values exaggerate its slider changes, within normal Shape limits. This also affects regions using this face.</div>
          }
         </div>
        }
        @if(Appearance.BaseShape.TotalInfluence==0){<div class="optiondescription">All influences are zero: using Face 3 as the neutral base.</div>}
        <div class="optionheading">Base body</div><div class="shapelabel"><span>@MathF.Round((1-Appearance.BaseShape.MaleBody)*100)% female</span><span>@MathF.Round(Appearance.BaseShape.MaleBody*100)% male</span></div><SliderControl class="shapeslider base-body" [email protected] OnValueChanged=@SetBodyBlend Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false />
        @foreach(var variant in new[]{"male","female"})
        {
         <button class=@($"creatorbutton quiet clothing-prefer-{variant}") role="checkbox" aria-checked=@(Appearance.BaseShape.ClothingVariant==variant?"true":"false") onclick=@(()=>ToggleClothingVariant(variant))><span class="material-icons">@(Appearance.BaseShape.ClothingVariant==variant?"check_box":"check_box_outline_blank")</span>Prefer @variant clothing version</button>
        }
        <div class="optiondescription">Neither selected: use the body blend. Available versions still fit your body shape.</div>
        <div class="optiondescription">Body blend is independent of face and skin. Breast adjustments fade with the female amount; bulge adjustments fade with the male amount.</div>
       }
       else
       {
        <div class="optionheading">Native base</div><div class="optiondescription">This saved character uses its original native face and body.</div><button class="creatorbutton" onclick=@EnableBlending>Enable weighted base blending</button>
        <div class="bodychoices"><button class="creatorbutton" onclick=@(()=>ChooseBody("male"))>Masculine</button><button class="creatorbutton" onclick=@(()=>ChooseBody("female"))>Feminine</button></div><div class="skingrid">@foreach(var skin in AppearanceCatalog.Skins.Where(x=>x.BodyType==BodyType)){<button class="creatorbutton skinchoice" onclick=@(()=>ChooseSkin(skin))>@FaceLabel(skin)</button>}</div>
       }

       <div class="optionheading">Eye color</div><div class="optiondescription">Color the iris while keeping the pupil and whites of the eyes.</div>
       <div class="colorchoices"><button class=@($"creatorbutton colorchoice eyecolorchoice {(!Appearance.CustomEyeColor?"chosen":"")}") onclick=@(()=>ChooseEyeColor(null))>Native</button>@foreach(var color in EyeColors){<button class=@($"creatorbutton colorchoice eyecolorchoice {(Appearance.CustomEyeColor && Appearance.EyeColor==color.Color?"chosen":"")}") onclick=@(()=>ChooseEyeColor(color.Color))><div class="colorswatch" style=@($"background-color: {ColorCss(color.Color)};")></div><span>@color.Label</span></button>}</div>
       <button class="creatorbutton quiet eyecustomtoggle" onclick=@(()=>ChooseEyeColor(Appearance.EyeColor))><span class="material-icons">palette</span>Custom eye color</button>
       @if(Appearance.CustomEyeColor){@foreach(var channel in new[]{"Red","Green","Blue"}){<div class="skinchannel"><div class="shapelabel">@channel</div><SliderControl class=@($"shapeslider eye-{channel.ToLowerInvariant()}") Value=@EyeChannel(channel) OnValueChanged=@((float value)=>SetEyeChannel(channel,value)) Min=@(0f) Max=@(1f) Step=@(1f/255) ShowTextEntry=@false /></div>}}
      }

      else if(Tab=="Skin")

      {

       @if(Appearance.BaseShape is not null){<div class="optionheading">Skin details</div><div class="optiondescription">Choose the native texture set: pigment, pores, freckles and other details. This does not change your face weights or body shape.</div><div class="skingrid">@foreach(var skin in AppearanceCatalog.Skins){<button class=@($"creatorbutton skinchoice {(Appearance.SkinPath==skin.ResourcePath?"chosen":"")}") onclick=@(()=>ChooseSkin(skin))>Skin @skin.Id.Replace("human","")</button>}</div>}
       <div class="optionheading">Skin color</div><div class="optiondescription">Choose a complexion independently of face and body. Native restores the selected skin's original color.</div>

       <div class="shapecategories"><button class=@($"creatorbutton skinpalette {(SkinPalette=="Human"?"chosen":"")}") onclick=@(()=>SkinPalette="Human")>Human</button><button class=@($"creatorbutton skinpalette {(SkinPalette=="Fantasy"?"chosen":"")}") onclick=@(()=>SkinPalette="Fantasy")>Fantasy &amp; custom</button></div>

       <div class="colorchoices">@foreach(var color in SkinPalette=="Human"?AppearanceCatalog.HumanSkinColors:AppearanceCatalog.FantasySkinColors){<button class=@($"creatorbutton colorchoice skincolorchoice skincolor-{color.Id} {(Appearance.SkinColor==color.Color && Appearance.NormalizeSkinTone==color.NormalizeSkinTone?"chosen":"")}") onclick=@(()=>ChooseSkinColor(color.Color,color.NormalizeSkinTone)) [email protected]><div class="colorswatch" style=@($"background-color: {ColorCss(color.Color)};")></div><span>@color.Label</span></button>}</div>

       @if(SkinPalette=="Fantasy")

       {

        <div class="fieldlabel colorlabel">Custom color</div><div class="skincolorpreview"><div class="colorswatch" style=@($"background-color: {ColorCss(Appearance.SkinColor)};")></div><span>@($"RGB {MathF.Round(Appearance.SkinColorR*255)} · {MathF.Round(Appearance.SkinColorG*255)} · {MathF.Round(Appearance.SkinColorB*255)}")</span></div>

        @foreach(var channel in new[]{"Red","Green","Blue"}){<div class="skinchannel"><div class="shapelabel">@channel</div><SliderControl class=@($"shapeslider skin-{channel.ToLowerInvariant()}") Value=@SkinChannel(channel) OnValueChanged=@((float value)=>SetSkinChannel(channel,value)) Min=@(0f) Max=@(1f) Step=@(1f/255) ShowTextEntry=@false /></div>}

       }

       <button class="creatorbutton quiet resetshape resetskincolor" onclick=@(()=>ChooseSkinColor(Color.White))><span class="material-icons">restart_alt</span>Reset skin color</button>

      }

      else if(Tab=="Makeup")

      {

       <div class="optionheading">Makeup &amp; markings</div><div class="optiondescription">Make it yours. Choose None to leave any makeup layer bare.</div>

       <div class="shapecategories">@foreach(var layer in MakeupCatalog.Layers){<button class=@($"creatorbutton makeuplayer {(MakeupLayer==layer?"chosen":"")}") onclick=@(()=>ChooseMakeupLayer(layer))>@(layer=="Tattoo"?"Markings":layer)</button>}</div>

       @if(MakeupLayer=="Tattoo")

       {

        <div class="markinglayers"><div class="markinglist">@for(var i=0;i<Appearance.Makeup.Markings.Count;i++){var index=i;var mark=Appearance.Makeup.Markings[i];<button class=@($"creatorbutton markinglayer {(SelectedMarking==i?"chosen":"")}") onclick=@(()=>SelectMarking(index))>@($"{i+1} · {MarkingCatalog.Style(mark.Style).Label}")</button>}</div><button class="creatorbutton addmarking" onclick=@AddMarking tooltip="Add an independent marking layer"><span class="material-icons">add</span>Add marking</button></div>

        @if(CurrentMarking is null)

        {<div class="markingempty"><span class="material-icons">brush</span><div class="fieldlabel">None</div><div class="optiondescription">Add a marking to choose its design, location and ink. Combine up to six.</div></div>}

        else

        {

         <div class="markingeditorheading"><button class="creatorbutton quiet inspectmarking" onclick=@InspectMarking tooltip="Focus the preview on this marking"><span class="material-icons">center_focus_strong</span>@MarkingCatalog.Region(CurrentMarking.Region).Label</button><button class="creatorbutton quiet deletemarking" onclick=@DeleteMarking tooltip="Delete this marking layer"><span class="material-icons">delete_outline</span>Delete layer</button></div>
         <div class="markingsections">@foreach(var section in new[]{"Design","Placement","Ink"}){<button class=@($"creatorbutton markingsection {(MarkingSection==section?"chosen":"")}") onclick=@(()=>MarkingSection=section)>@section</button>}</div>

         @if(MarkingSection=="Design")

         {

          <div class="markingdesigns">@foreach(var style in MarkingCatalog.Styles){<button class=@($"creatorbutton markingstyle style-{style.Id} {(CurrentMarking.Style==style.Id?"chosen":"")}") onclick=@(()=>ChooseMarkingStyle(style.Id))><span class="material-icons">@MarkingSymbol(style.Id)</span><span>@style.Label</span></button>}</div>

          <div class="fieldlabel markinglocationlabel">Location</div><div class="markinglocations">@foreach(var region in MarkingCatalog.Locations(CurrentMarking.Style)){<button class=@($"creatorbutton markinglocation location-{region.Id} {(CurrentMarking.Region==region.Id?"chosen":"")}") onclick=@(()=>ChooseMarkingRegion(region.Id))>@region.Label</button>}</div>

         }

         else if(MarkingSection=="Placement")

         {

          <div class="optiondescription">@(MarkingCatalog.Style(CurrentMarking.Style).Band?"This band wraps around the selected region. Slide it along the body and adjust its size and barb spacing.":"Move, turn and resize this marking within the selected region.")</div>

          <div class="markingplacement">@foreach(var control in MarkingCatalog.Style(CurrentMarking.Style).Band?new[]{"Vertical","Scale","Spacing"}:new[]{"Horizontal","Vertical","Scale","Rotation"}){<div class="skinchannel"><div class="shapelabel">@(control=="Vertical" && MarkingCatalog.Style(CurrentMarking.Style).Band?"Position along region":control=="Scale"?"Size":control=="Spacing"?"Barb spacing":control)</div><SliderControl class=@($"shapeslider marking-{control.ToLowerInvariant()}") Value=@MarkingValue(control) OnValueChanged=@((float value)=>SetMarkingValue(control,value)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>}</div><button class="creatorbutton quiet resetmarkingplacement" onclick=@ResetMarkingPlacement><span class="material-icons">restart_alt</span>Reset placement</button>

         }

        }

       }

       @if(MakeupLayer!="Tattoo" || CurrentMarking is not null && MarkingSection=="Ink")

       {

        <div class="colorchoices">

         @if(MakeupLayer!="Tattoo"){<button class=@($"creatorbutton colorchoice makeupnone {(!CurrentMakeupActive?"chosen":"")}") onclick=@ChooseNoMakeup><span class="material-icons">block</span><span>None</span></button>}

         @foreach(var color in MakeupCatalog.Colors(MakeupLayer)){<button class=@($"creatorbutton colorchoice makeupcolor makeup-{color.Id} {(CurrentMakeupActive && CurrentMakeup.Color==color.Color?"chosen":"")}") onclick=@(()=>ChooseMakeupColor(color.Color)) [email protected]><div class="colorswatch" style=@($"background-color: {ColorCss(color.Color)};")></div><span>@color.Label</span></button>}

        </div>

        <div class="skinchannel makeupstrength"><div class="shapelabel"><span>@(MakeupLayer=="Eyeliner"?"Upper eyelid strength":"Strength")</span><span class="shapevalue">@(MathF.Round(CurrentMakeup.Amount*100))%</span></div><SliderControl class="shapeslider makeupamount" [email protected] OnValueChanged=@SetMakeupAmount Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
        @if(MakeupLayer=="Eyeliner")
        {
         <div class="optiondescription">Upper and lower liner share one color. Wings follow the upper liner strength; inner-corner liner is optional.</div>
         @foreach(var part in new[]{"Lower eyelid","Inner corner"})
         {
          <div class="skinchannel makeupstrength"><div class="shapelabel"><span>@part strength</span><span class="shapevalue">@(MathF.Round(EyelinerStrength(part)*100))%</span></div><SliderControl class=@($"shapeslider eyeliner-{(part=="Lower eyelid"?"lower":"inner")}") Value=@EyelinerStrength(part) OnValueChanged=@((float value)=>SetEyelinerStrength(part,value)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
         }
         <div class="fieldlabel colorlabel">Wing</div>
         <div class="choicegrid">@foreach(var wing in new[]{"none","wing"}){<button class=@($"creatorbutton eyelinerwing {((wing=="none")== (Appearance.Makeup.EyelinerWing=="none")?"chosen":"")}") onclick=@(()=>ChooseEyelinerWing(wing))>@(wing=="none"?"No wing":"Wing")</button>}</div>
         @if(Appearance.Makeup.EyelinerWing!="none")
         {
          <div class="skinchannel makeupstrength"><div class="shapelabel"><span>Wing strength</span><span class="shapevalue">@(MathF.Round(Appearance.Makeup.EyelinerWingStrength*100))%</span></div><SliderControl class="shapeslider eyeliner-wing" [email protected] OnValueChanged=@((float value)=>SetEyelinerStrength("Wing",value)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
          <div class="skinchannel makeupstrength"><div class="shapelabel"><span>Wing tip horizontal</span><span class="shapevalue">@(MathF.Round(Appearance.Makeup.WingTipHorizontal*100))%</span></div><SliderControl class="shapeslider eyeliner-wing-horizontal" [email protected] OnValueChanged=@((float value)=>SetWingTip(false,value)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
          <div class="skinchannel makeupstrength"><div class="shapelabel"><span>Wing tip vertical</span><span class="shapevalue">@(Appearance.Makeup.WingTipVertical==0?"Level":$"{MathF.Round(MathF.Abs(Appearance.Makeup.WingTipVertical)*100)}% {(Appearance.Makeup.WingTipVertical>0?"up":"down")}")</span></div><SliderControl class="shapeslider eyeliner-wing-vertical" [email protected] OnValueChanged=@((float value)=>SetWingTip(true,value)) Min=@(-1f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
          <div class="skinchannel makeupstrength"><div class="shapelabel"><span>Wing thickness</span><span class="shapevalue">@(MathF.Round(Appearance.Makeup.EyelinerWingThickness*100))%</span></div><SliderControl class="shapeslider eyeliner-wing-thickness" [email protected] OnValueChanged=@SetWingThickness Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
         }
        }

        <button class="creatorbutton quiet makeupcustomtoggle" onclick=@(()=>CustomMakeup=!CustomMakeup)><span class="material-icons">palette</span>@(CustomMakeup?"Hide custom color":"Custom color")</button>

        @if(CustomMakeup){<div class="makeupcustomgrid">@foreach(var channel in new[]{"Red","Green","Blue"}){<div class="skinchannel"><div class="shapelabel">@channel</div><SliderControl class=@($"shapeslider makeup-{channel.ToLowerInvariant()}") Value=@MakeupChannel(channel) OnValueChanged=@((float value)=>SetMakeupChannel(channel,value)) Min=@(0f) Max=@(1f) Step=@(1f/255) ShowTextEntry=@false /></div>}</div>}

       }

       <div class="makeupresets"><button class="creatorbutton quiet clearmakeup" onclick=@ClearMakeup>Clear all makeup &amp; markings</button></div>

      }

      else if(Tab=="Clothing")
      {
       <div class="optionheading">Your wardrobe</div><div class="optiondescription">Click an item to wear it, click again to remove it. Outfits replace tops and bottoms; jackets and accessories can be layered.</div>
       <button class="creatorbutton quiet browsecloud" onclick=@(()=>CloudWardrobeOpen=!CloudWardrobeOpen)>@(CloudWardrobeOpen?"Close cloud browser":"Browse cloud clothing & purchased cosmetics")</button>
       @if(CloudWardrobeOpen){<CloudClothingBrowser Selected=@ChooseCloudClothing Appearance=@Appearance />}
       <TextEntry class="wardrobesearch" Value=@WardrobeSearch OnTextEdited=@((string value)=>{WardrobeSearch=value;}) Placeholder="Search clothing" />
       <div class="shapecategories">@foreach(var category in WardrobeCatalog.Categories){<button class=@($"creatorbutton {(WardrobeCategory==category?"chosen":"")}") onclick=@(()=>WardrobeCategory=category)>@category</button>}</div>
       <div class="fieldlabel">Wearing @WardrobeCatalog.Paths(Appearance).Count() items</div><div class="choicegrid">@foreach(var path in WardrobeCatalog.Paths(Appearance).ToArray()){var worn=ClothingResources.Available.FirstOrDefault(x=>x.Path==path);@if(worn is not null){<button class="creatorbutton chosen" onclick=@(()=>ChooseClothing(worn))>@($"{worn.Name} ×")</button>}}</div>
       <div class="fieldlabel">@($"{WardrobeCategory} · {VisibleWardrobe.Count()} choices")</div><div class="choicegrid thumbnailgrid">@foreach(var item in VisibleWardrobe){<button class=@($"creatorbutton wardrobechoice {((WardrobeCatalog.Paths(Appearance).Contains(item.Path)||Appearance.HairPath==item.Path||Appearance.FacialHairPath==item.Path)?"chosen":"")}") onclick=@(()=>ChooseClothing(item))><div class="thumbnailframe">@if(ClothingThumbnails.For(item.Path) is { Length: > 0 } icon){<div class="itemthumbnail" style=@($"background-image: url('{icon}');")></div>}else{<span class="material-icons thumbnailplaceholder">block</span>}</div><span class="itemlabel">@item.Name</span></button>}</div>
       @if(!VisibleWardrobe.Any()){<div class="optiondescription">No clothing matches your search in this category.</div>}
       <div class="choicegrid"><button class="creatorbutton quiet" onclick=@ClearClothing>Remove clothing</button><button class="creatorbutton quiet" onclick=@ResetClothing>Default outfit</button></div>
      }
      else if(Tab=="Hair")

      {

       <div class="optionheading">Hair</div><div class="choicegrid thumbnailgrid">@foreach(var hair in AvailableHair){<button class=@($"creatorbutton hairchoice {(Appearance.HairPath==hair.ResourcePath?"chosen":"")}") onclick=@(()=>ChooseHair(hair))><div class="thumbnailframe">@if(ClothingThumbnails.For(hair.ResourcePath) is { Length: > 0 } icon){<div class="itemthumbnail" style=@($"background-image: url('{icon}');")></div>}else{<span class="material-icons thumbnailplaceholder">block</span>}</div><span class="itemlabel">@hair.Label</span></button>}</div><div class="fieldlabel colorlabel">Hair color</div><div class="colorchoices">@foreach(var color in AppearanceCatalog.Colors){<button class=@($"creatorbutton colorchoice {(SameColor(color.Color)?"chosen":"")}") onclick=@(()=>ChooseColor(color)) [email protected]><div class="colorswatch" style=@($"background-color: {ColorCss(color.Color)};")></div><span>@color.Label</span></button>}</div><div class="fieldlabel colorlabel">Facial hair</div><div class="choicegrid thumbnailgrid">@foreach(var facial in AvailableFacialHair.Where(x=>x.BodyType==""||x.BodyType==BodyType)){<button class=@($"creatorbutton facialchoice {(Appearance.FacialHairPath==facial.ResourcePath?"chosen":"")}") onclick=@(()=>ChooseFacial(facial))><div class="thumbnailframe">@if(ClothingThumbnails.For(facial.ResourcePath) is { Length: > 0 } icon){<div class="itemthumbnail" style=@($"background-image: url('{icon}');")></div>}else{<span class="material-icons thumbnailplaceholder">block</span>}</div><span class="itemlabel">@facial.Label</span></button>}</div>

      }

      else if(Tab=="Shape")

      {

       <div class="optionheading">Refine your look</div><div class="optiondescription">Neutral is 50%; ear taper and belly start at 0%.</div>

       <div class="shapecategories">@foreach(var category in AppearanceCatalog.Sliders.Select(x=>x.Group).Distinct()){<button class=@($"creatorbutton shapecategory {(ShapeCategory==category?"chosen":"")}") onclick=@(()=>ChangeShapeCategory(category))>@category</button>}</div>

       @if(ShapeCategory=="Body" && Appearance.BaseShape is not null)
       {
        @foreach(var breast in new[]{true,false}){<div class="shaperow"><div class="shapelabel"><span>@(breast?"Breast size":"Bulge size")</span><span>@MathF.Round((breast?1-Appearance.BaseShape.MaleBody:Appearance.BaseShape.MaleBody)*100)% effect</span><button class="creatorbutton quiet" onclick=@(()=>SetBodyFeature(breast,.5f))>Reset</button></div><SliderControl class=@($"shapeslider {(breast?"breast":"bulge")}-size") Value=@(breast?Appearance.BaseShape.BreastSize:Appearance.BaseShape.BulgeSize) OnValueChanged=@((float value)=>SetBodyFeature(breast,value)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>}
       }
       <div class="shapegrid">@foreach(var slider in AppearanceCatalog.Sliders.Where(x=>x.Group==ShapeCategory && (Appearance.BaseShape is null || x.Id!="bodyfeature")))

       {

        <div class=@($"shaperow shape-{slider.Id}")><div class="shapelabel"><span>@slider.LabelFor(Appearance)</span><div class="shapeactions"><span class="shapevalue">@(MathF.Round(slider.Get(Appearance)*100))%</span><button class="creatorbutton quiet resetslider" onclick=@(()=>SetShape(slider,slider.Neutral)) tooltip=@($"Reset {slider.LabelFor(Appearance).ToLowerInvariant()} to {MathF.Round(slider.Neutral*100)}%")><span class="material-icons">restart_alt</span></button></div></div><SliderControl class="shapeslider" [email protected](Appearance) OnValueChanged=@((float value)=>SetShape(slider,value)) [email protected] [email protected] Step=@(0.01f) ShowTextEntry=@false />@if(slider.Id=="columella"){<div class="fitnesshint">Width between the nostrils</div>}@if(slider.Id=="philtrum"){<div class="fitnesshint">Width between nose and upper lip</div>}@if(slider.Id=="eartaper"){<div class="fitnesshint">Normal · Pointed</div>}@if(slider.Id=="fitness"){<div class="fitnesshint">Soft · Neutral · Muscular</div>}@if(slider.Id=="nostrils"){<div class="fitnesshint">Width of the nostril wings</div>}@if(slider.Id=="nostrilfullness"){<div class="fitnesshint">Smaller · Neutral · Fuller wings</div>}@if(slider.Id=="noseheight" || slider.Id=="chinheight"){<div class="fitnesshint">Lower · Neutral · Higher</div>}</div>

       }</div>

       <button class="creatorbutton quiet resetshape" onclick=@ResetShapes>Reset body &amp; face</button>

      }

     </div>

     <div class="creatorutility"><button class="creatorbutton quiet" onclick=@Randomize><span class="material-icons">shuffle</span>Randomize</button><button class="creatorbutton quiet" onclick=@Reset><span class="material-icons">restart_alt</span>Reset appearance</button></div>

    </div>

    <div class="creatorpresentation"><div class="previewheading"><div class="previewname">@(string.IsNullOrWhiteSpace(CharacterName)?"Your character":CharacterName.Trim())</div><div class="previewcaption">@(PreviewPan.Length>0.01f || MathF.Abs(PreviewPitch)>0.01f?"Free inspection":Zoom>CharacterCreatorPreview.FaceZoom+0.001f?"Face detail":Portrait?"Face preview":"Character preview")</div></div><CharacterCreatorPreview @ref=Preview class="mainpreview" Hero=@true Appearance=@Appearance Portrait=@Portrait Zoom=@Zoom Yaw=@Yaw Pitch=@PreviewPitch PitchChanged=@SetPitch PanOffset=@PreviewPan PanChanged=@SetPan Interactive=@(!LoadingCatalog) OrbitChanged=@SetYaw ZoomChanged=@SetZoom ShowClothing=@ShowClothing /><div class="previewcontrols"><div class="previewviewrow"><button class="creatorbutton quiet rotateleft" onclick=@(()=>Rotate(-30)) tooltip="Turn left"><span class="material-icons">rotate_left</span></button><button class=@($"creatorbutton bodyview {(Zoom==0?"chosen":"")}") onclick=@(()=>SetView(0))>Full body</button><button class=@($"creatorbutton previewzoom {(MathF.Abs(Zoom-CharacterCreatorPreview.FaceZoom)<0.001f?"chosen":"")}") onclick=@(()=>SetView(CharacterCreatorPreview.FaceZoom))>Face view</button><button class="creatorbutton quiet rotateright" onclick=@(()=>Rotate(30)) tooltip="Turn right"><span class="material-icons">rotate_right</span></button></div><button class="creatorbutton quiet clothingtoggle" onclick=@ToggleClothing><span class="material-icons">@(ShowClothing?"visibility_off":"visibility")</span>@(ShowClothing?"Hide clothing":"Show clothing")</button></div><div class="previewzoomrow"><span>Zoom</span><SliderControl class="customzoom" Value=@Zoom OnValueChanged=@SetZoom Min=@(0f) [email protected] Step=@(0.005f) ShowTextEntry=@false /></div><div class="previewrecenter"><button class="creatorbutton quiet recenterpreview" onclick=@RecenterPreview><span class="material-icons">center_focus_strong</span>Recenter preview</button></div><div class="previewhint">Left drag: turn · Right: tilt · Middle: pan · Scroll: zoom</div></div>

   </div>

   <div class="creatorfooter"><div class="creatoridentity"><div class="identityfields"><div class="namefield"><div class="fieldlabel">Character name</div><TextEntry class="charactername" Value=@CharacterName OnTextEdited=@SetCharacterName MaxLength=@(40) Placeholder="Your character's name" /></div></div></div><div class="creatorfeedback">@(Feedback??(LoadingCatalog?"Loading character options…":Preview?.Error)??(""))</div><button class="creatorbutton primary startplaythrough" onclick=@Start><span class="material-icons">play_arrow</span>@(SaveLabel)</button></div>

  </div>

 }

</root>

@code {
	public string SaveLabel { get; set; } = "Save character";
	public Func<string, CharacterAppearanceData, string> Saved { get; set; }
	public Action Closed { get; set; }
	public CharacterPreset InitialPreset { get; set; }

	CharacterPreset loadedPreset;
	public CharacterAppearanceData DraftAppearance => Appearance.DeepCopy();
	public string DraftCharacterName => CharacterName;

	public void LoadDraft(string name, CharacterAppearanceData data)
	{
		CharacterName = name;
		Appearance = data.DeepCopy();
		ChosenPreset = null;
		Tab = "Face & body";
	}

	public bool AppearanceReady => CatalogReady && Preview?.Loading != true && CharacterAppearance.ValidateForApply(Appearance)is null;

	IReadOnlyList<string> Tabs => new[]
	{
		"Presets",
		"Skin",
		"Face & body",
		"Photos",
		"Hair",
		"Clothing",
		"Shape",
		"Makeup"
	};

	bool CloudWardrobeOpen;
	string WardrobeSearch = "", WardrobeCategory = "Tops";
	IEnumerable<WardrobeItem> VisibleWardrobe => ClothingResources.Available.Where(x => x.Category == WardrobeCategory && (string.IsNullOrWhiteSpace(WardrobeSearch) || x.Name.Contains(WardrobeSearch, StringComparison.OrdinalIgnoreCase))).OrderBy(x => x.Name);

	void ChooseCloudClothing(WardrobeItem item)
	{
		WardrobeCategory = item.Category;
		WardrobeSearch = "";
		ChooseClothing(item);
		Appearance.Version = 2;
		ChosenPreset = null;
		Feedback = null;
		StateHasChanged();
	}

	void ChooseClothing(WardrobeItem item)
	{
		var clothing = ClothingResources.Get(item.Path);
		if (clothing is not null && ClothingResources.IsHair(clothing))
			Appearance.HairPath = Appearance.HairPath == item.Path || ClothingResources.Get(Appearance.HairPath) == clothing ? "" : item.Path;
		else if (clothing is not null && ClothingResources.IsFacialHair(clothing))
			Appearance.FacialHairPath = Appearance.FacialHairPath == item.Path || ClothingResources.Get(Appearance.FacialHairPath) == clothing ? "" : item.Path;
		else
			WardrobeCatalog.Toggle(Appearance, item);
		Appearance.Version = 2;
		ShowClothing = true;
		ChosenPreset = null;
		Feedback = null;
	}

	void ClearClothing()
	{
		Appearance.ClothingPaths = Array.Empty<string>();
		Appearance.Version = 2;
		ShowClothing = true;
		ChosenPreset = null;
	}

	void ResetClothing()
	{
		Appearance.ClothingPaths = WardrobeCatalog.DefaultPaths.ToArray();
		Appearance.Version = 2;
		ShowClothing = true;
		ChosenPreset = null;
	}

	CharacterAppearanceData Appearance = AppearanceCatalog.CreateDefault();
	string CharacterName = "Rowan", PlaythroughName = "A new adventure", Tab = "Presets", ShapeCategory = "Body", ChosenPreset = "traveller", Feedback;
	string BlendRegion = "overall";
	bool CatalogReady, LoadingCatalog;
	string SkinPalette = "Human";
	string MakeupLayer = "Lipstick";
	bool CustomMakeup;
	int SelectedMarking;
	string MarkingSection = "Design";
	CharacterMarkingData CurrentMarking => Appearance.Makeup.Markings.Count == 0 ? null : Appearance.Makeup.Markings[Math.Clamp(SelectedMarking, 0, Appearance.Makeup.Markings.Count - 1)];

	MakeupLayerData CurrentMakeup => MakeupLayer == "Tattoo" ? CurrentMarking?.Ink ?? Appearance.Makeup.Tattoo : MakeupCatalog.Layer(Appearance.Makeup, MakeupLayer);

	void ChooseMakeupLayer(string layer)
	{
		if (LoadingCatalog)
			return;
		MakeupLayer = layer;
		if (layer == "Tattoo")
			Appearance.Makeup.EditMarkings();
	}

	void SelectMarking(int index)
	{
		if (LoadingCatalog || index < 0 || index >= Appearance.Makeup.Markings.Count)
			return;
		SelectedMarking = index;
	}

	void AddMarking()
	{
		if (LoadingCatalog || Appearance.Makeup.Markings.Count >= MarkingCatalog.Maximum)
		{
			Feedback = "You can combine up to six marking layers.";
			return;
		}

		var marking = new CharacterMarkingData();
		MarkingCatalog.ResetPosition(marking);
		Appearance.Makeup.Markings.Add(marking);
		SelectedMarking = Appearance.Makeup.Markings.Count - 1;
		MarkingSection = "Design";
		ChangedCosmetics();
	}

	void DeleteMarking()
	{
		if (LoadingCatalog || CurrentMarking is null)
			return;
		Appearance.Makeup.Markings.Remove(CurrentMarking);
		SelectedMarking = Math.Clamp(SelectedMarking, 0, Math.Max(0, Appearance.Makeup.Markings.Count - 1));
		ChangedCosmetics();
	}

	string MarkingSymbol(string style) => style switch
	{
		"crescent" => "brightness_2",
		"teardrop" => "water_drop",
		"star" => "star_outline",
		"rune" => "gesture",
		"barbed-band" => "link",
		_ => "block"
	};
	void ChooseMarkingStyle(string style)
	{
		if (LoadingCatalog || CurrentMarking is null)
			return;
		MarkingCatalog.ChangeStyle(CurrentMarking, style);
		ChangedCosmetics();
	}

	void ChooseMarkingRegion(string region)
	{
		if (LoadingCatalog || CurrentMarking is null)
			return;
		MarkingCatalog.ChangeRegion(CurrentMarking, region);
		ChangedCosmetics();
	}

	float MarkingValue(string control) => control switch
	{
		"Horizontal" => CurrentMarking.Horizontal,
		"Vertical" => CurrentMarking.Vertical,
		"Scale" => CurrentMarking.Scale,
		"Spacing" => CurrentMarking.Spacing,
		_ => CurrentMarking.Rotation
	};
	void SetMarkingValue(string control, float value)
	{
		if (LoadingCatalog || CurrentMarking is null || !float.IsFinite(value))
			return;
		value = Math.Clamp(value, 0, 1);
		if (MarkingCatalog.Style(CurrentMarking.Style).Band && control is not "Vertical" and not "Scale" and not "Spacing")
			return;
		if (control == "Horizontal")
			CurrentMarking.Horizontal = value;
		else if (control == "Vertical")
			CurrentMarking.Vertical = value;
		else if (control == "Scale")
			CurrentMarking.Scale = value;
		else if (control == "Spacing")
			CurrentMarking.Spacing = value;
		else if (control == "Rotation")
			CurrentMarking.Rotation = value;
		ChangedCosmetics();
	}

	void ResetMarkingPlacement()
	{
		if (LoadingCatalog || CurrentMarking is null)
			return;
		MarkingCatalog.ResetPosition(CurrentMarking);
		CurrentMarking.Scale = .5f;
		CurrentMarking.Rotation = .5f;
		CurrentMarking.Spacing = .5f;
		ChangedCosmetics();
	}

	void InspectMarking()
	{
		if (LoadingCatalog || CurrentMarking is null)
			return;
		var region = MarkingCatalog.Region(CurrentMarking.Region);
		var center = MarkingCatalog.Focus(CurrentMarking);
		var outward = MarkingCatalog.Outward(CurrentMarking);
		Yaw = -MathF.Atan2(outward.y, outward.x) * 180 / MathF.PI;
		SetZoom(region.Head ? 1.55f : 1.15f);
		var rotated = Rotation.FromYaw(Yaw) * center;
		var targetHeight = 62 + 1.5f * MathF.Max(0, Zoom - 1);
		PreviewPan = new(rotated.x, rotated.y, rotated.z - targetHeight);
		PreviewPitch = 0;
	}

	void ChangedCosmetics()
	{
		ChosenPreset = null;
		Feedback = null;
	}

	void ChooseNoMakeup()
	{
		if (LoadingCatalog)
			return;
		CurrentMakeup.Amount = 0;
		if (MakeupLayer == "Eyeliner") MakeupCatalog.Reset(Appearance.Makeup, "Eyeliner");
		ChangedCosmetics();
	}

	void ChooseMakeupColor(Color color)
	{
		if (LoadingCatalog)
			return;
		CurrentMakeup.Color = color;
		if (!CurrentMakeupActive)
			CurrentMakeup.Amount = MakeupLayer == "Eyeliner" ? 1f : .65f;
		ChangedCosmetics();
	}

	void SetMakeupAmount(float value)
	{
		if (LoadingCatalog || !float.IsFinite(value))
			return;
		CurrentMakeup.Amount = Math.Clamp(value, 0, 1);
		ChangedCosmetics();
	}

	bool CurrentMakeupActive => MakeupLayer == "Eyeliner" ? Appearance.Makeup.EyelinerActive : CurrentMakeup.Amount > 0;

	float EyelinerStrength(string part) => part == "Lower eyelid" ? Appearance.Makeup.EyelinerLowerStrength : part == "Wing" ? Appearance.Makeup.EyelinerWingStrength : Appearance.Makeup.EyelinerInnerStrength;
	void SetEyelinerStrength(string part, float value)
	{
		if (LoadingCatalog || !float.IsFinite(value)) return;
		value = Math.Clamp(value, 0, 1);
		if (part == "Lower eyelid") Appearance.Makeup.EyelinerLowerStrength = value;
		else if (part == "Wing") Appearance.Makeup.EyelinerWingStrength = value;
		else Appearance.Makeup.EyelinerInnerStrength = value;
		ChangedCosmetics();
	}

	void PrepareAdjustableWing()
	{
		var makeup = Appearance.Makeup;
		makeup.EyelinerWingHorizontal = makeup.WingTipHorizontal;
		makeup.EyelinerWingVertical = makeup.WingTipVertical;
		makeup.EyelinerWing = "wing";
	}

	void SetWingTip(bool vertical, float value)
	{
		if (LoadingCatalog || !float.IsFinite(value)) return;
		PrepareAdjustableWing();
		if (vertical) Appearance.Makeup.EyelinerWingVertical = Math.Clamp(value, -1, 1);
		else Appearance.Makeup.EyelinerWingHorizontal = Math.Clamp(value, 0, 1);
		ChangedCosmetics();
	}

	void SetWingThickness(float value)
	{
		if (LoadingCatalog || !float.IsFinite(value)) return;
		PrepareAdjustableWing();
		Appearance.Makeup.EyelinerWingThickness = Math.Clamp(value, 0, 1);
		ChangedCosmetics();
	}

	void ChooseEyelinerWing(string wing)
	{
		if (LoadingCatalog) return;
		if (wing == "wing") PrepareAdjustableWing();
		else Appearance.Makeup.EyelinerWing = "none";
		if (wing != "none" && CurrentMakeup.Amount == 0) CurrentMakeup.Amount = 1;
		ChangedCosmetics();
	}

	float MakeupChannel(string channel) => channel == "Red" ? CurrentMakeup.Red : channel == "Green" ? CurrentMakeup.Green : CurrentMakeup.Blue;
	void SetMakeupChannel(string channel, float value)
	{
		if (LoadingCatalog || !float.IsFinite(value))
			return;
		value = Math.Clamp(value, 0, 1);
		if (channel == "Red")
			CurrentMakeup.Red = value;
		else if (channel == "Green")
			CurrentMakeup.Green = value;
		else
			CurrentMakeup.Blue = value;
		ChangedCosmetics();
	}

	void ClearMakeup()
	{
		if (LoadingCatalog)
			return;
		Appearance.Makeup = new();
		SelectedMarking = 0;
		ChangedCosmetics();
	}

	bool Portrait = true, ShowClothing = true;
	float Yaw, Zoom = CharacterCreatorPreview.FaceZoom;
	CharacterCreatorPreview Preview;
	Vector3 PreviewPan;
	float PreviewPitch;
	string BodyType => AppearanceCatalog.IsFemale(Appearance) ? "female" : "male";

	IEnumerable<AppearanceOption> AvailableHair => AppearanceCatalog.Hair.Concat(ClothingResources.Available.Where(x=>x.Category=="Hair").Select(x=>new AppearanceOption(x.Path,x.Name,x.Path))).DistinctBy(x=>x.ResourcePath);
 IEnumerable<AppearanceOption> AvailableFacialHair => AppearanceCatalog.FacialHair.Concat(ClothingResources.Available.Where(x=>x.Category=="Facial hair").Select(x=>new AppearanceOption(x.Path,x.Name,x.Path))).DistinctBy(x=>x.ResourcePath);
 string HairLabel => AppearanceCatalog.Hair.FirstOrDefault(x => x.ResourcePath == Appearance.HairPath)?.Label ?? "Hair";

	string FaceLabel(AppearanceOption option) => option.Label.Replace(" · male", "").Replace(" · female", "");
	string ColorCss(Color color) => $"rgb({(int)(color.r * 255)},{(int)(color.g * 255)},{(int)(color.b * 255)})";
	void SetCharacterName(string value)
	{
		CharacterName = value;
		Feedback = null;
	}

	void SetPlaythroughName(string value)
	{
		PlaythroughName = value;
		Feedback = null;
	}

	bool SameColor(Color color) => MathF.Abs(Appearance.HairColorR - color.r) < 0.001f && MathF.Abs(Appearance.HairColorG - color.g) < 0.001f && MathF.Abs(Appearance.HairColorB - color.b) < 0.001f;
	void ChangeTab(string tab)
	{
		if (tab != Tab && Tab == "Voice")
			StopVoicePreview();
		Tab = tab;
		if (tab == "Clothing")
			SetZoom(0);
		Feedback = null;
	}

	void StopVoicePreview()
	{
	}

	void ChangeShapeCategory(string category)
	{
		ShapeCategory = category;
		Feedback = null;
	}

	void ApplyPreset(string id)
	{
		if (LoadingCatalog)
			return;
		var voice = Appearance.VoiceId;
		bool unified = Appearance.BaseShape is not null;
		Appearance = AppearanceCatalog.ApplyPreset(id);
		if (unified)
			UnifiedHuman.Enable(Appearance);
		Appearance.VoiceId = voice;
		ChosenPreset = id;
		Feedback = null;
	}

	void EnableBlending()
	{
		UnifiedHuman.Enable(Appearance);
		ChosenPreset = null;
		Feedback = null;
	}

	void AddPhotoFace(PhotoFace face)
	{
		if (!face.Valid)
			return;
		UnifiedHuman.Enable(Appearance);
		if (Appearance.BaseShape.PhotoFaces.Count >= 4)
			return;
		Array.Fill(Appearance.BaseShape.HeadWeights, 0f);
		foreach (var old in Appearance.BaseShape.PhotoFaces)
			old.Influence = 0;
		Appearance.BaseShape.PhotoFaces.Add(face);
		ChosenPreset = null;
		Tab = "Face & body";
		BlendRegion = "overall";
		SetView(CharacterCreatorPreview.FaceZoom);
		Feedback = "Custom base added. Blend it here or refine it in Shape, then save your character.";
	}

	FaceRegionBlend Region(bool create = false)
	{
		if (BlendRegion == "overall")
			return null;
		if (!Appearance.BaseShape.Regions.TryGetValue(BlendRegion, out var value) && create)
		{
			value = new()
			{
				OverallInfluence = .5f
			};
			Appearance.BaseShape.Regions.Add(BlendRegion, value);
		}

		return value;
	}

	float OverallWeight => Region()?.OverallInfluence ?? .5f;

	void SetOverallWeight(float value)
	{
		if (!float.IsFinite(value) || BlendRegion == "overall")
			return;
		Region(true).OverallInfluence = Math.Clamp(value, 0, 1);
		ChosenPreset = null;
	}

	float HeadWeight(int index) => BlendRegion == "overall" ? Appearance.BaseShape.HeadWeights[index] : Region()?.HeadWeights[index] ?? 0;
	float PhotoWeight(PhotoFace face) => BlendRegion == "overall" ? face.Influence : Region()?.PhotoWeights.GetValueOrDefault(face.Id) ?? 0;
	string BlendWeightLabel(int index)
	{
		float share = FaceRegions.NativeShare(Appearance.BaseShape, index, BlendRegion);
		return $"{MathF.Round(HeadWeight(index) * 100)} influence · {MathF.Round(share * 100)}% of blend";
	}

	void SetHeadWeight(int index, float value)
	{
		if (Appearance.BaseShape is null || !float.IsFinite(value))
			return;
		(BlendRegion == "overall" ? Appearance.BaseShape.HeadWeights : Region(true).HeadWeights)[index] = Math.Clamp(value, 0, 1);
		ChosenPreset = null;
	}

	void SetPhotoWeight(PhotoFace face, float value)
	{
		if (!float.IsFinite(value))
			return;
		if (BlendRegion == "overall")
			face.Influence = Math.Clamp(value, 0, 1);
		else
			Region(true).PhotoWeights[face.Id] = Math.Clamp(value, 0, 1);
		ChosenPreset = null;
	}

	void ResetRegion()
	{
		if (BlendRegion != "overall")
			Appearance.BaseShape.Regions.Remove(BlendRegion);
		ChosenPreset = null;
	}

	void SetBodyBlend(float value)
	{
		if (Appearance.BaseShape is null || !float.IsFinite(value))
			return;
		Appearance.BaseShape.MaleBody = Math.Clamp(value, 0, 1);
		ChosenPreset = null;
	}

	void ToggleClothingVariant(string variant)
	{
		if (Appearance.BaseShape is null)
			return;
		Appearance.BaseShape.ClothingVariant = Appearance.BaseShape.ClothingVariant == variant ? null : variant;
		ChosenPreset = null;
	}

	void SetBodyFeature(bool breast, float value)
	{
		if (Appearance.BaseShape is null || !float.IsFinite(value))
			return;
		if (breast)
			Appearance.BaseShape.BreastSize = Math.Clamp(value, 0, 1);
		else
			Appearance.BaseShape.BulgeSize = Math.Clamp(value, 0, 1);
		ChosenPreset = null;
	}

	void ChooseBody(string body)
	{
		var skin = AppearanceCatalog.Skins.FirstOrDefault(x => x.BodyType == body);
		if (skin is not null)
			ChooseSkin(skin);
	}

	void ChooseSkin(AppearanceOption skin)
	{
		if (LoadingCatalog)
			return;
		Appearance.SkinPath = skin.ResourcePath;
		if (Appearance.BaseShape is null && !AppearanceCatalog.FacialHair.Any(x => x.ResourcePath == Appearance.FacialHairPath && (x.BodyType == "" || x.BodyType == skin.BodyType)))
			Appearance.FacialHairPath = "";
		ChosenPreset = null;
		Feedback = null;
	}

	void ChooseHair(AppearanceOption hair)
	{
		if (LoadingCatalog)
			return;
		Appearance.HairPath = hair.ResourcePath;
		ChosenPreset = null;
		Feedback = null;
	}

	void ChooseFacial(AppearanceOption facial)
	{
		if (LoadingCatalog)
			return;
		Appearance.FacialHairPath = facial.ResourcePath;
		ChosenPreset = null;
		Feedback = null;
	}

	void ChooseColor(AppearanceColorOption color)
	{
		if (LoadingCatalog)
			return;
		Appearance.HairColor = color.Color;
		ChosenPreset = null;
		Feedback = null;
	}

	void ChooseSkinColor(Color color, bool normalize = false)
	{
		if (LoadingCatalog)
			return;
		Appearance.SkinColor = color;
		Appearance.NormalizeSkinTone = normalize;
		ChosenPreset = null;
		Feedback = null;
	}

	static IReadOnlyList<AppearanceColorOption> EyeColors => new[]
	{
		new AppearanceColorOption("brown", "Brown", new(.24f, .12f, .055f)),
		new("hazel", "Hazel", new(.34f, .3f, .11f)),
		new("amber", "Amber", new(.58f, .34f, .075f)),
		new("green", "Green", new(.13f, .34f, .19f)),
		new("blue", "Blue", new(.09f, .3f, .55f)),
		new("grey", "Grey", new(.32f, .36f, .4f)),
		new("violet", "Violet", new(.32f, .16f, .46f))
	};

	void ChooseEyeColor(Color? color)
	{
		if (LoadingCatalog || LoadingCatalog)
			return;
		Appearance.CustomEyeColor = color.HasValue;
		if (color.HasValue)
			Appearance.EyeColor = color.Value;
	}

	float EyeChannel(string channel) => channel == "Red" ? Appearance.EyeColorR : channel == "Green" ? Appearance.EyeColorG : Appearance.EyeColorB;
	void SetEyeChannel(string channel, float value)
	{
		if (LoadingCatalog || LoadingCatalog)
			return;
		Appearance.CustomEyeColor = true;
		value = Math.Clamp(value, 0, 1);
		if (channel == "Red")
			Appearance.EyeColorR = value;
		else if (channel == "Green")
			Appearance.EyeColorG = value;
		else
			Appearance.EyeColorB = value;
	}

	float SkinChannel(string channel) => channel == "Red" ? Appearance.SkinColorR : channel == "Green" ? Appearance.SkinColorG : Appearance.SkinColorB;
	void SetSkinChannel(string channel, float value)
	{
		if (LoadingCatalog || !float.IsFinite(value))
			return;
		value = Math.Clamp(value, 0, 1);
		Appearance.NormalizeSkinTone = true;
		if (channel == "Red")
			Appearance.SkinColorR = value;
		else if (channel == "Green")
			Appearance.SkinColorG = value;
		else
			Appearance.SkinColorB = value;
		ChosenPreset = null;
		Feedback = null;
	}

	void SetShape(AppearanceSlider slider, float value)
	{
		if (LoadingCatalog)
			return;
		slider.Set(Appearance, value);
		ChosenPreset = null;
		Feedback = null;
	}

	void ResetShapes()
	{
		if (LoadingCatalog)
			return;
		AppearanceCatalog.ResetShapes(Appearance);
		ChosenPreset = null;
		Feedback = null;
	}

	void Randomize()
	{
		if (LoadingCatalog)
			return;
		var voice = Appearance.VoiceId;
		var variant = Appearance.BaseShape?.ClothingVariant;
		var photos = Appearance.BaseShape?.PhotoFaces.Select(x => x.DeepCopy()).ToList();
		Appearance = AppearanceCatalog.Randomize(blendHeads: Appearance.BaseShape is not null);
		Appearance.VoiceId = voice;
		if (Appearance.BaseShape is not null)
		{
			Appearance.BaseShape.ClothingVariant = variant;
			if (photos is not null)
			{
				foreach (var face in photos)
					face.Influence = 0;
				Appearance.BaseShape.PhotoFaces = photos;
			}
		}

		ChosenPreset = null;
		Feedback = null;
	}

	void Reset()
	{
		if (LoadingCatalog)
			return;
		var voice = Appearance.VoiceId;
		bool unified = Appearance.BaseShape is not null;
		Appearance = AppearanceCatalog.CreateDefault();
		if (unified)
			UnifiedHuman.Enable(Appearance);
		Appearance.VoiceId = voice;
		ChosenPreset = "traveller";
		Yaw = 0;
		RecenterPreview();
		SetZoom(CharacterCreatorPreview.FaceZoom);
		Feedback = null;
	}

	void Rotate(float delta)
	{
		Yaw = (Yaw + delta) % 360;
	}

	void SetYaw(float value)
	{
		Yaw = value;
	}

	void SetView(float value)
	{
		RecenterPreview();
		SetZoom(value);
	}

	void SetZoom(float value)
	{
		if (float.IsFinite(value))
		{
			Zoom = Math.Clamp(value, 0, CharacterCreatorPreview.MaxZoom);
			Portrait = Zoom >= CharacterCreatorPreview.FaceZoom - 0.001f;
		}
	}

	void SetPitch(float value)
	{
		PreviewPitch = value;
	}

	void SetPan(Vector3 value)
	{
		PreviewPan = value;
	}

	void RecenterPreview()
	{
		Yaw = 0;
		PreviewPan = Vector3.Zero;
		PreviewPitch = 0;
		Preview?.Recenter();
	}

	void ToggleClothing()
	{
		ShowClothing = !ShowClothing;
	}

	void Back()
	{
		if (!LoadingCatalog)
		{
			StopVoicePreview();
			Closed?.Invoke();
		}
	}

	void Start()
	{
		if (LoadingCatalog || Preview?.Loading == true)
			return;
		if (!CatalogReady)
		{
			Feedback = LoadingCatalog ? "Loading character options…" : "Character options could not be loaded.";
			return;
		}

		if (CharacterAppearance.ValidateForApply(Appearance)is string error)
		{
			Feedback = error;
			return;
		}

		StopVoicePreview();
		Feedback = Saved?.Invoke(CharacterName, Appearance.DeepCopy());
	}

	public override void Delete(bool immediate = false)
	{
		StopVoicePreview();
		base.Delete(immediate);
	}

	protected override async Task OnParametersSetAsync()
	{
		if (InitialPreset is not null && !ReferenceEquals(loadedPreset, InitialPreset))
		{
			LoadDraft(InitialPreset.Name, InitialPreset.Appearance);
			loadedPreset = InitialPreset;
		}

		if (CatalogReady || LoadingCatalog)
			return;
		LoadingCatalog = true;
		try
		{
			await AppearanceCatalog.EnsureLoadedAsync();
			CatalogReady = true;
		}
		catch (Exception)
		{
			Feedback = "Character options could not be loaded. Please try again.";
		}
		finally
		{
			LoadingCatalog = false;
		}
	}

	protected override int BuildHash() => HashCode.Combine(CharacterName, PlaythroughName, HashCode.Combine(Tab, ShapeCategory, SkinPalette, MakeupLayer, CustomMakeup, SelectedMarking, MarkingSection, BlendRegion), ChosenPreset, JsonSerializer.Serialize(Appearance) + Appearance.Makeup.PresentationKey(), HashCode.Combine(Portrait, Yaw, Feedback, LoadingCatalog, SaveLabel, Preview?.Error, CatalogReady, LoadingCatalog), Zoom, HashCode.Combine(ShowClothing, PreviewPan, PreviewPitch, WardrobeSearch, WardrobeCategory, ClothingResources.Revision, CloudWardrobeOpen));
}